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

freertos przykłady

W tym poście postaram się dokładnie zobrazować informacje przedstawione w poprzednim wpisie. Myślę, że ten artykuł rozwieje wszelkie wątpliwości. Utworzymy kilka zadań, zajmiemy się priorytetami, na przykładach zobaczymy jak wygląda przełączanie kontekstu oraz wykorzystamy funkcje typu Hook. Ponadto, nauczysz się jak przekazywać parametry do zadań oraz dowiesz się czym jest zagłodzenie zadania. Zahaczymy także o makra Trace Hook służące do śledzenia i analizy zachowania systemu FreeRTOS.

Zadania i priorytety – dodatkowe przykłady!

Planem na dziś jest, w oparciu o kilka dodatkowych przykładów, dokładne zobrazowanie zagadnień opisanych w poprzednim artykule. W tym celu przydatne będzie wykorzystanie dwóch zadań oraz systemowej funkcji Tick Hook, o której wspomniałem w poprzednim wpisie. Zacznijmy od implementacji zadań. Jedyną funkcjonalnością każdego z nich będzie ustawianie stanu wysokiego na określonym pinie:

Nie należy zapominać o utworzeniu zadań w systemie, ustawmy im ten sam priorytet:

Przekazywanie argumentów do zadań

Jak widać, powyższe zadania (pomijając fakt, że są bardzo proste) są niemal identyczne – różnią się jedynie numerem pinu. Lepszym rozwiązaniem będzie zaimplementowanie jednego zadania, które będzie przyjmowało numer pinu jako parametr, a następnie utworzenie w systemie dwóch zadań z różnymi argumentami.

pvParameters rzutujemy na uint16_t, ponieważ drugi parametr funkcji GPIO_WriteBit() jest takiego typu. Kompilator wyrzuci nam ostrzeżenie, że rzutujemy z wskaźnika na integer o innym rozmiarze, ponieważ taka operacja może doprowadzić do obcięcia liczby. My jednak w tej sytuacji wiemy co robimy – GPIO_Pin_5 i GPIO_Pin_6 są typu uint16_t, więc nic nie zostanie obcięte. Jeżeli chcesz się pozbyć ostrzeżenia, możesz zrzutować pvParameters na int. Program z powyższymi zadaniami na razie nie robi nic ciekawego. Jeżeli podłączymy diody led do tych pinów to nawet nie zaobserwujemy żadnego migania, ale zaraz się to zmieni 🙂

Funkcja Tick Hook

Funkcje typu Hook są funkcjami, które mogą być wywoływane podczas wystąpienia jakiegoś określonego zdarzenia, ale to my decydujemy co dokładnie mają robić. W tym przykładzie wykorzystamy funkcję Tick Hook, która jest wywoływana przy każdym tyknięciu zegara systemowego. Poprzednio pisałem, że tyknięcie systemowe jest realizowane przez funkcję xTaskIncrementTick() w procedurze obsługi przerwania xPortSysTickHandler(). Jeżeli zajrzymy do jej środka, znajdziemy tam poniższy fragment kodu:

Jak widać, jeżeli makro configUSE_TICK_HOOK będzie miało wartość 1 to przy każdym wywołaniu funkcji xTaskIncrementTick() zostanie także wywołana funkcja vApplicationTickHook(). W związku z tym, że jest ona wykonywana w obsłudze przerwania należy zadbać o to, aby była możliwie najkrótsza i nie wywoływała funkcji systemowych niekończących się na FromISR().
Przejdźmy teraz do jej implementacji. Jedyne co tam zrobimy to ustawianie na obu pinach stanu niskiego:

Nie wolno zapomnieć o ustawieniu makra configUSE_TICK_HOOK w pliku konfiguracyjnym FreeRTOSConfig.h:

Analiza działania systemu

Po co nam to wszystko? Otóż, dzięki temu będziemy mogli dokładnie zaobserwować działanie systemu 🙂 W pliku FreeRTOSConfig.h ustawmy jeszcze częstotliwość zegara systemowego na 10 Hz i odpalmy program.

Aby nie było wątpliwości, poniżej zamieszczam całą zawartość pliku main.c:

