gcc-10.0.1 Segfault específico

23

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-basecontenedor acoplable gcc-10.0.1con 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 -O2versión gdby noté algo que me parece extraño:

gdb vs código

Al recorrer la sección resaltada, parece que se omite la inicialización de los segundos elementos de las matrices ( R_alloces una envoltura alrededor de mallocla 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:testingcontenedor ventana acoplable tiene gcc-10al 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 runpara llegar gdba 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_xestá 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.

ingrese la descripción de la imagen aquí

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_allocdevuelve un desplazamiento en un mallocbloque ed que se garantiza que está doublealineado, 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_alloclanza 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 doublealineació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.

BrodieG
fuente
Si aún no se ha examinado, puede valer la pena mirar el desmontaje para ver exactamente lo que se está haciendo. Y también para comparar el desmontaje entre versiones de gcc.
kaylum
2
No trataría de meterme con la versión troncal de GCC. Es agradable divertirse, pero se llama tronco por una razón. Desafortunadamente, es casi imposible saber qué está mal sin (1) tener su código y configuración exacta (2) tener la misma versión de GCC (3) en la misma arquitectura. Sugeriría verificar si esto persiste cuando 10.0.1 se mueve de troncal a estable.
Marco Bonelli
1
Un comentario más: -mtune=nativeoptimiza 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=skylakeen mi computadora).
Nate Eldredge
1
Todavía es difícil de distinguir de las ejecuciones de depuración. El desmontaje debe ser concluyente. No necesita extraer nada, solo busque el archivo .o producido cuando compiló el proyecto y desmóntelo. También puede usar las disassembleinstrucciones dentro de gdb.
Nate Eldredge
55
De todos modos, felicidades, eres uno de los pocos cuyo problema en realidad era un error de compilación.
Nate Eldredge

Respuestas:

22

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 ):

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 versión 10.0.1 20200225 (experimental)) y -O2(todas las demás opciones resultaron innecesarias), el ensamblado generado en amd64 es el siguiente:

.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

Por lo tanto, tiene razón en que el compilador no puede inicializarse res.target[1](tenga en cuenta la notable ausencia de movq $.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_allocto void *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 por R_alloc()y echarlo a const char **, y luego almacenar una const char *parece que podría violar la regla de alias estricto , como chary const char *no son tipos compatibles. Hay una excepción que le permite acceder a cualquier objeto como char(para implementar cosas como memcpy), 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 en void *lugar de char *. 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-strleno -fno-tree-forwpropgenerará un código "correcto". Además, el uso -O1 -foptimize-strlenproduce el código incorrecto (pero -O1 -ftree-forwpropno lo hace).

Después de un pequeño git bisectejercicio, 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.cestá 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ón res.target, está almacenando la cadena en esa dirección, como si la declaración fuera strcpy(res.target, "12345678"). Tenga en cuenta lo que se avecina que esto resultaría en que el nul final se almacene en la dirección res.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 fuera strcpy(res.target+8, ""), el 8 viene del tamaño de a char *. Es decir, como si simplemente estuviera almacenando un byte nulo en la dirección res.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).

Nate Eldredge
fuente
La reestructuración de FWIW a un tipo diferente de puntero está documentada . No sé sobre alias para saber si está bien volver a transmitir int*pero no hacerlo const char**.
BrodieG
Si mi comprensión del alias estricto es correcta, entonces el reparto int *también es ilegal (o mejor dicho, almacenarlo intallí es ilegal).
Nate Eldredge
1
Esto no tiene nada que ver con una estricta regla de alias. La regla de alias estricta se trata de acceder a los datos que ya almacenó utilizando un controlador diferente. Como solo asigna aquí, no toca una estricta regla de alias. La conversión de punteros es válida cuando ambos tipos de punteros tienen los mismos requisitos de alineación, pero aquí está emitiendo char*y trabajando en x86_64 ... No veo ninguna UB aquí, este es un error de gcc.
KamilCuk
1
Sí y no, @KamilCuk. En la terminología del estándar, "acceder" incluye leer y modificar el valor de un objeto. La estricta regla de alias, por lo tanto, habla de "almacenamiento". No se limita a operaciones de lectura. Pero para los objetos sin tipo declarado, esto se debe al hecho de que escribir en dicho objeto cambia automáticamente su tipo efectivo para que se corresponda con lo que se escribió. Los objetos sin un tipo declarado son exactamente los asignados dinámicamente (independientemente del tipo de puntero por el que se accede), por lo que, de hecho, no hay violación de SA aquí.
John Bollinger
2
Sí, @Nate, con esa definición de R_alloc(), el programa se ajusta, independientemente de en qué unidad de traducción R_alloc()se defina. Es el compilador que no se conforma aquí.
John Bollinger