Wskazówki jak za pomocą abstrakcyjnych typów danych uzyskać modułowy kod odporny na jego błędne użycie.

Książki takie jak „Czysty kod. Podręcznik dobrego programisty” autorstwa Roberta C. Martin’a przekonują nas, że pisanie kodu podzielonego na małe zamknięte moduły jest bardzo ważne. Zgadzam się z tym jak najbardziej. Małe moduły są łatwiejsze w utrzymaniu, testowaniu i ponownym wykorzystywaniu. Jednocześnie podzielam obserwację Jacoba Beningo, autora „Reusable Firmware Development”. Jacob jako konsultant miał wgląd w kod pisany przez wiele różnych firm. Niestety często trafiał na kod składający się z ogromnych modułów odpowiedzialnych za robienie mnóstwa rzeczy i zależnych od kolejnych ogromnych modułów. W skrajnych przypadkach zdarzało mu się widzieć całe komercyjne projekty napisane w main.c. Jacob podkreśla, że w naszej branży często mamy mało czasu na rozwój kodu i tym bardziej powinniśmy zwrócić uwagę na tworzenie modułowego kodu. Takie podejście pozwoliłoby nam zaoszczędzić czas i wykorzystywać raz napisany i przetestowany moduł w wielu projektach.

Z jakiegoś powodu jednak postanawiamy porzucić dobre praktyki i tworzyć chaos. Czy to z braku wiedzy? Czy może wolimy odpuścić długoterminową inwestycję w czysty kod na rzecz szybkiego nadgonienia terminów? Może z obu powodów? Zostawiam to pytanie do przemyślenia. Ja z mojej strony mogę pokazać Ci korzyści wynikające z pewnych mało popularnych właściwości bardzo popularnego języka, jakim jest C.

obiektowość w C?

Język C nie kojarzy się z obiektowością i modułowym kodem. Gdy mowa o obiektowym kodzie w embedded to raczej na myśl nasuwa się C++. Racja, że C++ jest bardziej rozbudowany, ale samo C wcale nie jest aż tak ograniczone i można w nim tworzyć chociażby struktury z prywatnymi polami. Weźmy na warsztat klasyczny już fragment kodu – bufor cykliczny. Przypuszczam, że chociaż raz musiałeś napisać obsługę takiej struktury danych, więc łatwiej będzie Ci się odnieść do przedstawionych przykładów. Nie będę skupiał się na implementacji funkcji, a raczej na projekcie interfejsu.

wersja podstawowa

Nasz bufor cykliczny musi na pewno posiadać możliwość zapisu i odczytu danych. W końcu na tym polega cała idea tego modułu. Podstawowy interfejs w pliku circ.h mógłby więc wyglądać w ten sposób.

typedef struct
{
    size_t head;
    size_t tail;
    bool full;
    uint8_t *data;
}circBuffer_t;

size_t CIRC_write(circBuffer_t *buffer, uint8_t *src, size_t size);
size_t CIRC_read(circBuffer_t *buffer, uint8_t *dest, size_t size);

Już taki interfejs jest krokiem do przodu względem implementacji, która miesza się w jednym pliku z kodem używającym bufora. Tworząc typ circBuffer_t pozwalamy na tworzenie wielu buforów wykorzystujących wspólne funkcje do ich obsługi. Funkcje CIRC_write i CIRC_read operują tylko i wyłącznie na wskaźnikach na bufory, nie muszą przechowywać dodatkowego stanu. Oto jak możemy korzystać z takiego interfejsu.

uint8_t buffData[10];
circBuffer_t buff = {.head = 0, .tail = 0, .full = false, 
                     .data = buffData};

uint8_t frame[] = {1, 2, 3};
CIRC_write(&buff, frame, sizeof(frame));

Całkiem wygodnie, chociaż widzę tu jeden podstawowy problem. Mianowicie tworzenie instancji circBuffer_t. Zostawia ono spore miejsce na błąd, jeżeli przy inicjalizacji pola head, tail albo flaga full nie będą zerowe to bufor nie będzie działał poprawnie. Widziałem tego typu błędy. Jeżeli można kod zabezpieczyć przed czynnikiem ludzkim to dlaczego mielibyśmy tego nie zrobić.

lepiej

Wystarczy dodać funkcję CIRC_init. Wtedy nasz przykładowy kod wygląda w ten sposób.

uint8_t buffData[10];
circBuffer_t *buff = CIRC_init(buffData, sizeof(buffData));
                      
