Sì, ISO C ++ consente (ma non richiede) implementazioni per fare questa scelta.
Ma nota 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 un modo per aiutarti a trovare errori. (O perché è una DeathStation 9000. Essere rigorosamente conformi non è sufficiente affinché un'implementazione C ++ sia utile per qualsiasi scopo reale). Quindi ISO C ++ consentirebbe a un compilatore di creare un crash che si è bloccato (per motivi totalmente diversi) anche su un codice simile che legge un non inizializzato uint32_t
. Anche se è richiesto un tipo di layout fisso senza rappresentazioni trap.
È una domanda interessante su come funzionano le vere implementazioni, ma ricorda che anche se la risposta fosse diversa, il tuo codice sarebbe comunque pericoloso perché il C ++ moderno non è una versione portatile del linguaggio assembly.
Stai compilando per l' ABI System V x86-64 , che specifica che a bool
come una funzione arg in un registro è rappresentato dai bit-pattern false=0
etrue=1
negli 8 bit bassi del registro 1 . In memoria, bool
è un tipo a 1 byte che deve nuovamente avere un valore intero pari a 0 o 1.
(Un ABI è un insieme di scelte di implementazione su cui i compilatori per la stessa piattaforma concordano in modo che possano creare codice che chiama le reciproche funzioni, tra cui dimensioni dei tipi, regole di layout della struttura e convenzioni di chiamata.)
ISO C ++ non lo specifica, ma questa decisione ABI è diffusa perché rende la conversione bool-> int economica (solo zero-extension) . Non sono a conoscenza di ABI che non consentono al compilatore di assumere 0 o 1 per bool
, per qualsiasi architettura (non solo x86). Permette ottimizzazioni come !mybool
con il xor eax,1
capovolgere il bit basso: ogni possibile codice che può capovolgere un bit / intero / bool tra 0 e 1 in una singola istruzione CPU . O compilando a&&b
un AND bit per bit per i bool
tipi. Alcuni compilatori sfruttano effettivamente i valori booleani come compilatori a 8 bit. Le operazioni su di essi sono inefficienti? .
In generale, la regola as-if consente al compilatore di sfruttare le cose vere sulla piattaforma di destinazione per la compilazione , poiché il risultato finale sarà un codice eseguibile che implementa lo stesso comportamento visibile esternamente del sorgente C ++. (Con tutte le restrizioni che Undefined Behavior pone su ciò che è effettivamente "esternamente visibile": non con un debugger, ma da un altro thread in un programma C ++ ben formato / legale.)
Il compilatore è sicuramente consentito di trarre pieno vantaggio di una garanzia ABI nel suo codice-gen, e rendere il codice, come hai trovato che ottimizza strlen(whichString)
al
5U - boolValue
. (A proposito, questa ottimizzazione è un po 'intelligente, ma forse miope rispetto a branch e inline memcpy
come archivi di dati immediati 2. )
Oppure il compilatore avrebbe potuto creare una tabella di puntatori e indicizzarla con il valore intero di bool
, supponendo sempre che fosse 0 o 1. ( Questa possibilità è ciò che suggeriva la risposta di @ Barmar .)
Il tuo __attribute((noinline))
costruttore con l'ottimizzazione abilitata ha portato a clangare il caricamento di un byte dallo stack da utilizzare come uninitializedBool
. Ha fatto spazio per l'oggetto main
con push rax
(che è più piccolo e per vari motivi altrettanto efficiente sub rsp, 8
), quindi qualunque immondizia fosse in AL all'entrata main
è il valore per cui è stata usata uninitializedBool
. Questo è il motivo per cui in realtà hai ottenuto valori che non erano solo 0
.
5U - random garbage
può facilmente passare a un valore non firmato di grandi dimensioni, portando memcpy ad andare nella memoria non mappata. La destinazione è nella memoria statica, non nello stack, quindi non stai sovrascrivendo un indirizzo di ritorno o qualcosa del genere.
Altre implementazioni possono fare scelte diverse, ad esempio, false=0
e true=any non-zero value
. Quindi probabilmente clang non creerebbe il codice che si arresta in modo anomalo per questa specifica istanza di UB. (Ma sarebbe ancora permesso se lo volesse.) Non conosco implementazioni che scelgono qualcosa di diverso da ciò che fa x86-64 bool
, ma lo standard C ++ consente molte cose che nessuno fa o vorrebbe fare hardware simile alle attuali CPU.
ISO C ++ lascia non specificato ciò che troverai quando esamini o modifichi la rappresentazione dell'oggetto di abool
. (ad es. memcpy
ing bool
in into unsigned char
, cosa che puoi fare perché char*
può fare qualsiasi alias. Ed unsigned char
è garantito che non ha bit di riempimento, quindi lo standard C ++ ti consente formalmente di eseguire il dump delle rappresentazioni degli oggetti senza alcun UB. Puntatore-casting per copiare l'oggetto la rappresentazione è diversa dall'assegnazione char foo = my_bool
, ovviamente, quindi la booleanizzazione su 0 o 1 non accadrà e otterresti la rappresentazione dell'oggetto grezzo.)
Hai parzialmente "nascosto" l'UB su questo percorso di esecuzione dal compilatore connoinline
. 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 dei simboli. In secondo luogo, la definizione all'interno della class{}
definizione, quindi tutte le unità di traduzione devono avere la stessa definizione. Come con la inline
parola chiave.)
Quindi un compilatore potrebbe emettere solo una ret
o ud2
(istruzione illegale) come definizione per main
, perché il percorso di esecuzione che inizia in cima main
incontra inevitabilmente un comportamento indefinito. (Che il compilatore può vedere in fase di compilazione se ha deciso di seguire il percorso attraverso il costruttore non in linea.)
Qualsiasi programma che incontra UB è totalmente indefinito per la sua intera esistenza. Ma UB all'interno di una funzione o if()
ramo che non viene mai effettivamente eseguito non corrompe il resto del programma. In pratica, ciò significa che i compilatori possono decidere di emettere un'istruzione illegale, o un ret
, o di non emettere nulla e cadere nel blocco / funzione successivo, per l'intero blocco di base che può essere provato al momento della compilazione per contenere o portare a UB.
GCC e Clang, in pratica, non in realtà a volte emettono ud2
su UB, invece di nemmeno cercare di generare il codice per i percorsi di esecuzione che non hanno senso. O per casi come la fine di una non void
funzione, gcc a volte omette ret
un'istruzione. Se stavi pensando che "la mia funzione tornerà con qualunque spazzatura sia in RAX", ti sbagli gravemente. I compilatori C ++ moderni non trattano più il linguaggio come un linguaggio assembly portatile. Il tuo programma deve essere veramente C ++ valido, senza fare ipotesi su come una versione stand-alone non incorporata della tua funzione possa apparire in asm.
Un altro esempio divertente è: Perché l'accesso non allineato alla memoria di mmap a volte segfault su AMD64? . x86 non si guasta su numeri interi non allineati, giusto? Quindi perché un disallineamento uint16_t*
sarebbe un problema? Perché alignof(uint16_t) == 2
e violare tale presupposto ha portato a un segfault durante la vettorializzazione automatica con SSE2.
Vedi anche Quello che ogni programmatore C dovrebbe sapere sul comportamento indefinito n . 1/3, un articolo di uno sviluppatore di clang.
Punto chiave: se il compilatore ha notato l'UB in fase di compilazione, potrebbe "interrompere" (emettere un asm sorprendente) il percorso attraverso il codice che causa l'UB anche se indirizzato a un ABI in cui qualsiasi bit-pattern è una rappresentazione di oggetto valida per bool
.
Aspettati una totale ostilità nei confronti di molti errori da parte del programmatore, in particolare le cose di cui i moderni compilatori avvertono. Questo è il motivo per cui è necessario utilizzare -Wall
e correggere gli avvisi. Il C ++ non è un linguaggio intuitivo e qualcosa in C ++ può non essere sicuro anche se sarebbe sicuro per quanto riguarda il target per il quale si sta compilando. (ad esempio, l'overflow firmato è UB in C ++ e i compilatori supporranno che non accada, anche durante la compilazione per il complemento x86 di 2, a meno che non lo si usi clang/gcc -fwrapv
.)
L'UB visibile in fase di compilazione è sempre pericoloso, ed è davvero difficile essere certi (con l'ottimizzazione del tempo di collegamento) che hai davvero nascosto UB dal compilatore e puoi quindi ragionare sul tipo di asm che genererà.
Non essere troppo drammatico; spesso i compilatori ti permettono di cavartela con alcune cose ed emettere codice come ti aspetti anche quando qualcosa è UB. Ma forse sarà un problema in futuro se gli sviluppatori del compilatore implementano qualche ottimizzazione che ottiene maggiori informazioni sugli intervalli di valori (ad esempio che una variabile non è negativa, forse permettendole di ottimizzare l'estensione del segno per liberare l'estensione zero su x86- 64). Ad esempio, nell'attuale 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 sul target del complemento di questo 2 e a
non può essere superiore a quello.)
Quindi gcc / clang al momento non tornano indietro per ricavare informazioni sull'intervallo per gli input di un calcolo, solo sui risultati basati sul presupposto di un overflow non firmato: esempio su Godbolt . Non so se questa ottimizzazione è intenzionalmente "mancata" in nome della facilità d'uso o cosa.
Si noti inoltre che le implementazioni (ovvero i compilatori) possono definire comportamenti che ISO C ++ lascia indefiniti . Ad esempio, tutti i compilatori che supportano i valori intrinseci di Intel (come _mm_add_ps(__m128, __m128)
per la vettorializzazione manuale SIMD) devono consentire la formazione di puntatori mal allineati, che è UB in C ++ anche se non li si dereferenziano. __m128i _mm_loadu_si128(const __m128i *)
esegue carichi non allineati prendendo un __m128i*
argomento non allineato , non un void*
o char*
. `Reinterpret_cast` tra puntatore vettoriale hardware e tipo corrispondente è un comportamento indefinito?
GNU C / C ++ definisce anche il comportamento dello spostamento a sinistra di un numero con segno negativo (anche senza -fwrapv
), separatamente dalle normali regole UB con overflow firmato. ( Questo è UB in ISO C ++ , mentre i turni giusti dei numeri con segno sono definiti dall'implementazione (logica vs. aritmetica); le implementazioni di buona qualità scelgono l'aritmetica su HW che ha turni a destra aritmetici, ma ISO C ++ non specifica). Questo è documentato nella sezione Integer del manuale GCC , insieme alla definizione del comportamento definito dall'implementazione che gli standard C richiedono implementazioni per definire in un modo o nell'altro.
Ci sono sicuramente problemi di qualità dell'implementazione che interessano agli sviluppatori di compilatori; in genere non stanno cercando di creare compilatori intenzionalmente ostili, ma approfittare di tutte le buche UB in C ++ (tranne quelle che scelgono di definire) per ottimizzare meglio può essere quasi indistinguibile a volte.
Nota 1 : i 56 bit superiori possono essere spazzatura che la chiamata deve ignorare, come al solito per tipi più stretti di un registro.
( Altri ABI fanno fare scelte diverse qui . Alcuni non richiedono tipi interi strette per essere zero oppure registrati esteso per riempire un registro quando passato a o restituiti dalle funzioni, come MIPS64 e PowerPC64. Vedere l'ultima sezione di questa risposta x86-64 che confronta rispetto a quei precedenti ISA .)
Ad 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 &1
perché lo ha già fatto nel byte basso come parte di and edi, 0x01010101
, e sa che è necessario il call per ignorare i byte alti.
O se un bool viene passato come terzo argomento, forse un chiamante che ottimizza per la dimensione del codice lo carica mov dl, [mem]
invece di movzx edx, [mem]
, risparmiando 1 byte al costo di una falsa dipendenza dal vecchio valore di RDX (o altro effetto di registro parziale, a seconda sul modello di CPU). O per il primo argomento, mov dil, byte [r10]
invece di movzx edi, byte [r10]
, perché entrambi richiedono comunque un prefisso REX.
È per questo che emette clang movzx eax, dil
in Serialize
, invece di sub eax, edi
. (Per gli argomenti integer, clang viola questa regola ABI, invece a seconda del comportamento non documentato di gcc e clang a zero stretti o estesi a segno zero o estendi il segno a 32 bit. È necessario un segno o un'estensione zero quando si aggiunge un offset a 32 bit a un puntatore per l'ABI x86-64?
Quindi ero interessato a vedere che non fa la stessa cosa per bool
.)
Nota 2: dopo la diramazione, avresti solo un mov
archivio di 4 byte o un archivio di 4 byte + 1 byte. La lunghezza è implicita nelle larghezze negozio + offset.
OTOH, glibc memcpy eseguirà due carichi / archivi a 4 byte con una sovrapposizione che dipende dalla lunghezza, quindi questo alla fine rende il tutto libero da rami condizionali sul booleano. Vedi il L(between_4_7):
blocco in memcpy / memmove di glibc. O almeno, vai allo stesso modo per entrambi i valori booleani nella ramificazione di memcpy per selezionare una dimensione del blocco.
Se in linea, è possibile utilizzare 2x mov
-immediate + cmov
e un offset condizionale, oppure è possibile lasciare in memoria i dati della stringa.
O se l'ottimizzazione per Intel Ice Lake ( con la funzione MOV veloce REP MOV ), un effettivo rep movsb
potrebbe essere ottimale. glibc memcpy
potrebbe iniziare a utilizzarlo rep movsb
per CPU di piccole dimensioni con quella funzione, risparmiando molte ramificazioni.
Strumenti per rilevare UB e utilizzo di valori non inizializzati
In gcc e clang, è possibile compilare -fsanitize=undefined
per aggiungere la strumentazione di runtime che avviserà o eseguirà un errore su UB che si verifica in fase di esecuzione. Tuttavia, ciò non catturerà variabili unitarie. (Perché non aumenta le dimensioni del tipo per fare spazio a un bit "non inizializzato").
Vedi https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
Per trovare l'utilizzo di dati non inizializzati, c'è un disinfettante per indirizzi e un disinfettante in memoria in clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer mostra esempi di clang -fsanitize=memory -fPIE -pie
rilevamento di letture di memoria non inizializzate. Potrebbe funzionare meglio se si compila senza ottimizzazione, quindi tutte le letture delle variabili finiscono per essere effettivamente caricate dalla memoria nell'asm. Mostrano che viene utilizzato -O2
in un caso in cui il carico non si ottimizzerebbe. Non l'ho provato da solo. (In alcuni casi, ad esempio non inizializzando un accumulatore prima di sommare un array, clang -O3 emetterà un codice che si somma in un registro vettoriale che non ha mai inizializzato. Quindi, con l'ottimizzazione, è possibile avere un caso in cui non è presente alcuna memoria associata all'UB . Ma-fsanitize=memory
cambia l'asm generato e potrebbe comportare un controllo per questo.)
Tollera la copia di memoria non inizializzata e anche semplici operazioni logiche e aritmetiche con essa. In generale, MemorySanitizer tiene traccia silenziosa della diffusione dei dati non inizializzati in memoria e segnala un avviso quando viene acquisito (o non acquisito) un ramo di codice in base a un valore non inizializzato.
MemorySanitizer implementa un sottoinsieme di funzionalità presenti in Valgrind (strumento Memcheck).
Dovrebbe funzionare in questo caso perché la chiamata a glibc memcpy
con una length
memoria non inizializzata calcolata (all'interno della libreria) si tradurrà in un ramo basato su length
. Se avesse incorporato una versione completamente priva di diramazioni che ha appena utilizzato cmov
, indicizzato e due negozi, potrebbe non aver funzionato.
Valgrindmemcheck
cercherà anche questo tipo di problema, non lamentandosi di nuovo se il programma copia semplicemente dati non inizializzati. Ma dice che rileverà quando un "salto o spostamento condizionato dipende da valori non inizializzati", per cercare di catturare qualsiasi comportamento visibile esternamente che dipende da dati non inizializzati.
Forse l'idea dietro a non contrassegnare solo un carico è che le strutture possono avere il riempimento, e copiare l'intera struttura (incluso il riempimento) con un carico / archivio vettoriale ampio non è un errore anche se i singoli membri sono stati scritti solo uno alla volta. A livello di asm, le informazioni su ciò che era padding e ciò che è effettivamente parte del valore sono state perse.