1 points par GN⁺ 4 시간 전 | 1 commentaires | Partager sur WhatsApp
  • 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 à un std::atomic<int>* mal aligné est déjà un UB selon le standard, même si selon la plateforme cela peut provoquer un SIGBUS, être corrigé par le noyau ou sembler fonctionner normalement
  • Du code courant comme passer un char signé à isxdigit(), convertir un float en int, ou mal utiliser NULL avec 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 SIGBUS se 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() ou load() sur un std::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ù char est signé, il devient UB si la valeur d’entrée sort de la plage 0–127
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() sert à vérifier si un caractère est hexadécimal, et accepte aussi EOF comme argument
  • D’après C23 7.4p1, EOF est un int, ce qui permet d’en déduire qu’il s’agit d’une valeur non représentable comme unsigned char
  • isxdigit() prend un int, pas un char, et même si la conversion de char vers int est possible, les valeurs négatives d’un char signé posent problème
  • Selon C23 6.2.5 paragraphe 20, le caractère signé ou non de char dépend de l’implémentation
  • Une implémentation de isxdigit() comme celle-ci peut alors lire une mémoire inconnue via un indice négatif
    int 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 float exprimé en secondes, est courant mais contient de l’UB
    int 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_MAX n’est pas simple
    • caster le float en int peut déclencher l’UB qu’on cherchait justement à éviter
    • caster INT_MAX en float ne garantit pas une représentation exacte
    • si INT_MAX est arrondi en float vers une valeur non représentable comme int, la comparaison perd sa valeur de référence
  • Pour rendre cela sûr, il faut vérifier avec isfinite(), comparer avec des marges comme INT_MIN + 1000 et INT_MAX - 1000, puis faire un contrôle supplémentaire après la conversion et avant l’addition
    int 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 float en int, 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 nullptr sont des « null pointer constant » ; on peut ici les appeler NULL
  • Le langage C ne spécifie pas que le véritable pointeur NULL désigne l’adresse machine 0
  • Le standard C décrit une machine abstraite C, pas le matériel, et il garantit seulement que NULL et 0 se comparent comme égaux
  • Cette égalité peut simplement venir du fait que l’entier 0 est converti vers la valeur NULL native de la plateforme, qui pourrait être 0xffff
  • 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 pointeur NULL
  • Initialiser une structure à zéro puis supposer que ses membres pointeurs valent NULL pose aussi de vrais problèmes en pratique
  • Il a même existé des machines historiques où NULL n’était pas 0

Le problème d’imaginer une fonction à l’adresse 0

  • Même si, sur une machine moderne, NULL pointe vers l’adresse 0 et qu’un objet ou une fonction s’y trouve réellement, C 6.3.2.3 dit que NULL n’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:0000 ou CS:0000

Arguments variadiques et incompatibilités de type

  • Le dernier argument de execl() doit être un pointeur ; passer directement la macro NULL ou l’entier 0 peut donc être un UB
    execl("/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 NULL peut ê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 UB
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • Pour afficher un uint64_t, il faut utiliser PRIu64
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • Pour afficher un uid_t, on peut envisager un cast vers uintmax_t et PRIuMAX, mais même le caractère signé ou non de uid_t n’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, overflowed vaut 0 et non 1
    unsigned 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 devient 18446744071562067968 (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

Ressources associées

1 commentaires

 
GN⁺ 4 시간 전
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);. Si x est simplement un int, tout va bien, mais s’il est volatile, cela devient un comportement indéfini. Selon la norme C, un accès volatile a 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 eux
    On 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

    • En tant qu’auteur, je suis d’accord. Le but de cet article n’est pas d’énumérer les 283 occurrences du mot undefined dans la norme, ni tous les cas de comportement indéfini impliqués par omission
      L’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 find lise la variable automatique non initialisée status après waitpid(&status) mais avant de vérifier si waitpid() 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 exploitable
      Comme 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
    • volatile est 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 x produisait réellement une écriture mémoire côté CPU, ce qui faisait fonctionner le code des pilotes
      Mais avec l’arrivée des optimisations, le compilateur a considéré qu’il ne faisait que modifier x et l’a gardé uniquement en registre, ce qui a cassé les pilotes. Le volatile de 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 travail
      On 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 volatile seul, il n’y a aucun moyen de savoir ce qui se passe exactement ni ce que cela signifie
    • Il faut distinguer comportement indéfini et course. Cette distinction manque souvent dans les discussions sur le comportement indéfini
      Une 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
    • Le sens même de 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 change
      Le concept de variable volatile n’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émoire
      Le 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 volatile
    • Le point central de l’article est qu’il n’est même pas nécessaire d’écrire du code bizarre pour tomber sur du comportement indéfini
      Beaucoup 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* v en int* i, par exemple avec i=v en C ou f(v) lorsqu’une fonction attend un int*, constitue déjà un comportement indéfini si le pointeur résultant ne respecte pas les contraintes d’alignement d’un int
    Il 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* vers int* 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éfinition

    • En tant qu’auteur, oui, c’est exact. C’est ce que je traite dans la section « Actually, it was UB even before that » de l’article
      Je 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
    • C’est normal et prévisible. Un bon programmeur sait que les casts de pointeurs sont clairement une zone dangereuse
    • Quelqu’un peut-il indiquer où, dans la norme, il est dit qu’un pointeur non aligné est déjà un comportement indéfini en soi ?
    • Cela veut-il dire qu’avec #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 ?
    • À l’origine, la notion de comportement indéfini en C servait à laisser au compilateur la liberté d’adapter le code au matériel, même si les instructions machine variaient légèrement selon l’architecture. Le même programme C pouvait donc exprimer des comportements différents selon l’architecture sur laquelle il tournait
      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 »

    • L’étape « faire en sorte que le compilateur définisse ce qui est indéfini », on la met où ?
      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 compilateur
      Le 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 efficace
      La 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
    • En tant qu’auteur, le point de l’article est justement que « n’utilisez tout simplement pas de comportement indéfini » est impossible
      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++
    • « N’utilisez tout simplement pas de comportement indéfini » ressemble au mieux à une phase de marchandage
    • Il suffit de travailler sur des systèmes embarqués comme moi. Écrire du logiciel pour un CPU précis, c’est vraiment confortable
    • En C, l’acceptation ressemble plutôt à : « je vais utiliser du comportement indéfini, et un jour il se passera quelque chose de mauvais »
  • 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

    • Ada 83 ne traite pas le débordement de pile à l’appel comme un comportement indéfini. Le manuel de référence définit l’exception STORAGE_ERROR
      http://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 »
    • Pas du tout
      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
    • Un comportement indéfini dépendant de l’entrée peut aussi devenir une voie d’exploitation
    • Les exemples sont clairement du comportement indéfini. Point final
      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
    • Cet article est presque une définition du FUD
  • 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

    • Un exemple lié à cela : toute fonction est censée se terminer ou produire un effet de bord. Je ne l’ai jamais subi directement, mais il est tout à fait imaginable qu’en écrivant par erreur une boucle infinie ou une récursion infinie, la fonction soit supprimée
      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
    • Un crash est parmi les formes les plus bénignes de comportement indéfini. Au moins, c’est bien visible
      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
    • Oui, mais c’est aussi la fonction la plus utile du comportement indéfini et sa raison d’être
      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
    • Le passage de l’article qui dit que ce n’est pas un problème d’optimisation m’a particulièrement marqué
      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
    • Ce n’est pas un problème, c’est une fonctionnalité
  • 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

    • Hacker News reste davantage orienté langages de programmation que programmation réelle. Il y a peut-être aussi un héritage Lisp venu de Y Combinator
      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
    • De quoi tu parles ? J’utilisais déjà C et C++ il y a 20 ans, et à l’époque aussi le comportement indéfini occupait une place importante dans les discussions et les cursus
      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
    • Les anciens ordinateurs étaient cool, les ordinateurs actuels sont devenus dangereux
      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 !
    • Comme l’environnement d’exécution peut être une architecture totalement différente, ces détails sont très importants
      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
    • Plus exactement, on écrit non pas pour une spécification imaginaire, mais pour la cible visée. La spécification est utile pour prévoir grossièrement ce que la cible va faire, mais ce n’est pas l’autorité normative
      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

    • Oui. Les exemples sont soit des choses standard qu’on évite quand on écrit du code portable, soit des cas inutiles comme l’accès à un objet à l’adresse 0
      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
    • En quoi ne seraient-ils pas bons ? Si c’est vrai, c’est plutôt grave
  • 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

    • J’allais signaler qu’il s’agissait de C et non de C++, mais en revérifiant j’ai vu que c’était bien std::atomic et non atomic_int
  • Est-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

    • Intuitivement, oui. Le programme est compilé comme si aucune entrée de B ne pouvait jamais être fournie, ce qui peut inclure la suppression du code qui essaie de détecter ces entrées B
    • C’est un bon résumé
  • 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...

    • Un cas sur x86, précisément l’architecture pour laquelle on suppose souvent qu’il n’y aura pas de problème