gcc-10.0.1 Segfault specifico


23

Ho un pacchetto R con codice compilato in C che è stato relativamente stabile per un po 'ed è spesso testato su un'ampia varietà di piattaforme e compilatori (windows / osx / debian / fedora gcc / clang).

Più recentemente è stata aggiunta una nuova piattaforma per testare nuovamente il pacchetto:

Logs from checks with gcc trunk aka 10.0.1 compiled from source
on Fedora 30. (For some archived packages, 10.0.0.)

x86_64 Fedora 30 Linux

FFLAGS="-g -O2 -mtune=native -Wall -fallow-argument-mismatch"
CFLAGS="-g -O2 -Wall -pedantic -mtune=native -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection"
CXXFLAGS="-g -O2 -Wall -pedantic -mtune=native -Wno-ignored-attributes -Wno-deprecated-declarations -Wno-parentheses -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection"

A quel punto il codice compilato ha subito iniziato a segfaulting seguendo queste linee:

 *** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'

Sono stato in grado di riprodurre il segfault in modo coerente utilizzando il rocker/r-basecontenitore docker gcc-10.0.1con livello di ottimizzazione -O2. L'esecuzione di un'ottimizzazione inferiore elimina il problema. Eseguendo qualsiasi altro set-up, incluso sotto valgrind (sia -O0 che -O2), UBSAN (gcc / clang), non mostra alcun problema. Sono anche ragionevolmente sicuro che questo abbia funzionato gcc-10.0.0, ma non ho i dati.

Ho eseguito la gcc-10.0.1 -O2versione con gdbe ho notato qualcosa che mi sembra strano:

codice gdb vs

Mentre si passa attraverso la sezione evidenziata, sembra che l'inizializzazione dei secondi elementi delle matrici sia saltata ( R_allocè un involucro attorno a mallocquel self garbage che si raccoglie quando si restituisce il controllo a R; il segfault avviene prima di tornare a R). Successivamente, il programma si arresta in modo anomalo quando si accede all'elemento non inizializzato (nella versione gcc.10.0.1 -O2).

Ho risolto questo problema inizializzando esplicitamente l'elemento in questione ovunque nel codice che alla fine ha portato all'utilizzo dell'elemento, ma in realtà avrebbe dovuto essere inizializzato su una stringa vuota, o almeno è quello che avrei ipotizzato.

Mi sto perdendo qualcosa di ovvio o sto facendo qualcosa di stupido? Entrambi sono ragionevolmente probabili poiché C è di gran lunga la mia seconda lingua . È strano che questo sia appena spuntato ora, e non riesco a capire cosa sta cercando di fare il compilatore.


UPDATE : Le istruzioni per riprodurre questo, anche se questo riprodurrà solo finché debian:testingcontenitore finestra mobile ha gcc-10a gcc-10.0.1. Inoltre, non eseguire questi comandi se non ti fidi di me .

Spiacente, questo non è un esempio riproducibile minimo.

docker pull rocker/r-base
docker run --rm -ti --security-opt seccomp=unconfined \
  rocker/r-base /bin/bash
apt-get update
apt-get install gcc-10 gdb
gcc-10 --version  # confirm 10.0.1
# gcc-10 (Debian 10-20200222-1) 10.0.1 20200222 (experimental) 
# [master revision 01af7e0a0c2:487fe13f218:e99b18cf7101f205bfdd9f0f29ed51caaec52779]

mkdir ~/.R
touch ~/.R/Makevars
echo "CC = gcc-10
CFLAGS = -g -O2 -Wall -pedantic -mtune=native -Werror=format-security -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong -fstack-clash-protection -fcf-protection
" >> ~/.R/Makevars

R -d gdb --vanilla

Poi nella console R, dopo la digitazione rundi ottenere gdbper eseguire il programma:

f.dl <- tempfile()
f.uz <- tempfile()

github.url <- 'https://github.com/brodieG/vetr/archive/v0.2.8.zip'

