it-swarm.com.de

Funktion, die nicht im Code aufgerufen wird, wird zur Laufzeit aufgerufen

Wie kann das folgende Programm never_called aufrufen, wenn es niemals im Code aufgerufen wird

#include <cstdio>

static void never_called()
{
  std::puts("formatting hard disk drive!");
}

static void (*foo)() = nullptr;

void set_foo()
{
  foo = never_called;
}

int main()
{
  foo();
}

Dies unterscheidet sich von Compiler zu Compiler. Kompilieren mit Clang mit Optimierungen wird ausgeführt, und die Funktion never_called wird zur Laufzeit ausgeführt.

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

Beim Kompilieren mit GCC stürzt dieser Code jedoch einfach ab:

$ g++ -std=c++17 -O3 a.cpp && ./a.out
Segmentation fault (core dumped)

Compiler-Version:

$ clang --version
clang version 5.0.0 (tags/RELEASE_500/final)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
$ gcc --version
gcc (GCC) 7.2.1 20171128
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
24
Mário Feroldi

Das Programm enthält ein undefiniertes Verhalten, da das Dereferenzieren eines Nullzeigers (d. H. Aufrufen von foo(), ohne ihm zuvor eine gültige Adresse zuzuweisen) UB ist, weshalb durch den Standard keine Anforderungen gestellt werden.

Das Ausführen von _never_called_ zur Laufzeit ist eine absolut gültige Situation, wenn ein undefiniertes Verhalten festgestellt wurde. Es ist genauso gültig wie ein Absturz (wie beim Kompilieren mit GCC). Okay, aber warum macht Clang das? Wenn Sie es mit deaktivierten Optimierungen kompilieren, gibt das Programm "Formatieren der Festplatte" nicht mehr aus und stürzt einfach ab:

_$ clang++ -std=c++17 -O0 a.cpp && ./a.out
Segmentation fault (core dumped)
_

Der generierte Code für diese Version lautet wie folgt:

_main:                                   # @main
        Push    rbp
        mov     rbp, rsp
        call    qword ptr [foo]
        xor     eax, eax
        pop     rbp
        ret
_

Es wird versucht, eine Funktion aufzurufen, auf die foo verweist, und wenn foo mit nullptr initialisiert wird (oder wenn es keine Initialisierung gab, würde dies immer noch der Fall sein sein Wert ist Null. Hier wurde undefiniertes Verhalten getroffen, so dass alles passieren kann und das Programm unbrauchbar wird. Normalerweise führt ein Aufruf einer solchen ungültigen Adresse zu Segmentierungsfehlern, daher die Meldung, die wir beim Ausführen des Programms erhalten.

Lassen Sie uns nun dasselbe Programm untersuchen, es jedoch mit folgenden Optimierungen kompilieren:

_$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!
_

Der generierte Code für diese Version lautet wie folgt:

_set_foo():                            # @set_foo()
        ret
main:                                   # @main
        Push    rax
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        pop     rcx
        ret
.L.str:
        .asciz  "formatting hard disk drive!"
_

Interessanterweise haben Optimierungen das Programm so modifiziert, dass main _std::puts_ direkt aufruft. Aber warum hat Clang das getan? Und warum wird _set_foo_ zu einer einzigen Anweisung ret kompiliert?

Kommen wir für einen Moment zum Standard (speziell N4660) zurück. Was sagt es über undefiniertes Verhalten aus?

3.27 undefiniertes Verhalten [defns.undefined]

verhalten, für das dieses Dokument keine Anforderungen stellt

[Hinweis: Unbestimmtes Verhalten kann erwartet werden , wenn in diesem Dokument eine explizite Definition des Verhaltens fehlt, oder , wenn ein Programm ein verwendet fehlerhaftes Konstrukt oder fehlerhafte Daten. Zulässiges undefiniertes Verhalten reicht von völliges Ignorieren der Situation mit unvorhersehbaren Ergebnissen bis Verhalten während der Übersetzung oder Programmausführung in einer dokumentierten Weise, die für die Umgebung charakteristisch ist (mit oder ohne Ausgabe einer Diagnosemeldung), um eine Übersetzung oder Ausführung zu beenden (mit der Ausgabe einer Diagnosemeldung). Viele fehlerhafte Programmkonstruktionen erzeugen kein undefiniertes Verhalten. Sie müssen diagnostiziert werden. Die Auswertung eines konstanten Ausdrucks zeigt niemals ein Verhalten, das explizit als undefiniert angegeben ist ([expr.const]). - Endnote]

Betonung meiner.

Ein Programm, das undefiniertes Verhalten aufweist, wird nutzlos, da alles, was es bisher getan hat und weiter tun wird, keine Bedeutung hat, wenn es fehlerhafte Daten oder Konstrukte enthält. Denken Sie in diesem Zusammenhang daran, dass Compiler den Fall, in dem undefiniertes Verhalten festgestellt wird, möglicherweise vollständig ignorieren. Dies wird bei der Optimierung eines Programms tatsächlich als festgestellter Sachverhalt verwendet. Beispielsweise wird ein Konstrukt wie _x + 1 > x_ (wobei x eine vorzeichenbehaftete Ganzzahl ist) mit true kompiliert, auch wenn der Wert von x zur Kompilierungszeit unbekannt ist. Die Überlegung ist, dass der Compiler für gültige Fälle optimieren möchte, und dass dieses Konstrukt nur dann gültig ist, wenn es keinen arithmetischen Überlauf auslöst (d. H. Wenn x != std::numeric_limits<decltype(x)>::max()). Dies ist eine neue Erkenntnis im Optimierer. Basierend darauf ist das Konstrukt nachweislich immer wahr.

Hinweis : Diese Optimierung kann nicht für ganze Zahlen ohne Vorzeichen durchgeführt werden, da eine überlaufende Zahl nicht UB ist. Das heißt, der Compiler muss den Ausdruck so lassen, wie er ist, da er möglicherweise eine andere Auswertung hat, wenn er überläuft (nicht signiert ist Modul 2)N, wobei N die Anzahl der Bits ist). Das Wegoptimieren für vorzeichenlose ganze Zahlen wäre nicht standardkonform (danke aschepler).

