Le comportement «struct hack» est-il techniquement indéfini?

111

Ce que je demande, c'est le truc bien connu "le dernier membre d'une structure a une longueur variable". Ca fait plutot comme ca:

struct T {
    int len;
    char s[1];
};

struct T *p = malloc(sizeof(struct T) + 100);
p->len = 100;
strcpy(p->s, "hello world");

En raison de la façon dont la structure est disposée en mémoire, nous pouvons superposer la structure sur un bloc plus grand que nécessaire et traiter le dernier membre comme s'il était plus grand que celui 1 charspécifié.

La question est donc: cette technique est-elle un comportement techniquement indéfini? . Je m'attendrais à ce que ce soit le cas, mais j'étais curieux de savoir ce que la norme dit à ce sujet.

PS: Je suis conscient de l'approche C99 à ce sujet, j'aimerais que les réponses s'en tiennent spécifiquement à la version de l'astuce répertoriée ci-dessus.

Evan Teran
la source
33
Cela semble être une question assez claire, raisonnable et surtout à laquelle il faut répondre . Ne pas voir la raison du vote serré.
cHao le
2
Si vous introduisiez un compilateur "ansi c" qui ne supportait pas le hack struct, la plupart des programmeurs c que je connais n'accepteraient pas que votre compilateur "fonctionne correctement". Néanmoins, ils accepteraient une lecture stricte de la norme. Le comité en a tout simplement manqué une.
dmckee --- ex-moderator chaton
4
@james Le hack fonctionne en mallocalisant un objet assez grand pour le tableau que vous voulez dire, malgré avoir déclaré un tableau minimal. Vous accédez donc à la mémoire allouée en dehors de la définition stricte de la structure. Ecrire au-delà de votre allocation est une erreur incontestable, mais c'est différent d'écrire dans votre allocation mais en dehors de "la structure".
dmckee --- ex-moderator chaton
2
@James: Le malloc surdimensionné est essentiel ici. Il garantit qu'il y a de la mémoire --- mémoire avec une adresse légale et et "possédée" par la structure (c'est-à-dire qu'il est interdit à toute autre entité de l'utiliser) --- au-delà de l'extrémité nominale de la structure. Notez que cela signifie que vous ne pouvez pas utiliser le struct hack sur des variables automatiques: elles doivent être allouées dynamiquement.
dmckee --- ex-moderator chaton
5
@detly: Il est plus simple d'allouer / désallouer une chose que d'allouer / désallouer deux choses, d'autant plus que ce dernier a deux façons d'échouer que vous devez gérer. Cela m'importe plus que les économies marginales de coût / vitesse.
jamesdlin

Réponses:

52

Comme le dit la FAQ C :

On ne sait pas si c'est légal ou portable, mais c'est plutôt populaire.

et:

... une interprétation officielle a estimé qu'elle n'était pas strictement conforme à la norme C, bien qu'elle semble fonctionner sous toutes les implémentations connues. (Les compilateurs qui vérifient attentivement les limites des tableaux peuvent émettre des avertissements.)

La justification du bit 'strictement conforme' est dans la spécification, section J.2 Comportement non défini, qui inclut dans la liste des comportements non définis:

  • Un indice de tableau est hors de portée, même si un objet est apparemment accessible avec l'indice donné (comme dans l'expression lvalue a[1][7]donnée dans la déclaration int a[4][5]) (6.5.6).

Le paragraphe 8 de la section 6.5.6 Les opérateurs additifs a une autre mention que l'accès au-delà des limites de tableau définies n'est pas défini:

Si l'opérande du pointeur et le résultat pointent tous deux sur des éléments du même objet tableau, ou un après le dernier élément de l'objet tableau, l'évaluation ne produira pas de débordement; sinon, le comportement n'est pas défini.

Carl Norum
la source
1
Dans le code de l'OP, p->sn'est jamais utilisé comme tableau. Il est passé à strcpy, auquel cas il se désintègre en un plaine char *, ce qui arrive à pointer vers un objet qui peut légalement être interprété comme char [100];à l'intérieur de l'objet alloué.
R .. GitHub STOP HELPING ICE
3
Une autre façon de voir cela est peut-être que le langage pourrait restreindre la façon dont vous accédez aux variables de tableau réelles comme décrit dans J.2, mais il n'y a aucun moyen de faire de telles restrictions pour un objet alloué par malloc, lorsque vous avez simplement converti le void *vers un pointeur vers [une structure contenant] un tableau. Il est toujours valide d'accéder à n'importe quelle partie de l'objet alloué en utilisant un pointeur vers char(ou de préférence unsigned char).
R .. GitHub STOP HELPING ICE
@R. - Je peux voir comment J2 pourrait ne pas couvrir cela, mais n'est-il pas également couvert par 6.5.6?
detly le
1
Bien sûr que ça pourrait! Les informations de type et de taille pourraient être incorporées dans chaque pointeur, et toute arithmétique de pointeur erronée pourrait alors être transformée en piège - voir par exemple CCured . Sur un plan plus philosophique, peu importe qu'aucune implémentation possible ne puisse vous attraper, c'est toujours un comportement indéfini (il y a, iirc, des cas de comportement indéfini qui nécessiteraient un oracle pour que le problème d'arrêt soit résolu - c'est précisément pourquoi ils ne sont pas définis).
zwol le
4
L'objet n'est pas un objet de tableau, donc 6.5.6 n'est pas pertinent. L'objet est le bloc de mémoire alloué par malloc. Recherchez "objet" dans la norme avant de lancer bs.
R .. GitHub STOP HELPING ICE
34

Je crois que techniquement, c'est un comportement indéfini. La norme (sans doute) ne la traite pas directement, elle relève donc du "ou par l'omission de toute définition explicite du comportement". clause (§4 / 2 de C99, §3.16 / 2 de C89) qui dit que c'est un comportement indéfini.

Le "sans doute" ci-dessus dépend de la définition de l'opérateur d'indice de tableau. Plus précisément, il dit: "Une expression avec suffixe suivie d'une expression entre crochets [] est une désignation en indice d'un objet tableau." (C89, §6.3.2.1 / 2).

Vous pouvez affirmer que le "d'un objet tableau" est violé ici (puisque vous indiquez en dehors de la plage définie de l'objet tableau), auquel cas le comportement est (un tout petit peu plus) explicitement indéfini, au lieu de simplement indéfini grâce à rien de tout à fait le définir.

En théorie, je peux imaginer un compilateur qui vérifie les limites du tableau et (par exemple) abandonne le programme lorsque / si vous essayez d'utiliser un indice hors de portée. En fait, je ne sais pas qu'une telle chose existe, et étant donné la popularité de ce style de code, même si un compilateur essayait d'appliquer des indices dans certaines circonstances, il est difficile d'imaginer que quiconque accepterait de le faire dans cette situation.

Jerry Coffin
la source
2
Je peux aussi imaginer un compilateur qui pourrait décider que si un tableau était de taille 1, alors il arr[x] = y;pourrait être réécrit comme arr[0] = y;; pour un tableau de taille 2, arr[i] = 4;pourrait être réécrit comme i ? arr[1] = 4 : arr[0] = 4; Bien que je n'ai jamais vu un compilateur effectuer de telles optimisations, sur certains systèmes embarqués, ils pourraient être très productifs. Sur un PIC18x, en utilisant des types de données 8 bits, le code de la première instruction serait de seize octets, le deuxième, deux ou quatre et le troisième, huit ou douze. Pas une mauvaise optimisation si légale.
supercat
Si la norme définit l'accès au tableau en dehors des limites du tableau comme un comportement indéfini, alors le hack struct l'est également. Si, cependant, la norme définit l'accès aux tableaux comme du sucre syntaxique pour l'arithmétique des pointeurs ( a[2] == a + 2), ce n'est pas le cas. Si j'ai raison, toutes les normes C définissent l'accès au tableau comme une arithmatique de pointeur.
yyny
13

Oui, c'est un comportement indéfini.

Le rapport sur les défauts de langage C # 051 donne une réponse définitive à cette question:

L'idiome, bien que commun, n'est pas strictement conforme

http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_051.html

Dans le document de justification C99, le Comité C ajoute:

La validité de ce concept a toujours été discutable. Dans la réponse à un rapport d'anomalie, le Comité a décidé qu'il s'agissait d'un comportement indéfini car le tableau p-> éléments ne contient qu'un seul élément, que l'espace existe ou non.

ouah
la source
2
+1 pour avoir trouvé cela, mais je prétends toujours que c'est contradictoire. Deux pointeurs vers le même objet (dans ce cas, l'octet donné) sont égaux, et un pointeur vers celui-ci (le pointeur dans le tableau de représentation de l'objet entier obtenu par malloc) est valide dans l'addition, alors comment le pointeur identique peut-il, obtenu via une autre route, être invalide dans l'ajout? Même s'ils veulent prétendre que c'est UB, cela n'a pas de sens, car il n'y a aucun moyen informatique pour une implémentation de faire la distinction entre l'utilisation bien définie et l'utilisation supposée non définie.
R .. GitHub STOP HELPING ICE
C'est dommage que les compilateurs C aient commencé à interdire la déclaration de tableaux de longueur nulle; sans cette interdiction, de nombreux compilateurs n'auraient pas eu à faire de traitement spécial pour les faire fonctionner comme ils "devraient", mais auraient toujours été en mesure de coder des cas spéciaux pour les tableaux à un seul élément (par exemple, si *foocontient un tableau à un seul élément boz, l'expression foo->boz[biz()*391]=9;pourrait être simplifiée comme suit:) biz(),foo->boz[0]=9;. Malheureusement, le rejet des tableaux à zéro élément par les compilateurs signifie que beaucoup de code utilise des tableaux à un seul élément à la place, et serait brisé par cette optimisation.
supercat
11

Cette manière particulière de le faire n'est explicitement définie dans aucun standard C, mais C99 inclut le "struct hack" dans le cadre du langage. En C99, le dernier membre d'une structure peut être un "membre de tableau flexible", déclaré comme char foo[](avec le type de votre choix à la place de char).

Mandrin
la source
Pour être pédant, ce n'est pas le hack struct. Le hack struct utilise un tableau avec une taille fixe, pas un membre de tableau flexible. Le struct hack est ce qui a été demandé et est UB. Les membres du tableau flexible semblent juste être une tentative d'apaiser le genre de personnes vues dans ce fil se plaignant de ce fait.
underscore_d
7

Ce n'est pas un comportement indéfini , indépendamment de ce que quiconque, officiel ou non , dit, car il est défini par la norme. p->s, sauf lorsqu'il est utilisé comme lvalue, donne un pointeur identique à (char *)p + offsetof(struct T, s). En particulier, il s'agit d'un charpointeur valide à l'intérieur de l'objet malloc'd, et il y a 100 adresses successives (ou plus, en fonction des considérations d'alignement) qui le suivent immédiatement, qui sont également valides comme charobjets à l'intérieur de l'objet alloué. Le fait que le pointeur ait été dérivé en utilisant ->au lieu d'ajouter explicitement le décalage au pointeur renvoyé par malloc, cast vers char *, n'est pas pertinent.

Techniquement, p->s[0]est le seul élément du chartableau à l'intérieur de la structure, les quelques éléments suivants (par exemple à p->s[1]travers p->s[3]) sont probablement des octets de remplissage à l'intérieur de la structure, qui pourraient être corrompus si vous effectuez une affectation à la structure dans son ensemble, mais pas si vous accédez simplement à l'individu les membres et le reste des éléments constituent un espace supplémentaire dans l'objet alloué que vous êtes libre d'utiliser comme vous le souhaitez, tant que vous respectez les exigences d'alignement (et que vous n'avez charaucune exigence d'alignement).

Si vous craignez que la possibilité de chevauchement avec des octets de remplissage dans la structure puisse d'une manière ou d'une autre invoquer des démons nasaux, vous pouvez éviter cela en remplaçant 1in [1]par une valeur qui garantit qu'il n'y a pas de remplissage à la fin de la structure. Un moyen simple mais inutile de faire cela serait de créer une structure avec des membres identiques sauf aucun tableau à la fin, et de l'utiliser s[sizeof struct that_other_struct];pour le tableau. Ensuite, p->s[i]est clairement défini comme un élément du tableau dans la structure pour i<sizeof struct that_other_structet comme un objet char à une adresse suivant la fin de la structure pour i>=sizeof struct that_other_struct.

Edit: En fait, dans l'astuce ci-dessus pour obtenir la bonne taille, vous devrez peut-être également mettre une union contenant chaque type simple avant le tableau, pour vous assurer que le tableau lui-même commence par un alignement maximal plutôt qu'au milieu du remplissage d'un autre élément . Encore une fois, je ne crois pas que tout cela soit nécessaire, mais je le propose pour le plus paranoïaque des juristes linguistiques.

Edit 2: Le chevauchement avec les octets de remplissage n'est certainement pas un problème, en raison d'une autre partie de la norme. C exige que si deux structures s'accordent dans une sous-séquence initiale de leurs éléments, les éléments initiaux communs sont accessibles via un pointeur vers l'un ou l'autre type. En conséquence, si une structure identique à struct Tmais avec un tableau final plus grand était déclarée, l'élément s[0]devrait coïncider avec l'élément s[0]dans struct T, et la présence de ces éléments supplémentaires ne pourrait pas affecter ou être affectée par l'accès aux éléments communs de la structure plus grande en utilisant un pointeur vers struct T.

R .. GitHub STOP AIDING ICE
la source
4
Vous avez raison de dire que la nature de l'arithmétique du pointeur n'est pas pertinente, mais vous vous trompez sur l'accès au-delà de la taille déclarée du tableau. Voir N1494 (dernier brouillon public C1x) section 6.5.6 paragraphe 8 - vous n'êtes même pas autorisé à faire l' ajout qui prend un pointeur de plus d'un élément au-delà de la taille déclarée du tableau, et vous ne pouvez pas le déréférencer même si c'est juste un élément passé.
zwol le
1
@Zack: c'est vrai si l'objet est un tableau. Ce n'est pas vrai si l'objet est un objet alloué par malloclequel on accède en tant que tableau ou s'il s'agit d'une structure plus grande accessible via un pointeur vers une structure plus petite dont les éléments sont un sous-ensemble initial des éléments de la structure plus grande, entre autres cas.
R .. GitHub STOP HELPING ICE
6
+1 Si mallocn'alloue pas une plage de mémoire accessible avec l'arithmétique du pointeur, à quoi cela servirait-il? Et si la norme p->s[1]est définie comme du sucre syntaxique pour l'arithmétique des pointeurs, cette réponse réaffirme simplement que mallocc'est utile. Que reste-t-il à discuter? :)
Daniel Earwicker
3
Vous pouvez affirmer que c'est bien défini autant que vous le souhaitez, mais cela ne change rien au fait que ce n'est pas le cas. La norme est très claire sur l'accès au-delà des limites d'un tableau, et la limite de ce tableau est 1. C'est précisément aussi simple que cela.
Courses de légèreté en orbite le
3
@R .., je pense, votre hypothèse selon laquelle deux pointeurs comparant égaux doivent se comporter de la même manière est fausse. Envisagez de int m[1]; int n[1]; if(m+1 == n) m[1] = 0;supposer que la ifbranche est entrée. Ceci est UB (et non garanti pour l'initialisation n) selon 6.5.6 p8 (dernière phrase), comme je l'ai lu. Connexes: 6.5.9 p6 avec note de bas de page 109. (Les références sont à C11 n1570.) [...]
mafso
7

Oui, il s'agit d'un comportement techniquement indéfini.

Notez qu'il existe au moins trois façons d'implémenter le "struct hack":

(1) Déclarer le tableau de fin avec la taille 0 (la manière la plus "populaire" dans le code hérité). C'est évidemment UB, puisque les déclarations de tableau de taille zéro sont toujours illégales en C. Même s'il compile, le langage ne donne aucune garantie sur le comportement de tout code violant les contraintes.

(2) Déclarer le tableau avec une taille légale minimale - 1 (votre cas). Dans ce cas, toute tentative de prendre le pointeur p->s[0]et de l'utiliser pour l'arithmétique du pointeur qui va au-delà p->s[1]est un comportement indéfini. Par exemple, une implémentation de débogage est autorisée à produire un pointeur spécial avec des informations de plage incorporées, qui intercepteront chaque fois que vous tenterez de créer un pointeur au-delà p->s[1].

(3) Déclarer le tableau avec une taille "très grande" comme 10000, par exemple. L'idée est que la taille déclarée est censée être plus grande que tout ce dont vous pourriez avoir besoin dans la pratique réelle. Cette méthode est exempte d'UB en ce qui concerne la plage d'accès au tableau. Cependant, dans la pratique, bien sûr, nous allouerons toujours une plus petite quantité de mémoire (seulement autant que nécessaire). Je ne suis pas sûr de la légalité de cela, c'est-à-dire que je me demande dans quelle mesure il est légal d'allouer moins de mémoire pour l'objet que la taille déclarée de l'objet (en supposant que nous n'accédions jamais aux membres "non alloués").

Fourmi
la source
1
Dans (2), ce s[1]n'est pas un comportement indéfini. C'est la même chose que *(s+1), qui est identique à *((char *)p + offsetof(struct T, s) + 1), qui est un pointeur valide vers un chardans l'objet alloué.
R .. GitHub STOP HELPING ICE
D'un autre côté, je suis presque sûr que (3) est un comportement indéfini. Chaque fois que vous effectuez une opération qui dépend d'une telle structure résidant à cette adresse, le compilateur est libre de générer du code machine qui lit n'importe quelle partie de la structure. Cela pourrait être inutile ou cela pourrait être une fonction de sécurité pour un contrôle d'allocation strict, mais il n'y a aucune raison pour qu'une implémentation ne puisse pas le faire.
R .. GitHub STOP HELPING ICE
R: Si un tableau a été déclaré avoir une taille (n'est pas seulement le foo[]sucre syntaxique pour *foo), alors tout accès au-delà de la plus petite de sa taille déclarée et de sa taille allouée est UB, quelle que soit la façon dont l'arithmétique du pointeur a été effectuée.
zwol
1
@Zack, vous vous trompez sur plusieurs points. foo[]dans une structure n'est pas un sucre syntaxique pour *foo; c'est un membre de tableau flexible C99. Pour le reste, voir ma réponse et mes commentaires sur d'autres réponses.
R .. GitHub STOP HELPING ICE
6
Le problème est que certains membres du comité veulent désespérément que ce "hack" soit UB, car ils envisagent un royaume des fées où une implémentation C pourrait imposer des limites de pointeur. Pour le meilleur ou pour le pire, cependant, cela entrerait en conflit avec d'autres parties de la norme - des choses comme la possibilité de comparer des pointeurs pour l'égalité (si les limites étaient codées dans le pointeur lui-même) ou l'exigence que tout objet soit accessible via un unsigned char [sizeof object]tableau superposé imaginaire . Je maintiens mon affirmation selon laquelle le membre de tableau flexible "hack" pour le pré-C99 a un comportement bien défini.
R .. GitHub STOP HELPING ICE
3

La norme indique clairement que vous ne pouvez pas accéder aux éléments situés à la fin d'un tableau. (et passer par des pointeurs n'aide pas, car vous n'êtes même pas autorisé à incrémenter les pointeurs au-delà d'un point après la fin du tableau).

