Eine Studie über ein vages Verhalten

Der Artikel untersucht die möglichen Manifestationen von undefiniertem Verhalten, das in c ++ auftritt, wenn eine nicht leere Funktion abgeschlossen wird, ohne return mit einem geeigneten Wert aufzurufen. Der Artikel ist eher wissenschaftlich und unterhaltsam als praktisch.

Wer keinen Spaß daran hat, auf einen Rechen zu springen - wir gehen vorbei, wir hören nicht auf.

Einführung


Jeder weiß, dass Sie bei der Entwicklung von C ++ - Code kein undefiniertes Verhalten zulassen sollten.
Jedoch:

  • unbestimmtes Verhalten scheint aufgrund der Abstraktheit der möglichen Konsequenzen nicht gefährlich genug zu sein;
  • Es ist nicht immer klar, wo sich die Linie befindet.

Versuchen wir, die möglichen Manifestationen von undefiniertem Verhalten anzugeben, die in einem ziemlich einfachen Fall auftreten - in einer nicht leeren Funktion gibt es keine Rückkehr.

Berücksichtigen Sie dazu den Code, der von den beliebtesten Compilern in verschiedenen Optimierungsmodi generiert wurde.

Die Forschung unter Linux wird mit dem Compiler Explorer durchgeführt . Recherche zu Windows und MacOs X - zur Hardware, die mir direkt zur Verfügung steht.

Alle Builds werden für x86-x64 durchgeführt.

Es werden keine Maßnahmen ergriffen, um Compiler-Warnungen / -Fehler zu verbessern oder zu unterdrücken.

Es wird viel zerlegten Code geben. Sein Design ist leider bunt, weil Ich muss verschiedene Tools verwenden (zumindest habe ich es geschafft, überall Intel-Syntax zu erhalten). Ich werde mäßig detaillierte Kommentare zu zerlegtem Code abgeben, die jedoch die Notwendigkeit der Kenntnis der Prozessorregister und der Prinzipien des Stapels nicht beseitigen.

Standard lesen


C ++ 11 endgültiger Entwurf n3797, C ++ 14 endgültiger Entwurf N3936:
6.6.3 Die return-Anweisung
... Das Abfließen
am Ende einer Funktion entspricht einer Rückgabe ohne Wert. Dies führt zu einem undefinierten
Verhalten in einer Wertrückgabefunktion.
...

Das Erreichen des Endes einer Funktion entspricht einer Rückgabe ohne Rückgabewert. Für eine Funktion, deren Rückgabewert angegeben ist, führt dies zu undefiniertem Verhalten.

C ++ 17 Entwurf n4713
9.6.3 Die return-Anweisung
... Das Abfließen
des Endes eines Konstruktors, eines Destruktors oder einer Funktion mit dem Rückgabetyp cv void entspricht einer Rückgabe ohne Operanden. Andernfalls führt das Abfließen des Endes einer anderen Funktion als main (6.8.3.1) zu undefiniertem Verhalten.
...

Das Erreichen des Endes eines Konstruktors, Destruktors oder einer Funktion mit einem ungültigen Rückgabewert (möglicherweise mit const- und flüchtigen Qualifikationsmerkmalen) entspricht einer Rückgabe ohne Rückgabewert. Bei allen anderen Funktionen führt dies zu undefiniertem Verhalten (mit Ausnahme der Hauptfunktion).

Was bedeutet das in der Praxis?

Wenn die Funktionssignatur einen Rückgabewert liefert:

  • Die Ausführung sollte mit einer return-Anweisung mit einer Instanz des entsprechenden Typs enden.
  • ansonsten vages Verhalten;
  • undefiniertes Verhalten beginnt nicht ab dem Moment, in dem die Funktion aufgerufen wird, und nicht ab dem Moment, an dem der zurückgegebene Wert verwendet wird, sondern ab dem Moment, an dem die Funktion nicht ordnungsgemäß abgeschlossen wird.
  • Wenn die Funktion sowohl korrekte als auch falsche Ausführungspfade enthält, tritt undefiniertes Verhalten nur bei falschen Pfaden auf.
  • Das fragliche undefinierte Verhalten hat keinen Einfluss auf die Ausführung von Anweisungen, die im Hauptteil der Funktion enthalten sind.

