O co chodzi?

Mały wpis, który powstał na początku zabawy z RTOSami - więc już jakiś czas temu - odnośnie tego, czy “blokujące” drivery to coś co nie powinno się znaleźć w żadnym projekcie, czy jednak przy zmierzeniu zależności czasowych w systemie możemy z tego korzystać.

Przejście na programowanie z wykorzystaniem RTOSa względem nazwijmy to “klasycznego mikrokontrolerowego podejścia” wymaga bardzo dużej i co najważniejsze świadomej zmiany stylu programowania, nawyków, niestety wyzbycia się starych bibliotek -
zazwyczaj trzeba je od nowa przemyśleć itp.

Standardowym tematem jest to, żeby biblioteki dla mikrokontrolerów nie były blokujące - zapewne każdy zetknął z tym stwierdzeniem/opracował swoją metodę nieblokującego działania choćby w postaci używania mechanizmu timerów, czy też namiastki prostego schedulera.

Luźne spostrzeżenie na temat podejścia do RTOSów - trzeba się wyzbyć myślenia, że wszystko ma być jak najszybciej itp - wszystko ma być w czasie rzeczywistym, a dokładna wartość wynika z wymagań projektu! Idzie to w parze ze stwierdzeniem “premature optimization is the root of all evil”. W pracy spotkałem się z wieloma programistami z różnymi podejściami i wielu z nich twierdziło, że “coś można zrobić szybciej to tak ma być” tzn w sensie wykonać jakąś operację/obliczenie (oczywiście mówię tu o programistach embedded na uC) - ale na pytanie “a jak szybko to trzeba zrobić” odpowiadali tylko “najszybciej”. Często słyszałem, że jakieś biblioteki są “nieoptymalne”/”za wolne” bez żadnych pomiarów/zdefiniowania na bazie jakich parametrów tak stwierdzili. Niestety pisanie “optymalnych” bibliotek była często wymówką dla nieprzemyślanej architektury co skutkowało brakiem możliwości reużycia kodu w innej aplikacji. Wydaje mi się, że najważniejsza jest świadoma decyzja odnośnie wyboru danego rozwiązania (np podejścia do napisania biblioteki) i zmierzenie zależności czasowych/czasu wykonywania/zużycia innych zasobów jak pamięć i w wielu aspektach należy wybrać kompromisowe rozwiązanie. Przykładem jest np. używanie bibliotek ST HAL vs pisanie na rejestrach. Opracowałem swój własny duży zbiór bibliotek dla mikrokontrolerów ST bez użycia HALa i działają od niego zdecydowanie szybciej, jednak nie są tak przenośne/nie mają konfiguratora jak CubeMX - w projektach w jakich brałem udział okazywało się, że narzut czasowy spowodowany użyciem HALa jest akceptowalny, a w przypadku kiedy projekt musiałbyć przeportowany na mikrokontroler innej rodziny konfigurator bardzo ułatwił sprawę.

Przykładem do omówienia będzie prosta biblioteka do logowania zdarzeń - wyszła ona z moich testów napisania drivera uartu dla FreeRTOSa, gdzie w pewnym momencie okazało się… że mogę spokojnie wysyłać dane uartu w pollingu! I to jest coś czego z 2 godziny wcześniej bym nie przypuszczał i to mnie skłoniło do spisywania tego w formie notatki.

Użyta platforma to płytka nucleo z mikrokontrolerem STM32F070RB puszczonym na 48MHz

Aplikacja demonstracyjna jest bardzo prosta - mamy 2 taski które co 500 i 750 ms logują jakieś zdarzenie systemowe. Logowanie polega na tym, że jest generowany odpowiednio sformatowany string który następnie przesyłany jest kolejką do osobnego tasku odpowiedzialnego za jego wysłanie (jest to więc trzeci task w tej aplikacji).

Tak wygląda implementacja tych tasków:

void vTask1( void * pvParameters )
{
    int number = 0;
    for (;;)
    {
        log(LOG_INFO, "dzien doberek!!!\r\n");
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void vTask2( void * pvParameters )
{
    int number = 0;
    for (;;)
    {
        log(LOG_WARN, "dzien doberek!!!\r\n");
        vTaskDelay(pdMS_TO_TICKS(750));
    }
}

void vTaskUartSender( void * pvParameters )
{
    log_message_t to_send;
    for (;;)
    {
        if( xQueueReceive( xQueue_logSend, &to_send, ( TickType_t ) 100000 ) )
        {
            HAL_UART_Transmit(&huart3, to_send.message, to_send.len, 10000); // przesylanie w pelni blokujace
        }
    }
}

Taski1 i 2 są na takim samym poziomie, natomiast task wysyłający na niższym. Jaki mamy tego rezultat?

Posłużę się narzędziem SEGGER SystemViewer do wizualizacji:

ob1

Więc widzimy jak uruchamiają się oba taski - po czym task wysyłający - blokuje on procka na ponad 15ms!!! Blokuje? Czy jednak “blokuje” - bo przecież jakbyśmy mieli jeszcze wiele tasków z wiekszym priorytetem - to przecież one by się wykonały najpierw. Czy generalnie wysyłanie w pollingu jest generalnie nieoptymalne? Zazwyczaj tak. Czy w tym przypadku jest to mega źle zrobione itp? No ja bym powiedział, że nie. Tym bardziej zobaczmy sobie na obciążenie systemu - UartSender maxymalnie na sekundę zajmował 3,05% zasobów mikrokontrolera. “Taski aplikacyjne” max 0,07%. RTOS sam w sobie ~1,5%. Trzeba się przyzwyczaić do tego typu charakterystyk - mało kto mierzył takie rzeczy w “klasycznych” podejściach do programowania mikrokontrolerów - tam zostało podejście z mierzeniem diodą czasu wykonywania jakiś obliczeń i trzeba było mieć z tyłu głowy przy dokładaniu nowych funkcjonalności, żeby odpowiednio krótko się wykonywały, bo mogłoby to zaburzyć pracę reszty - ewentualnie z pomocą przychodziło wykorzystanie przerwań, ale też przerzucanie większych obliczeń do przerwań to też nie jest idealny pomysł. Tutaj nie ma też żadnego kodu pollingu jeśli chodzi o uruchomienie tasków - jeśli jest on zawieszony, bo czeka na nowe dane - to jest zawieszony i koniec - RTOS o to zadba.

Ot - mała notka, bo jakoś na mnie to zrobiło wrażenie, że wyszło mi małe koło - z robienia wszystkiego optymalnie na przerwaniu do początków nauki i blokującego wysyłania znak po znaku, ale tym razem z RTOSem za plecami. Oczywiście uruchomienie wysyłania w wersji na przerwaniu to teraz tylko podmiana jednej funkcji - odzyska się jeszcze więcej czasu procesora na inne rzeczy i też zdaję sobie sprawę, że w jakiś wymagających aplikacjach mogłoby być tak dużo tasków, że ten od logowania by się wcale nie wysłał w ten sposób/gubił dane (w zależności od ustawienia kolejki/zasobów na nią przeznaczonych) - no ale aktualnie to jest tylko logowanie! + nauka i zabawa nowym podejściem i narzędziami do niego, które mogą znacznie przyspieszyć/ułatwić pracę/weryfikację swojej pracy. Ogólnie logowanie rzeczy to jest też coś czego w kolorowych książkach nie ma, a mam nadzieję, że temat wygląda na ciekawy i widać, że od strony “obliczeniowej” i tak nam starczy czasu na funkcje typowo aplikacyjne.

Wcześniej wspomniana “biblioteka” do logów wyświetla je w mniej więcej takiej postaci:

ob2

Tak przygotowane logi można przeparsować programem typu “LogViewPlus”.

Może jednak przerwania?

Wpis miał się już zakonczyć, ale dla testów zmieniłem funkcję wysyłającą na wersję z przerwaniami:

void vTaskUartSender( void * pvParameters )
{
    log_message_t to_send;
    for (;;)
    {
        if( xQueueReceive( xQueue_logSend, &to_send, ( TickType_t ) 100000 ) )
        {
            while(HAL_UART_Transmit_IT(&huart3, to_send.message, to_send.len) == HAL_BUSY)
            {
                vTaskDelay(pdMS_TO_TICKS(10));
            }
        }
    }
}

Jeśli nie można aktualnie nic wysłać to task oczekuje w pętli na zwolnienie się do uartu. W sumie przy okazji powstał mechanizm, który powoduje, że wiele tasków na różnych priorytetach mogą raportować swoje zdarzenia, a dzięki kolejce nie stracimy kolejności wiadomości.

Więc tak - oprócz zmiany tej funkcji dodałem jeszcze podgląd/raportowanie w handlerze przerwania uartu, żeby mieć całkowity podgląd na zajętość zasobów mikrokontrolera.

ob3

Widzimy znów moment, kiedy wykonują się oba taski i poprzez umieszczenie wiadomości w kolejce odblokowują task “UartSender” - przerwania od uartu wykonują się co przesłanie znaku. Na diagramie czasowym wyraźnie widać różnicę zajętości MCU - wcześniej sam wątek przesyłania zajmował 3,05%, teraz to ~0,05% - oczywiście jego obsługa przeszła do poziomu przerwania i znów wiemy ile to dokładnie zajmuje - 0,53% czyli łącznie ~0,6% vs 3,05% - czyli moja zmiana spowodowała, że potrzebuje 5x mniej czasu na obsługę przesłania wiadomości. Oczywiście do tego dochodzi też narzut biblioteki Seggera w każdym przerwaniu i biblioteki HAL, która dość dużo robi w przerwaniu.

Jeszcze oddalę trochę diagram, żeby zobaczyć dalszą część przesyłanej wiadomości:

ob5

Luka między jedną wiadomością, a drugą jest spowodowana przez vTaskDelay 10ms.

Czyli jeszcze raz podsumowując - mamy “na papierze”, że to rozwiązanie jest ~5x lepsze jeśli chodzi o czas zajętości procka - ale - oczywiście o ile nie zależy nam jakoś super mega na czymś o większym priorytecie tasku - wtedy do niego trzeba będzie doliczyć ten czas przesyłania z przerwania - wszystko trzeba dobrać do założeń aplikacji.

W tym przypadku nakład pracy był minimalny - zamiana funkcji z wersji blokującej na wersję z użyciam przerwań i dodanie mechanizmu oczekiwania na zwolnienie UARTu, jednak gdyby pozostałe taski aplikacyjne wykorzystywały moc obliczeniową mikrokontrolera w mniej niż 90% rozwiązanie pollingowe byłoby akceptowalne.

Generalnie nawet prosta biblioteka do logów sama w sobie to temat na kolejne artykuły bo na jej przykładzie można przedstawić dużo różnych koncepcji/decyzji architektonicznych jakie trzeba podejmować w projektach wykorzystującyh RTOS. Mały spoiler - aktualnie duża “wada” jest taka, że każdy task potrzebuje na swoim stosie miejsce na wywołanie funkcji printf - jak sobie z tym poradzić i jakie to wszystko ma konsekwencje - omówię w przyszłości.

Podsumowanie

Jest to przeredagowana notatka i chyba trochę chaotycznie wyszło jak na pierwszy wpis - więc podsumowanie przekazu w pigułce:

  • wyznaczajmy kryteria akceptacyjne dla modułów w aplikacji
  • każdy driver itp można zrealizować na mnóstwo sposobów - bez kryteriów nie wiemy kiedy jest “wystarczająco dobry”/co poprawić/kiedy przestać go optymalizować
  • nie zawsze blokujące mechanizmy w taskach RTOSa to zło - RTOS zapewnia mechanizmy jak priorytety/wywłaszczanie, które można wykorzystać - ważne żeby było to świadome
  • polecam wizualizować/mierzyć zależności czasowe w systemie - bardzo ułatwia to pracę