2 Punkte von GN⁺ 2 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Wenn der Wert eines float nach dem Abschneiden des Nachkommateils außerhalb des Wertebereichs des Ziel-Ganzzahltyps liegt, tritt undefiniertes Verhalten (UB) auf; betroffen sind implizite Konvertierungen, funktionale Casts und static_cast
  • -Wall und -Wextra warnen nicht davor, und auch -Wconversion erkennt nur implizite Konvertierungen, sodass das Problem leicht übersehen wird
  • Auch die sichere Verengungskonvertierungsfunktion gsl::narrow der Microsoft GSL löst bei einigen Gleitkomma→Ganzzahl-Eingaben UB aus und hält damit das dokumentierte Verhalten nicht ein, bei nicht darstellbaren Werten eine Ausnahme zu werfen
  • x86s CVTTSS2SI behandelt nicht darstellbare Werte als INT_MIN, während AArch64s FCVTZS eine Sättigungskonvertierung vornimmt und NaN in 0 umwandelt; daher können die Ergebnisse je nach Hardware unterschiedlich ausfallen
  • Für eine sichere Konvertierung muss vor dem Cast der Wertebereich geprüft werden; mit der UBSan-Option -fsanitize=float-cast-overflow von Clang und GCC lässt sich das Problem erkennen

Konvertierungsregeln und Grenzen der Erkennung

  • Nach den C++-Regeln für Gleitkomma-Ganzzahl-Konvertierungen gilt: Passt der Wert nach dem Abschneiden des Nachkommateils nicht in den Ziel-Ganzzahltyp, ist das undefiniertes Verhalten
    • Auch wenn das Ziel unsigned ist, wird keine Modulo-Arithmetik angewendet
    • int i0 = f, int(f) und static_cast<int>(f) können bei bestimmten Eingaben alle UB auslösen
  • Mit üblichen Compiler-Warnungen lässt sich das Problem nur schwer vollständig finden
    • -Wall und -Wextra warnen bei keiner der drei Konvertierungen
    • -Wconversion warnt nur bei impliziten Konvertierungen
  • Selbst wenn das Programm auf dem aktuellen Prozessor und Compiler weiterläuft, können die Ergebnisse je nach Plattform unterschiedlich sein
    • x86s CVTTSS2SI bildet nicht darstellbare Eingaben auf INT_MIN ab
    • AArch64s FCVTZS führt eine Sättigung durch und bildet NaN auf 0 ab
    • Ausgeführtes UB kann dazu führen, dass Code plötzlich fehlerhaft läuft, wenn der Compiler eine andere Konvertierung anwendet

GSL-Beispiel und sichere Gegenmaßnahmen

  • gsl::narrow aus der Microsoft Guidelines Support Library versteht sich als sichere Verengungskonvertierung, die bei Werten, die im Zieltyp nicht darstellbar sind, eine Ausnahme wirft
    • Die tatsächliche Gleitkomma→Ganzzahl-Konvertierung führt bei einigen Eingaben jedoch zuerst UB aus und entspricht daher nicht der Dokumentation
    • Die GSL-Seite bewertete das interne UB als harmlos, weil auf den Zielplattformen keine Hardware-Trap-Repräsentationen berührt würden; diese Logik ist im Code weiterhin enthalten, und das Problem wurde nicht behoben
  • Die richtige Lösung ist, den Wertebereich vor dem Cast zu prüfen
    • Es gibt die auf Rusts Sättigungskonvertierung basierende Proof-of-Concept-Bibliothek cpp-clamp-cast
  • In Clangs und GCCs Undefined Behavior Sanitizer kann -fsanitize=float-cast-overflow verwendet werden, um dieses UB zu erkennen
    • Es wird empfohlen, sämtlichen C++-Code mit UBSan zu testen

1 Kommentare

 
GN⁺ 2 시간 전
Lobste.rs-Kommentare
  • Obwohl ich viele subtile Fälle von Undefined Behavior in C und C++ kannte, hat mich dieses Beispiel überrascht.
    Rust hatte ebenfalls eine Zeit lang Undefined Behavior geerbt, weil es dieselben Regeln für Gleitkomma→Integer-Konvertierungen in LLVM IR übernimmt, und wurde erst 2020 so geändert, dass komplexerer IR erzeugt wird. Da der Performance-Unterschied groß ist, gibt es für High-Performance-Schleifen, bei denen bekannt ist, dass der Wert endlich ist und im Bereich des Zieltyps liegt, auch eine ungeprüfte Gleitkomma→Integer-Konvertierung.
    Dass die C++ Core Guidelines Library das auf die leichte Schulter nimmt, ist absurd. LLVM hat Informationen aus der Konvertierung genutzt, um Bounds Checks zu entfernen, und dadurch gab es einen Fall eines Array-Out-of-Bounds trotz vorhandener Bounds Checks. Wenn man Memory Safety und die Vermeidung von Undefined Behavior ernst nimmt, kann man das nicht ignorieren, und es ist enttäuschend, dass Herb Sutter von „benign undefined behavior“ gesprochen hat.

  • Ich wusste, dass es in C++ viel Undefined Behavior gibt, aber das ist besonders überraschend. Ich frage mich, ob man die Konvertierung möglichst schnell machen wollte und sie deshalb als Undefined Behavior gelassen hat, weil es je nach Architektur Instruktionen gibt, die diese Extremwerte unterschiedlich behandeln. Das scheint der Hauptgrund für solche Regeln zu sein, ähnlich wie bei Signed Integer Overflow.

    • Wenn das der Grund ist, sollte es nicht Undefined Behavior, sondern implementierungsdefiniertes Verhalten sein. Dass Division durch 0 Undefined Behavior ist, ist nachvollziehbar, weil sie auf manchen Architekturen trappt.
      Undefined Behavior sollte auf Fälle beschränkt sein, in denen man selbst auf derselben Plattform wegen Effekten außerhalb der abstrakten C-Maschine, wie Use-after-free, kein konsistentes Ergebnis garantieren kann, oder auf Fälle, die auf manchen Zielen trapen können.
      Eine standardkonforme Implementierung kann Undefined Behavior für sich selbst definieren, und GCC tut das bei einigen Punkten auch. Wenn sich stabile Semantik ohne Performance-Kosten garantieren lässt, könnte man auch einfach das Verhalten der jeweiligen Gleitkomma→Integer-Instruktionen pro Ziel festlegen, aber in anderen Implementierungen wäre es weiterhin Undefined Behavior.
    • Wahrscheinlich ist das so. Die fctiw-Familie auf PowerPC macht saturierende Konvertierung, wandelt NaN in INT_MIN um und setzt außerdem FPSCR-Flags. fctid in 64-Bit Power ISA macht dasselbe für größere Integer, aber keines von beiden entspricht dem Verhalten von AArch64 oder x86.
  • In solchen Fällen wäre implementierungsdefiniertes Verhalten oder ein unspezifizierter Wert angemessener. Es ergibt keinen Sinn, dass das gesamte Programm die Kontrolle des C++-Standards verlässt, nur weil man Unendlich in int konvertiert hat.
    C++26 hat einige unsinnige Fälle von Undefined Behavior entfernt, und auch das ist ein offensichtlicher Kandidat für die Streichung. Experimentell scheint es so, als würden GCC und Clang dieses Undefined Behavior nicht für Optimierungen nutzen, daher wirkt sich das in der Praxis wohl nur begrenzt aus.

  • Das ist noch ein unerquickliches Beispiel dafür, dass es keinen Grund gibt, diese Operation in der Spezifikation als Undefined Behavior festzulegen.

  • Noch ein Grund, IEEE 754 nicht zu mögen. Wenn nicht andere Bibliotheken oder Performance es unbedingt erfordern, verwende ich nach Möglichkeit reine Integer statt Gleitkomma, rationale Zahlen mit großen Integer-Zählern und -Nennern oder Fixed-Point-Dezimalzahlen.

    • Dieser Vorfall hat nichts mit IEEE 754 zu tun und ist ausschließlich die Schuld von C++.
  • Wer C oder C++ benutzt hat, sollte vom Unterschied zwischen float32 und int32, ja sogar int64, nicht überrascht sein. float32 mit großem Exponenten kann ganzzahlige Werte darstellen, die viel größer sind als int64.
    Zwischen Typen mit unterschiedlicher Darstellungsfähigkeit, von denen keiner eine Obermenge des anderen ist, gibt es keinen Grund anzunehmen, dass eine Gleitkomma→Integer-Konvertierung sicher ist, unabhängig von der Sprachsyntax.

    • Du verfehlst den Kernpunkt. Dass nicht alle Gleitkommawerte verlustfrei erhalten werden können, ist etwas anderes als Undefined Behavior.
      Auch uint32_t kann nicht alle Werte von uint64_t darstellen, aber die Konvertierungssemantik ist dort als Abschneiden definiert. Hier ist das Wesentliche, dass der Compiler bei bestimmten Eingaben grundsätzlich alles tun darf.
    • Dass sich die Wertebereiche unterscheiden, heißt nicht, dass man keine sichere Konvertierung definieren kann. Rust definiert Gleitkomma→Integer-Konvertierungen ausdrücklich, und die Java-26-Sprachspezifikation regelt in Abschnitt 5.1.3 das Konvertierungsverfahren im Detail.
      Für eine Low-Level-Sprache wäre auch eine Abbildung auf Assembler-Instruktionen wie CVTTSS2SI oder FCVTZS möglich. Da Low-Level-Sprachen eng an Assembler liegen und auch andere „benign“ Undefined Behaviors umstritten sind, ist es eher noch überraschender, dass diese Konvertierung Undefined Behavior erlaubt.
    • Dass je nach Hardware implementationsabhängige Garbage-Werte herauskommen oder Hardware/Prüfwerkzeuge einen Trap auslösen und das Programm beenden, ist nicht besonders überraschend.
      Überraschend ist aber, dass dadurch auch erlaubt sein soll, völlig unzusammenhängenden Code seltsam fehlzukompilieren, die Festplatte zu formatieren oder „Dämonen aus der Nase fliegen zu lassen“.
      Bei Double Free oder Schreiben außerhalb der Array-Grenzen ist das Konzept von Undefined Behavior, bei dem alles passieren kann, plausibel, aber C und C++ verwenden es übermäßig auch dort, wo strengere Regeln wie implementierungsdefinierte Garbage-Werte oder Programmabbruch möglich wären. Rust trappt bei manchen Integer-Overflows im Debug-Modus und gibt im Release-Modus einen unspezifizierten Wert zurück, lässt aber nicht zu, dass dadurch auch noch fremder Code kaputtgeht.
      C++ muss nicht zu Java oder Rust werden, aber die Sprache würde eindeutig besser, wenn man unnötiges Undefined Behavior reduziert.
    • Das war mir neu. Ich dachte, wenn man ein nicht darstellbares Gleitkomma in einen Integer konvertiert, landet höchstens ein Müllwert im Ergebnis-Integer, aber ich wusste nicht, dass dadurch das ganze Programm dauerhaft kontaminiert wird und außerhalb der Kontrolle des C++-Standards gerät.