Wyjątki C++ na przykładzie systemu ISIX-RTOS

Jestem zwolennikiem stosowania języka C++ do pisania oprogramowania na mikrokontrolery. Jego umiejętne stosowanie nie powoduje zwiększenia kodu wynikowego, a ścisłe przestrzeganie typów prowadzi do powstawania bardziej niezawodnych i bezpiecznych programów. W niewielkich mikrokontrolerach niskobudżetowych posiadających 32…64 kB pamięci Flash zazwyczaj pomija się zaawansowane mechanizmy prowadzące do zwiększenia zapotrzebowania na pamięć takie jak wyjątki, czy RTTI (Run Time Type Information), tak aby minimalizować zajętość pamięci Flash oraz RAM. Pisząc oprogramowanie dla większych mikrokontrolerów jak np. connectivity line STM32F107 czy performance line (STM32F2/STM32F4), dysponujących pamięciami Flash i SRAM o dużej pojemności możemy pokusić się o wykorzystanie dodatkowych funkcjonalności języka. Jednym z takich mechanizmów pozwalających zwiększyć niezawodność kosztem nieco większej zajętości pamięci jest użycie mechanizmu wyjątków. W artykule pokażemy w jaki sposób wykorzystywać wyjątki C++, w systemie ISIX-RTOS oraz zbadamy ich wydajność.

Obsługa sytuacji wyjątkowych w C

Wyjątki są mechanizmem pozwalającym obsłużyć różne nietypowe sytuacje, które zdarzają się stosunkowo rzadko i są sytuacjami nadzwyczajnymi. Zazwyczaj są to błędy różnej kategorii, z którymi mamy do czynienia podczas działania programu, jak na przykład błąd działania urządzenia, błąd alokacji zasobów np. pamięci itp. W języku C nie istnieje żaden specjalizowany mechanizm obsługi sytuacji wyjątkowych, a do ich obsługi najczęściej wykorzystywana jest wartość zwracana przez funkcję, która jest porównywana z pewnymi wartościami specjalnymi, reprezentującymi błąd. Typowym przykładem jest tutaj alokacja pamięci, która w programie napisanym w C wygląda tak:

Funkcja malloc, w przypadku niepowodzenia zwraca wartość specjalną NULL, która jest informacją o błędzie przydziału pamięci. Programista musi pamiętać aby wraz z każdym wywołaniem malloc(), zadbać o sprawdzenie czy alokacja powiodła się. Jeżeli w programie występuje wiele takich alokacji, (co jest typowym przypadkiem), to mało komu wystarczy cierpliwości aby wszędzie nieprawidłowe alokację obsłużyć, i jeszcze na dodatek w żadnym miejscu programu nie zapomnieć o sprawdzeniu. Brak sprawdzenia w najlepszym przypadku w systemach operacyjnych z ochroną pamięci doprowadzi do błędu Segmentation Fault”. Przydział pamięci jest najprostszym przypadkiem, spójrzmy na typową sytuację przykładu kodu inicjalizującego układy peryferyjne, gdy jeden zasób potrzebny jest do działania drugiego:

Do prawidłowego działania programu konieczna jest poprawna inicjalizacja wszystkich zasobów, bez których nie może działać poprawnie. W przypadku, gdy nie uda nam się prawidłowo zainicjalizować, jednego z zasobów musimy pamiętać aby zwolnić wcześniejsze zasoby które zostały prawidłowo przydzielone. Widzimy również cała masę sprawdzeń warunków, w każdym etapie wywołania, które też zajmują nie mało czasu procesora, jeżeli spojrzymy na to, że muszą być wykonywane wielokrotnie, praktycznie na każdym etapie wywołania. W tak zawiłym kodzie istnieje tutaj również wiele możliwości popełnienia błędu. Wystarczy jednokrotne pominięcie sprawdzenia warunku, w dowolnym miejscu i cała logika obsługi błędów przestaje funkcjonować prawidłowo. Jest to jeden z klasycznych przykładów obalających teorie zwolenników C twierdzących, że programy w C wykonują się bardzo szybko i są proste i czytelne.

