1 Punkte von GN⁺ 4 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • 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* oder std::atomic<int>* können je nach Plattform zu SIGBUS, 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 char an isxdigit(), das Umwandeln von float nach int oder falsche Nutzung von NULL und 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 ist
    int 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 SIGBUS abstürzen
  • Auf SPARC tritt SIGBUS auf, 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() oder load() auf einem std::atomic<int>* aufruft, ist das Verhalten UB, wenn das Objekt nicht korrekt ausgerichtet ist
    void 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 char zu UB führen, wenn der Eingabewert außerhalb des Bereichs 0–127 liegt
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() ist eine Funktion zur Prüfung auf Hexadezimalzeichen und kann auch EOF als Argument annehmen
  • Laut C23 7.4p1 ist EOF ein int, woraus sich schließen lässt, dass es ein Wert ist, der sich nicht als unsigned char darstellen lässt
  • isxdigit() erwartet kein char, sondern ein int; die Umwandlung von char nach int ist zwar möglich, aber negative Werte eines signed char sind problematisch
  • Laut C23 6.2.5 Absatz 20 ist implementierungsdefiniert, ob char signed ist
  • Eine wie folgt implementierte isxdigit()-Funktion könnte mit einem negativen Index unbekannten Speicher lesen
    int 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 einen int-Wert in Millisekunden umwandelt, ist verbreitet, enthält aber UB
    int 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 float mit INT_MAX ist nicht trivial
    • Wenn man float nach int castet, kann genau das UB entstehen, das man vermeiden wollte
    • Wenn man INT_MAX nach float castet, weiß man nicht, ob der Wert exakt dargestellt wird
    • Wenn INT_MAX als float gerundet wird und dadurch zu einem Wert wird, der sich nicht als int darstellen lässt, ist der Vergleich nicht mehr repräsentativ
  • Um den Code sicher zu machen, braucht man eine Prüfung mit isfinite(), Vergleiche mit Sicherheitsabstand wie INT_MIN + 1000 und INT_MAX - 1000 sowie zusätzliche Prüfungen nach der Konvertierung und vor der Addition
    int 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 float in int umwandeln, 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 sie NULL nennen
  • 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 NULL und 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 auch 0xffff sein
  • 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)); einen NULL-Zeiger erzeugt
  • Auch die Annahme, dass ein mit Nullen initialisiertes Struct automatisch NULL in 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 NULL auf Adresse 0 zeigt und dort tatsächlich ein Objekt oder eine Funktion liegt, sagt C 6.3.2.3, dass NULL mit 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:0000 oder CS:0000 bedeutet

Variadische Argumente und Typinkonsistenzen

  • Das letzte Argument von execl() muss ein Zeiger sein; übergibt man daher das Makro NULL oder die Ganzzahl 0 direkt, kann das UB sein
    execl("/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 NULL kann 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 UB
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • Für die Ausgabe von uint64_t sollte man PRIu64 verwenden
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • Um uid_t auszugeben, kann es ein Ansatz sein, nach uintmax_t zu casten und PRIuMAX zu verwenden, aber nicht einmal ob uid_t unsigned ist, ist sicher
  • Im schlimmsten Fall wird statt -1 nur 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 overflowed nicht 1, sondern 0
    unsigned 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), sondern 18446744071562067968 (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 find wurden 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

Verwandte Materialien

1 Kommentare

 
GN⁺ 4 시간 전
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);. Wenn x nur ein int ist, ist das okay, aber mit volatile wird es zu Undefined Behavior. Nach dem C-Standard ist ein volatile-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.

    • Als Autor stimme ich zu. Das Ziel dieses Artikels ist nicht, alle 283 Stellen im Standard aufzuzählen, an denen das Wort undefined vorkommt, 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 find nach waitpid(&status) die nicht initialisierte automatische Variable status liest, bevor es prüft, ob waitpid() 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.
    • volatile ist 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 x zu einem echten Schreibzugriff im Speicher, und Treibercode funktionierte.
      Mit Optimierungen sah der Compiler aber nur, dass x weiter verändert wird, und behielt es im Register; der Treiber ging kaputt. volatile in 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 volatile allein lässt sich nicht erkennen, was passiert oder was es bedeutet.
    • Man muss Undefined Behavior und Race Conditions unterscheiden. Diese Unterscheidung fehlt in Diskussionen über Undefined Behavior oft.
      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.
    • Die Bedeutung von volatile ist 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.
    • Der Kern des Artikels ist, dass man nicht einmal seltsamen Code schreiben muss, um auf Undefined Behavior zu stoßen.
      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* v zu int* i, etwa in C mit i=v oder bei f(v) für eine Funktion, die int* erwartet, Undefined Behavior, wenn der resultierende Pointer die Alignment-Anforderung von int nicht 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* nach int* 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.

    • Als Autor: korrekt. Das wurde im Abschnitt „Actually, it was UB even before that“ behandelt.
      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.
    • Klingt normal und vorhersehbar. Ein guter Programmierer weiß, dass Pointer-Casts offensichtlich ein Bereich mit Gefahren sind.
    • Kannst du zeigen, wo im Standard steht, dass schon ein unaligned Pointer selbst Undefined Behavior ist?
    • Bedeutet das, dass man mit #pragma pack(push, 1) bei Structs Member-Pointer nicht verwenden kann, außer wenn die Member zufällig korrekt aligned sind?
    • Das Konzept von Undefined Behavior in C bedeutete ursprünglich, dem Compiler Freiheit zu geben, Code auf Hardware abzubilden, selbst wenn Maschinenbefehle je nach Architektur leicht unterschiedlich sind. Dasselbe C-Programm konnte also je nach Zielarchitektur unterschiedliches Verhalten ausdrücken.
      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.“

    • Wo gehört die Phase hin: „Dann bringe ich eben den Compiler dazu, das Undefinierte zu definieren“?
      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-aliasing einfach 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 -fwrapv definieren. 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.
    • Als Autor ist mein Punkt gerade, dass „Verwende einfach kein Undefined Behavior“ unmöglich ist.
      Solange Menschen Code schreiben, kann das kein Endzustand sein. Kein Mensch kann Undefined Behavior in C/C++ vollständig vermeiden.
    • „Verwende einfach kein Undefined Behavior“ klingt bestenfalls noch nach der Verhandlungsphase.
    • Man kann ja so arbeiten wie ich und auf Embedded-Geräten entwickeln. Software für eine bestimmte CPU zu schreiben, ist wirklich angenehm.
    • Akzeptanz in C ist eher: „Ich werde Undefined Behavior verwenden, und irgendwann wird etwas Schlimmes passieren.“
  • 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.

    • Ada 83 behandelt einen Überlauf des Call-Stacks nicht als Undefined Behavior. Im Referenzhandbuch ist die Ausnahme STORAGE_ERROR definiert.
      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“.
    • Das stimmt überhaupt nicht.
      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.
    • Auch eingabeabhängiges Undefined Behavior kann ein Exploit-Pfad sein.
    • Die Beispiele sind ganz klar Undefined Behavior. Ende.
      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.
    • Dieser Artikel kommt der Definition von FUD schon sehr nahe.
  • 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.

    • Ein verwandtes Beispiel ist die Bedingung, dass jede Funktion terminieren oder einen Seiteneffekt haben muss. Ich bin noch nicht selbst darüber gestolpert, aber ich kann mir gut vorstellen, versehentlich eine Endlosschleife oder Rekursion zu schreiben und dann zu erleben, dass die Funktion gelöscht wird.
      Mit Tail-Recursion obendrauf könnte der Bug in Debug-Builds unsichtbar bleiben und erst bei höherem Optimierungslevel auftauchen.
    • Ein Absturz ist unter den Formen von Undefined Behavior noch eine der harmloseren, weil man ihn wenigstens bemerkt.
      Schlimmer ist, wenn das Programm still mit Müllwerten weiterläuft, die Festplatte formatiert oder einem Angreifer die Schlüssel zum Königreich übergibt.
    • Stimmt, aber das ist auch die nützlichste Eigenschaft und der eigentliche Grund für die Existenz von Undefined Behavior.
      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.
    • Mir fiel besonders die Stelle im Artikel auf, an der gesagt wurde, dass es kein Optimierungsproblem 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.
    • Das ist kein Bug, sondern ein Feature.
  • 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.

    • Hacker News ist nach wie vor stärker an Programmiersprachen interessiert als am tatsächlichen Programmieren. Vielleicht spielt auch das Lisp-Erbe von Y Combinator eine Rolle.
      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.
    • Was soll das heißen? Ich habe schon vor 20 Jahren C und C++ benutzt, und auch damals war Undefined Behavior ein großes Thema in Gesprächen und in der Ausbildung.
      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.
    • Früher waren Computer cool, heute sind sie gefährlich geworden.
      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!
    • Die Laufzeitumgebung kann eine völlig andere Architektur sein, also sind solche Details sehr wichtig.
      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.
    • Genauer gesagt schreibt man nicht für eine eingebildete Spezifikation, sondern für das Zielsystem. Die Spezifikation ist nur hilfreich, um ungefähr vorherzusagen, was das Zielsystem tut, aber sie ist nicht normativ.
      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.

    • Ja. Die Beispiele sind entweder die üblichen Dinge, die man bei portablem Code ohnehin vermeidet, oder unnötige Sachen wie Zugriff auf ein Objekt an Adresse 0.
      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.
    • Inwiefern sind sie schlecht? Wenn das stimmt, ist es ziemlich gravierend.
  • 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.

    • Ich wollte gerade anmerken, dass der Code C und nicht C++ ist, habe dann aber noch einmal nachgesehen und festgestellt, dass dort tatsächlich std::atomic statt atomic_int steht.
  • 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.

    • Intuitiv ja. Das Programm wird kompiliert, als würden Eingaben aus B niemals auftreten; dazu kann auch gehören, Code zu entfernen, der solche Eingaben erkennen sollte.
    • Gute Zusammenfassung.
  • Ein konkretes Beispiel für Undefined Behavior durch unaligned Pointer: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • Gerade auf x86, wo man oft annimmt, dass so etwas unproblematisch sei, ist das besonders interessant.