Valgrind a détecté une rafale Le saut ou le déplacement conditionnel dépend de la ou des valeurs non initialisées dans l'un de mes tests unitaires.
En inspectant l’Assemblée, j’ai réalisé que le code suivant:
bool operator==(MyType const& left, MyType const& right) {
// ... some code ...
if (left.getA() != right.getA()) { return false; }
// ... some code ...
return true;
}
Où MyType::getA() const -> std::optional<std::uint8_t>
, a généré l'assembly suivant:
0x00000000004d9588 <+108>: xor eax,eax
0x00000000004d958a <+110>: cmp BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>: je 0x4d9597 <... function... +123>
x 0x00000000004d9591 <+117>: mov r15b,BYTE PTR [r14+0x1c]
x 0x00000000004d9595 <+121>: mov al,0x1
0x00000000004d9597 <+123>: xor edx,edx
0x00000000004d9599 <+125>: cmp BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>: je 0x4d95ae <... function... +146>
x 0x00000000004d95a0 <+132>: mov dil,BYTE PTR [r13+0x1c]
x 0x00000000004d95a4 <+136>: mov dl,0x1
x 0x00000000004d95a6 <+138>: mov BYTE PTR [rsp+0x97],dil
0x00000000004d95ae <+146>: cmp al,dl
0x00000000004d95b0 <+148>: jne 0x4da547 <... function... +4139>
0x00000000004d95b6 <+154>: cmp r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>: je 0x4d95c8 <... function... +172>
=> Jump on uninitialized
0x00000000004d95c0 <+164>: test al,al
0x00000000004d95c2 <+166>: jne 0x4da547 <... function... +4139>
Où j'ai marqué avec x
les instructions qui ne sont pas exécutées (sautées) dans le cas où optionnel n'est PAS défini.
Le membre A
ici est à offset 0x1c
dans MyType
. En vérifiant la mise en page de std::optional
on voit que:
+0x1d
correspond à bool _M_engaged
,+0x1c
correspond à std::uint8_t _M_payload
(dans une union anonyme).Le code d’intérêt pour std::optional
est:
constexpr explicit operator bool() const noexcept
{ return this->_M_is_engaged(); }
// Comparisons between optional values.
template<typename _Tp, typename _Up>
constexpr auto operator==(const optional<_Tp>& __lhs, const optional<_Up>& __rhs) -> __optional_relop_t<decltype(declval<_Tp>() == declval<_Up>())>
{
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
&& (!__lhs || *__lhs == *__rhs);
}
Ici, nous pouvons voir que gcc a considérablement transformé le code; si je comprends bien, en C cela donne:
char rsp[0x148]; // simulate the stack
/* comparisons of prior data members */
/*
0x00000000004d9588 <+108>: xor eax,eax
0x00000000004d958a <+110>: cmp BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>: je 0x4d9597 <... function... +123>
0x00000000004d9591 <+117>: mov r15b,BYTE PTR [r14+0x1c]
0x00000000004d9595 <+121>: mov al,0x1
*/
int eax = 0;
if (__lhs._M_engaged == 0) { goto b123; }
bool r15b = __lhs._M_payload;
eax = 1;
b123:
/*
0x00000000004d9597 <+123>: xor edx,edx
0x00000000004d9599 <+125>: cmp BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>: je 0x4d95ae <... function... +146>
0x00000000004d95a0 <+132>: mov dil,BYTE PTR [r13+0x1c]
0x00000000004d95a4 <+136>: mov dl,0x1
0x00000000004d95a6 <+138>: mov BYTE PTR [rsp+0x97],dil
*/
int edx = 0;
if (__rhs._M_engaged == 0) { goto b146; }
rdi = __rhs._M_payload;
edx = 1;
rsp[0x97] = rdi;
b146:
/*
0x00000000004d95ae <+146>: cmp al,dl
0x00000000004d95b0 <+148>: jne 0x4da547 <... function... +4139>
*/
if (eax != edx) { goto end; } // return false
/*
0x00000000004d95b6 <+154>: cmp r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>: je 0x4d95c8 <... function... +172>
*/
// Flagged by valgrind
if (r15b == rsp[097]) { goto b172; } // next data member
/*
0x00000000004d95c0 <+164>: test al,al
0x00000000004d95c2 <+166>: jne 0x4da547 <... function... +4139>
*/
if (eax == 1) { goto end; } // return false
b172:
/* comparison of following data members */
end:
return false;
Ce qui équivaut à:
// Note how the operands of || are inversed.
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
&& (*__lhs == *__rhs || !__lhs);
Je pense que l'Assemblée est correcte, si étrange. Autant que je sache, le résultat de la comparaison entre des valeurs non initialisées n'influence pas réellement le résultat de la fonction (et contrairement à C ou C++, je m'attends à ce que la comparaison d'ordures dans Assembly x86 ne soit pas UB):
nullopt
et que l'autre est définie, le saut conditionnel à +148
passe à end
(return false
), puis OK.Le seul cas d’intérêt est donc lorsque les deux options sont nullopt
:
nullopt
,__lhs._M_engaged
est faux, ce qui est vrai.Dans les deux cas, le code conclut donc que les deux options sont égales lorsque les deux sont nullopt
; CQFD.
C’est la première fois que je vois que gcc génère des lectures non initialisées apparemment «bénignes», et j’ai donc quelques questions à poser:
||
) pouvant se déclencher dans des circonstances non bénignes?}}Pour l'instant, je suis plutôt enclin à annoter les quelques fonctions avec optimize(1)
afin d'éviter les optimisations. Heureusement, les fonctions identifiées ne sont pas critiques en termes de performances.
Environnement:
-std=c++17 -g -Wall -Werror -O3 -flto
(+ includes includes)-O3 -flto
(+ bibliothèques appropriées)Remarque: peut apparaître avec -O2
au lieu de -O3
, mais jamais sans -flto
.
Faits amusants
Dans le code complet, ce modèle apparaît 32 fois dans la fonction décrite ci-dessus, pour différentes charges utiles: std::uint8_t
, std::uint32_t
, std::uint64_t
et même un struct { std::int64_t; std::int8_t; }
.
Il n'apparaît que dans quelques grands operator==
comparant les types avec ~ 40 membres de données, pas dans les plus petits. Et il n'apparaît pas pour le std::optional<std::string_view>
même dans ces fonctions spécifiques (qui appellent std::char_traits
pour la comparaison).
Finalement, isoler la fonction en question dans son propre fichier binaire provoque la disparition du "problème". Le mythique MCVE se révèle insaisissable.
Dans x86 asm, le pire qui se produise est qu'un seul registre ait une valeur inconnue (ou vous ne savez pas laquelle des deux valeurs possibles il a, ancien ou nouveau, en cas d'ordonnancement possible de la mémoire). Mais si votre code ne dépend pas de cette valeur de registre, tout va bien , contrairement à C++. C++ UB signifie que tout votre programme est théoriquement complètement bloqué après un débordement d'entiers signés, et même avant cela, le long des chemins de code que le compilateur peut voir mènera à UB. Rien de tel ne se produit jamais dans asm, du moins pas dans le code d'espace utilisateur non privilégié.
(Il est possible de faire certaines choses pour provoquer un comportement imprévisible du système dans le noyau, en définissant des registres de contrôle de manière étrange ou en insérant des éléments incohérents dans des tableaux de pages ou des descripteurs, mais cela ne se produira pas de cette manière, même si vous compiliez le code du noyau.)
Certaines ISA ont un "comportement imprévisible", comme au début de ARM si vous utilisez le même registre pour plusieurs opérandes d'une multiplication, le comportement est imprévisible. IDK si cela permet de casser le pipeline et de corrompre d’autres registres, ou s’il est limité à un résultat de multiplication inattendu. Ce dernier serait ma supposition.
Ou MIPS, si vous placez une branche dans le créneau de délai de la branche, le comportement est imprévisible. (La gestion des exceptions est compliquée à cause des créneaux de délai de branche ...). Mais il y a probablement encore des limites et vous ne pouvez pas bloquer la machine ou interrompre d'autres processus (dans un système multi-utilisateurs tel qu'Unix, il serait mauvais qu'un processus non privilégié d'espace utilisateur ne casse quoi que ce soit pour les autres utilisateurs).
Très tôt, MIPS avait également des intervalles de temps de chargement et multipliait les intervalles de temps: vous ne pouviez pas utiliser le résultat d'un chargement dans l'instruction suivante. Vous pouvez probablement obtenir l’ancienne valeur du registre si vous le lisez trop tôt, ou peut-être simplement des ordures. MIPS = étapes de pipeline à interconnexion minimale; ils voulaient éviter le blocage des logiciels, mais il s'est avéré que l'ajout d'un NOP lorsque le compilateur ne trouvait rien d'utile pour créer des fichiers binaires gonflés à la hausse et entraînait un code global plus lent que l'utilisation du blocage du matériel lorsque cela était nécessaire. Mais nous sommes bloqués avec des créneaux de retard de branche, car les supprimer modifierait l’ISA, contrairement à une assouplissement des restrictions imposées par les logiciels antérieurs.
Il n'y a pas de valeur d'interruption dans les formats entiers x86; par conséquent, la lecture et la comparaison de valeurs non initialisées génèrent des valeurs vraisemblables/fausses imprévisibles et aucun autre préjudice direct.
Dans un contexte cryptographique, l'état des valeurs non initialisées entraînant la création d'une branche différente peut s'infiltrer dans les informations de minutage ou d'autres attaques par canal latéral. Mais le renforcement de la cryptographie n’est probablement pas ce qui vous inquiète.
Le fait que gcc effectue une lecture non initialisée alors que peu importe si la lecture donne une valeur erronée ne signifie pas qu'il le fera quand cela importera.
Je ne serais pas si sûr que cela soit causé par une erreur du compilateur. Votre code contient peut-être des UB, ce qui permet au compilateur d’optimiser plus agressivement votre code. Quoi qu'il en soit, aux questions: