Alles in C ist undefiniertes Verhalten
(blog.habets.se)- Undefiniertes Verhalten (UB) ist keine bösartige Optimierung des Compilers, sondern eine Regel, nach der bei als gültig angenommenem Code unmögliche Ausführungspfade nicht behandelt werden müssen
- In nichttrivialem C/C++-Code verbergen sich neben Double-Free oder Zugriffen außerhalb von Grenzen auch subtile Formen von UB wie Alignment, Casts, Initialisierung und Typinkonsistenzen
- Zugriffe über ein nicht ausgerichtetes
int*oderstd::atomic<int>*können je nach Plattform zuSIGBUS, Korrekturen durch den Kernel oder scheinbar normalem Verhalten führen, sind nach dem Standard aber bereits UB - Auch häufiger Code wie das Übergeben eines signed
charanisxdigit(), das Umwandeln vonfloatnachintoder falsche Nutzung vonNULLund variadischen Argumenten verlässt leicht den Bereich des Standards - Bestehende Codebasen kann man nicht wegwerfen, aber man muss sie im großen Stil mit einer Kombination aus LLM-basierter UB-Erkennung und Expertenprüfung korrigieren; für Junioren ist das zu subtil
Undefiniertes Verhalten in C/C++ ist kein Optimierungsproblem
- Undefiniertes Verhalten (UB) bedeutet nicht, dass der Compiler Fehler von Entwicklern „ausnutzt“, sondern dass er annehmen darf, das Programm sei nach dem Standard gültig
- Auch wenn die Absicht für Menschen klar erscheint, kann es schwierig sein, diese Absicht in Compilerphasen oder zwischen Modulen auszudrücken
- Der Compiler ist nicht verpflichtet, bei der Codeerzeugung Sonderfälle zu behandeln, die „nicht passieren können“, und entlang des gesamten Ausführungspfads bis hin zur Hardware kann ein anderes Ergebnis als beabsichtigt entstehen
- Selbst wenn man Optimierungen abschaltet, wird UB nicht sicher, und es gibt keine Garantie, dass dasselbe Verhalten auf aktuellen oder künftigen Compilern und Architekturen erhalten bleibt
UB steckt nicht nur in abwegigem Code
- Double-Free, Use-after-Free, Zugriffe außerhalb von Objektgrenzen und Zugriffe auf nicht initialisierten Speicher sind bekannte Formen von UB, treten aber branchenweit weiterhin auf
- Es gibt auch viele subtilere und weniger intuitive Formen von UB, sodass ganz gewöhnlich aussehender C/C++-Code leicht außerhalb des Standards gerät
- Im C23-Standard kommt das Wort „undefined“ 283-mal vor, und wenn man Fälle mitzählt, die implizit undefiniert sind, ist der Bereich noch größer
- In nichttrivialem C/C++-Code steckt UB an allen Ecken, und man kann das kaum allein auf Unachtsamkeit einzelner Programmierer zurückführen
Zugriff auf nicht ausgerichtete Objekte
- Eine Funktion, die wie folgt
int*dereferenziert, führt zu UB, wenn der Zeiger nicht korrekt ausgerichtet istint foo(const int* p) { return *p; } - Ausrichtung (Alignment) kann meist eine Adresse bedeuten, die ein Vielfaches von
sizeof(int)ist, die tatsächlichen Anforderungen können sich aber je nach Plattform und Implementierung unterscheiden - Unter Linux Alpha konnte der Kernel in manchen Fällen einen Trap abfangen und den beabsichtigten Zugriff in Software nachbilden, in anderen Fällen konnte das Programm aber mit
SIGBUSabstürzen - Auf SPARC tritt
SIGBUSauf, während es auf x86/amd64 meist problemlos funktioniert oder sogar wie ein atomarer Lesezugriff erscheinen kann - Für ARM, RISC-V und künftige Architekturen lässt sich das Ergebnis nicht verallgemeinern; zukünftige Architekturen könnten sogar spezielle Register haben, die niederwertige Bits eines
int*nicht verwenden - Wenn der Compiler andere Load-Instruktionen verwendet, kann es sein, dass ein Zugriff, den der Kernel zuvor noch korrigiert hat, plötzlich nicht mehr korrigiert wird
- Der Compiler ist nicht verpflichtet, Assembler zu erzeugen, der auch mit nicht ausgerichteten Zeigern funktioniert; schon der Zugriff selbst ist UB
Auch atomare Typen sind bei falscher Ausrichtung bereits UB
- Selbst wenn man wie folgt
store()oderload()auf einemstd::atomic<int>*aufruft, ist das Verhalten UB, wenn das Objekt nicht korrekt ausgerichtet istvoid set_it(std::atomic<int>* p) { p->store(123); } int get_it(std::atomic<int>* p) { return p->load(); } - Die Frage „Ist diese Operation auch auf einem nicht ausgerichteten Objekt atomar?“ ergibt aus Sicht des Standards keinen Sinn
- Auf echter Hardware kann Atomarität zwar ein Problem sein, nach dem Standard ist der Code aber schon vorher UB
- Wenn ein Objekt, von dem man atomar lesen wollte, sich über eine Seite erstreckt, wird das Problem noch komplizierter, aber das Fazit ist nicht „geht schon“, sondern UB
Schon das Erzeugen eines Zeigers kann problematisch sein
- Ein nicht ausgerichteter Zeiger kann schon vor dem Dereferenzieren problematisch werden, allein dadurch, dass er in einen Zeiger eines bestimmten Typs gecastet wird
bool parse_packet(const uint8_t* bytes) { const int* magic_intp = (const int*)bytes; // UB! int magic_raw = foo(magic_intp); // Probably crashes on SPARC. int magic = ntohl(magic_raw); // this is fine, at least. […] } - Das Problem liegt hier nicht im Aufruf von
foo(), sondern im Cast(const int*)bytes - Nach dem Standard wäre es auch möglich, dass der Compiler niederwertigen Bits eines
int*eine Bedeutung wie Garbage-Collection- oder Security-Tag-Bits gibt
Das Problem beim Übergeben von char an isxdigit()
- Der folgende Code sieht einfach aus, kann aber auf Architekturen mit signed
charzu UB führen, wenn der Eingabewert außerhalb des Bereichs 0–127 liegtbool bar(char ch) { return isxdigit(ch); } isxdigit()ist eine Funktion zur Prüfung auf Hexadezimalzeichen und kann auchEOFals Argument annehmen- Laut C23 7.4p1 ist
EOFeinint, woraus sich schließen lässt, dass es ein Wert ist, der sich nicht alsunsigned chardarstellen lässt isxdigit()erwartet keinchar, sondern einint; die Umwandlung voncharnachintist zwar möglich, aber negative Werte eines signedcharsind problematisch- Laut C23 6.2.5 Absatz 20 ist implementierungsdefiniert, ob
charsigned ist - Eine wie folgt implementierte
isxdigit()-Funktion könnte mit einem negativen Index unbekannten Speicher lesenint isxdigit(int c) { if (c == EOF) { return false; } return some_array[c]; } - Wenn dieser Speicher in einen I/O-gemappten Bereich fällt, kann das über beliebige Werte oder Abstürze hinaus sogar Hardware-Aktionen auslösen
- Das ist in Embedded-Systemen wahrscheinlicher als in Anwendungen auf Desktop-Betriebssystemen, aber auch im Userspace gibt es Fälle wie Userspace-Netzwerktreiber, in denen der Schutz nicht ausreicht
Das Problem beim Cast von float nach int
- Code, der wie folgt einen
float-Wert in Sekunden in einenint-Wert in Millisekunden umwandelt, ist verbreitet, enthält aber UBint milliseconds(float seconds) { int tmp = (int)(seconds * 1000.0); /* WRONG */ return tmp + 1; /* WRONG separately (signed overflow is UB) */ } - C23 6.3.1.4 legt fest, dass beim Konvertieren eines endlichen reellen Fließkommawerts in einen Integer-Typ das Verhalten undefiniert ist, wenn sich der ganzzahlige Teil nicht in diesem Integer-Typ darstellen lässt
- Für nicht endliche Werte gibt es ebenfalls keine Festlegung, also entsteht auch dort UB
- Auch der Vergleich eines
floatmitINT_MAXist nicht trivial- Wenn man
floatnachintcastet, kann genau das UB entstehen, das man vermeiden wollte - Wenn man
INT_MAXnachfloatcastet, weiß man nicht, ob der Wert exakt dargestellt wird - Wenn
INT_MAXalsfloatgerundet wird und dadurch zu einem Wert wird, der sich nicht alsintdarstellen lässt, ist der Vergleich nicht mehr repräsentativ
- Wenn man
- Um den Code sicher zu machen, braucht man eine Prüfung mit
isfinite(), Vergleiche mit Sicherheitsabstand wieINT_MIN + 1000undINT_MAX - 1000sowie zusätzliche Prüfungen nach der Konvertierung und vor der Additionint milliseconds(float seconds) { const float ftmp = seconds * 1000.0f; if (!isfinite(ftmp)) { return 0; } if ((float)(INT_MIN + 1000) > ftmp) { return 0; } if ((float)(INT_MAX - 1000) < ftmp) { return 0; } const int tmp = (int)ftmp; if (INT_MAX == tmp) { return 0; } return tmp + 1; } - Man will eigentlich nur
floatinintumwandeln, aber sicherer Code wird deutlich länger
Objekte an Adresse 0 und der Nullzeiger
- In Betriebssystem-Kernen oder Embedded-Code kann es vorkommen, dass man ein Objekt an Adresse 0 platzieren möchte
- Praktisch gibt es nach dem C-Standard keinen gangbaren Weg, tatsächlich ein Objekt an Adresse 0 zu platzieren
- In C 6.3.2.3 sind die ganzzahlige Konstante 0, die in einen Zeiger konvertiert werden kann, und
nullptr„null pointer constants“; hier kann man sieNULLnennen - C legt nicht fest, dass ein echtes
NULL-Pointer auf die Maschinenadresse 0 zeigt - Der C-Standard beschreibt keine Hardware, sondern die abstrakte C-Maschine, und garantiert beim Vergleich von
NULLund 0 nur, dass sie gleich sind - Diese Gleichheit kann daher kommen, dass die Ganzzahl 0 in den nativen
NULL-Wert der Plattform umgewandelt wird, und dieser Wert könnte auch0xffffsein - Das Dereferenzieren eines Nullzeigers ist unabhängig vom konkreten Wert UB und ist in C 3.4.3 ein typisches Beispiel
- Deshalb darf man nicht annehmen, dass
memset(&ptr, 0, sizeof(ptr));einenNULL-Zeiger erzeugt - Auch die Annahme, dass ein mit Nullen initialisiertes Struct automatisch
NULLin seinen Zeiger-Membern enthält, ist für die meisten Programmierer ein reales Problem - Historisch gab es auch Maschinen mit von 0 verschiedenen NULL-Zeigern
Das Problem der Annahme, an Adresse 0 liege eine Funktion
- Selbst wenn auf modernen Maschinen
NULLauf Adresse 0 zeigt und dort tatsächlich ein Objekt oder eine Funktion liegt, sagt C 6.3.2.3, dassNULLmit keinem Objekt und keiner Funktion gleich ist - Deshalb ist der folgende Code UB
void (*func_ptr)() = NULL; func_ptr(); - Aus Sicht von C bedeutet das: „Dort gibt es keine Funktion“, und es gibt möglicherweise keine Möglichkeit, diese Absicht im Compiler intern auszudrücken
- Man kann nicht einfach annehmen, dass er eine
call-Instruktion an eine Adresse ausgibt, deren Bits alle 0 sind - Auf 16-Bit-x86 ist nicht einmal klar, ob „alle 0“
0000:0000oderCS:0000bedeutet
Variadische Argumente und Typinkonsistenzen
- Das letzte Argument von
execl()muss ein Zeiger sein; übergibt man daher das MakroNULLoder die Ganzzahl 0 direkt, kann das UB seinexecl("/bin/sh", "sh", "-c", "date", NULL); /* WRONG */ execl("/bin/sh", "sh", "-c", "date", 0); /* WRONG */ - Korrekt ist eine explizite Umwandlung in einen Zeigertyp
execl("/bin/sh", "sh", "-c", "date", (char*)NULL); - Das Makro
NULLkann als Ganzzahl 0 interpretiert werden, und bei variadischen Argumenten wird die nötige Typinformation nicht mitgeliefert - Auch bei
printf()ist ein Typfehler zwischen Format-String und tatsächlichem Argument UBuint64_t blah = 123; printf("%ld\n", blah); /* WRONG */ - Für die Ausgabe von
uint64_tsollte manPRIu64verwendenuint64_t blah = 123; printf("%"PRIu64"\n", blah); - Um
uid_tauszugeben, kann es ein Ansatz sein, nachuintmax_tzu casten undPRIuMAXzu verwenden, aber nicht einmal obuid_tunsigned ist, ist sicher - Im schlimmsten Fall wird statt
-1nur ein bedeutungsloser Wert ausgegeben
Division durch 0 und Sicherheitsprobleme
- Dass Division durch 0 UB ist, ist allgemein bekannt, wird aber zum Sicherheitsproblem, wenn der Nenner aus nicht vertrauenswürdiger Eingabe stammt
- Wichtig ist, dass UB hier nicht nur ein einfacher Laufzeitfehler ist, sondern an einer Grenze der Eingabevalidierung auftreten kann
Kein UB, aber Integer-Promotion ist trotzdem gefährlich
- Die Regeln der Integer-Promotion lassen sich beim schnellen Lesen von Code kaum zuverlässig anwenden und können zu Ergebnissen führen, die der Intuition widersprechen
- Im folgenden Code wird
overflowednicht 1, sondern 0unsigned char a = 0xff; unsigned char b = 1; unsigned char zero = 0; bool overflowed = (a + b) == zero; // overflowed is set to zero, not one. - Im nächsten Beispiel sehen alle Variablen wie unsigned aus, doch das Ergebnis ist nicht
2147483648 (0x80000000), sondern18446744071562067968 (ffffffff80000000)unsigned char a = 0x80; uint64_t b = a << 24; // Bonus UB(?) - Auch wenn es kein UB ist, sind die Integer-Regeln in C/C++ nicht intuitiv und erzeugen leicht Fehler
UB-Erkennung mit LLMs
- Moderne LLMs finden fast immer Probleme, wenn man sie bittet, in beliebigem C-Code UB zu suchen, und liegen dabei meist richtig
- Nachdem auf diese Weise in privatem Code UB gefunden wurde, wurde derselbe Ansatz auch auf ausgereiften und streng geschriebenen OpenBSD-Code angewandt
- Schon beim zuerst gewählten Tool
findwurden mehrere Probleme entdeckt - An OpenBSD wurden Patches für Schreibzugriffe außerhalb des Bereichs und für einen Logikfehler, der kein UB war, geschickt
- Für viele weitere verbliebene UB-Fälle wurden keine Patches eingereicht
- Es gab frühere Erfahrungen damit, dass das OpenBSD-Projekt Bug-Reports nicht besonders offen aufnahm
- Teilweise bestand die Einschätzung, dass die Fälle in der Praxis vielleicht unproblematisch seien
- Um UB aus der OpenBSD-Codebasis zu entfernen, bräuchte es ein größeres Projekt als nur das Weiterreichen einzelner Patches zwischen LLM und Projekt
Ein realistischer Umgang mit C/C++-Codebasen
- Bestehende C/C++-Codebasen kann man nicht wegwerfen, aber sie in einem grundsätzlich kaputten Zustand zu belassen ist ebenfalls keine Option
- UB muss im großen Stil behoben werden, ohne KI-erzeugte schlechte Änderungen zu committen und ohne menschliche Reviewer zu überfordern
- Im Jahr 2026 könnte es als ähnlich unverantwortlich wie ein Verstoß gegen SOX gelten, C oder C++ ohne UB-Aufsicht durch LLMs zu schreiben
- Wenn selbst OpenBSD-Entwickler in über 30 Jahren nicht alle diese Probleme gefunden haben, sind die Chancen in anderen Projekten noch geringer
- In privaten Projekten kann man ein LLM UB suchen, bei Bedarf erklären und beheben lassen, danach prüft ein Mensch das Ergebnis
- Zur Verifikation der Resultate braucht man allerdings Experten, und Experten sind meist mit anderen Aufgaben beschäftigt
- Diese Arbeit wirkt wie Aufräumarbeit, ist aber zu subtil, um sie den Junior-Programmierern zu überlassen, die solche Aufgaben traditionell übernehmen
1 Kommentare
Hacker-News-Kommentare
In C gibt es erstaunlich viele seltsame Undefined Behaviors, aber dieser Artikel zeigt das nicht besonders gut und kratzt nur an der Oberfläche.
Ein noch seltsameres Beispiel ist
volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);. Wennxnur einintist, ist das okay, aber mitvolatilewird es zu Undefined Behavior. Nach dem C-Standard ist einvolatile-Zugriff schon beim Lesen ein Seiteneffekt, ungeordnete Seiteneffekte auf dasselbe skalare Objekt sind Undefined Behavior, und die Auswertung von Funktionsargumenten ist in ihrer Reihenfolge nicht festgelegt.Üblicherweise meint Data Race, dass verschiedene Threads gleichzeitig auf dasselbe Objekt zugreifen und mindestens einer davon schreibt, aber in C kann es auch in einem einzelnen Thread ohne Schreibzugriff zu einer data-race-ähnlichen Situation kommen.
undefinedvorkommt, oder alle durch Auslassung undefinierten Fälle zu erfassen.Der Punkt ist, dass es unvermeidlich ist. Zumindest seit C 1972 erschienen ist, hat es kein Mensch geschafft, das vollständig zu vermeiden.
Wenn es in 54 Jahren nicht gelungen ist, dann ist „streng dich mehr an“ oder „mach keine Fehler“ keine Lösung. Ein von Mythos in OpenBSD gefundener ausnutzbarer Fehler wurde von den OpenBSD-Entwicklern recht positiv bewertet, aber schon beim einfachsten Code tauchte beim Einsatz von Tools massenhaft Undefined Behavior auf.
Zum Beispiel ist es auch Undefined Behavior, wenn
findnachwaitpid(&status)die nicht initialisierte automatische Variablestatusliest, bevor es prüft, obwaitpid()einen Fehler gemeldet hat. Eine Architektur oder einen Compiler, bei denen das ausnutzbar wäre, kann ich mir allerdings schwer vorstellen.Wie ich im Artikel geschrieben habe, geht es nicht darum, alles Undefined Behavior der Welt aufzuzählen, sondern darum, dass jeder nichttriviale C/C++-Code Undefined Behavior enthält.
volatileist ein Hack des Typsystems. Man hätte das prinzipieller lösen sollen, und moderne Sprachen sollten das nicht nachahmen, als wäre „C hat es so gemacht“ automatisch eine gute Idee.Frühe C-Compiler schrieben Werte immer in den Speicher zurück. Zeigte also ein Pointer auf Memory-Mapped-I/O-Hardware, dann führte jede Änderung an
xzu einem echten Schreibzugriff im Speicher, und Treibercode funktionierte.Mit Optimierungen sah der Compiler aber nur, dass
xweiter verändert wird, und behielt es im Register; der Treiber ging kaputt.volatilein C ist ein Hack, mit dem man dem Compiler sagt: „Diese Optimierung bitte nicht.“ Die eigentlich richtige Lösung, nämlich Intrinsic-Funktionen für Memory-Mapped I/O auf Bibliotheksebene bereitzustellen, wäre viel aufwendiger gewesen.Solche Intrinsics sind nötig, weil sie präzise ausdrücken können, welche Operationen möglich und unmöglich sind. Auf manchen Zielen sind 1-, 2- und 4-Byte-Schreibzugriffe jeweils unterschiedliche Operationen, und die Hardware unterscheidet das auch. Manche Geräte erwarten einen 4-Byte-RGBA-Schreibzugriff; sendet man stattdessen vier 1-Byte-Schreibzugriffe, kann das verwirrend sein oder gar nicht funktionieren. Manche Ziele unterstützen sogar bitweise Schreibzugriffe. Mit
volatileallein lässt sich nicht erkennen, was passiert oder was es bedeutet.Wenn man ein C-Programm kompiliert und anschließend disassembliert, erhält man ein Assemblerprogramm ohne Undefined Behavior, denn in Assembler gibt es dieses Konzept nicht.
Undefined Behavior ist eine Eigenschaft des Quellprogramms, nicht der Binärdatei. Es bedeutet, dass die Sprachspezifikation, in der der Quelltext geschrieben wurde, dem Programm keine Bedeutung zuweist. Der kompilierten Binärdatei weist dagegen die Maschinenspezifikation eine Bedeutung zu.
Eine Race Condition ist eine Eigenschaft des Programmverhaltens. Deshalb kann man sagen, dass ein C-Programm Undefined Behavior enthält, aber nicht, dass in der Binärdatei zwingend tatsächlich ein Race entsteht. Natürlich kann ein Compiler ein Programm mit Undefined Behavior beliebig übersetzen und dabei ein Race einführen, aber wenn er es ohne neue Threads kompiliert, gibt es kein Race.
volatileist doch gerade, dass der Wert durch etwas anderes verändert werden kann. Bei einer globalen Variable kann dieses „andere“ nicht nur ein anderer Thread sein, sondern auch ein Interrupt oder Signal-Handler. Bei einem Pointer, der eine bestimmte Adresse liest, kann es ein Hardware-Register sein, dessen Wert sich ändert.Das Konzept einer
volatile-Variablen an sich ist nicht das Problem. Wenn eine Sprache Interrupt-Routinen und Memory-Mapped I/O unterstützen soll, braucht der Compiler eine Möglichkeit zu verstehen, dass zweimal derselbe Hardware-Registerwert gelesen wird etwas anderes ist als zweimal dieselbe Speicherstelle zu lesen.Das eigentliche Problem ist, dass das Zusammenspiel von Sprachfeatures und Einschränkungen nicht ausreichend sauber geregelt wurde. Wenn man explizit sagt: „Dieser Wert kann sich jederzeit ändern“, ist es albern, gerade deshalb gewisse Verwendungen als Undefined Behavior zu behandeln. Für
volatile-Variablen hätte es eine Ausnahme in der Definition „ungeordneter Seiteneffekte“ geben müssen.Viele Leute glauben fälschlich, C und C++ seien „wirklich flexibel, weil man damit tun kann, was man will“. In Wirklichkeit ist fast jede kraftvoll und cool wirkende Technik ein Minenfeld aus Undefined Behavior.
Undefined Behavior bei unaligned pointern ist noch schlimmer. Ein unaligned Pointer ist nicht erst beim Zugriff Undefined Behavior, sondern bereits als Pointer selbst.
Deshalb ist schon das implizite Casten von
void* vzuint* i, etwa in C miti=voder beif(v)für eine Funktion, dieint*erwartet, Undefined Behavior, wenn der resultierende Pointer die Alignment-Anforderung vonintnicht erfüllt.Wichtig ist, dass das ein Problem auf C-Ebene ist. Wenn ein C-Programm Undefined Behavior enthält, ist es formal kein gültiges Programm, sondern fehlerhaft. Das ist kein Hardwareproblem und hat nichts mit Abstürzen oder Defekten zu tun.
Das Cast von
void*nachint*erzeugt im Hardwarecode normalerweise gar nichts; Typen existieren nur in C, also kann die Hardware bei diesem Cast auch nicht abstürzen. Man könnte denken, solange es als Ganzzahl im Register liegt, sei alles in Ordnung, aber der Punkt ist nicht, ob ein Pointer in Hardware „wirklich“ eine Ganzzahl ist, sondern dass das C-Programm in dem Moment definitionsgemäß kaputt ist, in dem auf einen unaligned Pointer gecastet wird.Ich wollte auch vermitteln, dass Undefined Behavior nicht in der Hardware steckt und nichts mit Abstürzen oder Defekten zu tun hat. Gleichzeitig wollte ich Leuten, die sagen „aber wenn man es anschaut, funktioniert es doch“, Beispiele geben, dass dem eben nicht so ist.
#pragma pack(push, 1)bei Structs Member-Pointer nicht verwenden kann, außer wenn die Member zufällig korrekt aligned sind?Diese Art von Undefined Behavior ist okay, und kaum jemand hält es für ein großes Problem, dass durch Hardwareunterschiede Bugs entstehen.
Mit der Zeit haben aggressive Auslegungen C aber in eine implizite Sprache des Design by Contract verwandelt, nur dass die Constraints unsichtbar sind. Das erzeugt ein ähnliches Problem wie implizite Destruktoraufrufe bei RAII, die man nicht sieht.
Wenn man in C einen Pointer dereferenziert, fügt der Compiler der Funktionssignatur implizit eine Nicht-Null-Bedingung hinzu. Gibt man der Funktion einen Pointer, der null sein könnte, bekommt man keinen Fehler wegen fehlender Prüfung oder Assertion; stattdessen propagiert der Compiler diese Nicht-Null-Annahme still weiter. Kann er beweisen, dass sie falsch ist, markiert er die Funktion als unerreichbar, und Aufrufe unerreichbarer Funktionen machen dann auch die aufrufende Funktion unerreichbar.
Die 5 Phasen, in denen man Undefined Behavior in C kennenlernt:
Leugnung: „Ich weiß, wie sich signed overflow auf meiner Maschine verhält.“
Wut: „Dieser Compiler ist Schrott! Warum macht er nicht, was ich gesagt habe?“
Verhandeln: „Ich werde wg14 diesen Vorschlag schicken, um C zu reparieren …“
Depression: „Kann man irgendeinem C-Code überhaupt trauen?“
Akzeptanz: „Verwende einfach kein Undefined Behavior.“
Unaligned Access lässt sich mit gepackten Structs lösen. Der Compiler erzeugt dann auf magische Weise den richtigen Code. Eigentlich konnte er das schon immer, er hat es nur nicht getan.
Die Strict-Aliasing-Regeln umgeht man mit Union-Typ-Punning. Jeder relevante Compiler dokumentiert, dass das funktioniert, auch wenn der Standard es nicht sagt. Oder man schaltet es mit
-fno-strict-aliasingeinfach ab. Man kann Speicher dann so reinterpretieren, wie man möchte; es gibt zwar scharfe Kanten, aber wenigstens kommen sie nicht vom Compiler.Overflow kann man mit
-fwrapvdefinieren. Ersetzt man+,-und*durch__builtin_*_overflow, bekommt man explizite Fehlerprüfungen gratis dazu. Das funktionale Interface ist gut und es erzeugt effizienten Code.Die eigentliche Akzeptanz ist eher: „Normale Menschen interessieren sich nicht für den C-Standard.“ Der Standard ist miserabel, wichtig ist der Compiler. Compiler haben viele sehr nützliche Features, mit denen sich die meisten dieser Probleme umgehen lassen. Dass Leute sie nicht verwenden, liegt daran, dass sie „portables“, „standardkonformes“ C schreiben wollen. Sich von dieser Denkweise zu lösen, ist die echte Akzeptanz.
Mit dieser Logik habe ich in freistehendem C einen Lisp-Interpreter gebaut und er lief sogar durch UBSan. Ich dachte zuerst, das würde explodieren, aber tat es nicht; wenn ich das kann, kann es jeder.
Solange Menschen Code schreiben, kann das kein Endzustand sein. Kein Mensch kann Undefined Behavior in C/C++ vollständig vermeiden.
Die Beispiele sind nicht wirklich konkretes Undefined Behavior, sondern eher Fälle, die je nach Eingabe oder Umständen zu Undefined Behavior werden können.
Wenn man es so weit fasst, wäre jeder Funktionsaufruf Undefined Behavior, weil man den Stack überlaufen lassen kann. In gewissem Sinne gilt das eigentlich in jeder Sprache.
C hat genug wirklich bemerkenswerte raue Stellen; so eine Art Sensationalismus kann gerade Anfängern die Aufmerksamkeit verstellen und am Ende eher schaden.
STORAGE_ERRORdefiniert.http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
Dort steht, dass diese Ausnahme auch ausgelöst wird, „wenn während der Ausführung eines Unterprogrammaufrufs nicht genügend Speicherplatz vorhanden ist“.
Zunächst einmal kann man definieren, was bei erschöpftem Stack-Speicher passiert. Außerdem brauchen nicht alle Programme einen Stack beliebiger Größe; manche brauchen nur eine im Voraus berechenbare konstante Größe. Manche Sprachimplementierungen verwenden überhaupt keinen Stack.
Eine Sprache kann Werkzeuge bereitstellen, um den verbleibenden Stack-Speicher zu prüfen und daran Garantien zu knüpfen. Oder sie kann erlauben, einen Handler zu installieren, der ausgeführt wird, wenn der Stack leerläuft.
Die richtige Denkweise ist: In dem Moment, in dem Undefined Behavior auftritt, steht man nicht mehr unter dem Schutz des Sprachstandards. Es kann noch eine Weile oder sogar für immer gut gehen. Tatsächlich liefert man sich damit aber stillschweigend den Launen von Toolchain, Compilerwechsel oder -Upgrade, Architektur, Runtime und libc-Versionen aus.
Am Ende baut man auf Sand, und genau das ist die Gefahr von Undefined Behavior.
Das Problem bei Undefined Behavior ist nicht, dass es auf irgendeiner Architektur abstürzen könnte.
Das eigentliche Problem ist, dass der Compiler erwartet, dass solcher Code niemals vorkommt. Verwendet man trotzdem Code mit Undefined Behavior, kann der Compiler – besonders der Optimierer – ihn in jede Form übersetzen, die ihm für den normalen Pfad passend erscheint. Dieses „irgendetwas“ kann manchmal sehr unerwartet sein, etwa das Entfernen großer Codeteile.
Mit Tail-Recursion obendrauf könnte der Bug in Debug-Builds unsichtbar bleiben und erst bei höherem Optimierungslevel auftauchen.
Schlimmer ist, wenn das Programm still mit Müllwerten weiterläuft, die Festplatte formatiert oder einem Angreifer die Schlüssel zum Königreich übergibt.
Wer sagt, man solle es einfach definieren oder zu unspecified behavior machen, übersieht den Kernpunkt: dass der Compiler dadurch große Teile eines Programms eliminieren kann.
Wenn man Code schreibt, der für bestimmte Eingaben Undefined Behavior ist, sagt man damit, dass das Programm für diese Eingaben absichtlich überhaupt kein Verhalten haben soll. Man möchte, dass der Compiler diesen Pfad wegoptimiert oder irgendetwas tut, das dem definierten Verhalten in anderen Fällen hilft.
Es ist ziemlich befriedigend, einen Log-String einzubauen, der nur über Undefined Behavior erreichbar wäre, und dann zu sehen, dass dieser String in der Binärdatei nicht mehr vorhanden ist.
Ich habe früher einmal einen Analyse-Pass geschrieben, der unter der Annahme lief, ganz am Ende der Transformation-Pipeline ausgeführt zu werden, und diese Annahme war für die Korrektheit nötig. Ich hielt das für sicher, weil danach keine Optimierungen mehr stattfinden sollten, aber inzwischen bin ich mir da nicht mehr sicher.
Ich benutze C seit 20 Jahren, aber in den letzten sechs Monaten auf Hacker News habe ich mehr über Undefined Behavior gehört als je zuvor.
In echten Gesprächen kam das fast nie vor. Man schreibt Code, und wenn etwas nicht funktioniert, debuggt man es und behebt oder umgeht es. Ich verstehe nicht, warum das Thema Undefined Behavior in C so konstant auf die Startseite kommt.
Es gibt dauerhaft eine Minderheit von Informatikern, die die Entwicklung oder Nutzung neuer Programmiersprachen für das Interessanteste der Welt hält, und einige davon behalten diese Sicht auch.
Dass solche Leute sich für Sprachdesign interessieren, ist natürlich, und Undefined Behavior in C gehört in diesen Bereich. Allerdings ging es ursprünglich oft darum, alte CPU-Architekturen ohne Performanceverlust zu unterstützen, und insofern ist das nur bedingt eine „Designentscheidung“ – ungefähr so, wie runde Räder eine Designentscheidung sind.
Um GCC 3.2 herum gab es einige ziemlich bekannte „Skandale“, weil Compiler Undefined Behavior in Optimierungen viel aggressiver ausnutzten, und deshalb blieben viele Leute lange bei GCC 2.95. GCC 3.2 erschien 2002.
Weil jedes Unternehmen ständig Sicherheit und Exposure betont – also auch, in die Nachrichten zu kommen –, ist das Gegennarrativ des „Unsicheren“ übermäßig groß geworden.
Die neue Welt ist ein bisschen so, als würden Stadtmenschen, die nie rohe Natur gesehen haben, einen Rasenmäher sehen und erschrocken zurückweichen. Da drehen sich Klingen? Das kann doch nicht sein!
Wenn das eigentliche Ziel ein kleines Embedded-System auf einem abgelegenen Funkturm ist, dann ist „läuft auf meiner Maschine“ nutzlos. Natürlich machen die meisten so etwas nicht, und die Mehrheit der Entwickler hier sind wahrscheinlich Webentwickler, aber selbst ohne direkte Erfahrung ist das eine interessante Diskussion. Vielleicht gerade dann.
Compiler können Bugs haben, bei denen etwas laut Spezifikation funktionieren müsste, es aber nicht tut; es gibt viele Erweiterungen ohne Entsprechung im Standard; und es gibt auch Verhalten, das laut Standard undefiniert ist, dem eine Implementierung aber dennoch ein sinnvolles Ergebnis zuweist.
Dem Einstieg stimme ich größtenteils zu, aber die Beispiele sind schlecht, und der ganze Artikel wirkt wie eine Verpackung, um LLM-Coding zu pushen.
Das wirkt wie jemand, der beliebigen Code schreiben und trotzdem will, dass er in allen Umgebungen gleich funktioniert. In so einer Sprache ginge aber der Vorteil verloren, dass man bei Bedarf plattformspezifisch schreiben kann.
Der C++-Code im Artikel ist teilweise seit über zehn Jahren nicht mehr idiomatisch und würde heute als Code Smell gelten.
Die Sprache hat sich zu etwas recht anderem entwickelt als bei ihrer Entstehung. In dem Moment, in dem überall rohe Pointer und direkter Pointer-Zugriff auftauchten, war klar, dass man Teile des Artikels mit Vorsicht lesen muss.
Ein weiteres offensichtliches Problem ist die Sichtweise, C und C++ fast wie dieselbe Sprache in einen Topf zu werfen. Heute haben sich die beiden tatsächlich ziemlich weit voneinander entfernt.
std::atomicstattatomic_intsteht.Ist folgende Sicht auf Undefined Behavior in C korrekt?
Ein Programm P hat eine Eingabemenge A, die kein Undefined Behavior auslöst, und eine komplementäre Menge B, die es auslöst.
Ein korrekter Compiler kompiliert P zu einer Binärdatei P'. Für alle Eingaben aus A muss sich P' genauso verhalten wie P.
Für Eingaben aus B gibt es an das Verhalten von P' jedoch überhaupt keine Anforderungen.
Ein konkretes Beispiel für Undefined Behavior durch unaligned Pointer: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...