download.file(github.url, f.dl)
unzip(f.dl, exdir=f.uz)
install.packages(
  file.path(f.uz, 'vetr-0.2.8'), repos=NULL,
  INSTALL_opts="--install-tests", type='source'
)
# minimal set of commands to segfault
library(vetr)
alike(pairlist(a=1, b="character"), pairlist(a=1, b=letters))
alike(pairlist(1, "character"), pairlist(1, letters))
alike(NULL, 1:3)                  # not a wild card at top level
alike(list(NULL), list(1:3))      # but yes when nested
alike(list(NULL, NULL), list(list(list(1, 2, 3)), 1:25))
alike(list(NULL), list(1, 2))
alike(list(), list(1, 2))
alike(matrix(integer(), ncol=7), matrix(1:21, nrow=3))
alike(matrix(character(), nrow=3), matrix(1:21, nrow=3))
alike(
  matrix(integer(), ncol=3, dimnames=list(NULL, c("R", "G", "B"))),
  matrix(1:21, ncol=3, dimnames=list(NULL, c("R", "G", "B")))
)

# Adding tests from docs

mx.tpl <- matrix(
  integer(), ncol=3, dimnames=list(row.id=NULL, c("R", "G", "B"))
)
mx.cur <- matrix(
  sample(0:255, 12), ncol=3, dimnames=list(row.id=1:4, rgb=c("R", "G", "B"))
)
mx.cur2 <-
  matrix(sample(0:255, 12), ncol=3, dimnames=list(1:4, c("R", "G", "B")))

alike(mx.tpl, mx.cur2)

L'ispezione in gdb mostra abbastanza rapidamente (se ho capito bene) che CSR_strmlen_xsta provando ad accedere alla stringa che non è stata inizializzata.

AGGIORNAMENTO 2 : questa è una funzione altamente ricorsiva e, soprattutto, il bit di inizializzazione della stringa viene chiamato molte, molte volte. Questo è principalmente b / c. Ero pigro, abbiamo solo bisogno delle stringhe inizializzate per la volta in cui incontriamo qualcosa che vogliamo segnalare nella ricorsione, ma è stato più facile inizializzare ogni volta che è possibile incontrare qualcosa. Ne parlo perché ciò che vedrai in seguito mostra più inizializzazioni, ma solo una di esse (presumibilmente quella con indirizzo <0x1400000001>) viene utilizzata.

Non posso garantire che le cose che sto mostrando qui siano direttamente correlate all'elemento che ha causato il segfault (anche se è lo stesso indirizzo di accesso illegale), ma come ha chiesto @ nate-eldredge dimostra che l'elemento array non è inizializzato o appena prima del ritorno o subito dopo il ritorno nella funzione chiamante. Nota che la funzione di chiamata sta inizializzando 8 di questi e li mostro tutti, con tutti pieni di immondizia o memoria inaccessibile.

inserisci qui la descrizione dell'immagine

AGGIORNAMENTO 3 , smontaggio della funzione in questione:

Breakpoint 1, ALIKEC_res_strings_init () at alike.c:75
75    return res;
(gdb) p res.current[0]
$1 = 0x7ffff46a0aa5 "%s%s%s%s"
(gdb) p res.current[1]
$2 = 0x1400000001 <error: Cannot access memory at address 0x1400000001>
(gdb) disas /m ALIKEC_res_strings_init
Dump of assembler code for function ALIKEC_res_strings_init:
53  struct ALIKEC_res_strings ALIKEC_res_strings_init() {
   0x00007ffff4687fc0 <+0>: endbr64 

54    struct ALIKEC_res_strings res;

55  
56    res.target = (const char **) R_alloc(5, sizeof(const char *));
   0x00007ffff4687fc4 <+4>: push   %r12
   0x00007ffff4687fc6 <+6>: mov    $0x8,%esi
   0x00007ffff4687fcb <+11>:    mov    %rdi,%r12
   0x00007ffff4687fce <+14>:    push   %rbx
   0x00007ffff4687fcf <+15>:    mov    $0x5,%edi
   0x00007ffff4687fd4 <+20>:    sub    $0x8,%rsp
   0x00007ffff4687fd8 <+24>:    callq  0x7ffff4687180 <R_alloc@plt>
   0x00007ffff4687fdd <+29>:    mov    $0x8,%esi
   0x00007ffff4687fe2 <+34>:    mov    $0x5,%edi
   0x00007ffff4687fe7 <+39>:    mov    %rax,%rbx

57    res.current = (const char **) R_alloc(5, sizeof(const char *));
   0x00007ffff4687fea <+42>:    callq  0x7ffff4687180 <R_alloc@plt>

58  
59    res.target[0] = "%s%s%s%s";
   0x00007ffff4687fef <+47>:    lea    0x1764a(%rip),%rdx        # 0x7ffff469f640
   0x00007ffff4687ff6 <+54>:    lea    0x18aa8(%rip),%rcx        # 0x7ffff46a0aa5
   0x00007ffff4687ffd <+61>:    mov    %rcx,(%rbx)

60    res.target[1] = "";

61    res.target[2] = "";
   0x00007ffff4688000 <+64>:    mov    %rdx,0x10(%rbx)

62    res.target[3] = "";
   0x00007ffff4688004 <+68>:    mov    %rdx,0x18(%rbx)

63    res.target[4] = "";
   0x00007ffff4688008 <+72>:    mov    %rdx,0x20(%rbx)

64  
65    res.tar_pre = "be";

66  
67    res.current[0] = "%s%s%s%s";
   0x00007ffff468800c <+76>:    mov    %rax,0x8(%r12)
   0x00007ffff4688011 <+81>:    mov    %rcx,(%rax)

68    res.current[1] = "";

69    res.current[2] = "";
   0x00007ffff4688014 <+84>:    mov    %rdx,0x10(%rax)

70    res.current[3] = "";
   0x00007ffff4688018 <+88>:    mov    %rdx,0x18(%rax)

71    res.current[4] = "";
   0x00007ffff468801c <+92>:    mov    %rdx,0x20(%rax)

72  
73    res.cur_pre = "is";

74  
75    return res;
=> 0x00007ffff4688020 <+96>:    lea    0x14fe0(%rip),%rax        # 0x7ffff469d007
   0x00007ffff4688027 <+103>:   mov    %rax,0x10(%r12)
   0x00007ffff468802c <+108>:   lea    0x14fcd(%rip),%rax        # 0x7ffff469d000
   0x00007ffff4688033 <+115>:   mov    %rbx,(%r12)
   0x00007ffff4688037 <+119>:   mov    %rax,0x18(%r12)
   0x00007ffff468803c <+124>:   add    $0x8,%rsp
   0x00007ffff4688040 <+128>:   pop    %rbx
   0x00007ffff4688041 <+129>:   mov    %r12,%rax
   0x00007ffff4688044 <+132>:   pop    %r12
   0x00007ffff4688046 <+134>:   retq   
   0x00007ffff4688047:  nopw   0x0(%rax,%rax,1)

End of assembler dump.

AGGIORNAMENTO 4 :

Quindi, cercando di analizzare lo standard qui ci sono le parti che sembrano rilevanti ( bozza C11 ):

6.3.2.3 Conversioni Par7> Altri operandi> Puntatori

Un puntatore a un tipo di oggetto può essere convertito in un puntatore a un diverso tipo di oggetto. Se il puntatore risultante non è allineato correttamente 68) per il tipo di riferimento, il comportamento non è definito.
Altrimenti, quando viene riconvertito di nuovo, il risultato deve essere paragonato al puntatore originale. Quando un puntatore a un oggetto viene convertito in un puntatore a un tipo di carattere, il risultato punta al byte indirizzato più basso dell'oggetto. Incrementi successivi del risultato, fino alla dimensione dell'oggetto, restituiscono puntatori ai byte rimanenti dell'oggetto.

6.5 Espressioni Par6

