Zephyr RTOS

Disclaimer

Wszystko co tu opisuję może nie być w pełni poprawnym opisem z teoretycznego punktu widzenia, ale powinno nadawać się do skonfigurowania wszystkiego i rozpoczęcia zabawy. Potraktuj to jak moje osobiste notatki na wypadek jakbym zapomniał jak wszystko zainstalować na nowym komputerze.

Linki

Co to OS?

OS czyli system operacyjny (Operating System) to - w mocnym uproszczeniu - oprogramowanie kontrolujące hardware i oferujące różne przydatne rzeczy, jak na przykład wątki, wiele procesów i inne takie. Coś jak biblioteka do obsługi sprzętu.

Normalne systemy operacyjne przeważnie pozwalają na uruchomienie wielu procesów i przydzielają im czas procesora tak, aby każdy miał okazję chwilę podziałać.

Co to RTOS?

RTOS czyli system operacyjny czasu rzeczywistego (Real Time Operating System). Tak jak zwykły OS jest "biblioteką" do obsługi sprzętu, jednak różnica polega na tym, że stara się mieć minimalny narzut i możliwie dużo czasu poświęcić twojemu programowi. Stawia bardziej na działanie tylko jednego programu, który ma dostęp do wszystkiego i cały procesor na wyłączność. Skutkuje to szybszym działaniem programu, co jest przydatne przy programowaniu mikrokontrolerów, które powinny jak najszybciej reagować na sygnały (takie jak wciśnięcie przycisku lub dane z czujników).

Oczywiście RTOSy mogą mieć wsparcie dla wielowątkowości i innych rodzajów zrównoleglenia kodu, ale generalnie chodzi o to, żeby system robił jak najmniej i jak najkrócej.

Co to Zephyr?

Zephyr jest systemem operacyjnym czasu rzeczywistego, obsługującym różne rodzaje kontrolerów i płytek (Raspberry Pi, Arduino...). Zephyr korzysta z device tree - pliku konfiguracyjnego, który definiuje użycie pinów GPIO i innych takich, dzięki czemu można napisać uniwersalny kod, a dopasowanie do konkretnej płytki wymaga jedynie modyfikacji device tree. Ponieważ jest to system uruchamiany na mikrokontrolerach, które z reguły nie mają za dużo pamięci, system powinien zajmować jak najmniej miejsca. Wybór co ma być dołączone do Zephyra polega na ustawieniu odpowiednich zmiennych KConfiga.

Oprócz tego dostępne jest tam narzędzie west (biblioteka Pythonowa), które obsługuje pobieranie pakietów, sterowników, kompilację i takie tam.

Setup

Prawdopodobnie są różne konwencje, ale ja używam następującej:

  • Wszystko co Zephyrowe - wspólne dla wielu projektów - znajduje się w folderze D:\Dev\Zephyr
  • Wszystko co dotyczy konkretnego projektu znajduje się gdzie indziej (np. D:\Workspace\nazwa_projektu)

Wszystkie wystąpienia tych ścieżek poniżej powinno dać się bez problemu zamienić na cokolwiek innego, więc nie należy ich traktować jako wymagane lokalizacje.

  1. Zainstaluj: Python, 7-zip, CMake, Ninja
  2. Wejdź na https://github.com/zephyrproject-rtos/sdk-ng/releases
  3. Pobierz "SDK Bundle" (minimal, pozostałe wersje mają wszystkie pozostałe toolchainy)
SDK i toolchain
SDK (Software Development Kit) zawiera narzędzia potrzebne do skompilowania wspólne dla różnych architektur

