Kurs FreeRTOS #3: zadania i priorytety

miganie led blinking freertos

Dzisiaj stworzymy w końcu pierwsze zadanie i będziemy mieli naoczny efekt naszej pracy – mikrokontrolerowy „Hello World”, czyli migającą diodę. Zapowiada się bardzo dużo konkretnych i istotnych informacji, dlatego nie przedłużam – bierzemy się do pracy! 😉

Zadania

W pierwszym artykule pisałem już o tym, że FreeRTOS jest systemem wielozadaniowym – umożliwia podzielenie całego, wielkiego projektu na małe zadania (ang. tasks), dzięki czemu projekt całego programu, a następnie implementacja funkcjonalności staje się zdecydowania prostsza. Ponadto, komunikacja pomiędzy zadaniami i utrzymywanie zależności czasowych staje się łatwiejsza, bo część odpowiedzialności spada na system operacyjny, a to zaoszczędza pisania sporej ilości kodu.

Każde zadanie można porównać do małego programu, który realizuje określoną funkcjonalność w nieskończonej pętli. Co istotne, każde zadanie ma swój własny stos oraz zestaw rejestrów procesora, przez co wydaje się mu, że ma procesor na wyłączność. Wielkość stosu dla każdego zadania możemy dopasować do jego potrzeb i robi się to przy jego tworzeniu. Gdy zadanie jest przełączane to zapisywany jest aktualny kontekst (stan rejestrów, stos) w jakim się ono znajduje oraz odtwarzany jest kontekst zadania, które będzie uruchomiane, dzięki czemu m.in. zadanie kontynuuje pracę od miejsca, w którym zostało przerwane, a nie zaczyna od początku. Jak można się domyślić przełączanie kontekstu (ang. context switching) nie jest realizowane w jednym takcie procesora… Wymaga to wykonania wielu operacji i jest to czas poświęcony na działanie systemu, a nie realizację konkretnych zadań urządzenia, więc można powiedzieć, że jest to w pewnym sensie czas stracony. Im więcej rejestrów ma procesor tym ten czas jest dłuższy, ale spokojnie – nie jest to ilość czasu, która ma ogromne znaczenie. Stanowi to maksymalnie kilka procent całkowitego zużycia czasu procesora.

Stany zadań

Jak wiadomo, w jednej chwili może być wykonywane wyłącznie jedno zadanie. Wydawać by się więc mogło, że zadanie może być albo wykonywane, albo wstrzymane. W rzeczywistości jest to trochę bardziej złożone, bowiem zadania mogą przyjmować następujące stany:

  • wykonywane (ang. running): zadanie, które jest aktualnie wykonywane,
  • gotowe (ang. ready): zadanie, które nie jest, ale może być wykonywane. Taki stan zadania informuje schedulera, że może on uruchomić to zadanie (kosztem innego),
  • zablokowane (ang.blocked): zadanie przebywające w tym stanie nie może być wykonywane: oczekuje na upłynięcie określonego czasu bądź pojawienie się jakiegoś zdarzenia,
  • wstrzymane (ang. suspended): zadanie jest „wyłączone” i nie jest nawet brane pod uwagę przez scheduler. Nie będzie mogło być wykonane dopóki nie zostanie odblokowane przez wywołanie odpowiedniej funkcji.

Poszczególne stany oraz możliwe przejścia pomiędzy nimi zostały przedstawione na poniższym rysunku:

freertos zadania
źródło: freertos.org

Zegar systemowy

Jak to się dzieje, że zadania są przełączane? Co jaki czas system sprawdza czy konieczna jest zmiana kontekstu i jak jest on odmierzany? Za wszystkim stoi mechanizm tyknięcia systemowego (ang. System Tick). Częstotliwość zegara systemowego (tych systemowych tyknięć) jest znacznie niższa niż częstotliwość zegara procesora i ustawia się ją za pomocą makra configTICK_RATE_HZ w pliku konfiguracyjnym. Zgodnie z ustawioną częstotliwością wyzwalane jest przerwanie sprzętowego timera systemowego SysTick. Procedurę obsługi tego przerwania podpinaliśmy w poprzednim wpisie. Jej definicja znajduje się w pliku port.c, zobaczmy jak wygląda:

Jak widać, całość sprowadza się do wywołania funkcji xTaskIncrementTick(). Jak sama nazwa wskazuje, funkcja  inkrementuje licznik tyknięć, dzięki czemu możliwe jest odliczanie czasu w systemie. W systemie występują takie funkcje jak vTaskDelay() oraz vTaskDelayUntil(), które umożliwiają wprowadzenie zadania w stan zablokowania na określony czas. W tym miejscu sprawdzane jest czy już ten czas upłynął i można zmienić stan zadania na gotowe oraz czy konieczna jest zmiana kontekstu. Jeśli tak, to ustawiany jest odpowiedni bit w rejestrze, dzięki czemu wyzwalane jest przerwanie PendSV, w którego obsłudze następuje przełączenie kontekstu i związane z nim niezbędne operacje (może wyglądać to inaczej dla różnych rdzeni procesora).

Im większa częstotliwość zegara systemowego tym więcej czasu procesora poświęcane jest na operacje związane z systemowym tyknięciem i przełączaniem kontekstu. W aplikacjach demonstracyjnych jest ona ustawiana na 1000Hz, można ją oczywiście zmieniać i dopasowywać do swoich potrzeb.

Priorytety

W sytuacji, gdy jedno zadanie jest w stanie gotowości, a pozostałe są zablokowane bądź wstrzymane, scheduler nie ma problemu z wyborem zadania do uruchomienia. Co dzieje się w przypadku, gdy istnieje wiele zadań w stanie gotowości? Które powinien wybrać? Bardzo często istnieją zadania bardziej i mniej ważne. Istotność każdego z nich można zdefiniować za pomocą tzw. priorytetu (ang. priority) – na jego podstawie scheduler decyduje, które zadanie, przebywające w stanie gotowości, powinno być wykonane jako pierwsze.

Maksymalną liczbę priorytetów określa się w pliku konfiguracyjnym za pomocą makra configMAX_PRIORITIES. Zadania o największej wartości priorytetu będą wywołane w pierwszej kolejności. Numerowanie zaczyna się od zera, więc maksymalną wartością priorytetu może być (configMAX_PRIORITIES-1).

Co ważne, w systemie FreeRTOS priorytety nie muszą być unikalne – tzn. wielu zadaniom można przypisać ten sam priorytet. Jeśli istnieje kilka zadań, w stanie gotowości, o tym samym priorytecie to są one szeregowane wg algorytmu karuzelowego (ang. round robin) – zadania są wykonywane po kolei i każde otrzymuje taki sam okres czasu.

W wyniku wystąpienia jakiegoś przerwania, stan zadań może ulec zmianie. Jak wspomniałem w pierwszym wpisie, FreeRTOS jest systemem z wywłaszczaniem, dlatego w sytuacji, gdy zadanie o priorytecie wyższym od priorytetu zadania aktualnie wykonywanego uzyska stan gotowości – scheduler przerywa zadanie działające w tej chwili i uruchamia zadanie o wyższym priorytecie.

Zadanie jałowe (bezczynności)

Sytuację, w której wiele zadań jest w stanie gotowości już rozważyliśmy. A co w przypadku, gdy wszystkie są zablokowane lub zawieszone? W wielu sytuacjach zadania będą przebywały w stanie zablokowania, oczekując na wystąpienie jakiegoś zdarzenia, a gdy takie się pojawi to zrealizują swoje zadanie, po czym znów przejdą w stan zablokowania. Istnieje więc spore prawdopodobieństwo, że taka sytuacja może wystąpić.

W takich okolicznościach wykonywane jest zadanie jałowe (ang. idle task), które jest automatycznie tworzone przy starcie systemu. Ma ono możliwie najniższy priorytet, ale cały czas jest w stanie gotowości, więc gdy wszystkie pozostałe zadania są zablokowane to zawsze wykonywane jest to.

Jego podstawowym zadaniem jest czyszczenie pamięci po usuniętych zadaniach, jednak gdy w systemie nie mamy dynamicznie tworzonych i usuwanych zadań to bardzo często, przez większość czasu nie robi nic. Można jednak zadanie jałowe wykorzystać bardziej sensownie. Ustawienie makra configUSE_IDLE_HOOK na 1, spowoduje, że będzie ono wywoływało specjalną funkcję nazywaną Idle Hook Function:

Możesz w niej robić co chcesz, należy jednak pamiętać, aby nie wywoływać w niej funkcji systemowych, które mogłyby wprowadzić zadanie jałowe w stan zablokowania. Bardzo często jest to idealne miejsce na wprowadzenie  mikrokontrolera w tryb oszczędzania energii, co może być szczególnie przydatne w przypadku urządzeń zasilanych bateryjnie.

