¿Violación de alias estricto en C, incluso sin ningún tipo de conversión?
strict-aliasing (7)
El alias estricto no se especifica en el Estándar C, pero la interpretación habitual es que el alias sindical (que reemplaza al alias estricto) solo se permite cuando se accede directamente a los miembros del sindicato por su nombre.
Para justificar esto, considere:
void f(int *a, short *b) {
La intención de la regla es que el compilador puede asumir que
b
no tienen alias, y generar código eficiente en
f
.
Pero si el compilador tuviera que tener en cuenta el hecho de que
b
podrían estar superpuestos a miembros del sindicato, en realidad no podría hacer esas suposiciones.
Si los dos punteros son o no parámetros de función o no es irrelevante, la estricta regla de alias no se diferencia en función de eso.
¿Cómo pueden
*i
y
ui
imprimir diferentes números en este código, aunque
i
esté definido como
int *i = &u.i;
?
Solo puedo suponer que estoy activando UB aquí, pero no puedo ver cómo exactamente.
(la
demostración de ideone se
replica si selecciono ''C'' como lenguaje. Pero como señaló @ 2501, no si ''C99 estricto'' es el lenguaje. Pero, de nuevo, ¡tengo el problema con
gcc-5.3.0 -std=c99
! )
// gcc -fstrict-aliasing -std=c99 -O2
union
{
int i;
short s;
} u;
int * i = &u.i;
short * s = &u.s;
int main()
{
*i = 2;
*s = 100;
printf(" *i = %d/n", *i); // prints 2
printf("u.i = %d/n", u.i); // prints 100
return 0;
}
(gcc 5.3.0, con
-fstrict-aliasing -std=c99 -O2
, también con
-std=c11
)
Mi teoría es que
100
es la respuesta ''correcta'', porque la escritura al miembro de la unión a través del valor
short
-lvalue
*s
se define como tal (para esta plataforma / endianness / lo que sea).
Pero creo que el optimizador no se da cuenta de que la escritura en
*s
puede alias
ui
, y por lo tanto piensa que
*i=2;
es la única línea que puede afectar
*i
.
¿Es esta una teoría razonable?
Si
*s
puede alias
ui
, y
ui
puede alias
*i
, entonces seguramente el compilador debería pensar que
*s
puede alias
*i
?
¿No debería ser el aliasing ''transitivo''?
Finalmente, siempre tuve la suposición de que los problemas de alias estricto fueron causados por un mal reparto. ¡Pero no hay casting en esto!
(Mi experiencia es C ++, espero hacer una pregunta razonable sobre C aquí. Mi comprensión (limitada) es que, en C99, es aceptable escribir a través de un miembro de la unión y luego leer a través de otro miembro de un miembro diferente tipo.)
Está investigando un área algo controvertida del estándar C.
Esta es la estricta regla de alias:
Un objeto tendrá acceso a su valor almacenado solo mediante una expresión lvalue que tenga uno de los siguientes tipos:
- un tipo compatible con el tipo efectivo del objeto,
- una versión calificada de un tipo compatible con el tipo efectivo del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente al tipo efectivo del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente a una versión calificada del tipo efectivo del objeto,
- un tipo agregado o de unión que incluye uno de los tipos antes mencionados entre sus miembros (incluido, recursivamente, un miembro de una unión agregada o contenida),
- un tipo de personaje
(C2011, 6.5 / 7)
La expresión lvalue
*i
tiene el tipo
int
.
La expresión lvalue
*s
tiene el tipo
short
.
Estos tipos no son compatibles entre sí, ni ambos son compatibles con ningún otro tipo en particular, ni la regla de alias estricto ofrece ninguna otra alternativa que permita que ambos accesos se conformen si los punteros tienen un alias.
Si al menos uno de los accesos no es conforme, entonces el comportamiento es indefinido, por lo que el resultado que informe, o cualquier otro resultado, es completamente aceptable.
En la práctica, el compilador debe producir código que reordena las asignaciones con las llamadas
printf()
, o que utiliza un valor previamente cargado de
*i
desde un registro en lugar de volver a leerlo desde la memoria, o algo similar.
La controversia antes mencionada surge porque la gente a veces señalará la nota 95:
Si el miembro utilizado para leer el contenido de un objeto de unión no es el mismo que el miembro utilizado por última vez para almacenar un valor en el objeto, la parte apropiada de la representación del objeto del valor se reinterpreta como una representación de objeto en el nuevo tipo como descrito en 6.2.6 (un proceso a veces llamado "punzonado de tipo"). Esto podría ser una representación trampa.
Sin embargo, las notas al pie son informativas, no normativas, por lo que no hay duda de qué texto gana si entran en conflicto. Personalmente, tomo la nota al pie de página simplemente como una guía de implementación, aclarando el significado del hecho de que el almacenamiento para los miembros del sindicato se superpone.
Este código de hecho invoca a UB, porque no respetas la estricta regla de alias. borrador n1256 de los estados C99 en 6.5 Expresiones §7:
Un objeto tendrá acceso a su valor almacenado solo mediante una expresión lvalue que tenga uno de los siguientes tipos:
- un tipo compatible con el tipo efectivo del objeto,
- una versión calificada de un tipo compatible con el tipo efectivo del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente al tipo efectivo del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente a una versión calificada del tipo efectivo del objeto,
- un tipo agregado o de unión que incluye uno de los tipos antes mencionados entre sus miembros (incluido, recursivamente, un miembro de una unión agregada o contenida), o
- un tipo de personaje.
Entre el
*i = 2;
y el
printf(" *i = %d/n", *i);
solo se modifica un objeto corto.
Con la ayuda de la estricta regla de alias, el compilador es libre de asumir que el objeto int apuntado por
i
no ha cambiado, y puede usar directamente un valor en caché sin volver a cargarlo desde la memoria principal.
Es evidente que no es lo que un ser humano normal esperaría, pero la estricta regla de alias se escribió con precisión para permitir que los compiladores optimicen el uso de valores en caché.
Para la segunda impresión, se hace referencia a las uniones en el mismo estándar en 6.2.6.1 Representaciones de tipos / General §7:
Cuando un valor se almacena en un miembro de un objeto de tipo unión, los bytes de la representación del objeto que no corresponden a ese miembro pero sí corresponden a otros miembros toman valores no especificados.
Entonces, como
us
han almacenado, hemos tomado un valor
no especificado por estándar
Pero podemos leer más adelante en 6.5.2.3 Estructura y miembros del sindicato §3 nota 82:
Si el miembro utilizado para acceder al contenido de un objeto de unión no es el mismo que el miembro utilizado por última vez para almacenar un valor en el objeto, la parte apropiada de la representación del objeto del valor se reinterpreta como una representación de objeto en el nuevo tipo como descrito en 6.2.6 (un proceso a veces llamado "punzonado de tipo"). Esto podría ser una representación trampa.
Aunque las notas no son normativas, sí permiten una mejor comprensión del estándar.
Cuando
us
hemos almacenado a través del puntero
*s
, los bytes correspondientes a un corto se han cambiado al valor 2.
Suponiendo un pequeño sistema endian, ya que 100 es más pequeño que el valor de un short, la representación como int ahora debería ser 2 ya que los bytes de alto orden eran 0.
TL / DR: incluso si no es normativo, la nota 82 debería requerir que en un pequeño sistema endian de las familias x86 o x64,
printf("ui = %d/n", ui);
imprime 2. Pero según la estricta regla de alias, el compilador aún puede asumir que el valor señalado por
i
no ha cambiado y puede imprimir
100
La disparidad se emite mediante la opción de optimización
-fstrict-aliasing
.
Su comportamiento y posibles trampas se describen en la
documentación de GCC
:
Presta especial atención al código como este:
union a_union { int i; double d; }; int f() { union a_union t; t.d = 3.0; return t.i; }
La práctica de leer de un miembro del sindicato diferente al que se le escribió más recientemente (llamado "punteo de tipo") es común. Incluso con
-fstrict-aliasing
, se permite la-fstrict-aliasing
tipos, siempre que se acceda a la memoria a través del tipo de unión . Entonces, el código anterior funciona como se esperaba. Ver enumeraciones de uniones de estructuras e implementación de campos de bits . Sin embargo, este código podría no :
int f() { union a_union t; int* ip; t.d = 3.0; ip = &t.i; return *ip; }
Tenga en cuenta que la implementación conforme está perfectamente permitida para aprovechar esta optimización, ya que el segundo ejemplo de código exhibe un comportamiento indefinido . Vea Olaf''s respuestas de Olaf''s y de otros para referencia.
Parece que esto es el resultado del optimizador haciendo su magia.
Con
-O0
, ambas líneas imprimen 100 como se esperaba (suponiendo little-endian).
Con
-O2
, hay algunos reordenamientos en curso.
gdb da el siguiente resultado:
(gdb) start
Temporary breakpoint 1 at 0x4004a0: file /tmp/x1.c, line 14.
Starting program: /tmp/x1
warning: no loadable sections found in added symbol-file system-supplied DSO at 0x2aaaaaaab000
Temporary breakpoint 1, main () at /tmp/x1.c:14
14 {
(gdb) step
15 *i = 2;
(gdb)
18 printf(" *i = %d/n", *i); // prints 2
(gdb)
15 *i = 2;
(gdb)
16 *s = 100;
(gdb)
18 printf(" *i = %d/n", *i); // prints 2
(gdb)
*i = 2
19 printf("u.i = %d/n", u.i); // prints 100
(gdb)
u.i = 100
22 }
(gdb)
0x0000003fa441d9f4 in __libc_start_main () from /lib64/libc.so.6
(gdb)
La razón por la que esto sucede, como han dicho otros, es porque es un comportamiento indefinido acceder a una variable de un tipo a través de un puntero a otro tipo, incluso si la variable en cuestión es parte de una unión. Por lo tanto, el optimizador puede hacer lo que desee en este caso.
La variable del otro tipo solo se puede leer directamente a través de una unión que garantiza un comportamiento bien definido.
Lo curioso es que incluso con
-Wstrict-aliasing=2
, gcc (a partir de 4.8.4) no se queja de este código.
Ya sea por accidente o por diseño, C89 incluye un lenguaje que ha sido interpretado de dos maneras diferentes (junto con varias interpretaciones intermedias). La cuestión es cuándo se debe exigir a un compilador que reconozca que se puede acceder al almacenamiento utilizado para un tipo a través de punteros de otro. En el ejemplo dado en la justificación de C89, se considera el alias entre una variable global que claramente no es parte de ninguna unión y un puntero a un tipo diferente, y nada en el código sugeriría que podría ocurrir un alias.
Una interpretación paraliza horriblemente el lenguaje, mientras que la otra restringiría el uso de ciertas optimizaciones a modos "no conformes". Si aquellos que no hubieran tenido sus optimizaciones preferidas dado el estado de segunda clase hubieran escrito C89 para que coincida inequívocamente con su interpretación, esas partes de la Norma habrían sido ampliamente denunciadas y habría habido algún tipo de reconocimiento claro de un no roto dialecto de C que honraría la interpretación no paralizante de las reglas dadas.
Desafortunadamente, lo que sucedió es que, dado que las reglas claramente no requieren que los escritores de compiladores apliquen una interpretación paralizante, la mayoría de los escritores de compiladores simplemente han interpretado las reglas durante años de una manera que conserva la semántica que hizo que C sea útil para la programación de sistemas; los programadores no tenían ningún motivo para quejarse de que el Estándar no exigía que los compiladores se comportaran con sensatez porque desde su perspectiva parecía obvio para todos que debían hacerlo a pesar de la descuido del Estándar. Mientras tanto, sin embargo, algunas personas insisten en que, dado que el Estándar siempre ha permitido que los compiladores procesen un subconjunto semánticamente debilitado del lenguaje de programación de sistemas de Ritchie, no hay razón para que un compilador que cumpla con los estándares procese cualquier otra cosa.
La solución sensata para este problema sería reconocer que C se usa para propósitos suficientemente variados para que haya múltiples modos de compilación: un modo requerido trataría todos los accesos de todo cuya dirección se tomó como si leyeran y escribieran el almacenamiento subyacente directamente , y sería compatible con el código que espera cualquier nivel de soporte de punteo de tipo basado en puntero. Otro modo podría ser más restrictivo que C11, excepto cuando el código usa explícitamente directivas para indicar cuándo y dónde el almacenamiento que se ha utilizado como un tipo necesitaría ser reinterpretado o reciclado para su uso como otro. Otros modos permitirían algunas optimizaciones pero admitirían algún código que se rompería bajo dialectos más estrictos; los compiladores sin soporte específico para un dialecto particular podrían sustituir uno con comportamientos de alias más definidos.
C estándar (es decir, C11, n1570), 6.5p7 :
Un objeto tendrá acceso a su valor almacenado solo mediante una expresión lvalue que tenga uno de los siguientes tipos:
- ...
- un tipo agregado o de unión que incluye uno de los tipos antes mencionados entre sus miembros (incluido, recursivamente, un miembro de una unión agregada o contenida), o un tipo de carácter.
Las expresiones de valor de sus punteros
no
son tipos de
union
, por lo tanto, esta excepción no se aplica.
El compilador es correcto explotando este comportamiento indefinido.
Haga los punteros de los tipos de punteros al tipo de
union
y desreferencia con el miembro respectivo.
Eso debería funcionar:
union {
...
} u, *i, *p;