¿El estándar de C++ permite que un bool no inicializado bloquee un programa?
llvm undefined-behavior (5)
Sí, ISO C ++ permite (pero no requiere) implementaciones para hacer esta elección.
Pero también tenga en cuenta que ISO C ++ le permite a un compilador emitir código que falla 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 un DeathStation 9000. Ser estrictamente conforme no es suficiente para que una implementación de C ++ sea útil para cualquier propósito real).
Así que ISO C ++ permitiría que un compilador hiciera un asm que se bloqueara (por razones totalmente diferentes) incluso en un código similar que lee un
uint32_t
inicializar.
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 aún sería inseguro porque C ++ moderno no es una versión portátil del lenguaje ensamblador.
Está compilando para el
ABI de System V x86-64
, que especifica que un
bool
como función arg en un registro está representado por los patrones de bits
false=0
y
true=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 un código que llame a las funciones de los demá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 es generalizada porque hace que la conversión de bool -> int sea más barata (solo una extensión cero)
.
No tengo conocimiento de ninguna ABI que no permita que el compilador asuma 0 o 1 para
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 puede voltear un bit / entero / bool entre 0 y 1 en una sola instrucción de CPU
.
O compilando
a&&b
a bitwise AND para tipos de
bool
.
Algunos compiladores realmente aprovechan
los valores booleanos como 8 bits en los compiladores.
¿Son ineficientes las operaciones sobre ellos?
.
En general, la regla as-if permite que el compilador se aproveche de las cosas que son verdaderas en la plataforma de destino que se está compilando , porque el resultado final será un código ejecutable que implementa el mismo comportamiento visible externamente que la fuente de C ++. (Con todas las restricciones que impone el comportamiento indefinido en lo que en realidad es "externamente visible": no con un depurador, sino desde otro hilo en un programa de C ++ bien formado / legal).
Definitivamente, se permite al compilador sacar el máximo provecho de una garantía ABI en su código-gen, y hacer que el código como el que usted encuentra optimice
strlen(whichString)
para
5U - boolValue
.
(Por cierto, esta optimización es
memcpy
inteligente, pero quizás miope frente a ramificación e
memcpy
como almacenes de datos inmediatos
2
).
O el compilador podría haber creado una tabla de punteros y haberla indexado con el valor entero de
bool
, asumiendo nuevamente que era un 0 o 1. (
Esta posibilidad es lo que sugiere la respuesta de @Barmar
).
Su
__attribute((noinline))
con la optimización habilitada hizo que se cuelgue un byte de la pila para usarlo como
uninitializedBool
.
Hizo espacio para el objeto en
main
con
push rax
(que es más pequeño y por varias razones tan eficiente como
sub rsp, 8
), por lo que cualquier basura en AL en la entrada a
main
es el valor que usó para
uninitializedBool
.
Esta es la razón por la que realmente obtuviste valores que no eran solo
0
.
5U - random garbage
puede envolverse fácilmente en un gran valor sin firmar, lo que lleva a memcpy 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 hacer elecciones diferentes, por ejemplo,
false=0
y
true=any non-zero value
.
Entonces clang probablemente no haría código que falla para
esta
instancia específica de UB.
(Pero aún así se permitiría si lo quisiera).
No conozco ninguna implementación que elija otra cosa que hace x86-64 para
bool
, pero el estándar C ++ permite muchas cosas que nadie hace o incluso querría hacer. en hardware que se parece a las CPU actuales.
ISO C ++ deja sin especificar lo que encontrará cuando examine o modifique la representación del objeto de un
bool
.
(p. ej., al
memcpy
el
bool
en
unsigned char
, se le permite hacerlo porque
char*
puede alias cualquier cosa. Y se garantiza que el
unsigned char
no tiene bits de relleno, por lo que el estándar de C ++ le permite formalmente hexadecir las representaciones de objetos sin UB La conversión de punteros para copiar la representación del objeto es diferente de la asignación de
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 formato.)
Has "ocultado"
parcialmente
la UB en esta ruta de ejecución desde el compilador con
noinline
.
Sin embargo, aunque no esté en línea, las optimizaciones interprocedenciales 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á creando un ejecutable, no una biblioteca compartida de Unix donde puede ocurrir la interposición de símbolos. Segundo, la definición dentro de la definición de la
class{}
por lo que todas las unidades de traducción deben tener la misma definición. Al igual que con la palabra clave en
inline
).
Por lo tanto, un compilador podría emitir solo un
ret
o
ud2
(instrucción ilegal) como la definición de
main
, porque la ruta de ejecución que comienza en la parte superior de
main
inevitablemente encuentra un comportamiento indefinido.
(Que el compilador puede ver en tiempo de compilación si decide seguir la ruta a través del constructor no en línea).
Cualquier programa que se encuentre con UB es 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 un
ret
, o no emitir nada y caer en el siguiente bloque / función, para que todo el bloque básico pueda demostrarse en tiempo de compilación que contenga o conduzca a UB.
GCC y Clang en la práctica a veces emiten
ud2
en UB, en lugar de incluso intentar generar código para rutas de ejecución que no tienen sentido.
O para casos como la caída del final de una función no
void
, gcc a veces omite una instrucción
ret
.
Si pensabas que "mi función simplemente regresará con cualquier basura que esté en RAX", estás muy equivocado.
Los compiladores modernos de C ++ ya no tratan el lenguaje como un lenguaje de ensamblado portátil.
Su programa realmente tiene que ser válido en C ++, sin hacer suposiciones sobre cómo podría verse una versión autónoma no incorporada de su función en asm.
Otro ejemplo divertido es
¿Por qué el acceso no alineado a la memoria mmap''ed a veces falla en AMD64?
.
x86 no falla en enteros no alineados, ¿verdad?
Entonces, ¿por qué un
uint16_t*
desalineado sería un problema?
Porque
alignof(uint16_t) == 2
, y violar esa suposición llevó a un error de segregación cuando se auto-vectoriza con SSE2.
Vea también Lo que todo programador de C debería 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 se dirige a un ABI donde cualquier patrón de bits es una representación de objeto válida para
bool
.
Espere una total hostilidad hacia muchos errores por parte del programador, especialmente sobre lo que advierten los compiladores modernos.
Esta es la razón por la que debes usar
-Wall
y corregir las advertencias.
C ++ no es un lenguaje fácil de usar, y algo en C ++ puede ser inseguro, incluso si sería seguro en asm en el destino que está compilando.
(Por ejemplo, el desbordamiento firmado es UB en C ++ y los compiladores asumirán que no sucede, incluso cuando compila para el complemento x86 de 2, a menos que use
clang/gcc -fwrapv
.)
La UB visible en tiempo de compilación siempre es peligrosa, y es muy difícil estar seguro (con la optimización de tiempo de enlace) de que realmente ocultó la UB del compilador y, por lo tanto, puede razonar qué tipo de asm generará.
No ser demasiado dramático;
a menudo los compiladores te permiten salirse con la suya con algunas cosas y emitir código como lo estás esperando incluso cuando algo es UB.
Pero tal vez sea un problema en el futuro si los desarrolladores del compilador implementan una optimización que obtenga más información sobre los rangos de valores (por ejemplo, que una variable no es negativa, tal vez 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 optimiza
a<0
como siempre falso, solo que
tmp
siempre es negativo.
(Debido a que
INT_MIN
+
a=INT_MAX
es negativo en el objetivo de complemento de este 2, y no puede ser más alto que eso).
Por lo tanto, gcc / clang no retrocede en la actualidad para obtener información de rango para las entradas de un cálculo, solo en los resultados basados en el supuesto de no desbordamiento firmado: ejemplo en Godbolt . No sé si esta optimización es intencionalmente "perdida" 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 soportan los intrínsecos de Intel (como
_mm_add_ps(__m128, __m128)
para la vectorización manual de SIMD) deben permitir la formación de punteros mal alineados, que es UB en C ++, incluso si
no los
elimina.
__m128i _mm_loadu_si128(const __m128i *)
realiza cargas no alineadas tomando una
__m128i*
arg desalineada, 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 de desbordamiento con signo.
(
Esto es UB en ISO C ++
, mientras que los desplazamientos a la derecha de los números firmados están definidos por la implementación (lógico frente a aritmético); las implementaciones de buena calidad eligen la aritmética en HW que tiene desplazamientos 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 que los estándares de C requieren implementaciones para definir de una manera u otra.
Definitivamente hay problemas de calidad de implementación que a los desarrolladores de compiladores les importan; por lo general, no están tratando de hacer compiladores que son intencionalmente hostiles, pero aprovechar los baches de UB en C ++ (excepto los que ellos 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 en los tipos más estrechos que un registro.
( Otras ABI hacen elecciones diferentes aquí . Algunas requieren que los tipos de enteros estrechos sean cero o con signo extendido para completar un registro cuando se pasan o se devuelven desde funciones, como MIPS64 y PowerPC64. Vea la última sección de esta respuesta x86-64 que compara con esas ISA anteriores .)
Por ejemplo, una persona que llama podría haber calculado
a & 0x01010101
en RDI y haberlo utilizado para otra cosa, antes de llamar a
bool_func(a&1)
.
El autor de la llamada podría optimizar el
&1
porque ya lo hizo con el byte bajo como parte de
and edi, 0x01010101
, y sabe que el destinatario debe ignorar los bytes altos.
O si se pasa un bool como el tercer argumento, tal vez una persona que llama optimice para el tamaño del código lo cargue con
mov dl, [mem]
lugar de
movzx edx, [mem]
, ahorrando 1 byte al costo de una dependencia falsa en el antiguo valor de RDX (u otro efecto de registro parcial, según el modelo de CPU).
O para el primer argumento,
mov dil, byte [r10]
lugar de
movzx edi, byte [r10]
, ya que ambos requieren un prefijo REX de todos modos.
Es por esto que
movzx eax, dil
emite
movzx eax, dil
en
Serialize
, en lugar de
sub eax, edi
.
(Para los argumentos de enteros, clang infringe esta regla ABI, en lugar de eso, depende del comportamiento no documentado de gcc y clang a cero o amplía con signo los enteros estrechos a 32 bits.
Es una extensión de signo o cero requerida al agregar un desplazamiento de 32 bits a un puntero para ¿El ABI x86-64?
Así que me interesó ver que no hace lo mismo para
bool
.)
Nota al pie 2:
Después de la bifurcación, solo tendría un
mov
4media de 4 bytes, 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 / almacenes 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.
Consulte el
bloque
L(between_4_7):
en memcpy / memmove de glibc.
O al menos, vaya de la misma manera para cualquiera de los booleanos en la bifurcación de memcpy para seleccionar un tamaño de trozo.
Si está realizando la
cmov
, puede usar 2x
mov
-immediate +
cmov
y un desplazamiento condicional, o puede dejar los datos de la cadena en la memoria.
O bien, si la sintonización para Intel Ice Lake (
con la función Fast Short REP MOV
), un
rep movsb
real 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 con
-fsanitize=undefined
para agregar instrumentación de tiempo de ejecución que avisará o producirá un error en UB que suceda en tiempo de ejecución.
Sin embargo, eso no capturará las variables unificadas.
(Debido a que no aumenta el tamaño de los tipos para dejar espacio para un bit "sin inicializar").
Ver https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
Para encontrar el uso de datos sin inicializar, hay Desinfectante de direcciones y Desinfectante de la memoria en clang / LLVM.
https://github.com/google/sanitizers/wiki/MemorySanitizer
muestra ejemplos de
clang -fsanitize=memory -fPIE -pie
detecta lecturas de memoria no inicializadas.
Podría funcionar mejor si compila
sin
optimización, por lo que todas las lecturas de variables terminan en realidad cargando de la memoria en el asm.
Muestran que se está utilizando en
-O2
en un caso donde la carga no se optimizaría.
No lo he probado yo mismo.
(En algunos casos, por ejemplo, no se inicializa un acumulador antes de sumar una matriz, clang -O3 emitirá un código que se suma a un registro vectorial que nunca se inicializó. Por lo tanto, con la optimización, puede haber un caso en el que no haya lectura de memoria asociada con el UB . Pero
-fsanitize=memory
cambia el asm generado, y puede resultar en una verificación de esto.)
Tolerará la copia de memoria no inicializada y también operaciones lógicas y aritméticas simples con ella. En general, MemorySanitizer realiza un seguimiento silencioso de la propagación de datos no inicializados en la memoria, e informa de una advertencia cuando se toma una rama de código (o no se toma) en función de un valor no inicializado.
MemorySanitizer implementa un subconjunto de funcionalidad que se encuentra en Valgrind (herramienta Memcheck).
Debería funcionar para este caso porque la llamada a glibc
memcpy
con una
length
calculada a partir de la memoria sin inicializar (dentro de la biblioteca) resultará en una rama basada en la
length
.
Si hubiera incorporado una versión completamente sin
cmov
que solo utilizaba
cmov
, indexación y dos tiendas, podría no haber funcionado.
El
memcheck
de Valgrind
también buscará este tipo de problema, de nuevo sin quejarse si el programa simplemente copia alrededor de datos sin inicializar.
Pero dice que detectará cuando un "Salto o movimiento condicional depende de los valores no inicializados", para intentar detectar cualquier comportamiento externo visible que dependa de los datos no inicializados.
Quizás la idea detrás de no marcar solo una carga es que las estructuras pueden tener relleno, y copiar la estructura completa (incluido el relleno) con una gran carga / almacenamiento vectorial no es un error, incluso si los miembros individuales solo se escribieron uno a la vez. En el nivel de asm, se ha perdido la información sobre qué era el relleno y qué es realmente parte del valor.
Sé que un "comportamiento indefinido" en C ++ puede permitir que el compilador haga lo que quiera. Sin embargo, tuve un accidente que me sorprendió, ya que asumí que el código era lo suficientemente seguro.
En este caso, el problema real ocurrió solo en una plataforma específica utilizando un compilador específico, y solo si la optimización estaba habilitada.
Probé varias cosas para reproducir el problema y simplificarlo al máximo.
Aquí hay un extracto de una función llamada
Serialize
, que tomaría un parámetro bool y copiaría la cadena
true
o
false
en un búfer de destino existente.
¿Estaría esta función en una revisión de código, no habría manera de saber que, de hecho, podría fallar si el parámetro bool fuera un valor no inicializado?
// Zero-filled global buffer of 16 characters
char destBuffer[16];
void Serialize(bool boolValue) {
// Determine which string to print based on boolValue
const char* whichString = boolValue ? "true" : "false";
// Compute the length of the string we selected
const size_t len = strlen(whichString);
// Copy string into destination buffer, which is zero-filled (thus already null-terminated)
memcpy(destBuffer, whichString, len);
}
Si este código se ejecuta con las optimizaciones de Clang 5.0.0 +, se bloqueará.
El esperado operador ternario
boolValue ? "true" : "false"
boolValue ? "true" : "false"
parecía lo suficientemente seguro para mí, estaba asumiendo que "el valor de la basura está en
boolValue
no importa, ya que se evaluará como verdadero o falso de todos modos".
He configurado un ejemplo del Explorador de compiladores que muestra el problema en el desmontaje, aquí el ejemplo completo. Nota: para reprozar el problema, la combinación que encontré que funcionó es usar Clang 5.0.0 con optimización de -O2.
#include <iostream>
#include <cstring>
// Simple struct, with an empty constructor that doesn''t initialize anything
struct FStruct {
bool uninitializedBool;
__attribute__ ((noinline)) // Note: the constructor must be declared noinline to trigger the problem
FStruct() {};
};
char destBuffer[16];
// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
// Determine which string to print depending if ''boolValue'' is evaluated as true or false
const char* whichString = boolValue ? "true" : "false";
// Compute the length of the string we selected
size_t len = strlen(whichString);
memcpy(destBuffer, whichString, len);
}
int main()
{
// Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
FStruct structInstance;
// Output "true" or "false" to stdout
Serialize(structInstance.uninitializedBool);
return 0;
}
El problema surge debido al optimizador: fue lo suficientemente inteligente como para deducir que las cadenas "verdadero" y "falso" solo difieren en longitud en 1. Entonces, en lugar de calcular realmente la longitud, utiliza el valor del bool en sí, que debería técnicamente sea 0 o 1, y va así:
const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue; // clang clever optimization
Si bien esto es "inteligente", por así decirlo, mi pregunta es: ¿el estándar C ++ permite que un compilador asuma que un bool solo puede tener una representación numérica interna de ''0'' o ''1'' y usarlo de tal manera?
¿O es este un caso de implementación definida, en cuyo caso la implementación asumió que todos sus bools solo contendrán 0 o 1, y cualquier otro valor es territorio de comportamiento indefinido?
A bool solo se le permite mantener los valores
0
o
1
, y el código generado puede asumir que solo mantendrá uno de estos dos valores.
El código generado para el ternario en la asignación podría usar el valor como índice en una matriz de punteros a las dos cadenas, es decir, podría convertirse en algo como:
// the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];
Si
boolValue
no está inicializado, en realidad podría contener cualquier valor entero, lo que provocaría el acceso fuera de los límites de la matriz de
strings
.
La función en sí es correcta, pero en su programa de prueba, la declaración que llama a la función causa un comportamiento indefinido al usar el valor de una variable sin inicializar.
El error está en la función de llamada, y podría ser detectado por la revisión del código o el análisis estático de la función de llamada. Usando el enlace del explorador del compilador, el compilador gcc 8.2 detecta el error. (Tal vez usted podría presentar un informe de error contra clang que no encuentra el problema).
Un comportamiento indefinido significa que puede pasar cualquier cosa , lo que incluye que el programa se bloquee unas pocas líneas después del evento que provocó el comportamiento indefinido.
NÓTESE BIEN. La respuesta a "¿Puede un comportamiento indefinido causar _____?" siempre es "si". Eso es, literalmente, la definición de comportamiento indefinido.
Resumiendo mucho su pregunta, está preguntando: ¿El estándar C ++ permite que un compilador suponga que un
bool
solo puede tener una representación numérica interna de ''0'' o ''1'' y usarla de tal manera?
El estándar no dice nada sobre la representación interna de un
bool
.
Solo define lo que sucede cuando se lanza un
bool
a un
int
(o viceversa).
Principalmente, debido a estas conversiones integrales (y al hecho de que las personas dependen bastante de ellas), el compilador usará 0 y 1, pero no tiene que hacerlo (aunque tiene que respetar las restricciones de cualquier ABI de nivel inferior que use ).
Entonces, el compilador, cuando ve un
bool
tiene derecho a considerar que dicho
bool
contiene cualquiera de los patrones de bits "
true
" o "
false
" y hace todo lo que se siente.
Entonces, si los valores de
true
y
false
son 1 y 0, respectivamente, al compilador se le permite optimizar
strlen
a
5 - <boolean value>
.
¡Otros comportamientos divertidos son posibles!
Como se afirma repetidamente aquí, el comportamiento indefinido tiene resultados indefinidos. Incluyendo pero no limitado a
- Tu código funciona como esperabas
- Tu código falla al azar
- Su código no se ejecuta en absoluto.
Vea lo que todo programador debe saber sobre el comportamiento indefinido
Se permite al compilador asumir que un valor booleano pasado como argumento es un valor booleano válido (es decir, uno que se haya inicializado o convertido en
true
o
false
).
El valor
true
no tiene que ser el mismo que el entero 1; de hecho, puede haber varias representaciones de
true
y
false
, pero el parámetro debe ser una representación válida de uno de esos dos valores, donde "representación válida" es la implementación definida.
Entonces, si no inicializa un
bool
, o si logra sobrescribirlo a través de algún puntero de un tipo diferente, entonces las suposiciones del compilador serán erróneas y se producirá un comportamiento indefinido.
Te habían advertido:
50) El uso de un valor bool en las formas descritas por esta Norma Internacional como "indefinido", como al examinar el valor de un objeto automático no inicializado, puede hacer que se comporte como si no fuera ni verdadero ni falso. (Nota al párrafo 6 de §6.9.1, Tipos fundamentales)