Toolchain zawiera kompilator i narzędzia pod konkretną architekturę procesora

  1. Pobierz toolchain pasujący do twojej płytki i systemu operacyjnego na którym pracujesz Jeśli planujesz na Windowsie pisać na płytkę z ARMem, pobierz arm-zephyr-eabi z kolumny "Windows"
  2. Rozpakuj SDK do folderu D:\Dev\Zephyr\(utworzy podfolder np. zephyr-sdk-<wersja> - w momencie pisania dostępna jest wersja 0.17.0, czyli: zephyr-sdk-0.17.0)
  3. Rozpakuj toolchain do folderu D:\Dev\Zephyr\zephyr-sdk-0.17.0 (w przypadku ARMowego, doda to podfolder arm-zephyr-eabi)
  4. Upewnij się, że masz folder 7-zip w PATHu
  5. Wejdź do folderu SDK: D:\Dev\Zephyr\zephyr-sdk-0.17.0 i uruchom setup.cmd (w przypadku Windowsa)
  6. setup.cmd może automatycznie pobrać różne toolchainy - wymagany masz już pobrany, więc na wszystkie pytania możesz odpowiedzieć N - poza ostatnim, dotyczącym zarejestrowania w CMake, na które warto odpowiedzieć Y

💬 W tym momencie struktura folderu powinna wyglądać mniej-więcej tak:

  • Zephyr
    • zephyr-sdk-0.17.0
      • arm-zephyr-eabi
      • setup.cmd
      • ...oraz kilka innych plików i folderów
  1. Wejdź do folderu D:\Dev\Zephyr
  2. Otwórz konsolę
  3. Utwórz venv: python -m venv .venv
Python Virtual Environment
Pythonowy moduł venv pozwala tworzyć "wirtualne" środowiska. W uproszczeniu chodzi o to, żeby mieć kopię Pythona z własnym zestawem modułów i nie zaśmiecać głównej instalacji.

Virtual Environment (AKA venv) tworzy się uruchamiając: python -m venv .venv

  • python oznacza, że uruchamiamy Pythona
  • -m venv mówi Pythonowi, że ma uruchomić moduł "venv"
  • .venv mówi modułowi "venv", że ma stworzyć Virtual Environment w folderze ".venv"

Początkowo, otwierając nową konsolę, używany będzie Python zdefiniowany w PATHu. Aby przełączyć się na venva można użyć skryptu ustawiającego wszystkie zmienne: .venv\Scripts\activate.bat. Wykonanie go w konsoli spowoduje zmianę znaku zachęty z D:\Dev\zephyr> na (.venv) D:\Dev\zephyr>.

  1. Aktywuj venva: .venv\Scripts\activate.bat
  2. Zainstaluj pakiet "west": pip install west
West
West jest Zephyrowym narzędziem, które
  • Pobiera kod i moduły Zephyra
  • Ułatwia kompilację projektu,
  • Pomaga we flashowaniu, debuggowaniu i kilku innych rzeczach
  1. Zainicjalizuj repozytorium Zephyra: west init zephyrproject
  2. Przejdź do folderu zephyrproject
  3. Pobierz wszystkie moduły: west update

💬 Możliwe jest też pobranie tylko wymaganych rzeczy, ale później, w trakcie pisania, konieczne może być sprawdzenie czego brakuje i ręczne pobranie konkretnych modułów (np. west update lvgl) Jeśli nie brakuje ci miejsca na dysku, to pobierz wszystko (u mnie całość zajmuje ~8GB).

  1. Pobierz Pythonowe zależności Zephyra: west packages pip --install

To powinno być na tyle jeśli chodzi o wspólną dla wszystkich projektów część.

Nowy projekt

Dobrym źródłem wiedzy oraz punktem wyjściowym nowego projektu może być Zephyrowy folder z przykładami. Na początek warto spróbować skompilować i uruchomić przykład "blinky", znajdujący się w D:\Dev\Zephyr\zephyrproject\samples\basic\blinky\, który powoduje mruganie diody na mikrokontrolerze (o ile jakieś ma).

