Ci sono state molte ipotesi (leggermente o del tutto) sbagliate nei commenti su alcuni dettagli / background per questo.
Stai guardando l'implementazione ottimizzata del fallback C di glibc. (Per gli ISA che non hanno un'implementazione asm scritta a mano) . O una vecchia versione di quel codice, che è ancora nell'albero dei sorgenti di glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html è un browser di codice basato sull'attuale albero glibc git. Apparentemente è ancora usato da alcuni target glibc tradizionali, incluso MIPS. (Grazie @zwol).
Su ISA popolari come x86 e ARM, glibc usa l'asm scritto a mano
Quindi l'incentivo a cambiare qualcosa su questo codice è inferiore a quanto si pensi.
Questo codice bithack ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) non è ciò che funziona effettivamente sul tuo server / desktop / laptop / smartphone. È meglio di un loop ingenuo byte alla volta, ma anche questo bithack è piuttosto male rispetto all'asm efficiente per le CPU moderne (in particolare x86 in cui AVX2 SIMD consente di controllare 32 byte con un paio di istruzioni, consentendo da 32 a 64 byte per orologio ciclo nel ciclo principale se i dati sono caldi nella cache L1d su CPU moderne con carico vettoriale 2 / clock e throughput ALU, ovvero per stringhe di medie dimensioni in cui l'overhead di avvio non domina.)
glibc usa trucchi di collegamento dinamico per risolvere strlen
una versione ottimale per la tua CPU, quindi anche in x86 c'è una versione SSE2 (vettori a 16 byte, linea di base per x86-64) e una versione AVX2 (vettori a 32 byte).
x86 ha un efficiente trasferimento di dati tra registri vettoriali e per scopi generici, il che lo rende in modo univoco (?) utile per l'utilizzo di SIMD per velocizzare le funzioni su stringhe a lunghezza implicita in cui il controllo del loop dipende dai dati. pcmpeqb
/ pmovmskb
rende possibile testare 16 byte separati alla volta.
glibc ha una versione di AArch64 come quella che utilizza AdvSIMD e una versione per CPU AArch64 in cui i registri vector-> GP bloccano la pipeline, quindi utilizza effettivamente questo bithack . Ma utilizza zero-conteggio iniziale per trovare il byte all'interno del registro una volta ottenuto un hit e sfrutta gli efficienti accessi non allineati di AArch64 dopo aver verificato l'attraversamento della pagina.
Anche correlato: perché questo codice è 6,5 volte più lento con le ottimizzazioni abilitate? ha qualche dettaglio in più su cosa è veloce o lento in x86 asm strlen
con un buffer di grandi dimensioni e una semplice implementazione asm che potrebbe essere utile per gcc per sapere come inline. (Alcune versioni di gcc sono inconsapevolmente in linea, il rep scasb
che è molto lento, o un bithack a 4 byte alla volta come questo. Quindi la ricetta inline strlen di GCC deve essere aggiornata o disabilitata.)
Asm non ha "comportamenti indefiniti" in stile C ; è sicuro accedere ai byte nella memoria come preferisci e un carico allineato che include tutti i byte validi non può essere difettoso. La protezione della memoria si verifica con granularità della pagina allineata; gli accessi allineati sono più stretti di così non possono oltrepassare un limite di pagina. È sicuro leggere oltre la fine di un buffer all'interno della stessa pagina su x86 e x64? Lo stesso ragionamento si applica al codice macchina che questo hack C consente ai compilatori di creare per un'implementazione indipendente non in linea di questa funzione.
Quando un compilatore emette un codice per chiamare una funzione non inline sconosciuta, deve presumere che la funzione modifichi qualsiasi / tutte le variabili globali e qualsiasi memoria a cui potrebbe avere un puntatore. cioè tutto tranne i locali che non hanno avuto il loro indirizzo di escape devono essere sincronizzati in memoria durante la chiamata. Questo vale per le funzioni scritte in asm, ovviamente, ma anche per le funzioni di libreria. Se non si abilita l'ottimizzazione del tempo di collegamento, si applica anche a unità di traduzione separate (file di origine).
Perché questo è sicuro come parte di glibc ma non altrimenti.
Il fattore più importante è che questo strlen
non può essere integrato in nient'altro. Non è sicuro per quello; contiene UB strettamente aliasing (lettura dei char
dati tramite un unsigned long*
). char*
è consentito alias qualsiasi altra cosa, ma non è vero il contrario .
Questa è una funzione di libreria per una libreria compilata in anticipo (glibc). Non verrà integrato con l'ottimizzazione del tempo di collegamento nei chiamanti. Ciò significa che deve solo compilare un codice macchina sicuro per una versione autonoma di strlen
. Non deve essere portatile / sicuro C.
La libreria GNU C deve solo compilare con GCC. Apparentemente non è supportato per compilarlo con clang o ICC, anche se supportano le estensioni GNU. GCC è un compilatore in anticipo che trasforma un file sorgente C in un file oggetto di codice macchina. Non un interprete, quindi a meno che non sia in linea in fase di compilazione, i byte in memoria sono solo byte in memoria. cioè UB con alias rigoroso non è pericoloso quando gli accessi con tipi diversi avvengono in funzioni diverse che non si allineano l'una con l'altra.
Ricorda che strlen
il comportamento è definito dallo standard ISO C. Il nome di tale funzione è specificamente parte dell'implementazione. Compilatori come GCC trattano persino il nome come una funzione integrata a meno che non lo si usi -fno-builtin-strlen
, quindi strlen("foo")
può essere una costante di tempo di compilazione 3
. La definizione nella libreria viene usata solo quando gcc decide di emettere effettivamente una chiamata invece di incorporare la propria ricetta o qualcosa del genere.
Quando UB non è visibile al compilatore al momento della compilazione, si ottiene un codice macchina sano di mente. Il codice macchina deve funzionare per il caso no-UB e, anche se lo volessi , non c'è modo per l'asm di rilevare quali tipi ha usato il chiamante per mettere i dati nella memoria puntata.
Glibc è compilato in una libreria statica o dinamica indipendente che non può essere integrata con l'ottimizzazione del tempo di collegamento. Gli script di compilazione di glibc non creano librerie statiche "fat" contenenti codice macchina + gcc GIMPLE rappresentazione interna per l'ottimizzazione del tempo di collegamento durante l'inserimento in un programma. (ovvero libc.a
non parteciperà -flto
all'ottimizzazione del tempo di collegamento nel programma principale). Costruire glibc in questo modo sarebbe potenzialmente pericoloso per gli obiettivi che lo utilizzano effettivamente.c
.
In effetti, come commenta @zwol, LTO non può essere usato quando si costruisce glibc stesso , a causa di un codice "fragile" come questo che potrebbe rompersi se fosse possibile allineare tra i file sorgente glibc. (Ci sono alcuni usi interni di strlen
, ad esempio forse come parte printf
dell'implementazione)
Questo strlen
fa alcune ipotesi:
CHAR_BIT
è un multiplo di 8 . Vero su tutti i sistemi GNU. POSIX 2001 garantisce persino CHAR_BIT == 8
. (Questo sembra sicuro per i sistemi con CHAR_BIT= 16
o 32
, come alcuni DSP; il ciclo prologo non allineato eseguirà sempre 0 iterazioni se sizeof(long) = sizeof(char) = 1
ogni puntatore è sempre allineato ed p & sizeof(long)-1
è sempre zero.) Ma se hai un set di caratteri non ASCII dove i caratteri sono 9 o largo 12 bit, 0x8080...
è il modello sbagliato.
- (forse)
unsigned long
è 4 o 8 byte. O forse funzionerebbe effettivamente per qualsiasi dimensione unsigned long
fino a 8, e usa un assert()
per verificarlo.
Quei due non sono possibili UB, sono solo non portabilità ad alcune implementazioni C. Questo codice fa parte (o faceva parte ) dell'implementazione C su piattaforme in cui funziona, quindi va bene.
Il prossimo presupposto è il potenziale UB:
- Un carico allineato che contiene byte validi non può essere difettoso ed è sicuro finché si ignorano i byte all'esterno dell'oggetto che si desidera effettivamente. (Vero in asm su tutti i sistemi GNU e su tutte le CPU normali perché la protezione della memoria avviene con granularità di pagine allineate. È sicuro leggere oltre la fine di un buffer all'interno della stessa pagina su x86 e x64? Sicuro in C quando l'UB non è visibile al momento della compilazione. Senza inline, questo è il caso qui. Il compilatore non può provare che leggere oltre il primo
0
è UB; potrebbe essere un char[]
array C contenente {1,2,0,3}
ad esempio)
Quest'ultimo punto è ciò che rende sicuro leggere qui oltre la fine di un oggetto C. Questo è praticamente sicuro anche quando ci si allinea con i compilatori attuali perché penso che al momento non trattino che implicare un percorso di esecuzione sia irraggiungibile. Ma comunque, il rigoroso aliasing è già uno showtopper se mai lo lasci in linea.
Quindi avresti problemi come la vecchia memcpy
macro CPP non sicura del kernel Linux che utilizzava il puntatore-casting unsigned long
( gcc, aliasing rigoroso e storie horror ).
Questo strlen
risale all'epoca in cui si poteva cavarsela con cose del genere in generale ; era abbastanza sicuro senza il "solo quando non inline" prima di GCC3.
L'UB che è visibile solo guardando oltre i confini di chiamata / retata non può farci del male. (es. chiamando questo su a char buf[]
invece che su un array di unsigned long[]
cast a a const char*
). Una volta che il codice macchina è impostato su pietra, si tratta solo di byte in memoria. Una chiamata di funzione non in linea deve presumere che il chiamato legge qualsiasi / tutta la memoria.
Scrivere questo in modo sicuro, senza UB alias rigoroso
L' attributo GCC typemay_alias
fornisce a un tipo lo stesso alias-qualunque trattamento di char*
. (Suggerito da @KonradBorowsk). Le intestazioni GCC attualmente lo usano per tipi vettoriali SIMD x86 come in questo __m128i
modo puoi sempre farlo in sicurezza _mm_loadu_si128( (__m128i*)foo )
. (Vedi `reinterpret_cast`ing tra il puntatore vettoriale hardware e il tipo corrispondente è un comportamento indefinito? Per maggiori dettagli su cosa questo significhi e non significhi.)
strlen(const char *char_ptr)
{
typedef unsigned long __attribute__((may_alias)) aliasing_ulong;
aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
for (;;) {
unsigned long ulong = *longword_ptr++; // can safely alias anything
...
}
}
Puoi anche usare aligned(1)
per esprimere un tipo con alignof(T) = 1
.
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;
È un modo portatile per esprimere un carico di aliasing in ISOmemcpy
, che i compilatori moderni sanno come incorporare come una singola istruzione di carico. per esempio
unsigned long longword;
memcpy(&longword, char_ptr, sizeof(longword));
char_ptr += sizeof(longword);
Questo funziona anche per carichi non allineati perché memcpy
funziona come se fosse char
un accesso immediato. Ma in pratica i compilatori moderni comprendono memcpy
molto bene.
Il pericolo qui è che se GCC non sa per certo che char_ptr
è allineato a parole, non lo incorporerà su alcune piattaforme che potrebbero non supportare carichi non allineati in asm. ad es. MIPS prima di MIPS64r6 o ARM precedente. Se hai una vera chiamata di funzione memcpy
solo per caricare una parola (e lasciarla in un'altra memoria), sarebbe un disastro. A volte GCC può vedere quando il codice allinea un puntatore. O dopo il ciclo char-at-a-time che raggiunge un limite ulong che potresti usare
p = __builtin_assume_aligned(p, sizeof(unsigned long));
Questo non evita il possibile UB read-past-the-object, ma con l'attuale GCC non è pericoloso nella pratica.
Perché è necessaria una sorgente C ottimizzata a mano: i compilatori attuali non sono abbastanza buoni
L'asm ottimizzato a mano può essere ancora migliore quando si desidera l'ultimo calo delle prestazioni per una funzione di libreria standard ampiamente utilizzata. Soprattutto per qualcosa di simile memcpy
, ma anche strlen
. In questo caso non sarebbe molto più semplice usare C con intrinseci x86 per sfruttare SSE2.
Ma qui stiamo solo parlando di una versione ingenua vs. bithack C senza funzionalità specifiche ISA.
(Penso che possiamo prenderlo come un dato che strlen
è ampiamente usato che è importante farlo funzionare il più velocemente possibile. Quindi la domanda diventa se possiamo ottenere un codice macchina efficiente da una fonte più semplice. No, non possiamo.)
Il GCC e il clang corrente non sono in grado di auto-vettorizzare i loop in cui il conteggio delle iterazioni non è noto prima della prima iterazione . (ad esempio, deve essere possibile verificare se il ciclo eseguirà almeno 16 iterazioni prima di eseguire la prima iterazione.) Ad esempio, è possibile memcpy autovectorizing (buffer di lunghezza esplicita) ma non strcpy o strlen (stringa di lunghezza implicita), data corrente compilatori.
Ciò include i loop di ricerca o qualsiasi altro loop con if()break
un contatore dipendente dai dati .
ICC (il compilatore di Intel per x86) può auto-vettorializzare alcuni cicli di ricerca, ma fa comunque solo ingenui byte per volta per una C semplice / ingenua strlen
come usa la libc di OpenBSD. ( Godbolt ). (Dalla risposta di @ Peske ).
Una libc ottimizzata a mano strlen
è necessaria per le prestazioni con i compilatori correnti . Andare 1 byte alla volta (con lo srotolamento di forse 2 byte per ciclo su CPU superscalari estese) è patetico quando la memoria principale può tenere il passo con circa 8 byte per ciclo e la cache L1d può fornire da 16 a 64 per ciclo. (2x carichi a 32 byte per ciclo su moderne CPU x86 tradizionali da Haswell e Ryzen. Senza contare AVX512 che può ridurre la velocità di clock solo per l'utilizzo di vettori a 512 bit; motivo per cui glibc probabilmente non ha fretta di aggiungere una versione AVX512 . Sebbene con vettori a 256 bit, AVX512VL + BW Comparazione mascheramento in una maschera e ktest
o kortest
potrebbe rendere strlen
più amichevole HyperThreading riducendone i UOP / iterazione.)
Sto includendo non-x86 qui, questo è il "16 byte". ad esempio, la maggior parte delle CPU AArch64 può fare almeno questo, credo, e sicuramente qualcosa di più. E alcuni hanno una velocità di esecuzione sufficiente per strlen
tenere il passo con quella larghezza di banda di carico.
Naturalmente i programmi che funzionano con stringhe di grandi dimensioni dovrebbero in genere tenere traccia delle lunghezze per evitare di dover ripetere la ricerca della lunghezza delle stringhe C di lunghezza implicita molto spesso. Ma le prestazioni di breve o media durata beneficiano ancora di implementazioni scritte a mano, e sono sicuro che alcuni programmi finiscono per usare stringhe su stringhe di media lunghezza.