Comportement indéfini en C : ça marche chez moi, pas ailleurs
Le comportement indéfini (undefined behavior) en C, avec des exemples réels qui changent entre -O0 et -O2, et comment le détecter avec -fsanitize=undefined.
Ton programme donne le bon résultat sur ta machine et un autre chez ton binôme. Ou bien il marche parfaitement, jusqu'au jour où tu ajoutes -O2 et où une boucle ne s'arrête plus. Le compilateur n'a pas de bug : ton code contient un comportement indéfini, et à partir de là, la norme ne promet plus rien sur ce que fera le programme.
En bref
Un comportement indéfini (undefined behavior, ou UB) est une situation pour laquelle la norme C n'impose aucune exigence : débordement d'un entier signé, lecture d'une variable non initialisée, accès hors d'un tableau, double modification d'une variable dans la même expression. Le compilateur a le droit de supposer que ça n'arrive jamais et optimise en conséquence, donc le résultat change selon le niveau d'optimisation, le compilateur ou la machine. Pour les débusquer, compile avec -Wall -Wextra, puis teste avec -fsanitize=undefined, -fsanitize=address et Valgrind.
Que veut dire « comportement indéfini » ?
La norme C (C23, section 3.5.3) définit le comportement indéfini comme un comportement, face à une construction erronée ou à des données erronées, « for which this document imposes no requirements ». Aucune exigence, au sens propre : le programme peut sembler marcher, afficher n'importe quoi, planter, ou faire autre chose selon l'humeur de l'optimiseur. Une note de la norme liste les possibilités, de l'ignorer avec un résultat imprévisible jusqu'à l'arrêt de la compilation ou de l'exécution.
Il ne faut pas le confondre avec deux catégories voisines, que la norme définit juste à côté. Elles sont bien moins dangereuses, parce que le résultat reste dans un ensemble connu :
| Catégorie | Ce que dit la norme | Exemples |
|---|---|---|
| défini par l'implémentation (implementation-defined) | plusieurs choix permis, chaque compilateur documente le sien | char signé ou non, décalage à droite d'un négatif, sizeof(int) |
| non spécifié (unspecified) | plusieurs possibilités, le compilateur choisit sans avoir à documenter | ordre d'évaluation des arguments d'une fonction ou des opérandes de + |
| indéfini (undefined) | aucune exigence | débordement d'un int, variable non initialisée, accès hors tableau, i = i++ |
Le signe de char montre bien la première catégorie. Ce programme met -1 dans un char :
#include <stdio.h>
int main(void){ char c = -1;
printf("c = %d\n", c); return 0;}$ gcc -Wall -Wextra char.c -o char$ ./charc = 255$ gcc -Wall -Wextra -fsigned-char char.c -o char-signed$ ./char-signedc = -1Sur cette machine ARM64, l'ABI fait de char un octet non signé : -1 devient 255. L'option -fsigned-char impose l'autre choix, et le même code affiche -1. Les deux résultats sont corrects, documentés, et un programme qui suppose l'un des deux n'est pas portable. Mais il n'y a rien d'indéfini : tu sais d'avance que ce sera l'un ou l'autre.
L'ordre d'évaluation illustre la deuxième :
#include <stdio.h>
int f(void) { printf("f "); return 1; }int g(void) { printf("g "); return 2; }
int main(void){ printf("= %d\n", f() + g()); return 0;}$ gcc -Wall -Wextra ordre.c -o ordre$ ./ordref g = 3GCC a appelé f avant g, mais la norme l'autorise à faire l'inverse et à afficher g f = 3. Le total, lui, vaudra toujours 3. Avec un comportement indéfini, même ce total n'est plus garanti.
Un test de débordement que -O2 supprime
Le cas le plus célèbre est le débordement d'un entier signé. La norme dit que si le résultat d'une opération sort de la plage de son type, le comportement est indéfini (C23, 6.5.1). Voici une fonction qui croit détecter le débordement après coup :
#include <limits.h>#include <stdio.h>
int deborde(int x){ int suivant = x + 1;
return suivant < x;}
int main(void){ printf("deborde(INT_MAX) = %d\n", deborde(INT_MAX)); return 0;}$ gcc -Wall -Wextra -O0 deborde.c -o deborde-O0$ ./deborde-O0deborde(INT_MAX) = 1$ gcc -Wall -Wextra -O2 deborde.c -o deborde-O2$ ./deborde-O2deborde(INT_MAX) = 0Même code, même compilateur, aucun warning, deux réponses. En -O0, GCC traduit le code presque ligne à ligne : le processeur fait l'addition, le résultat fait le tour et devient négatif, le test vaut 1. En -O2, l'optimiseur raisonne autrement :
- suivant = x + 1déborde si x vaut INT_MAX
- pas de débordementle compilateur a le droit de le supposer
- suivant > xdonc toujours vrai
- return 0le test est remplacé par une constante
Si le débordement n'arrive jamais, x + 1 est toujours plus grand que x, et suivant < x est toujours faux. Le test est supprimé, et ta protection avec. -O0 ne te protège pas non plus : écrite en une seule expression, return x + 1 > x;, la même idée est simplifiée en return 1; dès la compilation, sans optimisation.
$ gcc -Wall -Wextra -O0 plus.c -o plus-O0$ ./plus-O0plus_grand(INT_MAX) = 1$ objdump -d --no-show-raw-insn plus-O0 | grep -A8 "<plus_grand>:"0000000000400644 <plus_grand>: 400644: sub sp, sp, #0x10 400648: str w0, [sp, #12] 40064c: mov w0, #0x1 // #1 400650: add sp, sp, #0x10 400654: ret
0000000000400658 <main>: 400658: stp x29, x30, [sp, #-16]!plus.c est le même programme, avec return x + 1 > x; dans une fonction plus_grand. Si l'addition faisait vraiment le tour, la réponse serait 0. Le désassemblage montre qu'il n'y a plus d'addition du tout : la fonction range 1 dans le registre de retour (mov w0, #0x1) et rend la main.
La bonne façon est de tester avant de calculer, en comparant x à la borne : x > INT_MAX - 1 ne peut pas déborder. Depuis C23, <stdckdint.h> fait le travail pour toi :
#include <limits.h>#include <stdckdint.h>#include <stdio.h>
int main(void){ int somme;
if (ckd_add(&somme, INT_MAX, 1)) printf("INT_MAX + 1 déborde\n"); if (!ckd_add(&somme, INT_MAX - 1, 1)) printf("INT_MAX - 1 + 1 = %d\n", somme); return 0;}$ gcc -Wall -Wextra ckd.c -o ckd$ ./ckdINT_MAX + 1 débordeINT_MAX - 1 + 1 = 2147483647ckd_add calcule comme si les entiers étaient infinis, range le résultat converti dans somme, et renvoie true quand il ne tenait pas. Le programme se compile sans option particulière avec GCC 15, qui est en C23 par défaut.
add_overflows
Écris add_overflows, qui dit si a + b sortirait de la plage d'un int, sans jamais calculer a + b. C'est exactement le piège de deborde.c : un test écrit après l'addition est supprimé par l'optimiseur, il faut comparer a à INT_MAX - b ou INT_MIN - b selon le signe de b. Les tests vont jusqu'aux bornes exactes.
Une boucle qui devient infinie en -O2
Le même raisonnement, appliqué à une boucle, donne un résultat plus spectaculaire :
#include <stdio.h>
int main(void){ int tours = 0;
for (int i = 1; i > 0; i *= 2) tours++; printf("%d tours\n", tours); return 0;}$ gcc -Wall -Wextra -O0 boucle.c -o boucle-O0$ ./boucle-O031 tours$ gcc -Wall -Wextra -O2 boucle.c -o boucle-O2$ timeout 3 ./boucle-O2$ echo $?124En -O0, i double jusqu'à 2^30, puis 2^30 * 2 déborde, devient négatif sur ce processeur, et la boucle s'arrête après 31 tours. En -O2, le programme ne s'arrête plus : timeout le tue au bout de 3 secondes et renvoie le code 124. Le désassemblage montre ce qu'est devenu main :
$ objdump -d --no-show-raw-insn boucle-O2 | grep -A6 "<main>:"0000000000400500 <main>: 400500: b 400500 <main> 400504: nop 400508: nop 40050c: nop 400510: nop 400514: nopUne seule instruction utile : b 400500, un saut vers sa propre adresse. Partant de 1 et ne faisant que doubler sans jamais déborder, i reste positif ; la condition i > 0 est donc toujours vraie, la boucle ne se termine jamais, et tout ce qui la suit, printf compris, n'est jamais atteint. Le compilateur a gardé une boucle vide et jeté le reste.
Une variable non initialisée
Lire une variable locale qu'on n'a jamais initialisée donne une valeur indéterminée, et le compilateur ne te doit aucune valeur particulière. Ici, signe(0) ne passe par aucune des deux affectations :
#include <stdio.h>
int signe(int x){ int s;
if (x > 0) s = 1; else if (x < 0) s = -1; return s;}
int main(void){ printf("signe(0) = %d\n", signe(0)); return 0;}$ gcc -Wall -Wextra -O0 signe.c -o signe-O0$ ./signe-O0signe(0) = 6488063$ ./signe-O0signe(0) = 1245183$ gcc -Wall -Wextra -O2 signe.c -o signe-O2signe.c: In function 'signe':signe.c:11:12: warning: 's' may be used uninitialized [-Wmaybe-uninitialized] 11 | return s; | ^signe.c:5:9: note: 's' was declared here 5 | int s; | ^signe.c: In function 'main':signe.c:16:5: warning: 's' is used uninitialized [-Wuninitialized] 16 | printf("signe(0) = %d\n", signe(0)); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~signe.c:5:9: note: 's' was declared here 5 | int s; | ^$ ./signe-O2signe(0) = 0En -O0, la fonction renvoie ce qui traînait sur la pile, et ce n'est même pas la même valeur d'une exécution à l'autre. En -O2, elle renvoie 0. Note aussi que -Wall -Wextra n'a rien dit en -O0 : la documentation de GCC précise que ces avertissements dépendent de l'optimisation. Compiler de temps en temps en -O2 -Wall fait donc apparaître des warnings que tu ne vois jamais en mode débogage.
i = i++ : modifier deux fois la même variable
La norme rend indéfinie toute expression où une même variable est modifiée deux fois sans ordre imposé entre les deux modifications, ou modifiée et lue de cette façon (C23, 6.5.1). Ses propres exemples sont i = ++i + 1; et a[i++] = i;.
#include <stdio.h>
int main(void){ int i = 5;
i = i++; printf("i = %d\n", i); return 0;}$ gcc -Wall -Wextra -O2 incr.c -o incrincr.c: In function 'main':incr.c:7:7: warning: operation on 'i' may be undefined [-Wsequence-point] 7 | i = i++; | ~~^~~~~$ ./incri = 5GCC affiche 5, en -O0 comme en -O2. Ça ne prouve rien : la norme n'interdit à aucun compilateur d'afficher 6, ni autre chose. Le warning -Wsequence-point, activé par -Wall, est la seule information fiable de cette sortie. Si tu voulais incrémenter, écris i++;, et rien d'autre.
Pourquoi le compilateur a-t-il le droit de faire ça ?
Parce qu'un programme C correct ne contient aucun comportement indéfini, et que le compilateur optimise pour les programmes corrects. Savoir qu'un int ne déborde jamais lui permet, par exemple, de simplifier x * 2 / 2 en x, ou d'établir qu'une boucle for (i = 0; i <= n; i++) fait exactement n + 1 tours, ce qu'il ne pourrait pas affirmer si i avait le droit de revenir à INT_MIN. Le prix de ces optimisations, c'est qu'un programme qui contient un UB n'a plus de sens défini du tout, pas seulement à la ligne fautive. C23 précise seulement que ce que le programme a fait d'observable avant l'opération fautive se déroule normalement.
Deux idées reçues méritent d'être corrigées. Depuis C23, la norme impose la représentation en complément à deux pour les entiers signés, mais le débordement reste un comportement indéfini : la représentation dit comment les bits sont rangés, pas ce que vaut INT_MAX + 1. Et les entiers non signés, eux, ne débordent jamais au sens de la norme : leurs calculs se font modulo 2^N. UINT_MAX + 1 vaut 0, c'est défini, portable, et souvent tout aussi faux pour ton programme.
GCC propose -fwrapv, qui définit le débordement signé comme un tour complet en complément à deux :
$ gcc -Wall -Wextra -O2 -fwrapv deborde.c -o deborde-wrapv$ ./deborde-wrapvdeborde(INT_MAX) = 1Le test de deborde.c refonctionne. C'est une option de GCC, pas du C standard : ton code reste faux pour un autre compilateur ou sans l'option. Utile pour du code existant, pas une raison d'écrire des tests après coup.
checked_size
La version non signée du problème : checked_size dit si n * size tient dans un size_t. Ici le débordement n'est pas indéfini, il fait le tour modulo 2^64, ce qui est défini et faux (SIZE_MAX * SIZE_MAX vaut 1). Il faut donc décider avec une division, size > SIZE_MAX / n, et ne pas toucher à *out quand le produit ne tient pas.
Comment détecter un comportement indéfini
Aucun outil ne voit tout, et ces quatre-là se complètent :
| Outil | Quand | Ce qu'il attrape |
|---|---|---|
-Wall -Wextra, en -O0 et en -O2 | à la compilation | i = i++, variables non initialisées, certains accès hors tableau |
-fsanitize=undefined | à l'exécution | débordement signé, indice hors bornes d'un tableau de taille connue, décalages invalides, division par zéro |
-fsanitize=address | à l'exécution | accès hors des blocs alloués, pile ou tas, utilisation après free |
| Valgrind | à l'exécution, sur le binaire tel quel | valeurs non initialisées, accès invalides au tas, fuites |
UndefinedBehaviorSanitizer (UBSan) s'active à la compilation et ajoute des vérifications autour des opérations à risque, qui s'exécutent avec le programme :
$ gcc -g -fsanitize=undefined deborde.c -o deborde-ubsan$ ./deborde-ubsandeborde.c:6:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'deborde(INT_MAX) = 1$ gcc -g -O2 -fsanitize=undefined boucle.c -o boucle-ubsan$ ./boucle-ubsanboucle.c:7:30: runtime error: signed integer overflow: 1073741824 * 2 cannot be represented in type 'int'31 toursLe message donne le fichier, la ligne, la colonne et les valeurs exactes : 1073741824 * 2, c'est 2^30 fois 2. Même en -O2, la boucle instrumentée se termine cette fois après 31 tours : l'instrumentation a changé le code généré. Par défaut, UBSan signale et laisse continuer ; le programme ne plante pas, lis donc bien ce qui s'affiche.
UBSan ne voit pas les variables non initialisées. Sur signe.c, il ne dit rien et le programme affiche toujours une valeur au hasard. Là, c'est Valgrind qui trouve, avec --track-origins=yes pour savoir d'où vient la valeur :
$ gcc -g signe.c -o signe-g$ valgrind -q --track-origins=yes ./signe-g==38== Use of uninitialised value of size 8==38== at 0x490B9F8: _itoa_word (_itoa.c:183)==38== by 0x4915227: __printf_buffer (vfprintf-process-arg.c:155)==38== by 0x4917027: __vfprintf_internal (vfprintf-internal.c:1544)==38== by 0x490CBC7: printf (printf.c:33)==38== by 0x4006A3: main (signe.c:16)==38== Uninitialised value was created by a stack allocation==38== at 0x400644: signe (signe.c:4)...signe(0) = 0L'erreur est signalée dans printf, au moment où la valeur sert vraiment, et les deux dernières lignes remontent à sa source : une variable de la pile de signe, réservée à l'entrée de la fonction (ligne 4). Les accès hors des tableaux, enfin, ont leur méthode complète, avec AddressSanitizer, dans Segmentation fault en C.
Questions fréquentes
Un comportement indéfini fait-il toujours planter le programme ?
Non, et c'est ce qui le rend dangereux. Le plus souvent, le programme continue avec une valeur fausse, ou semble marcher. Un plantage, comme un segmentation fault, est même une issue plutôt favorable, parce qu'il te montre qu'il y a un problème.
Compiler en -O0 me protège-t-il ?
Non. -O0 rend le résultat plus prévisible sur ta machine, mais la norme n'y ajoute aucune garantie. GCC simplifie x + 1 > x en 1 même en -O0, et une variable non initialisée y renvoie ce qui traîne sur la pile. Et ton programme sera un jour compilé avec d'autres options.
Le débordement d'un unsigned est-il un comportement indéfini ?
Non. Les calculs sur les types non signés se font modulo 2^N, où N est leur nombre de bits : UINT_MAX + 1 vaut 0, partout. C'est défini, mais ça reste un bug si ton programme ne s'y attendait pas.
Où trouver la liste des comportements indéfinis ?
L'annexe J.2 de la norme les passe en revue, chacun avec un renvoi vers la section concernée. Le projet N3220, librement téléchargeable, en est une version quasi finale. Pour une présentation plus lisible avec des exemples, la page « Undefined behavior » de cppreference est un bon point de départ.
Sources
cppreference : Undefined behavior, avec des exemples d'optimisation
Chris Lattner, « What Every C Programmer Should Know About Undefined Behavior », blog LLVM, mai 2011
GCC : options d'instrumentation, -fsanitize=undefined et -fsanitize=address
GCC : options de warnings, dont -Wmaybe-uninitialized et -Wsequence-point
Arm : Procedure Call Standard for the Arm 64-bit Architecture, types C et char