uint8_t frame[] = {1, 2, 3};
CIRC_write(buff, frame, sizeof(frame));

Dzięki temu obowiązek wypełnienia pól na starcie spada na programistę modułu, a nie użytkownika. Jedno miejsce na błąd mniej, super. Przy okazji, jeżeli wewnątrz modułu implementacja bufora się zmieni i np. flaga full zostanie usunięta to w kodzie używającym tego modułu nie będziemy musieli wprowadzać zmian. Jest dobrze, ale może być lepiej. Nadal zostaje luka, dzięki której ktoś może nasz moduł wykorzystać w zły sposób. Ktoś chociażby może stwierdzić, że potrzebuje informacji o tym ile miejsca zostało w buforze. Mając dostęp do pól head, tail i full może pokusić się o wyliczenie w swoim kodzie wolnego miejsca w używanym buforze.

Są trzy opcje na dalszy rozwój wydarzeń. Albo zrobi to dobrze, albo źle, albo bardzo źle. Nawet jeżeli zrobi to dobrze, to jeżeli w implementacji modułu usunie się pole full to już jego kod przestaje działać. Jeżeli zrobi to źle to będzie błędnie wyliczał wolne miejsca, ponieważ nie rozumie jak bufor działa pod spodem. Jeżeli zrobi to bardzo źle to gdzieś zamiast == napisze = i niechcący przesunie np. head. W tym momencie nieważne jak dobrze mamy zaimplementowany nasz moduł, dalsze operacje na buforze będą niepoprawne. Lepiej się zabezpieczyć przed taką samowolką.

jeszcze lepiej

Kolejnym usprawnieniem będzie ukrycie pól struktury circBuffer_t. Jak takie coś można zrealizować w C? Używając forward declaration. Polega to na tym, że deklarujemy typ danych bez szczegółów jego implementacji. Nie wszędzie w programie kompilator musi znać wszystkie parametry danego typu, czasami wystarczy mu wiedza o tym, że taki typ istnieje. Można to wykorzystać na naszą korzyść.

typedef struct circ circBuffer_t;

size_t CIRC_write(circBuffer_t *buffer, uint8_t *src, size_t size);
size_t CIRC_read(circBuffer_t *buffer, uint8_t *dest, size_t size);
circBuffer_t *CIRC_init(uint8_t *data, size_t size);

Zauważ, że w pliku nagłówkowym nie ma informacji o polach typu circBuffer_t. Dopiero w pliku źródłowym modułu zamieszczamy szczegółową implementację struktury circ.

#include "circ.h"

struct circ
{
    size_t head;
    size_t tail;
    bool full;
    uint8_t *data;
};

Kompilatorowi taki układ nie przeszkadza tak długo, jak poza plikiem circ.c nie potrzebujemy znać rozmiaru typu circBuffer_t, ani próbować dobierać się do jego pól. Te ograniczenia są tym, czego potrzebujemy. Powstaje jednak problem: gdzie alokować pamięć na struktury obsługujące bufory. Poza circ.c nie znamy rozmiaru circBuffer_t, więc kompilator nie pozwoli nam na stworzenie instancji circBuffer_t. Aby nie odbiegać od tematu skrócę wywód o alokacji pamięci do tego, że obowiązek zarządzania pamięcią dla struktur circBuffer_t spada na moduł bufora i musi odbywać się w circ.c. Można użyć malloca, chociaż w embedded pewnie popularniejsza będzie statyczna tablica przechowująca z góry znaną maksymalną ilość buforów. Problem alokacji daje nam pierwszą korzyść. Żaden użytkownik naszego modułu nie może pominąć funkcji CIRC_init i stworzyć sobie własnej instancji z błędnie zainicjalizowanymi polami. Przykładowy kod wygląda tak samo, jak dotychczas.

uint8_t buffData[10];
circBuffer_t *buff = CIRC_init(buffData, sizeof(buffData));
                     
uint8_t frame[] = {1, 2, 3};
CIRC_write(buff, frame, sizeof(frame));

Co zyskaliśmy dzięki wykorzystaniu forward declaration? Operacje przedstawione poniżej są zakazane już na etapie kompilacji.

buff->head = 2;
buff->tail = 3;
buff->head = 4;
uint8_t b = buff->data[3];
buff++;

Kompilator oburzy się, gdy spróbujemy przesunąć wskaźnik na niekompletny typ. Przecież poza circ.c nie wiemy jaki rozmiar ma circBuffer_t, więc o ile bajtów przesunąć wskaźnik? Tak samo kompilator nie pozwala na dereferencję niekompletnego typu, a co za tym idzie dostęp do pól bufora jest niemożliwy spoza pliku circ.c.

example.c:7:9: error: dereferencing pointer to incomplete type ‘circBuffer_t {aka struct circ}’
     buff->head = 2;
         ^~
example.c:11:9: error: increment of pointer to an incomplete type ‘circBuffer_t {aka struct circ}’
     buff++;
         ^~

I o taką ochronę ze strony kompilatora nam chodziło. Nadal niestety można zrobić tak:

circBuffer_t *buff = CIRC_init(buffData, sizeof(buffData));
buff = 123;

czyli nadpisać wskaźnik na bufor powodując, że będzie wskazywał na niepoprawny adres w pamięci. Z tym też można trochę zawalczyć. Oczywiście można polegać na użytkowniku i zakładać, że użyje odpowiednio słowa kluczowego const.

circBuffer_t *const buff = CIRC_init(buffData, sizeof(buffData));
buff = 123;

W ten sposób powstaje stały wskaźnik (nie mylić ze wskaźnikiem na stałą) i operacja przypisania do niego jest zablokowana już na etapie kompilacji.

najlepiej

Oczywiście lepiej nie musieć polegać na użytkowniku i const dodać już do definicji typu. W tym celu nasz typedef musi definiować typ wskaźnikowy zamiast samej struktury. Wygląda to trochę dziwnie.

typedef struct circ *const circBuffer_t;

W tym miejscy polecam stronkę https://cdecl.org/ tłumaczącą deklaracje C na czytelny język angielski. Czasami się przydaje. Powyższa definicja zostaje przetłumaczona w poniższy sposób.

https://cdecl.org/?q=struct+circ+*const+circBuffer_t%3B

Fakt, że używany typ jest wskaźnikiem a nie strukturą jest ukryty przed programistą używającym tego modułu. Czy to dobrze czy to źle? Sam jeszcze do końca nie jestem przekonany. W każdym razie teraz użytkownik nie musi pamiętać o const, a operacja jawnego przypisania do buff jest nadal niemożliwa.

uint8_t buffData[10];
circBuffer_t buff = CIRC_init(buffData, sizeof(buffData));
buff = 123;
uint8_t frame[] = {1, 2, 3};
CIRC_write(buff, frame, sizeof(frame));
example.c:7:10: error: assignment of read-only variable ‘buff’
     buff = 123;

Teraz nasz kod wydaje się być już całkiem kuloodporny. Wciąż jest to C, więc jak ktoś się uprze to będzie w stanie zepsuć strukturę bufora, ale przed przypadkowymi błędami jesteśmy chronieni na tyle, na ile się da według mojej wiedzy. Efektem ubocznym całkowitego ukrycia szczegółów implementacji modułu jest prostota modyfikacji kodu. Gdy przyjdzie nam zmieniać kod w pliku circ.c nie będziemy musieli się martwić tym, czy użytkownik tego modułu nie polega w swoim kodzie na wartości któregoś z pól struktury bufora. Przecież nie jest w stanie się do nich dostać z „zewnątrz”.

więcej kodu?

Jeżeli po całym tym wywodzie masz jeszcze siły na trochę więcej kodu zapraszam do repozytorium, na którym uczyłem się zaprezentowanego podejścia oraz eksperymentowałem z narzędziami do unit testów.

Ostatecznie wybrałem narzędzie Ceedling, w niedalekiej przyszłości opiszę jak zacząć z nim pracę. W repozytorium są też raporty z pokrycia testów (podkatalog coverage_reports) w przyjemnej formie pliku .html. Ich generacja jest bajecznie prosta korzystając z Ceedling’a. Być może niedługo to wszystko szczegółowo pokażę w formie poradnika od zera do pierwszego testu – zainteresowany?

koniec

Dotarliśmy do końca wycieczki. Mam nadzieję, że podobał Ci się artykuł w formie iteracyjnych poprawek kodu. Masz pomysł, w jaki sposób kod można jeszcze bardziej zabezpieczyć? Jak zawsze – podziel się w komentarzu. Jeżeli uznałeś przekazaną tutaj wiedzę za przydatną podziel się linkiem do artykułu z innymi programistami. Zachęcam też do zapisania się na mój newsletter, informuję w nim o nowych artykułach, więc będziesz na bieżąco. Do usłyszenia.