Menu

Le comportement indéfini en C : ce que cela signifie et pourquoi cela mord

Le comportement indéfini, c'est du code sur lequel la norme C n'impose aucune exigence - donc n'importe quoi peut arriver, y compris l'optimiseur supprimant vos vérifications. Voici ce qui le provoque, pourquoi « ça marche sur ma machine » ne prouve rien, et les options qui l'attrapent.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

« Comportement indéfini » sonne comme du jargon pour « imprévisible ». C'est plus fort que cela. Quand la norme C dit qu'une construction a un comportement indéfini, cela signifie que la norme n'impose absolument aucune exigence sur ce que fait le programme - ni sur la valeur, ni sur l'instruction, ni sur le programme.

C'est la partie qui surprend : le comportement indéfini ne se limite pas à la ligne fautive. Un compilateur a le droit de supposer qu'il ne se produit jamais, et de réécrire le code environnant sur cette base. Le résultat peut être un programme dans lequel une vérification que vous avez clairement écrite n'existe pas dans le binaire.

Le modèle du contrat

Voyez la norme comme un contrat entre vous et le compilateur. Vous promettez de ne pas faire certaines choses ; en échange, le compilateur promet que votre programme veut dire ce qu'il dit.

N'indexez pas hors d'un tableau. Ne faites pas déborder un entier signé. Ne lisez pas une valeur non initialisée. N'utilisez pas un pointeur après l'avoir libéré. Ne modifiez pas deux fois le même objet dans une expression sans point de séquence entre les deux.

Brisez une clause et le marché est rompu - pour tout le programme, pas seulement cette ligne. Il n'y a aucun « repli raisonnable » et aucune obligation de planter.

Trois termes voisins méritent d'être séparés :

  • Comportement indéfini - n'importe quoi peut arriver. Accès hors bornes, débordement signé, utilisation après libération.
  • Comportement non spécifié - l'un de plusieurs résultats valides, et le compilateur n'a pas à vous dire lequel. L'ordre d'évaluation des arguments d'une fonction, par exemple.
  • Comportement défini par l'implémentation - l'implémentation choisit, et doit documenter son choix. Le fait que char soit signé, la taille d'un int.

Seul le premier est dangereux au sens « l'optimiseur a supprimé mon code ».

Les grandes sources

Le débordement d'entier signé

L'arithmétique non signée reboucle, et la norme le dit. L'arithmétique signée non - dépasser la plage est indéfini.

La vérification if (a > INT_MAX - b) s'exécute entièrement dans la plage valide, ce qui en fait un test de débordement correct. Écrire if (a + b < 0) effectue le débordement d'abord puis interroge le résultat - et le compilateur, autorisé à supposer que le débordement n'a jamais eu lieu, peut supprimer la vérification.

L'accès hors bornes

Lire ou écrire en dehors d'un tableau est indéfini, que cela plante ou non :

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB : l'indice 5 n'existe pas */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB meme de calculer ce pointeur */

Notez cette dernière ligne : former un pointeur au-delà d'un élément après la fin est indéfini même si vous ne le déréférencez jamais. La norme permet arr + 5 (un après la fin, pour terminer une boucle) mais pas arr + 6.

Les petits débordements sont les dangereux. Ils ne provoquent généralement pas de segfault ; ils écrasent discrètement une variable voisine, et la mauvaise réponse apparaît ailleurs.

Les lectures non initialisées

int x;
printf("%d\n", x);     /* UB : lecture d'une valeur indeterminee */

int *p;
*p = 42;               /* UB : dereferencement d'un pointeur indetermine */

Il est tentant de penser « ça contient juste du garbage », mais ce n'est pas ce que dit la norme, et les compilateurs exploitent la différence. GCC a déjà conclu qu'une variable lue avant affectation peut contenir la valeur qui lui plaît - y compris celle qui fait disparaître une branche.

Les pointeurs pendouillants

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB : utilisation apres liberation */
free(p);               /* UB : double liberation */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB : la duree de vie de l'objet a pris fin */

Les conséquences à l'exécution sont couvertes dans erreur de segmentation ; le point ici est que le plantage est l'issue chanceuse.

