Eu tenho um pacote R com código compilado C que é relativamente estável por um bom tempo e é frequentemente testado em uma ampla variedade de plataformas e compiladores (windows / osx / debian / fedora gcc / clang).
Mais recentemente, uma nova plataforma foi adicionada para testar o pacote novamente:
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"
Nesse ponto, o código compilado imediatamente iniciou o segfaulting ao longo destas linhas:
*** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'
Consegui reproduzir o segfault de forma consistente usando o rocker/r-base
contêiner do docker gcc-10.0.1
com nível de otimização -O2
. A execução de uma otimização mais baixa elimina o problema. A execução de qualquer outra configuração, inclusive sob valgrind (-O0 e -O2), UBSAN (gcc / clang), não mostra nenhum problema. Também tenho certeza de que isso ocorreu gcc-10.0.0
, mas não os dados.
Corri a gcc-10.0.1 -O2
versão gdb
e notei algo que me parece estranho:
Ao percorrer a seção destacada, parece que a inicialização dos segundos elementos das matrizes é ignorada ( R_alloc
é um invólucro em torno do malloc
qual o lixo próprio é coletado ao retornar o controle para R; o segfault ocorre antes de retornar para R). Posteriormente, o programa falha quando o elemento não inicializado (na versão gcc.10.0.1 -O2) é acessado.
Corrigi isso inicializando explicitamente o elemento em questão em qualquer lugar do código que eventualmente levou ao uso do elemento, mas ele realmente deveria ter sido inicializado com uma string vazia, ou pelo menos é o que eu teria assumido.
Estou perdendo algo óbvio ou fazendo algo estúpido? Ambos são razoavelmente prováveis, pois C é minha segunda língua de longe . É estranho que isso tenha surgido agora e não consigo descobrir o que o compilador está tentando fazer.
UPDATE : Instruções para reproduzir isso, embora isso só seja reproduzido enquanto o debian:testing
contêiner estiver gcc-10
em gcc-10.0.1
. Além disso, não execute esses comandos apenas se não confiar em mim .
Desculpe, este não é um exemplo mínimo reproduzível.
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
Em seguida, no console R, após a digitação run
de obter gdb
para executar o programa:
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)
A inspeção no gdb mostra rapidamente (se bem entendi) que
CSR_strmlen_x
está tentando acessar a string que não foi inicializada.
ATUALIZAÇÃO 2 : esta é uma função altamente recursiva e, além disso, o bit de inicialização da string é chamado muitas e muitas vezes. Isso é principalmente porque eu estava sendo preguiçoso, precisamos apenas das seqüências inicializadas pela única vez que encontramos algo que queremos relatar na recursão, mas era mais fácil inicializar toda vez que é possível encontrar algo. Menciono isso porque o que você verá a seguir mostra várias inicializações, mas apenas uma delas (presumivelmente a com endereço <0x1400000001>) está sendo usada.
Não posso garantir que as coisas que estou mostrando aqui estejam diretamente relacionadas ao elemento que causou o segfault (embora seja o mesmo acesso ilegal ao endereço), mas como @ nate-eldredge pediu, ele mostra que o elemento array não é inicializado imediatamente antes do retorno ou logo após o retorno na função de chamada. Observe que a função de chamada está inicializando 8 deles, e eu mostro todos eles, com todos eles cheios de lixo ou memória inacessível.
ATUALIZAÇÃO 3 , desmontagem da função em questão:
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.
ATUALIZAÇÃO 4 :
Então, tentando analisar o padrão aqui estão as partes dele que parecem relevantes ( rascunho C11 ):
6.3.2.3 Conversões Par7> Outros operandos> Ponteiros
Um ponteiro para um tipo de objeto pode ser convertido em um ponteiro para um tipo de objeto diferente. Se o ponteiro resultante não estiver alinhado corretamente 68) para o tipo referenciado, o comportamento será indefinido.
Caso contrário, quando convertido novamente, o resultado será comparado ao ponteiro original. Quando um ponteiro para um objeto é convertido em um ponteiro para um tipo de caractere, o resultado aponta para o byte endereçado mais baixo do objeto. Incrementos sucessivos do resultado, até o tamanho do objeto, produzem ponteiros para os bytes restantes do objeto.
6.5 Expressões Par6
O tipo efetivo de um objeto para um acesso ao seu valor armazenado é o tipo declarado do objeto, se houver. 87) Se um valor for armazenado em um objeto sem tipo declarado por meio de um lvalue com um tipo que não seja um tipo de caractere, o tipo do lvalue se tornará o tipo efetivo do objeto para esse acesso e para acessos subseqüentes que não modificar o valor armazenado. Se um valor for copiado em um objeto sem tipo declarado usando memcpy ou memmove ou for copiado como uma matriz de tipo de caractere, o tipo efetivo do objeto modificado para esse acesso e para acessos subseqüentes que não modificam o valor é o tipo efetivo do objeto do qual o valor é copiado, se houver um. Para todos os outros acessos a um objeto sem tipo declarado, o tipo efetivo do objeto é simplesmente o tipo de lvalue usado para o acesso.
87) Objetos alocados não têm tipo declarado.
IIUC R_alloc
retorna um deslocamento em um malloc
bloco ed que é garantido para ser double
alinhado, e o tamanho do bloco após o deslocamento é do tamanho solicitado (também há alocação antes do deslocamento para dados específicos de R). R_alloc
lança esse ponteiro (char *)
no retorno.
Seção 6.2.5 Par 29
Um ponteiro para anular deve ter os mesmos requisitos de representação e alinhamento que um ponteiro para um tipo de caractere. 48) Da mesma forma, os indicadores para versões qualificadas ou não qualificadas de tipos compatíveis devem ter os mesmos requisitos de representação e alinhamento. Todos os ponteiros para tipos de estrutura devem ter os mesmos requisitos de representação e alinhamento que os outros.
Todos os ponteiros para tipos de união devem ter os mesmos requisitos de representação e alinhamento que os outros.
Ponteiros para outros tipos não precisam ter os mesmos requisitos de representação ou alinhamento.48) Os mesmos requisitos de representação e alinhamento significam implicar argumentos de intercambiabilidade para funções, retornar valores de funções e membros de sindicatos.
Portanto, a questão é "podemos reformular o (char *)
to (const char **)
e escrever nele como (const char **)
". Minha leitura do exposto acima é que, enquanto os ponteiros nos sistemas em que o código é executado tiverem alinhamento compatível com o double
alinhamento, tudo bem.
Estamos violando o "aliasing estrito"? ou seja:
6.5 Par 7
Um objeto deve ter seu valor armazenado acessado apenas por uma expressão lvalue que possui um dos seguintes tipos: 88)
- um tipo compatível com o tipo efetivo do objeto ...
88) O objetivo desta lista é especificar as circunstâncias nas quais um objeto pode ou não ser aliasado.
Então, o que o compilador deve pensar que é o tipo efetivo do objeto apontado por res.target
(ou res.current
)? Presumivelmente, o tipo declarado (const char **)
, ou isso é realmente ambíguo? Parece-me que não é neste caso apenas porque não há outro 'lvalue' no escopo que acesse o mesmo objeto.
Admito que estou lutando poderosamente para extrair sentido dessas seções do padrão.
fonte
-mtune=native
otimiza para a CPU específica que sua máquina possui. Isso será diferente para testadores diferentes e pode fazer parte do problema. Se você executar a compilação,-v
poderá ver qual família de CPUs está na sua máquina (por exemplo,-mtune=skylake
no meu computador).disassemble
instruções dentro do gdb.Respostas:
Resumo: parece ser um bug no gcc, relacionado à otimização de strings. Um testcase independente está abaixo. Inicialmente, havia alguma dúvida sobre se o código está correto, mas acho que sim.
Eu relatei o bug como PR 93982 . Uma correção proposta foi confirmada, mas não a corrige em todos os casos, levando ao acompanhamento PR 94015 ( link godbolt ).
Você deve conseguir solucionar o erro compilando com o sinalizador
-fno-optimize-strlen
.Consegui reduzir seu caso de teste ao seguinte exemplo mínimo (também no godbolt ):
Com o tronco do gcc (versão do gcc 10.0.1 20200225 (experimental)) e
-O2
(todas as outras opções se tornaram desnecessárias), o assembly gerado no amd64 é o seguinte:Portanto, você está certo de que o compilador está falhando ao inicializar
res.target[1]
(observe a evidente ausência demovq $.LC1, 8(%rax)
).É interessante brincar com o código e ver o que afeta o "bug". Talvez significativamente, alterando o tipo de retorno de
R_alloc
paravoid *
fazer que ele desapareça e fornecer a saída "correta" da montagem. Talvez menos significativamente, mas de maneira mais divertida, alterar a corda"12345678"
para ser mais longa ou mais curta também a faz desaparecer.Discussão anterior, agora resolvida - o código é aparentemente legal.
A questão que tenho é se o seu código é realmente legal. O fato de você pegar o
char *
retornoR_alloc()
e convertê-lo emconst char **
e depois armazenar umconst char *
parece violar a regra estrita de alias , comochar
econst char *
não são tipos compatíveis. Há uma exceção que permite acessar qualquer objeto comochar
(para implementar coisas comomemcpy
), mas é o contrário, e, pelo que entendi, isso não é permitido. Isso faz seu código produzir um comportamento indefinido e, assim, o compilador pode legalmente fazer o que quiser.Se for assim, a correção correta seria para R alterar seu código para que
R_alloc()
retorne emvoid *
vez dechar *
. Então não haveria problema de apelido. Infelizmente, esse código está fora do seu controle e não está claro para mim como você pode usar essa função sem violar o aliasing estrito. Uma solução alternativa pode ser interpor uma variável temporária, por exemplo,void *tmp = R_alloc(); res.target = tmp;
que resolve o problema no caso de teste, mas ainda não tenho certeza se é legal.No entanto, não tenho certeza dessa hipótese de "aliasing estrito", porque a compilação com a
-fno-strict-aliasing
qual o AFAIK deve fazer com que o gcc permita tais construções, não faz o problema desaparecer!Atualizar. Tentando algumas opções diferentes, descobri que
-fno-optimize-strlen
ou-fno-tree-forwprop
resultará na geração de código "correto". Além disso, o uso-O1 -foptimize-strlen
gera o código incorreto (mas-O1 -ftree-forwprop
não).Após um pequeno
git bisect
exercício, o erro parece ter sido introduzido no commit 34fcf41e30ff56155e996f5e04 .Atualização 2. Tentei me aprofundar um pouco na fonte gcc, apenas para ver o que pude aprender. (Eu não pretendo ser nenhum tipo de especialista em compilador!)
Parece que o código
tree-ssa-strlen.c
é destinado a acompanhar as strings que aparecem no programa. Tão perto quanto eu posso dizer, o erro é que, olhando a instrução,res.target[0] = "12345678";
o compilador confunde o endereço da string literal"12345678"
com a própria string. (Isso parece estar relacionado a esse código suspeito que foi adicionado no commit mencionado acima, onde, se ele tenta contar os bytes de uma "string" que na verdade é um endereço, em vez disso, verifica o que esse endereço aponta.)Então ele pensa que a declaração
res.target[0] = "12345678"
, em vez de armazenar o endereço de"12345678"
no endereçores.target
, é armazenar a própria string, nesse endereço, como se a afirmação fossestrcpy(res.target, "12345678")
. Observe o que está à frente que isso resultaria no nulo à direita sendo armazenado no endereçores.target+8
(nesse estágio do compilador, todos os deslocamentos estão em bytes).Agora, quando o compilador olha
res.target[1] = ""
, ele também trata isso como se fossestrcpy(res.target+8, "")
, o 8 vindo do tamanho de achar *
. Ou seja, como se estivesse simplesmente armazenando um byte nulo no endereçores.target+8
. No entanto, o compilador "sabe" que a instrução anterior já armazenava um byte nulo nesse mesmo endereço! Como tal, esta declaração é "redundante" e pode ser descartada ( aqui ).Isso explica por que a string precisa ter exatamente 8 caracteres para acionar o bug. (Embora outros múltiplos de 8 também possam acionar o bug em outras situações.)
fonte
int*
mas não emconst char**
.int *
também é ilegal (ou melhor, armazenarint
s lá é ilegal).char*
e trabalhando no x86_64 ... Não vejo UB aqui, é um bug do gcc.R_alloc()
, o programa está em conformidade, independentemente da unidade de traduçãoR_alloc()
definida. É o compilador que falha em conformidade aqui.