A legkisebb Pi

7-szegmensű kijelző vezérlése Raspberry Pi Pico segítségével

Lassan két éve annak, hogy megjelent a Raspberry Pi Pico. Azóta már kijött a W jelzésű, Wi-Fi képes model is. Természetesen meg is rendeltem mindkettőt, de elég lassan jutottam el odáig, hogy valamit csináljak is velük. De ez a nap is eljött végre.

Egy négy karakteres 7-szegmensű kijelzőt fogunk vele meghajtani, méghozzá a PIO (programmable IO) segítségével, amik kis állapotgépek a Pico-n belül és assembly nyelven lehet rájuk programokat írni.

Előkészületek

A Raspberry Pi oldalán egész jó leírások vannak, hogy hogyan lehet fejlesztői környezeteket összerakni, úgyhogy erről most nem ejtenék túl sok szót.

Alapból nem túl fejlesztő barát a folyamat: lebuild-eled a programod, kihúzod az USB kábelt a Pico-ból, nyomva tartod a Pico-n lévő gombot, visszadugod az USB kábelt, a megjelenő háttértárra áthúzod a lebuild-elt U2F fájlt és már kész is vagyunk. Hogy is mondjam, egy kicsit megöli a hangulatot. Szerencsére vannak alternatív megoldások.

Én végül a Picoprobe + CLion irányba mentem, így az IDE-n belül egy gombnyomással lehet az új kódot a Pico-ra küldeni. Először Windows-on kezdtem el beállítani a dolgokat, de feladtam valahol a "build-eljünk OpenOCD-t MSYS2-vel" résznél és átnyargaltam Linuxra. Majd egyszer lehet újra nekifutok még WSL2-vel, ha valami kihívásra vágyok. Az összelövéshez a hivatalos dokumentáción kívül még ez a tweet is sokat segített.

A kijelző

Teljes nevén az SH5463AW-14

A kijelzőn 14 csatlakozási pont található, ezzel kellene valahogy működésre bírnunk azt a 33 szegmenst (a középső : egy szegmensnek számít), ami a karaktereket és a hozzájuk tartozó extra pontokat alkotják. Érezhető, hogy van itt valami huncutság, ami pedig nem más, mint hogy egyszerre mindig csak egy karaktert tudunk megjeleníteni a kijelzőn, de ha elég gyorsan váltogatjuk az éppen megjelenített karaktert, akkor a béna emberi szem számára úgy fog tűnni, mintha mind a négy világítana.

Ahhoz, hogy a megfelelő szegmens világítson, az alsó sorban szereplő pin-t 1-esre, a felső sorban szereplő COM pin-t pedig 0-ra kell állítanunk. Ha több helyen is ugyanazt a karaktert kell megjeleníteni, akkor több COM pin-t is 0-ra állíthatunk, de lehet nem éri meg ezzel vesződni a valóságban.

Nézzünk is meg egy - nem annyira - rövid példát. Mondjuk szeretnénk azt megjeleníteni, hogy 12:34:

  1. a 9-es és 4-es pin-eket 1-esre állítjuk, a 14-est 0-ra
  2. várunk egy picit
  3. a 13, 9, 2, 1, 5 pin-eket 1-esre állítjuk, a 11-est 0-ra
  4. várunk egy picit
  5. a 8-as pin-t 1-esre állítjuk, a 7-est 0-ra
  6. várunk egy picit
  7. a 13, 9, 4, 2, 5 pin-eket 1-esre állítjuk, a 10-est 0-ra
  8. várunk egy picit
  9. a 9, 4, 12, 5 pin-eket 1-esre állítjuk, a 6-ost 0-ra
  10. várunk egy picit
  11. ugrás az első pontra

Ha nem ugranánk a végén rögtön vissza az elejére és csinálnánk ezt az idők végezetéig, akkor egy pillanatra látnánk csak felvillanni az értéket a kijelzőn.

Első próbálkozás

Legelőször tulajdonképpen a fent leírt folyamatot szerettem volna C kóddá változtatni. Ezzel letesztelem, hogy tényleg jól értelmeztem-e a kijelző adatlapját, a Pico SDK dokumentációját és jól is kötöttem össze a komponenseket egymással. A PIO ezen a ponton még csak felesleges bonyolítás lenne. A teljes kód megtekinthető Github-on.

Kezdjük egy kis konfigurációval:

c_only.c
const uint pin_map_display_to_pico[] = {
  0,
  16, 17, 18, 19, 20, 21, 22,
  9, 10, 11, 12, 13, 14, 15,
};

const uint A  = pin_map_display_to_pico[13];
const uint B  = pin_map_display_to_pico[9];
const uint C  = pin_map_display_to_pico[4];
const uint D  = pin_map_display_to_pico[2];
const uint E  = pin_map_display_to_pico[1];
const uint F  = pin_map_display_to_pico[12];
const uint G  = pin_map_display_to_pico[5];
const uint DP = pin_map_display_to_pico[3];
const uint D5 = pin_map_display_to_pico[8];

const uint COM_1    = pin_map_display_to_pico[14];
const uint COM_2    = pin_map_display_to_pico[11];
const uint COM_3    = pin_map_display_to_pico[10];
const uint COM_4    = pin_map_display_to_pico[6];
const uint COM_DOTS = pin_map_display_to_pico[7];

A D6-ot lehagytam, mert ugyanaz, mint a D5. A pin_map_display_to_pico tartalmazza, hogy melyik kijelző pin melyik Pico pin-nek felel meg (a nulla nincs értelmezve). A Pico-n a 9-22 pin-eket használtam.

c_only.c
stdio_init_all();
for (int i = 1; i < sizeof(pin_map) / sizeof(pin_map[0]); ++i) {
    gpio_init(pin_map[i]);
    gpio_set_dir(pin_map[i], GPIO_OUT);
}

Még egy kis inicializálás a lényeg előtt. Minden használt pin-t kimeneti módba állítunk. Ezek után nincs más hátra, mint egy jó hosszú végtelen ciklus, ami majdnem azt csinálja, amit fentebb már átvettünk:

c_only.c
// a kettőspont kiválasztása és megjelenítése
gpio_put(COM_DOTS, 0);
gpio_put(D5, 1);

while (true) {
  // az első karakter hely kiválasztása
  gpio_put(COM_1, 0);
  gpio_put(COM_2, 1);
  gpio_put(COM_3, 1);
  gpio_put(COM_4, 1);

  // az 1-es megjelenítése
  gpio_put(A, 0);
  gpio_put(B, 1);
  gpio_put(C, 1);
  gpio_put(D, 0);
  gpio_put(E, 0);
  gpio_put(F, 0);
  gpio_put(G, 0);
  gpio_put(DP, 0);

  // várunk egy picit
  sleep_ms(2);

  // a második karakter hely kiválasztása
  gpio_put(COM_1, 1);
  gpio_put(COM_2, 0);
  gpio_put(COM_3, 1);
  gpio_put(COM_4, 1);

  // a 2-es megjelenítése
  gpio_put(A, 1);
  gpio_put(B, 1);
  gpio_put(C, 0);
  gpio_put(D, 1);
  gpio_put(E, 1);
  gpio_put(F, 0);
  gpio_put(G, 1);
  gpio_put(DP, 0);

  // várunk egy picit
  sleep_ms(2);

  // a harmadik karakter hely kiválasztása
  gpio_put(COM_1, 1);
  gpio_put(COM_2, 1);
  gpio_put(COM_3, 0);
  gpio_put(COM_4, 1);

  // a 3-as megjelenítése
  gpio_put(A, 1);
  gpio_put(B, 1);
  gpio_put(C, 1);
  gpio_put(D, 1);
  gpio_put(E, 0);
  gpio_put(F, 0);
  gpio_put(G, 1);
  gpio_put(DP, 0);

  // várunk egy picit
  sleep_ms(2);

  // a negyedik karakter hely kiválasztása
  gpio_put(COM_1, 1);
  gpio_put(COM_2, 1);
  gpio_put(COM_3, 1);
  gpio_put(COM_4, 0);

  // a 4-es megjelenítése
  gpio_put(A, 0);
  gpio_put(B, 1);
  gpio_put(C, 1);
  gpio_put(D, 0);
  gpio_put(E, 0);
  gpio_put(F, 1);
  gpio_put(G, 1);
  gpio_put(DP, 0);

  // várunk egy picit
  sleep_ms(2);
}

A különbség annyi, hogy mivel a kettőspont nem függ semelyik másik karaktertől (nem osztozik velük egyetlen pin-ben sem), így külön bekapcsolhatjuk a legelején és utána már nem kell vele foglalkozni. Érdemes megfigyelni, hogy ilyenkor a : egy kicsit erősebben világít, mint a számok.

Egy kis PIO

Elsőre itt is valami viszonylag egyszerűvel kezdünk, csak hogy lássuk, minden megfelelően működik. A teljes kód szintén fent van Github-on.

