La definizione di "volatile" è così volatile o GCC presenta alcuni problemi di conformità agli standard?


89

Ho bisogno di una funzione che (come SecureZeroMemory da WinAPI) azzeri sempre la memoria e non venga ottimizzata, anche se il compilatore pensa che la memoria non accederà mai più dopo. Sembra un candidato perfetto per volatile. Ma sto avendo alcuni problemi a far funzionare questo con GCC. Ecco una funzione di esempio:

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

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

Abbastanza semplice. Ma il codice che GCC genera effettivamente se lo chiami varia notevolmente con la versione del compilatore e la quantità di byte che stai effettivamente cercando di azzerare. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 e 4.5.3 non ignorano mai il volatile.
  • GCC 4.6.4 e 4.7.3 ignorano il volatile per le dimensioni di array 1, 2 e 4.
  • GCC 4.8.1 fino a 4.9.2 ignora volatile per le dimensioni di array 1 e 2.
  • GCC da 5.1 a 5.3 ignora il volatile per le dimensioni di array 1, 2, 4, 8.
  • GCC 6.1 lo ignora semplicemente per qualsiasi dimensione di array (punti bonus per la coerenza).

Qualsiasi altro compilatore che ho testato (clang, icc, vc) genera gli archivi che ci si aspetterebbe, con qualsiasi versione del compilatore e qualsiasi dimensione di array. Quindi a questo punto mi chiedo, si tratta di un bug del compilatore GCC (piuttosto vecchio e grave?), O la definizione di volatile nello standard è che imprecisa sul fatto che questo sia effettivamente un comportamento conforme, rendendo essenzialmente impossibile scrivere un portatile " SecureZeroMemory "funzione?

Modifica: alcune osservazioni interessanti.

#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;
}

L'eventuale scrittura da callMeMaybe () farà in modo che tutte le versioni di GCC tranne la 6.1 generino gli archivi previsti. Commentando nella barriera della memoria, GCC 6.1 genererà anche gli archivi, sebbene solo in combinazione con la possibile scrittura da callMeMaybe ().

Qualcuno ha anche suggerito di svuotare le cache. Microsoft non tenta affatto di svuotare la cache in "SecureZeroMemory". È probabile che la cache venga comunque invalidata abbastanza velocemente, quindi questo probabilmente non sarà un grosso problema. Inoltre, se un altro programma stava tentando di sondare i dati, o se stava per essere scritto nel file di paging, sarebbe sempre la versione azzerata.

Ci sono anche alcune preoccupazioni riguardo GCC 6.1 usando memset () nella funzione standalone. Il compilatore GCC 6.1 su godbolt potrebbe essere una build non funzionante, poiché GCC 6.1 sembra generare un ciclo normale (come fa 5.3 su godbolt) per la funzione standalone per alcune persone. (Leggi i commenti della risposta di zwol.)


4
IMHO l'utilizzo volatileè un bug a meno che non sia dimostrato diversamente. Ma molto probabilmente un bug. volatileè così sottospecificato da essere pericoloso: non usarlo.
Jesper Juhl

19
@JesperJuhl: No, volatileè appropriato in questo caso.
Dietrich Epp

9
@NathanOliver: non funzionerà, perché i compilatori possono ottimizzare gli archivi morti anche se usano memset. Il problema è che i compilatori sanno esattamente cosa memsetfa.
Dietrich Epp

8
@PaulStelian: Sarebbe un volatilepuntatore, a cui vogliamo un puntatore volatile(non ci interessa se ++è rigoroso, ma se *p = 0è rigoroso).
Dietrich Epp

7
@JesperJuhl: Non c'è niente di sotto specificato sulla volatile.
GManNickG

Risposte:


82

Il comportamento di GCC potrebbe essere conforme, e anche se non lo è, non dovresti fare affidamento volatileper fare ciò che vuoi in casi come questi. Il comitato C progettato volatileper i registri hardware mappati in memoria e per le variabili modificate durante il flusso di controllo anormale (ad esempio gestori di segnali e setjmp). Queste sono le uniche cose per cui è affidabile. Non è sicuro da usare come annotazione generale "non ottimizzarla".

In particolare, lo standard non è chiaro su un punto chiave. (Ho convertito il tuo codice in C; qui non dovrebbe esserci alcuna divergenza tra C e C ++. Ho anche eseguito manualmente l'inlining che sarebbe avvenuto prima della discutibile ottimizzazione, per mostrare ciò che il compilatore "vede" a quel punto .)

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;
}

Il ciclo di cancellazione della memoria accede arrtramite un lvalue qualificato per volatile, ma nonarr viene dichiarato . È, quindi, almeno discutibilmente consentito al compilatore C di dedurre che gli archivi creati dal ciclo sono "morti" ed eliminare del tutto il ciclo. C'è del testo nel Rationale C che implica che il comitato intendeva richiedere che quei negozi siano preservati, ma lo standard stesso non fa effettivamente quel requisito, come l'ho letto.volatile

