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.
-O | aboutToOverflow(0x7FFFFFFE) = 0 aboutToOverflow(0x7FFFFFFF) = 1 |
-Ofast | aboutToOverflow(0x7FFFFFFE) = 0 aboutToOverflow(0x7FFFFFFF) = 0 |
-Os | aboutToOverflow(0x7FFFFFFE) = 0 aboutToOverflow(0x7FFFFFFF) = 0 |
-O1 | aboutToOverflow(0x7FFFFFFE) = 0 aboutToOverflow(0x7FFFFFFF) = 1 |
-O2 | aboutToOverflow(0x7FFFFFFE) = 0 aboutToOverflow(0x7FFFFFFF) = 0 |
-O3 | aboutToOverflow(0x7FFFFFFE) = 0 aboutToOverflow(0x7FFFFFFF) = 0 |
-Og | aboutToOverflow(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!