Przygotowanie

  1. Utwórz folder projektu, np. D:\Workspace\nazwa_projektu
  2. Aktywuj venva: D:\Dev\Zephyr\.venv\Scripts\activate.bat
  3. Dodaj Zephyrowe zmienne środowiskowe: D:\Dev\Zephyr\zephyrproject\zephyr-env.cmd
  4. Opcjonalnie: Skopiuj pliki dowolnego przykładu do swojego folderu

Punkty 1. i 2. trzeba robić za każdym razem przy otwieraniu nowego okna konsoli.

Kompilacja

Kompilacja używa systemu budowania Ninja, więc upewnij się, że jest zainstalowany i dodany do PATHa.

  1. west build -p -b <nazwa_mikrokontrolera> gdzie:
    • -p oznacza pełne przebudowanie całego projektu - przydatne w razie jakichś błędów kompilacji, w większości przypadków lepiej pominąć.
    • -b <nazwa_mikrokontrolera> - <nazwa_mikrokontrolera> trzeba zamienić na nazwę mikrokontrolera na który piszesz program, np. -b xiao_ble

Po skompilowaniu utworzony zostanie plik .\build\zephyr\zephyr.uf2.

Flashowanie

Jeśli twój mikrokontroler obsługuje wgrywanie plików .uf2, wystarczy skopiować .\build\zephyr\zephyr.uf2.

W przeciwnym razie, można użyć west flash

⛔ TODO

Logi

Jeśli twój mikrokontroler to obsługuje, to Zephyr będzie wysyłał logi przez USB. Po pierwsze trzeba namierzyć na jakim porcie są nadawane. Na Windowsie można to zrobić używając "Menedżera Urzadzeń":

W tym przypadku kontroler wylądował na porcie COM7. Połączyć się z nim można przez putty. Jako "connection type" trzeba wybrać "Serial", a pod "Serial line" wpisać namierzony port:

Minusem takiego podejścia jest to, że po każdym zresetowaniu kontrolera trzeba ręcznie wszystko ponownie uruchomić. Osobiście wolę inne podejście - napisanie skryptu w Pythonie, który sam nawiązuje połączenie i wypisuje wszystkie logi. W uproszczeniu, jego kod wygląda tak:

import serial

with serial.Serial(port, baudrate=115200, timeout=0.5) as s:
    line = s.readline().decode('ASCII').rstrip()
    print(line)

Powyższy kod jest tylko przykładem - zamknięcie wszystkiego w pętli i dodanie ładnej obsługi błędów da bardzo przyjemny w użyciu nasłuchiwacz logów.

Przykład

Mój workflow wygląda następująco:

  1. Otwórz pierwsze okno konsoli
  2. Wywołaj w nim skrypt init.bat
  3. Otwórz drugie okno konsoli
  4. Uruchom w nim nasłuchiwacz logów: python serialwatch.py
  5. Po każdej zmianie w kodzie, w pierwszym oknie wywołaj build.bat && flash.bat

init.bat jest skryptem który uruchamia venva i ustawia Zephyrowe zmienne:

@echo off
call D:\Dev\zephyr\.venv\Scripts\activate.bat
call D:\Dev\zephyr\zephyrproject\zephyr-env.cmd

build.bat uruchamia kompilację:

@echo off
cls
west build -b xiao_ble %*

flash.bat kopiuje plik .uf2 na kontroler, który przeważnie w trybie flashowania pokazuje się u mnie jako dysk F:

@echo off
cp build\zephyr\zephyr.uf2 F:\

Struktura projektu

Foldery

Zephyr oczekuje pewnej konkretnej struktury folderów i plików. Poniżej znajduje się jej opis, a pod nim wyjaśnienie o co chodzi.

  • .\boards\ - folder zawierający pliki overlaye
  • .\build\ - folder zawierający pliki wygenerowane przez kompilator (nie wrzucaj go do systemu kontroli wersji, najlepiej od razu dopisz do .gitignore)
  • .\dts\bindings\ - folder zawierający bindingi
  • .\src\ - folder z kodem źródłowym twojego projektu
  • .\tests\ - folder z testami
  • .\CMakeLists.txt - konfiguracja projektu dla CMake - lista plików, opcje kompilacji...
  • .\prj.conf - KConfigowy plik konfiguracyjny Zephyra

