Skip to content

Lo standard C ++ consente a un bool non inizializzato di mandare in crash un programma?

Questo team di scrittori ha cercato per ore di risolvere i tuoi dubbi, condividiamo la risposta con te, quindi speriamo di esserti di grande supporto.

Soluzione:

Sì, lo standard ISO C++ consente (ma non richiede) alle implementazioni di fare questa scelta.

Ma si noti anche che ISO C++ consente a un compilatore di emettere codice che si blocca di proposito (ad esempio con un'istruzione illegale) se il programma incontra UB, ad esempio come modo per aiutare l'utente a trovare gli errori. (O perché è una DeathStation 9000). Essere strettamente conformi non è sufficiente perché un'implementazione C++ sia utile per qualsiasi scopo reale). Quindi l'ISO C++ permetterebbe a un compilatore di creare asm che si bloccano (per ragioni totalmente diverse) anche su codice simile che legge un file non inizializzato. uint32_t. Anche se è richiesto che sia un tipo a layout fisso senza rappresentazioni di trappole.

È una domanda interessante su come funzionano le implementazioni reali, ma ricordate che anche se la risposta fosse diversa, il vostro codice non sarebbe comunque sicuro perché il moderno C++ non è una versione portabile del linguaggio assembly.


State compilando per l'ABI del System V x86-64, che specifica che un bool come arg di una funzione in un registro è rappresentato dal modello di bit false=0 e true=1 negli 8 bit bassi del registro 1. In memoria, bool è un tipo a 1 byte che deve avere un valore intero pari a 0 o 1.

(Un ABI è un insieme di scelte di implementazione che i compilatori per la stessa piattaforma concordano in modo da poter creare codice che chiama le funzioni dell'altro, comprese le dimensioni dei tipi, le regole di disposizione delle strutture e le convenzioni di chiamata).

L'ISO C++ non lo specifica, ma questa decisione dell'ABI è diffusa perché rende economica la conversione bool->int (solo estensione zero).. Non sono a conoscenza di ABI che non permettano al compilatore di assumere 0 o 1 per i parametri boolper qualsiasi architettura (non solo x86). Questo permette ottimizzazioni come !mybool con xor eax,1 per capovolgere il bit basso: Qualsiasi codice possibile che possa capovolgere un bit/integro/bool tra 0 e 1 in una singola istruzione della CPU. Oppure compilando a&&b in un AND bitwise per bool tipi. Alcuni compilatori sfruttano effettivamente i valori booleani come 8 bit nei compilatori. Le operazioni su di essi sono inefficienti?

In generale, la regola as-if permette al compilatore di sfruttare le cose che sono vere sulla piattaforma di destinazione per la quale si sta compilando perché il risultato finale sarà un codice eseguibile che implementa lo stesso comportamento visibile all'esterno del sorgente C++. (Con tutte le restrizioni che Undefined Behaviour pone su ciò che è effettivamente "visibile dall'esterno": non con un debugger, ma da un altro thread in un programma C++ ben formato/legale).

Il compilatore è sicuramente autorizzato a sfruttare appieno una garanzia ABI nel suo code-gen, e a creare codice come quello che avete trovato che ottimizza strlen(whichString) a
5U - boolValue.
(BTW, questa ottimizzazione è piuttosto intelligente, ma forse poco lungimirante rispetto alla ramificazione e all'inlining). memcpycome archivio di dati immediati 2.)

Oppure il compilatore avrebbe potuto creare una tabella di puntatori e indicizzarla con il valore intero dell'elemento bool, sempre assumendo che fosse uno 0 o un 1. (Questa possibilità è quella suggerita dalla risposta di @Barmar).


Il vostro __attribute((noinline)) con l'ottimizzazione abilitata ha fatto sì che clang caricasse semplicemente un byte dallo stack da utilizzare come uninitializedBool. Ha fatto spazio per l'oggetto in main con push rax (che è più piccolo e, per varie ragioni, più o meno efficiente di sub rsp, 8).sub rsp, 8), per cui qualsiasi rifiuto fosse presente in AL all'ingresso in main è il valore usato per uninitializedBool. Questo è il motivo per cui si ottengono valori che non sono semplicemente 0.

5U - random garbage può facilmente avvolgersi in un grande valore senza segno, portando memcpy ad andare in memoria non mappata. La destinazione è nella memoria statica, non nello stack, quindi non si sta sovrascrivendo un indirizzo di ritorno o qualcosa del genere.


Altre implementazioni potrebbero fare scelte diverse, ad es. false=0 e true=any non-zero value. Quindi clang probabilmente non creerebbe codice che si blocca per questo specifica istanza di UB. (Ma sarebbe comunque autorizzato a farlo, se lo volesse). Non conosco alcuna implementazione che scelga qualcosa di diverso da quello che x86-64 fa per boolma lo standard C++ permette molte cose che nessuno fa o vorrebbe fare su un hardware simile alle CPU attuali.

L'ISO C++ non specifica che cosa si troverà quando si esamina o si modifica la rappresentazione ad oggetti di un oggetto bool. (ad esempio da memcpyil parametro bool in unsigned charche si può fare perché char* può avere come alias qualsiasi cosa. E unsigned char è garantito senza bit di riempimento, quindi lo standard C++ consente formalmente di eseguire l'hexdump di rappresentazioni di oggetti senza UB. La fusione dei puntatori per copiare la rappresentazione dell'oggetto è diversa dall'assegnazione di char foo = my_boolovviamente, quindi la booleanizzazione a 0 o 1 non avverrebbe e si otterrebbe la rappresentazione grezza dell'oggetto).

Avete parzialmente "nascondere" l'UB su questo percorso di esecuzione al compilatore con noinline. Anche se non è in linea, tuttavia, le ottimizzazioni interprocedurali potrebbero comunque creare una versione della funzione che dipende dalla definizione di un'altra funzione. (In primo luogo, clang sta creando un eseguibile, non una libreria condivisa Unix in cui può avvenire l'interposizione di simboli. In secondo luogo, la definizione all'interno dell'elemento class{} quindi tutte le unità di traduzione devono avere la stessa definizione. Come con la definizione inline ).

Quindi un compilatore potrebbe emettere solo una parola ret o ud2 (istruzione illegale) come definizione per mainperché il percorso di esecuzione che parte dall'inizio di main incontra inevitabilmente un comportamento non definito. (che il compilatore può vedere in fase di compilazione se decide di seguire il percorso attraverso il costruttore non in linea).

Qualsiasi programma che incontra UB è totalmente indefinito per tutta la sua esistenza. Ma UB all'interno di una funzione o if() che non viene mai eseguita non corrompe il resto del programma. In pratica, ciò significa che i compilatori possono decidere di emettere un'istruzione illegale, o un ramo ret. ret o non emettere nulla e cadere nel blocco/funzione successivo, per l'intero blocco di base che può essere dimostrato in fase di compilazione di contenere o portare a UB.

GCC e Clang in pratica fare in realtà a volte emettono ud2 su UB, invece di provare a generare codice per percorsi di esecuzione che non hanno senso. O per casi come la caduta dalla fine di un percorso non void gcc a volte ometterà un ret. Se stavate pensando che "la mia funzione ritornerà con qualsiasi spazzatura presente in RAX", vi state sbagliando di grosso. I moderni compilatori C++ non trattano più il linguaggio come un linguaggio assembly portatile. Il vostro programma deve essere davvero valido in C++, senza fare ipotesi su come una versione stand-alone non inline della vostra funzione potrebbe apparire in asm.

Un altro esempio divertente è Perché l'accesso non allineato alla memoria mmappata a volte dà luogo a un segfault su AMD64? x86 non dà errore sugli interi non allineati, giusto? Quindi perché un accesso non allineato uint16_t* è un problema? Perché alignof(uint16_t) == 2e la violazione di questo presupposto ha portato a un segfault durante l'autovettorizzazione con SSE2.

Si veda anche What Every C Programmer Should Know About Undefined Behavior #1/3, un articolo di uno sviluppatore di clang.

Punto chiave: se il compilatore si accorge dell'UB in fase di compilazione, esso potrebbe "interrompere" (emettere un asm sorprendente) il percorso attraverso il codice che causa l'UB, anche se si utilizza un ABI in cui qualsiasi modello di bit è una rappresentazione di oggetto valida per bool.

Aspettatevi un'ostilità totale nei confronti di molti errori del programmatore, specialmente quelli che i compilatori moderni mettono in guardia. Questo è il motivo per cui si dovrebbe usare -Wall e correggere gli avvertimenti. Il C++ non è un linguaggio facile da usare e qualcosa in C++ può essere non sicuro anche se sarebbe sicuro in asm sul target per cui si sta compilando. (Ad esempio, l'overflow firmato è UB in C++ e i compilatori presumono che non si verifichi, anche quando si compila per il complemento a 2 di x86, a meno che non si usi clang/gcc -fwrapv.)

L'UB visibile a tempo di compilazione è sempre pericoloso ed è davvero difficile essere sicuri (con l'ottimizzazione a tempo di link) di aver davvero nascosto l'UB al compilatore e di poter quindi ragionare sul tipo di asm che genererà.

Per non essere troppo drammaticic; spesso i compilatori permettono di farla franca con alcune cose ed emettono codice come ci si aspetta anche quando qualcosa è UB. Ma forse sarà un problema in futuro se gli sviluppatori del compilatore implementeranno qualche ottimizzazione che ottenga maggiori informazioni sugli intervalli di valori (ad esempio, che una variabile sia non negativa, magari consentendo di ottimizzare l'estensione del segno per liberare l'estensione zero su x86-64). Per esempio, negli attuali gcc e clang, fare tmp = a+INT_MIN non ottimizza a<0 come sempre-falso, solo che tmp è sempre negativo. (Perché INT_MIN + a=INT_MAX è negativo su questo obiettivo a complemento a 2 e a non può essere più alto di così).

Quindi gcc/clang al momento non fanno il backtracking per ricavare informazioni sul range per gli input di un calcolo, ma solo sui risultati basati sull'ipotesi di non overflow firmato: esempio su Godbolt. Non so se questa ottimizzazione sia intenzionalmente "saltata" in nome della facilità d'uso o cosa.

Si noti anche che le implementazioni (alias i compilatori) possono definire comportamenti che l'ISO C++ lascia indefiniti.. Per esempio, tutti i compilatori che supportano gli intrinseci di Intel (come _mm_add_ps(__m128, __m128) per la vettorizzazione SIMD manuale) devono consentire la formazione di puntatori non allineati, cosa che in C++ è UB anche se si non dereferenziarli. __m128i _mm_loadu_si128(const __m128i *) esegue carichi non allineati prendendo un file non allineato __m128i* non un parametro void* o char*. Il `reinterpret_casting' tra un puntatore vettoriale hardware e il tipo corrispondente è un comportamento non definito?

GNU C/C++ definisce anche il comportamento di spostamento a sinistra di un numero firmato negativo (anche senza -fwrapv), separatamente dalle normali regole UB per l'overflow firmato. (Questo è UB in ISO C++, mentre lo spostamento a destra dei numeri firmati è definito dall'implementazione (logica o aritmetica); le implementazioni di buona qualità scelgono l'aritmetica su HW con spostamento a destra aritmetico, ma ISO C++ non lo specifica). Questo è documentato nella sezione Integer del manuale di GCC, insieme alla definizione di comportamenti definiti dall'implementazione che gli standard C richiedono alle implementazioni di definire in un modo o nell'altro.

Ci sono sicuramente problemi di qualità dell'implementazione a cui gli sviluppatori di compilatori tengono; in genere non sono provando di creare compilatori intenzionalmente ostili, ma sfruttare tutte le falle UB del C++ (tranne quelle che loro stessi scelgono di definire) per ottimizzare meglio può essere a volte quasi indistinguibile.


Nota 1: I 56 bit superiori possono essere spazzatura che il chiamante deve ignorare, come di consueto per i tipi più stretti di un registro.

(Altre ABI fare fare scelte diverse qui. Alcuni richiedono che i tipi interi stretti siano estesi a zero o a segno per riempire un registro quando vengono passati o restituiti da funzioni, come MIPS64 e PowerPC64. Si veda l'ultima sezione di questa risposta su x86-64 che fa un confronto con le ISA precedenti).

Per esempio, un chiamante potrebbe aver calcolato a & 0x01010101 in RDI e averlo usato per qualcos'altro, prima di chiamare bool_func(a&1). Il chiamante potrebbe ottimizzare il parametro &1 perché lo ha già fatto per il byte basso come parte di and edi, 0x01010101e sa che il chiamante deve ignorare i byte alti.

Oppure, se viene passato un bool come terzo argomento, forse un chiamante che ottimizza la dimensione del codice lo carica con mov dl, [mem] invece di movzx edx, [mem]risparmiando 1 byte al costo di una falsa dipendenza dal vecchio valore di RDX (o di un altro effetto di registro parziale, a seconda del modello di CPU). Oppure per il primo argomento, mov dil, byte [r10] invece di movzx edi, byte [r10]perché entrambi richiedono comunque un prefisso REX.

Questo è il motivo per cui clang emette movzx eax, dil in Serializeinvece di sub eax, edi. (Per gli argomenti interi, clang viola questa regola ABI, dipendendo invece dal comportamento non documentato di gcc e clang per estendere a zero o a segno gli interi stretti a 32 bit. È necessaria un'estensione di segno o di zero quando si aggiunge un offset di 32 bit a un puntatore per l'ABI x86-64?
Mi ha quindi interessato vedere che non fa la stessa cosa con bool.)


Nota 2: Dopo la ramificazione, si avrebbe solo un 4 byte mov-immediato, o un archivio di 4 byte + 1 byte. La lunghezza è implicita nelle larghezze di memorizzazione + gli offset.

D'altra parte, glibc memcpy farà due caricamenti/memorizzazioni a 4 byte con una sovrapposizione che dipende dalla lunghezza, quindi questo finisce per rendere l'intera cosa libera da ramificazioni condizionali sul booleano. Si veda l'esempio L(between_4_7): in memcpy/memmove di glibc. O almeno, si può procedere allo stesso modo per entrambi i booleani nella ramificazione di memcpy per selezionare una dimensione di chunk.

Se si esegue l'inlining, si possono usare 2x mov-immediato + cmov e un offset condizionale, oppure si possono lasciare i dati della stringa in memoria.

Oppure, se si esegue la messa a punto per Intel Ice Lake (con la funzione Fast Short REP MOV), si può utilizzare un'opzione rep movsb potrebbe essere ottimale. glibc memcpy potrebbe iniziare a usare rep movsb per le piccole dimensioni sulle CPU con questa caratteristica, risparmiando molte ramificazioni.


Strumenti per rilevare UB e l'uso di valori non inizializzati

In gcc e clang, si può compilare con -fsanitize=undefined per aggiungere una strumentazione a tempo di esecuzione che avvisa o dà errore su UB che si verificano a tempo di esecuzione. Questo però non cattura le variabili unitizzate. (Perché non aumenta le dimensioni dei tipi per fare spazio a un bit "non inizializzato").

Vedere https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

Per trovare l'uso di dati non inizializzati, ci sono Address Sanitizer e Memory Sanitizer in clang/LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer mostra esempi di clang -fsanitize=memory -fPIE -pie per rilevare letture di memoria non inizializzate. Potrebbe funzionare meglio se si compila senza in modo che tutte le letture di variabili finiscano per essere effettivamente caricate dalla memoria nell'asm. Mostrano che viene usato a -O2 in un caso in cui il caricamento non sarebbe stato ottimizzato. Non l'ho provato personalmente. (In alcuni casi, ad esempio non inizializzando un accumulatore prima di sommare un array, clang -O3 emetterà codice che somma in un registro vettoriale mai inizializzato. Quindi, con l'ottimizzazione, si può avere un caso in cui non c'è lettura di memoria associata all'UB. Ma -fsanitize=memory cambia l'asm generato e potrebbe portare a un controllo di questo tipo).

Tollera la copia di memoria non inizializzata e anche semplici operazioni logiche e aritmetiche con essa. In generale, MemorySanitizer tiene traccia in modo silenzioso della diffusione di dati non inizializzati in memoria e segnala un avviso quando viene preso (o non preso) un ramo di codice che dipende da un valore non inizializzato.

MemorySanitizer implementa un sottoinsieme di funzionalità presenti in Valgrind (strumento Memcheck).

Dovrebbe funzionare per questo caso, perché la chiamata a glibc memcpy con un length calcolata da memoria non inizializzata risulterà (all'interno della libreria) in un ramo basato su length. Se fosse stata inlineata una versione completamente priva di ramificazioni che usava solo cmov, l'indicizzazione e due magazzini, forse non avrebbe funzionato.

Valgrind memcheck cerca anche questo tipo di problema, anche se non si lamenta se il programma copia semplicemente dati non inizializzati. Ma dice che rileverà quando un "salto o spostamento condizionale dipende da valori non inizializzati", per cercare di individuare qualsiasi comportamento visibile dall'esterno che dipenda da dati non inizializzati.

Forse l'idea di non segnalare solo un caricamento è che le strutture possono avere un padding, e copiare l'intera struttura (compreso il padding) con un ampio vettore di caricamento/salvataggio non è un errore anche se i singoli membri sono stati scritti solo uno alla volta. A livello di asm, l'informazione su ciò che era padding e ciò che è effettivamente parte del valore è andata persa.

Il compilatore può assumere che un valore booleano passato come argomento sia un valore booleano valido (cioè un valore che è stato inizializzato o convertito in true o false). Il valore true non deve essere necessariamente uguale all'intero 1. In effetti, ci possono essere diverse rappresentazioni di true e false -- ma il parametro deve essere una rappresentazione valida di uno di questi due valori, dove "rappresentazione valida" è definita dall'implementazione.

Quindi, se non si riesce a inizializzare un parametro boolo se si riesce a sovrascriverlo attraverso un puntatore di tipo diverso, le ipotesi del compilatore saranno sbagliate e si avrà un comportamento non definito. Siete stati avvertiti:

50) L'uso di un valore bool in modi descritti da questo Standard Internazionale come "non definiti", come ad esempio esaminando il valore di un oggetto automatico non inizializzato, potrebbe far sì che esso si comporti come se non fosse né vero né falso. (Nota a piè di pagina al paragrafo 6 del §6.9.1, Tipi fondamentali)

La funzione in sé è corretta, ma nel vostro programma di test, l'istruzione che chiama la funzione causa un comportamento non definito utilizzando il valore di una variabile non inizializzata.

Il bug si trova nella funzione chiamante e potrebbe essere individuato tramite la revisione del codice o l'analisi statica della funzione chiamante. Utilizzando il link di Compiler Explorer, il compilatore gcc 8.2 rileva il bug. (Forse si potrebbe fare una segnalazione di bug contro clang che non trova il problema).

Comportamento non definito significa qualsiasi cosa può accadere, compreso l'arresto del programma poche righe dopo l'evento che ha innescato il comportamento non definito.

NB. La risposta a "Il comportamento non definito può causare _____?" è sempre "Sì". Questa è letteralmente la definizione di comportamento non definito.

Ci piacerebbe se potessi raccomandare questa affermazione se ti è stata utile.



Utilizzate il nostro motore di ricerca

Ricerca
Generic filters

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.