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”?