Obsługa sytuacji wyjątkowych w C++

Język C++ posiada dedykowany mechanizm obsługi wyjątków stanowiący integralną część języka. Jest on pozbawiony wcześniej wspomnianych wad ręcznej obsługi sytuacji wyjątkowych. Do największych zalet należy tutaj brak konieczności ciągłego sprawdzania rezultatów, brak możliwości zignorowania zgłoszonego wyjątku ( poprzez nie sprawdzenie zwróconego kodu błędu), oraz co najważniejsze automatyczne zwalnianie zasobów przez destruktory obiektów w momencie propagacji wyjątku. Wszystkie te czynniki powodują, że kod staje się znacznie bardziej czytelny, oraz bardziej odporny na błędy. Wyjątek stanowi obiekt, który może dowolnego typu a w szczególności może być typem prostym POD jak typ int. Możemy również zbudować hierarchię klas wyjątków, czy skorzystać ze standardowej hierarchii klas wyjątków zawartych w pliku nagłówkowym <exception> oraz <stdexcept>. Wyjątki w C++, obsługiwane są przez słowa kluczowe: try, catch, throw. Mechanizm ich działania polega na tym, że kod w przypadku wystąpienia błędu zamiast zwracać rezultat funkcji informujący o błędzie rzuca wyjątek za pomocą wywołania throw exception(), gdzie exception() jest to klasa wyjątku, który będzie rzucony (najwygodniej jest w tym miejscu używać obiektów tymczasowych). Natomiast w innej części programu fragment, który może potencjalnie rzucić wyjątek zostaje umieszczony w sekcji try, po której następują klauzule przechwytywania poszczególnych klas wyjątków. Ostatnia klauzula catch( … ) powoduje przechwycenie wszystkich pozostałych wyjątków, ale bez możliwości dostępu do obiektu wyjątku:

Działanie mechanizmu wyjątków polega na tym że, rzucony wyjątek zwija stos bieżącej funkcji, niszcząc wszystkie zmienne lokalne, włącznie z wywołaniem ich destruktorów jeżeli zmienną lokalną stanowi obiekt klasy a nie typ prosty (np. int), a następnie próbuje skoczyć do najbliższej klauzuli catch, która go obsłuży. Jeżeli w danej funkcji nie występuje klauzula catch, zwijany jest stos kolejnej funkcji która ją wywołała. Dzieję się tak aż do napotkania klauzuli catch, w kolejnej funkcji wywołującej. Jeżeli stanie się tak, iż zwijanie wyjątku dojdzie do funkcji main(), a funkcja ta również nie będzie zawierała klauzuli catch() odpowiadającej danemu wyjątkowi, nastąpi sytuacja którą nazywamy nie przechwyconym wyjątkiem. Niewyłapany wyjątek powoduje natychmiastowe wywołanie funkcji terminate(), która zwyczajowo po wypisaniu na standardowym wyjściu rodzaju wyjątku powoduje oddanie kontroli systemowi operacyjnemu, który kończy program. W przypadku znalezienia odpowiedniej klauzuli catch() dla danego wyjątku, wykonuje się procedura jego obsługi, a po zakończeniu wykonywania kodu klauzuli catch, następuje dalsze wykonanie programu, które jest kontynuowane tuż za sekcjami catch, tak jak gdyby nigdy nic się nie wydarzyło. Naturalnie nie musimy w danej sekcji catch obsługiwać wszystkich rodzajów wyjątków, część z nich może być obsłużona w jednej funkcji, natomiast część w zupełnie innej. Dzięki temu, że w momencie wywołania wyjątku zwijany jest stos, oraz wywoływane są destruktory obiektów lokalnych, budując odpowiednio obiekty klas nie będzie potrzeby ręcznego zarządzania zasobami jak w przypadku języka C, a wszystko będzie odbywać się automatycznie. Spójrzmy jeszcze raz na przykład alokacji pamięci, gdzie w przypadku języka C trzeba było zarówno sprawdzić poprawność przydziału pamięci przez malloc(), jak i zadbać o odpowiednią inicjalizację struktury. W przypadku C++ do alokacji pamięci wykorzystujemy operator new, gdzie przykładowa alokacja z poprzedniego przykładu może wyglądać tak:

porównując to kodem z początku poprzedniego punktu możemy zauważyć, iż jest dużo prostszy i bardziej czytelny. Jeżeli objekt MyObject, będzie klasą posiadającą konstruktor, zostanie on automatycznie wywołany, co spowoduje wykonanie czynności początkowych zdefiniowanych w konstruktorze. Również standardowy operator new nie zwraca NULL a zgłasza wyjątek std::bad_alloc, którego nie musimy przechwytywać osobno w każdej funkcji. Wystarczy jedynie użycie klauzuli catch(std::exception &e) w funkcji main(), co powoduje znaczne zwiększenie czytelności kodu oraz redukuje konieczność ciągłego sprawdzania rezultatów funkcji, na każdym etapie wywołania. Należy tutaj przestrzec początkujących programistów przed nadmiernym używaniem systemu wyjątków i wykorzystaniem go nie do zgłaszania sytuacji nadzwyczajnych (wystąpienie błędu), a jako systemu przekazywania wartości. Zgłaszanie wyjątków jest stosunkowo czasochłonnym procesem z uwagi na wykonywanie czynności związanych ze zmianą przebiegu wykonania programu. Jednak umiejętne ich używanie, powoduje wzrost ogólnej wydajności programu, ponieważ podczas „prawidłowego” (bezbłędnego), przebiegu nie musimy wykonywać ciągłych i wielokrotnych sprawdzeń wartości.

W języku C++ istnieje kilka predefiniowanych klas wyjątków tak jak wspomniany wcześniej std::bad_alloc, które mogą być zgłaszane przez bibliotekę standardową. Klasą bazową dla wszystkich wyjątków jest tutaj klasa std::exception, której deklaracja wygląda następująco:

Najistotniejsza jest tutaj metoda wirtualna what(), która zwraca łańcuch tekstowy zawierający opis błędu. Wszystkie klasy wyjątków rzucane przez bibliotekę standardową jako klasę bazową wykorzystują exception, a zatem klauzula catch( exception &e) powoduje przechwycenie wszystkich wyjątków, klas pochodnych które dziedziczą z tej klasy. Pozostałe wyjątki które mogą być zgłaszane przez bibliotekę standardową C++, podzielono na błędy logiczne, wywodzące się z klasy bazowej logic_error (tabela 1), oraz błędy wykonania wywodzące się z klasy runtime_error (tabela 2).

 

Tab. 1. Błędy logiczne sygnalizowane wyjątkami

Klasa wyjątku Opis
logic_error Klasa bazowa dla pozostałych błędów logicznych wywodząca się z exception.
domain_error Liczba poza dziedziną.
invalid_argument Błędny argument przekazany do funkcji.
length_error Błąd rozmiaru. Np przy próbie zmiany rozmiaru wektora.
out_of range Wartość poza zakresem.

 

Tab. 2. Błędy wykonania sygnalizowane wyjątkami

Klasa wyjątku Opis
runtime_error Klasa bazowa dla pozostałych błędów wykonania wywodząca się z exception.
range_error Błąd zakresu.
overflow_error Błąd przepełnienia.
underflow_error Błąd niedomiaru.

 

Oczywiście każdy z tych wyjątków może być zgłaszany nie tylko przez bibliotekę standardową, ale także przez kod użytkownika. Bazując na powyższych klasach, hierarchia wyjątków może być rozbudowywana w razie potrzeb przez użytkownika.

Przykład praktyczny: obsługa wyjątków w ISIX-RTOS

