Al preguntar sobre el comportamiento indefinido común en C , las personas a veces se refieren a la estricta regla de alias.
De qué están hablando?
Al preguntar sobre el comportamiento indefinido común en C , las personas a veces se refieren a la estricta regla de alias.
De qué están hablando?
Respuestas:
Una situación típica en la que encuentra problemas de alias estrictos es cuando superpone una estructura (como un mensaje de dispositivo / red) en un búfer del tamaño de palabra de su sistema (como un puntero a uint32_t
so uint16_t
s). Cuando superpone una estructura en dicho búfer, o un búfer en dicha estructura a través de la conversión del puntero, puede violar fácilmente las estrictas reglas de alias.
Entonces, en este tipo de configuración, si quiero enviar un mensaje a algo, tendría que tener dos punteros incompatibles apuntando a la misma porción de memoria. Entonces podría codificar ingenuamente algo como esto (en un sistema con sizeof(int) == 2
):
typedef struct Msg
{
unsigned int a;
unsigned int b;
} Msg;
void SendWord(uint32_t);
int main(void)
{
// Get a 32-bit buffer from the system
uint32_t* buff = malloc(sizeof(Msg));
// Alias that buffer through message
Msg* msg = (Msg*)(buff);
// Send a bunch of messages
for (int i =0; i < 10; ++i)
{
msg->a = i;
msg->b = i+1;
SendWord(buff[0]);
SendWord(buff[1]);
}
}
La estricta regla de alias hace que esta configuración sea ilegal: desreferenciar un puntero que alias un objeto que no es de un tipo compatible o uno de los otros tipos permitidos por C 2011 6.5 párrafo 7 1 es un comportamiento indefinido. Desafortunadamente, todavía puede codificar de esta manera, tal vez obtener algunas advertencias, compilarlo bien, solo para tener un comportamiento extraño e inesperado cuando ejecuta el código.
(GCC parece algo inconsistente en su capacidad de dar advertencias de aliasing, a veces dándonos una advertencia amistosa y otras no).
Para ver por qué este comportamiento es indefinido, tenemos que pensar en lo que la regla de alias estricto compra al compilador. Básicamente, con esta regla, no tiene que pensar en insertar instrucciones para actualizar el contenido de buff
cada ejecución del ciclo. En cambio, cuando se optimiza, con algunas suposiciones molestas sobre el alias, puede omitir esas instrucciones, cargar buff[0]
y buff[1
] en los registros de la CPU una vez antes de que se ejecute el bucle, y acelerar el cuerpo del bucle. Antes de que se introdujera un alias estricto, el compilador tenía que vivir en un estado de paranoia que el contenido debuff
podía cambiar en cualquier momento y en cualquier lugar. Entonces, para obtener una ventaja de rendimiento adicional, y suponiendo que la mayoría de las personas no escriben punteros, se introdujo la estricta regla de alias.
Tenga en cuenta que si cree que el ejemplo está ideado, esto podría suceder incluso si pasa un búfer a otra función que realiza el envío por usted, si es que lo ha hecho.
void SendMessage(uint32_t* buff, size_t size32)
{
for (int i = 0; i < size32; ++i)
{
SendWord(buff[i]);
}
}
Y reescribió nuestro bucle anterior para aprovechar esta conveniente función
for (int i = 0; i < 10; ++i)
{
msg->a = i;
msg->b = i+1;
SendMessage(buff, 2);
}
El compilador puede o no ser capaz o lo suficientemente inteligente como para intentar enviar SendMessage en línea y puede o no decidir cargar o no cargar buff nuevamente. Si SendMessage
es parte de otra API que se compila por separado, probablemente tenga instrucciones para cargar el contenido de buff. Por otra parte, tal vez estás en C ++ y esta es una implementación de solo encabezado con plantilla que el compilador cree que puede en línea. O tal vez es algo que escribió en su archivo .c para su propia conveniencia. De todos modos, todavía podría seguir un comportamiento indefinido. Incluso cuando sabemos algo de lo que sucede debajo del capó, sigue siendo una violación de la regla, por lo que no se garantiza un comportamiento bien definido. Entonces, simplemente envolviendo una función que toma nuestro búfer delimitado por palabras no necesariamente ayuda.
Entonces, ¿cómo puedo evitar esto?
Usa una unión. La mayoría de los compiladores admiten esto sin quejarse de un alias estricto. Esto está permitido en C99 y explícitamente en C11.
union {
Msg msg;
unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)];
};
Puede desactivar el alias estricto en su compilador ( f [no-] alias estricto en gcc))
Puede usar char*
para crear alias en lugar de la palabra de su sistema. Las reglas permiten una excepción para char*
(incluyendo signed char
y unsigned char
). Siempre se supone que char*
alias otros tipos. Sin embargo, esto no funcionará de otra manera: no se supone que su estructura alias un búfer de caracteres.
Principiante ten cuidado
Este es solo un campo minado potencial cuando se superponen dos tipos entre sí. También debe aprender sobre endianness , alineación de palabras y cómo lidiar con problemas de alineación a través de estructuras de empaque correctamente.
1 Los tipos a los que C 2011 6.5 7 le permite acceder a un valor son:
unsigned char*
puede usar lejos char*
? Tiendo a usar en unsigned char
lugar de char
como el tipo subyacente byte
porque mis bytes no están firmados y no quiero que la rareza del comportamiento firmado (especialmente wrt se desborde)
unsigned char *
está bien.
uint32_t* buff = malloc(sizeof(Msg));
y las siguientes unsigned int asBuffer[sizeof(Msg)];
declaraciones de búfer de unión tendrán diferentes tamaños y ninguna de ellas es correcta. La malloc
llamada se basa en la alineación de 4 bytes debajo del capó (no lo hagas) y la unión será 4 veces más grande de lo necesario ... Entiendo que es por claridad, pero no me molesta. menos ...
La mejor explicación que he encontrado es Mike Acton, Understanding Strict Aliasing . Se centra un poco en el desarrollo de PS3, pero eso es básicamente solo GCC.
Del artículo:
"El alias estricto es una suposición, hecha por el compilador de C (o C ++), de que desreferenciar los punteros a objetos de diferentes tipos nunca se referirá a la misma ubicación de memoria (es decir, alias entre sí)".
Así que, básicamente, si tiene una int*
señal que apunta a alguna memoria que contiene una int
y luego señala una float*
a esa memoria y la usa como una float
infracción de la regla. Si su código no respeta esto, entonces el optimizador del compilador probablemente romperá su código.
La excepción a la regla es un char*
, que puede apuntar a cualquier tipo.
Esta es la estricta regla de alias, que se encuentra en la sección 3.10 del estándar C ++ 03 (otras respuestas proporcionan una buena explicación, pero ninguna proporcionó la regla en sí):
Si un programa intenta acceder al valor almacenado de un objeto a través de un valor l diferente de uno de los siguientes tipos, el comportamiento no está definido:
- el tipo dinámico del objeto
- una versión calificada por cv del tipo dinámico del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente al tipo dinámico del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente a una versión calificada por cv del tipo dinámico 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 que es un tipo de clase base (posiblemente calificado por cv) del tipo dinámico del objeto,
- a
char
ounsigned char
tipo.
Texto de C ++ 11 y C ++ 14 (cambios enfatizados):
Si un programa intenta acceder al valor almacenado de un objeto a través de un valor gl diferente de uno de los siguientes tipos, el comportamiento no está definido:
- el tipo dinámico del objeto
- una versión calificada por cv del tipo dinámico del objeto,
- un tipo similar (como se define en 4.4) al tipo dinámico del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente al tipo dinámico del objeto,
- un tipo que es el tipo con signo o sin signo correspondiente a una versión calificada por cv del tipo dinámico del objeto,
- un tipo de agregado o unión que incluye uno de los tipos antes mencionados entre sus elementos o miembros de datos no estáticos (incluido, recursivamente, un elemento o miembro de datos no estático de un subaggregado o unión contenida),
- un tipo que es un tipo de clase base (posiblemente calificado por cv) del tipo dinámico del objeto,
- a
char
ounsigned char
tipo.
Dos cambios fueron pequeños: glvalue en lugar de lvalue , y aclaración del caso agregado / unión.
El tercer cambio ofrece una garantía más sólida (relaja la fuerte regla de alias): el nuevo concepto de tipos similares que ahora son seguros para el alias.
También la redacción C (C99; ISO / IEC 9899: 1999 6.5 / 7; exactamente la misma redacción se utiliza en ISO / IEC 9899: 2011 §6.5 ¶7):
Un objeto tendrá acceso a su valor almacenado solo mediante una expresión lvalue que tenga uno de los siguientes tipos 73) u 88) :
- 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
73) u 88) La intención de esta lista es especificar aquellas circunstancias en las que un objeto puede o no tener alias.
wow(&u->s1,&u->s2)
debería ser legal incluso cuando se usa un puntero para modificar u
, y eso negaría la mayoría de las optimizaciones la regla de alias fue diseñada para facilitar.
Esto se extrae de mi "¿Cuál es la regla de alias estricto y por qué nos importa?" redacción
En C y C ++, el alias tiene que ver con qué tipos de expresión se nos permite acceder a los valores almacenados. Tanto en C como en C ++, el estándar especifica qué tipos de expresión tienen permiso para alias de qué tipos. El compilador y el optimizador pueden asumir que seguimos estrictamente las reglas de alias, de ahí el término regla de alias estricto . Si intentamos acceder a un valor utilizando un tipo no permitido, se clasifica como comportamiento indefinido ( UB ). Una vez que tenemos un comportamiento indefinido, todas las apuestas se cancelan, los resultados de nuestro programa ya no son confiables.
Desafortunadamente, con violaciones de alias estrictas, a menudo obtendremos los resultados que esperamos, dejando la posibilidad de que una versión futura de un compilador con una nueva optimización rompa el código que pensamos que era válido. Esto no es deseable y es un objetivo que vale la pena comprender las estrictas reglas de alias y cómo evitar violarlas.
Para entender más acerca de por qué nos importa, discutiremos los problemas que surgen cuando se violan las estrictas reglas de alias, el tipo de punteo, ya que las técnicas comunes utilizadas en el tipo de punteo a menudo violan las estrictas reglas de alias y cómo escribir el juego de palabras correctamente.
Veamos algunos ejemplos, luego podemos hablar sobre exactamente lo que dicen los estándares, examinar algunos ejemplos adicionales y luego ver cómo evitar el alias estricto y detectar las violaciones que pasamos por alto. Aquí hay un ejemplo que no debería sorprender ( ejemplo en vivo ):
int x = 10;
int *ip = &x;
std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";
Tenemos un int * que apunta a la memoria ocupada por un int y este es un alias válido. El optimizador debe asumir que las asignaciones a través de ip podrían actualizar el valor ocupado por x .
El siguiente ejemplo muestra alias que conduce a un comportamiento indefinido ( ejemplo en vivo ):
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x << "\n"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x << "\n"; // Expect 0?
}
En la función foo tomamos un int * y un float * , en este ejemplo llamamos a foo y establecemos ambos parámetros para que apunten a la misma ubicación de memoria que en este ejemplo contiene un int . Tenga en cuenta que reinterpret_cast le dice al compilador que trate la expresión como si tuviera el tipo especificado por su parámetro de plantilla. En este caso, le estamos diciendo que trate la expresión & x como si tuviera el tipo float * . Podemos esperar ingenuamente que el resultado del segundo cout sea 0 pero con la optimización habilitada usando -O2, tanto gcc como clang producen el siguiente resultado:
0
1
Lo cual puede no esperarse pero es perfectamente válido ya que hemos invocado un comportamiento indefinido. Un flotante no puede alias válidamente un objeto int . Por lo tanto, el optimizador puede asumir que la constante 1 almacenada al desreferenciar i será el valor de retorno ya que una tienda a través de f no podría afectar válidamente un objeto int . Conectar el código en el Explorador de compiladores muestra que esto es exactamente lo que está sucediendo ( ejemplo en vivo ):
foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret
El optimizador usando Análisis Alias Tipo-Based (TBAA) asume 1 serán devueltos y se mueve directamente el valor constante en el registro EAX que lleva el valor de retorno. TBAA usa las reglas de idiomas sobre qué tipos están permitidos para alias para optimizar cargas y tiendas. En este caso, TBAA sabe que un flotador no puede alias e int y optimiza la carga de i .
¿Qué dice exactamente la norma que se nos permite y no se nos permite hacer? El lenguaje estándar no es sencillo, por lo que para cada elemento intentaré proporcionar ejemplos de código que demuestren el significado.
El estándar C11 dice lo siguiente en la sección 6.5 Expresiones párrafo 7 :
A un objeto se le 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,
int x = 1;
int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int
- una versión calificada de un tipo compatible con el tipo efectivo del objeto,
int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int
- un tipo que es el tipo con signo o sin signo correspondiente al tipo efectivo del objeto,
int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to
// the effective type of the object
gcc / clang tiene una extensión y también que permite asignar int * a int * sin firmar aunque no sean tipos compatibles.
- un tipo que es el tipo con signo o sin signo correspondiente a una versión calificada del tipo efectivo del objeto,
int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type
// that corresponds with to a qualified verison of the effective type of the object
- 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
struct foo {
int x;
};
void foobar( struct foo *fp, int *ip ); // struct foo is an aggregate that includes int among its members so it can
// can alias with *ip
foo f;
foobar( &f, &f.x );
- un tipo de personaje.
int x = 65;
char *p = (char *)&x;
printf("%c\n", *p ); // *p gives us an lvalue expression of type char which is a character type.
// The results are not portable due to endianness issues.
El borrador del estándar C ++ 17 en la sección [basic.lval] párrafo 11 dice:
Si un programa intenta acceder al valor almacenado de un objeto a través de un valor gl diferente de uno de los siguientes tipos, el comportamiento no está definido: 63 (11.1): el tipo dinámico del objeto,
void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0}; // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n"; // *ip gives us a glvalue expression of type int which matches the dynamic type
// of the allocated object
(11.2) - una versión calificada por cv del tipo dinámico del objeto,
int x = 1;
const int *cip = &x;
std::cout << *cip << "\n"; // *cip gives us a glvalue expression of type const int which is a cv-qualified
// version of the dynamic type of x
(11.3) - un tipo similar (como se define en 7.5) al tipo dinámico del objeto,
(11.4) - un tipo que es el tipo con signo o sin signo correspondiente al tipo dinámico del objeto,
// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
si = 1;
ui = 2;
return si;
}
(11.5) - un tipo que es el tipo con signo o sin signo correspondiente a una versión calificada por cv del tipo dinámico del objeto,
signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing
(11.6) - un tipo de agregado o unión que incluye uno de los tipos antes mencionados entre sus elementos o miembros de datos no estáticos (incluido, recursivamente, un elemento o miembro de datos no estático de un subaggregado o unión contenida),
struct foo {
int x;
};
// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
fp.x = 1;
ip = 2;
return fp.x;
}
foo f;
foobar( f, f.x );
(11.7) - un tipo que es un tipo de clase base (posiblemente calificado por cv) del tipo dinámico del objeto,
struct foo { int x ; };
struct bar : public foo {};
int foobar( foo &f, bar &b ) {
f.x = 1;
b.x = 2;
return f.x;
}
(11.8): un tipo char, unsigned char o std :: byte.
int foo( std::byte &b, uint32_t &ui ) {
b = static_cast<std::byte>('a');
ui = 0xFFFFFFFF;
return std::to_integer<int>( b ); // b gives us a glvalue expression of type std::byte which can alias
// an object of type uint32_t
}
Vale la pena señalar que el carácter firmado no está incluido en la lista anterior, esta es una diferencia notable de C que dice un tipo de carácter .
Hemos llegado a este punto y podemos preguntarnos, ¿por qué querríamos alias? La respuesta generalmente es escribir un juego de palabras , a menudo los métodos utilizados violan estrictas reglas de alias.
A veces queremos evitar el sistema de tipos e interpretar un objeto como un tipo diferente. Esto se denomina punteo de tipo , para reinterpretar un segmento de memoria como otro tipo. La escritura de tipos es útil para tareas que desean acceder a la representación subyacente de un objeto para ver, transportar o manipular. Las áreas típicas que encontramos que se utilizan son los compiladores, la serialización, el código de red, etc.
Tradicionalmente, esto se ha logrado tomando la dirección del objeto, convirtiéndolo en un puntero del tipo con el que queremos reinterpretarlo y luego accediendo al valor, o en otras palabras, aliasing. Por ejemplo:
int x = 1 ;
// In C
float *fp = (float*)&x ; // Not a valid aliasing
// In C++
float *fp = reinterpret_cast<float*>(&x) ; // Not a valid aliasing
printf( "%f\n", *fp ) ;
Como hemos visto anteriormente, este no es un alias válido, por lo que estamos invocando un comportamiento indefinido. Pero tradicionalmente los compiladores no aprovechaban las estrictas reglas de alias y este tipo de código generalmente funcionaba, desafortunadamente los desarrolladores se han acostumbrado a hacer las cosas de esta manera. Un método alternativo común para el tipo de punteo es a través de uniones, que es válido en C pero comportamiento indefinido en C ++ ( ver ejemplo en vivo ):
union u1
{
int n;
float f;
} ;
union u1 u;
u.f = 1.0f;
printf( "%d\n”, u.n ); // UB in C++ n is not the active member
Esto no es válido en C ++ y algunos consideran que el propósito de las uniones es únicamente para implementar tipos de variantes y sienten que el uso de uniones para el castigo de tipo es un abuso.
El método estándar para la escritura de tipos en C y C ++ es memcpy . Esto puede parecer un poco pesado, pero el optimizador debe reconocer el uso de memcpy para la escritura de tipo y optimizarlo y generar un registro para registrar el movimiento. Por ejemplo, si sabemos que int64_t tiene el mismo tamaño que el doble :
static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17 does not require a message
podemos usar memcpy :
void func1( double d ) {
std::int64_t n;
std::memcpy(&n, &d, sizeof d);
//...
En un nivel de optimización suficiente, cualquier compilador moderno decente genera un código idéntico al método reinterpret_cast mencionado anteriormente o al método de unión para el tipo punning . Examinando el código generado, vemos que usa solo registrar mov ( ejemplo de Live Compiler Explorer ).
En C ++ 20 podemos obtener bit_cast ( implementación disponible en el enlace de la propuesta ) que proporciona una forma simple y segura de escribir juegos de palabras, además de ser utilizable en un contexto constexpr.
El siguiente es un ejemplo de cómo usar bit_cast para escribir pun un int no firmado para flotar , ( verlo en vivo ):
std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)
En el caso de que los tipos To y From no tengan el mismo tamaño, requiere que usemos una estructura intermedia15. Usaremos una estructura que contenga una matriz de caracteres sizeof (unsigned int) (se supone que 4 bytes unsigned int ) es el tipo From y unsigned int como el tipo To . :
struct uint_chars {
unsigned char arr[sizeof( unsigned int )] = {} ; // Assume sizeof( unsigned int ) == 4
};
// Assume len is a multiple of 4
int bar( unsigned char *p, size_t len ) {
int result = 0;
for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
uint_chars f;
std::memcpy( f.arr, &p[index], sizeof(unsigned int));
unsigned int result = bit_cast<unsigned int>(f);
result += foo( result );
}
return result ;
}
Es lamentable que necesitemos este tipo intermedio, pero esa es la restricción actual de bit_cast .
No tenemos muchas herramientas buenas para detectar el alias estricto en C ++, las herramientas que tenemos detectarán algunos casos de violaciones de alias estricto y algunos casos de cargas y tiendas desalineadas.
gcc usando la bandera -fstrict-aliasing y -Wstrict-aliasing puede detectar algunos casos, aunque no sin falsos positivos / negativos. Por ejemplo, los siguientes casos generarán una advertencia en gcc ( verlo en vivo ):
int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught
// it was being accessed w/ an indeterminate value below
printf("%i\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));
aunque no captará este caso adicional ( verlo en vivo ):
int *p;
p=&a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));
Aunque el sonido metálico permite estas banderas, aparentemente no implementa las advertencias.
Otra herramienta que tenemos disponible es ASan, que puede detectar cargas y tiendas desalineadas. Aunque estas no son violaciones de alias estrictamente directas, son un resultado común de violaciones de alias estrictas. Por ejemplo, los siguientes casos generarán errores de tiempo de ejecución cuando se crean con clang usando -fsanitize = address
int *x = new int[2]; // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6); // regardless of alignment of x this will not be an aligned address
*u = 1; // Access to range [6-9]
printf( "%d\n", *u ); // Access to range [6-9]
La última herramienta que recomendaré es específica de C ++ y no estrictamente una herramienta, sino una práctica de codificación, no permita conversiones de estilo C. Tanto gcc como clang producirán un diagnóstico para los lanzamientos de estilo C usando -Wold-style-cast . Esto forzará a cualquier juego de palabras de tipo indefinido a usar reinterpret_cast, en general reinterpret_cast debería ser una bandera para una revisión más detallada del código. También es más fácil buscar en su base de código reinterpret_cast para realizar una auditoría.
Para C tenemos todas las herramientas ya cubiertas y también tenemos tis-interpreter, un analizador estático que analiza exhaustivamente un programa para un gran subconjunto del lenguaje C. Dada una versión en C del ejemplo anterior donde el uso de -fstrict-aliasing pierde un caso ( verlo en vivo )
int a = 1;
short j;
float f = 1.0 ;
printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));
int *p;
p=&a;
printf("%i\n", j = *((short*)p));
tis-interpeter es capaz de atrapar a los tres, el siguiente ejemplo invoca tis-kernal como tis-interpreter (la salida se edita por brevedad):
./bin/tis-kernel -sa example1.c
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
rules by accessing a cell with effective type int.
...
example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
accessing a cell with effective type float.
Callstack: main
...
example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
accessing a cell with effective type int.
Finalmente está TySan, que actualmente está en desarrollo. Este desinfectante agrega información de verificación de tipos en un segmento de memoria secundaria y verifica los accesos para ver si violan las reglas de alias. La herramienta debería poder detectar todas las infracciones de alias pero puede tener una gran sobrecarga de tiempo de ejecución.
reinterpret_cast
podría hacer o lo que cout
podría significar. (Está bien mencionar C ++, pero la pregunta original era sobre C y IIUC, estos ejemplos podrían escribirse igualmente en C.)
El alias estricto no se refiere solo a los punteros, sino que también afecta a las referencias, escribí un artículo al respecto para la wiki de desarrollador de impulso y fue tan bien recibido que lo convertí en una página en mi sitio web de consultoría. Explica completamente qué es, por qué confunde tanto a las personas y qué hacer al respecto. Libro blanco de alias estricto . En particular, explica por qué las uniones son un comportamiento arriesgado para C ++, y por qué usar memcpy es la única solución portátil en C y C ++. Espero que esto sea útil.
Como anexo a lo que Doug T. ya escribió, aquí hay un caso de prueba simple que probablemente lo desencadena con gcc:
check.c
#include <stdio.h>
void check(short *h,long *k)
{
*h=5;
*k=6;
if (*h == 5)
printf("strict aliasing problem\n");
}
int main(void)
{
long k[1];
check((short *)k,k);
return 0;
}
Compilar con gcc -O2 -o check check.c
. Usualmente (con la mayoría de las versiones de gcc que probé) esto genera un "problema de alias estricto", porque el compilador supone que "h" no puede ser la misma dirección que "k" en la función "verificar". Por eso, el compilador optimiza el if (*h == 5)
ausente y siempre llama a printf.
Para aquellos que estén interesados aquí está el código de ensamblador x64, producido por gcc 4.6.3, que se ejecuta en ubuntu 12.04.2 para x64:
movw $5, (%rdi)
movq $6, (%rsi)
movl $.LC0, %edi
jmp puts
Entonces, la condición if desapareció por completo del código del ensamblador.
long long*
y int64_t
*). Uno podría esperar que un compilador sensata debería reconocer que una long long*
y int64_t*
podría tener acceso a la misma de almacenamiento si están almacenados de forma idéntica, pero dicho tratamiento ya no está de moda.
La escritura de tipos mediante el uso de punteros (en lugar de usar una unión) es un ejemplo importante de romper el alias estricto.
fpsync()
directiva entre escribir como fp y leer como int o viceversa [en implementaciones con tuberías y cachés de enteros y FPU por separado , tal directiva podría ser costosa, pero no tan costosa como hacer que el compilador realice dicha sincronización en cada acceso de unión]. O una implementación podría especificar que el valor resultante nunca será utilizable, excepto en circunstancias que utilizan secuencias iniciales comunes.
Según la justificación de C89, los autores de la Norma no querían exigir que los compiladores dieran código como:
int x;
int test(double *p)
{
x=5;
*p = 1.0;
return x;
}
se debe exigir que vuelva a cargar el valor de x
entre la asignación y la declaración de retorno para permitir la posibilidad de que p
pueda apuntar x
, y la asignación de *p
puede alterar en consecuencia el valor de x
. La noción de que un compilador debería tener derecho a suponer que no habrá alias en situaciones como las anteriores no fue controvertida.
Desafortunadamente, los autores del C89 escribieron su regla de una manera que, si se lee literalmente, haría que incluso la siguiente función invoque Comportamiento indefinido:
void test(void)
{
struct S {int x;} s;
s.x = 1;
}
porque usa un valor de tipo l int
para acceder a un objeto de tipo struct S
, y int
no está entre los tipos que se pueden usar para acceder a unstruct S
. Debido a que sería absurdo tratar todo uso de miembros de estructuras y uniones que no sean del tipo de caracteres como Comportamiento indefinido, casi todos reconocen que hay al menos algunas circunstancias en las que se puede usar un valor de un tipo para acceder a un objeto de otro tipo . Lamentablemente, el Comité de Normas C no ha podido definir cuáles son esas circunstancias.
Gran parte del problema es el resultado del Informe de defectos # 028, que preguntó sobre el comportamiento de un programa como:
int test(int *ip, double *dp)
{
*ip = 1;
*dp = 1.23;
return *ip;
}
int test2(void)
{
union U { int i; double d; } u;
return test(&u.i, &u.d);
}
El Informe de defectos n. ° 28 establece que el programa invoca Comportamiento indefinido porque la acción de escribir un miembro de unión de tipo "double" y leer uno de tipo "int" invoca un comportamiento definido por la implementación. Tal razonamiento no tiene sentido, pero forma la base de las reglas de Tipo efectivo que complican innecesariamente el lenguaje sin hacer nada para abordar el problema original.
La mejor manera de resolver el problema original probablemente sería tratar la nota al pie de página sobre el propósito de la regla como si fuera normativa, y hacer que la regla no se pueda hacer cumplir, excepto en los casos que realmente involucran accesos conflictivos usando alias. Dado algo como:
void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
s.x = 1;
p = &s.x;
inc_int(p);
return s.x;
}
No hay conflicto en el interior inc_int
porque todos los accesos al almacenamiento al que se accede *p
se realizan con un valor de tipo l int
, y no hay conflicto test
porque p
se deriva visiblemente de un struct S
, y para la próxima vez que s
se use, todos los accesos a ese almacenamiento se realizarán alguna vez a través p
ya habrá sucedido.
Si el código fuera cambiado ligeramente ...
void inc_int(int *p) { *p = 3; }
int test(void)
{
int *p;
struct S { int x; } s;
p = &s.x;
s.x = 1; // !!*!!
*p += 1;
return s.x;
}
Aquí, existe un conflicto de alias p
y el acceso a s.x
en la línea marcada porque en ese punto de ejecución existe otra referencia que se utilizará para acceder al mismo almacenamiento .
Si el Informe de defectos 028 decía que el ejemplo original invocaba a UB debido a la superposición entre la creación y el uso de los dos punteros, eso habría aclarado mucho las cosas sin tener que agregar "Tipos efectivos" u otra complejidad similar.
Después de leer muchas de las respuestas, siento la necesidad de agregar algo:
El alias estricto (que describiré en un momento) es importante porque :
El acceso a la memoria puede ser costoso (en cuanto al rendimiento), por lo que los datos se manipulan en los registros de la CPU antes de volver a escribirse en la memoria física.
Si los datos en dos registros de CPU diferentes se escribirán en el mismo espacio de memoria, no podemos predecir qué datos "sobrevivirán" cuando codifiquemos en C.
En el ensamblaje, donde codificamos la carga y descarga de los registros de la CPU manualmente, sabremos qué datos permanecen intactos. Pero C (afortunadamente) abstrae este detalle.
Dado que dos punteros pueden apuntar a la misma ubicación en la memoria, esto podría resultar en un código complejo que maneja posibles colisiones .
Este código adicional es lento y perjudica el rendimiento ya que realiza operaciones adicionales de lectura / escritura de memoria que son más lentas y (posiblemente) innecesarias.
La regla de alias estricto nos permite evitar el código de máquina redundante en los casos en que debería ser seguro asumir que dos punteros no apuntan al mismo bloque de memoria (ver también la restrict
palabra clave).
El alias estricto indica que es seguro asumir que los punteros a diferentes tipos apuntan a diferentes ubicaciones en la memoria.
Si un compilador nota que dos punteros apuntan a diferentes tipos (por ejemplo, an int *
y a float *
), asumirá que la dirección de memoria es diferente y no protegerá contra colisiones de direcciones de memoria, lo que resulta en un código de máquina más rápido.
Por ejemplo :
Asumamos la siguiente función:
void merge_two_ints(int *a, int *b) {
*b += *a;
*a += *b;
}
Para manejar el caso en el que a == b
(ambos punteros apuntan a la misma memoria), necesitamos ordenar y probar la forma en que cargamos datos de la memoria a los registros de la CPU, por lo que el código podría terminar así:
carga a
y b
de memoria.
añadir a
a b
.
guardar b
y recargar a
.
(guardar desde el registro de la CPU en la memoria y cargar desde la memoria en el registro de la CPU).
añadir b
a a
.
guardar a
(desde el registro de la CPU) en la memoria.
El paso 3 es muy lento porque necesita acceder a la memoria física. Sin embargo, se requiere para proteger contra instancias donde a
y b
apuntar a la misma dirección de memoria.
El alias estricto nos permitiría evitar esto al decirle al compilador que estas direcciones de memoria son claramente diferentes (lo que, en este caso, permitirá una optimización aún mayor que no se puede realizar si los punteros comparten una dirección de memoria).
Esto se puede decir al compilador de dos maneras, utilizando diferentes tipos para señalar. es decir:
void merge_two_numbers(int *a, long *b) {...}
Usando la restrict
palabra clave. es decir:
void merge_two_ints(int * restrict a, int * restrict b) {...}
Ahora, al cumplir la regla de Alias estricto, se puede evitar el paso 3 y el código se ejecutará significativamente más rápido.
De hecho, al agregar la restrict
palabra clave, toda la función podría optimizarse para:
carga a
y b
de memoria.
añadir a
a b
.
guardar resultado tanto para a
como para b
.
Esta optimización no podría haberse hecho antes, debido a la posible colisión (dónde a
y b
se triplicaría en lugar de duplicarse).
b
(no volviendo a cargarlo) y volviendo a cargar a
. Espero que sea más claro ahora.
restrict
, pero creo que este último en la mayoría de las circunstancias sería más efectivo, y aflojar algunas restricciones register
le permitiría completar algunos de los casos en los restrict
que no ayudaría. No estoy seguro de que alguna vez fue "importante" tratar el Estándar como una descripción completa de todos los casos en que los programadores deben esperar que los compiladores reconozcan la evidencia de alias, en lugar de simplemente describir los lugares donde los compiladores deben suponer alias, incluso cuando no existe evidencia particular de ello .
restrict
palabra clave minimiza no solo la velocidad de las operaciones, sino también su número, lo que podría ser significativo ... Quiero decir, después de todo, la operación más rápida es ninguna operación :)
El alias estricto no permite diferentes tipos de puntero a los mismos datos.
Este artículo debería ayudarlo a comprender el problema con todo detalle.
int
una estructura que contiene un int
).
Técnicamente en C ++, la estricta regla de alias probablemente nunca sea aplicable.
Tenga en cuenta la definición de indirección ( * operador ):
El operador unario * realiza indirectamente: la expresión a la que se aplica será un puntero a un tipo de objeto, o un puntero a un tipo de función y el resultado es un valor l que se refiere al objeto o función al que apunta la expresión .
También de la definición de glvalue
Un glvalue es una expresión cuya evaluación determina la identidad de un objeto, (... snip)
Entonces, en cualquier rastreo de programa bien definido, un valor de gl se refiere a un objeto. Por lo tanto, la llamada regla de alias estricto no se aplica, nunca. Esto puede no ser lo que los diseñadores querían.
int foo;
, ¿a qué accede la expresión lvalue *(char*)&foo
? ¿Es eso un objeto de tipo char
? ¿Ese objeto llega a existir al mismo tiempo que foo
? ¿Escribiría para foo
cambiar el valor almacenado de ese objeto de tipo mencionado anteriormente char
? Si es así, ¿hay alguna regla que permita char
acceder al valor almacenado de un objeto de tipo utilizando un valor de tipo l int
?
int i;
crea cuatro objetos de cada tipo de carácter in addition to one of type
int ? I see no way to apply a consistent definition of "object" which would allow for operations on both
* (char *) & i` y i
. Finalmente, no hay nada en el Estándar que permita que incluso un volatile
puntero calificado acceda a registros de hardware que no cumplan con la definición de "objeto".
c
yc++faq
.