1 points par GN⁺ 19 시간 전 | 1 commentaires | Partager sur WhatsApp
  • Les règles du langage C peuvent faire basculer même un code en apparence simple — comme la comparaison de pointeurs, l’aliasing, les pointeurs nuls ou les valeurs non initialisées — dans un comportement indéfini
  • Les constantes entières, sizeof, les constantes caractère et l’arithmétique sur uint8_t peuvent produire des résultats différents selon la plateforme, la notation et l’emplacement des affectations intermédiaires, en raison du choix des types et des promotions entières
  • Dans les déclarations de fonctions, foo() et foo(void), l’absence de prototype, les promotions par défaut des arguments et les fonctions sans valeur de retour diffèrent en légalité ou en comportement entre C et C++
  • Un tableau n’est pas un pointeur ; les paramètres de tableau sont ajustés en pointeurs, et a, &a, &a[0], même s’ils ont la même adresse, ont des types différents et ne sont donc pas interchangeables
  • La priorité des opérateurs et l’ordre d’évaluation sont distincts ; y compris pour la structure du corps d’un switch et la durée de vie des objets temporaires, c’est la formulation de la norme qui détermine le résultat réel de l’exécution

Comportement indéfini et règles sur les pointeurs

  • Comparaison de pointeurs et règle de strict aliasing

    • Même si deux pointeurs de même type p et q pointent vers la même adresse, si ils proviennent d’objets différents et ne font pas partie du même objet aggregate ou union, la comparaison p == q peut constituer un comportement indéfini
    • L’idée qu’un pointeur est plus abstrait qu’une simple adresse numérique est développée dans cet article
    • Accéder à un objet int via une lvalue short constitue un comportement indéfini selon la règle de strict aliasing
    • Un pointeur vers unsigned char constitue une exception et peut aliaser n’importe quel objet ; il est donc légal d’accéder à un objet int via une lvalue unsigned char
    • Il est garanti que unsigned char n’a ni bits de bourrage ni trap representation, et depuis C11 il est aussi garanti que signed char n’a pas de bits de bourrage
    • L’analyse d’aliasing fondée sur les types est traitée dans cet article
  • Pointeur nul et représentation des pointeurs

    • La représentation binaire d’un pointeur nul n’est pas nécessairement composée de bits tous à 0
    • La norme C définit une null pointer constant, mais ne définit ni la représentation d’un pointeur nul à l’exécution, ni celle d’un pointeur ordinaire
    • La Symbolics Lisp Machine 3600 utilise un tuple de la forme <array-object, index> au lieu de pointeurs numériques, et la représentation du pointeur nul est <nil, 0>
    • D’autres exemples figurent dans la FAQ clc 5.17
    • La constante 0 devient selon le contexte soit un entier, soit un pointeur nul, et (void *)0 est évalué comme un pointeur nul
    • Le fait qu’une expression e s’évalue à 0 ne garantit pas que (void *)e devienne un pointeur nul
    • Ce n’est que lorsqu’une null pointer constant est convertie vers un type pointeur qu’elle est garantie égale au pointeur nul
    • L’arithmétique sur un pointeur nul est un comportement indéfini ; même si e est un pointeur nul, e + 0 n’est donc pas garanti être un pointeur nul
  • Valeurs non initialisées

    • Lorsqu’on lit un objet à durée de stockage automatique non initialisé, si cet objet peut avoir la classe de stockage register et que son adresse n’a jamais été prise, cela constitue un comportement indéfini selon C11 § 6.3.2.1 ¶ 2
    • Cette règle est liée à l’architecture Intel Itanium évoquée dans DR338
    • Les registres entiers généraux d’Itanium comportent 64 bits plus un trap bit, ce dernier correspondant à NaT (not-a-thing), qui indique si le registre a été initialisé
    • Si l’on prend l’adresse de la variable, cette condition disparaît, mais la valeur reste indéterminée et peut être soit une trap representation, soit une valeur non spécifiée
    • Lire une trap representation constitue un comportement indéfini selon C11 § 6.2.6.1 ¶ 5
    • S’il s’agit d’une valeur non spécifiée, le résultat de x != x peut être true comme false, et si int x est non spécifié, rien ne garantit que x vaille 0 après x *= 0
    • Les notions de valeur indéterminée et de valeur non spécifiée sont discutées dans DR260, DR451, N1793, N1818, N2012, N2013, N2221
  • unsigned char et memcpy

    • Le type unsigned char n’a pas de trap representation selon C11 § 6.2.6.1 ¶ 3, donc sa valeur initiale est non spécifiée
    • Une réponse sur StackOverflow d’un membre du comité C affirme qu’après un appel à la fonction de bibliothèque standard memcpy, la valeur de x devrait devenir spécifiée ; selon cette interprétation, x != x devient donc false
    • Le fondement textuel de cette lecture dans la norme C n’est pas clair, et la réponse du comité dans DR451 entre en conflit avec cette interprétation en affirmant que l’utilisation d’une valeur indéterminée avec une fonction de bibliothèque constitue un comportement indéfini
    • La question reste ouverte ; voir aussi Uninitialized Reads pour des discussions supplémentaires