Do pinów możesz podłączyć diody LED i zaobserwować co się dzieje. Ja zamiast tego wykorzystam analizator stanów logicznych i przebiegi zamieszczę poniżej. Przejdźmy teraz do analizy naszego programu. Oba zadania mają ten sam priorytet i nigdy nie przechodzą w stan zablokowania. Gdy jedno z zadań przebywa w stanie aktywnym, drugie jest przez cały czas w stanie gotowości. W związku z tym są one szeregowane zgodnie z algorytmem karuzelowym, o czym pisałem w poprzednim wpisie (zadania są wykonywane po kolei). W tym wypadku każde otrzymuje taką samą ilość czasu, ponieważ zmiana kontekstu następuje wraz z tyknięciem zegara systemowego. Teraz zobaczymy jak to wygląda w praktyce 🙂 Wraz z tyknięciem zegara systemowego następuje przełączenie kontekstu oraz na pinach ustawiane są niskie stany logiczne. Przy wejściu do któregoś z zadań ustawiany jest wysoki stan logiczny na pinie odpowiadającym danemu zadaniu, dzięki czemu dokładnie widzimy, które zadanie jest aktualnie realizowane. Działanie programu dobrze odzwierciedla poniższy przebieg:

Szeregowanie zadań o takim samym priorytecie

Co więcej, jeśli mocno przybliżymy przebieg to zauważymy, że ustawienie stanu niskiego na jednym z pinów i wysokiego na drugim nie wykonuje się w tym samym czasie. W ten sposób jesteśmy w stanie sprawdzić ile czasu procesora jest poświęcane na zadania czysto systemowe: realizację tyknięcia systemowego i przełączenie kontekstu.

Czas poświęcony na przełączenie zadań

Jak widać, w tym wypadku jest to ok. 5 us. Wprowadzając drobne zmiany można np. sprawdzić ile zajmuje samo przełączanie kontekstu 🙂

Warto jeszcze podkreślić, że zmniejszyliśmy częstotliwość zegara systemowego, dzięki czemu na diodach LED wyraźnie widać przełączanie zadań. Przy wyższej częstotliwości diody świeciły by ciągle, przez co odnieślibyśmy wrażenie, że oba zadania wykonują się jednocześnie.

Zagłodzenie zadania

Wprowadźmy teraz do powyższego przykładu drobną zmianę – ustawmy jednemu z zadań wyższy priorytet:

Domyślasz już się jaki będzie efekt? Sprawdźmy:

Zagłodzenie zadania o niższym priorytecie

Jak widać, na jednym z pinów jest cały czas stan niski, tzn. że odpowiadające mu zadanie Task2 się nie wykonuje. Natomiast na pinie odpowiadającym zadaniu Task1 jest niemal cały czas stan wysoki. Widoczne są wyłącznie bardzo krótkie okresy stanu niskiego (ok. 2 µs), które pojawiają się podczas realizacji tyknięcia systemowego, ale w tym wypadku przełączenie kontekstu już nie występuje. W ten oto sposób doprowadziliśmy do zagłodzenia jednego z zadań (ang. task starvation). Jest to sytuacja, w której zadanie wskutek nieodpowiedniego doboru priorytetów i nie do końca przemyślanego workflow aplikacji nie będzie się wykonywało. Ten przykład jest bardzo trywialny i trzeba było się postarać, aby do tego doprowadzić, jednak w większych projektach nie zawsze jest to tak oczywiste i niekoniecznie musi wystąpić wyłącznie wskutek złego doboru priorytetów, ale o tym będzie jeszcze w kolejnych wpisach 🙂

Stan zablokowania

Powyższy problem można bardzo łatwo rozwiązać. Każdy z tasków po realizacji swoich zadań powinien zwalniać zasoby dla pozostałych zadań poprzez przejście w stan zablokowania. W tym wypadku nic takiego się nie działo. Wystarczy, że w zadaniu wywołamy funkcję vTaskDelay() i wszystko zacznie działać jak należy. Zadanie wykona to co ma do wykonania, po czym przejdzie na określony czas w stan zablokowania, dzięki czemu drugie zadanie otrzyma zasoby mikrokontrolera. Uniknięcie zagłodzenia innych zadań nie jest jedyną korzyścią płynącą z takiego podejścia. Warto także zauważyć, że dzięki temu zadanie nie marnuje czasu procesora na bezsensowne operacje. Jest ono wykonywane wyłącznie wtedy, gdy rzeczywiście jest coś istotnego do zrobienia.

W tym wypadku przełączenie kontekstu następuje od razu przy przejściu zadania w stan zablokowania. Wobec tego do poprawnego zobrazowania działania systemu nie wystarczy już wcześniejsza metoda, ponieważ scheduler nie czeka z przełączeniem kontekstu na wystąpienie tyknięcia systemowego. Poprzedni sposób był dobrym miejscem do zaprezentowania implementacji funkcji typu Hook, przedstawienia mechanizmu tyknięcia systemowego czy zasady działania algorytmu karuzelowego przy szeregowaniu zadań, ale nie sprawdzi się do śledzenia przebiegu programu w każdej sytuacji.