Device tree

Device Tree jest plikiem opisującym komponenty systemu i ich parametry. Dla przykładu weźmy płytkę której używałem: Seeed Studio XIAO nRF52840.

Device tree tej płytki wygląda następująco (niektóre fragmenty wycięte):

#include <nordic/nrf52840_qiaa.dtsi>

/ {
    chosen {
        zephyr,ieee802154 = &ieee802154;
    };

    leds {
        compatible = "gpio-leds";
        led0: led_0 {
            gpios = <&gpio0 26 GPIO_ACTIVE_LOW>;
            label = "Red LED";
        };
        led1: led_1 {
            gpios = <&gpio0 30 GPIO_ACTIVE_LOW>;
            label = "Green LED";
        };
        led2: led_2 {
            gpios = <&gpio0 6 GPIO_ACTIVE_LOW>;
            label = "Blue LED";
        };
    };

    aliases {
        led0 = &led0;
        led1 = &led1;
        led2 = &led2;
    };
};

W pierwszej linii widać, że includowany jest plik nrf52840_qiaa.dtsi co potwierdza, że ten kontroler bazuje na układzie nRF52840 (czyli jego nazwa nie kłamie). Z zawartości device tree da się odczytać, że płytka ta ma trzy LEDy - czerwony, zielony i niebieski - wewnętrznie podpięte do GPIO kolejno 26, 30 i 6, które są włączone po podaniu stanu niskiego.

Przykładowy projekt "blinky" pokazuje jak użyć danych z device tree w kodzie:

struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
  • DT_ALIAS konwertuje podany alias na identyfikator węzła device tree, używając aliasów zdefiniowanych w bloku aliases
  • DT_ALIAS(led0) konwertuje alias led0 - w tym przypadku alias i nazwa węzła są takie same
  • GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios) pobiera informacje o pinie GPIO z noda na który wskazuje alias led0, które zapisane są w polu gpios
  • gpio_dt_spec przechowuje informacje o pinie GPIO takie jak jego numer, przy jakim stanie jest uznawany za aktywny...

Jeśli chcemy podpiąć do kontrolera jakieś inne urządzenia, lub przestawić parametry podpiętych (np. żeby przepiąć wbudowane LEDy na zewnętrzne) można zrobić overlay.

Overlay

Overlay jest nakładką na device tree wybranego kontrolera. Przykładowo: płytka ma swoje LEDy, ale w projekcie który piszemy możemy zamiast nich chcieć używać innych, lub dołożyć nowe diody. To, co dostępne jest w każdej płytce opisane jest w device tree ukrytym w Zephyrze, a to, co używane przez projekt będzie do niego doklejone podczas kompilacji.

Ponieważ używam płytki xiao_ble, w folderze boards musiałem stworzyć plik xiao_ble.overlay. Jego zawartość wygląda następująco (fragmenty wycięte):

/ {
    buzzers {       
        compatible = "gpio-leds";

        buzzer0: buzzer0 {
            gpios = <&xiao_d 6 GPIO_ACTIVE_LOW>;
            label = "Buzzer";
        };
    };

    qmbstarter: qmbstarter {
        compatible = "qmbstarter";

        relay-power-gpios = <&xiao_d 1 GPIO_ACTIVE_HIGH>;
        relay-starter-gpios = <&xiao_d 7 GPIO_ACTIVE_LOW>;
        relay-ignition-gpios = <&xiao_d 0 GPIO_ACTIVE_LOW>;
    };

    qmbstarter_charging_sensor: qmbstarter_charging_sensor {
        compatible = "adc";

        io-channels = <&adc 0>;
    };
};

&adc {
    status = "okay";

    channel@0 {
        reg = <0>;
        zephyr,gain = "ADC_GAIN_1_6";
        zephyr,reference = "ADC_REF_INTERNAL";
        zephyr,acquisition-time = <ADC_ACQ_TIME_DEFAULT>;
        zephyr,input-positive = <NRF_SAADC_AIN3>;
        zephyr,resolution = <12>;
    };

};
  • W / zdefiniowane są nowe komponenty device tree, nie występujące w bazowym
  • W bloku buzzers opisany jest jednotonowy głośnik (liczba dźwięków, nie waga), który:
    • Ma takie same parametry jak LED (compatible = "gpio-leds")
    • Ma identyfikator buzzer0
    • Jest podpięty do GPIO zwanego w tej płytce "D6" (&xiao_d 6, bazując na dokumentacji układu jest to równoważne &gpio1 11).
  • W bloku qmbstarter opisane jest coś, co:
    • Ma parametry opisane w bindingu qmbstarter
    • Używa trzech przekaźników podpiętych do pinów "D1", "D7" oraz "D0"
    • Pierwszy przekaźnik aktywowany jest stanem wysokim, pozostałe stanem niskim
  • W bloku qmbstarter_charging_sensor mowa o czymś, co:
    • Jest ADC
    • Używa ADC numer 0
  • W &adc modyfikowane są wartości zdefiniowane w oryginalnym device tree
    • status ustawiony jest na "okay" (co oznacza, że ADC będzie dostępny do użycia - w przeciwnym razie pin działał by jako zwykłe GPIO),
    • channel@0 definiuje parametry ADC numer 0
    • zephyr,input-positive = <NRF_SAADC_AIN3> mówi którego pinu GPIO użyć jako ADC, ale co wpisać w zamiast NRF_SAADC_AIN3 jest wiedzą tajemną (z pomocą przyszedł mi ChatGPT, bo nic lepszego nie znalazłem ಠ_ಠ )

Czyli, z programistycznego na ludzki, powyższy overlay mówi, że do tej płytki dodatkowo podłączony jest głośniczek, trzy przekaźniki oraz jeden z pinów GPIO używany jest jako ADC z pewnymi parametrami.

Tak jak w poprzednim przypadku, aby spod kodu dostać się do parametrów zdefiniowanych w device tree i overlayu, trzeba użyć odpowiednich makr:

GPIO_DT_SPEC_GET(DT_NODELABEL(qmbstarter), relay_power_gpios);
  • DT_NODELABEL(qmbstarter) wyszukuje węzeł o nazwie qmbstarter (bezpośrednio po jego nazwie, nie po aliasie)
  • GPIO_DT_SPEC_GET(DT_NODELABEL(qmbstarter), relay_power_gpios) pobiera parametry GPIO z pola relay_power_gpios w węźle o nazwie qmbstarter

Oczywiście, możliwe jest zdefiniowanie wszystkiego ręcznie, bezpośrednio w kodzie - numer pinu, jakim stanem aktywowane, parametry ADC, ... - ale jest bardzo ważny powód używania device tree i overlayów: łatwo można przeportować kod na inną płytkę. Jeśli postanowię skompilować swój projekt na czymś innym, na przykład xiao_rp2040, wystarczy, że zrobię nowego overlaya xiao_rp2040.overlay w którym opiszę gdzie co jest podpięte, oraz skompiluję z flagą -b xiao_rp2040. Kod pozbawiony jest jakichkolwiek informacji o sprzęcie na jakim będzie działał co sprawia, że jest czytelniejszy i ułatwia kompilację na innym układzie.

Bindings

W device tree co chwilę pojawia się pole compatible. Mówi ono jakie parametry ma dany węzeł i jakie są ich wartości domyślne. Konieczność napisania własnych bindingsów pojawia się jeśli chcemy podłączyć coś do płytki i zachować logiczne zgrupowanie tych rzeczy.