Przy okazji dodam, że istnieje także możliwość włączenia innych funkcji typu hook, jak np. Tick Hook Function albo Stack Overflow Hook Function.

Innym sposobem na realizowanie jakiejś własnej funkcjonalności w stanie jałowym jest utworzenie zadań o zerowym priorytecie. W takim wypadku należy zwrócić uwagę na odpowiednie ustawienie makra configIDLE_SHOULD_YIELD – jego dokładny opis znajdziesz tutaj.

Implementacja zadania

Na początku wspomniałem, że zadania można porównać do małych programów, który realizują określone funkcjonalności w nieskończonych pętlach. Nie powinna, więc nas dziwić ich struktura, które wygląda następująco:

Jak widać, jest to zwykła funkcja z nieskończoną pętlą, wewnątrz której implementuje się określoną funkcjonalność. Funkcja nie zwraca nic, ale przyjmuje wskaźnik typu void, dzięki czemu możliwe jest przekazanie do zadania informacji dowolnego typu. Każda funkcja, która jest implementacją zadania powinna mieć taki sam typ i strukturę.

Czas na miganie diodą

Najważniejsza teoria za nami – czas na przykład! Po raz pierwszy zamigamy diodą pod kontrolą systemu operacyjnego 😀 No to do dzieła 🙂

Nie wygląda to skomplikowanie, co? 😉 LED_TOG to makro, za pomocą którego realizujemy zmianę stanu logicznego na danym pinie na przeciwny. Przy użyciu biblioteki standardowej dla STM32 może to wyglądać następująco:

Wprowadzanie opóźnień

Po zmianie stanu wywoływana jest systemowa funkcja vTaskDelay() umożliwiająca wprowadzenie opóźnienia. Jednak, co istotne, działa ona w sposób nieblokujący procesora. Po jej wywołaniu, stan aktualnie wykonywanego zadania zmieniany jest na zablokowane na okres czasu podany w argumencie. Procesor nie siedzi w pustej pętli – następuje przełączenie kontekstu i wykonywane jest inne zadanie 🙂 Po upłynięciu tego czasu stan zadania zmienia się na gotowe i scheduler może je uruchomić ponownie.

Zmiana stanu zadania na gotowe nie jest równoznacznaczne z jego ponownym uruchomieniem, ponieważ może być aktywne zadanie o wyższym priorytecie i to ono będzie wykonywane w pierwszej kolejności.

Funkcja vTaskDelay() jako argument przyjmuje liczbę taktów zegara systemowego. Zgodzisz się pewnie, że niekoniecznie jest to najwygodniejsza jednostka wprowadzanych opóźnień, często zdecydowanie lepsze mogą okazać się milisekundy. Do tego celu wykorzystuje się specjalne systemowe makro portTICK_RATE_MS, które umożliwia konwersję milisekund na takty zegarowe. Zapis 100 / portTICK_RATE_MS, spowoduje więc ustawienie opóźnienia na 100ms. W przypadku zadań, które mają wykonywać się z określoną częstotliwością lepiej jest wykorzystać funkcję vTaskDelayUntil(). Funkcje są dostępne wyłącznie wtedy gdy makra INCLUDE_vTaskDelay oraz INCLUDE_vTaskDelayUntil w pliku konfiguracyjnym mają wartość ustawioną na 1.

Oczywiście chcąc sterować pinem GPIO nie można zapomnieć o włączeniu zegara i ustawieniu pinu jako wyjście. Dla porządku, z SPL, wygląda to następująco:

Dodawanie zadań w systemie

Stworzyliśmy zadanie, dokonaliśmy konfiguracji GPIO. Czy to już wszystko? Otóż nie 😀 Brakuje jeszcze jednej podstawowej rzeczy. Zadanie co prawda będzie już istniało w pamięci, ale scheduler nie będzie miał pojęcia o jego istnieniu i nigdy go nie uruchomi 🙂 Trzeba go jeszcze o tym zadaniu poinformować. Służy do tego funkcja xTaskCreate:

Opiszę teraz jej kolejne parametry:

  • TaskFunction_t pxTaskCode: wskaźnik do funkcji zadania, czyli w tym przypadku do zaimplementowanego już vLEDTask. Typ TaskFunction_t to nic innego jak specjalny typ wskaźnika funkcyjnego typedef void (*TaskFunction_t)( void * ). Przekazana w argumencie funkcja nic nie zwraca i przyjmuje wskaźnik typu void. Więcej na temat wskaźników funkcyjnych możesz przeczytać w innym moim wpisie, który znajdziesz tutaj.
  • const char * const pcName: nazwa zadania. Nie jest ona wykorzystywana przez system, ale w przypadku debugowania taka tekstowa nazwa jest bardziej przyjazna człowiekowi.
  • const uint16_t usStackDepth: jak już wspominałem, każdemu zadaniu przydzielany jest oddzielny stos. Za pomocą tego argumentu określamy jego wielkość, ale uwaga – przekazana wartość nie określa liczby bajtów stosu dla danego zadania, tylko liczbę słów, które na stosie zmieścimy. W skrócie, wielkość stosu, która zostanie zarezerwowana dla tego zadania wynosi usStackDepth * sizeof(StackType_t), gdzie StackType_t jest zdefiniowane dla każdego portu w pliku portmacro.h. W przypadku Cortex-M3 odpowiada mu uint32_t, czyli przekazując w tym argumencie wartość 100, w rzeczywistości zarezerwowane zostanie 400 bajtów stosu dla tego zadania.
  • void * const pvParameters: tworząc zadanie, do jego funkcji można przekazywać różne dane i robimy to przy pomocy tego wskaźnika.
  • UBaseType_t uxPriority: priorytet zadania. Wartość od 0 do (configMAX_PRIORITIES-1).
  • TaskHandle_t * const pxCreatedTask: uchwyt do tworzonego zadania, dzięki któremu możemy się potem do niego odwołać, np. zmienić jego priorytet, albo je usunąć.

Wywołanie może wyglądać następująco:

Priorytet został ustawiony na 1, ale w tym wypadku mamy tylko jedno zadanie, więc nie robi to większej różnicy. Nie ma potrzeby przekazywania czegokolwiek do funkcji zadania, jak również, nie jest potrzebny uchwyt, więc można tam wpisać wartości NULL.

Cały plik główny

No i to tyle 🙂 Pokażę jeszcze jak wygląda cały główny plik main.c:

Czas na kompilacje i test programu – teraz już wszystko powinno działać! Mamy w końcu naoczny efekt działania systemu! 🙂

Jak już wspominałem, przy wyjściu z zadania jego kontekst jest zapisywany, a przy jego wznowieniu kontekst jest odczytywany i zadanie kontynuuje pracę od miejsca, w którym zostało przerwane – nie zaczyna od początku. Sprawdźmy to delikatnie modyfikując kod naszego zadania:

Jak widać, dioda nadal miga 🙂 Gdyby przy wznowieniu zadanie uruchamiało się od początku to nie zaobserwowalibyśmy migania tylko ciągłe świecenie.

Jak można się domyślić dostępnych jest jeszcze wiele innych funkcji systemowych przenaczonych do zarządzania zadaniami, jak np. vTaskSuspend czy vTaskPrioritySet(), jednak nie sposób bym je tutaj wszystkie opisał. Poza tym, wydaje mi się, że nie miałoby to większego sensu, ponieważ dokładny opis wszystkich dostępnych funkcji znajdziesz w dokumentacji systemu.

Podsumowanie

To było by na tyle w tym wpisie. Nie spodziewałem się, że wyjdzie tego aż tyle 😀 Zacząłem się zastanawiać czy jakoś go nie podzielić, ale taki sposób opisu wydał mi się najbardziej spójny i sensowny. Mam nadzieję, że podobał Ci się ten artykuł i poznałeś w nim wiele nowych zagadnień 🙂 W razie trudności warto przeczytać go jeszcze raz. W kolejnym wpisie pokażę jeszcze kilka dodatkowych przykładów, które, mam nadzieję, jeszcze lepiej zobrazują działanie systemu i opisanych tu zagadnień. Warto też czasem w IDE kliknąć na wywoływane funkcje i zobaczyć co tam się pod spodem dzieje. Mamy pełny dostęp do kodu źródłowego systemu, więc korzystajmy 🙂 Przypuszczać by można było, że dzieje się tam jakiś hardcore, a okazuje się, że wcale nie. Nie ma tam żadnej ciemnej magii, a takie podejrzenie kodu może rozwiać wiele wątpliwości.

Kodu z tego przykładu na githuba nie wrzucam, bo nic, poza plikiem main.c, którego kod zamieściłem, nie uległo zmianie. Natomiast pusty projekt z poprzedniego wpisu znajduje się tutaj.

Miłego dnia lub wieczora, cześć! 😉

Kurs FreeRTOS #4: zadania i priorytety – dodatkowe przykłady