basic_pio.pio
.program basic_pio

.define PUBLIC pin_count 14

loop:
  pull
  out pins, pin_count
  jmp loop

A pull behúzza a C kódból küldött 32 bit adatot (és blokkolja a futást addig, amíg nem érkezett adat), az out pedig ebből 14 bitet kiír az általunk meghatározott pin-ekre (a maradék felülíródik a következő pull-nál), majd kezdődik az egész elölről. A publikusan definiált pin_count-ot a C kódból is elérhetjük majd basic_pio_pin_count néven.

Hogy hol határoztuk meg ezeket a pin-eket? A pio fájlnak van még egy kis C-ben írt része is, ami az egész programot beállítja (nem mondom, hogy szimpatikus ez a nyelv vegyítés fájlon belül és a CLion-nak sem tetszik különösebben, de hát ez van):

basic_pio.pio
% c-sdk {
static inline void basic_pio_program_init(PIO pio, uint sm, uint offset, uint pin) {
  pio_sm_config config = basic_pio_program_get_default_config(offset);

  sm_config_set_out_pins(&config, pin, basic_pio_pin_count);

  for (uint i = 0; i < basic_pio_pin_count; ++i) {
    pio_gpio_init(pio, pin + i);
  }
  pio_sm_set_consecutive_pindirs(pio, sm, pin, basic_pio_pin_count, true);

  pio_sm_init(pio, sm, offset, &config);
  pio_sm_set_enabled(pio, sm, true);
}
%}

Következhet a C kód, amiből használni fogjuk ezt a PIO programot. A programnak 32 bit adatot fogunk küldeni, amiből valójában csak 14 bit lesz hasznos, ez a 14 bit határozza meg a 14 pin állapotát. Jobbról az első bit a 9-es pin-nek felel meg, az utolsó pedig a 22-es pin-nek.

//                             pin 9
//                                 v
uint example_data = 0b00010000000010;
//                    ^
//                    pin 22

Definiálhatunk néhány segéd konstanst, hogy könnyebb dolgunk legyen a számok megadásánál. A COM-ok megadása kicsit furcsa, mivel az összes többit kell egyesre állítani, nem azt, amelyiken megjeleníteni szeretnénk.

basic_pio.c
#define START_PIN 9

#define A  1 << (14 - START_PIN)
#define B  1 << (10 - START_PIN)
#define C  1 << (19 - START_PIN)
#define D  1 << (17 - START_PIN)
#define E  1 << (16 - START_PIN)
#define F  1 << (13 - START_PIN)
#define G  1 << (20 - START_PIN)
#define DP 1 << (18 - START_PIN)
#define D5 1 << (9 - START_PIN)

#define COM_1    1 << (15 - START_PIN)
#define COM_2    1 << (12 - START_PIN)
#define COM_3    1 << (11 - START_PIN)
#define COM_4    1 << (21 - START_PIN)
#define COM_DOTS 1 << (22 - START_PIN)

const uint one   = B|C|D5;
const uint two   = A|B|D|E|G;
const uint three = A|B|C|D|G;
const uint four  = B|C|F|G|DP;

const uint com_1 = COM_2|COM_3|COM_4;
const uint com_2 = COM_1|COM_3|COM_4|COM_DOTS;
const uint com_3 = COM_1|COM_2|COM_4|COM_DOTS;
const uint com_4 = COM_1|COM_2|COM_3|COM_DOTS;

A : bekapcsolása trükkös módon a one változóba van elrejtve, így már az a probléma sem áll fenn, hogy erősebben világítana, mint a többi.

Ahhoz, hogy használni tudjuk a PIO programot, be kell húznunk a CMake által generált header fájlt, ami esetemben egy #include "basic_pio.pio.h" sor volt a C fájl tetején. Majd kezdődhet a program beállítása.

basic_pio.c
const PIO pio = pio0;

const uint offset = pio_add_program(pio, &basic_pio_program);
const uint sm = pio_claim_unused_sm(pio, true);

basic_pio_program_init(pio, sm, offset, START_PIN);

Hozzáadjuk a programot, szerzünk egy használaton kívüli állapotgépet és felkonfiguráljuk.

Ezután már csak a megjelenítés marad. Kicsit rövidebb, mint a tisztán C változat, de lényegében ugyanazt csinálja.

basic_pio.c
while (true) {
  pio_sm_put(pio, sm, com_1|one);
  sleep_ms(2);
  pio_sm_put(pio, sm, com_2|two);
  sleep_ms(2);
  pio_sm_put(pio, sm, com_3|three);
  sleep_ms(2);
  pio_sm_put(pio, sm, com_4|four);
  sleep_ms(2);
}