Per ulteriori discussioni su ciò che lo standard richiede o non richiede, vedere Perché una variabile locale volatile è ottimizzata in modo diverso da un argomento volatile e perché l'ottimizzatore genera un ciclo no-op da quest'ultimo? , L'accesso a un oggetto non volatile dichiarato tramite un riferimento / puntatore volatile conferisce regole volatili a detti accessi? e bug GCC 71793 .

Per ulteriori informazioni su ciò che il comitato pensava volatile fosse, cerca nel C99 Rationale la parola "volatile". L'articolo di John Regehr " Volatiles are Miscompiled " illustra in dettaglio come le aspettative dei programmatori riguardo volatilepossano non essere soddisfatte dai compilatori di produzione. La serie di saggi del team LLVM " Quello che ogni programmatore C dovrebbe sapere sul comportamento indefinito " non tocca specificamente volatilema ti aiuterà a capire come e perché i moderni compilatori C non sono "assemblatori portatili".


Alla domanda pratica di come implementare una funzione che fa ciò che si desidera volatileZeroMemory: indipendentemente da ciò che lo standard richiede o doveva richiedere, sarebbe più saggio presumere che non sia possibile utilizzarlo volatileper questo. V'è un'alternativa che può essere fatto valere per il lavoro, perché si spezzerebbe troppo altre cose se non ha funzionato:

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) {}

Tuttavia, devi assicurarti che memory_optimization_fencenon sia inline in nessuna circostanza. Deve essere nel proprio file sorgente e non deve essere soggetto a ottimizzazione del tempo di collegamento.

Ci sono altre opzioni, che si basano sulle estensioni del compilatore, che possono essere utilizzabili in alcune circostanze e possono generare codice più stretto (una di queste è apparsa in un'edizione precedente di questa risposta), ma nessuna è universale.

(Consiglio di chiamare la funzione explicit_bzero, perché è disponibile con quel nome in più di una libreria C. Ci sono almeno altri quattro contendenti per il nome, ma ognuno è stato adottato solo da una singola libreria C.)

Dovresti anche sapere che, anche se riesci a farlo funzionare, potrebbe non essere sufficiente. In particolare, considera

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);
}

Assumendo hardware con istruzioni di accelerazione AES, se expand_keye encrypt_with_eksono inline, il compilatore potrebbe essere in grado di mantenerlo ekinteramente nel file di registro vettoriale - fino alla chiamata a explicit_bzero, che lo costringe a copiare i dati sensibili sullo stack solo per cancellarli, e, peggio ancora, non fa niente per le chiavi che sono ancora presenti nei registri vettoriali!


6
Interessante ... Sarei interessato a vedere un riferimento ai commenti del comitato.
Dietrich Epp

10
Come funziona questo quadrato con la definizione di volatilecome 6.7.3 (7) [...] Quindi qualsiasi espressione riferita a un tale oggetto deve essere valutata rigorosamente secondo le regole della macchina astratta, come descritto in 5.1.2.3. Inoltre, in ogni punto della sequenza, l'ultimo valore memorizzato nell'oggetto dovrà concordare con quello prescritto dalla macchina astratta , salvo quanto modificato dalle incognite menzionate in precedenza. Ciò che costituisce un accesso a un oggetto che ha un tipo qualificato volatile è definito dall'implementazione. ?
Iwillnotexist Idonotexist

15
@IwillnotexistIdonotexist La parola chiave in quel passaggio è oggetto . volatile sig_atomic_t flag;è un oggetto volatile . *(volatile char *)fooè semplicemente un accesso attraverso un valore qualificato volatile e lo standard non richiede che abbia effetti speciali.
zwol

3
Lo Standard dice quali criteri qualcosa deve soddisfare per essere un'implementazione "conforme". Non fa alcuno sforzo per descrivere i criteri che un'implementazione su una determinata piattaforma deve soddisfare per essere un'implementazione "buona" o "utilizzabile". Il trattamento di GCC volatilepotrebbe essere sufficiente per renderlo un'implementazione "conforme", ma ciò non significa che sia sufficiente essere "buono" o "utile". Per molti tipi di programmazione di sistemi dovrebbe essere considerato come deplorevolmente carente sotto questi aspetti.
supercat

3
La specifica C dice anche piuttosto direttamente "Un'attuale implementazione non ha bisogno di valutare parte di un'espressione se può dedurre che il suo valore non viene utilizzato e che non vengono prodotti effetti collaterali necessari ( inclusi quelli causati dalla chiamata a una funzione o dall'accesso a un oggetto volatile ) . " (enfatizza il mio).
Johannes Schaub - litb

15

Ho bisogno di una funzione che (come SecureZeroMemory da WinAPI) azzeri sempre la memoria e non venga ottimizzata,

A questo serve la funzione standard memset_s.


Quanto alla questione se questo comportamento con volatili è conforme o no, questo è un po 'difficile da dire, e volatile è stato detto di essere stato a lungo tormentato con gli insetti.