Il tipo effettivo di un oggetto per un accesso al suo valore memorizzato è il tipo dichiarato dell'oggetto, se presente. 87) Se un valore è memorizzato in un oggetto senza un tipo dichiarato attraverso un valore che ha un tipo che non è un tipo di carattere, allora il tipo del valore diventa il tipo effettivo dell'oggetto per quell'accesso e per gli accessi successivi che non lo fanno modifica il valore memorizzato. Se un valore viene copiato in un oggetto senza tipo dichiarato utilizzando memcpy o memmove, oppure viene copiato come una matrice di tipo di carattere, il tipo effettivo dell'oggetto modificato per quell'accesso e per gli accessi successivi che non modificano il valore è il tipo effettivo dell'oggetto da cui viene copiato il valore, se ne ha uno. Per tutti gli altri accessi a un oggetto che non ha un tipo dichiarato, il tipo effettivo dell'oggetto è semplicemente il tipo del valore utilizzato per l'accesso.

87) Gli oggetti allocati non hanno un tipo dichiarato.

IIUC R_allocrestituisce un offset in un mallocblocco ed che è garantito per essere doubleallineato e la dimensione del blocco dopo l'offset è della dimensione richiesta (c'è anche allocazione prima dell'offset per dati specifici R). R_alloclancia quel puntatore (char *)su al ritorno.

Sezione 6.2.5 Par 29

Un puntatore a vuoto deve avere gli stessi requisiti di rappresentazione e allineamento di un puntatore a un tipo di carattere. 48) Allo stesso modo, i puntatori a versioni qualificate o non qualificate di tipi compatibili devono avere gli stessi requisiti di rappresentazione e allineamento. Tutti i puntatori ai tipi di struttura devono avere gli stessi requisiti di rappresentazione e allineamento reciproci.
Tutti i puntatori ai tipi di unione devono avere gli stessi requisiti di rappresentazione e allineamento reciproci.
I puntatori ad altri tipi non devono avere gli stessi requisiti di rappresentazione o allineamento.

48) Gli stessi requisiti di rappresentazione e allineamento hanno lo scopo di implicare l'interscambiabilità degli argomenti per le funzioni, restituire valori dalle funzioni e membri dei sindacati.

Quindi la domanda è "ci è permesso di rifondere il (char *)to (const char **)e scrivere come (const char **)". La mia lettura di quanto sopra è che fintanto che i puntatori sui sistemi in cui viene eseguito il codice hanno un allineamento compatibile con l' doubleallineamento, allora va bene.

Stiamo violando un "aliasing rigoroso"? vale a dire:

6.5 Par 7

Un oggetto deve avere il suo valore memorizzato accessibile solo da un'espressione lvalue che ha uno dei seguenti tipi: 88)

- un tipo compatibile con il tipo effettivo dell'oggetto ...

88) L'intento di questo elenco è quello di specificare le circostanze in cui un oggetto può essere o meno aliasato.

Quindi, quale dovrebbe essere il compilatore che dovrebbe pensare al tipo effettivo dell'oggetto indicato da res.target(o res.current)? Presumibilmente il tipo dichiarato (const char **), o è davvero ambiguo? Mi sembra che non sia in questo caso solo perché non esiste un altro "valore" nell'ambito che acceda allo stesso oggetto.

Devo ammettere che sto lottando intensamente per estrarre senso da queste sezioni dello standard.


Se non è già stato esaminato, può valere la pena guardare lo smontaggio per vedere esattamente cosa si sta facendo. E anche per confrontare lo smontaggio tra le versioni di gcc.
kaylum,

2
Non proverei a pasticciare con la versione trunk di GCC. È bello divertirsi con, ma si chiama trunk per un motivo. Sfortunatamente è quasi impossibile dire cosa c'è che non va senza (1) avere il tuo codice e la configurazione esatta (2) avere la stessa versione di GCC (3) sulla stessa architettura. Suggerirei di verificare se questo persiste quando 10.0.1 si sposta da trunk a stable.
Marco Bonelli,

1
Un altro commento: -mtune=nativeottimizza per la CPU particolare che la tua macchina ha. Questo sarà diverso per i diversi tester e potrebbe essere parte del problema. Se esegui la compilation con -vte dovresti essere in grado di vedere quale famiglia di cpu è sul tuo computer (ad esempio -mtune=skylakesul mio computer).
Nate Eldredge,

