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.

Par
Publié le

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égorieCe que dit la normeExemples
défini par l'implémentation (implementation-defined)plusieurs choix permis, chaque compilateur documente le sienchar signé ou non, décalage à droite d'un négatif, sizeof(int)
non spécifié (unspecified)plusieurs possibilités, le compilateur choisit sans avoir à documenterordre d'évaluation des arguments d'une fonction ou des opérandes de +
indéfini (undefined)aucune exigencedé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 :

char.c
#include <stdio.h>
int main(void){    char c = -1;
    printf("c = %d\n", c);    return 0;}
Terminal
$ gcc -Wall -Wextra char.c -o char$ ./charc = 255$ gcc -Wall -Wextra -fsigned-char char.c -o char-signed$ ./char-signedc = -1

Sur 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 :

ordre.c
#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;}
Terminal
$ gcc -Wall -Wextra ordre.c -o ordre$ ./ordref g = 3

GCC 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 :

deborde.c
#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;}
Terminal
$ 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) = 0

Mê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 :

  1. suivant = x + 1déborde si x vaut INT_MAX
  2. pas de débordementle compilateur a le droit de le supposer
  3. suivant > xdonc toujours vrai
  4. return 0le test est remplacé par une constante
Comment le test disparaît en -O2

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.

Terminal
$ 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 :

ckd.c
#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;}
Terminal
$ gcc -Wall -Wextra ckd.c -o ckd$ ./ckdINT_MAX + 1 débordeINT_MAX - 1 + 1 = 2147483647

ckd_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.

C

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.

Ouvrir l'exercice

Une boucle qui devient infinie en -O2

Le même raisonnement, appliqué à une boucle, donne un résultat plus spectaculaire :

boucle.c
#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;}
Terminal
$ 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 $?124

En -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 :

Terminal
$ 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:	nop

Une 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 :

signe.c
#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;}
Terminal
$ 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) = 0

En -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;.

incr.cÀ éviter
#include <stdio.h>
int main(void){    int i = 5;
    i = i++;    printf("i = %d\n", i);    return 0;}
Terminal
$ 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 = 5

GCC 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 :

Terminal
$ gcc -Wall -Wextra -O2 -fwrapv deborde.c -o deborde-wrapv$ ./deborde-wrapvdeborde(INT_MAX) = 1

Le 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.

C

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.

Ouvrir l'exercice

Comment détecter un comportement indéfini

Aucun outil ne voit tout, et ces quatre-là se complètent :

OutilQuandCe qu'il attrape
-Wall -Wextra, en -O0 et en -O2à la compilationi = i++, variables non initialisées, certains accès hors tableau
-fsanitize=undefinedà l'exécutiondébordement signé, indice hors bornes d'un tableau de taille connue, décalages invalides, division par zéro
-fsanitize=addressà l'exécutionaccès hors des blocs alloués, pile ou tas, utilisation après free
Valgrindà l'exécution, sur le binaire tel quelvaleurs 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 :

Terminal
$ 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 tours

Le 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 :

Terminal
$ 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) = 0

L'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

À toi d'écrire le code.