Une Ă©tude d'un comportement vague

L'article explore les manifestations possibles d'un comportement non défini qui se produit dans c ++ lorsqu'une fonction non vide est terminée sans appeler return avec une valeur appropriée. L'article est plus scientifique et amusant que pratique.

Qui n'aime pas s'amuser à sauter sur un râteau - on passe, on ne s'arrête pas.

introduction


Tout le monde sait que lors du développement de code c ++, vous ne devez pas autoriser un comportement non défini.
Toutefois:

  • un comportement indĂ©fini peut ne pas sembler suffisamment dangereux en raison de l'abstraction des consĂ©quences possibles;
  • on ne sait pas toujours oĂą est la ligne.

Essayons de spécifier les manifestations possibles d'un comportement indéfini qui se produit dans un cas assez simple - dans une fonction non vide, il n'y a pas de retour.

Pour ce faire, considérez le code généré par les compilateurs les plus populaires dans différents modes d'optimisation.

La recherche sous Linux sera effectuée à l'aide de l' explorateur du compilateur . Recherche sur Windows et macOs X - sur le matériel dont je dispose directement.

Toutes les versions seront effectuées pour x86-x64.

Aucune mesure ne sera prise pour améliorer ou supprimer les avertissements / erreurs du compilateur.

Il y aura beaucoup de code démonté. Sa conception, malheureusement, est hétéroclite, car Je dois utiliser plusieurs outils différents (enfin, au moins j'ai réussi à obtenir la syntaxe Intel partout). Je donnerai des commentaires modérément détaillés sur le code désassemblé, qui, cependant, n'éliminent pas le besoin de connaître les registres du processeur et les principes de la pile.

Lire la norme


C ++ 11 version finale n3797, C ++ 14 version finale N3936:
6.6.3 L'instruction return
...
La sortie d'une fin de fonction Ă©quivaut Ă  un retour sans valeur; cela se traduit par un
comportement indéfini dans une fonction de retour de valeur.
...

Atteindre la fin d'une fonction équivaut à retourner sans valeur de retour; pour une fonction dont la valeur de retour est fournie, cela conduit à un comportement indéfini.

C ++ 17 draft n4713
9.6.3 L'instruction return
...
S'écoulant de la fin d'un constructeur, d'un destructeur ou d'une fonction avec un type de retour cv void est équivalent à un retour sans opérande. Sinon, la sortie d'une fin de fonction autre que main (6.8.3.1) entraîne un comportement indéfini.
...

Atteindre la fin d'un constructeur, d'un destructeur ou d'une fonction avec une valeur de retour vide (éventuellement avec des qualificateurs const et volatile) équivaut à retourner sans valeur de retour. Pour toutes les autres fonctions, cela conduit à un comportement indéfini (à l'exception de la fonction principale).

Qu'est-ce que cela signifie dans la pratique?

Si la signature de fonction fournit une valeur de retour:

  • son exĂ©cution doit se terminer par une instruction return avec une instance du type appropriĂ©;
  • sinon, comportement vague;
  • un comportement indĂ©fini ne dĂ©marre pas au moment oĂą la fonction est appelĂ©e et non pas au moment oĂą la valeur retournĂ©e est utilisĂ©e, mais Ă  partir du moment oĂą la fonction n'est pas terminĂ©e correctement;
  • si la fonction contient des chemins d'exĂ©cution corrects et incorrects - un comportement indĂ©fini ne se produira que sur des chemins incorrects;
  • le comportement indĂ©fini en question n'affecte pas l'exĂ©cution des instructions contenues dans le corps de la fonction.

L'expression concernant la fonction principale n'est pas nouvelle dans c ++ 17 - dans les versions précédentes de la norme, une exception similaire était décrite dans la section 3.6.1 Fonction principale.

Exemple 1 - bool


En c ++ il n'y a pas de type avec un état plus simple que bool. Commençons par lui.

#include <iostream>

bool bad() {};

int main()
{
    std::cout << bad();

    return 0;
}

MSVC génère une erreur de compilation C4716 pour un tel exemple, donc le code pour MSVC devra être légèrement compliqué en fournissant au moins un chemin d'exécution correct:

#include <iostream>
#include <stdlib.h>

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    std::cout << bad();

    return 0;
}

Compilation:

Plate-formeCompilateurRĂ©sultat de la compilation
Linuxx86-x64 Clang 10.0.0avertissement: la fonction non-void ne renvoie pas de valeur [-Wreturn-type]
Linuxx86-x64 gcc 9.3avertissement: pas de déclaration de retour dans la fonction retournant non-void [-Wreturn-type]
Mac OS XApple clang version 11.0.0avertissement: la commande atteint la fin de la fonction non nulle [-Type de retour]
les fenêtresMSVC 2019 16.5.4L'exemple d'origine est l'erreur C4716, compliquée - avertissement C4715: tous les chemins de contrôle ne renvoient pas de valeur

Résultats d'exécution:
OptimisationRetour du programmeSortie console
Linux x86-x64 Clang 10.0.0
-O0255Aucune sortie
-O1, -O20Aucune sortie
Linux x86-x64 gcc 9.3
-O0089
-O1, -O2, -O30Aucune sortie
macOs X Apple clang version 11.0.0
-O0, -O1, -O200
Windows MSVC 2019 16.5.4, exemple d'origine
/ Od, / O1, / O2Pas de constructionPas de construction
Windows MSVC 2019 16.5.4 Exemple compliqué
/ Od041
/ O1, / O201

Même dans cet exemple le plus simple, quatre compilateurs ont démontré au moins trois façons d'afficher un comportement non défini.

Voyons ce que ces compilateurs ont compilé là-bas.

Linux x86-x64 Clang 10.0.0, -O0


image

La dernière instruction de la fonction bad () est ud2 .

Description des instructions du manuel du développeur du logiciel des architectures Intel 64 et IA-32 :
UD2—Undefined Instruction
Generates an invalid opcode exception. This instruction is provided for software testing to explicitly generate an invalid opcode exception. The opcode for this instruction is reserved for this purpose.
Other than raising the invalid opcode exception, this instruction has no effect on processor state or memory.

Even though it is the execution of the UD2 instruction that causes the invalid opcode exception, the instruction pointer saved by delivery of the exception references the UD2 instruction (and not the following instruction).

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

En bref, il s'agit d'une instruction spéciale pour lever une exception.

Vous devez terminer l'appel bad () dans un essai ... attraper! Bloquer

Peu importe comment. Ce n'est pas une exception c ++.

Est-il possible d'attraper ud2 en runtime?
Sous Windows, __try doit être utilisé pour cela; sous Linux et macOs X, le gestionnaire de signal SIGILL.

Linux x86-x64 Clang 10.0.0, -O1, -O2


image

À la suite de l'optimisation, le compilateur a simplement pris et jeté à la fois le corps de la fonction bad () et son appel.

Linux x86-x64 gcc 9.3, -O0


image

