webentwicklung-frage-antwort-db.com.de

Faszinierende Assembly zum Vergleichen von std :: optional von primitiven Typen

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_payloadinnerhalb 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.

  1. Wenn eine Option nullopt ist und die andere festgelegt ist, springt der bedingte Sprung bei +148 zu endreturn false), OK.
  2. Wenn beide Optionen eingestellt sind, werden beim Vergleich die initialisierten Werte angezeigt. OK.

Der einzige interessierende Fall ist also, wenn beide Optionen nullopt sind:

  • wenn die Werte gleich sind, kommt der Code zu dem Schluss, dass die Optionen gleich sind, was wahr ist, da beide nullopt sind.
  • andernfalls kommt der Code zu dem Schluss, dass die Optionen gleich sind, wenn __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:

  1. _ (Sind nicht initialisierte Lesevorgänge in Assembly (x84_64) in Ordnung?
  2. Ist dies das Syndrom einer fehlgeschlagenen Optimierung (Umkehrung von ||), 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:

  • compiler: gcc 7.3
  • compile flags: -std=c++17 -g -Wall -Werror -O3 -flto (+ passende Includes)
  • link Flags: -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.

23
Matthieu M.

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.

4
Peter Cordes

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:

  1. UB ist kein Thema in der Montage. In den meisten Fällen wird gelesen, was unter der Adresse übrig ist, auf die Sie sich beziehen. Natürlich füllen die meisten Betriebssysteme Speicherseiten, bevor sie programmiert werden, aber Ihre Variable befindet sich höchstwahrscheinlich auf Stack, daher enthält sie höchstwahrscheinlich Speicherdaten. Soo, solange Sie mit dem zufälligen Datenvergleich in Ordnung sind (was ziemlich schlecht ist, da es zu falschen Ergebnissen kommen kann), ist die Assembly gültig
  2. Am wahrscheinlichsten ist es das Syndrom des umgekehrten Vergleichs
0
bartop