Parę lat temu dostałem zadanie poprawienia błędu w prawie gotowym projekcie. Kod był rozwijany od kilku lat, ale nie brałem udziału w jego tworzeniu. Edytując kod dodałem jedno pole do istniejącej już struktury danych. Urządzenie działało poprawnie, a błąd zniknął. Super? No nie bardzo. Parę dni później okazało się, że gdy urządzenie ze starym firmware’em zaktualizowaliśmy do poprawionej przeze mnie wersji przestało ono działać. Jeżeli urządzenie było uruchamiane po raz pierwszy problem ten nie występował. Kilka dni później dowiedziałem się, że rozmiar zmienionej przeze mnie struktury był krytyczny dla działania urządzenia i powinien być stały dla wszystkich wersji. Dodając do struktury nowe pole zwiększyłem jej rozmiar, powodując przesunięcie sąsiadującej z nią następnej struktury. Dane przechowywane w pamięci urządzenia były zapisane tam przez starą wersję programu. Po aktualizacji te same dane odczytywane przez nowy program były błędnie interpretowane. Mój błąd nigdy na szczęście nie trafił na produkcję, ale pochłonął paręnaście godzin.
Z tamtego błędu wyciągnąłem lekcje i teraz pisząc kod stosuję odpowiednie zabezpieczenia. Przedstawiam Wam asercje czasu kompilacji. Choć mam nadzieje, iż większości czytelników nie trzeba ich przedstawiać.
Asercje możemy podzielić na te uruchamiane w trakcie działania programu oraz te sprawdzane na etapie kompilacji. Dzisiaj zajmiemy się asercjami sprawdzanymi, zanim nasz kod zostanie uruchomiony. Zaletą asercji czasu kompilacji jest ich zerowy koszt. Nie pochłaniają i tak już szczupłych zasobów mikrokontrolera. Dla programisty też nie są obciążeniem w trakcie pracy, nie wymagają pisania żadnej obsługi błędów, ani uruchamiania testów. Przyjmują one raczej formę pisemnego oczekiwania programisty wobec jakiejś prostej cechy pisanego przez niego kodu. W przypadku, gdy te oczekiwania nie zostaną spełnione kompilator poinformuje nas o błędzie. Kiedy na pewno warto stosować asercje?
kiedy rozmiar ma znaczenie
W systemach wbudowanych częściej, niż w innych projektach polegamy na konkretnym rozmiarze danych. Pisząc obsługę protokołu definiujemy struktury, które rozmiarami pól i wyrównaniem odpowiadają danym upakowanym w ramce. Nagła zmiana rozmiaru takiej struktury spowoduje, że dane w jakiejś ramce protokołu będą źle interpretowane. Zapisując jakieś dane do pamięci flash też często wykorzystujemy upakowane struktury. Gdy w nowej wersji firmware’u odczytujemy dane, które poprzednia wersja zapisała do tego flash’a zakładamy, że nadal pasują one do pól naszej struktury. A co, jeżeli ktoś w nowszej wersji dodał kolejne pole w środku struktury?
Kolejnym miejscem na błąd jest tworzenie enumu o stałym rozmiarze. Definiujemy enuma, który ma mieć rozmiar jednego bajtu, aby potem móc użyć go w strukturze reprezentującej ramkę protokołu.
typedef enum
{
PROTOCOL_DataType_Temperature = 0x00,
PROTOCOL_DataType_Humidity = 0x01,
PROTOCOL_DataType_Light = 0x02
}PROTOCOL_dataType_t;
typedef struct
{
uint8_t address;
PROTOCOL_dataType_t dataType;
uint32_t data;
uint8_t crc;
}PROTOCOL_dataFrame_t;
Rozmiar struktury PROTOCOL_dataFrame_t zależy od architektury procesora. Na PC albo ARM ta struktura będzie miała inny rozkład w pamięci niż na ośmiobitowym STM8. Rozmiar enuma PROTOCOL_dataType_t także zależy od architektury procesora oraz od opcji kompilacji. Bez dodatkowych flag kompilacji taki enum ma rozmiar 4 bajtów na PC. Z flagą -fshort-enums rozmiar zmniejsza się do oczekiwanego 1 bajtu. Drugą opcją na zapewnienie, że ten enum będzie miał rozmiar 1B jest dodanie __attribute__((packed)) w taki sposób:
typedef enum __attribute__((packed))
{
PROTOCOL_DataType_Temperature = 0x00,
PROTOCOL_DataType_Humidity = 0x01,
PROTOCOL_DataType_Light = 0x02
}PROTOCOL_dataType_t;
Przy okazji, definiowane enuma w poniższy sposób
typedef enum
{
PROTOCOL_DataType_Temperature = (uint8_t)0x00,
PROTOCOL_DataType_Humidity = (uint8_t)0x01,
PROTOCOL_DataType_Light = (uint8_t)0x02
}PROTOCOL_dataType_t;
wcale nie sprawi, że sizeof(PROTOCOL_dataType_t) zwróci 1. Jeżeli u Ciebie tak się dzieje to albo używasz -fshort-enums albo piszesz program na architekturę, w której enum jest reprezentowany na jednym bajcie.
W obu tych przypadkach Twój kod nie jest przenośny na inną platformę. Nie jest też zabezpieczony przed nieświadomą zmianą rozmiarów typów danych. Zastosujmy więc w kodzie asercję, aby upewnić się, że rozmiar zdefiniowanych typów jest zawsze taki sam, niezależnie od platformy i opcji kompilacji. Najpierw enum.
typedef enum
{
PROTOCOL_DataType_Temperature = 0x00,
PROTOCOL_DataType_Humidity = 0x01,
PROTOCOL_DataType_Light = 0x02
}PROTOCOL_dataType_t;
ASSERT(sizeof(PROTOCOL_dataType_t) == sizeof(uint8_t));
Dodany w ósmej linii ASSERT jasno pokazuje, jakie są wymagania programisty co do tego kodu – rozmiar PROTOCOL_dataType_t ma być taki sam jak rozmiar uint8_t. Taki kod na STM8 kompiluje się bez krzyku, na PC z włączonym -fshort-enums również. Ale co się dzieje, gdy na PC próbujemy skompilować ten fragment bez włączonej flagi -short-enums?
$ gcc -std=c11 -Wall -Wextra -Wpedantic asserts.c && ./a
asserts.c:5:27: error: static assertion failed: "sizeof(PROTOCOL_dataType_t) == sizeof(uint8_t)"
#define ASSERT(condition) _Static_assert(condition, #condition)
^
asserts.c:14:1: note: in expansion of macro ‘ASSERT’
ASSERT(sizeof(PROTOCOL_dataType_t) == sizeof(uint8_t));
^~~~~~
Kompilacja zostaje przerwana i dostajemy jasny komunikat o popełnionym błędzie. Wróćmy teraz do rozmiaru zdefiniowanej ramki protokołu. Co w przypadku, gdy przedstawioną wcześniej strukturę przeniesiemy z STM8 na PC albo ARM? Jeżeli odpowiednio ją zabezpieczyliśmy to unikniemy przykrych niespodzianek.
typedef struct
{
uint8_t address;
PROTOCOL_dataType_t dataType;
uint32_t data;
uint8_t crc;
}PROTOCOL_dataFrame_t;
ASSERT(sizeof(PROTOCOL_dataFrame_t) == 7);
Po odpaleniu na PC od razu dowiemy się, że rozmiar tej struktury się zmienił, a nie powinien.
$ gcc -std=c11 -Wall -Wextra -Wpedantic asserts.c && ./a
asserts.c:5:27: error: static assertion failed: "sizeof(PROTOCOL_dataFrame_t) == 7"
#define ASSERT(condition) _Static_assert(condition, #condition)
^
asserts.c:22:1: note: in expansion of macro ‘ASSERT’
ASSERT(sizeof(PROTOCOL_dataFrame_t) == 7);
^~~~~~
Możemy zmusić gcc do upakowania struktury za pomocą #pragma pack (1). Po upakowaniu enuma i struktury tak, jak w kodzie poniżej kompilacja odbywa się bez błędów. My za to śpimy spokojniej wiedząc, że gdyby w przyszłości z jakiegokolwiek powodu rozmiary tych typów się zmieniły natychmiast się o tym dowiemy.
typedef enum __attribute__((packed))
{
PROTOCOL_DataType_Temperature = 0x00,
PROTOCOL_DataType_Humidity = 0x01,
PROTOCOL_DataType_Light = 0x02
}PROTOCOL_dataType_t;
ASSERT(sizeof(PROTOCOL_dataType_t) == sizeof(uint8_t));
#pragma pack (1)
typedef struct
{
uint8_t address;
PROTOCOL_dataType_t dataType;
uint32_t data;
uint8_t crc;
}PROTOCOL_dataFrame_t;
ASSERT(sizeof(PROTOCOL_dataFrame_t) == 7);
#pragma pack ()
makro ASSERT
Co właściwie kryje się pod makrem ASSERT? W C99 język C nie posiadał wbudowanych asercji czasu kompilacji. Dopiero C11 wprowadził wyrażenie _Static_assert. Przyjmuje ono dwa argumenty: warunek do sprawdzenia oraz tekst błędu do wyświetlenia. Zastosować je możemy więc w ten sposób:
typedef struct
{
uint8_t address;
PROTOCOL_dataType_t dataType;
uint32_t data;
uint8_t crc;
}PROTOCOL_dataFrame_t;
_Static_assert(sizeof(PROTOCOL_dataFrame_t) == 7, "Rozmiar PROTOCOL_dataFrame_t musi byc rowny 7B, nie zmieniaj go!");
$ gcc -std=c11 -Wall -Wextra -Wpedantic asserts.c && ./a
asserts.c:22:1: error: static assertion failed: "Rozmiar PROTOCOL_dataFrame_t musi byc rowny 7B, nie zmieniaj go!"
_Static_assert(sizeof(PROTOCOL_dataFrame_t) == 7, "Rozmiar PROTOCOL_dataFrame_t musi byc rowny 7B, nie zmieniaj go!");
^~~~~~~~~~~~~~
W większości przypadków tekst wiadomości jest opisem sprawdzanego warunku, więc uważam go za zbędny. Stąd moje makro ASSERT zdefiniowałem w ten sposób.
#define ASSERT(condition) _Static_assert(condition, #condition)
Zdejmuje ono ze mnie obowiązek pisania komunikatów błędu i z warunku tworzy tekst za pomocą # (ang. stringification).
asercje a dinozaury
Co, jeżeli nie mamy do dyspozycji kompilatora wspierającego C11? Jest 2019 rok, a mi nadal zdarza się pisać kod na STM8 z kompilatorem Cosmic, dla którego C99 jest najnowszym standardem. Problem da się obejść za pomocą pewnej sztuczki znalezionej w internecie, o tutaj. Makro ASSERT w implementacji działającej pod starszymi kompilatorami wygląda następująco:
#define ASSERT_CONCAT_(a, b) a##b
#define ASSERT_CONCAT(a, b) ASSERT_CONCAT_(a, b)
#define ASSERT(e) \
enum { ASSERT_CONCAT(assert_line_, __LINE__) = 1/(int)(!!(e)) }
Dla końcowego użytkownika istotne jest makro ASSERT. Makra ASSERT_CONCAT_ i ASSERT_CONCAT mają na celu rozwinięcie makra __LINE__ i sklejenia go za pomocą ## z „assert_line”. Dzięki temu makro ASSERT może stworzyć enuma bez nazwy. Jego jedyne pole nazywać się będzie „assert_line” z dołączonym na końcu numerem linii, w której użyte było makro ASSERT. Do makra tak samo, jak poprzednio przekazujemy warunek do sprawdzenia. Ale, w jaki sposób ta asercja ma przerwać kompilacje po napotkaniu błędnego warunku? Spójrzmy na wartość, którą ma przyjąć jedyne pole w tworzonym enumie.
#define ASSERT(e) \
enum { ASSERT_CONCAT(assert_line_, __LINE__) = 1/(int)(!!(e)) }
Jeżeli warunek e rozwinie się do wartości większej lub równej jeden to kod
!!(e)
zagwarantuje nam wartość 1. Jeżeli e zwróci 0 to powyższy fragment też rozwinie się do wartości 0. Co dzieje się dalej? Wartość pola w enumie jest wynikiem dzielenia 1 przez !!e. Więc jeżeli warunek jest nieprawdziwy i !!e jest równe 0 to w efekcie dochodzi do próby dzielenia przez 0. W tym momencie kompilator przerwie prace i zwróci błąd. Zastosujmy więc nowe ASSERT w przykładzie.
#include <stdio.h>
#include <stdint.h>
#define ASSERT_CONCAT_(a, b) a##b
#define ASSERT_CONCAT(a, b) ASSERT_CONCAT_(a, b)
#define ASSERT(e) \
enum { ASSERT_CONCAT(assert_line_, __LINE__) = 1/(int)(!!(e)) }
typedef enum
{
PROTOCOL_DataType_Temperature = (uint8_t)0x00,
PROTOCOL_DataType_Humidity = (uint8_t)0x01,
PROTOCOL_DataType_Light = (uint8_t)0x02
}PROTOCOL_dataType_t;
typedef struct
{
uint8_t address;
PROTOCOL_dataType_t dataType;
uint32_t data;
uint8_t crc;
}PROTOCOL_dataFrame_t;
ASSERT(sizeof(PROTOCOL_dataFrame_t) == 7);
int main(void)
{
return 0;
}
$ gcc -std=c99 -Wall -Wextra -Wpedantic asserts.c && ./a
asserts.c:8:53: warning: division by zero [-Wdiv-by-zero]
enum { ASSERT_CONCAT(assert_line_, __LINE__) = 1/(int)(!!(e)) }
^
asserts.c:24:1: note: in expansion of macro ‘ASSERT’
ASSERT(sizeof(PROTOCOL_dataFrame_t) == 7);
^~~~~~
asserts.c:8:26: error: enumerator value for ‘assert_line_24’ is not an integer constant
enum { ASSERT_CONCAT(assert_line_, __LINE__) = 1/(int)(!!(e)) }
^
asserts.c:5:30: note: in definition of macro ‘ASSERT_CONCAT_’
#define ASSERT_CONCAT_(a, b) a##b
^
asserts.c:8:12: note: in expansion of macro ‘ASSERT_CONCAT’
enum { ASSERT_CONCAT(assert_line_, __LINE__) = 1/(int)(!!(e)) }
^~~~~~~~~~~~~
asserts.c:24:1: note: in expansion of macro ‘ASSERT’
ASSERT(sizeof(PROTOCOL_dataFrame_t) == 7);
^~~~~~
Dużo tutaj szumu, ale możemy ostatecznie doszukać się informacji o tym, skąd się wziął błąd kompilacji. W treści błędu zawarta jest nazwa assert_line_24. Faktycznie ASSERT powodujący błąd znajduje się w linii 24. Informacja o błędzie wygląda dużo gorzej niż w przypadku korzystania z _Static_assert. Mglista informacja o błędzie jest moim zdaniem jednak dużo lepsza niż nieświadomość jego popełnienia.
Możemy jeszcze z ciekawości skorzystać z flagi -E w gcc i zobaczyć, w jaki sposób preprocesor przetwarza makro ASSERT. Polecam tą opcję przy tworzeniu złożonych makr.
$ gcc -E -std=c99 asserts.c
Wśród wszystkiego, co preprocesor wkleił do naszego pliku możemy znaleźć linię z rozwinięciem ASSERT’a.
enum { assert_line_24 = 1/(int)(!!(sizeof(PROTOCOL_dataFrame_t) == 7)) };
mniej popularne zastosowanie asercji
Asercje czasu kompilacji można wykorzystywać do sprawdzania także innych wartości, nie tylko rozmiarów struktur i enumów. Według mnie fajne możliwości może dawać połączenie makra ASSERT z NELEMS i MAX_RANGE. Makro MAX_RANGE przedstawiałem w artykule o _Generic. Jeżeli jeszcze nie czytałeś to gorąco zachęcam do lektury.
Łącząc te trzy makra razem zyskujemy zdolność zabezpieczenia się przed błędami zbyt małych zakresów zmiennych. Dla przykładu pokażę, jak wygodnie można zabezpieczyć zakres zmiennej odpowiedzialnej za indeksowanie aktualnego miejsca do zapisu w buforze.
#include <stdio.h>
#include <stdint.h>
#define MAX_RANGE(num) _Generic(num, int8_t: INT8_MAX, \
int16_t: INT16_MAX, \
int32_t: INT32_MAX, \
int64_t: INT64_MAX, \
uint8_t: UINT8_MAX, \
uint16_t: UINT16_MAX,\
uint32_t: UINT32_MAX,\
uint64_t: UINT64_MAX)
#define NELEMS(array) (sizeof(array) / sizeof(array[0]))
#define ASSERT(condition) _Static_assert(condition, #condition)
static struct
{
uint8_t head;
uint8_t data[100];
}buffer;
ASSERT(MAX_RANGE(buffer.head) >= NELEMS(buffer.data));
int main(void)
{
buffer.data[buffer.head++] = 1;
return 0;
}
Asercja w linii 22 jasno wyraża intencje programisty: „Pole buffer.head ma mieć zasięg wystarczający, aby móc indeksować do samego końca buffer.data, niezależnie od typu danych w nim przechowywanych”. Jeżeli autor kodu albo jego kolega z zespołu, z jakiegoś powodu kiedyś zwiększą rozmiar buffer.data z 100 na 1000 to już przy pierwszej kompilacji dowiedzą się o błędzie.
static struct
{
uint8_t head;
uint8_t data[1000]; //Zmiana rozmiaru bufora, poprzedni byl za maly
}buffer;
ASSERT(MAX_RANGE(buffer.head) >= NELEMS(buffer.data));
$ gcc -std=c11 -Wall -Wextra -Wpedantic asserts.c && ./a
asserts.c:15:27: error: static assertion failed: "MAX_RANGE(buffer.head) >= NELEMS(buffer.data)"
#define ASSERT(condition) _Static_assert(condition, #condition)
^
asserts.c:22:1: note: in expansion of macro ‘ASSERT’
ASSERT(MAX_RANGE(buffer.head) >= NELEMS(buffer.data));
^~~~~~
Dopiero zmiana typu pola buffer.head z uint8_t na uint16_t albo większy uciszy komunikat o błędzie. Podobną asercję można by zastosować do upewnienia się, że zmienna indeksująca tablicę w pętli for ma wystarczający rozmiar, aby doliczyć do końca tablicy.
asercje – czy warto?
Czy warto stosować makro ASSERT? Jeżeli nadal uważasz, że nie to może po prostu jeszcze nie popełniłeś błędu, któremu asercja mogłaby zapobiec. Albo może waga tego błędu nie była znacząca. Według mnie każdy programista urządzeń embedded powinien wiedzieć o możliwości korzystania z asercji i przewidywać, gdzie jej zastosowanie zabezpieczy kod przed błędami w przyszłości. Ich stosowanie nic nie kosztuje. Gdyby w mojej historii opisanej na wstępie kod był zabezpieczony asercją, błąd zostałby wyłapany w parę minut. Nie mam ochoty popełnić drugi raz takiego samego błędu, więc wszędzie gdzie widzę możliwość stosuję ASSERT. To co, warto? Jeśli znasz inne przykłady zastosowania asercji, to proszę podziel się w komentarzu – chętnie je poznam.
Podobał Ci się ten artykuł? Zapraszam do zapisana się na newsletter. Nie przegapisz następnego wpisu.