Explications (dans l'ordre inverse, car dans ce cas la chaîne est plus facile à analyser depuis la fin):

5. L'opérateur de sortie dans stream est appelé pour bool (ligne 14);

4. L'adresse std :: cout est placée dans le registre edi - c'est le premier argument de l'opérateur de sortie dans stream (ligne 13);

3. Le contenu du registre eax est placé dans le registre esi - c'est le deuxième argument de l'opérateur de sortie dans le flux (ligne 12);

2. Les trois octets hauts de eax sont remis à zéro, la valeur de al ne change pas (ligne 11);

1. La fonction bad () est appelée (ligne 10);

0. La fonction bad () devrait mettre la valeur de retour dans le registre al.

Au lieu de cela, la ligne 4 affiche nop (aucune opération, factice).

Un octet de déchets du registre al est envoyé à la console. Le programme se termine normalement.

Linux x86-x64 gcc 9.3, -O1, -O2, -O3


image

Le compilateur a tout jeté suite à l'optimisation.

macOs X Apple clang version 11.0.0, -O0


Fonction main ():

image

Le chemin de l'argument booléen de l'opérateur de sortie vers le flux (cette fois dans l'ordre direct):

1. Le contenu du registre al est placé dans le registre edx (ligne 8);

2. Tous les bits du registre edx sont mis à zéro, à l'exception du plus petit (ligne 9);

3. Un pointeur vers std :: cout est placé dans le registre rdi - c'est le premier argument de l'opérateur de sortie dans stream (ligne 10);

4. Le contenu du registre edx est placé dans le registre esi - c'est le deuxième argument de l'opérateur de sortie dans le flux (ligne 11);

5. L'instruction de sortie est appelée dans stream pour bool (ligne 13);

La fonction principale s'attend à obtenir le résultat de la fonction bad () du registre al.

La fonction bad ():

image

1. La valeur de l'octet suivant de la pile, non encore allouée, est placée dans le registre al (ligne 4);

2. Tous les bits du registre al sont exceptés, sauf le moins significatif (ligne 5);

Un bit de déchets de la pile non allouée est sorti sur la console. Il se trouve que lors d'un test, il s'est avéré être nul.

Le programme se termine normalement.

macOs X Apple clang version 11.0.0, -O1, -O2


image

L'argument booléen de l'opérateur de sortie dans stream est annulé (ligne 5).

L'appel bad () a été lancé lors de l'optimisation.

Le programme affiche toujours zéro dans la console et se ferme normalement.

Windows MSVC 2019 16.5.4, exemple avancé, / Od


image

On peut voir que la fonction bad () doit fournir une valeur de retour dans le registre al.

image

La valeur renvoyée par la fonction bad () est d'abord poussée sur la pile puis dans le registre edx pour la sortie à diffuser.

Un octet de déchets du registre al est envoyé à la console (un peu plus précisément, puis l'octet de poids faible du résultat de rand ()). Le programme se termine normalement.

Windows MSVC 2019 16.5.4 Exemple compliqué, / O1, / O2


image

Le compilateur a forcé l'appel de bad (). Fonction principale:

  • copie un octet de ebx de la mĂ©moire situĂ©e Ă  [rsp + 30h];
  • si rand () a retournĂ© zĂ©ro, copiez l'unitĂ© d'ecx vers ebx (ligne 11);
  • copie la mĂŞme valeur dans dl (plus prĂ©cisĂ©ment, son octet le moins significatif) (ligne 13);
  • appelle la fonction de sortie dans stream, qui produit la valeur dl (ligne 14).

Un octet de déchets de la RAM (à partir de l'adresse rsp + 30h) est émis en flux.

La conclusion de l'exemple 1


Les résultats de l'examen des listes de désassembleurs sont présentés dans le tableau:
OptimisationRetour du programmeSortie consoleCause
Linux x86-x64 Clang 10.0.0
-O0255Aucune sortieud2
-O1, -O20Aucune sortieLa sortie de la console et l'appel à la fonction bad () ont été lancés à la suite de l'optimisation
Linux x86-x64 gcc 9.3
-O0089Un octet de déchets du registre al
-O1, -O2, -O30Aucune sortieLa sortie de la console et l'appel à la fonction bad () ont été lancés à la suite de l'optimisation
macOs X Apple clang version 11.0.0
-O000Un peu de déchets de la RAM
-O1, -O200Appel de fonction bad () remplacé par zéro
Windows MSVC 2019 16.5.4, exemple d'origine
/ Od, / O1, / O2Pas de constructionPas de constructionPas de construction
Windows MSVC 2019 16.5.4 Exemple compliqué
/ Od041Un octet de déchets du registre al
/ O1, / O201Un octet de déchets de la RAM

Il s'est avéré que les compilateurs n'ont pas démontré 3, mais pas moins de 6 variantes de comportement indéfini - juste avant d'envisager les listes de désassembleurs, nous n'avons pas pu en distinguer certaines.

Exemple 1a - Gestion d'un comportement indéfini


Essayons de diriger un peu avec un comportement non défini - affectons la valeur retournée par la fonction bad ().

Cela ne peut être fait qu'avec des compilateurs qui génèrent des ordures.
Pour ce faire, placez les valeurs souhaitées dans les emplacements d'où les compilateurs les prendront.

Linux x86-x64 gcc 9.3, -O0


La fonction bad () vide ne modifie pas la valeur du registre al, comme le code appelant l'exige. Ainsi, si nous plaçons une certaine valeur dans al avant d'appeler bad (), nous nous attendons à voir cette valeur comme le résultat de l'exécution de bad ().

De toute évidence, cela peut être fait en appelant toute autre fonction qui renvoie bool. Mais cela peut aussi être fait en utilisant une fonction qui retourne, par exemple, un caractère non signé.

Exemple de code complet
#include <iostream>

bool bad() {}

bool goodTrue()
{
    return rand();
}

bool goodFalse()
{
    return !goodTrue();
}

unsigned char goodChar(unsigned char ch)
{
    return ch;
}

int main()
{
    goodTrue();
    std::cout << bad() << std::endl;

    goodChar(85);
    std::cout << bad() << std::endl;

    goodFalse();
    std::cout << bad() << std::endl;

    goodChar(240);
    std::cout << bad() << std::endl;

    return 0;
}


Sortie vers la console:
1
85
0
240

Windows MSVC 2019 16.5.4, / Od


Dans l'exemple pour MSVC, la fonction bad () renvoie l'octet de poids faible du résultat de rand ().

Sans modifier la fonction bad (), le code externe peut affecter sa valeur de retour en modifiant le résultat de rand ().

Exemple de code complet
#include <iostream>
#include <stdlib.h>

void control(unsigned char value)
{
    uint32_t count = 0;
    srand(0);
    while ((rand() & 0xff) != value) {
        ++count;
    }

    srand(0);
    for (uint32_t i = 0; i < count; ++i) {
        rand();
    }
}

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    control(1);
    std::cout << bad() << std::endl;

    control(85);
    std::cout << bad() << std::endl;

    control(0);
    std::cout << bad() << std::endl;

    control(240);
    std::cout << bad() << std::endl;

    return 0;
}


Sortie vers la console:
1
85
0
240


Windows MSVC 2019 16.5.4, / O1, / O2


Pour influencer non la valeur «retournée» par la fonction bad (), il suffit de créer une variable de pile. Pour que l'enregistrement ne soit pas supprimé lors de l'optimisation, vous devez le marquer comme volatile.
Exemple de code complet
#include <iostream>
#include <stdlib.h>

bool bad()
{
  if (rand() == 0) {
    return true;
  }
}

int main()
{
  volatile unsigned char ch = 1;
  std::cout << bad() << std::endl;

  ch = 85;
  std::cout << bad() << std::endl;

  ch = 0;
  std::cout << bad() << std::endl;

  ch = 240;
  std::cout << bad() << std::endl;

  return 0;
}


Sortie vers la console:
1
85
0
240


macOs X Apple clang version 11.0.0, -O0


Avant d'appeler bad (), vous devez entrer une certaine valeur dans cette cellule mémoire qui sera une de moins que le haut de la pile au moment d'appeler bad ().

Exemple de code complet
#include <iostream>

bool bad() {}

void putToStack(uint8_t value)
{
    uint8_t memory[1]{value};
}

int main()
{
    putToStack(20);
    std::cout << bad() << std::endl;

    putToStack(55);
    std::cout << bad() << std::endl;

    putToStack(0xfe);
    std::cout << bad() << std::endl;

    putToStack(11);
    std::cout << bad() << std::endl;

    return 0;
}

-O0, memory. , .

memory , — , , .

, .. , — putToStack .

Sortie vers la console:
0
1
0
1

Cela semble être arrivé: il est possible de modifier la sortie de la fonction bad (), et seul le bit de poids faible est pris en compte.

La conclusion de l'exemple 1a


Un exemple a permis de vérifier la bonne interprétation des listes de démonteurs.

Exemple 1b - bool cassé


Eh bien, vous y pensez, "41" sera affiché dans la console au lieu de "1" ... Est-ce dangereux?

Nous allons vérifier deux compilateurs qui fournissent un octet entier de déchets.

Windows MSVC 2019 16.5.4, / Od


Exemple de code complet
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    bool badBool1 = bad();
    bool badBool2 = bad();

    std::cout << "badBool1: " << badBool1 << std::endl;
    std::cout << "badBool2: " << badBool2 << std::endl;

    if (badBool1) {
      std::cout << "if (badBool1): true" << std::endl;
    } else {
      std::cout << "if (badBool1): false" << std::endl;
    }
    if (!badBool1) {
      std::cout << "if (!badBool1): true" << std::endl;
    } else {
      std::cout << "if (!badBool1): false" << std::endl;
    }

    std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
              << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
              << std::endl;
    std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
              << std::set<bool>{badBool1, badBool2, true, false}.size()
              << std::endl;
    std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
              << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
              << std::endl;

    return 0;
}


Sortie vers la console:
badBool1: 41
badBool2: 35
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , vrai, faux} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4

Un comportement indéfini a conduit à l'apparition d'une variable booléenne qui rompt au moins:
  • opĂ©rateurs de comparaison pour les valeurs boolĂ©ennes;
  • fonction de hachage de la valeur boolĂ©enne.


Windows MSVC 2019 16.5.4, / O1, / O2


Exemple de code complet
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
  if (rand() == 0) {
    return true;
  }
}