Un problema è che le specifiche dicono che "Gli accessi agli oggetti volatili sono valutati rigorosamente secondo le regole della macchina astratta". Ma questo si riferisce solo agli "oggetti volatili", non accedendo a un oggetto non volatile tramite un puntatore a cui è stato aggiunto volatile. Quindi apparentemente se un compilatore può dire che non stai realmente accedendo a un oggetto volatile, non è necessario trattare l'oggetto come volatile, dopotutto.


4
Nota: fa parte dello standard C11 e non è ancora disponibile in tutte le toolchain.
Dietrich Epp

5
È interessante notare che questa funzione è standardizzata per C11 ma non per C ++ 11, C ++ 14 o C ++ 17. Quindi tecnicamente non è una soluzione per C ++, ma sono d'accordo che questa sembra l'opzione migliore da un punto di vista pratico. A questo punto mi chiedo però se il comportamento di GCC sia conforme o meno. Modifica: in realtà, VS 2015 non ha memset_s, quindi non è ancora così portabile.
cooky451

2
@ cooky451 Pensavo che C ++ 17 includesse la libreria standard C11 per riferimento (vedi la seconda Misc).
nwp

14
Inoltre, descrivere memset_scome standard C11 è un'esagerazione. Fa parte dell'allegato K, che è opzionale in C11 (e quindi anche opzionale in C ++). Fondamentalmente tutti gli implementatori, inclusa Microsoft, di cui era stata l'idea in primo luogo (!), Hanno rifiutato di prenderlo; l'ultima volta ho sentito che stavano parlando di rottamarlo in C-next.
zwol

8
@ cooky451 In certi ambienti, Microsoft è nota per forzare le cose nello standard C fondamentalmente sulle obiezioni di tutti gli altri e quindi non si preoccupa di implementarlo da sé. (L'esempio più eclatante di questo è il rilassamento delle regole da parte di C99 per ciò che il tipo sottostante size_tpuò essere. L'ABI di Win64 non è conforme a C90. Sarebbe stato ... non ok , ma non terribile ... se MSVC aveva effettivamente rilevato C99 cose come uintmax_te %zuin modo tempestivo, ma non l' hanno fatto .)
zwol

2

Offro questa versione come C ++ portatile (sebbene la semantica sia leggermente diversa):

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

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

Ora hai accesso in scrittura a un oggetto volatile , non solo accessi a un oggetto non volatile effettuati tramite una vista volatile dell'oggetto.

La differenza semantica è che ora termina formalmente la durata di qualsiasi oggetto (i) ha occupato la regione di memoria, perché la memoria è stata riutilizzata. Quindi l'accesso all'oggetto dopo aver azzerato il suo contenuto ora è sicuramente un comportamento indefinito (in precedenza sarebbe stato un comportamento indefinito nella maggior parte dei casi, ma sicuramente esistevano alcune eccezioni).

Per utilizzare questo azzeramento durante la vita di un oggetto anziché alla fine, il chiamante deve utilizzare il posizionamento new per reinserire una nuova istanza del tipo originale.

Il codice può essere reso più breve (anche se meno chiaro) utilizzando l'inizializzazione del valore:

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

ea questo punto è una battuta e garantisce a malapena una funzione di aiuto.


2
Se gli accessi all'oggetto dopo l'esecuzione della funzione richiamassero UB, ciò significherebbe che tali accessi potrebbero restituire i valori che l'oggetto deteneva prima che fosse "cancellato". Perché non è l'opposto della sicurezza?
supercat

0

Dovrebbe essere possibile scrivere una versione portabile della funzione utilizzando un oggetto volatile sul lato destro e costringendo il compilatore a preservare gli archivi nell'array.

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];
}

L' zerooggetto è dichiaratovolatile che garantisce che il compilatore non possa fare ipotesi sul suo valore anche se viene valutato sempre come zero.

L'espressione di assegnazione finale legge da un indice volatile nell'array e memorizza il valore in un oggetto volatile. Poiché questa lettura non può essere ottimizzata, garantisce che il compilatore debba generare gli archivi specificati nel ciclo.


1
Questo non funziona affatto ... guarda solo il codice che viene generato.
cooky451

1
Avendo letto il mio ASM generato mo 'meglio, sembra in linea la chiamata alla funzione e mantenere il ciclo, ma non eseguire alcuna memorizzazione *ptrdurante quel ciclo, o in realtà qualsiasi cosa ... solo ciclo. wtf, ci va il mio cervello.
underscore_d

3
@underscore_d È perché sta ottimizzando il negozio preservando la lettura del volatile.
D Krueger

1
Sì, e scarica il risultato in modo immutabile edx: ottengo questo:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d

1
Se cambio la funzione per consentire il passaggio di un volatile unsigned char constbyte di riempimento arbitrario ... non lo legge nemmeno . La chiamata inline generata a volatileFill()è solo [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Perché l'ottimizzatore (A) non rilegge il byte di riempimento e (B) si preoccupa di preservare il ciclo dove non fa nulla?
underscore_d
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.