Ist die Definition von „flüchtig“ so flüchtig oder hat GCC einige Standardkonformitätsprobleme?

88

Ich benötige eine Funktion, die (wie SecureZeroMemory von WinAPI) den Speicher immer auf Null setzt und nicht optimiert wird, selbst wenn der Compiler glaubt, dass der Speicher danach nie wieder aufgerufen wird. Scheint ein perfekter Kandidat für volatile zu sein. Aber ich habe einige Probleme damit, dass dies mit GCC funktioniert. Hier ist eine Beispielfunktion:

void volatileZeroMemory(volatile void* ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = (volatile unsigned char*)ptr;

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

Einfach genug. Der Code, den GCC tatsächlich generiert, wenn Sie ihn aufrufen, hängt jedoch stark von der Compilerversion und der Anzahl der Bytes ab, die Sie tatsächlich auf Null setzen möchten. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 und 4.5.3 ignorieren niemals die flüchtigen Bestandteile.
  • GCC 4.6.4 und 4.7.3 ignorieren flüchtig für die Arraygrößen 1, 2 und 4.
  • GCC 4.8.1 bis 4.9.2 ignorieren flüchtig für Arraygrößen 1 und 2.
  • GCC 5.1 bis 5.3 ignorieren flüchtig für Arraygrößen 1, 2, 4, 8.
  • GCC 6.1 ignoriert es einfach für jede Arraygröße (Bonuspunkte für Konsistenz).

Jeder andere von mir getestete Compiler (clang, icc, vc) generiert die erwarteten Speicher mit jeder Compilerversion und jeder Arraygröße. An diesem Punkt frage ich mich also, ob dies ein (ziemlich alter und schwerwiegender?) GCC-Compiler-Fehler ist oder ob die Definition von flüchtig im Standard ungenau ist, dass dies tatsächlich konform ist, was es im Wesentlichen unmöglich macht, ein tragbares Gerät zu schreiben. " SecureZeroMemory "Funktion?

Edit: Einige interessante Beobachtungen.

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <atomic>

void callMeMaybe(char* buf);

void volatileZeroMemory(volatile void* ptr, std::size_t size)
{
    for (auto bytePtr = static_cast<volatile std::uint8_t*>(ptr); size-- > 0; )
    {
        *bytePtr++ = 0;
    }

    //std::atomic_thread_fence(std::memory_order_release);
}

std::size_t foo()
{
    char arr[8];
    callMeMaybe(arr);
    volatileZeroMemory(arr, sizeof arr);
    return sizeof arr;
}

Durch das mögliche Schreiben von callMeMaybe () werden alle GCC-Versionen außer 6.1 die erwarteten Speicher generieren. Durch das Kommentieren im Speicherzaun generiert GCC 6.1 auch die Speicher, allerdings nur in Kombination mit dem möglichen Schreiben von callMeMaybe ().

Jemand hat auch vorgeschlagen, die Caches zu leeren. Microsoft versucht in "SecureZeroMemory" überhaupt nicht , den Cache zu leeren. Der Cache wird wahrscheinlich sowieso ziemlich schnell ungültig, daher ist dies wahrscheinlich keine große Sache. Wenn ein anderes Programm versucht hat, die Daten zu prüfen, oder wenn sie in die Auslagerungsdatei geschrieben werden sollen, ist dies immer die auf Null gesetzte Version.

Es gibt auch einige Bedenken bezüglich GCC 6.1, das memset () in der Standalone-Funktion verwendet. Der GCC 6.1-Compiler für Godbolt ist möglicherweise fehlerhaft, da GCC 6.1 für einige Benutzer eine normale Schleife (wie 5.3 für Godbolt) für die Standalone-Funktion zu generieren scheint. (Lesen Sie die Kommentare zu zwols Antwort.)

cooky451
quelle
4
IMHO Verwendung volatileist ein Fehler, sofern nicht anders nachgewiesen. Aber höchstwahrscheinlich ein Fehler. volatileist so unterbestimmt, dass es gefährlich ist - benutze es einfach nicht.
Jesper Juhl
19
@JesperJuhl: Nein, volatileist in diesem Fall angemessen.
Dietrich Epp
9
@ NathanOliver: Das wird nicht funktionieren, da Compiler tote Speicher optimieren können, selbst wenn sie sie verwenden memset. Das Problem ist, dass Compiler genau wissen, was sie memsettun.
Dietrich Epp
8
@PaulStelian: Das würde einen volatileZeiger ergeben, auf den wir einen Zeiger wollen volatile(es ist uns egal, ob er ++streng ist, aber ob er *p = 0streng ist).
Dietrich Epp
7
@JesperJuhl: Volatile sind nicht unterbestimmt.
GManNickG

Antworten:

81

Das Verhalten von GCC kann konform sein, und selbst wenn dies nicht der volatileFall ist, sollten Sie sich in solchen Fällen nicht darauf verlassen, das zu tun, was Sie wollen. Das C-Komitee wurde volatilefür speicherabgebildete Hardwareregister und für Variablen entwickelt, die während eines abnormalen Steuerflusses geändert wurden (z. B. Signalhandler und setjmp). Dies sind die einzigen Dinge, für die es zuverlässig ist. Es ist nicht sicher, die allgemeine Annotation "Nicht optimieren" zu verwenden.

Insbesondere ist der Standard in einem wichtigen Punkt unklar. (Ich habe Ihren Code in C konvertiert. Hier sollte es keine Abweichungen zwischen C und C ++ geben. Ich habe auch manuell das Inlining durchgeführt, das vor der fragwürdigen Optimierung erfolgen würde, um zu zeigen, was der Compiler zu diesem Zeitpunkt "sieht" .)

extern void use_arr(void *, size_t);
void foo(void)
{
    char arr[8];
    use_arr(arr, sizeof arr);

    for (volatile char *p = (volatile char *)arr;
         p < (volatile char *)(arr + 8);
         p++)
      *p = 0;
}

Die Speicherlöschschleife greift arrüber einen flüchtig qualifizierten Wert zu, wird jedoch arrselbst nicht deklariert volatile. Es ist daher zumindest wohl zulässig, dass der C-Compiler daraus schließt, dass die von der Schleife erstellten Speicher "tot" sind, und die Schleife insgesamt löscht. Es gibt Text in dem C Rationale , das bedeutet , dass der Ausschuß gemeint , diese Geschäfte zu verlangen , zu erhalten, aber der Standard selbst macht nicht wirklich , dass die Anforderung, wie ich es gelesen.

Weitere Informationen darüber, was der Standard erfordert oder nicht, finden Sie unter Warum wird eine flüchtige lokale Variable anders als ein flüchtiges Argument optimiert und warum generiert der Optimierer aus letzterem eine No-Op-Schleife? , Ist ein Zugriff erklärt nicht-flüchtiges Objekt durch einen flüchtigen Verweis / Zeiger confer flüchtigen Regeln auf die Zugriffe? und GCC-Fehler 71793 .

Um mehr darüber zu erfahren, wofür das Komitee gedacht hat volatile , suchen Sie in der C99-Begründung nach dem Wort "volatile". John Regehrs Artikel " Volatiles are Miscompiled " zeigt im Detail, wie die Erwartungen von Programmierern volatilevon Produktions-Compilern möglicherweise nicht erfüllt werden. Die Aufsatzserie des LLVM-Teams " Was jeder C-Programmierer über undefiniertes Verhalten wissen sollte " geht nicht speziell darauf ein, volatilesondern hilft Ihnen zu verstehen, wie und warum moderne C-Compiler keine "tragbaren Assembler" sind.


Zur praktischen Frage, wie eine Funktion implementiert werden kann, die das tut, was Sie volatileZeroMemorytun wollten : Unabhängig davon, was der Standard erfordert oder erfordern sollte, ist es am klügsten anzunehmen, dass Sie dies nicht verwenden volatilekönnen. Es gibt eine Alternative, auf die man sich bei der Arbeit verlassen kann, weil sie viel zu viele andere Dinge kaputt machen würde, wenn sie nicht funktionieren würde:

extern void memory_optimization_fence(void *ptr, size_t size);
inline void
explicit_bzero(void *ptr, size_t size)
{
   memset(ptr, 0, size);
   memory_optimization_fence(ptr, size);
}

/* in a separate source file */
void memory_optimization_fence(void *unused1, size_t unused2) {}

Sie müssen jedoch unbedingt sicherstellen, dass memory_optimization_fencedies unter keinen Umständen inline ist. Es muss sich in einer eigenen Quelldatei befinden und darf keiner Optimierung der Verbindungszeit unterzogen werden.

Es gibt andere Optionen, die sich auf Compiler-Erweiterungen stützen und unter bestimmten Umständen verwendet werden können und engeren Code generieren können (eine davon wurde in einer früheren Ausgabe dieser Antwort veröffentlicht), aber keine ist universell.

(Ich empfehle, die Funktion aufzurufen explicit_bzero, da sie unter diesem Namen in mehr als einer C-Bibliothek verfügbar ist. Es gibt mindestens vier weitere Kandidaten für den Namen, aber jeder wurde nur von einer einzelnen C-Bibliothek übernommen.)

Sie sollten auch wissen, dass dies möglicherweise nicht ausreicht, selbst wenn Sie dies zum Laufen bringen können. Insbesondere berücksichtigen

struct aes_expanded_key { __uint128_t rndk[16]; };

void encrypt(const char *key, const char *iv,
             const char *in, char *out, size_t size)
{
    aes_expanded_key ek;
    expand_key(key, ek);
    encrypt_with_ek(ek, iv, in, out, size);
    explicit_bzero(&ek, sizeof ek);
}

Unter der Annahme , Hardware mit AES - Beschleunigung Anweisungen, wenn expand_keyund encrypt_with_ekInline ist, kann der Compiler in der Lage sein zu halten ekvöllig Datei in dem Vektorregister - bis den Anruf an explicit_bzero, welche Kräfte es zu den sensiblen Daten auf den Stapel zu kopieren nur um es zu löschen, und, Schlimmer noch, macht nichts gegen die Schlüssel, die noch in den Vektorregistern sitzen!

zwol
quelle
6
Das ist interessant ... Ich würde gerne einen Verweis auf die Kommentare des Ausschusses sehen.
Dietrich Epp
10
Wie passt dieses Quadrat zur Definition von 6.7.3 (7) volatileals [...] ? Daher muss jeder Ausdruck, der sich auf ein solches Objekt bezieht, streng nach den Regeln der abstrakten Maschine bewertet werden, wie in 5.1.2.3 beschrieben. Darüber hinaus muss an jedem Sequenzpunkt der zuletzt im Objekt gespeicherte Wert mit dem von der abstrakten Maschine vorgeschriebenen Wert übereinstimmen , sofern er nicht durch die zuvor genannten unbekannten Faktoren geändert wurde. Was einen Zugriff auf ein Objekt mit einem flüchtig qualifizierten Typ darstellt, ist implementierungsdefiniert. ?
Iwillnotexist Idonotexist
15
@IwillnotexistIdonotexist Das Schlüsselwort in dieser Passage ist Objekt . volatile sig_atomic_t flag;ist ein flüchtiges Objekt . *(volatile char *)fooist lediglich ein Zugang über einen flüchtig qualifizierten Wert, und der Standard verlangt keine Spezialeffekte.
zwol
3
Der Standard sagt, welche Kriterien etwas erfüllen muss, um eine "konforme" Implementierung zu sein. Es wird nicht versucht zu beschreiben, welche Kriterien eine Implementierung auf einer bestimmten Plattform erfüllen muss, um eine "gute" oder eine "verwendbare" Implementierung zu sein. Die Behandlung von GCC volatilekann ausreichen, um eine "konforme" Implementierung zu erreichen, aber das bedeutet nicht, dass es ausreicht, "gut" oder "nützlich" zu sein. Für viele Arten der Systemprogrammierung sollte diesbezüglich als äußerst mangelhaft angesehen werden.
Supercat
3
In der C-Spezifikation heißt es auch ziemlich direkt: "Eine tatsächliche Implementierung muss keinen Teil eines Ausdrucks auswerten, wenn daraus geschlossen werden kann, dass sein Wert nicht verwendet wird und keine erforderlichen Nebenwirkungen auftreten ( einschließlich solcher, die durch den Aufruf einer Funktion oder den Zugriff auf ein flüchtiges Objekt verursacht werden ). . " (betone meine).
Johannes Schaub - litb
15

Ich brauche eine Funktion, die (wie SecureZeroMemory von der WinAPI) immer Nullspeicher hat und nicht weg optimiert wird,

Dafür ist die Standardfunktion gedacht memset_s.


Ob dieses Verhalten mit flüchtigen Bestandteilen konform ist oder nicht, ist etwas schwer zu sagen, und flüchtige Menschen sollen seit langem von Fehlern geplagt sein.

Ein Problem ist, dass die Spezifikationen besagen, dass "Zugriffe auf flüchtige Objekte streng nach den Regeln der abstrakten Maschine bewertet werden". Dies bezieht sich jedoch nur auf "flüchtige Objekte" und nicht auf den Zugriff auf ein nichtflüchtiges Objekt über einen Zeiger, dem flüchtige Elemente hinzugefügt wurden. Wenn ein Compiler also feststellen kann, dass Sie nicht wirklich auf ein flüchtiges Objekt zugreifen, ist es anscheinend nicht erforderlich, das Objekt schließlich als flüchtig zu behandeln.

bames53
quelle
4
Hinweis: Dies ist Teil des C11-Standards und noch nicht in allen Toolchains verfügbar.
Dietrich Epp
5
Interessanterweise ist diese Funktion für C11 standardisiert, nicht jedoch für C ++ 11, C ++ 14 oder C ++ 17. Technisch gesehen ist es keine Lösung für C ++, aber ich stimme zu, dass dies aus praktischer Sicht die beste Option zu sein scheint. An dieser Stelle frage ich mich allerdings, ob das Verhalten von GCC konform ist oder nicht. Bearbeiten: Eigentlich hat VS 2015 keine memset_s, also ist es noch nicht so portabel.
cooky451
2
@ cooky451 Ich dachte, C ++ 17 zieht die C11-Standardbibliothek als Referenz ein (siehe zweites Misc).
nwp
13
Auch die Beschreibung memset_sals C11-Standard ist eine Übertreibung. Es ist Teil von Anhang K, der in C11 optional ist (und daher auch in C ++ optional ist). Grundsätzlich haben sich alle Implementierer, einschließlich Microsoft, dessen Idee es überhaupt war (!), Abgelehnt, es aufzugreifen. Zuletzt hörte ich, dass sie darüber sprachen, es in C-next zu verschrotten.
zwol
8
@ cooky451 In bestimmten Kreisen ist Microsoft dafür berüchtigt, Dinge über die Einwände aller anderen in den C-Standard zu zwingen und sich dann nicht die Mühe zu machen, sie selbst zu implementieren. (Das ungeheuerlichste Beispiel dafür ist die Lockerung der Regeln für den zugrunde liegenden Typ von C99 size_t. Der Win64 ABI ist nicht konform mit C90. Das wäre ... nicht in Ordnung , aber nicht schrecklich ... wenn MSVC hatte tatsächlich C99-Dinge wie uintmax_tund %zurechtzeitig aufgegriffen , aber sie taten es nicht .)
zwol
2

Ich biete diese Version als portables C ++ an (obwohl die Semantik subtil unterschiedlich ist):

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = new (ptr) volatile unsigned char[size];

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

Jetzt haben Sie Schreibzugriffe auf ein flüchtiges Objekt und nicht nur Zugriff auf ein nichtflüchtiges Objekt, das über eine flüchtige Ansicht des Objekts erstellt wurde.

Der semantische Unterschied besteht darin, dass nun die Lebensdauer aller Objekte, die den Speicherbereich belegen, formal beendet wird, da der Speicher wiederverwendet wurde. Der Zugriff auf das Objekt nach dem Nullstellen seines Inhalts ist jetzt sicherlich ein undefiniertes Verhalten (früher wäre es in den meisten Fällen ein undefiniertes Verhalten gewesen, aber es gab sicherlich einige Ausnahmen).

Um diese Nullung während der Lebensdauer eines Objekts anstatt am Ende zu verwenden, sollte der Aufrufer die Platzierung verwenden new, um eine neue Instanz des ursprünglichen Typs wieder zurückzusetzen.

Der Code kann durch Verwendung der Wertinitialisierung verkürzt (wenn auch weniger klar) werden:

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    new (ptr) volatile unsigned char[size] ();
}

und zu diesem Zeitpunkt ist es ein Einzeiler und garantiert kaum eine Hilfsfunktion.

Ben Voigt
quelle
2
Wenn Zugriffe auf das Objekt nach Ausführung der Funktion UB aufrufen würden, würde dies bedeuten, dass solche Zugriffe die Werte ergeben könnten, die das Objekt vor dem "Löschen" hatte. Wie ist das nicht das Gegenteil von Sicherheit?
Supercat
0

Es sollte möglich sein, eine tragbare Version der Funktion zu schreiben, indem ein flüchtiges Objekt auf der rechten Seite verwendet wird und der Compiler gezwungen wird, die Speicher im Array beizubehalten.

void volatileZeroMemory(void* ptr, unsigned long long size)
{
    volatile unsigned char zero = 0;
    unsigned char* bytePtr = static_cast<unsigned char*>(ptr);

    while (size--)
    {
        *bytePtr++ = zero;
    }

    zero = static_cast<unsigned char*>(ptr)[zero];
}

Das zeroObjekt wird deklariert volatile, um sicherzustellen , dass der Compiler keine Annahmen über seinen Wert treffen kann, obwohl er immer als Null ausgewertet wird.

Der endgültige Zuweisungsausdruck liest aus einem flüchtigen Index im Array und speichert den Wert in einem flüchtigen Objekt. Da dieser Lesevorgang nicht optimiert werden kann, wird sichergestellt, dass der Compiler die in der Schleife angegebenen Speicher generieren muss.

D Krüger
quelle
1
Das funktioniert überhaupt nicht ... sehen Sie sich nur den Code an, der generiert wird.
cooky451
1
Nachdem ich mein generiertes ASM besser gelesen habe, scheint es den Funktionsaufruf zu inlineieren und die Schleife beizubehalten, aber *ptrwährend dieser Schleife keine Speicherung vorzunehmen oder überhaupt irgendetwas ... nur eine Schleife. wtf, da geht mein Gehirn.
underscore_d
3
@underscore_d Das liegt daran, dass der Speicher optimiert wird, während das Lesen des Volatilen erhalten bleibt.
D Krueger
1
Ja, und das Ergebnis wird unverändert edx: Ich .L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d
1
Wenn ich die Funktion so ändere, dass ein beliebiges volatile unsigned char constFüllbyte übergeben werden kann, wird es nicht einmal gelesen . Der generierte Inline-Aufruf von volatileFill()ist nur [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Warum liest der Optimierer (A) das Füllbyte nicht erneut und (B) behält die Schleife bei, wo er nichts tut?
underscore_d