#025 - RTOS vs callbacki, część 1 - teoria
RTOS vs callbacki
Callbacki w języku C to jeden z kluczowych mechanizmów dzięki któremu można zapewnić reużywalność bibliotek.
W dużym skrócie (ponieważ ten wpis nie tłumaczy podstaw tylko bardziej wysokopoziomowy problem) - dzięki temu możemy przekazać do uniwersalnego modułu funkcję z logiką aplikacyjną konkretnej aplikacji do wywołania. Dla mnie najlepszy przykład dla wykorzystania callbacków to biblioteka do obsługi przycisków. W module przycisków zamknięta jest cała logika związana z debouncingiem, wykrywaniem eventów itp - taki moduł nie powinien mieć w sobie żadnej wiedzy aplikacyjnej - jednak potrzeba jakoś wywołać reakcję na konkretne zdarzenia. Wtedy taka biblioteka może podczas inicjalizacji przyjmować kilka wskaźników do funkcji dla konkretnych eventów i je uruchamiać po ich wykryciu.
Podczas używania modułów z callbackami zazwyczaj można wyróżnić 3 składowe elementy:
- funkcja która będzie callbackiem
- zazwyczaj to biblioteka definiuje jakiego typu ma to być funkcja - jakie parametry ma przyjmować i/lub co też musi zwracać
- metoda rejestracji callbacka; najczęściej spotykane:
- podczas inicjalizacji modułu
- podczas runtime - przez funkcję do rejestracji (wtedy też zazwyczaj można podmieniać w czasie pracy na różne callbacki)
- metoda z “#define” nazw funkcji - ja to nazywam “przesłanianiem nazw”, ale osobiście jej nie używam, bo zdecydowanie zmniejsza czytelność
- w bibliotece z zależności od jej logiki znajdzie się gdzieś wywołanie tego callbacka po wykryciu “eventu”
Artykuł Jacob’a Beningo na temat callbacków - (to z niego jest powyższy obrazek): https://www.beningo.com/embedded-basics-callback-functions/ (generalnie bardzo polecam zapoznanie się z pracą Jacob’a).
I właśnie z tym wywołaniem może być pewien problem do przemyślenia jeśli jest wykorzystywany RTOS.
Na czym polega potencjalny problem?
Załóżmy “standardowe” podejście MAIN+ISR i bibliotekę przycisków.
Działanie aplikacji będzie wyglądać mniej więcej tak:
Nie ma tutaj niczego dziwnego - klasyczne podejście spotykane w wielu bibliotekach - w głównym loopie sprawdzamy eventy z poszczególnych bibliotek. Ewentualnie jeśli w urządzeniu jest zastosowany jakiś prosty scheduler - funkcje z biblioteki przekazujemy do niego, a czasem w ten sposób można pozbyć się też funkcji sprawdzającej z poziomu ISR i przerzucić to też do schedulera.
Teraz odpowiednik tego, ale z wykorzystaniem RTOSa - tak samo używamy “zwykłego” callbacka, ale wszystko przeniesione do kontekstu oddzielnego taska.
Powiedziałbym, że często taka forma do bibliotek będzie widoczna u osób, które dopiero przechodzą na RTOSa - czyli w prosty sposób obudowanie starej biblioteki z podejścia “main+ISR” na podejście z RTOSem.
Więc gdzie jest ukryty problem? Na diagramach oznaczone są konteksty - gdzie w MAIN+ISR będzie to tak naprawdę wspólny stos, natomiast w RTOSie task “buttons” uruchomi w swoim kontekście funkcję użytkownika korzystając ze swojego przypisanego stosu - o ile bardzo łatwo jest wyciągnąć informacje ile stosu (w gcc dodatkowa flaga -Wstack-usage wygeneruje komplet informacji do przeparsowania, a w CubeIDE dla STM32 jest narzędzie do analizy danych z tych plików i wizualizacji) potrzeba na funkcję danego taska o tyle w przypadku callbacków już nie jest to takie proste.
Czyli w przypadku “MAIN+ISR” używamy “aplikacyjnego/wspólnego” stosu, natomiast w wersji implementacji z wykorzystaniem RTOSa - callback użytkownika jest wołany z poziomu taska przycisków - czyli zmienne lokalne tego callbacka i wszystkich funkcji które będzie on potem wołał będą używać jego stosu.
Jakie to podejście ma wady:
- na użytkownika przerzucono odpowiedzialność zadbania o to, aby wystarczyło miejsca na stosie w tym tasku
- potencjalne źródło problemu ze stackoverflow dla tego taska
- inną kwestią wartą też poruszenia jest czas wykonywania tego callbacka - zbyt długi może spowodować przesunięcie uruchomienia kolejnego “ticku” taska do obsługi przycisków - to też wtedy przerzucamy na użytkownika
Biblioteki powinny ułatwiać pracę, więc spychanie odpowiedzialności na użytkownika w tego typu kwestiach powinno być ostatecznością. Dodatkowo na tym etapie wiemy już z jakiego typu “klasą problemu” będzie trzeba się zmierzyć jeśli parametry będą źle ustawione - problemy z przepełnieniem się stosu - dość ciężkie do zdebugowania (choć w fazie dev w RTOSach polecam mieć zawsze włączony mechanizm detekcji).
W jaki sposób można to zrobić lepiej przy zachowaniu zbliżonej funkcjonalności?
Zmiana podejścia z callbacków na np. “kolejkę komend” wychodzącą z modułu - czyli wyniki eventów (to co w callbacku byłoby parametrami funkcji) obudować w strukturę i przesyłać kolejką - udostępniając interfejs do odebrania danych z tej kolejki (alternatywne rozwiązanie - można też przekazywać obiekt kolejki do użytkownika, aby sam to obudował). W sumie przypomina to trochę użycie patternu “active object” - gdzie interfejs wejściowy/komendy do modułu ubieramy w kolejkę - tutaj tak samo zrobimy w przypadku danych wyjściowych.
“wady” takiego rozwiązania (tzn. osobiście dla mnie te “wady” mają dość mały priorytet skoro zwiększamy dzięki temu reużywalność):
- zwiększone zużycie zasobów
- zasoby na kolejkę z komendami
- zasoby na task użytkownika z poziomu którego eventy będą przetwarzane
- co za tym idzie - trzeba napisać ten task itp - wcześniej wystarczyło zaimplementować tylko funkcję callbacka
Co zyskujemy:
- nie trzeba uwzględniać wywołania callbacka przy wyliczaniu rozmiaru stosu dla taska!
- czyli worst case dla użycia stosu biblioteki button będzie stały - nie będzie już zależał od tego poda użytkownik
- użytkownik może wprost wykonywać z poziomu swojego taska operacje o większym czasie wykonywania - bez wpływu na interwał pomiarowy biblioteki button
- dodatkowo “nie zgubimy” eventów przycisków w takiej sytuacji - kolejka może przechować kilka eventów (można wystawić interfejs do ustawiania jej długości) - wcześniej taki mechanizm użytkownik musiałby dopisać sam - potencjalny imporvement
Przykład implementacji - w kolejnym wpisie, będzie on modyfikacją biblioteki z postu: https://www.embedownik.pl/posts/012.html, która aktualnie to na użytkownika przerzuca dbanie o rozmiar stosu.
Podobny problem można napotkać przy implementacji np. systemu logowania - jeśli w urządzeniu występuje kilkanaście tasków i każdy będzie coś logował ze swojego kontekstu - należy w każdym tasku zwiększyć rozmiar stosu tak, aby umożliwić wywołanie funkcji typu snprinf itp (zakładam, że biblioteki do logów wołają je pod spodem do sformatowania/ustandaryzowania linijki logu) + zapewne jakiś bufor lokalny na sformatowany text (żeby zapewnić, że funkcje są reentrant) - zmarnujemy wtedy bardzo dużo pamięci RAM (chyba, że mamy jest sporo i nie trzeba się tym przejmować). Do obsługi logowania powstanie osobna biblioteka z osobnym wpisem ze szczegółami jak do tego podejść.