1
Ancora difficile da dire dalle esecuzioni di debug. Lo smontaggio dovrebbe essere conclusivo. Non è necessario estrarre nulla, basta trovare il file .o prodotto durante la compilazione del progetto e disassemblarlo. Puoi anche usare le disassembleistruzioni all'interno di gdb.
Nate Eldredge,

5
Ad ogni modo, congratulazioni, sei uno dei pochi rari il cui problema era in realtà un bug del compilatore.
Nate Eldredge,

Risposte:


22

Riepilogo: questo sembra essere un bug in gcc, relativo all'ottimizzazione delle stringhe. Di seguito è riportato un testcase autonomo. Inizialmente c'erano dei dubbi sul fatto che il codice sia corretto, ma penso che lo sia.

Ho segnalato il bug come PR 93982 . Una correzione proposta è stata impegnata ma non la risolve in tutti i casi, portando al follow-up PR 94015 ( link godbolt ).

Dovresti essere in grado di aggirare il bug compilando con il flag -fno-optimize-strlen.


Sono stato in grado di ridurre il tuo test case al seguente esempio minimo (anche su godbolt ):

struct a {
    const char ** target;
};

char* R_alloc(void);

struct a foo(void) {
    struct a res;
    res.target = (const char **) R_alloc();
    res.target[0] = "12345678";
    res.target[1] = "";
    res.target[2] = "";
    res.target[3] = "";
    res.target[4] = "";
    return res;
}

Con gcc trunk (gcc versione 10.0.1 20200225 (sperimentale)) e -O2(tutte le altre opzioni sono risultate non necessarie), l'assembly generato su amd64 è il seguente:

.LC0:
        .string "12345678"
.LC1:
        .string ""
foo:
        subq    $8, %rsp
        call    R_alloc
        movq    $.LC0, (%rax)
        movq    $.LC1, 16(%rax)
        movq    $.LC1, 24(%rax)
        movq    $.LC1, 32(%rax)
        addq    $8, %rsp
        ret

Quindi hai perfettamente ragione sul fatto che il compilatore non riesce a inizializzare res.target[1](nota la cospicua assenza di movq $.LC1, 8(%rax)).

È interessante giocare con il codice e vedere cosa influenza il "bug". Forse in modo significativo, cambiando il tipo di ritorno in R_allocper void *farlo scomparire e si ottiene un output "corretto" dell'assemblaggio. Forse in modo meno significativo ma più divertente, anche cambiare la stringa "12345678"per essere più lunga o più corta lo fa andare via.


Discussione precedente, ora risolta: il codice è apparentemente legale.

La domanda che ho è se il tuo codice è effettivamente legale. Il fatto che si prende il char *restituito da R_alloc()e lanci a const char **, e quindi memorizza un const char *sembra che potrebbe violare la regola rigorosa aliasing , come chare const char *non sono tipi compatibili. C'è un'eccezione che ti consente di accedere a qualsiasi oggetto come char(per implementare cose del genere memcpy), ma questo è il contrario, e come meglio lo capisco, non è permesso. Fa sì che il tuo codice produca un comportamento indefinito e quindi il compilatore può fare legalmente qualunque cosa voglia.

In questo caso, la correzione corretta sarebbe che R cambi il loro codice in modo che R_alloc()ritorni void *invece di char *. Quindi non ci sarebbero problemi di aliasing. Sfortunatamente, quel codice è al di fuori del tuo controllo e non mi è chiaro come puoi usare questa funzione senza violare il rigoroso aliasing. Una soluzione alternativa potrebbe essere quella di interporre una variabile temporanea, ad esempio void *tmp = R_alloc(); res.target = tmp;che risolve il problema nel caso di test, ma non sono ancora sicuro che sia legale.

Tuttavia, non sono sicuro di questa ipotesi "rigorosa aliasing", perché la compilazione con -fno-strict-aliasing, che per quanto ne so che si suppone faccia gcc permettere tali costrutti, non senza rendere il problema andare via!