Dies ist nützlich, da es nmengen von Optimierungen ermöglicht. So weit, so gut, aber was passiert, wenn x zur Laufzeit seinen Maximalwert beibehält? Nun, das ist undefiniertes Verhalten, also ist es Unsinn, darüber nachzudenken, da alles passieren kann und der Standard keine Anforderungen auferlegt.

Jetzt haben wir genügend Informationen, um Ihr fehlerhaftes Programm besser untersuchen zu können. Wir wissen bereits, dass der Zugriff auf einen Nullzeiger ein undefiniertes Verhalten ist, und das ist der Grund für das komische Verhalten zur Laufzeit. Versuchen wir also zu verstehen, warum Clang (oder technisch LLVM) das Programm so optimiert hat, wie es getan wurde.

_static void (*foo)() = nullptr;

static void never_called()
{
  std::puts("formatting hard disk drive!");
}

void set_foo()
{
  foo = never_called;
}

int main()
{
  foo();
}
_

Denken Sie daran, dass Sie _set_foo_ aufrufen können, bevor der Eintrag main ausgeführt wird. Wenn Sie beispielsweise eine Variable auf oberster Ebene deklarieren, können Sie sie aufrufen, während Sie den Wert dieser Variablen initialisieren:

_void set_foo();
int x = (set_foo(), 42);
_

Wenn Sie dieses Snippet vor main schreiben, zeigt das Programm kein undefiniertes Verhalten mehr und die Meldung "Formatieren der Festplatte!" wird mit aktivierten oder deaktivierten Optimierungen angezeigt.

Wie kann dieses Programm nur gültig sein? Es gibt diese _set_foo_ -Funktion, die foo die Adresse von _never_called_ zuweist, sodass wir hier möglicherweise etwas finden. Beachten Sie, dass foo als static markiert ist. Dies bedeutet, dass es interne Verknüpfungen gibt und von außerhalb dieser Übersetzungseinheit nicht zugegriffen werden kann. Im Gegensatz dazu ist die Funktion _set_foo_ extern verknüpft und von außen zugänglich. Wenn eine andere Übersetzungseinheit einen Ausschnitt wie den oben genannten enthält, wird dieses Programm gültig.

Cool, aber niemand ruft _set_foo_ von draußen an. Obwohl dies der Fall ist, sieht der Optimierer, dass dieses Programm nur gültig ist, wenn _set_foo_ vor main aufgerufen wird, andernfalls ist es nur undefiniertes Verhalten. Das ist eine neu erlernte Tatsache, und es wird angenommen, dass _set_foo_ tatsächlich aufgerufen wird. Basierend auf diesen neuen Erkenntnissen können andere Optimierungen, die eingesetzt werden, daraus Nutzen ziehen.

Wenn beispielsweise Konstante Faltung angewendet wird, ist das Konstrukt foo() nur gültig, wenn foo ordnungsgemäß initialisiert werden kann. Das kann nur passieren, wenn _set_foo_ außerhalb dieser Übersetzungseinheit aufgerufen wird, also _foo = never_called_.

Dead Code Elimination und Interprozedurale Optimierung könnten herausfinden, dass wenn _foo == never_called_, der Code in _set_foo_ nicht benötigt wird, so dass er in einen einzigen umgewandelt wird ret Anweisung.

Inline-Erweiterung Optimierung sieht, dass _foo == never_called_, so dass der Aufruf von foo durch seinen Körper ersetzt werden kann. Am Ende haben wir so etwas wie folgendes:

_set_foo():
        ret
main:
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        ret
.L.str:
        .asciz  "formatting hard disk drive!"
_

Das entspricht in etwa der Ausgabe von Clang mit eingeschalteten Optimierungen. Natürlich kann (und könnte) das, was Clang wirklich getan hat, anders sein, aber Optimierungen sind dennoch in der Lage, dieselbe Schlussfolgerung zu ziehen.

Bei der Überprüfung der GCC-Ausgabe mit eingeschalteten Optimierungen hat es anscheinend nichts ausgemacht, Folgendes zu untersuchen:

_.LC0:
        .string "formatting hard disk drive!"
never_called():
        mov     edi, OFFSET FLAT:.LC0
        jmp     puts
set_foo():
        mov     QWORD PTR foo[rip], OFFSET FLAT:never_called()
        ret
main:
        sub     rsp, 8
        call    [QWORD PTR foo[rip]]
        xor     eax, eax
        add     rsp, 8
        ret
_

Das Ausführen dieses Programms führt zu einem Absturz (Segmentierungsfehler). Wenn Sie jedoch _set_foo_ in einer anderen Übersetzungseinheit aufrufen, bevor main ausgeführt wird, zeigt dieses Programm kein undefiniertes Verhalten mehr.

All dies kann sich verrückt ändern, da immer mehr Optimierungen vorgenommen werden. Verlassen Sie sich also nicht auf die Annahme, dass Ihr Compiler sich um Code kümmert, der undefiniertes Verhalten enthält. Es könnte auch zu Problemen führen (und Ihre Festplatte tatsächlich formatieren!). )


Ich empfehle Ihnen, Was jeder C-Programmierer über undefiniertes Verhalten wissen sollte und Eine Anleitung zu undefiniertem Verhalten in C und C++ zu lesen. Beide Artikelreihen sind sehr informativ und könnten Ihnen dabei helfen den Stand der Technik verstehen.

36
Mário Feroldi

Wenn eine Implementierung nicht den Effekt angibt, dass versucht wird, einen Nullfunktionszeiger aufzurufen, könnte sie sich als Aufruf von beliebigem Code verhalten. Solch beliebiger Code könnte sich durchaus wie ein Aufruf der Funktion "foo ()" verhalten. Während Anhang L des C-Standards Implementierungen zur Unterscheidung zwischen "kritischer UB" und "nichtkritischer UB" auffordert, und einige C++ - Implementierungen möglicherweise eine ähnliche Unterscheidung anwenden, wäre das Aufrufen eines ungültigen Funktionszeigers in jedem Fall eine kritische UB.

Beachten Sie, dass sich die Situation in dieser Frage sehr unterscheidet, z.

unsigned short q;
unsigned hey(void)
{
  if (q < 50000)
    do_something();
  return q*q;
}

In der letzteren Situation kann ein Compiler, der nicht behauptet "analysefähig" zu sein, erkennen, dass der Code aufgerufen wird, wenn q größer als 46,340 ist, wenn die Ausführung die Anweisung return erreicht, und dass er auch do_something() unbedingt aufrufen kann. Anhang L ist zwar schlecht geschrieben, es scheint jedoch die Absicht zu sein, solche "Optimierungen" zu verbieten. Im Falle des Aufrufs eines ungültigen Funktionszeigers kann jedoch auch direkt generierter Code auf den meisten Plattformen ein beliebiges Verhalten aufweisen.

0
supercat