Przykładowo: chciałem podłączyć trzy przekaźniki, które razem są częścią systemu startującego silnik spalinowy. Napisałem więc binding dts\bindings\qmbstarter.yaml:

description: binding for QMBStarter

compatible: "qmbstarter"

properties:
  relay-power-gpios:
    type: phandle-array
    required: true
    description: GPIO connected to the 'power' relay

  relay-starter-gpios:
    type: phandle-array
    required: true
    description: GPIO connected to the 'starter' relay

  relay-ignition-gpios:
    type: phandle-array
    required: true
    description: GPIO connected to the 'ignition' relay
  • compatible: "qmbstarter" opisuje jaką wartość powinno mieć pole compatible w device tree
  • properties mówi jakie parametry ma węzeł device tree
    • relay_power_gpios to nazwa parametru
    • type: phandle-array to typ parametru
    • required: true mówi, że trzeba zawsze zdefiniować wartość
    • description: ... to zrozumiały dla człowieka opis

Możliwe jest też pójście na łatwiznę i ustawienie wszystkiemu compatible = "gpio-leds", ale wtedy nie widać, że niektóre rzeczy są częścią tego samego systemu (trzy niezależne przekaźniki VS trzy przekaźniki używane w starterze silnika).

Dokumentacja bindingów zawiera więcej informacji.

KConfig

System konfiguracyjny Zephyra, mówiący jakie komponenty systemu powinny być dołączone i skompilowane. Czemu nie kompilować wszystkiego? Bo kontroler może mieć za mało pamięci, żeby zmieścić Zephyra w jego pełnej okazałości. No i nie ma potrzeby marnować prądu na usługi, które nie będą wykorzystane, a kontroler może przecież być zasilany z małej baterii.

Plik konfiguracyjny znajduje się bezpośrednio w folderze projektu i nazywa się prj.conf. Przykładowa zawartość:

CONFIG_LOG=y
CONFIG_GPIO=y
CONFIG_ADC=y
CONFIG_SPI=y

CONFIG_BT=y
CONFIG_BT_BAS=y
CONFIG_BT_DEVICE_NAME="QMB139 Starter"
  • CONFIG_LOG - włącz w Zephyrze system logów
  • CONFIG_GPIO - będziemy używali GPIO, więc dołącz też wszystko co do tego potrzebne
  • CONFIG_ADC - o, ADC też nam się przyda...
  • CONFIG_SPI - ...i SPI...
  • CONFIG_BT - ...oraz Bluetooth, więc skompiluj wszystko co do niego potrzebne
  • CONFIG_BT_BAS - mój kontroler będzie nadawał stan baterii używając usługi bateriowej
  • CONFIG_BT_DEVICE_NAME - ...i tak ma się przedstawiać na liście urządzeń Bluetooth!

Proces kompilacji

Proces kompilacji przebiega, w dużym uproszczeniu, następująco:

  1. Znajdź device tree dla podanej płytki (zdefiniowanej flagą -b, np. -b xiao_ble)
  2. Nałóż overlay podanej płytki na device tree
  3. W trakcie przeglądania device tree, sprawdź czy wszystkie węzły spełniają wymagania podane w bindingach wskazanych przez compatible
  4. Wygeneruj jeden wielki device tree i zapisz go w pliku build\zephyr\zephyr.dts
  5. Przekonwertuj device tree do C i zapisz go w pliku build\zephyr\include\generated\zephyr\devicetree_generated.h
  6. Sprawdź które komponenty Zephyra powinny być skompilowane bazując na zawartości prj.conf
  7. Skompiluj projekt i Zephyra do pliku build\zephyr\zephyr.uf2

TODO

  • Sprawdzić jak używać west flash
  • Sprawdzić jak zachowuje się west sdk install

Komentarze