Constantes entières, promotions, sizeof

  • Notation et types des constantes entières

    • Une constante entière décimale sans suffixe est toujours choisie dans la liste des types signed, alors qu’une constante octale ou hexadécimale peut être de type signed ou unsigned
    • Conformément à C17 § 6.4.4.1, le type d’une constante entière est déterminé comme le premier type de la liste capable de représenter cette valeur
    • Sans suffixe, les constantes décimales suivent l’ordre int, long int, long long int, tandis que les constantes octales et hexadécimales suivent l’ordre int, unsigned int, long int, unsigned long int, long long int, unsigned long long int
    • Les constantes comprises entre INT_MAX+1 et UINT_MAX peuvent avoir un type différent selon qu’elles sont écrites en décimal ou en hexadécimal, ce qui peut créer des différences dans du code sensible à l’ABI, comme les appels à des fonctions variadiques
    • Dans l’ABI Arm 32 bits, int et long sont passés sur un seul registre de 32 bits, tandis que long long est passé sur deux registres pour un total de 64 bits
    • Sur une plateforme où int fait 32 bits, -1 < 0x8000 vaut true, alors que sur une plateforme où int fait 16 bits, l’expression vaut false, ce qui peut poser un problème de portabilité
    • La différence de type des constantes peut aussi modifier le résultat dans des expressions impliquant generic selection, la surcharge de fonctions en C++, ou des expressions comme sizeof(0x80000000) == sizeof(2147483648)
  • sizeof(int) > -1

    • L’opérateur sizeof renvoie un entier unsigned de type size_t
    • Selon les usual arithmetic conversions de C11 § 6.3.1.8, si un opérande signed a un rang inférieur à celui d’un opérande unsigned, il est converti vers le type unsigned de même rang
    • Un entier signed correspondant à -1, converti en unsigned, devient l’entier unsigned maximal de ce rang
    • Par conséquent, sizeof(int) > -1 est toujours évalué à false
  • Type des constantes caractère

    • En C, une constante caractère est de type int selon C11 § 6.4.4.4 ¶ 10
    • Par conséquent, rien ne garantit que sizeof(char) == sizeof('x') soit toujours true, seule l’égalité sizeof(int) == sizeof('x') est garantie
    • Une integer character constant peut être composée d’une ou plusieurs séquences de caractères multioctets, donc 'abc' est aussi valide, et sa représentation est définie par l’implémentation
    • La valeur d’une integer character constant contenant un seul caractère est égale à la représentation entière d’un objet de type char représentant ce même caractère unique
  • Arithmétique sur uint8_t et division

    • Même si a, b et c sont initialisés avant lecture, les valeurs de x et z peuvent différer à cause des promotions entières et de l’emplacement de l’affectation intermédiaire
    • La valeur de chaque variable est promue à la taille de int, puis l’addition et la division sont effectuées, et chaque résultat d’affectation est tronqué pour être stocké dans le type de la variable concernée
    • Par exemple, si a=255, b=1, c=2, alors x vaut ((255 + 1) / 2) % 256 = 128
    • La variable intermédiaire y devient (255 + 1) % 256 = 0, puis z devient (0 / 2) % 256 = 0, donc 128 != 0
    • Le dépassement de capacité d’un entier unsigned est un comportement défini
    • Comme l’opération modulo est distributive par rapport à l’addition, si l’on remplace la division par une addition, x et z sont toujours égaux
    • Si l’on remplace aussi la première affectation par uint8_t x = ((uint8_t)(a + b)) / c;, alors x et z sont également toujours égaux
  • Variables const et variable length array

    • Même si des variables n et m qualifiées const sont utilisées comme tailles de tableau, elles ne constituent pas des integer constant expressions en C
    • Dans C11 § 6.6 ¶ 6, une integer constant expression se limite à une integer constant, une enumeration constant, une character constant, un sizeof, _Alignof ou cast dont le résultat est un entier constant, avec comme opérande immédiat une floating constant, etc.
    • Si l’expression de taille d’un tableau n’est pas une integer constant expression, alors il s’agit d’une variable length array selon C11 § 6.7.6.2 ¶ 4
    • Une variable length array n’est pas autorisée en portée fichier, donc l’unité de compilation contenant le tableau global x ne compile pas
    • En portée bloc, les variable length arrays sont autorisées, donc l’unité de compilation contenant le tableau local y peut être compilée
    • Les variable length arrays sont une fonctionnalité conditionnelle que les implémentations ne sont pas obligées de prendre en charge, donc, sur un compilateur qui ne les supporte pas, même l’exemple en portée bloc peut ne pas compiler
    • En C++, les deux unités de compilation sont compilées, et comme le C++ n’a pas de notion de variable length array, y est compilé comme un tableau ordinaire de 42 éléments