Makra Trace Hook

Do prawidłowego monitorowania działania systemu wykorzystane mogą być np. makra śledzące (ang. Trace Hook Macros). Na tym etapie nie jest to jednak najistotniejszy temat, dlatego pozwól, że przedstawię go bardzo skrótowo. Na potrzeby zobrazowania przykładów, wykorzystałem makro traceTASK_SWITCHED_OUT(), które jest wywoływane przy przełączaniu kontekstu – w systemowej funkcji vTaskSwitchContext(). Jego definicję można umieścić np. na końcu pliku FreeRTOSConfig.h:

Jak widać, realizowane jest tutaj ustawianie stanu niskiego na pinach odpowiadających poszczególnym zadaniom. GPIO_Pin_7 jest przypisany zadaniu jałowemu, ale o tym będzie jeszcze za chwilę. Poniżej zamieszczam uzyskany przebieg:

Przebieg uzyskany przy zadaniach wprowadzanych w stan zablokowania

Na początku realizowane jest zadanie o najwyższym priorytecie Task1 – ustawiany jest stan wysoki na pinie PA5, po czym natychmiast następuje przełączenie kontekstu. Następnie, wykonywane jest zadanie Task2, ponieważ jest ono aktualnie zadaniem posiadającym najwyższy priorytet z zadań będących w stanie gotowości. Po ustawieniu stanu wysokiego na pinie PA6, zadanie Task2 także przechodzi w stan zablokowania i kontrolę ponownie przejmuje scheduler. Tym razem wybiera zadanie jałowe, ponieważ jest ono aktualnie jedynym zadaniem w stanie gotowości. Jak widać jest ono wykonywane przez większość czasu. Kolejne przełączenie kontekstu następuje dopiero po upłynięciu określonego czasu i odblokowaniu zadania o wyższym priorytecie. Więcej na temat zadania jałowego pisałem w poprzednim artykule. Wspomniałem tam także o funkcji vApplicationIdleHook(). Sposób jej implementacji jest analogiczny do poznanej w tym wpisie funkcji vApplicationTickHook(). W tym przykładzie, na potrzeby stworzenia wizualnego odzwierciedlenia pracy systemu, ustawiam w niej stan wysoki na pinie PA7. Aby funkcja była wywoływana, należy jeszcze pamiętać o ustawieniu makra configUSE_IDLE_HOOK na wartość 1.

Symulowanie długotrwałych operacji

Na wyżej przedstawionej grafice, przy zadaniach Task1 i Task2 widać tylko bardzo wąskie szpilki i bez znacznego przybliżenia przebiegu ciężko stwierdzić, które zadanie jest realizowane jako pierwsze. Nie można też zaobserwować mignięcia diod LED, ponieważ dane zadanie wykonuje się wyłącznie przez ułamek sekundy. Dlatego dla celów wizualnych, w zadaniu można wprowadzić drobne opóźnienie symulujące wykonywanie długotrwałej operacji:

Poniżej przedstawiono zaktualizowany przebieg:

Przebieg z wprowadzonymi opóźnieniami

Wiesz skąd biorą się i co oznaczają szpilki w przebiegu zadania jałowego? Pozostawiam to do przemyślenia 😉 W razie wątpliwości śmiało pytaj w komentarzu lub na grupie facebookowej. Poniżej zamieszczam jeszcze całą zawartość pliku main.c:

Podsumowanie

Dochodzimy do końca artykułu. Minęło sporo czasu od ostatniego wpisu, przez co pisało mi się go trochę trudniej niż poprzednie, ale skoro dotarłeś aż tutaj to chyba nie wyszedł tak źle 😉 Jeśli po poprzednim wpisie występowały jakieś wątpliwości to wierzę, że przedstawione tutaj dodatkowe przykłady pomogły je rozwiać. Przy okazji starałem się przemycić trochę dodatkowych, ciekawych informacji. Mam nadzieję, że nawet jeśli po poprzednim artykule wszystko było zrozumiałe to dowiedziałeś się kilku nowych rzeczy 🙂 Możliwych scenariuszy działania systemu jest wiele i takie przykłady można byłoby mnożyć w nieskończoność. Z tego powodu celowo, mimo, że nie jest to temat podstawowy, pokazałem jeden z możliwych sposobów śledzenia sekwencji realizowanych zadań, abyś mógł samodzielne eksperymentować. Bardzo zachęcam do dodawania zadań, zmian priorytetów i sprawdzania jaki będzie efekt. Istnieją bardziej profesjonalne, płatne narzędzia do monitorowania pracy systemu, ale przedstawione w artykule podejście powinno w większości sytuacji wystarczyć 😉