Po zapoznaniu się z podstawowymi wiadomościami teoretycznymi na temat wyjątków w C++ , pokażemy, że mogą być stosowane z powodzeniem również w przypadku nieco większych mikrokontrolerów. Pozwalając na znaczące podniesienie niezawodności działania programu, oraz przy stosowaniu z umiarem zapewniają również podniesienie ogólnej wydajności aplikacji.

Posłużmy się teraz prostym przykładem (platforma STM32Butterfly), w którym jeden wątek będzie odpowiedzialny za mruganie diodą D1, i sygnalizował będzie jedynie prawidłowe działanie systemu operacyjnego. Natomiast drugi wątek, będzie zmieniał stan diody D2 na przeciwny w przypadku wciśnięcia klawisza joysticka OK. W przypadku wciśnięcia klawisza DOWN, lub UP, zgłaszany będzie wyjątek, który będzie symulował wystąpienie, błędu. Dodatkowo na linii PE0 wystawiany będzie stan 1 przez czas od momentu zgłoszenia wyjątku, do jego przechwycenia co pozwoli na zbadanie ogólnej wydajności obsługi wyjątków w GCC na architekturze CORTEX-M3. Kod programu przedstawiono na poniższym listingu:

Klasa led_blink odpowiedzialna jest za cykliczne mruganie diodą D1, które realizowane jest w pętli głównej wątku led_blink::main(). Mruganie diodą informuje nas o prawidłowym działaniu systemu operacyjnego, i nie wymaga dalszego komentarza. Główna demonstracja mechanizmu wyjątków realizowana jest w klasie ledkey. W konstruktorze klasy led_blink porty PE.0 oraz PE.15 odpowiedzialne odpowiednio za pin testowy dla badania czasu wykonania wyjątku oraz sterowanie diodą LED, ustawiane są w kierunku wyjścia. Po wykonaniu konstruktorów, system operacyjny w osobnym wątku wykonuje metodę ledkey::main(). W sekcji try zawarto pętlę nieskończona main(), która wywołuje metodę ledkey::execute_keycheck(), natomiast w sekcji przechwytywania wyjątków catch będziemy przechwytywać wyjątek typu podstawowego int oraz wspomniany wcześniej wyjątek klasy std::logic_error(). W momencie zgłoszenia jednego z tych wyjątków przez metodę ledkey::execute_keycheck(), wykonanie pętli nieskończonej zostanie przerwane, poprzez przejście do sekcji catch jednego z wyjątków. Efektem tego będzie wypisanie na porcie szeregowym komunikatu o rodzaju wyjątku, a następnie przejście poza sekcję catch, co spowoduje zakończenie wykonania tego wątku. A zatem program przestanie reagować na wciskanie klawisza OK. W cyklicznie wywoływanej metodzie ledkey::execute_keycheck(), sprawdzany jest stan klawisza OK. W przypadku wykrycia wciśnięcia tego klawisza stan diody zmieniany jest na przeciwny. Symulacja wystąpienia sytuacji wyjątkowych, realizowana jest po wciśnięciu klawisza UP lub DOWN. W przypadku wciśnięcia klawisza UP, zgłaszany jest wyjątek typu int o wartości -1. W przypadku wciśnięciu klawisza DOWN zgłaszany jest wyjątek klasy std::logic_error. Tuż przed zgłoszeniem wyjątków linia PE0, ustawiana jest w stan wysoki. Wciśnięcie odpowiedniego klawisza i zgłoszenie wyjątku, spowoduje przejście do odpowiedniej sekcji catch, w której zerowany jest stan linii PE0, oraz wypisywana albo wartość wyjątku int (-1), lub opis tekstowy wyjątku który zwróci metoda what().

