Tengo un paquete R con código compilado en C que ha sido relativamente estable durante bastante tiempo y con frecuencia se prueba en una amplia variedad de plataformas y compiladores (windows / osx / debian / fedora gcc / clang).
Más recientemente, se agregó una nueva plataforma para probar el paquete nuevamente:
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"
En ese momento, el código compilado comenzó a segfaularse rápidamente a lo largo de estas líneas:
*** caught segfault ***
address 0x1d00000001, cause 'memory not mapped'
He podido reproducir el segfault de manera consistente mediante el uso del rocker/r-base
contenedor acoplable gcc-10.0.1
con un nivel de optimización -O2
. Ejecutar una optimización más baja elimina el problema. Ejecutar cualquier otra configuración, incluso bajo valgrind (tanto -O0 como -O2), UBSAN (gcc / clang), no muestra ningún problema. También estoy razonablemente seguro de que esto se ejecutó por debajo gcc-10.0.0
, pero no tengo los datos.
Ejecuté la gcc-10.0.1 -O2
versión gdb
y noté algo que me parece extraño:
Al recorrer la sección resaltada, parece que se omite la inicialización de los segundos elementos de las matrices ( R_alloc
es una envoltura alrededor de malloc
la basura que se recolecta cuando se regresa el control a R; la falla predeterminada ocurre antes de regresar a R). Más tarde, el programa se bloquea cuando se accede al elemento no inicializado (en la versión gcc.10.0.1 -O2).
Arreglé esto inicializando explícitamente el elemento en cuestión en todas partes del código que eventualmente condujo al uso del elemento, pero realmente debería haberse inicializado en una cadena vacía, o al menos eso es lo que habría asumido.
¿Me estoy perdiendo algo obvio o estoy haciendo algo estúpido? Ambos son razonablemente probables ya que C es mi segundo idioma con diferencia . Es extraño que esto haya surgido ahora, y no puedo entender qué está tratando de hacer el compilador.
ACTUALIZACIÓN : Instrucciones para reproducir este, aunque esto sólo va a reproducir, siempre y cuando debian:testing
contenedor ventana acoplable tiene gcc-10
al gcc-10.0.1
. Además, no solo ejecute estos comandos si no confía en mí .
Lo sentimos, este no es un ejemplo mínimo reproducible.
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
Luego, en la consola de R, después de escribir run
para llegar gdb
a ejecutar el 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)
La inspección en gdb muestra bastante rápido (si lo entiendo correctamente) que
CSR_strmlen_x
está intentando acceder a la cadena que no se inicializó.
ACTUALIZACIÓN 2 : esta es una función altamente recursiva, y además el bit de inicialización de cadena se llama muchas, muchas veces. Esto es principalmente b / c. Estaba siendo vago, solo necesitamos las cadenas inicializadas por la única vez que realmente encontramos algo que queremos informar en la recursión, pero fue más fácil de inicializar cada vez que es posible encontrar algo. Menciono esto porque lo que verá a continuación muestra múltiples inicializaciones, pero solo se está utilizando una de ellas (presumiblemente la que tiene la dirección <0x1400000001>).
No puedo garantizar que las cosas que muestro aquí estén directamente relacionadas con el elemento que causó la falla predeterminada (aunque es el mismo acceso de dirección ilegal), pero como preguntó @ nate-eldredge, muestra que el elemento de matriz no es inicializado justo antes del retorno o justo después del retorno en la función de llamada Tenga en cuenta que la función de llamada está inicializando 8 de estos, y los muestro a todos, con todos ellos llenos de basura o memoria inaccesible.
ACTUALIZACIÓN 3 , desmontaje de la función en cuestión:
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.
ACTUALIZACIÓN 4 :
Entonces, tratar de analizar el estándar aquí son las partes que parecen relevantes ( borrador C11 ):
6.3.2.3 Conversiones Par7> Otros operandos> Punteros
Un puntero a un tipo de objeto puede convertirse en un puntero a un tipo de objeto diferente. Si el puntero resultante no está alineado correctamente 68) para el tipo referenciado, el comportamiento no está definido.
De lo contrario, cuando se convierta nuevamente, el resultado se comparará igual al puntero original. Cuando un puntero a un objeto se convierte en un puntero a un tipo de carácter, el resultado apunta al byte direccionado más bajo del objeto. Los incrementos sucesivos del resultado, hasta el tamaño del objeto, arrojan punteros a los bytes restantes del objeto.
6.5 Expresiones de Par6
El tipo efectivo de un objeto para acceder a su valor almacenado es el tipo declarado del objeto, si lo hay. 87) Si un valor se almacena en un objeto que no tiene un tipo declarado a través de un valor que tiene un tipo que no es un tipo de caracteres, entonces el tipo del valor se convierte en el tipo efectivo del objeto para ese acceso y para accesos posteriores que no Modificar el valor almacenado. Si se copia un valor en un objeto que no tiene un tipo declarado usando memcpy o memmove, o se copia como una matriz de tipo de caracteres, entonces el tipo efectivo del objeto modificado para ese acceso y para accesos posteriores que no modifican el valor es el tipo efectivo del objeto desde el que se copia el valor, si tiene uno. Para todos los demás accesos a un objeto que no tiene un tipo declarado, el tipo efectivo del objeto es simplemente el tipo del valor l utilizado para el acceso.
87) Los objetos asignados no tienen tipo declarado.
IIUC R_alloc
devuelve un desplazamiento en un malloc
bloque ed que se garantiza que está double
alineado, y el tamaño del bloque después del desplazamiento es del tamaño solicitado (también hay una asignación antes del desplazamiento para datos específicos de R). R_alloc
lanza ese puntero a la (char *)
vuelta.
Sección 6.2.5 Par 29
Un puntero a anular tendrá los mismos requisitos de representación y alineación que un puntero a un tipo de carácter. 48) Del mismo modo, los punteros a versiones calificadas o no calificadas de tipos compatibles tendrán los mismos requisitos de representación y alineación. Todos los punteros a los tipos de estructura deben tener los mismos requisitos de representación y alineación que los demás.
Todos los punteros a tipos de unión tendrán los mismos requisitos de representación y alineación que los demás.
Los punteros a otros tipos no necesitan tener los mismos requisitos de representación o alineación.48) Los mismos requisitos de representación y alineación están destinados a implicar intercambiabilidad como argumentos a funciones, valores de retorno de funciones y miembros de sindicatos.
Por lo tanto, la pregunta es "¿se nos permite refundir el (char *)
to (const char **)
y escribirle como (const char **)
". Mi lectura de lo anterior es que mientras los punteros en los sistemas en los que se ejecuta el código tengan una alineación compatible con la double
alineación, entonces está bien.
¿Estamos violando el "alias estricto"? es decir:
6.5 Par 7
Un objeto tendrá acceso a su valor almacenado solo mediante una expresión lvalue que tenga uno de los siguientes tipos: 88)
- un tipo compatible con el tipo efectivo del objeto ...
88) La intención de esta lista es especificar aquellas circunstancias en las que un objeto puede tener un alias o no.
Entonces, ¿qué debería pensar el compilador que es el tipo efectivo del objeto señalado por res.target
(o res.current
)? Presumiblemente el tipo declarado (const char **)
, ¿o es realmente ambiguo? Me parece que no es en este caso solo porque no hay otro 'lvalue' en el alcance que acceda al mismo objeto.
Admito que estoy luchando poderosamente para extraer sentido de estas secciones de la norma.
fuente
-mtune=native
optimiza para la CPU particular que tiene su máquina. Eso será diferente para diferentes evaluadores y puede ser parte del problema. Si ejecuta la compilación-v
, debería poder ver qué familia de CPU está en su máquina (por ejemplo,-mtune=skylake
en mi computadora).disassemble
instrucciones dentro de gdb.Respuestas:
Resumen: Esto parece ser un error en gcc, relacionado con la optimización de cadenas. Un caso de prueba autónomo está debajo. Inicialmente hubo algunas dudas sobre si el código es correcto, pero creo que lo es.
He informado del error como PR 93982 . Se confirmó una solución propuesta, pero no la soluciona en todos los casos, lo que lleva al seguimiento PR 94015 ( enlace de perno prisionero ).
Debería poder evitar el error compilando con la bandera
-fno-optimize-strlen
.Pude reducir su caso de prueba al siguiente ejemplo mínimo (también en godbolt ):
Con gcc trunk (gcc versión 10.0.1 20200225 (experimental)) y
-O2
(todas las demás opciones resultaron innecesarias), el ensamblado generado en amd64 es el siguiente:Por lo tanto, tiene razón en que el compilador no puede inicializarse
res.target[1]
(tenga en cuenta la notable ausencia demovq $.LC1, 8(%rax)
).Es interesante jugar con el código y ver qué afecta al "error". Tal vez de manera significativa, cambiar el tipo de retorno de
R_alloc
tovoid *
hace que desaparezca y le da una salida de ensamblaje "correcta". Quizás de manera menos significativa pero más divertida, cambiar la cadena"12345678"
para que sea más larga o más corta también hace que desaparezca.Discusión previa, ahora resuelta: el código es aparentemente legal.
La pregunta que tengo es si su código es realmente legal. El hecho de que se tome el
char *
devueltos porR_alloc()
y echarlo aconst char **
, y luego almacenar unaconst char *
parece que podría violar la regla de alias estricto , comochar
yconst char *
no son tipos compatibles. Hay una excepción que le permite acceder a cualquier objeto comochar
(para implementar cosas comomemcpy
), pero esto es al revés, y según tengo entendido, eso no está permitido. Hace que su código produzca un comportamiento indefinido y, por lo tanto, el compilador puede hacer legalmente lo que quiera.Si esto es así, la solución correcta sería que R cambie su código para que
R_alloc()
regrese envoid *
lugar dechar *
. Entonces no habría ningún problema de alias. Desafortunadamente, ese código está fuera de su control, y no está claro para mí cómo puede usar esta función sin violar el alias estricto. Una solución alternativa podría ser interponer una variable temporal, por ejemplo,void *tmp = R_alloc(); res.target = tmp;
que resuelva el problema en el caso de prueba, pero todavía no estoy seguro de si es legal.Sin embargo, no estoy seguro de esta hipótesis de "alias estricto", porque compilar con
-fno-strict-aliasing
, que AFAIK debe hacer que gcc permita tales construcciones, ¡ no hace que el problema desaparezca!Actualizar. Al probar algunas opciones diferentes, descubrí que
-fno-optimize-strlen
o-fno-tree-forwprop
generará un código "correcto". Además, el uso-O1 -foptimize-strlen
produce el código incorrecto (pero-O1 -ftree-forwprop
no lo hace).Después de un pequeño
git bisect
ejercicio, el error parece haberse introducido en commit 34fcf41e30ff56155e996f5e04 .Actualización 2. Intenté profundizar un poco en la fuente de gcc, solo para ver qué podía aprender. (¡No pretendo ser ningún tipo de experto en compiladores!)
Parece que el código
tree-ssa-strlen.c
está destinado a hacer un seguimiento de las cadenas que aparecen en el programa. Por lo que puedo decir, el error es que al mirar la declaración,res.target[0] = "12345678";
el compilador combina la dirección del literal de cadena"12345678"
con la cadena misma. (Eso parece estar relacionado con este código sospechoso que se agregó en la confirmación mencionada anteriormente, donde si intenta contar los bytes de una "cadena" que en realidad es una dirección, en cambio mira a qué apunta esa dirección).Por lo tanto, cree que la declaración
res.target[0] = "12345678"
, en lugar de almacenar la dirección de"12345678"
en la direcciónres.target
, está almacenando la cadena en esa dirección, como si la declaración fuerastrcpy(res.target, "12345678")
. Tenga en cuenta lo que se avecina que esto resultaría en que el nul final se almacene en la direcciónres.target+8
(en esta etapa en el compilador, todas las compensaciones están en bytes).Ahora, cuando el compilador mira
res.target[1] = ""
, también trata esto como si fuerastrcpy(res.target+8, "")
, el 8 viene del tamaño de achar *
. Es decir, como si simplemente estuviera almacenando un byte nulo en la direcciónres.target+8
. Sin embargo, el compilador "sabe" que la declaración anterior ya almacenaba un byte nulo en esa misma dirección. Como tal, esta declaración es "redundante" y puede descartarse ( aquí ).Esto explica por qué la cadena tiene que tener exactamente 8 caracteres para activar el error. (Aunque otros múltiplos de 8 también pueden desencadenar el error en otras situaciones).
fuente
int*
pero no hacerloconst char**
.int *
también es ilegal (o mejor dicho, almacenarloint
allí es ilegal).char*
y trabajando en x86_64 ... No veo ninguna UB aquí, este es un error de gcc.R_alloc()
, el programa se ajusta, independientemente de en qué unidad de traducciónR_alloc()
se defina. Es el compilador que no se conforma aquí.