Et pour "travailler dans la pratique". J'ai vu l'optimiseur gcc / g ++ utiliser cette partie de la norme générant ainsi un code erroné lors de la rencontre de ce C.

Bernhard R. Link
la source
Pouvez-vous donner un exemple?
Tal le
1

Si un compilateur accepte quelque chose comme

typedef struct {
  int len;
  char dat [];
};

Je pense qu'il est assez clair qu'il doit être prêt à accepter un indice sur «dat» au-delà de sa longueur. D'un autre côté, si quelqu'un code quelque chose comme:

typedef struct {
  int peu importe;
  char dat [1];
} MON_STRUCT;

puis accède plus tard à somestruct-> dat [x]; Je ne pense pas que le compilateur soit obligé d'utiliser un code de calcul d'adresse qui fonctionnera avec de grandes valeurs de x. Je pense que si l'on voulait être vraiment sûr, le paradigme approprié serait plutôt:

#define LARGEST_DAT_SIZE 0xF000
typedef struct {
  int peu importe;
  char dat [LARGEST_DAT_SIZE];
} MON_STRUCT;

puis effectuez un malloc de (sizeof (MYSTRUCT) -LARGEST_DAT_SIZE + desire_array_length) octets (en gardant à l'esprit que si la longueur de la table est plus grande que LARGEST_DAT_SIZE, les résultats peuvent être indéfinis).

Incidemment, je pense que la décision d'interdire les tableaux de longueur nulle était malheureuse (certains dialectes plus anciens comme Turbo C le supportent) car un tableau de longueur nulle pourrait être considéré comme un signe que le compilateur doit générer du code qui fonctionnera avec des index plus grands .

supercat
la source