„Undefiniertes Verhalten" klingt nach Fachjargon für „unvorhersehbar". Es ist stärker als das. Wenn der C-Standard sagt, ein Konstrukt habe undefiniertes Verhalten, bedeutet das, dass der Standard überhaupt keine Anforderungen daran stellt, was das Programm tut - nicht an den Wert, nicht an die Anweisung, nicht an das Programm.
Das ist der Teil, der Leute überrascht: Undefiniertes Verhalten beschränkt sich nicht auf die betreffende Zeile. Ein Compiler darf annehmen, dass es nie eintritt, und umgebenden Code auf dieser Annahme umschreiben. Das Ergebnis kann ein Programm sein, in dem eine Prüfung, die du deutlich geschrieben hast, in der Binärdatei nicht existiert.
Das Vertragsmodell
Stell dir den Standard als einen Vertrag zwischen dir und dem Compiler vor. Du versprichst, bestimmte Dinge nicht zu tun; im Gegenzug verspricht der Compiler, dass dein Programm bedeutet, was es sagt.
Indiziere nicht außerhalb eines Arrays. Lass keine vorzeichenbehaftete Ganzzahl überlaufen. Lies keinen uninitialisierten Wert. Benutze keinen Zeiger nach dem Freigeben. Verändere dasselbe Objekt nicht zweimal in einem Ausdruck ohne Sequenzpunkt dazwischen.
Brich eine Klausel, und der Handel ist geplatzt - für das ganze Programm, nicht nur für diese Zeile. Es gibt keinen „vernünftigen Rückfall" und keine Pflicht abzustürzen.
Drei verwandte Begriffe sollte man auseinanderhalten:
- Undefiniertes Verhalten - alles kann passieren. Zugriff außerhalb der Grenzen, vorzeichenbehafteter Überlauf, Use-after-free.
- Unspezifiziertes Verhalten - eines von mehreren gültigen Ergebnissen, und der Compiler muss dir nicht sagen, welches. Zum Beispiel die Reihenfolge, in der Funktionsargumente ausgewertet werden.
- Implementierungsabhängiges Verhalten - die Implementierung wählt und muss ihre Wahl dokumentieren. Ob
charvorzeichenbehaftet ist, wie groß einintist.
Nur das Erste ist im Sinne von „der Optimierer hat meinen Code gelöscht" gefährlich.
Die großen Quellen
Vorzeichenbehafteter Ganzzahlüberlauf
Vorzeichenlose Arithmetik schlägt um, und der Standard sagt das. Vorzeichenbehaftete tut es nicht - den Bereich zu überschreiten ist undefiniert.
Die Prüfung if (a > INT_MAX - b) läuft vollständig innerhalb des gültigen Bereichs, und das macht sie zu einem korrekten Überlauftest. if (a + b < 0) zu schreiben führt den Überlauf zuerst aus und fragt dann nach dem Ergebnis - und der Compiler, der annehmen darf, dass der Überlauf nie passierte, kann die Prüfung entfernen.
Zugriff außerhalb der Grenzen
Außerhalb eines Arrays zu lesen oder zu schreiben ist undefiniert, ob es abstürzt oder nicht:
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: Index 5 existiert nicht */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB, schon diesen Zeiger zu berechnen */
Beachte die letzte Zeile: Einen Zeiger mehr als eins hinter das Ende zu bilden ist undefiniert, auch wenn du ihn nie dereferenzierst. Der Standard erlaubt arr + 5 (eins hinter dem Ende, zum Abbruch einer Schleife), aber nicht arr + 6.
Kleine Überschreitungen sind die gefährlichen. Sie erzeugen meist keinen Segfault; sie überschreiben still eine benachbarte Variable, und die falsche Antwort taucht irgendwo Unverwandtem auf.
Uninitialisierte Lesezugriffe
int x;
printf("%d\n", x); /* UB: Lesen eines unbestimmten Werts */
int *p;
*p = 42; /* UB: Dereferenzieren eines unbestimmten Zeigers */
Es ist verlockend zu denken „es enthält halt Müll", aber das ist nicht, was der Standard sagt, und Compiler nutzen den Unterschied aus. GCC ist dafür bekannt zu schließen, dass eine vor der Zuweisung gelesene Variable jeden beliebigen Wert halten darf - auch den, der einen Zweig wegfallen lässt.
Baumelnde Zeiger
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: Use-after-free */
free(p); /* UB: doppeltes Freigeben */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: die Lebensdauer des Objekts endete */
Die Laufzeitfolgen behandelt Segmentierungsfehler; der Punkt hier ist, dass der Absturz der glückliche Ausgang ist.
Die falsche printf-Angabe
printf("%d\n", 3.14); /* UB: %d mit einem double */
printf("%s\n", 42); /* UB: %s mit einem int - stürzt meist ab */
printf("%d %d\n", 1); /* UB: weniger Argumente als Angaben */
long n = 5;
printf("%d\n", n); /* UB auf Systemen, wo long breiter als int ist */
printf nimmt variabel viele Argumente: Es liest sie gemäß der Formatzeichenkette und kann sie nicht prüfen. Eine Fehlpaarung lässt es die falsche Anzahl Bytes von der falschen Stelle lesen. Kompiliere mit -Wall, und der Compiler prüft die Formatzeichenkette für dich - das ist eine der wertvollsten Warnungen des Satzes.
Ein Objekt zweimal in einem Ausdruck verändern
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
Diese sind undefiniert, nicht bloß „compilerabhängig". Lehrbuchrätsel, die fragen, was i = i++ + ++i ergibt, haben keine richtige Antwort.
Striktes Aliasing
Auf ein Objekt über einen Zeiger eines unverträglichen Typs zuzugreifen ist undefiniert, und das überrascht erfahrene Programmierer:
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: ein float über ein int * lesen */
Der Compiler nimmt an, dass ein int * und ein float * nie auf denselben Speicher zeigen, und ordnet entsprechend um. Der definierte Weg, Bytes umzudeuten, ist memcpy (das zu denselben Instruktionen optimiert wird) oder eine Union:
Ein char * ist die Ausnahme - du darfst die Bytes jedes Objekts immer über ein unsigned char * betrachten.
Warum „bei mir läuft es" nichts beweist
Undefiniertes Verhalten scheint oft zu funktionieren, und das macht es gefährlich. Das Programm läuft durch Entwicklung und Tests korrekt und geht dann kaputt, wenn sich etwas völlig Unverwandtes ändert:
- Eine neue Compilerversion mit einem schlaueren Optimierer.
- Der Wechsel von
-O0zu-O2für den Release-Build. - Eine unverwandte Funktion hinzuzufügen, was das Stacklayout verschiebt, sodass eine Überschreitung nun auf etwas Wichtiges trifft.
- Eine andere Maschine, eine andere libc, ein anderes Betriebssystem.
Der Anschein zu funktionieren ist kein Beleg für Korrektheit, denn der Standard hat nie etwas versprochen. Es ist ein Bug im Ruhezustand, und der Auslöser ist meist der Release-Build.
Wie der Optimierer es ausnutzt
Hier ist das Beispiel, das die Diskussion meist beendet. Ein Programmierer schreibt eine Nullprüfung:
void process(int *p) {
int value = *p; /* dereferenzieren */
if (p == NULL) { /* dann auf null prüfen */
return;
}
printf("%d\n", value * 2);
}
Die Reihenfolge ist falsch - die Prüfung kommt nach der Dereferenzierung -, aber die Prüfung läuft doch sicher trotzdem?
Sie muss nicht. Der Compiler schließt: *p wurde dereferenziert, also kann p nicht NULL sein (NULL zu dereferenzieren ist undefiniert, in jedem Programm mit definiertem Verhalten ist es also nicht NULL), also ist p == NULL immer falsch, also ist der gesamte if-Rumpf toter Code und kann gelöscht werden.
Die übersetzte Funktion hat überhaupt keine Nullprüfung mehr. Ein echtes Vorkommen dieses Musters im Linux-Kernel wurde zu CVE-2009-1897, wo GCC genau eine solche Prüfung entfernte und aus einem harmlos aussehenden Reihenfolgefehler eine ausnutzbare Schwachstelle machte.
Ein zweites, kleineres Beispiel:
/* Eine Überlaufprüfung, die nicht funktioniert */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "ist es umgeschlagen?" */
return -1;
}
return sum;
}
Bei vorzeichenbehafteten Typen darf der Compiler annehmen, dass a + b nicht überlief, und dann ist sum < a nur möglich, wenn b < 0. Mit dieser Annahme prüft der Test etwas anderes als das Gemeinte, und bei b >= 0 kann er vollständig wegoptimiert werden. Die funktionierende Fassung prüft vorher:
Beide Tests bleiben im darstellbaren Bereich, es tritt also nie ein Überlauf auf und es gibt nichts, was der Optimierer wegannehmen könnte. (GCC und clang bieten außerdem __builtin_add_overflow, das das in einer Instruktion erledigt.)
Es erkennen
Zuerst statische Warnungen - sie sind kostenlos:
gcc -Wall -Wextra -Wpedantic program.c -o program
Das fängt Fehlpaarungen bei Formatzeichenketten, manche uninitialisierten Lesezugriffe, verdächtige Vergleiche und unerreichbaren Code ab.
Dann die Sanitizer, die das Programm instrumentieren und im Moment des Verstoßes melden:
gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
Kombiniere sie mit AddressSanitizer für die Speicherhälfte:
gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program
Zusammen fangen sie Zugriffe außerhalb der Grenzen, Use-after-free, doppeltes Freigeben, vorzeichenbehafteten Überlauf, ungültige Verschiebungen, falsch ausgerichtete Zeiger und Null-Dereferenzierungen ab - jeweils mit Datei, Zeile und Aufrufkette. Grob doppelt so langsam, was während der Entwicklung nichts ist.
valgrind ./program braucht keine Neuübersetzung und fängt uninitialisierte Lesezugriffe und Speicherfehler ab, wenn auch kein arithmetisches UB. clang --analyze und gcc -fanalyzer finden einiges davon, ohne das Programm überhaupt auszuführen.
Die praktische Regel: Lass deine Tests in der CI unter den Sanitizern laufen. UB, das lokal zu funktionieren scheint, ist genau das, wofür es sie gibt.
Damit leben
Du kannst undefiniertes Verhalten nicht durch Sorgfalt vermeiden - jeder schreibt irgendwann welches. Was funktioniert, ist, es laut zu machen:
- Baue vom ersten Tag an mit
-Wall -Wextraund behebe jede Warnung. - Lass Tests unter
-fsanitize=address,undefinedlaufen. - Initialisiere jede Variable bei der Deklaration und jeden Zeiger mit
NULL. - Prüfe Array-Indizes gegen die Länge und iteriere mit
i < n. - Prüfe auf Überlauf vor der Rechnung, mit
<limits.h>. - Setze einen Zeiger nach dem Freigeben auf
NULL. - Nimm
unsigned-Typen, wo das Umschlagen das gewünschte Verhalten ist - dort ist es definiert. - Bevorzuge
memcpygegenüber Zeiger-Casts, wenn du Bytes umdeutest.
Cs Geschwindigkeit kommt daher, dass der Compiler annehmen darf, du habest den Vertrag eingehalten. Das ist ein echter Handel, kein Konstruktionsfehler - und die obigen Werkzeuge geben dir den größten Teil der Sicherheit zurück, zu Entwicklungskosten von null.
Für das Laufzeitgesicht dieser Regeln siehe Segmentierungsfehler; für die Fehler zur Übersetzungszeit, die zuerst kommen, häufige Fehler.
Häufig gestellte Fragen
Was ist undefiniertes Verhalten in C?
Code, für den der C-Standard überhaupt keine Anforderungen stellt. Der Compiler darf alles erzeugen: einen Absturz, eine falsche Antwort, Code, der zu funktionieren scheint, oder Code, in dem der betreffende Zweig ganz entfernt wurde. Es ist nicht „implementierungsabhängig" oder „zufällig" - es ist ein Vertrag, den du gebrochen hast, und danach ist nichts mehr zugesichert.
Warum hat C undefiniertes Verhalten, statt einfach alles zu definieren?
Geschwindigkeit und Portabilität. Eine Bereichsprüfung bei jedem Arrayzugriff zu verlangen würde Leistung kosten, die C bewusst nicht zahlen wollte; vorzeichenbehafteten Überlauf als Umschlagen zu definieren würde auf Hardware, die stattdessen abfängt, zusätzliche Instruktionen erzwingen. Diese Fälle undefiniert zu lassen erlaubt dem Compiler anzunehmen, dass sie nie eintreten, und entsprechend zu optimieren.
Ist ein vorzeichenbehafteter Ganzzahlüberlauf in C undefiniertes Verhalten?
Ja. INT_MAX + 1 ist undefiniert - es ist nicht garantiert, dass es auf INT_MIN umschlägt. Beim vorzeichenlosen Überlauf ist es anders: Er ist vollständig definiert und schlägt modulo 2^N um. Deshalb dürfen Compiler annehmen, dass x + 1 > x für ein vorzeichenbehaftetes x immer wahr ist, und eine so geschriebene Überlaufprüfung löschen.
Wie erkenne ich undefiniertes Verhalten in meinem C-Programm?
Kompiliere mit -Wall -Wextra, um abzufangen, was der Compiler statisch sehen kann, und lass deine Tests dann mit -fsanitize=address,undefined laufen, das Zugriffe außerhalb der Grenzen, Use-after-free, vorzeichenbehafteten Überlauf und mehr in dem Moment meldet, in dem sie passieren, samt Datei und Zeile. valgrind fängt Ähnliches ohne Neuübersetzung ab.