Menu

Niezdefiniowane zachowanie w C (undefined behavior): co oznacza i dlaczego boli

Niezdefiniowane zachowanie to kod, na który standard C nie nakłada żadnych wymagań, więc może się stać cokolwiek, łącznie z tym, że optymalizator usunie twoje sprawdzenia. Zobacz, co je powoduje, dlaczego "u mnie działa" niczego nie dowodzi i jakie flagi je wyłapują.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

"Niezdefiniowane zachowanie" brzmi jak żargonowe określenie na "nieprzewidywalne". To coś mocniejszego. Kiedy standard C mówi, że dana konstrukcja ma niezdefiniowane zachowanie, oznacza to, że standard nie nakłada żadnych wymagań na to, co robi program: ani na wartość, ani na instrukcję, ani na cały program.

To właśnie zaskakuje ludzi: niezdefiniowane zachowanie nie ogranicza się do winnej linii. Kompilator może założyć, że ono nigdy nie występuje, i na tej podstawie przepisać kod wokół niej. W efekcie może powstać program, w którego pliku binarnym nie ma sprawdzenia, wyraźnie zapisanego w kodzie źródłowym.

Model kontraktu

Traktuj standard jak kontrakt między tobą a kompilatorem. Ty obiecujesz, że nie zrobisz pewnych rzeczy; w zamian kompilator obiecuje, że program znaczy to, co w nim zapisano.

Nie indeksuj poza tablicą. Nie przepełniaj liczby całkowitej ze znakiem. Nie odczytuj niezainicjowanej wartości. Nie używaj wskaźnika po jego zwolnieniu. Nie modyfikuj tego samego obiektu dwa razy w jednym wyrażeniu bez punktu sekwencyjnego pomiędzy.

Złam jedną klauzulę i umowa przestaje obowiązywać, i to dla całego programu, a nie tylko tej linii. Nie ma żadnego "rozsądnego zachowania awaryjnego" ani obowiązku, żeby program się wysypał.

Warto rozróżnić trzy pokrewne pojęcia:

  • Zachowanie niezdefiniowane: może się stać cokolwiek. Wyjście poza zakres, przepełnienie liczb ze znakiem, użycie po zwolnieniu.
  • Zachowanie nieokreślone: jeden z kilku poprawnych wyników, a kompilator nie musi mówić, który. Na przykład kolejność obliczania argumentów funkcji.
  • Zachowanie zależne od implementacji: implementacja wybiera i musi udokumentować swój wybór. Czy char ma znak, jak duży jest int.

Tylko pierwsze jest groźne w sensie "optymalizator usunął mój kod".

Główne źródła

Przepełnienie liczb całkowitych ze znakiem

Arytmetyka bez znaku się zawija i standard to gwarantuje. Arytmetyka ze znakiem nie: wyjście poza zakres jest niezdefiniowane.

Sprawdzenie if (a > INT_MAX - b) odbywa się w całości w poprawnym zakresie i właśnie dlatego jest prawidłowym testem przepełnienia. Zapis if (a + b < 0) najpierw wykonuje przepełnienie, a dopiero potem pyta o wynik, a kompilator, który ma prawo założyć, że przepełnienia nigdy nie było, może to sprawdzenie usunąć.

Wyjście poza zakres tablicy

Odczyt lub zapis poza tablicą jest niezdefiniowany, niezależnie od tego, czy program się wysypie:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: indeks 5 nie istnieje */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB już samo obliczenie tego wskaźnika */

Zwróć uwagę na ostatnią linię: utworzenie wskaźnika sięgającego dalej niż jeden element za końcem jest niezdefiniowane, nawet jeśli nigdy go nie wyłuskasz. Standard dopuszcza arr + 5 (jeden za końcem, do kończenia pętli), ale nie arr + 6.

Groźne są właśnie małe przekroczenia. Zwykle nie powodują segfaulta; po cichu nadpisują sąsiednią zmienną, a błędny wynik pojawia się w zupełnie innym miejscu.

Odczyt niezainicjowanych wartości

int x;
printf("%d\n", x);     /* UB: odczyt wartości nieokreślonej */

int *p;
*p = 42;               /* UB: wyłuskanie nieokreślonego wskaźnika */

Kusi, żeby myśleć "tam po prostu są śmieci", ale standard mówi co innego, a kompilatory wykorzystują tę różnicę. Zdarzało się, że GCC uznawał, że zmienna odczytana przed przypisaniem może mieć dowolną wartość, jaka mu pasuje, łącznie z taką, przy której cała gałąź znika.

Wiszące wskaźniki

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: użycie po zwolnieniu */
free(p);               /* UB: podwójne zwolnienie */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: czas życia obiektu się skończył */

Skutki w czasie działania programu opisuje segmentation fault; tu chodzi o to, że awaria to ten szczęśliwy scenariusz.

Zły specyfikator printf

printf("%d\n", 3.14);        /* UB: %d z double */
printf("%s\n", 42);          /* UB: %s z int, zwykle kończy się awarią */
printf("%d %d\n", 1);        /* UB: mniej argumentów niż specyfikatorów */
long n = 5;
printf("%d\n", n);           /* UB na systemach, gdzie long jest szerszy niż int */

printf jest funkcją wariadyczną: odczytuje argumenty zgodnie z łańcuchem formatu i nie może ich sprawdzić. Niezgodność sprawia, że czyta złą liczbę bajtów ze złego miejsca. Skompiluj z -Wall, a kompilator sprawdzi łańcuch formatu za ciebie: to jedno z najcenniejszych ostrzeżeń w całym zestawie.

Dwukrotna modyfikacja obiektu w jednym wyrażeniu

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

To jest niezdefiniowane, a nie tylko "zależne od kompilatora". Podręcznikowe zagadki pytające, ile wynosi i = i++ + ++i, nie mają poprawnej odpowiedzi.

Strict aliasing

Dostęp do obiektu przez wskaźnik niezgodnego typu jest niezdefiniowany i to zaskakuje nawet doświadczonych programistów:

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: odczyt float przez int * */

Kompilator zakłada, że int * i float * nigdy nie wskazują na tę samą pamięć, i odpowiednio zmienia kolejność operacji. Zdefiniowany sposób na reinterpretację bajtów to memcpy (optymalizowane do tych samych instrukcji) albo unia:

Wyjątkiem jest char *: bajty dowolnego obiektu zawsze możesz obejrzeć przez unsigned char *.

Dlaczego "u mnie działa" niczego nie dowodzi

Niezdefiniowane zachowanie często wydaje się działać i właśnie to czyni je groźnym. Program działa poprawnie podczas pisania i testów, a potem psuje się, gdy zmieni się coś zupełnie niezwiązanego:

  • Nowa wersja kompilatora ze sprytniejszym optymalizatorem.
  • Przejście z -O0 na -O2 w buildzie produkcyjnym.
  • Dodanie niezwiązanej funkcji, które przesuwa układ stosu tak, że przekroczenie trafia teraz w coś ważnego.
  • Inna maszyna, inna libc, inny system operacyjny.

To, że program wydaje się działać, nie dowodzi jego poprawności, bo standard niczego nie obiecywał. To uśpiony błąd, a wyzwalaczem jest zwykle build produkcyjny.

Jak wykorzystuje to optymalizator

Oto przykład, który zwykle kończy dyskusję. Programista pisze sprawdzenie wskaźnika na NULL:

void process(int *p) {
    int value = *p;              /* wyłuskanie */
    if (p == NULL) {             /* potem sprawdzenie NULL */
        return;
    }
    printf("%d\n", value * 2);
}

Kolejność jest zła, bo sprawdzenie następuje po wyłuskaniu, ale przecież sprawdzenie i tak się wykona?

Nie musi. Kompilator rozumuje tak: *p zostało wyłuskane, więc p nie może być NULL (wyłuskanie NULL jest niezdefiniowane, więc w każdym programie o zdefiniowanym zachowaniu to nie jest NULL), więc p == NULL jest zawsze fałszywe, więc całe ciało if to martwy kod, który można usunąć.

W skompilowanej funkcji nie ma żadnego sprawdzenia NULL. Prawdziwy przypadek tego wzorca w jądrze Linuksa stał się podatnością CVE-2009-1897: GCC usunął dokładnie takie sprawdzenie i zamienił niewinnie wyglądający błąd kolejności w możliwą do wykorzystania lukę.

Drugi, mniejszy przykład:

/* Sprawdzenie przepełnienia, które nie działa */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "czy się zawinęło?" */
        return -1;
    }
    return sum;
}

Dla typów ze znakiem kompilator może założyć, że a + b się nie przepełniło, a wtedy sum < a jest możliwe tylko przy b < 0. Przy tym założeniu sprawdzenie testuje coś innego, niż zamierzał autor, a przy b >= 0 może zostać całkowicie wyoptymalizowane. Działająca wersja sprawdza zawczasu:

Oba testy mieszczą się w reprezentowalnym zakresie, więc przepełnienie nigdy nie następuje i optymalizator nie ma czego zakładać. (GCC i clang oferują też __builtin_add_overflow, które robi to w jednej instrukcji.)

Jak to wykryć

Najpierw ostrzeżenia statyczne, bo są za darmo:

gcc -Wall -Wextra -Wpedantic program.c -o program

