System operacyjny czasu rzeczywistego dla Arduino

Dzisiejszy temat będzie z gatunku tych nieco bardziej zaawansowanych technicznie, jednak niczego się nie bójcie ;) Postaram się przedstawić zagadnienie najprościej jak się to da. Chciałbym zaprezentować Wam krótkie wprowadzenie do systemów operacyjnych czasu rzeczywistego na przykładzie systemu freeRTOS dla Arduino.

Jak działa zwykły program na Arduino

(lub dowolny inny mikrokontroler)

Jak każdy jednordzeniowy procesor również mikrokontrolery ATmega328 (które wykorzystywane są m.in. w płytkach Arduino Uno) potrafią wykonywać jedną rzecz na raz. Oznacza to, że nie możemy jednocześnie ustawić stanu wysokiego na wybranym pinie i odczytać stan innego pinu. Te opracje zawsze będą wykonane po sobie (mimo, że tego nie zauważamy, bo dzieje się to tak szybko). Wszystko jest fajnie dopóki nie potrzebujemy wykonywać pewnych operacji współbieżnie.

Wyobraźmy sobie, że musimy jednocześnie odczytywać dane z kilku różnych czujników, które będą sterowały jakimiś wyjściami. Nasuwa się od razu rozwiązanie, że możemy robić to sekwencyjnie: odczyt czujnika -> ustawienie wyjść dla niego, odcyt kolejnego czujnika itd.

Problem pojawia się, kiedy odczyt któregoś z czujników jest na tyle czasochłonny, że potrafimy dostrzec „laga” w procesorze, kiedy ten odczytuje dane opóźniając przy tym odczyty kolejnych czujników. Tutaj z pomocą przychodzi współbieżność.

Zrównoleglenie pracy

Dzięki zastosowaniu systemu czasu rzeczywistego oraz wątków, które oferuje możemy nasze odczyty prowadzić współbieżnie. Oznacza to, że czas procesora będzie odpowiednio przydzielany dla każdego wątku. Co więcej dzięki temu, że nasz system operacyjny jest czasu rzeczywistego to ten czas nigdy nie będzie przypadkowy i jako programiści mamy nad tym kontrolę.

Obsługę każdego czujnika możemy oddelegować do osobnego wątku, dzięki czemu żadne z tych zadań nie powinno blokować nam procesora.

Czy to naprawdę takie proste?

Nie.

Niestety, ale praca z wątkami pomimo swoich ogromnych zalet do najprzyjemniejszych nie należy. Jeżeli mamy proste wątki, które nie są od siebie zależne (lub nie używają wspólnych zasobów) to wszystko fajnie: odpalamy je i wszystko działa elegancko. Jeżeli jednak praca jednego wątku jest zależna od pracy innego (np. możemy wysterować wyjścia dopiero po odebraniu danych z kilku czujników) to temat zaczyna robić się już trochę trudniejszy – musimy zadbać o odpowiednią komunikację pomiędzy wątkami. Komunikację taką można zrealizować na kilka sposobów: za pomocą semaforów, kolejek, mutexów i innych takich dziwnych rzeczy. O tym może jednak w innym artykule ;)

Innym problemem, który może wystąpić poza wyżej opisaną zależnością od pracy innego wątku jest dostęp do wspólnego zasobu. Załóżmy, że mamy kilka wątków, które jednocześnie chcą pisać do jakiegoś pliku na karcie pamięci.

Przykładowo mamy 2 wątki mierzące temepraturę wewnątrz i na zewnątrz budynku.

Kolejne odczyty temperatury wewnątrz budynku: 22.0, 22.2, 22.3, 22.2

Kolejne odczyty temperatury na zewnątrz budynku: -1.6, -1.7, -1.5, -1.6

Jeżeli oba wątki na raz będą chciały zapisywać swoje wyniki do pliku w rezultacie możemy uzyskać coś takiego:

-122.6,.0, 22.-1.7 itd.

Jak widać wyniki te przeplatają się wzajemnie. W takiej sytuacji powinniśmy umożliwić dostęp do danego zasobu tylko jednemu wątkowi w danej chwili. Dopiero jak wątek odczytujący temperaturę zewnętrzną skończy pisać do pliku możemy pozwolić temu drugiemu skorzystać z możliwości zapisu.

Jak zainstalować freeRTOS

Skoro już wiemy jakie są zalety i wady wykorzystywania wątków możemy przejść do instalacji biblioteki oraz uruchomienia prostego przykładu.

Aby zainstalować bibliotekę w Arduino IDE należy znaleźć ją poprzez Menedżer bibliotek. Biblioteka o nazwie freeRTOS jest dostępna w domyślnym repozytorium Arduino.

Przykładowy szkic

Weźmy do analizy szkic z menu Plik > Przykłady > FreeRTOS > Blink_AnalogRead. Jest to szkic, który łączy dwie proste czynności, które wykonuje każdy początkujący użytkownik Arduino. Miganie diodą oraz odczyt danych z wejścia analogowego i wypisywanie ich na port szeregowy.

Pierwszą ważną czynnością jest załączenie odpowiedniej biblioteki:

#include <Arduino_FreeRTOS.h>

Następnie deklarujemy sygnatury naszych funkcji, które będą „ciałem” wątku.

void TaskBlink( void *pvParameters );
void TaskAnalogRead( void *pvParameters );

Jak widać każda funkcja, która chce aspirować do bycia wątkiem musi zwracać void’a oraz przyjmować wskaźnik na voida jako jedyny parametr.

Ale zaraz zaraz, przecież wskaźnik na nic (bo void jest niczym) jest całkowicie bez sensu. Wbrew pozorom ma to jednak trochę sensu bo z pozoru pod niczmym może kryć się wszystko ;) Wskaźnik na voida możemu rzutować na cokolwiek tylko chcemy. Nie jest to bezpieczne, ale umożliwia nam pewnego rodzaju atrapę polimorfizmu. W takim argumencie pvParameters możemy wrzucić wskaźnik na strukturę z danymi początkowymi dla wątku.

W funkcji setup poza standardowymi czynnościami typu inicjalizacja portu szeregowego tworzymy też nasze wątki.

xTaskCreate(
  TaskBlink
  ,  (const portCHAR *)"Blink"   // A name just for humans
  ,  128  // This stack size can be checked & adjusted by reading the Stack Highwater
  ,  NULL
  ,  2  // Priority, with 3 (configMAX_PRIORITIES - 1) being the highest, and 0 being the lowest.
  ,  NULL );

xTaskCreate(
  TaskAnalogRead
  ,  (const portCHAR *) "AnalogRead"
  ,  128  // Stack size
  ,  NULL
  ,  1  // Priority
  ,  NULL );

Pierwszym parametrem jest wskaźnik na funkcję, która będzie ciałem wątku. Wskaźnik na funkcję w C/C++ jest poprostu jej nazwą. Kolejnym parametrem jest nazwa wątku – posłuży jedynie do identyfikacji wątku przez człowieka, więc powinna być human-readale. Trzeci parametr to rozmiar stosu w słowach (nie w bajtach). Słowo na Arduino to 16 bitów (2 bajty), tak więc 128 słów to 256 bajtów pamięci. Kolejna wartość to omawiane wcześniej pvParameters. Jeżeli nie chcemy inicjować wątku zadnymi danymi początkowymi możemy wpisać tu NULL. Przedostatni parametr to priorytet wątku (gdzie 3 to najwyższy, a 0 najniższy), Ostatnim parametrem jest uchwyt do tworzonego wątku (który pozowli nam w przyszłości odwołać się do niego i np. zakończyć jego pracę). Wpisując powyższy kod na koniec funkcji setup() stworzyliśmy (i automatycznie uruchomiliśmy) dwa wątki. Jak widzimy funkcja loop() w tym przykładzie jest pusta dlatego, że całą robotę wykonuje wbudowany w RTOSa scheduler, który zarządza wątkami. Funkcja loop i tak nigdy się nie wykona.

Rzućmy teraz okiem na ciałą funkcji wątków:

void TaskBlink(void *pvParameters)  // This is a task.
{
  (void) pvParameters;
  pinMode(LED_BUILTIN, OUTPUT);

  for (;;) // A Task shall never return or exit.
  {
    digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
    digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
    vTaskDelay( 1000 / portTICK_PERIOD_MS ); // wait for one second
  }
}

void TaskAnalogRead(void *pvParameters)  // This is a task.
{
  (void) pvParameters;
  for (;;)
  {
    // read the input on analog pin 0:
    int sensorValue = analogRead(A0);
    // print out the value you read:
    Serial.println(sensorValue);
    vTaskDelay(1);  // one tick delay (15ms) in between reads for stability
  }
}

W przypadku TaskBlink jest to kod z przykładu Blink ze zmienioną funkcją delay() na vTaskDelay(). Jest to zoptymalizowana wersja delaya do użycia z wątkami. Różni się także przyjmowanym parametrem, który w tym przypadku zonacza odstęp jednego pełnego czasu procesora przydzielonego dla wątku. Kod, który jest przed nieskończoną pętlą for to odpowiednik funkcji setup(), natomiast to, co jest wewnątrz tego fora to odpowiednik funkcji loop().

W funkcji TaskAnalogRead widzimy, że wartość z pinu A0 odczytywana jest do zmiennej typu int, a następnie wypisywana na port szeregowy. Po tym odczekujemy 1 tick deklarując, że do końca przydzielonego czasu przez procesor już nic nie będziemy robili.

Po wgraniu tego programu na Arduino zauważymy, że miga ono wbudowaną diodą co sekundę jednocześnie wypisujęc na port szeregowy wartości pobrane z pinu A0.