Sí, ISO C ++ permite (pero no requiere) implementaciones para tomar esta decisión.
Pero también tenga en cuenta que ISO C ++ permite que un compilador emita código que se bloquea a propósito (por ejemplo, con una instrucción ilegal) si el programa encuentra UB, por ejemplo, como una forma de ayudarlo a encontrar errores. (O porque es una DeathStation 9000. Cumplir estrictamente no es suficiente para que una implementación de C ++ sea útil para cualquier propósito real). Por lo tanto, ISO C ++ permitiría que un compilador creara un asm que se bloqueó (por razones totalmente diferentes) incluso en un código similar que lee un no inicializado uint32_t
. Aunque se requiere que sea un tipo de diseño fijo sin representaciones de trampa.
Es una pregunta interesante sobre cómo funcionan las implementaciones reales, pero recuerde que incluso si la respuesta fuera diferente, su código seguiría siendo inseguro porque C ++ moderno no es una versión portátil del lenguaje ensamblador.
Estás compilando para el x86-64 System V ABI , que especifica que a bool
como función arg en un registro está representado por los patrones de bitsfalse=0
ytrue=1
en los 8 bits bajos del registro 1 . En la memoria, bool
es un tipo de 1 byte que nuevamente debe tener un valor entero de 0 o 1.
(Un ABI es un conjunto de opciones de implementación que los compiladores de la misma plataforma acuerdan para que puedan crear códigos que se invoquen entre sí, incluidos los tamaños de tipo, las reglas de diseño de estructura y las convenciones de llamada).
ISO C ++ no lo especifica, pero esta decisión ABI está muy extendida porque hace que la conversión bool-> int sea barata (solo extensión cero) . No conozco ninguna ABI que no permita que el compilador asuma 0 o 1 bool
para cualquier arquitectura (no solo x86). Permite optimizaciones como !mybool
con xor eax,1
para voltear el bit bajo: cualquier código posible que pueda voltear un bit / entero / bool entre 0 y 1 en una sola instrucción de CPU . O compilando a&&b
un bit a bit Y para bool
tipos. Algunos compiladores realmente aprovechan los valores booleanos como 8 bits en los compiladores. ¿Las operaciones en ellos son ineficientes? .
En general, la regla as-if permite que el compilador aproveche las cosas que son verdaderas en la plataforma objetivo para la que se compila , porque el resultado final será un código ejecutable que implementa el mismo comportamiento visible externamente como la fuente C ++. (Con todas las restricciones que Undefined Behavior impone a lo que en realidad es "externamente visible": no con un depurador, sino desde otro hilo en un programa C ++ bien formado / legal).
El compilador es, sin duda permitió a sacar el máximo provecho de una garantía ABI en su código-gen, y hacer que el código que has encontrado lo que optimiza strlen(whichString)
a
5U - boolValue
. (Por cierto, esta optimización es un poco inteligente, pero tal vez miope versus ramificación y alineación memcpy
como almacenes de datos inmediatos 2 ).
O el compilador podría haber creado una tabla de punteros e indexarla con el valor entero de bool
, suponiendo nuevamente que era un 0 o 1. ( Esta posibilidad es lo que sugiere la respuesta de @ Barmar ).
Su __attribute((noinline))
constructor con la optimización habilitada condujo al sonido metálico simplemente cargando un byte de la pila para usar como uninitializedBool
. Hizo espacio para el objeto main
con push rax
(que es más pequeño y por varias razones casi tan eficiente como sub rsp, 8
), por lo que cualquier basura que haya en AL al entrar main
es el valor para el que se usó uninitializedBool
. Es por eso que realmente obtuviste valores que no eran solo0
.
5U - random garbage
puede ajustarse fácilmente a un gran valor sin signo, lo que lleva a la memoria a ir a la memoria sin asignar. El destino está en el almacenamiento estático, no en la pila, por lo que no está sobrescribiendo una dirección de retorno o algo así.
Otras implementaciones podrían tomar diferentes decisiones, por ejemplo, false=0
y true=any non-zero value
. Entonces, el sonido de claxon probablemente no generaría código que se bloquee para esta instancia específica de UB. (Pero aún así se permitiría si quisiera). No conozco ninguna implementación que elija otra cosa para lo que x86-64 hacebool
, pero el estándar C ++ permite muchas cosas que nadie hace o incluso querría hacer. hardware similar a las CPU actuales.
ISO C ++ deja sin especificar lo que encontrará cuando examine o modifique la representación de objeto de abool
. (p. ej. memcpy
al bool
ingresar unsigned char
, lo que se le permite hacer porque char*
puede alias cualquier cosa. Yunsigned char
se garantiza que no tendrá bits de relleno, por lo que el estándar C ++ le permite formalmente representar objetos hexagonales sin ningún UB. Proyección de puntero para copiar el objeto la representación es diferente de la asignación char foo = my_bool
, por supuesto, por lo que la booleanización a 0 o 1 no sucedería y obtendría la representación del objeto sin procesar).
Ha "ocultado" parcialmente la UB en esta ruta de ejecución desde el compilador connoinline
. Sin embargo, incluso si no está en línea, las optimizaciones interprocediales aún podrían hacer una versión de la función que depende de la definición de otra función. (Primero, clang está haciendo un ejecutable, no una biblioteca compartida de Unix donde puede ocurrir la interposición de símbolos. Segundo, la definición dentro de la class{}
definición, por lo que todas las unidades de traducción deben tener la misma definición.inline
palabra clave).
Por lo tanto, un compilador podría emitir solo una ret
o ud2
(instrucción ilegal) como la definición de main
, porque la ruta de ejecución que comienza en la parte superior main
inevitablemente encuentra un Comportamiento indefinido. (Lo que el compilador puede ver en el momento de la compilación si decide seguir la ruta a través del constructor no en línea).
Cualquier programa que encuentre UB está totalmente indefinido para toda su existencia. Pero UB dentro de una función o if()
rama que nunca se ejecuta realmente no corrompe el resto del programa. En la práctica, eso significa que los compiladores pueden decidir emitir una instrucción ilegal, o a ret
, o no emitir nada y caer en el siguiente bloque / función, para que todo el bloque básico que se puede probar en el momento de la compilación contenga o conduzca a UB.
CCG y Sonido metálico en la práctica hacen realidad a veces emiten ud2
en la UB, en lugar de intentar siquiera para generar código para rutas de ejecución que no tienen sentido. O para casos como caerse del final de una no void
función, a veces gcc omitirá una ret
instrucción. Si estabas pensando que "mi función simplemente volverá con cualquier basura que haya en RAX", estás muy equivocado. Los compiladores modernos de C ++ ya no tratan el lenguaje como un lenguaje ensamblador portátil. Su programa realmente tiene que ser C ++ válido, sin hacer suposiciones acerca de cómo una versión independiente no en línea de su función podría verse en asm.
Otro ejemplo divertido es ¿Por qué el acceso no alineado a la memoria mmap'ed a veces es predeterminado en AMD64? . x86 no falla en enteros no alineados, ¿verdad? Entonces, ¿por qué un desalineado uint16_t*
sería un problema? Porque alignof(uint16_t) == 2
, y violar esa suposición condujo a una segfault cuando se auto-vectoriza con SSE2.
Vea también Lo que todo programador de C debe saber sobre el comportamiento indefinido # 1/3 , un artículo de un desarrollador de clang.
Punto clave: si el compilador notó el UB en el momento de la compilación, podría "romper" (emitir un asm sorprendente) la ruta a través de su código que causa UB incluso si apunta a un ABI donde cualquier patrón de bits es una representación de objeto válida bool
.
Espere una hostilidad total hacia muchos errores por parte del programador, especialmente de lo que advierten los compiladores modernos. Es por eso que debe usar -Wall
y corregir las advertencias. C ++ no es un lenguaje fácil de usar, y algo en C ++ puede ser inseguro incluso si estaría seguro en el destino para el que está compilando. (por ejemplo, el desbordamiento firmado es UB en C ++ y los compiladores supondrán que no sucede, incluso cuando se compila para el complemento x86 de 2, a menos que useclang/gcc -fwrapv
).
La UB visible en tiempo de compilación siempre es peligrosa, y es realmente difícil estar seguro (con la optimización del tiempo de enlace) de que realmente ha ocultado UB del compilador y, por lo tanto, puede razonar sobre qué tipo de asm generará.
No ser demasiado dramático; a menudo los compiladores le permiten salirse con la suya y emitir código como espera incluso cuando algo es UB. Pero tal vez sea un problema en el futuro si los desarrolladores del compilador implementan una optimización que obtiene más información sobre los rangos de valores (por ejemplo, que una variable no es negativa, lo que quizás le permita optimizar la extensión de signo para liberar la extensión cero en x86- 64) Por ejemplo, en gcc y clang actuales, hacer tmp = a+INT_MIN
no se optimiza a<0
como siempre falso, solo que tmp
siempre es negativo. (Porque INT_MIN
+ a=INT_MAX
es negativo en el objetivo del complemento de este 2, ya
no puede ser más alto que eso).
Por lo tanto, gcc / clang no retrocede actualmente para derivar información de rango para las entradas de un cálculo, solo en los resultados basados en la suposición de un desbordamiento no firmado: ejemplo en Godbolt . No sé si esta optimización se "pierde" intencionalmente en nombre de la facilidad de uso o qué.
También tenga en cuenta que las implementaciones (también conocidas como compiladores) pueden definir el comportamiento que ISO C ++ deja sin definir . Por ejemplo, todos los compiladores que admiten los elementos intrínsecos de Intel (como _mm_add_ps(__m128, __m128)
la vectorización SIMD manual) deben permitir formar punteros mal alineados, que es UB en C ++, incluso si no los desreferencia. __m128i _mm_loadu_si128(const __m128i *)
realiza cargas no alineadas tomando un __m128i*
argumento desalineado , no un void*
o char*
. ¿Es `reinterpret_cast`ing entre el puntero de vector de hardware y el tipo correspondiente un comportamiento indefinido?
GNU C / C ++ también define el comportamiento de desplazar a la izquierda un número con signo negativo (incluso sin -fwrapv
), por separado de las reglas normales de UB con desbordamiento con signo. ( Esto es UB en ISO C ++ , mientras que los desplazamientos a la derecha de los números con signo están definidos por la implementación (lógica frente a aritmética); las implementaciones de buena calidad eligen la aritmética en HW que tiene desplazamientos aritméticos a la derecha, pero ISO C ++ no especifica). Esto se documenta en la sección Integer del manual de GCC , junto con la definición del comportamiento definido por la implementación de que los estándares C requieren implementaciones para definir de una forma u otra.
Definitivamente, hay problemas de calidad de implementación que preocupan a los desarrolladores de compiladores; Por lo general, no están tratando de hacer compiladores que sean intencionalmente hostiles, pero aprovecharse de todos los baches UB en C ++ (excepto los que eligen definir) para optimizar mejor puede ser casi indistinguible a veces.
Nota al pie 1 : Los 56 bits superiores pueden ser basura que el destinatario debe ignorar, como es habitual para los tipos más estrechos que un registro.
( Otros ABIs hacen tomar decisiones diferentes aquí . Algunos no requieren tipos enteros estrechas ser cero o signo extendido para llenar un registro o cuando se pasa a regresar de funciones, como MIPS64 y PowerPC64. Ver la última sección de esta respuesta x86-64 que se compara con los ISA anteriores ).
Por ejemplo, una persona que llama podría haber calculado a & 0x01010101
en RDI y haberlo usado para otra cosa, antes de llamar bool_func(a&1)
. La persona que llama podría optimizar el &1
porque ya lo hizo al byte bajo como parte de and edi, 0x01010101
, y sabe que la persona que llama debe ignorar los bytes altos.
O si se pasa un bool como el tercer argumento, tal vez una persona que llama que optimiza el tamaño del código lo carga en mov dl, [mem]
lugar de movzx edx, [mem]
guardar 1 byte a costa de una falsa dependencia del valor anterior de RDX (u otro efecto de registro parcial, dependiendo en modelo de CPU). O para el primer argumento, en mov dil, byte [r10]
lugar de movzx edi, byte [r10]
, porque ambos requieren un prefijo REX de todos modos.
Esta es la razón por la cual se emite el sonido metálico movzx eax, dil
en Serialize
lugar de sub eax, edi
. (Para los argumentos enteros, clang viola esta regla ABI, dependiendo del comportamiento indocumentado de gcc y clang a enteros estrechos de extensión cero o signo a 32 bits. Se requiere una extensión signo o cero al agregar un desplazamiento de 32 bits a un puntero para el x86-64 ABI?
Así que me interesó ver que no hace lo mismo bool
).
Nota a pie de página 2: después de bifurcar, solo tendría una mov
tienda de 4 bytes inmediata o una tienda de 4 bytes + 1 byte. La longitud está implícita en los anchos de tienda + compensaciones.
OTOH, glibc memcpy hará dos cargas / tiendas de 4 bytes con una superposición que depende de la longitud, por lo que esto realmente hace que todo esté libre de ramas condicionales en el booleano. Vea el L(between_4_7):
bloque en la memoria / memoria de glibc. O al menos, siga el mismo camino para cualquiera de los booleanos en la ramificación de memcpy para seleccionar un tamaño de fragmento.
Si está en línea, puede usar 2x mov
-media + cmov
y un desplazamiento condicional, o puede dejar los datos de la cadena en la memoria.
O si se está ajustando para Intel Ice Lake ( con la función Fast Short REP MOV ), un real rep movsb
podría ser óptimo. glibc memcpy
podría comenzar a usar rep movsb
para tamaños pequeños en CPU con esa función, ahorrando muchas ramificaciones.
Herramientas para detectar UB y uso de valores no inicializados
En gcc y clang, puede compilar -fsanitize=undefined
para agregar instrumentación en tiempo de ejecución que avisará o generará un error en UB que ocurre en tiempo de ejecución. Sin embargo, eso no captará variables unitarias. (Debido a que no aumenta los tamaños de letra para dejar espacio para un bit "no inicializado").
Ver https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
Para encontrar el uso de datos no inicializados, hay Address Sanitizer y Memory Sanitizer en clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer muestra ejemplos de clang -fsanitize=memory -fPIE -pie
detección de lecturas de memoria no inicializadas. Podría funcionar mejor si compila sin optimización, por lo que todas las lecturas de variables terminan realmente cargándose de la memoria en el asm. Muestran que se usa en los-O2
en un caso en el que la carga no se optimizaría. No lo he intentado yo mismo. (En algunos casos, por ejemplo, no inicializando un acumulador antes de sumar una matriz, clang -O3 emitirá un código que suma un registro vectorial que nunca se inicializó. Entonces, con la optimización, puede tener un caso en el que no hay lectura de memoria asociada con la UB . Pero-fsanitize=memory
cambia el asm generado, y podría resultar en una verificación para esto).
Tolerará la copia de memoria no inicializada, y también operaciones lógicas y aritméticas simples con ella. En general, MemorySanitizer rastrea silenciosamente la propagación de datos no inicializados en la memoria e informa una advertencia cuando se toma (o no se toma) una ramificación de código, según un valor no inicializado.
MemorySanitizer implementa un subconjunto de funcionalidades que se encuentra en Valgrind (herramienta Memcheck).
Debería funcionar para este caso porque la llamada a glibc memcpy
con una length
memoria calculada a partir de memoria no inicializada (dentro de la biblioteca) dará como resultado una bifurcación basada en length
. Si hubiera incluido una versión completamente sin ramificaciones que acaba de usar cmov
, indexación y dos tiendas, podría no haber funcionado.
Valgrindmemcheck
también buscará este tipo de problema, de nuevo no se quejará si el programa simplemente copia datos no inicializados. Pero dice que detectará cuándo un "salto o movimiento condicional depende de valores no inicializados", para tratar de detectar cualquier comportamiento visible desde el exterior que dependa de datos no inicializados.
Quizás la idea detrás de no marcar solo una carga es que las estructuras pueden tener relleno, y copiar toda la estructura (incluido el relleno) con una carga / almacén de vector ancho no es un error, incluso si los miembros individuales solo se escribieron uno a la vez. En el nivel asm, se ha perdido la información sobre lo que estaba rellenando y lo que realmente es parte del valor.