Aggiornare. Provando alcune opzioni diverse, ho scoperto che uno -fno-optimize-strleno entrambi -fno-tree-forwpropgenererà il codice "corretto". Inoltre, l'utilizzo -O1 -foptimize-strlenrestituisce il codice errato (ma -O1 -ftree-forwpropnon lo fa).

Dopo un po 'di git bisectesercizio, l'errore sembra essere stato introdotto in commit 34fcf41e30ff56155e996f5e04 .


Aggiornamento 2. Ho provato a scavare un po 'nel sorgente gcc, solo per vedere cosa avrei potuto imparare. (Non pretendo di essere un esperto del compilatore!)

Sembra che il codice in tree-ssa-strlen.cabbia lo scopo di tenere traccia delle stringhe visualizzate nel programma. Per quanto posso dire, il bug è che guardando l'istruzione res.target[0] = "12345678";il compilatore confonde l' indirizzo della stringa letterale "12345678"con la stringa stessa. (Ciò sembra essere correlato a questo codice sospetto che è stato aggiunto nel summenzionato commit, dove se tenta di contare i byte di una "stringa" che in realtà è un indirizzo, guarda invece a cosa punta quell'indirizzo.)

Quindi pensa che l'istruzione res.target[0] = "12345678", invece di memorizzare l' indirizzo di "12345678"all'indirizzo res.target, stia memorizzando la stringa stessa a quell'indirizzo, come se fosse l'istruzione strcpy(res.target, "12345678"). Nota per quanto ci aspetta che ciò comporterebbe che il niling finale sia archiviato all'indirizzo res.target+8(in questa fase del compilatore, tutti gli offset sono in byte).

Ora, quando il compilatore guarda res.target[1] = "", tratta anche questo come se fosse strcpy(res.target+8, ""), l'8 proveniente dalla dimensione di a char *. Cioè, come se stesse semplicemente memorizzando un nul byte all'indirizzo res.target+8. Tuttavia, il compilatore "sa" che la precedente istruzione ha già memorizzato un nul byte proprio in quell'indirizzo! Pertanto, questa affermazione è "ridondante" e può essere scartata ( qui ).

Questo spiega perché la stringa deve contenere esattamente 8 caratteri per attivare il bug. (Anche se altri multipli di 8 possono anche innescare il bug in altre situazioni.)


È documentata la rifusione di FWIW in un diverso tipo di puntatore . Non so aliasing per sapere se va bene rifondare int*ma non farlo const char**.
BrodieG,

Se la mia comprensione dell'aliasing rigoroso è corretta, allora anche il cast di int *è illegale (o meglio, in realtà memorizzarlo intè illegale).
Nate Eldredge,

1
Questo non ha nulla a che fare con la rigida regola di aliasing. La regola di aliasing rigorosa riguarda l' accesso ai dati che hai già archiviato utilizzando un handle diverso. Dato che assegni solo qui, non tocca una regola di aliasing rigorosa. Il cast dei puntatori è valido quando entrambi i tipi di puntatori hanno gli stessi requisiti di allineamento, ma qui stai lanciando char*e lavorando su x86_64 ... Non vedo nessun UB qui, questo è un bug gcc.
KamilCuk

1
Sì e no, @KamilCuk. Nella terminologia dello standard, "accedere" include sia la lettura che la modifica del valore di un oggetto. La rigida regola di aliasing quindi parla di "memorizzazione". Non è limitato alle operazioni di rilettura. Ma per gli oggetti senza tipo dichiarato, ciò è motivato dal fatto che la scrittura su tale oggetto cambia automaticamente il suo tipo effettivo per corrispondere a ciò che è stato scritto. Gli oggetti senza un tipo dichiarato sono esattamente quelli allocati dinamicamente (indipendentemente dal tipo di puntatore con cui si accede), quindi in questo caso non vi è alcuna violazione SA.
John Bollinger,

2
Sì, @Nate, con quella definizione di R_alloc(), il programma è conforme, indipendentemente da quale unità di traduzione R_alloc()è definita. È il compilatore che non si conforma qui.
John Bollinger,
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.