(O mecanografía, comportamiento vago y alineación, ¡Dios mío!)Amigos, queda muy poco tiempo antes del lanzamiento de un nuevo hilo en el curso
"C ++ Developer" . Es hora de publicar una traducción de la segunda parte del material, que informa sobre lo que está escribiendo un juego de palabras.
¿Qué es una tipificación de juego de palabras?Hemos llegado al punto en que podemos preguntarnos por qué podríamos necesitar seudónimos. Por lo general, para la implementación de juegos de palabras, tk. Los métodos utilizados con frecuencia violan las estrictas reglas de alias.

A veces queremos evitar el sistema de tipos e interpretar el objeto como otro tipo. Reinterpretar un segmento de memoria como otro tipo se llama
juego de palabras . Los juegos de palabras son útiles para tareas que requieren acceso a la representación base de un objeto para ver, transportar o manipular los datos proporcionados. Áreas típicas donde podemos encontrar el uso de juegos de palabras: compiladores, serialización, código de red, etc.
Tradicionalmente, esto se lograba tomando la dirección del objeto, convirtiéndolo en un puntero al tipo al que queremos interpretar, y luego accediendo al valor, o en otras palabras, usando alias. Por ejemplo:
int x = 1 ; // C float *fp = (float*)&x ; // // C++ float *fp = reinterpret_cast<float*>(&x) ; // printf( “%f\n”, *fp ) ;
Como vimos anteriormente, este es un alias inaceptable, esto causará un comportamiento indefinido. Pero tradicionalmente, los compiladores no usaban reglas de alias estrictas, y este tipo de código generalmente solo funcionaba, y los desarrolladores, desafortunadamente, están acostumbrados a permitir tales cosas. Un método alternativo común de tipeo es a través de la unión, que es válida en C, pero causará un comportamiento indefinido en C ++ (
ver ejemplo ):
union u1 { int n; float f; } ; union u1 u; uf = 1.0f; printf( "%d\n”, un ); // UB(undefined behaviour) C++ “n is not the active member”
Esto es inaceptable en C ++, y algunos creen que las uniones están destinadas únicamente a implementar tipos de variantes, y consideran que usar uniones para escribir juegos de palabras es un abuso.
¿Cómo implementar un juego de palabras?El método estándar bendecido para escribir juegos de palabras en C y C ++ es memcpy. Esto puede parecer un poco complicado, pero el optimizador necesita reconocer el uso de memcpy para el juego de palabras, 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
Podemos usar
memcpy
:
void func1( double d ) { std::int64_t n; std::memcpy(&n, &d, sizeof d); //…
Con un nivel suficiente de optimización, 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 obtener un juego de palabras. Al estudiar el código generado, vemos que solo usa el registro mov (
ejemplo ).
Tipos de juegos de palabras y matricesPero, ¿qué sucede si queremos implementar el juego de palabras de una matriz de caracteres sin signo en una serie de int sin signo y luego realizar una operación en cada valor int sin signo? Podemos usar memcpy para convertir una matriz de caracteres sin signo en un tipo int sin nombre temporal. El optimizador aún podrá ver todo a través de memcpy y optimizar tanto el objeto temporal como la copia, y trabajar directamente con los datos subyacentes (
ejemplo ):
// , int foo( unsigned int x ) { return x ; } // , len sizeof(unsigned int) int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { unsigned int ui = 0; std::memcpy( &ui, &p[index], sizeof(unsigned int) ); result += foo( ui ) ; } return result; }
En este ejemplo, tomamos
char*p
, suponemos que apunta a varios fragmentos de
sizeof(unsigned int)
, interpretamos cada fragmento de datos como
unsigned int
, calculamos
foo()
para cada fragmento del juego de palabras, sumamos el resultado y devolvemos el valor final .
El ensamblaje para el cuerpo del bucle muestra que el optimizador convierte el cuerpo en acceso directo a la matriz base de caracteres
unsigned int
como un
unsigned int
, y lo agrega directamente a
eax
:
add eax, dword ptr [rdi + rcx]
El mismo código, pero usando
reinterpret_cast
para implementar un juego de palabras (viola el alias estricto):
// , len sizeof(unsigned int) int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { unsigned int ui = *reinterpret_cast<unsigned int*>(&p[index]); result += foo( ui ); } return result; }
C ++ 20 y bit_castEn C ++ 20, tenemos
bit_cast
, que proporciona una forma fácil y segura de interpretar, y también se puede usar en el contexto de
constexpr
.
El siguiente es un ejemplo de cómo usar
bit_cast
para interpretar un entero sin signo en un
float
(
ejemplo ):
std::cout << bit_cast<float>(0x447a0000) << "\n" ; //, sizeof(float) == sizeof(unsigned int)
En el caso de que los tipos To y From no tengan el mismo tamaño, esto requiere que usemos una estructura intermedia. Utilizaremos una estructura que contenga una matriz de caracteres múltiplos de
sizeof(unsigned int)
(se supone un 4-byte unsigned int) como tipo From y
unsigned int
como To. Tipo:
struct uint_chars { unsigned char arr[sizeof( unsigned int )] = {} ; // sizeof( unsigned int ) == 4 }; // len 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 ; }
Desafortunadamente, necesitamos este tipo intermedio: esta es la limitación actual de
bit_cast
.
AlineaciónEn ejemplos anteriores, vimos que la violación de las estrictas reglas de alias puede conducir a la exclusión del almacenamiento durante la optimización. La violación del alias estricto también puede conducir a la violación de los requisitos de alineación. Tanto los estándares C como C ++ dicen que los objetos están sujetos a requisitos de alineación que limitan el lugar donde los objetos pueden colocarse (en la memoria) y, por lo tanto, ser accesibles.
C11 sección 6.2.8 Estados de alineación de objetos :
Los tipos completos de objetos tienen requisitos de alineación que imponen restricciones en las direcciones en las que se pueden colocar objetos de este tipo. La alineación es un valor entero definido por la implementación que representa el número de bytes entre direcciones consecutivas en las que se puede colocar este objeto. El tipo de objeto impone un requisito de alineación en cada objeto de este tipo: se puede solicitar una alineación más estricta utilizando la
_Alignas
.
El estándar del proyecto C ++ 17 en la sección 1 [basic.align] :
Los tipos de objeto tienen requisitos de alineación (6.7.1, 6.7.2) que imponen restricciones en las direcciones en las que se puede colocar un objeto de este tipo. La alineación es un valor entero definido por la implementación que representa el número de bytes entre direcciones consecutivas en las que se puede colocar un objeto determinado. Un tipo de objeto impone un requisito de alineación en cada objeto de este tipo; Se puede solicitar una alineación más estricta utilizando el especificador de alineación (10.6.2).
Tanto C99 como C11 indican explícitamente que una conversión que da como resultado un puntero no alineado es un comportamiento indefinido, sección 6.3.2.3.
Punteros dice:
Un puntero a un objeto o tipo parcial se puede convertir en un puntero a otro objeto o tipo parcial. Si el puntero resultante no está alineado correctamente para el tipo de puntero, el comportamiento es indefinido. ...
Aunque C ++ no es tan obvio, creo que esta oración del párrafo 1
[basic.align]
suficiente:
... El tipo de objeto impone un requisito de alineación en cada objeto de este tipo; ...
EjemploEntonces supongamos:
- alignof (char) y alignof (int) son 1 y 4 respectivamente
- sizeof (int) es 4
Por lo tanto, interpretar una matriz de caracteres de tamaño 4 como
int
viola el alias estricto y también puede violar los requisitos de alineación si la matriz tiene una alineación de 1 o 2 bytes.
char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; // 1 2 int x = *reinterpret_cast<int*>(arr); // Undefined behavior
Lo que puede resultar en un rendimiento reducido o error de bus en algunas situaciones. Mientras que usar alignas para forzar la misma alineación para una matriz en int evitará que se rompan los requisitos de alineación:
alignas(alignof(int)) char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; int x = *reinterpret_cast<int*>(arr);
AtomicidadOtro castigo inesperado para el acceso desequilibrado es que viola la atomicidad de algunas arquitecturas. Los almacenes atómicos pueden no parecer atómicos para otros hilos en x86 si no están alineados.
Capturar infracciones estrictas de aliasNo tenemos muchas buenas herramientas para rastrear alias estricto en C ++. Las herramientas que tenemos detectarán algunos casos de violaciones y algunos casos de carga y almacenamiento incorrectos.
gcc usando los
-fstrict-aliasing
y
-Wstrict-aliasing
puede detectar algunos casos, aunque no sin falsos positivos / problemas. Por ejemplo, los siguientes casos generarán una advertencia en gcc (
ejemplo ):
int a = 1; short j; float f = 1.f; // , TIS , printf("%i\n", j = *(reinterpret_cast<short*>(&a))); printf("%i\n", j = *(reinterpret_cast<int*>(&f)));
aunque no captará este caso adicional (
ejemplo ):
int *p; p=&a; printf("%i\n", j = *(reinterpret_cast<short*>(p)));
Aunque el
clang
resuelve estas banderas, no parece implementar realmente la advertencia.
Otra herramienta que tenemos es ASan, que puede detectar la grabación y el almacenamiento desalineados. Aunque no son violaciones directas de alias estricto, este es un resultado bastante común. Por ejemplo, los siguientes casos generarán errores de tiempo de ejecución durante el ensamblaje usando clang usando
-fsanitize=address
int *x = new int[2]; // 8 : [0,7]. int *u = (int*)((char*)x + 6); // x *u = 1; // [6-9] printf( "%d\n", *u ); // [6-9]
La última herramienta que recomiendo es específica para C ++ y, de hecho, no es solo una herramienta, sino también una práctica de codificación que no permite la conversión al estilo C. Tanto
gcc
como
clang
realizarán diagnósticos para
-Wold-style-cast
C usando
-Wold-style-cast
. Esto obligará a los juegos de palabras indefinidos a utilizar reinterpret_cast. En general,
reinterpret_cast
debería ser una señal para un análisis más completo del código.
También es más fácil buscar en la base de código
reinterpret_cast
para realizar una auditoría.
Para C, tenemos todas las herramientas que ya están descritas, y también tenemos
tis-interpreter
, un analizador estático que analiza exhaustivamente el programa para un gran subconjunto de C. Dadas las versiones C del ejemplo anterior, donde el uso de -fstrict-aliasing omite un caso (
ejemplo )
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));
El intérprete TIS puede interceptar los tres, el siguiente ejemplo llama al núcleo TIS como intérprete TIS (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.
Y finalmente,
TySan , que está en desarrollo. Este desinfectante agrega información de verificación de tipo al segmento de memoria secundaria y verifica los accesos para determinar si violan las reglas de alias. La herramienta debería ser capaz de rastrear todas las violaciones de alias, pero puede tener una gran sobrecarga en tiempo de ejecución.
ConclusiónAprendimos sobre las reglas de alias en C y C ++, lo que significa que el compilador espera que sigamos estrictamente estas reglas y aceptemos las consecuencias de no cumplirlas. Hemos aprendido algunas herramientas que pueden ayudarnos a identificar algunos abusos de seudónimo. Hemos visto que el uso habitual de aliasing es un juego de palabras de tipificación. También aprendimos cómo implementarlo correctamente.
Los optimizadores están mejorando gradualmente el análisis de alias basado en tipos y ya están rompiendo algunos códigos que se basan en violaciones estrictas de alias. Podemos esperar que las optimizaciones mejoren y rompan aún más código que antes funcionaba.
Tenemos métodos compatibles estándar listos para interpretar tipos. A veces, para las construcciones de depuración, estos métodos deben ser abstracciones libres. Tenemos varias herramientas para detectar violaciones graves de alias, pero para C ++ solo detectarán una pequeña parte de los casos, y para C usando el intérprete tis podemos rastrear la mayoría de las violaciones.
Gracias a quienes comentaron sobre este artículo: JF Bastien, Christopher Di Bella, Pascal Quoc, Matt P. Dziubinski, Patrice Roy y Olafur Vaage
Por supuesto, al final, todos los errores pertenecen al autor.
Así que la traducción de un material bastante grande ha llegado a su fin, la primera parte de la cual se puede leer
aquí . Y tradicionalmente lo invitamos a
la jornada de puertas abiertas , que se llevará a cabo el 14 de marzo por el jefe del departamento de desarrollo de tecnología en Rambler & Co -
Dmitry Shebordaev.