Déclarations de fonctions, valeurs de retour et linkage

  • foo() et foo(void)

    • Une déclaration de fonction de la forme foo() déclare une fonction dont on ne connaît ni le nombre ni le type des arguments, tandis que foo(void) déclare une fonction nulle-aire sans argument
    • Cette différence est abordée dans cet article sur les déclarations, définitions et prototypes de fonctions
    • Une déclaration sans liste d’arguments n’introduit que le nom de la fonction et ne définit ni le nombre ni le type des arguments ; elle peut donc être légale si elle se combine ensuite avec la définition de la fonction
    • Si une fonction est appelée sans prototype, les promotions d’arguments par défaut s’appliquent et float est promu en double
    • Si le type de fonction après promotion n’est pas compatible avec le type de la définition réelle de la fonction, la combinaison de la déclaration et de la définition n’est pas valide
    • Un appel de fonction sans déclaration peut être compilé en C, car les fonctions implicites y étaient autorisées, mais provoque une erreur de compilation en C++
    • Si l’on appelle bar(42) sans déclaration, les promotions des arguments entiers s’appliquent et 42 est représenté comme un int ; si bar n’est pas compatible avec T (*)(int) pour un type de retour T quelconque, cela entraîne un comportement indéfini
  • Fonction à valeur de retour qui ne renvoie pas de valeur

    • Même si une fonction de type de retour int ne renvoie pas de valeur, cela peut être légal en C tant que la valeur de retour de l’appel n’est pas utilisée
    • En K&R C, le type void n’existait pas et, si le type était omis, le type par défaut int était supposé ; les fonctions ne renvoyant pas de valeur et la règle du int implicite sont donc liées historiquement
    • La règle du int implicite a été supprimée en C99 ; voir la discussion connexe dans N661 et la justification de C99
    • C17 § 6.9.1 ¶ 12 précise que si l’exécution atteint le } final d’une fonction et que l’appelant utilise la valeur de l’appel de fonction, il s’agit d’un comportement indéfini
    • En C++98 § 6.6.3 ¶ 2, le simple fait d’atteindre la fin d’une fonction censée renvoyer une valeur équivaut à un return sans valeur, ce qui constitue un comportement indéfini dans une telle fonction
    • Comme les compilateurs C++ ne peuvent généralement pas prouver dans quelle branche abort_program() termine l’exécution, ils ne peuvent souvent émettre qu’un diagnostic plutôt qu’une erreur
  • Linkage et extern

    • Si l’on redéclare le même identifiant avec extern dans une portée où une déclaration précédente est visible, le linkage de la déclaration ultérieure est le même que celui de la déclaration précédente
    • C17 § 6.2.2 ¶ 4 dispose que si la déclaration précédente spécifiait un linkage interne ou externe, la déclaration extern ultérieure a le même linkage
    • Si aucune déclaration précédente n’est visible, ou si la déclaration précédente n’avait pas de linkage, l’identifiant déclaré avec extern a un linkage externe
    • Une combinaison de déclarations dans l’ordre inverse peut entraîner un comportement indéfini, et GCC comme Clang le détectent