Przyjrzyjmy się teraz zasobom pamięci Flash zajmowanym przez mechanizm obsługi wyjątków. W tym celu opisany wcześniej przykład najpierw pozbawimy obsługi wyjątków realizowanych przez klasę (std::logic_error), a zostawimy zgłaszanie wyjątku typu prostego int. Kolejną czynnością będzie całkowite wyłączenie kodu obsługi wyjątków w tym również zmianę w pliku Makefile zmiennej CPP_EXCEPTIONS=n wyłączającej obsługę wyjątków w ISIX, a następnie sprawdzenie rozmiaru zajmowanej pamięci, co pozwoli mieć orientacyjny pogląd na to ile zajmuje dodatkowy kod obsługi wyjątków. Wyniki przedstawionych badań przedstawiają się w sposób pokazany w tabeli 3.

 

Tab. 3. Wymagane pojemności pamięci Flash w zalezności od zastosowanych mechanizmów obsługi wyjątków

Opis Pojemność Flash
Kompletny przykład z wyjątkami std::logic_error oraz int ~35 kB
Przykład z obsługą wyłącznie wyjątków POD typu int ~19 kB
Przykład bez obsługi wyjątków ~7 kB

 

Jak łatwo możemy zauważyć sam kod odpowiedzialny za podstawową obsługę wyjątków zajmuje około 12 kB pamięci Flash. W przypadku większych mikrokontrolerów rodziny connectivity line, czy performance line jest to wielkość pomijalna, ponieważ jest to koszt całkowity użycia wyjątków niezależnie od tego ile razy później będą użyte w programie. Dodatkowo 16 kB będzie użyte w przypadku, gdy wykorzystamy standardowe wyjątki z hierarchii wyjątków biblioteki STD, co jest związane głównie z dołączeniem do kodu klasy std::string odpowiedzialnej za łańcuchy tekstowe. Jednak w przypadku większych mikrokontrolerów jest to koszt całkowicie do zaakceptowania. Spójrzmy teraz na czas jaki upływa od momentu zgłoszenia wyjątku do jego przechwycenia w klauzuli catch(…) – na rysunkach 1 oraz 2 przedstawiono odpowiednio oscylogramy na linii PE0 reprezentujące czas przechwycenia wyjątku typu int oraz wyjątku klasy std::logic_error().

 

Rys. 1. Czas obsługi wyjątku typu int

Rys. 1. Czas obsługi wyjątku typu int

 

Rys. 2. Czas obsługi wyjątku klasy std::logic_error()

Rys. 2. Czas obsługi wyjątku klasy std::logic_error()

 

Czas jaki upłynął od momentu zgłoszenia wyjątku do jego przechwycenia, dla typu wyjątku int wynosi około 107 µs. Czas od momentu zgłoszenia wyjątku do jego przechwycenia dla typu std::logic_error() wynosi 151 µs przy taktowaniu mikrokontrolera częstotliwością 72 MHz. Zdaniem autora uzyskane czasy są całkiem zadowalające, ponieważ jak już wcześniej wspomnieliśmy, mechanizm wyjątków powinien być używany tylko do zgłaszania sytuacji wyjątkowych jak na przykład błędy. Należy także pamiętać o tym, że wykonanie programu, gdy przebiega on zgodnie ze ścieżką podstawową (bez wystąpienia wyjątku [błędu]), jest bardziej wydajne niż w przypadku ręcznej obsługi wyjątków poprzez wartości zwracane jak w C, ponieważ nie ma konieczności aby w każdej z warstw sprawdzać rezultat błędu. Warto tutaj wspomnieć jeszcze o zasobach pamięci RAM potrzebnych do obsługi wyjątków. Praktyczne próby pokazały że minimalna wartość stosu dla wątku, jeżeli chcemy wykorzystywać w nim wyjątki powinna wynosić około 1 kB.

Zaprezentowany przykład obsługi wyjątków dostępny jest wraz z pozostałymi przykładami dla systemu ISIX-RTOS na stronie: http://bryndza.boff.pl/index.php?dz=rozne&id=isixrtos a najświeższe przykłady można pobrać z repozytorium mercurial za pomocą polecenia: hg clone http://ww.boff.pl/hg/isix/isix_samples.

Lucjan Bryndza

Autor: varhyid@o2.pl