Le mauvais spécificateur printf

printf("%d\n", 3.14);        /* UB : %d avec un double */
printf("%s\n", 42);          /* UB : %s avec un int - plante generalement */
printf("%d %d\n", 1);        /* UB : moins d'arguments que de specificateurs */
long n = 5;
printf("%d\n", n);           /* UB sur les systemes ou long est plus large qu'int */

printf est variadique : il lit les arguments d'après la chaîne de format et ne peut pas les vérifier. Une discordance le fait lire le mauvais nombre d'octets au mauvais endroit. Compilez avec -Wall et le compilateur vérifie la chaîne de format pour vous - c'est l'un des avertissements les plus rentables du lot.

Modifier un objet deux fois dans une expression

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

Ceux-ci sont indéfinis, pas seulement « dépendants du compilateur ». Les énigmes de manuel demandant ce que vaut i = i++ + ++i n'ont pas de réponse correcte.

L'aliasing strict

Accéder à un objet via un pointeur d'un type incompatible est indéfini, et celui-ci surprend des programmeurs expérimentés :

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB : lecture d'un float a travers un int * */

Le compilateur suppose qu'un int * et un float * ne pointent jamais la même mémoire, et réordonne en conséquence. La façon définie de réinterpréter des octets est memcpy (qui s'optimise vers les mêmes instructions) ou une union :

Un char * fait exception - vous pouvez toujours inspecter les octets de n'importe quel objet à travers un unsigned char *.

Pourquoi « ça marche sur ma machine » ne prouve rien

Le comportement indéfini semble souvent fonctionner, et c'est ce qui le rend dangereux. Le programme tourne correctement pendant le développement et les tests, puis casse quand quelque chose de totalement sans rapport change :

  • Une nouvelle version du compilateur avec un optimiseur plus malin.
  • Le passage de -O0 à -O2 pour la version de production.
  • L'ajout d'une fonction sans rapport, décalant la disposition de la pile de sorte qu'un débordement atterrit désormais sur quelque chose d'important.
  • Une autre machine, une autre libc, un autre système d'exploitation.

L'apparence de fonctionnement n'est pas une preuve de justesse, car la norme n'a jamais rien promis. C'est un bug en sommeil, et le déclencheur est généralement la compilation de production.

Comment l'optimiseur l'exploite

Voici l'exemple qui tranche généralement le débat. Un programmeur écrit une vérification de nullité :

void process(int *p) {
    int value = *p;              /* dereferencement */
    if (p == NULL) {             /* puis test de nullite */
        return;
    }
    printf("%d\n", value * 2);
}

L'ordre est faux - la vérification vient après le déréférencement - mais la vérification s'exécute quand même, non ?

Elle n'y est pas obligée. Le compilateur raisonne : *p a été déréférencé, donc p ne peut pas être NULL (déréférencer NULL est indéfini, donc dans tout programme au comportement défini, il n'est pas NULL), donc p == NULL est toujours faux, donc tout le corps du if est du code mort et peut être supprimé.

La fonction compilée ne contient aucune vérification de nullité. Une instance réelle de ce motif dans le noyau Linux est devenue la CVE-2009-1897, où GCC a supprimé exactement une telle vérification et transformé une erreur d'ordonnancement d'apparence bénigne en vulnérabilité exploitable.

Un second exemple, plus petit :

/* Une verification de debordement qui ne fonctionne pas */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "a-t-il reboucle ?" */
        return -1;
    }
    return sum;
}

Pour les types signés, le compilateur peut supposer que a + b n'a pas débordé, auquel cas sum < a n'est possible que si b < 0. Avec cette hypothèse, la vérification teste autre chose que ce que l'auteur voulait, et avec b >= 0 elle peut être complètement optimisée. La version qui fonctionne vérifie d'avance :

Les deux tests restent dans la plage représentable, donc aucun débordement ne se produit et l'optimiseur n'a rien à supposer. (GCC et clang fournissent aussi __builtin_add_overflow, qui fait cela en une instruction.)

Le détecter

