it-swarm.com.de

An welcher Stelle in der Schleife wird ein Ganzzahlüberlauf undefiniertes Verhalten?

Dies ist ein Beispiel zur Veranschaulichung meiner Frage, die etwas viel komplizierteren Code beinhaltet, den ich hier nicht posten kann.

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

Dieses Programm enthält undefiniertes Verhalten auf meiner Plattform, da a in der 3. Schleife überläuft.

Hat das ganze Programm ein undefiniertes Verhalten, oder erst nachdem der Überlauf tatsächlich passiert? Könnte der Compiler möglicherweise herausfinden, dass awill überläuft, so dass er die gesamte Schleife als undefiniert deklarieren kann und die printfs nicht ausführen muss, obwohl sie alle vor dem Überlauf auftreten?

(Tags mit C und C++ unterscheiden sich zwar, da ich an Antworten für beide Sprachen interessiert wäre, wenn sie unterschiedlich sind.)

85
jcoder

Wenn Sie an einer rein theoretischen Antwort interessiert sind, lässt der C++ - Standard undefiniertes Verhalten "Zeitreisen" zu:

[intro.execution]/5: Eine konforme Implementierung, die ein wohlgeformtes Programm ausführt, muss dasselbe beobachtbare Verhalten erzeugen als eine der möglichen Ausführungen der entsprechenden Instanz der abstrakten Maschine mit demselben Programm und die gleiche Eingabe. Wenn eine solche Ausführung eine undefinierte Operation enthält, stellt dieser Internationale Standard __.. Keine Anforderung an die Implementierung, die das Programm mit dieser Eingabe ausführt (auch nicht in Bezug auf Operationen, die der ersten undefinierten Operation vorangehen)

Wenn also Ihr Programm undefiniertes Verhalten enthält, ist das Verhalten Ihres ganzen Programms nicht definiert.

106
TartanLlama

Lassen Sie mich zunächst den Titel dieser Frage korrigieren:

Undefined Behavior ist nicht (spezifisch) im Bereich der Ausführung.

Undefiniertes Verhalten wirkt sich auf alle Schritte aus: Kompilieren, Verknüpfen, Laden und Ausführen.

Einige Beispiele, um dies zu untermauern, bedenken Sie, dass kein Abschnitt vollständig ist:

  • der Compiler kann davon ausgehen, dass Codeteile, die Undefined Behavior enthalten, niemals ausgeführt werden. Daher wird davon ausgegangen, dass die Ausführungspfade, die dazu führen würden, toter Code sind. Siehe Was jeder C-Programmierer über undefiniertes Verhalten wissen sollte von niemand anderem als Chris Lattner.
  • der Linker kann davon ausgehen, dass bei Vorhandensein mehrerer Definitionen eines schwachen Symbols (erkannt durch den Namen) alle Definitionen dank der One Definition Rule
  • der Loader (falls Sie dynamische Bibliotheken verwenden) kann dasselbe annehmen und so das erste gefundene Symbol auswählen. Dies wird normalerweise (ab) zum Abfangen von Anrufen mit LD_PRELOAD-Tricks unter Unixes verwendet
  • die Ausführung kann fehlschlagen (SIGSEV), wenn Sie Schlenkerzeiger verwenden

Dies ist, was so unheimlich über undefiniertes Verhalten ist: Es ist fast unmöglich, im Voraus vorherzusagen, welches Verhalten genau auftreten wird, und diese Vorhersage muss bei jeder Aktualisierung der Toolchain, des zugrunde liegenden Betriebssystems, nochmals überarbeitet werden. .


Ich empfehle dieses Video von Michael Spencer (LLVM-Entwickler): CppCon 2016: Mein kleiner Optimierer: Undefined Behavior is Magic .

30
Matthieu M.

Ein aggressiv optimierender C- oder C++ - Compiler, der auf einen 16-Bit int abzielt, bewirkt know, dass das Verhalten beim Hinzufügen von 1000000000 zu einem int-Typ undefined ist.

Es ist jedem Standard erlaubt, alles zu tun, was er will, wobei könnte das Löschen des gesamten Programms beinhalten und int main(){} übrig lassen.

Aber was ist mit größeren ints? Ich kenne keinen Compiler, der dies noch tut (und ich bin kein Experte für C- und C++ - Compiler-Design), aber ich kann mir vorstellen, dass irgendwann ein Compiler ist, der auf ein 32-Bit-int-Ziel abzielt höher wird herausfinden, dass die Schleife unendlich ist (i ändert sich nicht) und, so dass a schließlich überläuft. Es kann also wieder die Ausgabe nach int main(){} optimiert werden. Ich möchte hier darauf hinweisen, dass die Optimierungen von Compilern immer aggressiver werden und immer undefiniertere Verhaltenskonstrukte sich auf unerwartete Weise manifestieren.

Die Tatsache, dass Ihre Schleife unendlich ist, ist an sich nicht undefiniert, da Sie in die Standardausgabe des Schleifenkörpers schreiben.

28
Bathsheba

Technisch gesehen ist unter dem C++ - Standard, wenn ein Programm undefiniertes Verhalten enthält, das Verhalten des gesamten Programms selbst zur Kompilierzeit (bevor das Programm überhaupt ausgeführt wird) undefiniert.

Da der Compiler (als Teil einer Optimierung) annehmen kann, dass der Überlauf nicht auftritt, ist in der Praxis zumindest das Verhalten des Programms bei der dritten Iteration der Schleife (unter der Annahme einer 32-Bit-Maschine) undefiniert Wahrscheinlich erhalten Sie vor der dritten Iteration korrekte Ergebnisse. Da das Verhalten des gesamten Programms technisch undefiniert ist, kann das Programm jedoch keine völlig falsche Ausgabe generieren (einschließlich keine Ausgabe), zu einem beliebigen Zeitpunkt während der Ausführung abstürzen oder gar nicht kompilieren (da undefiniertes Verhalten reicht aus) Kompilierzeit).

Ein undefiniertes Verhalten gibt dem Compiler mehr Spielraum für die Optimierung, da bestimmte Annahmen über das, was der Code tun muss, beseitigt werden. Dabei kann nicht garantiert werden, dass Programme, die auf Annahmen mit undefiniertem Verhalten beruhen, wie erwartet funktionieren. Daher sollten Sie sich nicht auf ein bestimmtes Verhalten verlassen, das gemäß dem C++ - Standard als undefiniert gilt.

11
bwDraco

Um warum undefined Verhalten zu verstehen, kann 'Zeitreise' als @TartanLlama angemessen ausgedrückt werden - lassen Sie uns einen Blick auf die 'wenn-wenn'-Regel werfen:

1.9 Programmausführung 

1 Die semantischen Beschreibungen in dieser Internationalen Norm definieren ein parametrisierte nichtdeterministische abstrakte Maschine. Diese Internationale Standard stellt keine Anforderung an die Struktur der Konformität Implementierungen. Insbesondere brauchen sie das .__ nicht zu kopieren oder zu emulieren. Struktur der abstrakten Maschine. Eher konforme Implementierungen sind erforderlich, um (nur) das beobachtbare Verhalten der Zusammenfassung zu emulieren Maschine wie unten erklärt.

Damit können wir das Programm als "Black Box" mit einer Eingabe und einer Ausgabe betrachten. Die Eingabe könnte Benutzereingaben, Dateien und viele andere Dinge sein. Die Ausgabe ist das in der Norm erwähnte 'beobachtbare Verhalten'.

Der Standard definiert nur eine Zuordnung zwischen der Eingabe und der Ausgabe, sonst nichts. Dazu wird eine "Beispiel-Black-Box" beschrieben, explizit wird jedoch gesagt, dass jede andere Black-Box mit demselben Mapping gleichermaßen gültig ist. Dies bedeutet, dass der Inhalt der Blackbox irrelevant ist.

In diesem Sinne wäre es nicht sinnvoll zu sagen, dass undefiniertes Verhalten zu einem bestimmten Zeitpunkt auftritt. In der sample -Implementierung der Black Box können wir sagen, wo und wann es passiert, aber die Black Box {actual könnte etwas völlig anderes sein, daher können wir nicht sagen, wo und wann es passiert mehr. Theoretisch könnte ein Compiler beispielsweise entscheiden, alle möglichen Eingaben aufzuzählen und die resultierenden Ausgaben vorberechnen. Das undefinierte Verhalten wäre dann beim Kompilieren aufgetreten.

Ein undefiniertes Verhalten ist das Fehlen einer Zuordnung zwischen Eingabe und Ausgabe. Ein Programm kann für einige Eingaben undefiniertes Verhalten haben, für andere jedoch definiertes Verhalten. Dann ist die Abbildung zwischen Eingabe und Ausgabe einfach unvollständig. Es gibt einen Eingang, für den kein Mapping zur Ausgabe existiert.
.__ Das Programm in der Frage hat für jede Eingabe ein undefiniertes Verhalten, daher ist das Mapping leer.

9
alain

TartanLlamas Antwort ist richtig. Das undefinierte Verhalten kann jederzeit auftreten, auch während der Kompilierzeit. Das mag absurd erscheinen, aber es ist eine Schlüsselfunktion, die es Compilern erlaubt, das zu tun, was sie tun müssen. Es ist nicht immer einfach, ein Compiler zu sein. Sie müssen jedes Mal genau das tun, was die Spezifikation sagt. Manchmal kann es jedoch äußerst schwierig sein zu beweisen, dass ein bestimmtes Verhalten auftritt. Wenn Sie sich an das Anhalteproblem erinnern, ist es ziemlich trivial, Software zu entwickeln, für die Sie nicht nachweisen können, ob eine unendliche Schleife abgeschlossen wird oder in eine unendliche Schleife eingeht, wenn eine bestimmte Eingabe eingespeist wird.

Wir könnten Compiler pessimistisch machen und ständig aus Angst vor der nächsten Anweisung kompilieren, dass die nächste Anweisung eines dieser stoppenden Probleme sein könnte, aber das ist nicht sinnvoll. Stattdessen geben wir dem Compiler einen Pass: Bei diesen "undefinierten Verhalten" -Themen sind sie von jeglicher Verantwortung befreit. Undefiniertes Verhalten besteht aus all den Verhaltensweisen, die so subtil schändlich sind, dass wir Schwierigkeiten haben, sie von den wirklich bösen, schändlichen Halteproblemen und so weiter zu trennen.

Es gibt ein Beispiel, das ich sehr gerne poste, obwohl ich zugeben muss, dass ich die Quelle verloren habe, also muss ich umschreiben. Es war aus einer bestimmten Version von MySQL. In MySQL hatten sie einen Ringpuffer, der mit vom Benutzer bereitgestellten Daten gefüllt wurde. Sie wollten natürlich sicherstellen, dass die Daten den Puffer nicht überlaufen, weshalb sie überprüft wurden:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

Es sieht gesund aus. Was aber, wenn numberOfNewChars wirklich groß ist und überläuft? Dann wird es umbrochen und wird zu einem Zeiger, der kleiner als endOfBufferPtr ist, sodass die Überlauflogik niemals aufgerufen wird. Sie fügten also noch einen zweiten Check hinzu:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

Es sieht so aus, als hätten Sie sich um den Pufferüberlauffehler gekümmert, oder? Es wurde jedoch ein Fehler gemeldet, der besagt, dass dieser Puffer bei einer bestimmten Version von Debian übergelaufen ist! Eine sorgfältige Untersuchung ergab, dass diese Version von Debian die erste war, die eine besonders blutende Edge-Version von gcc verwendete. In dieser Version von gcc hat der Compiler erkannt, dass currentPtr + numberOfNewChars never ein kleinerer Zeiger als currentPtr sein kann, da ein Überlauf für Zeiger undefiniertes Verhalten ist! Das war ausreichend für gcc, um die gesamte Prüfung zu optimieren, und plötzlich waren Sie nicht gegen Pufferüberläufe geschützt obwohl Sie den Code geschrieben haben, um ihn zu überprüfen!

Dies war ein spezifisches Verhalten. Alles war legal (obwohl ich das gehört habe, hat gcc diese Änderung in der nächsten Version zurückgenommen). Es ist nicht das, was ich als intuitives Verhalten bezeichnen würde, aber wenn Sie Ihre Vorstellungskraft etwas ausdehnen, ist es leicht zu erkennen, wie eine geringfügige Variante dieser Situation ein stoppendes Problem für den Compiler werden könnte. Aus diesem Grund machten die Spec-Autoren "Undefined Behavior" und erklärten, dass der Compiler absolut alles tun könne, was ihm gefallen würde.

6
Cort Ammon

Angenommen, int ist 32-Bit, tritt undefiniertes Verhalten bei der dritten Iteration auf. Wenn die Schleife zum Beispiel nur bedingt erreichbar war oder vor der dritten Iteration bedingt beendet werden konnte, würde es kein undefiniertes Verhalten geben, es sei denn, die dritte Iteration wird tatsächlich erreicht. Im Falle eines undefinierten Verhaltens ist alle Ausgaben des Programms undefiniert, einschließlich Ausgaben, die "in der Vergangenheit" sind, relativ zum Aufruf von undefiniertem Verhalten. In Ihrem Fall bedeutet dies beispielsweise nicht, dass drei "Hallo" -Meldungen in der Ausgabe angezeigt werden.

6
R..

Abgesehen von den theoretischen Antworten wäre eine praktische Beobachtung, dass Compiler während einer langen Zeit verschiedene Transformationen auf Schleifen angewendet haben, um den Arbeitsaufwand in ihnen zu reduzieren. Zum Beispiel gegeben:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

ein Compiler könnte das in Folgendes verwandeln:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

Dadurch wird eine Multiplikation bei jeder Schleifeniteration eingespart. Eine zusätzliche Form der Optimierung, die von Compilern mit unterschiedlichem Grad an Aggressivität angepasst wurde, ... würde dies zu Folgendem machen:

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

Selbst auf Maschinen mit stummem Wraparound-Überlauf kann dies zu Fehlfunktionen führen, wenn __. Eine Zahl kleiner als n vorhanden wäre, die, wenn sie mit der Skala multipliziert würde, __ 0 ergibt. Es könnte sich auch in eine Endlosschleife verwandeln, wenn scale mehr als einmal aus dem Speicher gelesen wurde und etwas seinen Wert unerwartet geändert hat (in jedem Fall, in dem "Scale" in der Mitte der Schleife geändert werden konnte, ohne UB aufzurufen, würde ein Compiler dies nicht tun sein erlaubt, die Optimierung durchzuführen).

Während die meisten derartigen Optimierungen in Fällen, in denen zwei Short unsigned-Typen mit einem Wert zwischen INT_MAX + 1 .__ und UINT_MAX multipliziert werden, keine Probleme haben, gibt es in gcc einige Fälle, in denen eine solche Multiplikation innerhalb einer Schleife auftritt .__ Kann die Schleife vorzeitig beenden lassen. Ich habe nicht bemerkt, dass solche Verhalten von Vergleichsanweisungen in generiertem Code stammen. Es ist jedoch in Fällen wahrnehmbar, in denen der Compiler den Überlauf verwendet, um darauf zu schließen, dass eine Schleife höchstens 4 oder weniger Male ausgeführt werden kann. Es generiert standardmäßig keine Warnungen in Fällen, in denen einige -Eingaben UB verursachen würden und andere nicht, auch wenn deren Folgerungen dazu führen, dass die -Obergrenze der Schleife ignoriert wird.

4
supercat

Undefiniertes Verhalten ist per Definition ein grauer Bereich. Sie können einfach nicht vorhersagen, was es tun wird oder nicht - das ist, was "undefiniertes Verhalten" bedeutet.

Seit undenklichen Zeiten haben Programmierer immer versucht, Reste von Definedness aus einer undefinierten Situation zu retten. Sie haben etwas Code, den sie wirklich verwenden wollen, der sich jedoch als undefiniert erweist, also versuchen sie zu argumentieren: "Ich weiß, dass es undefiniert ist, aber im schlimmsten Fall wird dies oder das tun; es wird niemals Das." Und manchmal sind diese Argumente mehr oder weniger richtig - aber oft sind sie falsch. Und da die Compiler immer intelligenter und intelligenter werden (oder manche Leute sagen schleichender und hinterlistiger), ändern sich die Grenzen der Frage ständig.

Also, wenn Sie Code schreiben wollen, der garantiert funktioniert und der lange Zeit funktionieren wird, gibt es nur eine Wahl: Vermeiden Sie das undefinierte Verhalten um jeden Preis. Wahrlich, wenn Sie sich damit beschäftigen, wird es zurückkommen, um Sie zu verfolgen.

4
Steve Summit

Eine Sache, die Ihr Beispiel nicht berücksichtigt, ist die Optimierung. a ist in der Schleife festgelegt, wird jedoch nie verwendet, und ein Optimierer könnte dies ermitteln. Daher ist es für den Optimierer legitim, a vollständig zu verwerfen, und in diesem Fall verschwindet alles undefinierte Verhalten wie das Opfer eines Bujum.

Dies ist jedoch natürlich undefiniert, da die Optimierung nicht definiert ist. :)

1
Graham

Da diese Frage mit C und C++ doppelt markiert ist, werde ich versuchen, beide anzusprechen. C und C++ verfolgen hier unterschiedliche Ansätze. 

In C muss die Implementierung nachweisen können, dass undefiniertes Verhalten aufgerufen wird, um das gesamte Programm so zu behandeln, als ob es undefiniertes Verhalten hätte. Im OP-Beispiel würde es für den Compiler trivial erscheinen, dies zu beweisen, und daher ist es so, als wäre das gesamte Programm undefiniert.

Wir können dies aus Fehlerbericht 109 sehen, der an seiner Kreuzung fragt:

Wenn der C-Standard jedoch die getrennte Existenz von "undefinierten Werten" erkennt (deren bloße Erstellung nicht vollständig "undefiniertes Verhalten" umfasst), könnte eine Person, die Compilertests ausführt, einen Testfall wie den folgenden schreiben und könnte auch erwarten (oder möglicherweise fordern), dass eine konforme Implementierung diesen Code mindestens "kompilieren" muss (und möglicherweise auch die Ausführung zulässt), und zwar "ohne Fehler".

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

Die letzte Frage lautet also: Muss der obige Code "erfolgreich übersetzt" werden (was immer das bedeutet)? (Siehe die Fußnote zu Unterabschnitt 5.1.1.3.) 

und die Antwort war:

Der C-Standard verwendet den Begriff "unbestimmt bewertet" und nicht "undefinierter Wert". Die Verwendung eines unbestimmten Wertobjekts führt zu undefiniertem Verhalten . In der Fußnote zu Abschnitt 5.1.1.3 wird darauf hingewiesen, dass eine Implementierung beliebig viele Diagnosen erstellen kann, solange ein gültiges Programm noch korrekt übersetzt ist. Wenn ein Ausdruck, dessen Auswertung zu einem undefinierten Verhalten führen würde, in einem Kontext erscheint, in dem ein konstanter Ausdruck erforderlich ist, ist das enthaltende Programm nicht streng konform. Darüber hinaus würde jede mögliche Ausführung eines bestimmten Programms zu einem undefinierten Verhalten führen , das angegebene Programm ist nicht streng konform . Eine konforme Implementierung darf nicht versagen, ein streng konformes Programm zu übersetzen, nur weil eine mögliche Ausführung dieses Programms zu undefiniertem Verhalten führen würde. Da foo möglicherweise nie aufgerufen wird, muss das angegebene Beispiel von einer konformen Implementierung erfolgreich übersetzt werden.

In C++ scheint der Ansatz entspannter zu sein und deutet darauf hin, dass ein Programm undefiniertes Verhalten aufweist, unabhängig davon, ob die Implementierung es statisch beweisen kann oder nicht.

Wir haben [intro.abstrac] p5 was sagt:

Eine konforme Implementierung, die ein wohlgeformtes Programm ausführt, muss dasselbe beobachtbare Verhalten erzeugen wie eine der möglichen Ausführungen der entsprechenden Instanz der abstrakten Maschine mit demselben Programm und derselben Eingabe . Wenn eine solche Ausführung eine undefinierte Operation enthält, stellt dieses Dokument keine Anforderungen an die Implementierung, die dieses Programm mit dieser Eingabe ausführt (auch nicht in Bezug auf Operationen, die der ersten undefinierten Operation vorangehen).

0
Shafik Yaghmour