Der Satz über die Hauptfunktion ist in c ++ 17 nicht neu - in früheren Versionen des Standards wurde eine ähnliche Ausnahme in Abschnitt 3.6.1 Hauptfunktion beschrieben.

Beispiel 1 - bool


In c ++ gibt es keinen Typ mit einem einfacheren Status als bool. Beginnen wir mit ihm.

#include <iostream>

bool bad() {};

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

    return 0;
}

MSVC generiert für ein solches Beispiel einen C4716-Kompilierungsfehler, sodass der Code für MSVC durch Angabe mindestens eines korrekten Ausführungspfads etwas kompliziert werden muss:

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

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

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

    return 0;
}

Zusammenstellung:

PlattformCompilerZusammenstellungsergebnis
Linuxx86-x64 Clang 10.0.0Warnung: Nicht-void-Funktion gibt keinen Wert zurück [-Wreturn-Typ]
Linuxx86-x64 gcc 9.3Warnung: Keine return-Anweisung in der Funktion, die nicht ungültig zurückgibt [-Wreturn-Typ]
Mac OS XApple Clang Version 11.0.0Warnung: Die Steuerung erreicht das Ende der nicht leeren Funktion [-Treturn-Typ]
WindowsMSVC 2019 16.5.4Das ursprüngliche Beispiel ist Fehler C4716, kompliziert - Warnung C4715: Nicht alle Steuerpfade geben einen Wert zurück

Ausführungsergebnisse:
OptimierungProgrammrückgabeKonsolenausgabe
Linux x86-x64 Clang 10.0.0
-O0255Keine Leistung
-O1, -O20Keine Leistung
Linux x86-x64 gcc 9.3
-O0089
-O1, -O2, -O30Keine Leistung
macOs X Apple Clang Version 11.0.0
-O0, -O1, -O200
Windows MSVC 2019 16.5.4, Originalbeispiel
/ Od, / O1, / O2Kein BuildKein Build
Windows MSVC 2019 16.5.4 Kompliziertes Beispiel
/ Od041
/ O1, / O201

Selbst in diesem einfachsten Beispiel haben vier Compiler mindestens drei Möglichkeiten aufgezeigt, um undefiniertes Verhalten anzuzeigen.

Lassen Sie uns herausfinden, was diese Compiler dort kompiliert haben.

Linux x86-x64 Clang 10.0.0, -O0


Bild

Die letzte Anweisung in der Funktion bad () ist ud2 .

Beschreibung der Anweisungen aus dem Entwicklerhandbuch für Intel 64- und IA-32-Architekturen :
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.

Kurz gesagt, dies ist eine spezielle Anweisung zum Auslösen einer Ausnahme.

Sie müssen den Aufruf von bad () in einen Versuch einschließen ... catch! Block

Egal wie. Dies ist keine C ++ - Ausnahme.

Ist es möglich, ud2 zur Laufzeit abzufangen?
Unter Windows sollte __try dafür verwendet werden, unter Linux und MacOs X der SIGILL-Signalhandler.

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


Bild

Als Ergebnis der Optimierung hat der Compiler einfach den Hauptteil der bad () - Funktion und ihren Aufruf weggenommen und weggeworfen.

Linux x86-x64 gcc 9.3, -O0


Bild

Erklärungen (in umgekehrter Reihenfolge, da in diesem Fall die Kette vom Ende aus leichter zu analysieren ist):

5. Der Ausgabeoperator in stream for bool wird aufgerufen (Zeile 14);

4. Die Adresse std :: cout wird in das edi-Register gestellt - dies ist das erste Argument des Ausgabeoperators in stream (Zeile 13);

3. Der Inhalt des eax-Registers wird in das esi-Register gestellt - dies ist das zweite Argument des Ausgabeoperators im Stream (Zeile 12);

2. Die drei hohen Bytes von eax werden auf Null zurückgesetzt, der Wert von al ändert sich nicht (Zeile 11);

1. Die Funktion bad () heißt (Zeile 10);

0. Die Funktion bad () sollte den Rückgabewert in das al-Register einfügen.

Stattdessen zeigt Zeile 4 nop (No Operation, Dummy).

Ein Byte Müll aus dem al-Register wird an die Konsole ausgegeben. Das Programm endet normal.

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


Bild

Der Compiler warf alles als Ergebnis der Optimierung.

macOs X Apple klirrte Version 11.0.0, -O0


Funktion main ():

Bild

Der Pfad des Booleschen Arguments des Ausgabeoperators zum Stream (diesmal in direkter Reihenfolge):

1. Der Inhalt des al-Registers wird im edx-Register abgelegt (Zeile 8);

2. Alle Bits des edx-Registers mit Ausnahme des niedrigsten (Zeile 9) werden auf Null gesetzt.

3. Ein Zeiger auf std :: cout wird in das rdi-Register gesetzt - dies ist das erste Argument des Ausgabeoperators in stream (Zeile 10);

4. Der Inhalt des edx-Registers wird in das esi-Register gestellt - dies ist das zweite Argument für den Ausgabeoperator in stream (Zeile 11);

5. Die Ausgabeanweisung wird im Stream für bool aufgerufen (Zeile 13).

Die Hauptfunktion erwartet, das Ergebnis der Funktion bad () aus dem Register al zu erhalten.

Die Funktion bad ():

Bild

1. Der noch nicht zugewiesene Wert aus dem nächsten Byte des Stapels wird in ein Register gestellt (Zeile 4);

2. Alle Bits des al-Registers mit Ausnahme der niedrigstwertigen (Zeile 5) sind ausgenommen.

Ein Stück Müll vom nicht zugewiesenen Stapel wird an die Konsole ausgegeben. Es kam vor, dass es sich während eines Testlaufs als Null herausstellte.

Das Programm endet normal.

macOs X Apple Clang Version 11.0.0, -O1, -O2


Bild

Das boolesche Argument des Ausgabeoperators im Stream wird ungültig gemacht (Zeile 5).

Der Aufruf bad () wurde während der Optimierung ausgelöst.

Das Programm zeigt in der Konsole immer Null an und wird normal beendet.

Windows MSVC 2019 16.5.4, Erweitertes Beispiel, / Od


Bild

Es ist ersichtlich, dass die Funktion bad () einen Rückgabewert im al-Register liefern sollte.

Bild

Der von der Funktion bad () zurückgegebene Wert wird zuerst auf den Stapel und dann in das edx-Register verschoben, damit die Ausgabe gestreamt werden kann.

Ein einzelnes Müllbyte aus dem al-Register wird an die Konsole ausgegeben (wenn etwas genauer, dann das niedrige Byte des Ergebnisses von rand ()). Das Programm endet normal.

Windows MSVC 2019 16.5.4 Kompliziertes Beispiel, / O1, / O2


Bild

Der Compiler hat den Aufruf bad () zwangsweise eingefügt. Hauptfunktion:

  • kopiert ein Byte von ebx aus dem Speicher bei [rsp + 30h];
  • Wenn rand () Null zurückgibt, kopieren Sie die Einheit von ecx nach ebx (Zeile 11).
  • kopiert den gleichen Wert nach dl (genauer gesagt das niedrigstwertige Byte) (Zeile 13);
  • ruft die Ausgabefunktion in stream auf, die den dl-Wert ausgibt (Zeile 14).

Ein Byte Müll aus dem RAM (von der Adresse rsp + 30h) wird an den Stream ausgegeben.

Die Schlussfolgerung von Beispiel 1


Die Ergebnisse der Berücksichtigung von Disassembler-Listen sind in der Tabelle aufgeführt:
OptimierungProgrammrückgabeKonsolenausgabeUrsache
Linux x86-x64 Clang 10.0.0
-O0255Keine Leistungud2
-O1, -O20Keine LeistungDie Konsolenausgabe und der Aufruf der Funktion bad () wurden als Ergebnis der Optimierung ausgelöst
Linux x86-x64 gcc 9.3
-O0089Ein Byte Müll aus Register al
-O1, -O2, -O30Keine LeistungDie Konsolenausgabe und der Aufruf der Funktion bad () wurden als Ergebnis der Optimierung ausgelöst
macOs X Apple Clang Version 11.0.0
-O000Ein bisschen Müll aus dem RAM
-O1, -O200Funktionsaufruf bad () durch Null ersetzt
Windows MSVC 2019 16.5.4, Originalbeispiel
/ Od, / O1, / O2Kein BuildKein BuildKein Build
Windows MSVC 2019 16.5.4 Kompliziertes Beispiel
/ Od041Ein Byte Müll aus Register al
/ O1, / O201Ein Byte Müll aus dem RAM

Wie sich herausstellte, zeigten die Compiler nicht 3, sondern 6 Varianten undefinierten Verhaltens - kurz bevor wir Disassembler-Listen in Betracht zogen, konnten wir einige davon nicht unterscheiden.

Beispiel 1a - Verwalten von undefiniertem Verhalten


Versuchen wir, mit undefiniertem Verhalten ein wenig zu steuern - beeinflussen Sie den Wert, der von der Funktion bad () zurückgegeben wird.

Dies ist nur mit Compilern möglich, die Müll ausgeben.
Führen Sie dazu die gewünschten Werte an den Stellen aus, an denen die Compiler sie übernehmen.

Linux x86-x64 gcc 9.3, -O0


Die leere Funktion bad () ändert den Wert von register al nicht, da der aufrufende Code dies erfordert. Wenn wir also vor dem Aufruf von bad () einen bestimmten Wert in al setzen, erwarten wir, dass genau dieser Wert als Ergebnis der Ausführung von bad () angezeigt wird.

Dies kann natürlich durch Aufrufen einer anderen Funktion erfolgen, die bool zurückgibt. Es kann aber auch eine Funktion verwendet werden, die beispielsweise nicht gesungenes Zeichen zurückgibt.

Vollständiger Beispielcode
#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;
}


Ausgabe an die Konsole:
1
85
0
240

Windows MSVC 2019 16.5.4, / Od


Im Beispiel für MSVC gibt die Funktion bad () das Low-Byte des Ergebnisses von rand () zurück.

Ohne die Funktion bad () zu ändern, kann externer Code seinen Rückgabewert beeinflussen, indem das Ergebnis von rand () geändert wird.

Vollständiger Beispielcode
#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;
}


Ausgabe an die Konsole:
1
85
0
240


Windows MSVC 2019 16.5.4, / O1, / O2


Um den von der Funktion bad () zurückgegebenen Wert nicht zu beeinflussen, reicht es aus, eine Stapelvariable zu erstellen. Damit der darin enthaltene Datensatz während der Optimierung nicht verworfen wurde, sollten Sie ihn als flüchtig markieren.
Vollständiger Beispielcode
#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;
}


Ausgabe an die Konsole:
1
85
0
240


macOs X Apple klirrte Version 11.0.0, -O0


Bevor Sie bad () aufrufen, müssen Sie einen bestimmten Wert in diese Speicherzelle eingeben, der zum Zeitpunkt des Aufrufs von bad () um eins unter dem oberen Rand des Stapels liegt.

Vollständiger Beispielcode
#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 .

Ausgabe an die Konsole:
0
1
0
1

Es scheint passiert zu sein: Es ist möglich, die Ausgabe der Funktion bad () zu ändern, und nur das niederwertige Bit wird berücksichtigt.

Die Schlussfolgerung von Beispiel 1a


Ein Beispiel ermöglichte es, die korrekte Interpretation von Disassembler-Listen zu überprüfen.

Beispiel 1b - gebrochener Bool


Nun, Sie denken daran, "41" wird in der Konsole anstelle von "1" angezeigt ... Ist das gefährlich?

Wir werden zwei Compiler überprüfen, die ein ganzes Byte Müll bereitstellen.

Windows MSVC 2019 16.5.4, / Od


Vollständiger Beispielcode
#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;
}


Ausgabe an die Konsole:
badBool1: 41
badBool2: 35
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , true, false} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4

Undefiniertes Verhalten führte zum Auftreten einer Booleschen Variablen, die mindestens Folgendes zerstört:
  • Vergleichsoperatoren für boolesche Werte;
  • Hash-Funktion des Booleschen Wertes.


Windows MSVC 2019 16.5.4, / O1, / O2


Vollständiger Beispielcode
#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;
}


Ausgabe an die Konsole:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , true, false} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4

Die Arbeit mit einer beschädigten booleschen Variablen hat sich beim Aktivieren der Optimierung nicht geändert.

Linux x86-x64 gcc 9.3, -O0


Vollständiger Beispielcode
#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;
}


Ausgabe an die Konsole:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): true
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , true, false} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4


Im Vergleich zu MSVC hat gcc auch die falsche Operation des Operators not hinzugefügt.

Die Schlussfolgerung von Beispiel 1b


Die Unterbrechung grundlegender Operationen mit Booleschen Werten kann schwerwiegende Folgen für die Logik auf hoher Ebene haben.

Warum ist das geschehen?

Weil einige Operationen mit Booleschen Variablen unter der Annahme implementiert werden, dass true ausschließlich eine Einheit ist.

Wir werden dieses Problem im Disassembler nicht berücksichtigen - der Artikel erwies sich als umfangreich.

Wir werden die Tabelle noch einmal mit dem Verhalten der Compiler verdeutlichen:
OptimierungProgrammrückgabeKonsolenausgabeUrsacheFolgen der Verwendung des Ergebnisses von bad ()
Linux x86-x64 Clang 10.0.0
-O0255Keine Leistungud2
-O1, -O20Keine LeistungDie Konsolenausgabe und der Aufruf der Funktion bad () wurden als Ergebnis der Optimierung ausgelöst
Linux x86-x64 gcc 9.3
-O0089Ein Byte Müll aus Register alVerletzung der Arbeit:
nicht; ==; ! =; <; >; <=; > =; std :: hash.
-O1, -O2, -O30Keine LeistungDie Konsolenausgabe und der Aufruf der Funktion bad () wurden als Ergebnis der Optimierung ausgelöst
macOs X Apple Clang Version 11.0.0
-O000Ein bisschen Müll aus dem RAM
-O1, -O200Funktionsaufruf bad () durch Null ersetzt
Windows MSVC 2019 16.5.4, Originalbeispiel
/ Od, / O1, / O2Kein BuildKein BuildKein Build
Windows MSVC 2019 16.5.4 Kompliziertes Beispiel
/ Od041Ein Byte Müll aus Register alVerletzung der Arbeit:
==; ! =; <; >; <=; > =; std :: hash.
/ O1, / O201Ein Byte Müll aus dem RAMVerletzung der Arbeit:
==; ! =; <; >; <=; > =; std :: hash.

Vier Compiler gaben 7 verschiedene Manifestationen von undefiniertem Verhalten.

Beispiel 2 - Struktur


Nehmen wir ein etwas komplizierteres Beispiel:

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

Für die Erstellung der Teststruktur ist ein einzelner Parameter vom Typ int erforderlich. Diagnosemeldungen werden von seinem Konstruktor und Destruktor ausgegeben. Die Funktion bad (int) verfügt über zwei gültige Ausführungspfade, von denen keiner in einem einzigen Aufruf implementiert wird.

Diesmal - zuerst die Tabelle, dann die Disassembler-Analyse für dunkle Punkte.
OptimierungProgram 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 ()
Müll von einem Speicherort, dessen Adresse in rax ist

Wieder sehen wir viele Optionen: Zusätzlich zu dem bereits bekannten ud2 gibt es mindestens 4 verschiedene Verhaltensweisen.

Das Compilerhandling mit einem Konstruktor ist sehr interessant:

  • In einigen Fällen wurde die Ausführung fortgesetzt, ohne den Konstruktor aufzurufen. In diesem Fall befand sich das Objekt in einem zufälligen Zustand.
  • In anderen Fällen war auf dem Ausführungspfad kein Konstruktoraufruf vorgesehen, was ziemlich seltsam ist.

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


Bild

Im Code wird nur ein Vergleich durchgeführt (Zeile 14), und es gibt nur einen bedingten Sprung (Zeile 15). Der Compiler ignorierte den zweiten Vergleich und den zweiten bedingten Sprung.
Dies führt zu dem Verdacht, dass unbestimmtes Verhalten früher begann, als es der Standard vorschreibt.

Das Überprüfen des Zustands der Sekunde, wenn es keine Nebenwirkungen enthält, und die Compilerlogik funktionierten wie folgt:

  • Wenn die zweite Bedingung erfüllt ist, müssen Sie den Konstruktor Test mit dem Argument 142 aufrufen.
  • Wenn die zweite Bedingung nicht erfüllt ist, wird die Funktion beendet, ohne einen Wert zurückzugeben. Dies bedeutet ein undefiniertes Verhalten, bei dem der Compiler alles tun kann. Einschließen - denselben Konstruktor mit demselben Argument aufrufen;
  • Die Überprüfung ist überflüssig. Der Testkonstruktor mit dem Argument 142 kann aufgerufen werden, ohne die Bedingung zu überprüfen.

Mal sehen, was passiert, wenn die zweite Prüfung eine Bedingung mit Nebenwirkungen enthält:

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

Vollständiger Code
#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;
}


Bild

Der Compiler reproduzierte ehrlich alle beabsichtigten Nebenwirkungen, indem er rand () (Zeile 16) aufrief, wodurch Zweifel an dem unangemessen frühen Beginn eines undefinierten Verhaltens zerstreut wurden.

Windows MSVC 2019 16.5.4, / Od / RTCs


Die Option / RTCs aktiviert die Fehlerprüfung zur Laufzeit des Stapelrahmens. Diese Option ist nur in der Debug-Assembly verfügbar. Betrachten Sie den disassemblierten Code des main () -Segments:

Bild

Vor dem Aufruf von bad (int) (Zeile 4) werden die Argumente vorbereitet - der Wert der rnd-Variablen wird in das edx-Register (Zeile 2) kopiert und die effektive Adresse einer lokalen Variablen an der Adresse wird in das rcx-Register geladen rsp + 28h (Zeile 3).

Vermutlich ist rsp + 28 die Adresse einer temporären Variablen, die das Ergebnis des Aufrufs von bad (int) speichert.

Diese Annahme wird durch die Zeilen 19 und 20 bestätigt - die effektive Adresse derselben Variablen wird in rcx geladen, wonach der Destruktor aufgerufen wird.

Im Intervall der Zeilen 4 bis 18 wird jedoch trotz der Ausgabe des Werts des zu streamenden Datenfelds nicht auf diese Variable zugegriffen.

Wie wir aus früheren MSVC-Listen gesehen haben, sollte das Argument für den Stream-Ausgabeoperator im rdx-Register erwartet werden. Das rdx-Register erhält das Ergebnis der Dereferenzierung der in rax befindlichen Adresse (Zeile 9).

Daher erwartet der aufrufende Code von bad (int):

  • Ausfüllen einer Variablen, deren Adresse durch das rcx-Register geleitet wird (hier sehen wir RVO in Aktion);
  • Rückgabe der Adresse dieser Variablen über das Rax-Register.

Fahren wir mit der Auflistung von bad (int) fort:

Bild

  • In eax wird der Wert 0xCCCCCCCC eingegeben, den wir in der Zugriffsverletzungsnachricht (Zeile 9) gesehen haben (beachten Sie, dass es nur 4 Bytes sind, während in der AccessViolation-Nachricht die Adresse aus 8 Bytes besteht).
  • Der Befehl rep stos wird aufgerufen und führt 0xC-Zyklen aus, in denen der Inhalt von eax ausgehend von der Adresse rdi (Zeile 10) in den Speicher geschrieben wird. Dies sind 48 Bytes - genau so viel, wie auf dem Stapel in Zeile 6 zugewiesen ist.
  • auf den korrekten Ausführungspfaden wird der Wert von rsp + 40h in rax eingegeben (Zeilen 23, 36);
  • der Wert des rcx-Registers (durch das main () die Zieladresse übergeben hat) wird bei rsp + 8 (Zeile 4) auf den Stapel geschoben;
  • rdi wird auf den Stapel geschoben, wodurch rsp um 8 reduziert wird (Zeile 5);
  • 30h Bytes werden auf dem Stapel durch Verringern von rsp zugewiesen (Zeile 6).

Also sind rsp + 8 in Zeile 4 und rsp + 40h im Rest des Codes der gleiche Wert.
Der Code ist eher verwirrend als es wird kein rbp verwendet.

Die Meldung "Zugriffsverletzung" enthält zwei Unfälle:

  • Nullen im oberen Teil der Adresse - es könnte Müll geben;
  • Die Adresse hat sich versehentlich als falsch herausgestellt.

Anscheinend ermöglichte die Option / RTCs das Überschreiben von Stapeln mit bestimmten Werten ungleich Null, und die Meldung "Zugriffsverletzung" war nur ein zufälliger Nebeneffekt.

Mal sehen, wie sich der Code mit aktivierter Option / RTCs vom Code ohne unterscheidet.

Bild

Der Paketcode main () unterscheidet sich nur in den Adressen der lokalen Variablen auf dem Stapel.

Bild

(Aus Gründen der Übersichtlichkeit habe ich zwei Versionen der Funktion bad (int) nebeneinander gestellt - mit / ohne / RTCs.)
Ohne / RTCs verschwand die Anweisung rep stos und bereitete zu Beginn der Funktion Argumente dafür vor.

Beispiel 2a


Versuchen Sie erneut, unbestimmtes Verhalten zu kontrollieren. Diesmal für nur einen Compiler.

Windows MSVC 2019 16.5.4, / Od / RTCs


Mit der Option / RTCs fügt der Compiler am Anfang der Funktion bad (int) Code ein, der die untere Hälfte von rax mit einem festen Wert auffüllt, was zu einer Zugriffsverletzung führen kann.

Um dieses Verhalten zu ändern, füllen Sie rax einfach mit einer gültigen Adresse.
Dies kann mit einer sehr einfachen Modifikation erreicht werden: Fügen Sie die Ausgabe von etwas zu std :: cout zum fehlerhaften (int) body hinzu.

Vollständiger Beispielcode
#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 ()

Der Operator << gibt einen Link zum Stream zurück, der implementiert wird, indem die Adresse std :: cout in rax platziert wird. Die Adresse ist korrekt, sie kann dereferenziert werden. Zugriffsverletzung wird verhindert.

Fazit


Anhand der einfachsten Beispiele konnten wir:

  • Sammeln Sie ungefähr 10 verschiedene Manifestationen unbestimmten Verhaltens.
  • Erfahren Sie im Detail genau, wie diese Optionen ausgeführt werden.

Alle Compiler zeigten die strikte Einhaltung des Standards - in keinem Beispiel begann das unbestimmte Verhalten früher als erwartet. Sie können Compiler-Entwicklern jedoch keine Fantasie verweigern.

Oft hängt die Manifestation von subtilen Nuancen ab: Es lohnt sich, eine scheinbar irrelevante Codezeile hinzuzufügen oder zu entfernen - und das Verhalten des Programms ändert sich erheblich.

Offensichtlich ist es einfacher, solchen Code nicht zu schreiben, als später Rätsel zu lösen.

All Articles