Les avertissements statiques d'abord - ils sont gratuits :

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

Cela attrape les discordances de chaînes de format, certaines lectures non initialisées, les comparaisons suspectes et le code inatteignable.

Puis les sanitizers, qui instrumentent le programme et signalent au moment de la violation :

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'

Combinez avec AddressSanitizer pour la moitié mémoire :

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

Ensemble, ils attrapent les accès hors bornes, les utilisations après libération, les doubles libérations, le débordement signé, les décalages invalides, les pointeurs mal alignés et les déréférencements nuls - chacun avec un fichier, une ligne et une trace de pile. Environ 2x plus lent, ce qui n'est rien pendant le développement.

valgrind ./program ne demande aucune recompilation et attrape les lectures non initialisées et les erreurs mémoire, mais pas l'UB arithmétique. clang --analyze et gcc -fanalyzer en trouvent une partie sans même exécuter le programme.

La règle pratique : faites tourner vos tests sous les sanitizers en intégration continue. L'UB qui semble fonctionner en local est exactement ce qu'ils existent pour exposer.

Vivre avec

Vous ne pouvez pas éviter le comportement indéfini en étant prudent - tout le monde finit par en écrire. Ce qui fonctionne, c'est de le rendre bruyant :

  • Compilez avec -Wall -Wextra dès le premier jour, et corrigez chaque avertissement.
  • Faites tourner les tests sous -fsanitize=address,undefined.
  • Initialisez chaque variable à la déclaration, et chaque pointeur à NULL.
  • Vérifiez les indices de tableau contre la longueur, et bouclez avec i < n.
  • Vérifiez le débordement avant l'arithmétique, avec <limits.h>.
  • Mettez un pointeur à NULL après l'avoir libéré.
  • Utilisez des types unsigned là où le rebouclage est le comportement voulu - il y est défini.
  • Préférez memcpy aux casts de pointeurs pour réinterpréter des octets.

La vitesse du C vient du fait que le compilateur est autorisé à supposer que vous avez tenu le contrat. C'est un vrai compromis, pas un défaut de conception - et les outils ci-dessus vous rendent l'essentiel de la sûreté pour un coût nul au moment du développement.

Pour le visage à l'exécution de ces règles, voir erreur de segmentation ; pour les erreurs de compilation qui viennent d'abord, erreurs courantes.

Questions fréquentes

Qu'est-ce que le comportement indéfini en C ?

Du code pour lequel la norme C n'impose absolument aucune exigence. Le compilateur est libre de produire n'importe quoi : un plantage, une mauvaise réponse, du code qui semble marcher, ou du code dont la branche fautive a été entièrement supprimée. Ce n'est ni « défini par l'implémentation » ni « aléatoire » - c'est un contrat que vous avez rompu, et plus rien n'est promis ensuite.

Pourquoi le C a-t-il du comportement indéfini au lieu de tout définir ?

La vitesse et la portabilité. Exiger une vérification de bornes à chaque accès à un tableau coûterait des performances que le C a été conçu pour ne pas payer ; définir le débordement signé comme un rebouclage imposerait des instructions supplémentaires sur du matériel qui lève une exception. Laisser ces cas indéfinis permet au compilateur de supposer qu'ils n'arrivent jamais et d'optimiser en conséquence.

Le débordement d'entier signé est-il un comportement indéfini en C ?

Oui. INT_MAX + 1 est indéfini - il n'est pas garanti de reboucler vers INT_MIN. Le débordement non signé est différent : il est parfaitement défini et reboucle modulo 2^N. C'est pourquoi les compilateurs peuvent supposer que x + 1 > x est toujours vrai pour un x signé, et supprimer une vérification de débordement écrite ainsi.

Comment détecter le comportement indéfini dans mon programme C ?

Compilez avec -Wall -Wextra pour attraper ce que le compilateur voit statiquement, puis exécutez vos tests avec -fsanitize=address,undefined, qui signale les accès hors bornes, les utilisations après libération, le débordement signé et plus encore au moment où cela se produit, en nommant le fichier et la ligne. valgrind attrape un ensemble similaire sans recompiler.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER