Oui, ISO C ++ permet (mais ne nécessite pas) des implémentations pour faire ce choix.
Mais notez également que ISO C ++ permet à un compilateur d'émettre du code qui se bloque exprès (par exemple avec une instruction illégale) si le programme rencontre UB, par exemple comme un moyen de vous aider à trouver des erreurs. (Ou parce que c'est une DeathStation 9000. Être strictement conforme n'est pas suffisant pour qu'une implémentation C ++ soit utile dans un but réel). Ainsi, ISO C ++ permettrait à un compilateur de faire un asm qui s'est écrasé (pour des raisons totalement différentes) même sur un code similaire qui lit un fichier non initialisé uint32_t
. Même si cela doit être un type à disposition fixe sans représentation d'interruption.
C'est une question intéressante sur le fonctionnement des implémentations réelles, mais rappelez-vous que même si la réponse était différente, votre code serait toujours dangereux car le C ++ moderne n'est pas une version portable du langage d'assemblage.
Vous compilez pour le système V86 x86-64 ABI , qui spécifie qu'un en bool
tant que fonction arg dans un registre est représenté par les modèles de bits false=0
ettrue=1
dans les 8 bits de poids faible du registre 1 . En mémoire, bool
est un type à 1 octet qui doit à nouveau avoir une valeur entière de 0 ou 1.
(Un ABI est un ensemble de choix d'implémentation sur lesquels les compilateurs de la même plate-forme s'accordent pour qu'ils puissent créer du code qui appelle les fonctions les uns des autres, y compris les tailles de type, les règles de disposition de structure et les conventions d'appel.)
ISO C ++ ne le spécifie pas, mais cette décision ABI est répandue car elle rend la conversion bool-> int bon marché (juste une extension zéro) . Je ne connais aucun ABI qui ne laisse pas le compilateur assumer 0 ou 1 pour bool
, pour n'importe quelle architecture (pas seulement x86). Il permet des optimisations comme !mybool
avec xor eax,1
pour inverser le bit bas: Tout code possible qui peut inverser un bit / entier / booléen entre 0 et 1 en instruction CPU unique . Ou la compilation a&&b
sur un ET au niveau du bit pour les bool
types. Certains compilateurs profitent en fait des valeurs booléennes de 8 bits dans les compilateurs. Les opérations sur eux sont-elles inefficaces? .
En général, la règle as-if permet au compilateur de tirer parti des informations qui sont vraies sur la plate-forme cible à compiler , car le résultat final sera un code exécutable qui implémentera le même comportement visible de l'extérieur que la source C ++. (Avec toutes les restrictions que le comportement indéfini place sur ce qui est réellement "visible de l'extérieur": non pas avec un débogueur, mais à partir d'un autre thread dans un programme C ++ bien formé / légal.)
Le compilateur est certainement autorisé à profiter pleinement d'une garantie ABI dans son code-gen, et rendre le code comme vous avez trouvé ce qui permet d' optimiser strlen(whichString)
à
5U - boolValue
. (BTW, cette optimisation est assez intelligente, mais peut-être à courte vue par rapport à la ramification et à l'inline en memcpy
tant que magasins de données immédiates 2. )
Ou le compilateur aurait pu créer une table de pointeurs et l'indexer avec la valeur entière de la bool
, en supposant à nouveau qu'il s'agissait d'un 0 ou 1. ( Cette possibilité est ce que la réponse de @ Barmar a suggéré .)
Votre __attribute((noinline))
constructeur avec l'optimisation activée a conduit à claquer juste le chargement d'un octet de la pile pour l'utiliser comme uninitializedBool
. Il a fait l' espace pour l'objet main
avec push rax
( ce qui est plus petit et pour diverses raisons à peu près aussi efficace que sub rsp, 8
), de sorte que tout ce qui était dans les ordures AL à l' entrée de main
la valeur qu'elle utilisée pour uninitializedBool
. C'est pourquoi vous avez en fait obtenu des valeurs qui n'étaient pas seulement 0
.
5U - random garbage
peut facilement encapsuler une grande valeur non signée, ce qui amène memcpy à entrer dans la mémoire non mappée. La destination est en stockage statique, pas la pile, donc vous n'écrasez pas une adresse de retour ou quelque chose.
D'autres implémentations pourraient faire des choix différents, par exemple false=0
et true=any non-zero value
. Ensuite, clang ne produirait probablement pas de code qui se bloque pour cette instance spécifique d'UB. (Mais il serait toujours autorisé à le faire s'il le voulait.) Je ne connais aucune implémentation qui choisisse autre chose que ce que fait x86-64 bool
, mais la norme C ++ autorise beaucoup de choses que personne ne fait ou ne voudrait faire sur matériel qui ressemble à des processeurs actuels.
ISO C ++ ne précise pas ce que vous trouverez lorsque vous examinerez ou modifierez la représentation objet d'unbool
. (par exemple par memcpy
ing l' bool
en unsigned char
, que vous êtes autorisé à le faire parce que char*
tout peut alias. Et unsigned char
est garanti d'avoir aucun bit de remplissage, de sorte que le standard C ++ ne vous permet formellement HexDump représentations d'objets sans UB. Pointer-casting pour copier l'objet la représentation est différente de l'affectation char foo = my_bool
, bien sûr, donc la booléenisation à 0 ou 1 ne se produirait pas et vous obtiendriez la représentation d'objet brut.)
Vous avez partiellement "caché" l'UB sur ce chemin d'exécution du compilateur avecnoinline
. Même si elle n'est pas en ligne, cependant, les optimisations interprocédurales pourraient toujours créer une version de la fonction qui dépend de la définition d'une autre fonction. (Premièrement, clang crée un exécutable, pas une bibliothèque partagée Unix où l'interposition de symboles peut se produire. Deuxièmement, la définition se trouve à l'intérieur de la class{}
définition de sorte que toutes les unités de traduction doivent avoir la même définition. Comme avec le inline
mot - clé.)
Ainsi, un compilateur pourrait émettre juste un ret
ou ud2
(instruction illégale) comme définition pour main
, car le chemin d'exécution commençant au sommet de main
rencontre inévitablement un comportement indéfini. (Ce que le compilateur peut voir au moment de la compilation s'il décide de suivre le chemin à travers le constructeur non en ligne.)
Tout programme qui rencontre UB est totalement indéfini pour toute son existence. Mais UB à l'intérieur d'une fonction ou d'une if()
branche qui ne s'exécute jamais ne corrompe pas le reste du programme. En pratique, cela signifie que les compilateurs peuvent décider d'émettre une instruction illégale ret
, ou de ne pas émettre quoi que ce soit et de tomber dans le bloc / fonction suivant, pour l'ensemble du bloc de base qui peut être prouvé au moment de la compilation pour contenir ou conduire à UB.
GCC et Clang dans la pratique ne fait parfois émettent ud2
sur UB, au lieu de même essayer de générer du code pour les chemins d'exécution qui ne font pas de sens. Ou pour des cas comme tomber de la fin d'une non- void
fonction, gcc omettra parfois une ret
instruction. Si vous pensiez que "ma fonction ne fera que revenir avec les ordures dans RAX", vous vous trompez profondément. Les compilateurs C ++ modernes ne traitent plus le langage comme un langage d'assemblage portable. Votre programme doit vraiment être C ++ valide, sans faire d'hypothèses sur l'apparence d'une version autonome non intégrée de votre fonction dans asm.
Un autre exemple amusant est: Pourquoi l'accès non aligné à la mémoire mmap est-il parfois un défaut de segmentation sur AMD64? . x86 ne fait pas défaut sur les entiers non alignés, non? Alors pourquoi un mauvais alignement uint16_t*
serait-il un problème? Parce que alignof(uint16_t) == 2
, et violer cette hypothèse a conduit à une erreur de segmentation lors de la vectorisation automatique avec SSE2.
Voir aussi Ce que tout programmeur C devrait savoir sur le comportement indéfini # 1/3, un article d'un développeur clang.
Point clé: si le compilateur a remarqué l'UB au moment de la compilation, il pourrait "casser" (émettre un asm surprenant) le chemin à travers votre code qui provoque UB même s'il cible un ABI où n'importe quel motif binaire est une représentation d'objet valide pour bool
.
Attendez-vous à une hostilité totale envers de nombreuses erreurs de la part du programmeur, en particulier les choses que les compilateurs modernes mettent en garde. C'est pourquoi vous devez utiliser -Wall
et corriger les avertissements. C ++ n'est pas un langage convivial, et quelque chose en C ++ peut être dangereux même s'il serait sûr en asm sur la cible pour laquelle vous compilez. (Par exemple, le débordement signé est UB en C ++ et les compilateurs supposeront que cela ne se produit pas, même lors de la compilation pour le complément x86 à 2, sauf si vous l'utilisez clang/gcc -fwrapv
.)
L'UB visible à la compilation est toujours dangereux, et il est vraiment difficile d'être sûr (avec l'optimisation de la liaison) que vous avez vraiment caché l'UB au compilateur et pouvez donc raisonner sur le type d'asm qu'il générera.
Ne pas être trop dramatique; Souvent, les compilateurs vous permettent de vous en sortir avec certaines choses et d'émettre du code comme vous vous attendez même lorsque quelque chose est UB. Mais ce sera peut-être un problème à l'avenir si les développeurs du compilateur implémentent une optimisation qui obtient plus d'informations sur les plages de valeurs (par exemple, qu'une variable n'est pas négative, lui permettant peut-être d'optimiser l'extension de signe pour libérer l'extension zéro sur x86- 64). Par exemple, dans gcc et clang actuels, faire tmp = a+INT_MIN
ne s'optimise pas a<0
comme toujours faux, mais c'est tmp
toujours négatif. (Parce que INT_MIN
+ a=INT_MAX
est négatif sur la cible de complément de 2 et a
ne peut pas être supérieur à cela.)
Donc, gcc / clang ne fait pas actuellement marche arrière pour dériver les informations de plage pour les entrées d'un calcul, uniquement sur les résultats basés sur l'hypothèse d'aucun débordement signé: exemple sur Godbolt . Je ne sais pas si cette optimisation est intentionnellement «manquée» au nom de la convivialité ou quoi.
Notez également que les implémentations (alias compilateurs) sont autorisées à définir le comportement qu'ISO C ++ laisse non défini . Par exemple, tous les compilateurs qui prennent en charge les intrinsèques d'Intel (comme _mm_add_ps(__m128, __m128)
pour la vectorisation SIMD manuelle) doivent permettre de former des pointeurs mal alignés, ce qui est UB en C ++ même si vous ne les déréférencez pas. __m128i _mm_loadu_si128(const __m128i *)
effectue des charges non alignées en prenant un __m128i*
argument mal aligné , pas un void*
ou char*
. Est-ce que `reinterpret_cast`ing entre le pointeur vectoriel matériel et le type correspondant est un comportement non défini?
GNU C / C ++ définit également le comportement de décalage à gauche d'un nombre signé négatif (même sans -fwrapv
), séparément des règles UB normales de débordement signé. ( Il s'agit d'UB dans ISO C ++ , tandis que les décalages à droite des nombres signés sont définis par l'implémentation (logique ou arithmétique); des implémentations de bonne qualité choisissent l'arithmétique sur HW qui a des décalages à droite arithmétiques, mais ISO C ++ ne spécifie pas). Ceci est documenté dans la section Integer du manuel GCC , ainsi que la définition du comportement défini par l'implémentation que les normes C nécessitent que les implémentations définissent d'une manière ou d'une autre.
Il y a certainement des problèmes de qualité de mise en œuvre qui intéressent les développeurs de compilateurs; ils n'essaient généralement pas de faire des compilateurs intentionnellement hostiles, mais tirer parti de tous les nids-de-poule UB en C ++ (sauf ceux qu'ils choisissent de définir) pour mieux optimiser peut parfois être presque impossible à distinguer.
Note de bas de page 1 : Les 56 bits supérieurs peuvent être des ordures que l'appelé doit ignorer, comme d'habitude pour les types plus étroits qu'un registre.
( D' autres ABIs font faire des choix différents ici . Certains ne nécessitent des types entiers étroits pour être ou signe-zéro étendu pour remplir un registre lorsqu'il est passé ou retour de fonctions, comme MIPS64 et PowerPC64. Voir la dernière section de cette réponse x86-64 qui compare avec les ISA antérieures .)
Par exemple, un appelant peut avoir calculé a & 0x01010101
en RDI et l'utiliser pour autre chose, avant d'appeler bool_func(a&1)
. L'appelant pourrait optimiser le &1
car il l'a déjà fait pour l'octet bas dans le cadre de and edi, 0x01010101
, et il sait que l'appelé doit ignorer les octets élevés.
Ou si un booléen est passé comme 3e argument, peut-être qu'un appelant optimisant pour la taille du code le charge avec mov dl, [mem]
au lieu de movzx edx, [mem]
, économisant 1 octet au prix d'une fausse dépendance à l'ancienne valeur de RDX (ou tout autre effet de registre partiel, selon sur modèle CPU). Ou pour le premier argument, mov dil, byte [r10]
au lieu de movzx edi, byte [r10]
, car les deux nécessitent de toute façon un préfixe REX.
C'est pourquoi clang émet movzx eax, dil
à la Serialize
place de sub eax, edi
. (Pour les arguments entiers, clang viole cette règle ABI, en fonction du comportement non documenté de gcc et clang à zéro ou à extension de signe des entiers étroits à 32 bits. Un signe ou une extension zéro est-il requis lors de l'ajout d'un décalage de 32 bits à un pointeur pour le x86-64 ABI?
J'ai donc été intéressé de voir qu'il ne fait pas la même chose pour bool
.)
Note de bas de page 2: après la mov
création d'un branchement, vous disposez simplement d'un magasin à 4 octets immédiat ou d'un magasin à 4 octets + 1 octet. La longueur est implicite dans les largeurs de magasin + décalages.
OTOH, glibc memcpy fera deux chargements / magasins de 4 octets avec un chevauchement qui dépend de la longueur, donc cela finit vraiment par rendre le tout exempt de branches conditionnelles sur le booléen. Voir le L(between_4_7):
bloc dans memcpy / memmove de glibc. Ou du moins, procédez de la même manière pour chaque booléen dans la branche de memcpy pour sélectionner une taille de bloc.
Si vous êtes en ligne, vous pouvez utiliser 2x mov
-immediate + cmov
et un décalage conditionnel, ou vous pouvez laisser les données de chaîne en mémoire.
Ou si le réglage pour Intel Ice Lake ( avec la fonction Fast Short REP MOV ), un réel rep movsb
peut être optimal. glibc memcpy
peut commencer à utiliser rep movsb
pour les petites tailles sur les processeurs avec cette fonctionnalité, économisant ainsi beaucoup de branchements.
Outils de détection d'UB et d'utilisation de valeurs non initialisées
Dans gcc et clang, vous pouvez compiler avec -fsanitize=undefined
pour ajouter une instrumentation d'exécution qui avertira ou générera une erreur sur UB qui se produit lors de l'exécution. Cependant, cela n'acceptera pas les variables unitarisées. (Parce qu'il n'augmente pas la taille des caractères pour faire de la place pour un bit "non initialisé").
Voir https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
Pour trouver l'utilisation des données non initialisées, il existe un assainisseur d'adresse et un assainisseur de mémoire dans clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer montre des exemples de clang -fsanitize=memory -fPIE -pie
détection de lectures de mémoire non initialisées. Cela peut fonctionner mieux si vous compilez sans optimisation, donc toutes les lectures de variables finissent par se charger réellement à partir de la mémoire dans l'asm. Ils montrent qu'il est utilisé -O2
dans un cas où la charge ne serait pas optimisée. Je ne l'ai pas essayé moi-même. (Dans certains cas, par exemple en n'initialisant pas un accumulateur avant de sommer un tableau, clang -O3 émettra du code qui résume dans un registre vectoriel qu'il n'a jamais initialisé. Ainsi, avec l'optimisation, vous pouvez avoir un cas où il n'y a pas de lecture de mémoire associée à l'UB . Mais-fsanitize=memory
modifie l'asm généré et peut entraîner une vérification.)
Il tolérera la copie de la mémoire non initialisée, ainsi que les opérations logiques et arithmétiques simples avec elle. En général, MemorySanitizer suit silencieusement la propagation des données non initialisées en mémoire et signale un avertissement lorsqu'une branche de code est prise (ou non prise) en fonction d'une valeur non initialisée.
MemorySanitizer implémente un sous-ensemble de fonctionnalités trouvées dans Valgrind (outil Memcheck).
Cela devrait fonctionner dans ce cas, car l'appel à glibc memcpy
avec une length
mémoire calculée à partir de la mémoire non initialisée entraînera (à l'intérieur de la bibliothèque) une branche basée sur length
. S'il avait intégré une version entièrement sans branche qui vient d'utiliser cmov
, l'indexation et deux magasins, cela n'aurait peut-être pas fonctionné.
Valgrindmemcheck
recherchera également ce type de problème, ne se plaignant pas non plus si le programme copie simplement des données non initialisées. Mais il dit qu'il détectera quand un "saut ou déplacement conditionnel dépend de valeurs non initialisées", pour essayer d'attraper tout comportement visible de l'extérieur qui dépend de données non initialisées.
Peut-être que l'idée de ne pas signaler uniquement une charge est que les structures peuvent avoir un remplissage, et la copie de la structure entière (y compris le remplissage) avec un large chargement / stockage vectoriel n'est pas une erreur même si les membres individuels n'ont été écrits qu'un par un. Au niveau asm, les informations sur ce qui était du remplissage et ce qui fait réellement partie de la valeur ont été perdues.