web-dev-qa-db-fra.com

Assemblage intriguant pour comparer std :: optional de types primitifs

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;
}

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):

  1. Si l'une des options est nullopt et que l'autre est définie, le saut conditionnel à +148 passe à end (return false), puis OK.
  2. Si les deux options sont définies, la comparaison lit les valeurs initialisées, OK.

Le seul cas d’intérêt est donc lorsque les deux options sont nullopt:

  • si les valeurs se comparent égales, le code conclut que les options sont égales, ce qui est vrai puisqu'elles sont toutes les deux nullopt,
  • sinon, le code conclut que les options sont égales si __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:

  1. Les lectures non initialisées sont-elles correctes dans Assembly (x84_64)?
  2. _ {Est-ce le syndrome d'une optimisation échouée (inversion de ||) 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:

  • compilateur: gcc 7.3
  • compile flags: -std=c++17 -g -Wall -Werror -O3 -flto (+ includes includes)
  • indicateurs de lien: -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.

23
Matthieu M.

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.

4
Peter Cordes

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.

6

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:

  1. UB n'est pas un problème en Assemblée. Dans la plupart des cas, ce qui reste sous l’adresse à laquelle vous faites référence sera lu. Bien sûr, la plupart des systèmes d’exploitation remplissent les pages de mémoire avant de les donner au programme, mais votre variable réside probablement dans la pile, elle contient donc probablement des données parasites. Soo, tant que vous êtes d'accord avec la comparaison aléatoire de données (ce qui est assez mauvais, car cela peut faussement donner des résultats différents) L'assemblage est valide
  2. Il s'agit probablement d'un syndrome de comparaison inversée
0
bartop