Zobacz, czym język C może Cię zaskoczyć nawet, jeśli jesteś już wprawionym programistą. Dzisiaj na tapecie integer overflow.

Język C jest używany w systemach embedded od dawna i pewnie przez długi czas będzie w tej dziedzinie królował. Pomimo, że jest domyślnym językiem w branży, nadal potrafi przysporzyć sporo problemów nawet wprawionym programistom. Z mojego doświadczenia wynika, że im mniej mamy z nim do czynienia, tym bardziej jesteśmy przekonani, że znamy go dobrze. Kiedy pięć lat temu zaczynałem pisać w C zawodowo miałem już za sobą doświadczenie ze studiów i kilka hobbistycznych projektów na koncie. Myślałem, że widziałem już wszystko i sam język już mnie raczej nie zaskoczy. Pięć lat minęło, a ja nadal poznaję nowe sposoby na strzelenie sobie w stopę. W serii Pułapki języka C postaram się przybliżyć takie elementy języka, konstrukcje i funkcje, które mogą prowadzić do nieprzewidywalnych rezultatów na etapie działania, łatwych do przegapienia ostrzeżeń kompilatora oraz do nieprzenośnego pomiędzy platformami i kompilatorami kodu.

undefined behaviour

Jeżeli zajrzymy do standardu C99 (porywająca lektura) i przejrzymy rozdział 3. Terms, definitions, and symbols znajdziemy punkt mówiący o niezdefiniowanych zachowaniach – 3. 4. 3 undefined behaviour.

3.4.3

1 undefined behavior
behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this International Standard imposes no requirements
2 NOTE Possible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message).
3 EXAMPLE An example of undefined behavior is the behavior on integer overflow.

Czytając ten podpunkt dowiadujemy się, że w języku C są konstrukcje, których użycie może prowadzić do nieoczekiwanych wyników, zachowań poprawnych tylko w danym środowisku (kompilator/procesor) albo nawet przerwania kompilacji czy działania programu. Świetnie!

integer overflow

Spójrzmy na przedstawiony w standardzie przykład. Okazuje się, że działanie, jakim jest przepełnienie (ang. overflow) się zmiennej całkowitej (ang. integer) jest niezdefiniowanym zachowaniem. Napiszmy więc prostą funkcję wykrywającą czy zmienna typu int32_t za chwile się przepełni.

bool aboutToOverflow(int32_t a)
{
    return a + 1 < a;
}

Moglibyśmy napisać tę funkcję inaczej – tak, aby sprawdzała, czy zmienna ma już swoją maksymalną wartość. Ale przecież sprytnie zauważaliśmy, że jeżeli do zmiennej dodamy liczbę jeden i otrzymany wynik jest mniejszy niż oryginalna liczba to oznacza, że zmienna osiągnęła już wartość maksymalną i doszło do przepełnienia. Jednocześnie myślimy, że tym sposobem zabezpieczamy się na przyszłość i gdy z jakiegoś powodu zamienimy int32_t na inny typ to oczekujemy, że funkcja nadal będzie działać poprawnie, bez konieczności pamiętania o podmianie wartości w warunku sprawdzającym zakres zmiennej.

Przetestujmy więc działanie naszej funkcji.

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>


bool aboutToOverflow(int32_t a)
{
    return a + 1 < a;
}

int main(void)
{
    printf("aboutToOverflow(0x%X) = %u\n", 
           INT32_MAX - 1, aboutToOverflow(INT32_MAX - 1));
    printf("aboutToOverflow(0x%X) = %u\n", 
           INT32_MAX, aboutToOverflow(INT32_MAX));
    return 0;
}

Kompilujemy i uruchamiamy. Kod uruchamiam na PC w środowisku MSYS2 z wykorzystaniem GCC 7.4.0.

$ gcc -std=c99 int_ovf.c && ./a
aboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 1

Dla maksymalnej wartości, jaką może przyjąć int32_t (INT32_MAX = 0x7FFFFFFF) funkcja zwraca prawdę wskazując, że może zaraz dojść do przepełnienia. Dla wartości o jeden mniejszej dostajemy fałsz. Funkcja zwraca wyniki zgodnie z oczekiwaniami, więc ruszamy dalej z projektem zupełnie nieświadomi, że działanie naszego kodu opiera się na niezdefiniowanym zachowaniu.

pułapka

Mija rok i chcemy albo jesteśmy zmuszeni włączyć optymalizację kodu, na przykład, gdy okazuje się, że kod działa za wolno albo zajmuje za dużo miejsca.

$ gcc -std=c99 -O2 int_ovf.c && ./a
aboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0

I nagle mamy problem. Po wprowadzeniu optymalizacji O2, dla INT32_MAX funkcja zwróciła fałsz, a spodziewaliśmy się, że powinna zwrócić prawdę tak jak poprzednio. W tym prostym przykładzie łatwo było zauważyć błąd. Ale wyobraź sobie, gdyby to był fragment dużego projektu z testami niepokrywającymi warunku przepełnienia tego licznika – tragedia gotowa. Co jest przyczyną problemu? Właśnie to wspomniane wcześniej niezdefiniowane zachowanie. W tym przypadku akurat sprawa jest o tyle prosta, że kompilator chce nas przed tym przestrzec. Skorzystajmy więc z flagi -Wall (wcale nie włączającej wszystkich ostrzeżeń, jakby można podejrzewać po nazwie).

$ gcc -std=c99 -Wall -O2 int_ovf.c && ./a
int_ovf.c: In function ‘aboutToOverflow’:
int_ovf.c:8:5: warning: assuming signed overflow does not occur when assuming that (X + c) < X is always false [-Wstrict-overflow]
     return a + 1 < a;
     ^~~~~~
aboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0

No i sprawa jasna! Standard C99 nie kłamał, a GCC raczył nas ostrzec. Więc o ile nie mamy w projekcie 100 ostrzeżeń, a to miałoby być nasze 101 zignorowane ostrzeżenie to udało się uniknąć kłopotów. Według standardu przepełnienie int nie ma sensu, więc kompilator skorzystał z prawa, które daje mu opisane w standardzie undefined behaviour i zamienił:

bool aboutToOverflow(int32_t a)
{
    return a + 1 < a;
}

na:

bool aboutToOverflow(int32_t a)
{
    return false;
}

Z ciekawości zobaczmy jeszcze, jakie wyniki dostaniemy dla różnych flag optymalizacji.

-OaboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 1
-OfastaboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0
-OsaboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0
-O1aboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 1
-O2aboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0
-O3aboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0
-OgaboutToOverflow(0x7FFFFFFE) = 0
aboutToOverflow(0x7FFFFFFF) = 0

Może właśnie stąd wzięło się przeświadczenie, że optymalizacje psują działanie kodu? W tym przypadku kod był zepsuty już na początku! O dziwo ostrzeżenia o wykorzystaniu undefined behaviour nie dostaniemy kompilując kod bez optymalizacji i z włączonymi ostrzeżeniami.

inne pułapki?

A Ty masz już za sobą podobne problemy? Daj znać, czy wiedziałeś o problemach z niezdefiniowanym zachowaniem integer overflow, czy jest to dla Ciebie nowość? A może spotkałeś się z innymi pułapkami? Podziel się!

Jeśli masz do mnie jakieś pytania – pisz śmiało na ask@embedcode.pl. Odpowiadam na każdą wiadomość!

Lista niezdefiniowanych zachowań jest obszerna. Ciekawsze przypadki opiszę w następnych częściach Pułapek w języku C. Do usłyszenia!