Qualificateurs et types incomplets

  • const sur les paramètres de fonction

    • Si, dans une déclaration de fonction, le paramètre x est qualifié par const, mais pas dans la définition, et que le corps de la fonction écrit dans x, cela reste légal
    • Selon C11 § 6.7.6.3 ¶ 15, lorsqu’on détermine la compatibilité des types de paramètres de fonction et le type composite, chaque paramètre déclaré avec un type qualifié est traité comme sa version non qualifiée
    • Le même sujet est aussi abordé dans DR040
  • const sur le type de retour d’une fonction

    • Si seul le type de retour dans la définition de la fonction est qualifié par const, mais pas dans la déclaration, il est difficile de considérer la réponse comme simplement juste ou fausse
    • Le consensus général est que les qualificateurs des rvalues devraient être ignorés, mais le texte des standards jusqu’à C11 ne le traitait pas explicitement
    • En C17, il devient clair que les qualificateurs de rvalue doivent être ignorés dans les cast, les conversions de lvalue et les déclarateurs de fonction
    • C17 § 6.7.6.3 ¶ 5 précise que le type renvoyé par une fonction est la version non qualifiée de T, et cette formulation a été ajoutée en C17
    • Même si la qualification const du type de retour diffère, l’affectation de types de fonctions peut rester légale
    • Voir aussi DR423 et DR481
  • Structure incomplète et variable globale

    • Même si struct foo est un type incomplet au moment de la déclaration d’une variable globale et que sa taille n’est donc pas connue, cela peut être autorisé dans certains cas si le type est complété plus tard dans la même translation unit
    • Une logique similaire s’applique aux variables globales ou aux tableaux de type incomplet
    • Ce point est aussi abordé dans DR016
  • Objet externe de type void

    • Une déclaration de variable de type void avec linkage interne n’est pas légale, mais une déclaration de variable de type void avec linkage externe est syntaxiquement légale et n’est explicitement interdite nulle part dans la norme C11
    • Selon C11 § 6.2.5 ¶ 19, le type void est un type objet incomplet qui ne peut pas être complété, constitué d’un ensemble vide de valeurs
    • C11 § 6.3.2.1 ¶ 1 définit une lvalue comme une expression de type objet autre que void, donc le nom d’un objet foo de type void n’est pas une lvalue valide
    • D’après C11, il est difficile d’imaginer une opération significative et conforme sur un objet externe de type void
    • DR012 indique que, si l’on change le type en const void, il devient légal de prendre l’adresse de l’objet foo, ce qui ressemble davantage à un oubli qu’à une fonctionnalité intentionnelle
  • Conversion pointeur-vers-const

Tableaux, littéraux de chaîne et ajustement des pointeurs

  • Un tableau n’est pas un pointeur

    • L’initialisation d’un tableau et l’initialisation d’un pointeur ne sont pas équivalentes
    • La première forme initialise un tableau modifiable de durée de stockage automatique ou statique
    • La seconde forme initialise un pointeur vers un tableau de durée de stockage statique, et ce tableau n’est pas nécessairement modifiable
    • Un tableau n’est pas un pointeur ; voir cet article connexe pour plus de détails
  • a, &a, &a[0]

    • Avec int a[42];, a, &a et &a[0] s’évaluent tous à l’adresse du premier élément du tableau
    • Toutefois, comme les types des trois expressions sont différents, elles ne sont pas interchangeables
    • Voir cet article connexe pour plus de détails
  • Paramètres tableau et tableaux locaux

    • Si le type d’un paramètre de fonction est « tableau de T », il est ajusté en « pointeur vers T »
    • Même si le paramètre x ressemble à int[42], il est en réalité traité comme un int *
    • Si la variable locale y est un int[42], alors sizeof(y) vaut 42 * sizeof(int)
    • Comme, en général, la taille d’un pointeur d’objet n’est pas égale à la taille de 42 int, sizeof(x) == sizeof(y) vaut généralement false
    • Voir cet article connexe pour plus de détails