A végeredmény

Az előző példában a megjelenítés időzítését továbbra is a C kód intézte, ami nem annyira szerencsés, ha valami mást is szeretnénk csinálni a kódban, nem csak ezzel a kijelzővel foglalkozni. Jó lenne egy olyan megoldás, hogy a PIO-nak átadjuk a adatokat, ő pedig megoldja a megjelenítéssel járó minden gondot és bajt.

Négyszer 14 bitnyi adatról van szó, úgyhogy nem férünk bele egy 32 bites változóba. Szerencsére az állapotgépnek két regisztere is van, amit használhatunk (x és y), úgyhogy kétszer 28 bitnyi adatként átküldhetjük neki a kijelző tartalmát. A PIO program feladata csak annyi lenne, hogy eltárolja ezt az adatot a két regiszterben és kiküldje őket 14 bites egységekben a GPIO pin-ekre, megfelelő ütemezésben.

advanced_pio.pio
.program advanced_pio

.define PUBLIC pin_count 14

.wrap_target
  mov isr, x
  mov x, y
  mov y, isr

  pull noblock
  mov x, osr

  out pins, pin_count [5]
  out pins, pin_count
.wrap

A .wrap_target/.wrap olyan, mint egy loop:/jmp loop az egész körül, de nem kerül extra utasításba.

Az első blokkban megcseréljük az x és az y regiszterben lévő értékeket, ehhez az isr-t (Input Shift Register) használjuk köztes tárnak, ami nem probléma, mert egyébként nincs használatban (a GPIO felől jövő adatok lennének benne, ha a pinek input módban lennének).

Ez után egy nem blokkoló pull következik, ami a C program felől érkező adatokat teszi el az osr-be (Output Shift Register, a GPIO felé menő adatok). A nem blokkoló pull egyik kellemes tulajdonsága, hogy ha nem jött adat, akkor az x regiszter tartalmát fogja az osr-be átmásolni. Így meg is van oldva az, hogy ha nincs új adat, akkor továbbra is a régit jelenítjük meg.

Ez után már csak kétszer 14 bitnyi adatot fogunk kitolni a pin-ekre. Az első out végén lévő [5] egy 5 utasításnyi késleltetés, így a kijelző szemszögéből nézve mindkét out után van 5 utasításnyi szünet.

A végeredmény az lesz, hogy felváltva szed ki az x és y regiszterekből kétszer 14 bitnyi adatot, valamint felváltva frissíti a regiszterek tartalmát az új bejövő adatokkal.

Természetesen ehhez a PIO programhoz is tartozik egy beállító függvényke, ami majdnem teljesen megegyezik az előző programunkhoz tartozóval.

advanced_pio.pio
% c-sdk {
#include "hardware/clocks.h"

static inline void advanced_pio_program_init(PIO pio, uint sm, uint offset, uint pin) {
  pio_sm_config config = advanced_pio_program_get_default_config(offset);

  sm_config_set_out_pins(&config, pin, advanced_pio_pin_count);

  float clock_divider = (float) clock_get_hz(clk_sys) / 2000000;
  sm_config_set_clkdiv(&config, clock_divider);

  for (uint i = 0; i < advanced_pio_pin_count; ++i) {
    pio_gpio_init(pio, pin + i);
  }
  pio_sm_set_consecutive_pindirs(pio, sm, pin, advanced_pio_pin_count, true);

  pio_sm_init(pio, sm, offset, &config);
  pio_sm_set_enabled(pio, sm, true);
}
%}

Az egyetlen különbség, hogy a sm_config_set_clkdiv segítségével lelassítjuk az állapotgép futását, hogy a kijelző számai megfelelő ütemben frissüljenek.

advanced_pio.c
pio_sm_put(pio, sm, ((com_1|one) << advanced_pio_pin_count) | com_2|two);
pio_sm_put(pio, sm, ((com_3|three) << advanced_pio_pin_count) | com_4|four);

while (true) {
  sleep_ms(1000);
}

A C programunk nagy része ugyanaz marad, mint az előző példa, csak a végtelen ciklus környékén változtatunk egy kicsit. A PIO programnak csak egyszer küldjük át az adatokat, onnantól bármit csinálhatunk a C programban, a kijelzőn a megfelelő érték fog megjelenni. És ez a kód is megtalálható Github-on.

This post is also available in english: The smallest Pi

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha kedveled a régi jó dolgokat.