int main()
{
  volatile unsigned char ch = 213;
  bool badBool1 = bad();
  ch = 137;
  bool badBool2 = bad();

  std::cout << "badBool1: " << badBool1 << std::endl;
  std::cout << "badBool2: " << badBool2 << std::endl;

  if (badBool1) {
    std::cout << "if (badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (badBool1): false" << std::endl;
  }
  if (!badBool1) {
    std::cout << "if (!badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (!badBool1): false" << std::endl;
  }

  std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
    << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
    << std::endl;
  std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;
  std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;

  return 0;
}


Sortie vers la console:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , vrai, faux} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4

Le travail avec une variable booléenne corrompue n'a pas changé lorsque l'optimisation a été activée.

Linux x86-x64 gcc 9.3, -O0


Exemple de code complet
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
}

unsigned char goodChar(unsigned char ch)
{
  return ch;
}

int main()
{
  goodChar(213);
  bool badBool1 = bad();

  goodChar(137);
  bool badBool2 = bad();

  std::cout << "badBool1: " << badBool1 << std::endl;
  std::cout << "badBool2: " << badBool2 << std::endl;

  if (badBool1) {
    std::cout << "if (badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (badBool1): false" << std::endl;
  }
  if (!badBool1) {
    std::cout << "if (!badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (!badBool1): false" << std::endl;
  }

  std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
    << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
    << std::endl;
  std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;
  std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;

  return 0;
}


Sortie vers la console:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): true
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , vrai, faux} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4


Par rapport à MSVC, gcc a également ajouté l'opération incorrecte de l'opérateur not.

La conclusion de l'exemple 1b


L'interruption des opérations de base avec des valeurs booléennes peut avoir de graves conséquences pour la logique de haut niveau.

Pourquoi est-ce arrivé?

Parce que certaines opérations avec des variables booléennes sont implémentées en supposant que true est strictement une unité.

Nous n'aborderons pas cette question dans le démonteur - l'article s'est avéré volumineux.

Encore une fois, nous allons clarifier le tableau avec le comportement des compilateurs:
OptimisationRetour du programmeSortie consoleCauseConséquences de l'utilisation du résultat de bad ()
Linux x86-x64 Clang 10.0.0
-O0255Aucune sortieud2
-O1, -O20Aucune sortieLa sortie de la console et l'appel à la fonction bad () ont été lancés à la suite de l'optimisation
Linux x86-x64 gcc 9.3
-O0089Un octet de déchets du registre alViolation du travail:
non; ==; ! =; <; >; <=; > =; std :: hachage.
-O1, -O2, -O30Aucune sortieLa sortie de la console et l'appel à la fonction bad () ont été lancés à la suite de l'optimisation
macOs X Apple clang version 11.0.0
-O000Un peu de déchets de la RAM
-O1, -O200Appel de fonction bad () remplacé par zéro
Windows MSVC 2019 16.5.4, exemple d'origine
/ Od, / O1, / O2Pas de constructionPas de constructionPas de construction
Windows MSVC 2019 16.5.4 Exemple compliqué
/ Od041Un octet de déchets du registre alViolation du travail:
==; ! =; <; >; <=; > =; std :: hachage.
/ O1, / O201Un octet de déchets de la RAMViolation du travail:
==; ! =; <; >; <=; > =; std :: hachage.

Quatre compilateurs ont donné 7 manifestations différentes d'un comportement indéfini.

Exemple 2 - struct


Prenons un exemple un peu plus compliqué:

#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == 1) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();
    std::cout << "rnd: " << rnd << std::endl;

    std::cout << bad(rnd).value << std::endl;

    return 0;
}

La structure de test nécessite un seul paramètre de type int pour être construit. Les messages de diagnostic sont émis par son constructeur et son destructeur. La fonction bad (int) a deux chemins d'exécution valides, dont aucun ne sera implémenté dans un seul appel.

Cette fois - d'abord le tableau, puis l'analyse du désassembleur sur les points obscurs.
OptimisationProgram returnConsole output
Linux x86-x64 Clang 10.0.0
-O0255rnd: 1804289383ud2
-O1, -O20rnd: 1804289383
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
Linux x86-x64 gcc 9.3
-O00rnd: 1804289383
4198608
Test::~Test()
nop .
value .
-O1, -O2, -O30rnd: 1804289383
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
macOs X Apple clang version 11.0.0
-O0The program has unexpectedly finished.rnd: 16807ud2
-O1, -O20rnd: 16807
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
Windows MSVC 2019 16.5.4
/Od /RTCsAccess violation reading location 0x00000000CCCCCCCCrnd: 41MSVC stack frame run-time error checking
/Od, /O1, /O20rnd: 41
8791061810776
Test :: ~ Test ()
Ordures provenant d'un emplacement mémoire dont l'adresse est en rax

Encore une fois, nous voyons de nombreuses options: en plus de l'ud2 déjà connu, il existe au moins 4 comportements différents.

La manipulation du compilateur avec un constructeur est très intéressante:

  • dans certains cas, l'exĂ©cution s'est poursuivie sans appeler le constructeur - dans ce cas, l'objet Ă©tait dans un Ă©tat alĂ©atoire;
  • dans d'autres cas, un appel constructeur n'a pas Ă©tĂ© prĂ©vu sur le chemin d'exĂ©cution, ce qui est plutĂ´t Ă©trange.

Linux x86-x64 Clang 10.0.0, -O1, -O2


image

Une seule comparaison est faite dans le code (ligne 14), et il n'y a qu'un seul saut conditionnel (ligne 15). Le compilateur a ignoré la deuxième comparaison et le deuxième saut conditionnel.
Cela conduit à la suspicion qu'un comportement indéfini a commencé plus tôt que la norme ne le prescrit.

Mais la vérification de la condition du second ne contient aucun effet secondaire et la logique du compilateur a fonctionné comme suit:

  • si la deuxième condition est vraie - vous devez appeler le constructeur Test avec l'argument 142;
  • si la deuxième condition n'est pas vraie, la fonction se fermera sans retourner de valeur, ce qui signifie un comportement indĂ©fini dans lequel le compilateur peut faire n'importe quoi. Y compris - appeler le mĂŞme constructeur avec le mĂŞme argument;
  • la vĂ©rification est superflue; le constructeur Test avec l'argument 142 peut ĂŞtre appelĂ© sans vĂ©rifier la condition.

Voyons ce qui se passe si la deuxième vérification contient une condition avec des effets secondaires:

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == rand()) {
        return {142};
    }
}

Code complet
#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == rand()) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();
    std::cout << "rnd: " << rnd << std::endl;

    std::cout << bad(rnd).value << std::endl;

    return 0;
}


image

Le compilateur a honnêtement reproduit tous les effets secondaires voulus en appelant rand () (ligne 16), dissipant ainsi les doutes sur le début incorrectement précoce d'un comportement non défini.

Windows MSVC 2019 16.5.4, / Od / RTCs


L'option / RTCs permet de vérifier les erreurs d'exécution du cadre de pile. Cette option est disponible uniquement dans l'assembly de débogage. Considérez le code désassemblé de la section main ():

image

avant d'appeler bad (int) (ligne 4), les arguments sont préparés - la valeur de la variable rnd est copiée dans le registre edx (ligne 2) et l'adresse effective d'une variable locale située à l'adresse est chargée dans le registre rcx rsp + 28h (ligne 3).

Vraisemblablement, rsp + 28 est l'adresse d'une variable temporaire qui stocke le résultat de l'appel de bad (int).

Cette hypothèse est confirmée par les lignes 19 et 20 - l'adresse effective de la même variable est chargée dans rcx, après quoi le destructeur est appelé.

Cependant, dans l'intervalle des lignes 4 à 18, cette variable n'est pas accessible, malgré la sortie de la valeur de son champ de données à diffuser.

Comme nous l'avons vu dans les listes MSVC précédentes, l'argument de l'opérateur de sortie de flux doit être attendu dans le registre rdx. Le registre rdx obtient le résultat du déréférencement de l'adresse située dans rax (ligne 9).

