W kolejnym artykule z serii Pułapki Języka C przedstawiam popularny błąd o niespodziewanych efektach ubocznych. Niech pierwszy rzuci kamieniem/myszką/programatorem, ten kto nigdy przez przypadek nie wybiegł pętlą for poza obszar jakiejś tablicy. Być może po latach popełniania błędów nauczyliśmy się pisać kod bardziej odporny na ten jakże popularny sposób popsucia sobie dnia. Jestem przekonany, że zdarzyło się to jednak każdemu programiście C, chociażby na początku drogi. Sam nieraz ten błąd popełniłem i nie ma w tym nic szczególnie ciekawego. Ciekawsze jest raczej to, jakich konsekwencji się spodziewałem, gdy odkryłem, że napisałem for’a ze znakiem <= zamiast < w warunku końca pętli.
Gdy pętla, którą pisałem zapisywała coś poza dozwolony obszar, od razu błąd w mojej głowie urastał do rangi poważnego i o ogromnych, nieprzewidywalnych konsekwencjach. “Przecież nadpisuję jakieś inne zmienne, inne ważne dane”. Jednak, gdy ta sama pętla służyła jedynie do odczytywania czegoś z pamięci poza tablicą, problem nie wydawał mi się tak poważny. “Przecież tylko czytam sobie jeden bajt za daleko”. Być może nawet zajrzałem z ciekawości do mapy pamięci i stwierdziłem, że “tam akurat nie ma nic takiego, co mogłoby zepsuć działanie”. Błąd oczywiście poprawiałem, ale zakładałem mniejsze jego konsekwencje. Okazuje się, że niesłusznie!
Jakiś czas temu natknąłem się na informację, że dostęp poza obszar tablicy jest przykładem znanego, chociażby z poprzedniego artykułu tej serii, niezdefiniowanego zachowania (ang. undefined behaviour).
Postanowiłem sprawdzić, co mówi o tym standard. Aneks J do standardu C99 w rozdziale 2 zawiera listę niezdefiniowanych zachowań. Te, które traktują o dostępie do tablic przedstawiam poniżej:
An array subscript is out of range, even if an object is apparently accessible with the given subscript (as in the lvalue expression a[1][7] given the declaration int a[4][5]) (6.5.6).
Addition or subtraction of a pointer into, or just beyond, an array object and an integer type produces a result that points just beyond the array object and is used as the operand of a unary * operator that is evaluated (6.5.6).
teoria a praktyka
Sprawdźmy, co może pójść nie tak i celowo popsujmy poniższy kod. Zanim jednak wprowadzimy nasz błąd, upewnijmy się, że kod przed zmianami działa poprawnie.
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
typedef struct
{
int32_t address;
int32_t data[4];
int32_t crc;
}frame_t;
void print_frame(frame_t *frame)
{
int32_t *b = (int32_t *)frame;
printf("FRAME: ");
for(size_t i = 0; i < sizeof(frame_t) / sizeof(int); i++)
{
printf("0x%X ", b[i]);
}
printf("\n");
}
int32_t searchInFrameData(frame_t *frame, int32_t v)
{
for(size_t i = 0; i < 4; i++)
{
if(frame->data[i] == v)
{
return i;
}
}
return -1;
}
int main(void)
{
frame_t frame = {.address = 1, .data = {2, 3, 4, 5}, .crc = 6};
print_frame(&frame);
printf("%d\n", searchInFrameData(&frame, 1));
return 0;
}
Całkiem sporo tego kodu, wiem. Ale staram się podawać Wam w miarę możliwości realne przykłady. Na początku widzimy powstanie nowego typu frame_t, udającego ramkę jakiegoś wymyślonego protokołu. Dalej jest pomocnicza funkcja print_frame wyświetlająca ramkę jako surowe dane w formacie heksadecymalnym. Nie ona jest celem naszego eksperymentu. Interesuje nas funkcja searchInFrameData. Jej zadaniem jest sprawdzanie, czy podana przez nas wartość występuje w danych przekazanej ramki. Jeżeli w tablicy frame->data nie zostanie znaleziona szukana wartość to funkcja zwraca -1. Już na tym etapie możemy wychwycić w linii 29 (proszącą się o błąd) stałą wartość 4, reprezentującą ilość danych w frame->data. Sprawdzamy działanie kodu przekazując do searchInFrameData ramkę z danymi {2, 3, 1, 5} i szukając w niej liczby 1.
int main(void)
{
frame_t frame = {.address = 1, .data = {2, 3, 1, 5}, .crc = 6};
print_frame(&frame);
printf("%d\n", searchInFrameData(&frame, 1));
return 0;
}
$ gcc -std=c99 -Wall -Wextra -Wpedantic bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x1 0x5 0x6
2
Zgadza się. Wypisany na końcu programu indeks 2 poprawnie wskazuje miejsce liczby 1 w frame->data. Zamieńmy więc ramkę na taką bez liczby 1 wśród danych, na przykład {2, 3, 4, 5}.
frame_t frame = {.address = 1, .data = {2, 3, 4, 5}, .crc = 6};
$ gcc -std=c99 -Wall -Wextra -Wpedantic bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x4 0x5 0x6
-1
Otrzymujemy -1, co oznacza, że szukana przez nas wartość nie została znaleziona. Wszystko działa tak, jak powinno. Sprawdziłem oba przypadki dla optymalizacji -O2 i dostałem takie same wyniki.
psujemy
Teraz wprowadźmy zmiany i wywołajmy błąd, o który tak bardzo się prosimy. W naszym testowym projekcie zmieniają się wymagania i dochodzi do modyfikacji protokołu. W rezultacie struktura ramki wygląda teraz tak:
typedef struct
{
int32_t address;
int32_t data[3];
int32_t crc;
}frame_t;
Pole data zostało skrócone z czterech do trzech pozycji. Czwórki w funkcji searchInFrameData nikt oczywiście nie zmienił na trójkę. Sprawdźmy, co się dzieje dla przypadku, gdy dane nie zawierają szukanej przez nas liczby 1.
frame_t frame = {.address = 1, .data = {2, 3, 4}, .crc = 5};
$ gcc -std=c99 -Wall -Wextra -Wpedantic bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x4 0x5
-1
Funkcja nadal działa poprawnie. Sprawdźmy, czy dostaniemy poprawny wynik w sytuacji, gdy dane zawierają to, czego szukamy.
frame_t frame = {.address = 1, .data = {2, 3, 1}, .crc = 5};
$ gcc -std=c99 -Wall -Wextra -Wpedantic bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x1 0x5
2
Okej, ciągle nic się nie popsuło. Wydaje się, że jedyny problem może się pojawić, gdy pole crc w ramce będzie równe naszej szukanej wartości. Ponieważ pętla szukająca sięga o jedną pozycję tablicy za daleko to natrafia na sąsiadujące z nią pole crc.
frame_t frame = {.address = 1, .data = {2, 3, 4}, .crc = 1};
$ gcc -std=c99 -Wall -Wextra -Wpedantic bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x4 0x1
3
Faktycznie, indeks 3 jest już poza interesującym nas obszarem danych, a mimo to funkcja go zwróciła. Takiej konsekwencji dostępu poza obszar tablicy mogliśmy się spodziewać.
to wszystko?
Czy to jedyne konsekwencje naszego błędu? Włączmy optymalizacje!
frame_t frame = {.address = 1, .data = {2, 3, 4}, .crc = 5};
$ gcc -std=c99 -Wall -Wextra -Wpedantic -O2 bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x4 0x5
2
Dobra, to już jest trochę dziwne. Program twierdzi, że pod indeksem 2 w frame->data znajduje się szukana przez nas liczba 1, chociaż w rzeczywistości znajduje się tam liczba 4. Spróbuję podejrzeć, co robi program i dodam na początku pętli szukającej printf.
int32_t searchInFrameData(frame_t *frame, int32_t v)
{
for(size_t i = 0; i < 4; i++)
{
printf("frame->data[%lu] = %d\n", i, frame->data[i]); //DODANY PRINTF
if(frame->data[i] == v)
{
return i;
}
}
return -1;
}
$ gcc -std=c99 -Wall -Wextra -Wpedantic -O2 bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x4 0x5
frame->data[0] = 2
frame->data[1] = 3
frame->data[2] = 4
frame->data[3] = 5
frame->data[4] = 0
frame->data[5] = 0
frame->data[6] = 0
frame->data[7] = -13200
frame->data[8] = 0
frame->data[9] = -2144092808
frame->data[10] = 1
10
Ok. Co tu się wydarzyło?? Wygląda to tak, jakby dodanie printf’a zmieniło działanie programu i teraz pętla wybiega daleko poza dozwolony obszar. Może iteruje tak długo aż w pamięci znajdzie szukaną wartość? To teraz zamiast 1 poszukajmy np. 10.
int main(void)
{
frame_t frame = {.address = 1, .data = {2, 3, 4}, .crc = 5};
print_frame(&frame);
printf("%d\n", searchInFrameData(&frame, 10));
return 0;
}
$ gcc -std=c99 -Wall -Wextra -Wpedantic -O2 bounds.c && ./a
FRAME: 0x1 0x2 0x3 0x4 0x5
frame->data[0] = 2
frame->data[1] = 3
frame->data[2] = 4
frame->data[3] = 5
frame->data[4] = 0
frame->data[5] = 0
frame->data[6] = 0
frame->data[7] = -13200
frame->data[8] = 0
frame->data[9] = -2144092808
frame->data[10] = 1
frame->data[11] = -12816
frame->data[12] = 0
frame->data[13] = -2147178364
frame->data[14] = 1
frame->data[15] = 0
frame->data[16] = 0
frame->data[17] = 32
frame->data[18] = 0
frame->data[19] = 251723520
frame->data[20] = 50331907
Wyświetlane kolejnych obiegów pętli się nie kończy… W końcu program zatrzymuje Segmentation fault.
frame->data[3322] = 0
frame->data[3323] = 12840
frame->data[3324] = 0
frame->data[3325] = -2147182624
frame->data[3326] = 1
frame->data[3327] = -2147187766
frame->data[3328] = 1
frame->data[3329] = 0
frame->data[3330] = 0
Segmentation fault (zrzut pamięci)
Jak widać programowi udało się dotrzeć do indeksu 3330 w frame->data i nadal nie znaleźć dziesiątki. Czyli chyba jednak wybiegamy dalej niż o jedno pole za tablicę. Z ciekawości odpaliłem kod jeszcze raz, szukając tym razem 11. Znalazłem. Na pozycji 738. Program zakończył się poprawnie. Gdyby funkcja zwracała zamiast indeksu tylko prawdę albo fałsz moglibyśmy nigdy nie zauważyć, że interesująca nas liczba została znaleziona kilobajt za interesującym nas obszarem.
lekarstwo
Mam nadzieję, że po takim przykładzie nabraliście większego respektu do tego typu błędów. Jak przestrzec się przed taką pomyłką? Nie pisać kodu polegającego na stałych rozmiarach tablic albo struktur. Zamiast
for(size_t i = 0; i < 4; i++)
proponuję stworzyć makro wyliczające ilość elementów w tablicy (nie bajtów).
#define NELEMS(array) (sizeof(array) / sizeof(array[0]))
Stosując je, uodparniamy naszą pętlę na modyfikację typu frame_t. Wystarczy pętlę przepisać w ten sposób:
int32_t searchInFrameData(frame_t *frame, int32_t v)
{
for(size_t i = 0; i < NELEMS(frame->data); i++)
{
if(frame->data[i] == v)
{
return i;
}
}
return -1;
}
Poza wyeliminowaniem ryzyka błędu funkcja zyskała również na czytelności. Nie iterujemy już ku tajemniczej i niepowiązanej z niczym liczbie 4. Biało na czarnym widać, że pętla ma iterować przez wszystkie elementy tablicy frame->data.
to wszystko
Kolejna, moim zdaniem ciekawa pułapka za nami. Wiedziałeś, że konsekwencje dostępu poza obszar tablicy mogą być aż tak duże? Czy tak jak ja, żyłeś w przeświadczeniu, że “czytam po prostu jeden bajt za daleko”?
Należałoby jednak dodać uwagę, że dotyczy to jedynie tablic statycznych. Inaczej możemy się zdziwić, że nie zadziała. A wystarczy, że przejdzie przez rzutowanie jako argument funkcji.
Masz na myśli sytuację gdy tablice przekażemy do jakiejś funkcji jako wskaźnik a jej rozmiar jako argument? Wtedy podejrzewam, że kompilator mógłby nie dokonać takiej radykalnej i dziwnej optymalizacji. W takim przypadku ciało funkcji samo w sobie nie wystarczyłoby do stwierdzenia, że kod odczytuje coś spoza tablicy. W moim przykładzie po kodzie funkcji można jednoznacznie stwierdzić, że dochodzi do odczytania spoza dozwolonego obszaru.
mam na myśli, ze ktoś przekaże do funkcji tablicę jako wskaźnik i nie przekaże jej rozmiaru, a użyje makra NELEMS. I zdziwi się, że coś się nie zgadza. Należy o tym wspomnieć, bo niektórzy nie zwrócą uwagi, że przekazywana w przykładach jest struktura.
Uwaga, kod z celowym błędem.
void foo(uint8_t *a)
{
printf(“NELEMS(a) = %lu\n”, NELEMS(a));
}
int main(void)
{
uint8_t b[3] = {0};
foo(b);
return 0;
}
Wrzucam kawałek kodu, który chyba odwzorowuje to co masz na myśli. Zgadzam się, że to jest pułapka dla początkującego. Cały język C jest pułapką 😉 Zastanowię się czy warto to dopisywać do artykułu.
Nawet jeśli ktoś tak by zrobił to sytuacja wcale nie jest taka beznadziejna. Przy kompilacji powyższego kodu dostaniemy warning. Zakładając, że włączyliśmy flagę -Wall.
$ gcc -Wall test.c
test.c: In function ‘foo’:
test.c:4:38: warning: division ‘sizeof (uint8_t * {aka unsigned char *}) / sizeof (uint8_t {aka unsigned char})’ does not compute the number of array elements [-Wsizeof-pointer-div]
4 | #define NELEMS(array) (sizeof(array) / sizeof(array[0]))
| ^
test.c:17:33: note: in expansion of macro ‘NELEMS’
17 | printf("NELEMS(a) = %lu\n", NELEMS(a));
| ^~~~~~
test.c:15:19: note: first ‘sizeof’ operand was declared here
15 | void foo(uint8_t *a)
| ~~~~~~~~~^
GCC informuje nas, że w tym przypadku makro NELEMS wcale nie wylicza ilości elementów tablicy. Można pójść dalej i wykorzystać -Werror dla tego konkretnego warninga aby go nie przegapić.
$ gcc -Wall -Werror=sizeof-pointer-div test.c
test.c: In function ‘foo’:
test.c:4:38: error: division ‘sizeof (uint8_t * {aka unsigned char *}) / sizeof (uint8_t {aka unsigned char})’ does not compute the number of array elements [-Werror=sizeof-pointer-div]
4 | #define NELEMS(array) (sizeof(array) / sizeof(array[0]))
| ^
test.c:17:33: note: in expansion of macro ‘NELEMS’
17 | printf("NELEMS(a) = %lu\n", NELEMS(a));
| ^~~~~~
test.c:15:19: note: first ‘sizeof’ operand was declared here
15 | void foo(uint8_t *a)
| ~~~~~~~~~^
cc1: some warnings being treated as errors
można też dodać volatile dla zmiennej w pętli (nie zostanie zoptymalizowana) fakt wyjdziemy poza tablicę ale nie tak jak w przykładzie powyżej.
Można też odwrócić “logikę” i przeszukiwać tablicę od końca np:
uint16_t i = 5;
while(i–) {
// …
}
Możliwe, że dodanie volatile spowoduje w tym przypadku, że błędu nie będzie. Nie jest to jednak kod, któremu bym zaufał, nadal występuje w nim niezdefiniowane zachowanie. Nie wiadomo kiedy przestanie działać i w jaki sposób. A możliwe, że dodanie volatile zupełnie nic nie zmieni.
Pętle liczące od góry w dół są niecodziennym zjawiskiem i zazwyczaj niepotrzebnym (chyba, że faktycznie algorytm tego wymaga). Pisząc kod lepiej używać idiomów niż zaskakiwać niepotrzebnie kolegów/siebie z przyszłości. Fajnie o idiomach w C opowiada Brian Kernighan
https://youtu.be/8SUkrR7ZfTA?t=1840
polecam cały wykład, ale linkuje od fragmentu o idiomach w C.
Maciej Gajdzica w artykule Mikrooptymalizacje są bez sensu też wypowiadał się przeciw odwrotnym pętlom.
A