Opérateurs, ordre d’évaluation et flux de contrôle

  • x+++y

    • En C, il est impossible de définir de nouveaux opérateurs comme en C++, donc il n’existe pas de nouvel opérateur tel que +++
    • x+++y est interprété comme une combinaison d’opérateurs existants et équivaut à (x++) + y
    • --*--p n’est pas non plus un nouvel opérateur, mais une combinaison d’opérateurs existants
    • --*--p équivaut à --(*(--p)) et, dans l’exemple, s’évalue à -1 avec pour effet de bord d’assigner -1 à x[0]
  • Ordre d’évaluation des opérandes arithmétiques

    • La priorité des opérateurs est bien définie, mais l’ordre d’évaluation des opérandes arithmétiques ne l’est pas
    • (x=1) + (x=2) est un comportement indéfini, car l’ordre des deux affectations n’est pas défini et la valeur finale de x, 1 ou 2, n’est donc pas déterminée
    • Avec l’option -std=c11 -O2, GCC 8.2.1 évalue l’expression d’exemple à 4, tandis que Clang 7.0.0 l’évalue à 3
  • Ordre d’évaluation des opérateurs logiques

    • Avec les opérateurs logiques && et ||, l’ordre d’évaluation des opérandes est lui aussi bien défini
    • Dans les termes du standard C, il existe un sequence point entre l’évaluation du premier opérande et celle du second
    • Dans l’exemple, x=1 est d’abord évalué et devient true, puis x=2 est évalué et devient lui aussi true, donc l’expression entière vaut true
  • Structure libre du corps de switch

    • Le corps d’une instruction switch peut être n’importe quel statement, donc une structure mêlant boucle et if peut aussi être valide
    • Même dans la branche true d’une instruction if dont l’expression de contrôle vaut toujours false, si un label case s’y trouve alors cette instruction devient active, et printf("1"); n’est pas du code mort
    • En sautant vers case 2, il est possible que la clause-1 et l’expression de contrôle de la boucle ne soient pas exécutées ; la variable i doit donc être initialisée à l’avance
    • Même s’il n’y a pas de break dans case 1 et qu’un fall through se produit, si case 1 se trouve dans la branche true du if et case 2 dans la branche false, alors case 2 peut être ignoré et l’exécution se poursuivre à case 3
    • Après les trois appels foo(0); foo(1); foo(2);, la sortie console devient 02313223
    • Un exemple réel célèbre mêlant boucle et switch est le Duff's device

Durée de vie des objets temporaires et différences entre versions du standard C

  • Un fragment de code donné peut relever d’un comportement indéfini en C11, sans que ce soit forcément le cas en C99
  • En C11, la durée de vie de certains objets est raccourcie : un objet renvoyé par un appel de fonction ne reste vivant que pendant l’évaluation de l’opérande de droite
  • En C99, le même objet reste vivant jusqu’à la fin du bloc englobant
  • Référencer un objet dont la durée de vie est terminée constitue un comportement indéfini selon C11 § 6.2.4 ¶ 2
  • En C99 également, la durée de vie d’un objet à durée de stockage automatique est liée au bloc englobant le plus proche ; le référencer en dehors de ce bloc constitue donc un comportement indéfini
  • C11 § 6.2.4 ¶ 8 précise qu’une expression non-lvalue de type structure ou union qui contient un membre tableau fait référence à un objet ayant une durée de stockage automatique et une durée de vie temporaire
  • La durée de vie de cet objet temporaire commence lors de l’évaluation de l’expression et se termine à la fin de l’évaluation de la full expression ou du full declarator qui l’englobe
  • Toute tentative de modifier un objet ayant une durée de vie temporaire constitue un comportement indéfini
  • L’exemple concerné est tiré de N1285, où l’on trouve aussi des discussions complémentaires

