- Wenn der Wert eines
floatnach dem Abschneiden des Nachkommateils außerhalb des Wertebereichs des Ziel-Ganzzahltyps liegt, tritt undefiniertes Verhalten (UB) auf; betroffen sind implizite Konvertierungen, funktionale Casts undstatic_cast -Wallund-Wextrawarnen nicht davor, und auch-Wconversionerkennt nur implizite Konvertierungen, sodass das Problem leicht übersehen wird- Auch die sichere Verengungskonvertierungsfunktion
gsl::narrowder 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
CVTTSS2SIbehandelt nicht darstellbare Werte alsINT_MIN, während AArch64sFCVTZSeine 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-overflowvon 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)undstatic_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
-Wallund-Wextrawarnen bei keiner der drei Konvertierungen-Wconversionwarnt 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
CVTTSS2SIbildet nicht darstellbare Eingaben aufINT_MINab - AArch64s
FCVTZSfü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
- x86s
GSL-Beispiel und sichere Gegenmaßnahmen
gsl::narrowaus 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-overflowverwendet werden, um dieses UB zu erkennen- Es wird empfohlen, sämtlichen C++-Code mit UBSan zu testen
1 Kommentare
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.
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.
fctiw-Familie auf PowerPC macht saturierende Konvertierung, wandelt NaN inINT_MINum und setzt außerdem FPSCR-Flags.fctidin 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
intkonvertiert 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.
Wer C oder C++ benutzt hat, sollte vom Unterschied zwischen
float32undint32, ja sogarint64, nicht überrascht sein.float32mit großem Exponenten kann ganzzahlige Werte darstellen, die viel größer sind alsint64.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.
Auch
uint32_tkann nicht alle Werte vonuint64_tdarstellen, aber die Konvertierungssemantik ist dort als Abschneiden definiert. Hier ist das Wesentliche, dass der Compiler bei bestimmten Eingaben grundsätzlich alles tun darf.Für eine Low-Level-Sprache wäre auch eine Abbildung auf Assembler-Instruktionen wie
CVTTSS2SIoderFCVTZSmö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.Ü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.