Grundsätzlich undefiniertes Verhalten

8

Ob in C oder C ++, ich denke, dass dieses illegale Programm, dessen Verhalten gemäß dem C- oder C ++ - Standard undefiniert ist, interessant ist:

#include <stdio.h>

int foo() {
    int a;
    const int b = a;
    a = 555;
    return b;
}

void bar() {
    int x = 123;
    int y = 456;
}

int main() {
    bar();
    const int n1 = foo();
    const int n2 = foo();
    const int n3 = foo();
    printf("%d %d %d\n", n1, n2, n3);
    return 0;
}

Ausgabe auf meinem Computer (nach Kompilierung ohne Optimierung):

123 555 555

Ich denke, dass dieses illegale Programm interessant ist, weil es die Stapelmechanik veranschaulicht, weil der Grund, warum man C oder C ++ (anstelle von beispielsweise Java) verwendet, darin besteht, nahe an der Hardware, nahe an der Stapelmechanik und dergleichen zu programmieren.

Wenn jedoch in StackOverflow der Code eines Fragestellers versehentlich aus dem nicht initialisierten Speicher gelesen wird, zitieren die am stärksten bewerteten Antworten ausnahmslos den C- oder C ++ - Standard (insbesondere C ++), sodass das Verhalten undefiniert ist. Dies gilt natürlich für den Standard - das Verhalten ist in der Tat undefiniert -, aber es ist merkwürdig, dass alternative Antworten, die aus Hardware- oder stapelmechanischer Sicht versuchen, zu untersuchen, warum ein bestimmtes undefiniertes Verhalten (wie das Ausgabe oben) könnte aufgetreten sein, sind selten und werden tendenziell ignoriert.

Ich erinnere mich sogar an eine Antwort, die darauf hinwies, dass undefiniertes Verhalten das Neuformatieren meiner Festplatte beinhalten könnte. Ich habe mir darüber jedoch keine allzu großen Sorgen gemacht, bevor ich das obige Programm ausgeführt habe.

Meine Frage lautet: Warum ist es wichtiger, den Lesern lediglich beizubringen, dass Verhalten in C oder C ++ undefiniert ist, als das undefinierte Verhalten zu verstehen? Ich meine, wenn der Leser das undefinierte Verhalten verstehen würde, würde er es dann nicht eher vermeiden?

Meine Ausbildung ist zufällig in Elektrotechnik und ich arbeite als Bauingenieur. Das letzte Mal, dass ich als Programmierer per se gearbeitet habe, war 1994, daher bin ich neugierig, die Perspektive von Anwendern mit konventionelleren, mehr zu verstehen Aktuelle Hintergründe der Softwareentwicklung.

thb
quelle
3
Manchmal ist es wirklich schwer zu verstehen, was Ihr Programm tatsächlich tut, bis Sie sich die produzierte Assembly ansehen und feststellen, dass der Compiler plötzlich einen guten Teil des Codes aufgrund eines kleinen undefinierten Verhaltens optimiert hat.
Chris
7
Undefiniertes Verhalten bedeutet, dass alles passieren kann. Ob die Ausgabe sinnvoll ist oder nicht, spielt keine Rolle ... Es ist nur ein Zufall, dass der Compiler so implementiert wird, wie Sie es erwarten würden ...
Jaa-c
5
Wie ein Compiler UB kompiliert, ist zu spezifisch, um eine nützliche SO-Frage zu sein: Dies hängt vom jeweiligen Compiler, Betriebssystem, der Maschinenarchitektur, den Optimierungsstufen und der genauen Version des verwendeten Compilers ab. Die Artikelserie unter blog.llvm.org/2011/05/what-every-c-programmer-should-know.html bietet einen guten Überblick darüber, warum Sie UB vermeiden sollten und einige Dinge, die schief gehen können.
Paul Hankin
4
Ein anderer Compiler oder derselbe Compiler unter verschiedenen Einstellungen, verschiedenen Optimierungsstufen oder vielleicht sogar auf einem anderen System kann den Code unterschiedlich kompilieren. Sie können nicht sicher wissen, wie die Ergebnisse aussehen werden. Da es an der inneren "schwarzen Magie" des Compilers liegt und es möglicherweise durch Optionen und andere äußere Parameter beeinflusst wird, ist es möglicherweise nicht reproduzierbar und selbst wenn es nicht ratsam wäre. Wenn Sie mehr über den Stapel erfahren möchten, gibt es bessere Möglichkeiten. Ich würde möglicherweise vorschlagen, eine gültige Ausgabe der Code-Assembly zu prüfen.
Tommy Andersen
2
Das Problem bei dieser Frage besteht darin, wie Sie "undefiniert" (ha!) Definieren. Wenn Sie wissen, was der Compiler tun wird, ist er nicht undefiniert : Er ist implementierungsdefiniert (wenn der ISO C-Standard der Implementierung keine explizite Berechtigung zum Definieren gibt, ist er implementierungsdefiniert und Sie sind jetzt auch mit GNU C oder was auch immer anstelle von ISO C). Es ist nicht sinnvoll, über das "Verstehen" der wahren UB zu sprechen . Wenn es konsequent verstanden werden kann, ist es nicht.
Leushenko

Antworten:

5

Die Wertanalyse von Frama-C, ein statischer Analysator, dessen angebliches Ziel es ist, alle undefinierten Verhaltensweisen in einem C-Programm zu finden, betrachtet die Zuordnung const int b = a;als in Ordnung. Dies ist eine bewusste Entwurfsentscheidung, um zu ermöglichen memcpy()(normalerweise als Schleife über unsigned charElemente eines virtuellen Arrays implementiert , und dass der C-Standard möglicherweise eine erneute Implementierung als solche zulässt), ein struct(das Polster und nicht initialisierte Elemente haben kann ) nach zu kopieren Ein weiterer.

Die "Ausnahme" gilt nur für lvalue = lvalue;Zuweisungen ohne dazwischenliegende Konvertierung, dh für Zuweisungen, die einer Kopie eines Speicherabschnitts für einen Speicherort an einen anderen entsprechen.

Ich (als einer der Autoren der Wertanalyse von Frama-C) habe dies mit Xavier Leroy zu einem Zeitpunkt besprochen, als er sich selbst über die Definition im verifizierten C-Compiler CompCert wunderte, sodass er möglicherweise dieselbe Definition verwendet hat. Es ist meiner Meinung nach sauberer als das, was der C-Standard mit unbestimmten Werten zu tun versucht, die unsigned charFallenrepräsentationen sein können, und dem Typ , der garantiert keine Fallendarstellungen hat, aber sowohl CompCert als auch Frama-C nehmen relativ nicht exotische Ziele an. und vielleicht hat das Standardisierungskomitee versucht, Plattformen unterzubringen, auf denen das Lesen eines nicht initialisierten intProgramms das Programm tatsächlich abbrechen kann.

Wiederkehrende boder vorbei n1, n2oder n3bis printfin dem Ende zumindest undefiniertes Verhalten betrachtet werden, da eine nicht initialisierte Scheibe Speicherkopieren macht es nicht initialisiert. Mit einer alten Frama-C-Version:

$ frama-c -val t.c

t.c:19:… accessing uninitialized left-value: assert \initialized(&n1);

Und in einer alten Version von CompCert nach geringfügigen Änderungen, um das Programm für es akzeptabel zu machen:

$ ccomp -interp t.c
Time 33: in function foo, expression <loc> = <undef>
ERROR: Undefined behavior
Kompliziert siehe Bio
quelle
8

Undefiniertes Verhalten bedeutet letztendlich, dass das Verhalten nicht deterministisch ist. Programmierer, die nicht wissen, dass sie nicht deterministischen Code schreiben, sind nur schlechte, ignorante Programmierer. Diese Seite soll Programmierer besser (und weniger unwissend) machen.

Es ist nicht unmöglich, angesichts nicht deterministischen Verhaltens ein korrektes Programm zu schreiben. Es ist jedoch eine spezialisierte Programmierumgebung und erfordert eine andere Art von Programmierdisziplin.

Selbst in Ihrem Beispiel können sich die Werte auf dem "Stapel" so ändern, dass Sie nicht die erwarteten Werte erhalten, wenn das Programm ein extern ausgelöstes Signal empfängt. Wenn die Maschine Trap-Werte hat, kann das Lesen von Zufallswerten außerdem dazu führen, dass etwas Seltsames passiert.

jxh
quelle
4
@jxh Ich bin nicht sicher, ob nicht deterministisch richtig ist. Ein Programm könnte auf einer bestimmten Plattform undefiniert, aber vollständig wiederholbar sein, oder?
Quant
3
@Arman: Es kann auf einer bestimmten Plattform wiederholbar sein oder nicht, das ist der Punkt.
jxh
1
@Giorgio: Der andere Punkt ist, dass undefiniertes Verhalten nicht deterministisch sein muss, selbst für genau dieselbe Plattform und Implementierung.
jxh
1
C und C ++ verwenden zwei unterschiedliche Begriffe: undefiniertes Verhalten und nicht angegebenes Verhalten. Es ist auch unbestimmt sequenziert. Und die Unterscheidung ist wichtig. Es ist möglich, wenn auch schwierig, ein korrektes Programm bei nicht spezifiziertem Verhalten zu schreiben. Keine sorgfältige Codierung kann jedoch die Richtigkeit bei undefiniertem Verhalten gewährleisten. Undefiniertes Verhalten entfernt die semantische Bedeutung Ihres gesamten Programms. Andererseits kann das von der Sprache nicht definierte Verhalten von der Plattform definiert werden.
Ben Voigt
1
@jxh: Fehlertolerante Systeme sind in der Tat sehr interessant. Aber sie sind nicht tolerant gegenüber undefiniertem Verhalten. Kopien, die im Gleichschritt ausgeführt werden und auf undefiniertes Verhalten stoßen, treffen möglicherweise die falsche Wahl, und die Abstimmung hilft dann nicht weiter.
Ben Voigt
6

Warum ist es wichtiger, den Lesern lediglich beizubringen, dass Verhalten in C oder C ++ undefiniert ist, als das undefinierte Verhalten zu verstehen?

Weil das spezifische Verhalten möglicherweise nicht wiederholbar ist, selbst von Lauf zu Lauf ohne Neuerstellung.

Genau das zu verfolgen, was passiert ist, mag eine nützliche akademische Übung sein, um die Macken Ihrer speziellen Plattform besser zu verstehen, aber aus Codierungssicht ist die einzig relevante Lektion "Mach das nicht". Ein Ausdruck wie a++ * a++ist ein Codierungsfehler, Punkt. Das ist wirklich alles jemand muss wissen.

John Bode
quelle
5

"Undefiniertes Verhalten" ist die Abkürzung für "Dieses Verhalten ist nicht deterministisch. Es wird sich wahrscheinlich nicht nur auf verschiedenen Compilern oder Hardwareplattformen unterschiedlich verhalten, sondern auch auf verschiedenen Versionen desselben Compilers."

Die meisten Programmierer würden dies als unerwünschtes Merkmal betrachten, insbesondere da C und C ++ standardbasierte Sprachen sind . Das heißt, Sie verwenden sie teilweise, weil die Sprachspezifikation bestimmte Garantien für das Verhalten der Sprache gibt, wenn Sie einen standardkonformen Compiler verwenden.

Wie bei den meisten Dingen in der Programmierung müssen Sie die Vor- und Nachteile abwägen. Wenn der Vorteil einer Operation, die UB ist, die Schwierigkeit übersteigt, ein stabiles, plattformunabhängiges Verhalten zu erzielen, verwenden Sie auf jeden Fall das undefinierte Verhalten. Die meisten Programmierer werden denken, dass es sich die meiste Zeit nicht lohnt.

Das Mittel gegen undefiniertes Verhalten besteht darin, das Verhalten zu untersuchen, das Sie bei einer bestimmten Plattform und einem bestimmten Compiler tatsächlich erhalten. Diese Art von Prüfung wird wahrscheinlich nicht von einem erfahrenen Programmierer in einem Q & A-Umfeld für Sie untersucht.

Robert Harvey
quelle
+1 Wie @aschepler besser erklärt hat als ich, sind die detaillierten Details des undefinierten Verhaltens beim Debuggen von Interesse. Wenn mein Unit-Test Segfaults ausführt und ich die Speicherverwaltungsmechanismen verstehe, die Segfaults erzeugen, kann ich mein Programm schneller debuggen. Natürlich haben Sie Recht: Es ist schwer, sich einen Fall vorzustellen, in dem man UB absichtlich im fertigen Code aufrufen würde!
thb
1
Sie vermissen "mit verschiedenen Kompilierungsoptionen". Immer lustig, wenn sich die Develop / Test / Release-Versionen anders verhalten.
Henk Holterman
1
Oder sogar "kann in aufeinanderfolgenden Läufen derselben Binärdatei, die aus einer einzelnen Kompilierung resultieren, unterschiedliche Ergebnisse erzeugen".
Vatine
Undefiniertes Verhalten sollte manchmal bedeuten, und manchmal sollte dies bedeuten: "Dieses Aktionsverhalten sollte bei allen Implementierungen für Plattformen, die wir kennen, identisch funktionieren, sollte sich jedoch auf Plattformen, auf denen dies problematisch wäre, anders verhalten. Es besteht kein Mandatsbedarf." Das normale Verhalten auf gängigen Plattformen, da Compiler-Autoren, die nicht absichtlich stumpf sind, die Dinge so verarbeiten, unabhängig davon, ob der Standard dies verlangt oder nicht. " Ein Beispiel für Letzteres wäre, (-1)<<1dass C89 auf Plattformen, die nicht gepolstertes Zweierkomplement verwenden, als -2 definiert wird ...
Supercat
... ganzzahlige Typen, aber C99 betrachtet dies als undefiniertes Verhalten, ohne einen Grund für die Änderung anzugeben. Wenn man die beabsichtigte Bedeutung wie oben interpretiert, wäre dies keine bahnbrechende Änderung, außer auf Plattformen, auf denen das C89-Verhalten unpraktisch war, aber ein Code sich trotzdem darauf stützte.
Supercat
1

Wenn in der Dokumentation für einen bestimmten Compiler angegeben ist, was zu tun ist, wenn Code etwas tut, das vom Standard als "undefiniertes Verhalten" eingestuft wird, funktioniert Code, der sich auf dieses Verhalten stützt, beim Kompilieren mit diesem Compiler ordnungsgemäß , kann sich jedoch in beliebiger Weise verhalten, wenn Kompiliert mit einem anderen Compiler, dessen Dokumentation das Verhalten nicht spezifiziert.

Wenn in der Dokumentation für einen Compiler nicht angegeben ist, wie er mit einem bestimmten "undefinierten Verhalten" umgehen soll, sagt die Tatsache, dass das Verhalten eines Programms bestimmten Regeln zu entsprechen scheint, nichts darüber aus, wie sich ähnliche Programme verhalten. Jede Vielzahl von Faktoren kann dazu führen, dass ein Compiler Code ausgibt, der unerwartete Situationen unterschiedlich behandelt - manchmal auf scheinbar bizarre Weise.

Betrachten Sie beispielsweise einen Computer mit inteiner 32-Bit-Ganzzahl:

int undef_behavior_example(uint16_t size1, uint16_t size2)
{
  int flag = 0;
  if ((uint32_t)size1 * size2 > 2147483647u)
    flag += 1;
  if (((size1*size2) & 127) != 0) // Test whether product is a multiple of 128
    flag += 2;
  return flag;
}

Wenn size1undsize2Wären beide gleich 46341 (ihr Produkt ist 2147488281), könnte man erwarten, dass die Funktion 3 zurückgibt, aber ein Compiler könnte den ersten Test legitimerweise vollständig überspringen. Entweder wäre das Produkt klein genug, dass die Bedingung falsch wäre, oder die bevorstehende Multiplikation würde überlaufen und den Compiler von jeglicher Verpflichtung entbinden, etwas zu tun oder getan zu haben. Während ein solches Verhalten bizarr erscheinen mag, scheinen einige Compilerautoren sehr stolz auf die Fähigkeit ihrer Compiler zu sein, solche "unnötigen" Tests zu eliminieren. Einige Leute könnten erwarten, dass ein Überlauf bei der zweiten Multiplikation im schlimmsten Fall dazu führen würde, dass alle Bits dieses bestimmten Produkts willkürlich beschädigt werden. in der Tat jedoch

Superkatze
quelle
Würde die Multiplikation nicht modulo UINT16_MAX erfolgen?
Neugieriger
@curiousguy: Wenn intein 32-Bit - Ganzzahl, dann Werte vom Typ uint16_twird gefördert werden , intbevor irgendwelche Berechnungen mit ihnen. Eine Regel, die im Allgemeinen in Ordnung wäre, wenn Implementierungen signierte Arithmetik nur dann als anders als nicht signiert behandeln würden, wenn sie ein anderes definiertes Verhalten hätten.
Supercat
Ich glaube, jeder Operand vom Typ ohne Vorzeichen hat dazu geführt, dass die Operation ohne Vorzeichen war.
Neugieriger
@curiousguy: Einige Compiler haben in den Tagen vor dem Standard so gearbeitet, aber der Standard gibt an, dass nicht signierte Typen, die unter dem Standard liegen unsignedund einen Wertebereich haben, der vollständig in den von passt int, zu einem signierten Typ befördert werden int.
Supercat