Wyłapują niezgodności w łańcuchach formatu, część odczytów niezainicjowanych zmiennych, podejrzane porównania i nieosiągalny kod.

Potem sanitizery, które instrumentują program i zgłaszają problem w chwili naruszenia:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

Połącz to z AddressSanitizerem, który pokrywa część związaną z pamięcią:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

Razem wyłapują wyjście poza zakres, użycie po zwolnieniu, podwójne zwolnienia, przepełnienie liczb ze znakiem, niepoprawne przesunięcia bitowe, źle wyrównane wskaźniki i wyłuskanie NULL, za każdym razem z plikiem, linią i śladem stosu. Program działa mniej więcej 2x wolniej, co w trakcie pisania nie ma znaczenia.

valgrind ./program nie wymaga ponownej kompilacji i wyłapuje odczyty niezainicjowanych wartości oraz błędy pamięci, choć nie arytmetyczne UB. clang --analyze i gcc -fanalyzer znajdują część problemów w ogóle bez uruchamiania programu.

Praktyczna zasada: uruchamiaj testy pod sanitizerami w CI. UB, które lokalnie wydaje się działać, to dokładnie to, do czego ujawniania one istnieją.

Jak z tym żyć

Samą ostrożnością nie unikniesz niezdefiniowanego zachowania: każdy w końcu je napisze. Działa co innego: sprawienie, żeby było głośne:

  • Kompiluj z -Wall -Wextra od pierwszego dnia i poprawiaj każde ostrzeżenie.
  • Uruchamiaj testy z -fsanitize=address,undefined.
  • Inicjalizuj każdą zmienną przy deklaracji, a każdy wskaźnik jako NULL.
  • Sprawdzaj indeksy tablic względem długości i pisz pętle z i < n.
  • Sprawdzaj przepełnienie przed działaniem arytmetycznym, korzystając z <limits.h>.
  • Po zwolnieniu wskaźnika ustaw go na NULL.
  • Używaj typów unsigned tam, gdzie zawijanie jest zamierzone: tam jest zdefiniowane.
  • Przy reinterpretacji bajtów wybieraj memcpy zamiast rzutowania wskaźników.

Szybkość C bierze się z tego, że kompilator może założyć, że dotrzymujesz kontraktu. To prawdziwy kompromis, a nie wada projektu, a powyższe narzędzia oddają ci większość bezpieczeństwa za zerowy koszt w czasie pisania kodu.

Stronę tych zasad widoczną w czasie działania programu opisuje segmentation fault; błędy kompilacji, które pojawiają się wcześniej, opisują częste błędy.

Najczęściej zadawane pytania

Czym jest niezdefiniowane zachowanie w C?

To kod, na który standard C nie nakłada absolutnie żadnych wymagań. Kompilator może wygenerować cokolwiek: awarię, błędny wynik, kod, który pozornie działa, albo kod, z którego problematyczna gałąź zniknęła całkowicie. To nie jest zachowanie "zależne od implementacji" ani "losowe": to złamany kontrakt, po którym nic nie jest obiecane.

Dlaczego C ma niezdefiniowane zachowanie, zamiast wszystko zdefiniować?

Dla szybkości i przenośności. Wymóg sprawdzania granic przy każdym dostępie do tablicy kosztowałby wydajność, której C z założenia nie płaci; zdefiniowanie przepełnienia liczb ze znakiem jako zawijania wymusiłoby dodatkowe instrukcje na sprzęcie, który w takiej sytuacji zgłasza pułapkę. Pozostawienie tych przypadków jako niezdefiniowanych pozwala kompilatorowi założyć, że nigdy nie występują, i odpowiednio optymalizować.

Czy przepełnienie liczby całkowitej ze znakiem to niezdefiniowane zachowanie w C?

Tak. INT_MAX + 1 jest niezdefiniowane: nie ma gwarancji, że wartość zawinie się do INT_MIN. Przepełnienie liczb bez znaku to co innego: jest w pełni zdefiniowane i zawija się modulo 2^N. Dlatego kompilator może założyć, że x + 1 > x jest zawsze prawdziwe dla x ze znakiem, i usunąć sprawdzenie przepełnienia napisane w ten sposób.

Jak wykryć niezdefiniowane zachowanie w programie w C?

Kompiluj z -Wall -Wextra, żeby wyłapać to, co kompilator widzi statycznie, a potem uruchamiaj testy z -fsanitize=address,undefined, które zgłasza wyjście poza zakres, użycie po zwolnieniu, przepełnienie liczb ze znakiem i inne błędy w chwili, gdy wystąpią, podając plik i linię. valgrind wyłapuje podobny zestaw bez ponownej kompilacji.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