Ainsi, le code appelant attend de mauvais (int):

  • remplir une variable dont l'adresse est passĂ©e par le registre rcx (ici on voit RVO en action);
  • renvoyer l'adresse de cette variable via le registre rax.

Passons Ă  la liste des mauvais (int):

image

  • dans eax, la valeur 0xCCCCCCCC est entrĂ©e, ce que nous avons vu dans le message de violation d'accès (ligne 9) (notez qu'il ne s'agit que de 4 octets, tandis que dans le message AccessViolation, l'adresse se compose de 8 octets);
  • la commande rep stos est appelĂ©e, exĂ©cutant des cycles 0xC d'Ă©criture du contenu de eax dans la mĂ©moire Ă  partir de l'adresse rdi (ligne 10). Ce sont 48 octets - exactement autant que ce qui est allouĂ© sur la pile de la ligne 6;
  • sur les bons chemins d'exĂ©cution, la valeur de rsp + 40h est entrĂ©e dans rax (lignes 23, 36);
  • la valeur du registre rcx (par laquelle main () a transmis l'adresse de destination) est poussĂ©e sur la pile Ă  rsp + 8 (ligne 4);
  • rdi est poussĂ© sur la pile, ce qui rĂ©duit rsp de 8 (ligne 5);
  • 30h octets sont allouĂ©s sur la pile en diminuant rsp (ligne 6).

Donc rsp + 8 Ă  la ligne 4 et rsp + 40h dans le reste du code ont la mĂŞme valeur.
Le code est assez déroutant car il n'utilise pas rbp.

Il y a deux accidents dans le message de violation d'accès:

  • zĂ©ros dans la partie supĂ©rieure de l'adresse - il pourrait y avoir des ordures;
  • l'adresse s'est avĂ©rĂ©e accidentellement incorrecte.

Apparemment, l'option / RTCs permettait d'écraser la pile avec certaines valeurs non nulles, et le message de violation d'accès n'était qu'un effet secondaire aléatoire.

Voyons comment le code avec l'option / RTCs activée diffère du code sans lui.

image

Le code des sections de main () ne diffère que par les adresses des variables locales sur la pile.

image

(pour plus de clarté, j'ai placé deux versions de la mauvaise fonction (int) à côté d'elle - avec / RTC et sans)
Sans les / RTC, l'instruction rep stos a disparu et prépare des arguments pour elle au début de la fonction.

Exemple 2a


Encore une fois, essayez de contrôler le comportement indéfini. Cette fois pour un seul compilateur.

Windows MSVC 2019 16.5.4, / Od / RTCs


Avec l'option / RTCs, le compilateur insère au début du mauvais code de fonction (int) qui remplit la moitié inférieure de rax avec une valeur fixe, ce qui peut entraîner une violation d'accès.

Pour changer ce comportement, remplissez simplement rax avec une adresse valide.
Ceci peut être réalisé avec une modification très simple: ajoutez la sortie de quelque chose à std :: cout au mauvais corps (int).

Exemple de code complet
#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
  std::cout << "rnd: " << v << std::endl;
  
  if (v == 0) {
        return {42};
    } else if (v == 1) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();

    std::cout << bad(rnd).value << std::endl;

    return 0;
}


rnd: 41
8791039331928
Test :: ~ Test ()

L'opérateur << renvoie un lien vers le flux, qui est implémenté en plaçant l'adresse std :: cout dans rax. L'adresse est correcte, elle peut être déréférencée. La violation d'accès est empêchée.

Conclusion


En utilisant les exemples les plus simples, nous avons pu:

  • recueillir environ 10 manifestations diffĂ©rentes d'un comportement indĂ©fini;
  • dĂ©couvrez en dĂ©tail comment ces options seront exĂ©cutĂ©es.

Tous les compilateurs ont démontré une stricte adhésion à la norme - en aucun cas le comportement indéfini n'a commencé plus tôt. Mais vous ne pouvez pas refuser un fantasme aux développeurs de compilateurs.

Souvent, la manifestation dépend de nuances subtiles: il vaut la peine d'ajouter ou de supprimer une ligne de code apparemment non pertinente - et le comportement du programme change considérablement.

De toute évidence, il est plus facile de ne pas écrire un tel code que de résoudre des énigmes plus tard.

All Articles