1 commentaires

 
Avis sur Lobste.rs
  • La question 4 n’est pas valide en C23, même si elle l’était avant
    La question 10 n’est ni juste ni fausse, donc c’est un peu agaçant pour un QCM
    La question 15 est techniquement incorrecte, surtout par rapport à la question 13, et la question 20 relève de l’« indéterminé », donc là encore aucune réponse n’est correcte
    La question 30 est ambiguë selon la lecture qu’on en fait
    Malgré tout, j’en ai eu 27 bonnes sur 31, et le fait d’être développeur de compilateurs aide un peu

  • Après avoir résolu environ quatre questions, il ne me restait plus rien de cette impression que le C est assez simple pour servir à un side project

    • Avec GCC ou clang, en utilisant -std=<language-standard> -pedantic -Wall -Wextra et en corrigeant réellement chaque avertissement, tout en évitant autant que possible les casts de pointeurs et la manipulation de pointeurs, on devrait pouvoir éviter les gros pièges
      Les avertissements de GCC/clang sont plutôt bons aujourd’hui, et pour <language-standard> on peut utiliser c89, c99, c11 ou c23
    • Le C est simple, mais les acrobaties autour du comportement indéfini ne le sont pas
      En utilisant un compilateur comme tcc, qui ne fait pas d’optimisations bizarres, on a moins de chances d’avoir des surprises étranges
  • J’ai simplement choisi selon le critère « quel est le comportement le plus absurde ici ? » et j’ai eu 21 bonnes sur 32
    La plupart de mes erreurs venaient du fait que je n’avais pas réfléchi assez profondément au degré de cette absurdité
    Je n’ai touché au C qu’un peu il y a plus de 15 ans, et voir ce genre de quiz ne me donne pas envie de m’y remettre

    • Pour référence, ChatGPT a eu 22 bonnes sur 32 sans voir les explications supplémentaires après chaque réponse
  • En C23, la réponse à la question 4 n’est pas valide

  • Fait intéressant, alors que je n’avais pas utilisé le C depuis un moment, j’ai quand même eu 27 bonnes sur 32
    C’est précisément pour ce genre de choses que je me suis appuyé sur des analyseurs statiques et des linters

  • Dès la question 1, ça me mettait déjà mal à l’aise
    On ne tenait pas compte d’où ces pointeurs pouvaient venir, et il faut des conditions très particulières pour que le cas évoqué tienne
    Dans la plupart des cas, le simple fait d’essayer de créer le pointeur relève déjà du comportement indéfini, mais on peut malgré tout considérer que c’est acceptable
    La question 3 était vraiment surprenante, encore un autre piège du C
    Le fait que les littéraux entiers en C aient un type fixé d’avance est franchement agaçant
    Les règles de promotion des entiers rattrapent un peu la situation, mais elles sont aussi une source d’erreurs
    Les langages modernes devraient, pour la plupart voire tous, interdire les conversions numériques implicites, inférer le type des littéraux depuis le contexte quand c’est possible, et exiger un cast explicite quand ce ne l’est pas
    Après la question 6, j’ai cessé de faire confiance au test et j’ai abandonné
    Au début, c’était parce que la réponse à la question 5 semblait conçue pour faire rater la question 6, mais en y revenant, il semble que la question 6 elle-même soit erronée
    L’explication dit que l’appel de fonction relève du comportement indéfini, mais la question demandait si la définition de fonction était légale, et elle l’était probablement

    • Si deux tableaux sont adjacents en mémoire, et qu’un pointeur vise le premier élément de l’un ainsi que juste après le dernier élément de l’autre, on obtient cette situation
      Et ça ne semble pas être un cas si rare que ça
  • Le problème sur switch() était vraiment excellent
    C’était subtil, mais très amusant à résoudre mentalement