Tout, en C, relève du comportement indéfini
(blog.habets.se)- Le comportement indéfini (UB) n’est pas une optimisation malveillante du compilateur, mais une règle selon laquelle il n’a pas à traiter les chemins d’exécution impossibles dès lors que le code est supposé valide
- Dans le code C/C++ non trivial, des UB subtils liés à l’alignement, aux conversions, à l’initialisation ou aux incompatibilités de types se cachent largement, au-delà des seuls double-free ou accès hors limites
- Accéder à un
int*ou à unstd::atomic<int>*mal aligné est déjà un UB selon le standard, même si selon la plateforme cela peut provoquer unSIGBUS, être corrigé par le noyau ou sembler fonctionner normalement - Du code courant comme passer un
charsigné àisxdigit(), convertir unfloatenint, ou mal utiliserNULLavec des arguments variadiques sort facilement du cadre du standard - On ne peut pas jeter les bases de code existantes, mais il faut les corriger à grande échelle en combinant détection d’UB par LLM et validation par des experts, car ces cas sont trop subtils pour être confiés à des juniors
Le comportement indéfini en C/C++ n’est pas un problème d’optimisation
- Le comportement indéfini (UB) ne signifie pas que le compilateur « exploite » les erreurs du développeur, mais qu’il peut supposer que le programme est valide au regard du standard
- Même si l’intention paraît évidente pour un humain, il peut être difficile de l’exprimer aux différentes étapes du compilateur ou entre modules
- Le compilateur n’a pas l’obligation de gérer dans le code généré des cas particuliers censés « ne pas pouvoir arriver », et le résultat peut diverger de l’intention tout au long du chemin d’exécution, matériel compris
- Désactiver les optimisations ne rend pas l’UB sûr, et rien ne garantit qu’un même comportement sera conservé sur les compilateurs ou architectures présents ou futurs
L’UB ne se limite pas au code manifestement anormal
- Les double-free, use-after-free, accès hors des limites d’un objet et accès à de la mémoire non initialisée sont des UB bien connus, mais ils continuent d’être répétés dans toute l’industrie
- Il existe aussi de nombreux UB plus subtils et contre-intuitifs, si bien qu’un code C/C++ en apparence banal peut facilement sortir du cadre du standard
- Le standard C23 contient 283 occurrences du mot « undefined », et si l’on inclut les cas non explicités mais de fait non définis, le périmètre est encore plus large
- Dans tout code C/C++ non trivial, l’UB est partout, et il est difficile d’en faire porter la responsabilité à la seule négligence des programmeurs
Accès à des objets mal alignés
- Une fonction qui déréférence un
int*comme ci-dessous devient un UB si le pointeur n’est pas correctement alignéint foo(const int* p) { return *p; } - L’alignement (alignment) correspond souvent à une adresse multiple de
sizeof(int), mais les exigences réelles peuvent varier selon la plateforme et l’implémentation - Sur Linux Alpha, dans certains cas, le noyau pouvait intercepter le trap et émuler en logiciel l’accès voulu, mais dans d’autres le programme pouvait se terminer avec
SIGBUS - Sur SPARC, un
SIGBUSse produit, tandis que sur x86/amd64 cela fonctionne souvent sans problème apparent, voire donne l’impression d’une lecture atomique - Sur ARM, RISC-V ou de futures architectures, on ne peut rien généraliser, et une future architecture pourrait même avoir des registres spéciaux n’utilisant pas les bits de poids faible d’un
int* - Si le compilateur utilise une autre instruction de chargement, un accès auparavant corrigé par le noyau peut cesser de l’être
- Le compilateur n’a aucune obligation de générer de l’assembleur qui fonctionne avec des pointeurs mal alignés, car cet accès est déjà un UB en soi
Les types atomiques aussi deviennent UB s’ils sont mal alignés
- Même en appelant
store()ouload()sur unstd::atomic<int>*comme ci-dessous, le comportement est indéfini si l’objet n’est pas correctement alignévoid set_it(std::atomic<int>* p) { p->store(123); } int get_it(std::atomic<int>* p) { return p->load(); } - Du point de vue du standard, la question « cette opération reste-t-elle atomique sur un objet mal aligné ? » n’a même pas de sens
- En pratique matérielle, l’atomicité peut poser problème, mais au regard du standard on est déjà en UB avant cela
- Si l’objet qu’on pense lire atomiquement chevauche une page, le problème devient encore plus complexe, mais la conclusion n’est pas « ça va », c’est UB
Le simple fait de créer un pointeur peut déjà poser problème
- Avec un pointeur mal aligné, le simple cast vers un pointeur d’un certain type peut déjà être problématique, même avant tout déréférencement
bool parse_packet(const uint8_t* bytes) { const int* magic_intp = (const int*)bytes; // UB! int magic_raw = foo(magic_intp); // Probably crashes on SPARC. int magic = ntohl(magic_raw); // this is fine, at least. […] } - Ici, le problème n’est pas l’appel à
foo(), mais le cast(const int*)bytes - Le standard autorise aussi, en théorie, qu’un compilateur donne aux bits de poids faible d’un
int*une signification particulière, comme des marqueurs de garbage collection ou des bits de sécurité
Le problème de isxdigit() avec char
- Le code suivant semble simple, mais sur une architecture où
charest signé, il devient UB si la valeur d’entrée sort de la plage 0–127bool bar(char ch) { return isxdigit(ch); } isxdigit()sert à vérifier si un caractère est hexadécimal, et accepte aussiEOFcomme argument- D’après C23 7.4p1,
EOFest unint, ce qui permet d’en déduire qu’il s’agit d’une valeur non représentable commeunsigned char isxdigit()prend unint, pas unchar, et même si la conversion decharversintest possible, les valeurs négatives d’uncharsigné posent problème- Selon C23 6.2.5 paragraphe 20, le caractère signé ou non de
chardépend de l’implémentation - Une implémentation de
isxdigit()comme celle-ci peut alors lire une mémoire inconnue via un indice négatifint isxdigit(int c) { if (c == EOF) { return false; } return some_array[c]; } - Si cette mémoire correspond à une zone d’E/S mappée, on peut provoquer non seulement une valeur arbitraire ou un crash, mais aussi un comportement matériel
- C’est plus probable dans l’embarqué que dans les applications de bureau, mais certains cas comme les pilotes réseau en espace utilisateur montrent que les protections de l’espace utilisateur ne suffisent pas toujours
Le problème du cast de float vers int
- Un code comme celui-ci, qui convertit en millisecondes un
floatexprimé en secondes, est courant mais contient de l’UBint milliseconds(float seconds) { int tmp = (int)(seconds * 1000.0); /* WRONG */ return tmp + 1; /* WRONG separately (signed overflow is UB) */ } - C23 6.3.1.4 stipule que lors de la conversion d’une valeur flottante réelle finie vers un type entier, si la partie entière ne peut pas être représentée par ce type entier, le comportement est indéfini
- Pour les valeurs non finies, l’absence de précision explicite conduit aussi à de l’UB
- Même comparer un
floatàINT_MAXn’est pas simple- caster le
floatenintpeut déclencher l’UB qu’on cherchait justement à éviter - caster
INT_MAXenfloatne garantit pas une représentation exacte - si
INT_MAXest arrondi enfloatvers une valeur non représentable commeint, la comparaison perd sa valeur de référence
- caster le
- Pour rendre cela sûr, il faut vérifier avec
isfinite(), comparer avec des marges commeINT_MIN + 1000etINT_MAX - 1000, puis faire un contrôle supplémentaire après la conversion et avant l’additionint milliseconds(float seconds) { const float ftmp = seconds * 1000.0f; if (!isfinite(ftmp)) { return 0; } if ((float)(INT_MIN + 1000) > ftmp) { return 0; } if ((float)(INT_MAX - 1000) < ftmp) { return 0; } const int tmp = (int)ftmp; if (INT_MAX == tmp) { return 0; } return tmp + 1; } - Alors qu’on voulait simplement convertir un
floatenint, la version sûre devient bien plus longue
Les objets à l’adresse 0 et le pointeur nul
- Dans le code de noyau ou l’embarqué, on peut rencontrer le besoin de placer un objet à l’adresse 0
- En pratique, il n’existe pas vraiment de méthode conforme au standard C pour placer réellement un objet à l’adresse 0
- En C 6.3.2.3, la constante entière 0 convertible en pointeur et
nullptrsont des « null pointer constant » ; on peut ici les appelerNULL - Le langage C ne spécifie pas que le véritable pointeur
NULLdésigne l’adresse machine 0 - Le standard C décrit une machine abstraite C, pas le matériel, et il garantit seulement que
NULLet 0 se comparent comme égaux - Cette égalité peut simplement venir du fait que l’entier 0 est converti vers la valeur
NULLnative de la plateforme, qui pourrait être0xffff - Déréférencer un pointeur nul est un UB quelle que soit sa valeur, et c’est même l’exemple canonique donné en C 3.4.3
- On ne peut donc pas supposer que
memset(&ptr, 0, sizeof(ptr));produise un pointeurNULL - Initialiser une structure à zéro puis supposer que ses membres pointeurs valent
NULLpose aussi de vrais problèmes en pratique - Il a même existé des machines historiques où
NULLn’était pas 0
Le problème d’imaginer une fonction à l’adresse 0
- Même si, sur une machine moderne,
NULLpointe vers l’adresse 0 et qu’un objet ou une fonction s’y trouve réellement, C 6.3.2.3 dit queNULLn’est égal à aucun objet ni aucune fonction - Le code suivant est donc un UB
void (*func_ptr)() = NULL; func_ptr(); - Du point de vue du C, cela signifie qu’« il n’y a pas de fonction à cet endroit », et il peut ne pas exister de moyen d’exprimer une autre intention à l’intérieur du compilateur
- On ne peut pas simplement supposer qu’une instruction d’appel vers une adresse dont tous les bits valent zéro sera émise
- En x86 16 bits, on ne sait même pas clairement si « tous les bits à zéro » signifie
0000:0000ouCS:0000
Arguments variadiques et incompatibilités de type
- Le dernier argument de
execl()doit être un pointeur ; passer directement la macroNULLou l’entier 0 peut donc être un UBexecl("/bin/sh", "sh", "-c", "date", NULL); /* WRONG */ execl("/bin/sh", "sh", "-c", "date", 0); /* WRONG */ - La forme correcte consiste à caster explicitement vers un type pointeur
execl("/bin/sh", "sh", "-c", "date", (char*)NULL); - La macro
NULLpeut être interprétée comme l’entier 0, et dans des arguments variadiques l’information de type nécessaire n’est pas transmise - Avec
printf(), si le spécificateur de format ne correspond pas au type réel de l’argument, c’est aussi un UBuint64_t blah = 123; printf("%ld\n", blah); /* WRONG */ - Pour afficher un
uint64_t, il faut utiliserPRIu64uint64_t blah = 123; printf("%"PRIu64"\n", blah); - Pour afficher un
uid_t, on peut envisager un cast versuintmax_tetPRIuMAX, mais même le caractère signé ou non deuid_tn’est pas garanti - Dans le pire des cas, on peut afficher une valeur absurde au lieu de
-1
Division par zéro et problèmes de sécurité
- Le fait que la division par zéro soit un UB est largement connu, mais si le dénominateur provient d’une entrée non fiable, cela devient un problème de sécurité
- L’important est qu’il ne s’agit pas seulement d’une simple erreur d’exécution : de l’UB peut apparaître à la frontière même de la validation des entrées
Ce n’est pas de l’UB, mais les promotions entières restent risquées
- Les règles de promotion entière sont difficiles à appliquer à la vitesse d’une simple relecture de code, et elles peuvent produire des résultats contraires à l’intuition
- Dans le code suivant,
overflowedvaut 0 et non 1unsigned char a = 0xff; unsigned char b = 1; unsigned char zero = 0; bool overflowed = (a + b) == zero; // overflowed is set to zero, not one. - Dans l’exemple suivant, toutes les variables semblent unsigned, mais le résultat n’est pas
2147483648 (0x80000000); il devient18446744071562067968 (ffffffff80000000)unsigned char a = 0x80; uint64_t b = a << 24; // Bonus UB(?) - Même hors UB, les règles entières de C/C++ restent peu intuitives et propices aux défauts
Détecter l’UB avec des LLM
- Les LLM récents, lorsqu’on leur demande de trouver de l’UB dans du code C arbitraire, détectent presque toujours des problèmes et produisent généralement des réponses justes
- Après avoir trouvé de l’UB dans du code personnel, la même approche a été appliquée à du code OpenBSD pourtant mature et écrit avec rigueur
- Plusieurs problèmes ont été trouvés dans
find, l’outil choisi en premier - Des correctifs ont été envoyés à OpenBSD pour une écriture hors limites et pour un bug logique qui n’était pas de l’UB
- Beaucoup d’autres UB restants n’ont pas fait l’objet de correctifs envoyés
- Le projet OpenBSD avait par le passé montré peu d’ouverture aux rapports de bugs
- Certains cas semblaient pouvoir être acceptables en pratique
- Pour éliminer l’UB de sa base de code, OpenBSD aurait besoin d’un projet d’ampleur plus importante qu’un simple flux de correctifs individuels transmis entre un LLM et le projet
Une voie réaliste pour les bases de code C/C++
- On ne peut pas jeter les bases de code C/C++ existantes, mais les laisser dans un état fondamentalement cassé n’est pas non plus une option
- Il faut corriger l’UB à grande échelle sans committer des modifications médiocres générées par l’IA ni submerger les relecteurs humains
- En 2026, écrire du C ou du C++ sans supervision d’un LLM sur les UB pourrait être perçu comme une violation de la SOX et comme une attitude irresponsable
- Si même les développeurs d’OpenBSD n’ont pas réussi à repérer tous ces problèmes en plus de 30 ans, les autres projets ont encore moins de chances d’y parvenir
- Sur un projet personnel, on peut demander à un LLM de trouver les UB, de les expliquer si besoin, de proposer des corrections, puis faire valider le résultat par un humain
- Mais pour vérifier ces résultats, il faut des experts, et ces experts sont en général déjà occupés à autre chose
- Cela ressemble à une tâche de nettoyage, mais c’est en réalité trop subtil pour être confié aux programmeurs juniors auxquels ce type de travail revenait traditionnellement
1 commentaires
Commentaires sur Hacker News
Il existe beaucoup de comportements indéfinis en C, étonnants et étranges, mais cet article ne les montre pas très bien et ne fait qu’en effleurer la surface
Un exemple encore plus étrange serait
volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);. Sixest simplement unint, tout va bien, mais s’il estvolatile, cela devient un comportement indéfini. Selon la norme C, un accèsvolatilea un effet de bord rien qu’à la lecture, et des effets de bord non ordonnés sur le même objet scalaire constituent un comportement indéfini, car l’évaluation des arguments d’une fonction n’a pas d’ordre déterminé entre euxOn entend souvent par data race le fait que différents threads accèdent simultanément au même objet, avec au moins une écriture, mais en C une situation proche d’une course peut apparaître dans un seul thread, même sans écriture
undefineddans la norme, ni tous les cas de comportement indéfini impliqués par omissionL’idée essentielle est que c’est inévitable. Au moins depuis 1972, date d’apparition du C, aucun humain n’a réussi à l’éviter complètement
Si cela n’a pas marché en 54 ans, alors « faites plus attention » ou « ne faites pas d’erreur » ne sont pas des solutions. Une faille exploitable trouvée par Mythos dans OpenBSD avait pourtant été plutôt bien accueillie par les développeurs d’OpenBSD, mais dès qu’on applique des outils à du code très simple, on tombe sur une grande quantité de comportements indéfinis
Par exemple, le fait que
findlise la variable automatique non initialiséestatusaprèswaitpid(&status)mais avant de vérifier siwaitpid()a renvoyé une erreur est aussi un comportement indéfini, même s’il est difficile d’imaginer une architecture ou un compilateur où cela serait exploitableComme je l’ai écrit dans l’article, le but n’est pas de lister tous les comportements indéfinis du monde, mais de dire que tout code C/C++ non trivial contient du comportement indéfini
volatileest un hack du système de types. Il aurait fallu une solution plus principielle, et les langages modernes ne devraient pas l’imiter comme si « puisque C l’a fait, c’était une bonne idée »Les premiers compilateurs C écrivaient toujours les valeurs en mémoire, donc si un pointeur visait du matériel d’E/S mappé en mémoire, chaque modification de
xproduisait réellement une écriture mémoire côté CPU, ce qui faisait fonctionner le code des pilotesMais avec l’arrivée des optimisations, le compilateur a considéré qu’il ne faisait que modifier
xet l’a gardé uniquement en registre, ce qui a cassé les pilotes. Levolatilede C est un hack qui dit au compilateur « n’applique pas cette optimisation », alors qu’une vraie solution — fournir au niveau bibliothèque des intrinsics d’E/S mappées en mémoire — aurait demandé bien plus de travailOn a besoin d’intrinsics parce qu’elles permettent d’exprimer précisément ce qui est possible et ce qui ne l’est pas. Sur certaines cibles, des écritures de 1, 2 ou 4 octets ont chacune une signification différente, et le matériel fait bien la distinction. Certains périphériques attendent une écriture RGBA de 4 octets, et si on émet à la place quatre écritures d’un octet, ils peuvent se comporter de façon étrange ou ne pas fonctionner. Certaines cibles supportent même des écritures au bit près. Avec
volatileseul, il n’y a aucun moyen de savoir ce qui se passe exactement ni ce que cela signifieUne fois qu’un programme C est compilé puis désassemblé, on obtient un programme assembleur sans comportement indéfini, car l’assembleur n’a pas cette notion
Le comportement indéfini est une propriété du programme source, pas du binaire exécutable. Cela signifie que la spécification du langage dans lequel le source est écrit n’attribue aucun sens à ce programme. En revanche, le binaire résultant de la compilation reçoit un sens de la spécification de la machine
Une course est une propriété du comportement d’un programme. On peut donc dire qu’un programme C contient du comportement indéfini, mais pas qu’un binaire « produit réellement une course ». Bien sûr, comme un compilateur peut compiler librement un programme contenant du comportement indéfini, il pourrait en introduire une, mais s’il compile le programme sans créer de nouveaux threads, il n’y a pas de course
volatile, c’est que la valeur peut être modifiée par autre chose. Pour une variable globale, cet « autre chose » peut être un autre thread, mais aussi une interruption ou un gestionnaire de signal. Pour un pointeur lisant à une adresse donnée, cela peut être un registre matériel dont la valeur changeLe concept de variable
volatilen’est pas en soi le problème. Si un langage veut prendre en charge les routines d’interruption et les E/S mappées en mémoire, il doit fournir un moyen d’indiquer au compilateur que lire deux fois le même registre matériel n’est pas équivalent à lire deux fois le même emplacement mémoireLe vrai problème est que l’interaction entre les fonctionnalités et les contraintes du langage n’a pas été suffisamment clarifiée. Dire « cette valeur peut changer à tout moment » puis considérer certains usages comme indéfinis précisément pour cette raison est absurde. Il aurait dû y avoir une exception à la définition des « effets de bord non ordonnés » pour les variables
volatileBeaucoup de gens s’imaginent que C et C++ sont « très flexibles parce qu’ils permettent de faire ce qu’on veut ». En réalité, presque toutes les techniques qui ont l’air puissantes et élégantes sont un champ de mines de comportements indéfinis
Le comportement indéfini lié aux pointeurs non alignés est encore pire. Un pointeur non aligné est un comportement indéfini non seulement lors de l’accès, mais du seul fait d’exister
Ainsi, convertir implicitement un
void* venint* i, par exemple aveci=ven C ouf(v)lorsqu’une fonction attend unint*, constitue déjà un comportement indéfini si le pointeur résultant ne respecte pas les contraintes d’alignement d’unintIl est important de comprendre que c’est un problème au niveau du C. Si un programme C contient du comportement indéfini, alors formellement ce programme C n’est pas valide et est incorrect. Ce n’est pas un problème matériel, ni une question de crash ou de bug
En code machine, le cast de
void*versint*n’est généralement rien du tout, et comme les types n’existent qu’en C, le matériel ne va pas planter à ce moment-là. On pourrait croire que tout va bien tant que la valeur entière dans le registre est correcte, mais le point essentiel est que la question n’est pas de savoir si le pointeur est « réellement un entier » au niveau matériel : dès l’instant où on effectue ce cast vers un pointeur non aligné, le programme C est déjà cassé par définitionJe voulais aussi faire passer l’idée que le comportement indéfini n’existe pas au niveau matériel et n’a rien à voir avec un crash ou une panne. En même temps, je voulais montrer des exemples à ceux qui disent « pourtant ça a l’air de marcher », alors qu’en réalité ce n’est pas le cas
#pragma pack(push, 1)pour définir une structure, on ne peut pas utiliser les pointeurs sur membres sauf quand le membre tombe par hasard sur un alignement correct ?Ce type de comportement indéfini est acceptable, et presque personne ne considère comme un gros problème le fait que des différences matérielles puissent provoquer des bugs
Mais avec le temps, les interprétations agressives ont transformé le C en une sorte de langage de design by contract implicite, où les contraintes sont invisibles. Cela crée un problème comparable au fait que les appels implicites au destructeur en RAII sont invisibles
En C, dès qu’on déréférence un pointeur, le compilateur ajoute implicitement à la signature de la fonction une contrainte « non nul ». Si l’on passe à une fonction un pointeur qui peut être nul, au lieu de signaler une erreur comme l’absence de test ou d’assertion, le compilateur propage silencieusement cette contrainte de non-nullité au pointeur. Et s’il prouve ensuite que cette contrainte est fausse, il marque la fonction comme inatteignable, puis tout appel à cette fonction rend à son tour la fonction appelante inatteignable
Les 5 étapes de l’apprentissage du comportement indéfini en C
Déni : « Je sais ce qui se passe avec le dépassement signé sur ma machine »
Colère : « Ce compilateur est nul ! Pourquoi il ne fait pas ce que je lui demande ? »
Marchandage : « Je vais soumettre cette proposition au wg14 pour corriger C… »
Dépression : « Peut-on encore faire confiance à du code C ? »
Acceptation : « N’utilisez tout simplement pas de comportement indéfini »
Les accès non alignés se règlent avec des structures packées. Le compilateur génère alors comme par magie le bon code. En réalité, il a toujours su le faire correctement, il a juste choisi de ne pas le faire autrement
Pour l’aliasing strict, il suffit d’utiliser une conversion via union. Tout compilateur important documente que cela fonctionne, même si la norme ne le dit pas. Sinon, on peut simplement désactiver la règle avec
-fno-strict-aliasing. On peut alors réinterpréter la mémoire comme on veut ; il restera peut-être des coins coupants, mais au moins ils ne viendront pas du compilateurLe dépassement se définit avec
-fwrapv. Si on remplace+,-et*par__builtin_*_overflow, on obtient en plus une vérification d’erreur explicite gratuitement. L’interface fonctionnelle est propre, et le code généré reste efficaceLa vraie acceptation ressemble plutôt à : « personne de normal ne se soucie de la norme C ». La norme est mauvaise ; ce qui compte, c’est le compilateur. Et les compilateurs ont beaucoup de fonctionnalités très utiles pour contourner la plupart de ces problèmes. Si les gens ne les utilisent pas, c’est parce qu’ils veulent écrire du C « portable » et « standard ». Sortir de cet état d’esprit, c’est la vraie acceptation
Avec cette logique, j’ai construit un interpréteur Lisp en C en environnement freestanding, et il passait aussi UBSan. Je pensais au départ qu’il allait exploser, mais non ; si j’y arrive, n’importe qui le peut
Tant que des humains écriront le code, cela ne pourra pas être l’état final. Aucun être humain ne peut éviter complètement le comportement indéfini en C/C++
Les exemples ressemblent moins à de vrais comportements indéfinis qu’à des cas qui peuvent devenir indéfinis selon l’entrée ou la situation
En prenant une définition aussi large, tout appel de fonction devient aussi un comportement indéfini, puisqu’on peut toujours dépasser la pile. En réalité, on pourrait dire la même chose de presque n’importe quel langage dans ce sens
Le C a déjà assez de vrais angles morts notables ; ce type de sensationnalisme risque surtout de distraire les débutants et peut même être contre-productif
STORAGE_ERRORhttp://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
Il y est précisé que cette exception se produit aussi « lorsqu’il n’y a pas assez d’espace de stockage pendant l’exécution d’un appel de sous-programme »
D’abord, on peut définir ce qui se passe lorsqu’on dépasse l’espace de pile. Ensuite, tous les programmes n’ont pas besoin d’une pile de taille arbitraire, et certains n’exigent qu’une taille constante calculable à l’avance. Certaines implémentations de langages n’utilisent même pas de pile
Un langage peut aussi fournir des outils pour vérifier l’espace de pile restant et garantir certains comportements en conséquence. Ou permettre d’installer un gestionnaire qui s’exécute lorsqu’il n’y a plus de pile
La bonne manière de penser est qu’au moment où du comportement indéfini apparaît, vous n’êtes plus protégé par la norme du langage. Cela peut continuer à marcher pendant un moment, voire pour toujours. Mais en pratique, vous devenez sans le savoir dépendant des caprices de la toolchain, d’un changement ou d’une mise à jour du compilateur, de l’architecture, du runtime ou de la version de la libc
Au final, vous construisez sur du sable, et c’est cela le danger du comportement indéfini
Le problème du comportement indéfini n’est pas qu’il puisse planter sur certaines architectures
Le vrai problème est que le compilateur suppose qu’un tel code n’arrive jamais. Si vous écrivez malgré tout du code à comportement indéfini, le compilateur — surtout l’optimiseur — peut le traduire dans n’importe quelle forme qui l’arrange sur le chemin normal. Et ce « n’importe quoi » peut parfois être très inattendu, comme la suppression de gros pans de code
Avec en plus de la récursion terminale, on peut même imaginer un bug qui n’apparaît pas en build debug parce qu’on n’atteint pas la boucle infinie, puis se manifeste uniquement en augmentant le niveau d’optimisation
Dans les cas pires, le programme peut continuer silencieusement avec des valeurs absurdes, formater le disque dur ou donner à un attaquant les clés du royaume
Ceux qui veulent simplement le définir ou le transformer en comportement non spécifié passent à côté du point essentiel : permettre au compilateur de supprimer de larges parties d’un programme
Si vous écrivez du code qui devient indéfini pour certaines entrées, cela signifie que pour ces entrées, vous entendez que le programme n’ait aucun comportement. Vous voulez que le compilateur puisse optimiser ce chemin hors de l’existence, ou faire quoi que ce soit d’utile pour le comportement des autres cas définis
C’est assez satisfaisant d’insérer une chaîne de log atteignable uniquement via du comportement indéfini et de constater qu’elle ne reste même pas dans le binaire
J’ai autrefois écrit une passe d’analyse en supposant qu’elle s’exécutait en toute fin du pipeline de transformations, et cette hypothèse était nécessaire à la correction. Je pensais que c’était sûr puisqu’il n’y aurait plus d’optimisations ensuite, mais maintenant je n’en suis plus certain
J’utilise le C depuis 20 ans, mais je n’ai jamais autant entendu parler de comportement indéfini que dans les six derniers mois sur Hacker News
Dans les discussions réelles, le sujet ne revenait presque jamais. On écrit le code, et si ça ne marche pas, on débogue puis on corrige ou on contourne. Je ne comprends pas pourquoi le sujet du comportement indéfini en C revient si régulièrement à la une
Il existe toujours une minorité d’informaticiens qui trouvent que développer ou utiliser un nouveau langage est la chose la plus passionnante au monde, et certains le pensent encore durablement
Il est naturel que ces personnes s’intéressent aux aspects de conception des langages, et le comportement indéfini du C relève de ce domaine. Cela dit, une grande partie provenait à l’origine de la volonté de prendre en charge d’anciennes architectures CPU sans perte de performance ; ce n’est pas non plus un « choix de conception » au même titre que dire qu’une roue est ronde
Il y a eu plusieurs « scandales » assez connus quand les compilateurs, autour de GCC 3.2, ont commencé à exploiter beaucoup plus agressivement le comportement indéfini dans les optimisations, ce qui a poussé beaucoup de gens à rester longtemps sur GCC 2.95. GCC 3.2 est sorti en 2002
Toutes les entreprises martèlent en permanence les questions de sécurité et d’exposition, c’est-à-dire le fait de finir dans l’actualité, si bien que le récit opposé au « pas sûr » a pris une ampleur excessive
Le nouveau monde ressemble à des citadins qui n’ont jamais vu la nature à l’état brut et qui paniquent devant une tondeuse à gazon. Des lames qui tournent ? Mais c’est de la folie !
Si la cible réelle est un petit système embarqué au sommet d’une tour de télécommunication isolée, alors « ça marche sur ma machine » ne sert à rien. Bien sûr, la plupart des gens ne font pas ce genre de travail, et la majorité des développeurs ici sont probablement des développeurs web, mais la discussion reste intéressante même sans l’avoir vécu directement. Peut-être même davantage dans ce cas
Un compilateur peut avoir des bugs là où, selon la spécification, il devrait se comporter correctement ; il existe aussi de nombreuses extensions sans équivalent standard, et des comportements que la norme laisse indéfinis mais auxquels une implémentation donnée attribue malgré tout un résultat significatif
Je suis globalement d’accord avec l’introduction, mais les exemples sont mauvais et tout l’article ressemble à un habillage destiné à promouvoir le codage avec des LLM
Cela donne l’impression d’une personne qui veut écrire n’importe quel code comme bon lui semble et obtenir le même comportement sur tous les environnements. Si on conçoit le langage ainsi, on perd l’avantage de pouvoir cibler une plateforme de manière adaptée quand on le souhaite
Le code C++ de l’article n’était pour partie plus idiomatique depuis plus de dix ans, et serait aujourd’hui considéré comme une odeur de code
Le langage a évolué au point de devenir assez différent de ce qu’il était à l’origine. Dès qu’on voit partout des pointeurs bruts et des accès directs aux pointeurs, il devient clair qu’il faut lire certaines parties de l’article avec précaution
Un autre problème évident est la tendance à regrouper C et C++ comme s’il s’agissait presque du même langage. Aujourd’hui, ils se sont en réalité beaucoup éloignés l’un de l’autre
std::atomicet nonatomic_intEst-ce qu’on peut comprendre le comportement indéfini en C de cette manière ?
Le programme P possède un ensemble d’entrées A qui ne déclenchent pas de comportement indéfini, et un ensemble complémentaire B qui en déclenchent
Un compilateur correct compile P en un exécutable P'. Pour toute entrée de A, P' doit se comporter comme P
Mais pour toute entrée de B, le comportement de P' n’est soumis à aucune exigence
Un exemple concret de comportement indéfini causé par des pointeurs non alignés : https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...