Valgrind hat einen Aufruhr aufgefangen Bedingter Sprung oder Bewegung hängt von nicht initialisierten Werten ab in einem meiner Unit-Tests.
Als ich die Baugruppe inspizierte, stellte ich fest, dass der folgende Code:
bool operator==(MyType const& left, MyType const& right) {
// ... some code ...
if (left.getA() != right.getA()) { return false; }
// ... some code ...
return true;
}
Dabei hat MyType::getA() const -> std::optional<std::uint8_t>
die folgende Assembly generiert:
0x00000000004d9588 <+108>: xor eax,eax
0x00000000004d958a <+110>: cmp BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>: je 0x4d9597 <... function... +123>
x 0x00000000004d9591 <+117>: mov r15b,BYTE PTR [r14+0x1c]
x 0x00000000004d9595 <+121>: mov al,0x1
0x00000000004d9597 <+123>: xor edx,edx
0x00000000004d9599 <+125>: cmp BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>: je 0x4d95ae <... function... +146>
x 0x00000000004d95a0 <+132>: mov dil,BYTE PTR [r13+0x1c]
x 0x00000000004d95a4 <+136>: mov dl,0x1
x 0x00000000004d95a6 <+138>: mov BYTE PTR [rsp+0x97],dil
0x00000000004d95ae <+146>: cmp al,dl
0x00000000004d95b0 <+148>: jne 0x4da547 <... function... +4139>
0x00000000004d95b6 <+154>: cmp r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>: je 0x4d95c8 <... function... +172>
=> Jump on uninitialized
0x00000000004d95c0 <+164>: test al,al
0x00000000004d95c2 <+166>: jne 0x4da547 <... function... +4139>
Wo ich mit x
markiert habe, die Anweisungen, die nicht ausgeführt werden (übersprungen), wenn das optionale NICHT gesetzt ist.
Das Mitglied A
befindet sich hier am Versatz 0x1c
in MyType
. Beim Überprüfen des Layouts von std::optional
sehen wir Folgendes:
+0x1d
entspricht bool _M_engaged
,+0x1c
entspricht std::uint8_t _M_payload
innerhalb einer anonymen Union).Der Code von Interesse für std::optional
ist:
constexpr explicit operator bool() const noexcept
{ return this->_M_is_engaged(); }
// Comparisons between optional values.
template<typename _Tp, typename _Up>
constexpr auto operator==(const optional<_Tp>& __lhs, const optional<_Up>& __rhs) -> __optional_relop_t<decltype(declval<_Tp>() == declval<_Up>())>
{
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
&& (!__lhs || *__lhs == *__rhs);
}
Hier können wir sehen, dass gcc den Code ziemlich grundlegend verändert hat; wenn ich es richtig verstehe, ergibt sich in C:
char rsp[0x148]; // simulate the stack
/* comparisons of prior data members */
/*
0x00000000004d9588 <+108>: xor eax,eax
0x00000000004d958a <+110>: cmp BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>: je 0x4d9597 <... function... +123>
0x00000000004d9591 <+117>: mov r15b,BYTE PTR [r14+0x1c]
0x00000000004d9595 <+121>: mov al,0x1
*/
int eax = 0;
if (__lhs._M_engaged == 0) { goto b123; }
bool r15b = __lhs._M_payload;
eax = 1;
b123:
/*
0x00000000004d9597 <+123>: xor edx,edx
0x00000000004d9599 <+125>: cmp BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>: je 0x4d95ae <... function... +146>
0x00000000004d95a0 <+132>: mov dil,BYTE PTR [r13+0x1c]
0x00000000004d95a4 <+136>: mov dl,0x1
0x00000000004d95a6 <+138>: mov BYTE PTR [rsp+0x97],dil
*/
int edx = 0;
if (__rhs._M_engaged == 0) { goto b146; }
rdi = __rhs._M_payload;
edx = 1;
rsp[0x97] = rdi;
b146:
/*
0x00000000004d95ae <+146>: cmp al,dl
0x00000000004d95b0 <+148>: jne 0x4da547 <... function... +4139>
*/
if (eax != edx) { goto end; } // return false
/*
0x00000000004d95b6 <+154>: cmp r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>: je 0x4d95c8 <... function... +172>
*/
// Flagged by valgrind
if (r15b == rsp[097]) { goto b172; } // next data member
/*
0x00000000004d95c0 <+164>: test al,al
0x00000000004d95c2 <+166>: jne 0x4da547 <... function... +4139>
*/
if (eax == 1) { goto end; } // return false
b172:
/* comparison of following data members */
end:
return false;
Welches ist gleichbedeutend mit:
// Note how the operands of || are inversed.
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
&& (*__lhs == *__rhs || !__lhs);
I _ (denkedass die Assembly korrekt ist, wenn auch seltsam. Soweit ich sehen kann, beeinflusst das Ergebnis des Vergleichs zwischen nicht initialisierten Werten das Ergebnis der Funktion nicht wirklich (und anders als C oder C++, I Erwarten Sie, dass der Vergleich von Junk in der x86-Assembly NICHT UB ist.
nullopt
ist und die andere festgelegt ist, springt der bedingte Sprung bei +148
zu end
return false
), OK.Der einzige interessierende Fall ist also, wenn beide Optionen nullopt
sind:
nullopt
sind.__lhs._M_engaged
false ist, was true ist.In beiden Fällen kommt der Code daher zu dem Schluss, dass beide Optionen gleich sind, wenn beide nullopt
sind. CQFD.
Dies ist das erste Mal, dass gcc scheinbar "harmlose" nicht initialisierte Lesevorgänge generiert. Daher habe ich ein paar Fragen:
||
), die unter nicht-harmlosen Umständen ausgelöst werden könnte?Im Moment neige ich dazu, die wenigen Funktionen mit optimize(1)
zu kommentieren, um Optimierungen zu vermeiden. Glücklicherweise sind die identifizierten Funktionen nicht leistungskritisch.
Umgebung:
-std=c++17 -g -Wall -Werror -O3 -flto
(+ passende Includes)-O3 -flto
(+ entsprechende Bibliotheken)Hinweis: Kann mit -O2
anstelle von -O3
erscheinen, jedoch niemals ohne -flto
.
Wissenswertes
Im vollständigen Code erscheint dieses Muster 32 Mal in der oben beschriebenen Funktion für verschiedene Nutzdaten: std::uint8_t
, std::uint32_t
, std::uint64_t
und sogar einen struct { std::int64_t; std::int8_t; }
.
Es erscheint nur in einigen großen operator==
Vergleichstypen mit ~ 40 Datenelementen, nicht in kleineren. Und es erscheint nicht für den std::optional<std::string_view>
, auch nicht in den spezifischen Funktionen (die für den Vergleich std::char_traits
aufrufen).
Schließlich lässt das Isolieren der fraglichen Funktion in einer eigenen Binärdatei das "Problem" wütend werden. Das mythische MCVE erweist sich als schwer fassbar.
In x86 asm ist das Schlimmste, dass ein einzelnes Register einen unbekannten Wert hat (oder Sie wissen nicht, welchen von zwei möglichen Werten es gibt, alte oder neue, falls eine mögliche Speicherreihenfolge vorliegt). Aber wenn Ihr Code nicht von diesem Registerwert abhängt, sind Sie in Ordnung , im Gegensatz zu C++. C++ UB bedeutet, dass Ihr gesamtes Programm theoretisch nach einem Überlauf der Ganzzahl mit Vorzeichen vollständig abgespritzt wird. Bereits davor führt der Compiler über Codepfade, die der Compiler sehen kann, zu UB. Nichts ähnliches passiert in asm, zumindest nicht in unprivilegiertem User-Space-Code.
(Es gibt einige Möglichkeiten, um systemweit unvorhersehbares Verhalten im Kernel zu bewirken, indem Sie Steuerregister auf seltsame Weise setzen oder inkonsistente Dinge in Seitentabellen oder Deskriptoren einfügen. Dies wird jedoch nicht durch Folgendes geschehen. selbst wenn Sie Kernel-Code kompilierten.)
Einige ISAs haben ein "unvorhersehbares Verhalten", wie zum Beispiel das frühe ARM, wenn Sie dasselbe Register für mehrere Operanden einer Multiplikation verwenden, ist das Verhalten unvorhersehbar. IDK, wenn dies das Brechen der Pipeline und das Zerstören anderer Register ermöglicht oder wenn es auf ein unerwartetes Multiplikationsergebnis beschränkt ist. Letzteres wäre meine Vermutung.
Oder MIPS, wenn Sie eine Verzweigung in den Verzweigungsverzögerungsschlitz einfügen, ist das Verhalten nicht vorhersagbar. (Die Behandlung von Ausnahmen ist aufgrund von Verzweigungsverzögerungsslots chaotisch ...). Vermutlich gibt es jedoch noch Grenzen und Sie können die Maschine nicht abstürzen oder andere Prozesse unterbrechen (in einem Mehrbenutzersystem wie Unix wäre es schlecht, wenn ein nicht privilegierter Prozess im Benutzerraum irgendetwas für andere Benutzer beeinträchtigen könnte.
Sehr früh hatte MIPS auch Lade-Verzögerungs-Slots und Multiplikations-Delay-Slots: Sie konnten das Ergebnis eines Ladens nicht in der nächsten Anweisung verwenden. Vermutlich erhalten Sie möglicherweise den alten Wert des Registers, wenn Sie es zu früh lesen oder vielleicht einfach nur Müll. MIPS = minimal miteinander verriegelte Pipelinestufen; Sie wollten das Stalling auf Software übertragen, aber es stellte sich heraus, dass das Hinzufügen eines NOPs, wenn der Compiler nichts brauchte, um die nächsten aufgeblähten Binaries zu tun, zu einem langsameren Gesamtcode im Vergleich zu einem ständigen Hardware-Stall führte. Wir sind jedoch mit Zweigverzögerungs-Slots beschäftigt, weil das Entfernen der ISA die ISA ändern würde, im Gegensatz zu einer Einschränkung bei etwas, das frühe Software nicht getan hat.
In x86-Ganzzahlformaten gibt es keine Trap-Werte. Wenn Sie also nicht initialisierte Werte lesen und vergleichen, werden unvorhersehbare Wahrheits-/Falschwerte und kein anderer direkter Schaden generiert.
In einem kryptografischen Kontext könnte der Zustand der nicht initialisierten Werte, die dazu führen, dass ein anderer Zweig verwendet wird, in Zeitsteuerungslecks oder andere Seitenkanalangriffe auslaufen. Aber kryptographische Verhärtung macht Ihnen wahrscheinlich keine Sorgen.
Die Tatsache, dass gcc uninitialisierte Lesevorgänge ausführt, wenn es egal ist, ob der Lesevorgang den falschen Wert angibt, bedeutet nicht, dass er dies tun wird, wenn es wichtig ist.
Ich wäre nicht so sicher, dass es durch einen Compiler-Fehler verursacht wird. Möglicherweise gibt es in Ihrem Code einige UB, die es dem Compiler ermöglichen, Ihren Code aggressiver zu optimieren. Jedenfalls zu den Fragen: