c++ - objeto - Conjunto intrigante para comparar std:: opcional de tipos primitivos
tipos de datos primitivos en c (3)
En x86 asm, lo peor que sucede es que un solo registro tiene un valor desconocido (o no sabe cuál de los dos valores posibles tiene, antiguo o nuevo, en caso de un posible pedido de memoria). Pero si su código no depende de ese valor de registro, está bien , a diferencia de C ++. C ++ UB significa que, en teoría, todo el programa está completamente diseñado después de un desbordamiento de enteros con signo, e incluso antes, a lo largo de las rutas de código que puede ver el compilador conducirá a UB. Nada de eso sucede en asm, al menos no en el código de espacio de usuario sin privilegios.
(Puede haber algunas cosas que puede hacer para provocar básicamente un comportamiento impredecible en todo el sistema en el kernel, estableciendo registros de control de formas extrañas o colocando cosas inconsistentes en las tablas de páginas o descriptores, pero eso no sucederá de algo como esto, incluso si estuvieras compilando el código del kernel.)
Algunos ISA tienen un "comportamiento impredecible", como un ARM temprano si usa el mismo registro para múltiples operandos de una multiplicación, el comportamiento es impredecible. IDK si esto permite romper la tubería y corromper otros registros, o si está restringido a un resultado multiplicado inesperado. Este último sería mi conjetura.
O MIPS, si coloca una rama en la ranura de retardo de rama, el comportamiento es impredecible. (El manejo de las excepciones es complicado debido a las ranuras de retardo de ramificación ...). Pero, presumiblemente, todavía hay límites y no puede bloquear la máquina o romper otros procesos (en un sistema multiusuario como Unix, sería malo si un proceso de espacio de usuario sin privilegios pudiera romper algo para otros usuarios).
Los MIPS muy tempranos también tenían ranuras de retardo de carga y multiplican las ranuras de retardo: no podía usar el resultado de una carga en la siguiente instrucción. Es de suponer que podría obtener el valor anterior del registro si lo leyó demasiado pronto, o tal vez solo basura. MIPS = Etapas de tubería mínimamente entrelazadas; querían descargar el estancamiento al software, pero resultó que agregar un NOP cuando el compilador no pudo encontrar nada útil para hacer los próximos binarios inflados y condujo a un código general más lento en lugar de tener el hardware parado cuando fue necesario. Pero estamos atascados con ranuras de retardo de sucursal porque eliminarlas cambiaría la ISA, a diferencia de relajar una restricción en algo que el software anterior no hizo.
Valgrind captó una ráfaga El salto o movimiento condicional depende de los valores no inicializados en una de mis pruebas de unidad.
Al inspeccionar el ensamblaje, me di cuenta de que el siguiente código:
bool operator==(MyType const& left, MyType const& right) {
// ... some code ...
if (left.getA() != right.getA()) { return false; }
// ... some code ...
return true;
}
Donde MyType::getA() const -> std::optional<std::uint8_t>
, generó el siguiente ensamblado:
0x00000000004d9588 <+108>: xor eax,eax
0x00000000004d958a <+110>: cmp BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>: je 0x4d9597 <... function... +123>
x 0x00000000004d9591 <+117>: mov r15b,BYTE PTR [r14+0x1c]
x 0x00000000004d9595 <+121>: mov al,0x1
0x00000000004d9597 <+123>: xor edx,edx
0x00000000004d9599 <+125>: cmp BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>: je 0x4d95ae <... function... +146>
x 0x00000000004d95a0 <+132>: mov dil,BYTE PTR [r13+0x1c]
x 0x00000000004d95a4 <+136>: mov dl,0x1
x 0x00000000004d95a6 <+138>: mov BYTE PTR [rsp+0x97],dil
0x00000000004d95ae <+146>: cmp al,dl
0x00000000004d95b0 <+148>: jne 0x4da547 <... function... +4139>
0x00000000004d95b6 <+154>: cmp r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>: je 0x4d95c8 <... function... +172>
=> Jump on uninitialized
0x00000000004d95c0 <+164>: test al,al
0x00000000004d95c2 <+166>: jne 0x4da547 <... function... +4139>
Donde marqué con x
las declaraciones que no se ejecutaron (saltaron) en el caso en que el opcional NO está establecido.
El miembro A
aquí está en el desplazamiento 0x1c
en MyType
. Comprobando el diseño de std::optional
vemos que:
-
+0x1d
corresponde abool _M_engaged
, -
+0x1c
corresponde astd::uint8_t _M_payload
(dentro de una unión anónima).
El código de interés para std::optional
es:
constexpr explicit operator bool() const noexcept
{ return this->_M_is_engaged(); }
// Comparisons between optional values.
template<typename _Tp, typename _Up>
constexpr auto operator==(const optional<_Tp>& __lhs, const optional<_Up>& __rhs) -> __optional_relop_t<decltype(declval<_Tp>() == declval<_Up>())>
{
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
&& (!__lhs || *__lhs == *__rhs);
}
Aquí, podemos ver que gcc transformó el código bastante sustancialmente; Si lo entiendo correctamente, en C esto da:
char rsp[0x148]; // simulate the stack
/* comparisons of prior data members */
/*
0x00000000004d9588 <+108>: xor eax,eax
0x00000000004d958a <+110>: cmp BYTE PTR [r14+0x1d],0x0
0x00000000004d958f <+115>: je 0x4d9597 <... function... +123>
0x00000000004d9591 <+117>: mov r15b,BYTE PTR [r14+0x1c]
0x00000000004d9595 <+121>: mov al,0x1
*/
int eax = 0;
if (__lhs._M_engaged == 0) { goto b123; }
bool r15b = __lhs._M_payload;
eax = 1;
b123:
/*
0x00000000004d9597 <+123>: xor edx,edx
0x00000000004d9599 <+125>: cmp BYTE PTR [r13+0x1d],0x0
0x00000000004d959e <+130>: je 0x4d95ae <... function... +146>
0x00000000004d95a0 <+132>: mov dil,BYTE PTR [r13+0x1c]
0x00000000004d95a4 <+136>: mov dl,0x1
0x00000000004d95a6 <+138>: mov BYTE PTR [rsp+0x97],dil
*/
int edx = 0;
if (__rhs._M_engaged == 0) { goto b146; }
rdi = __rhs._M_payload;
edx = 1;
rsp[0x97] = rdi;
b146:
/*
0x00000000004d95ae <+146>: cmp al,dl
0x00000000004d95b0 <+148>: jne 0x4da547 <... function... +4139>
*/
if (eax != edx) { goto end; } // return false
/*
0x00000000004d95b6 <+154>: cmp r15b,BYTE PTR [rsp+0x97]
0x00000000004d95be <+162>: je 0x4d95c8 <... function... +172>
*/
// Flagged by valgrind
if (r15b == rsp[097]) { goto b172; } // next data member
/*
0x00000000004d95c0 <+164>: test al,al
0x00000000004d95c2 <+166>: jne 0x4da547 <... function... +4139>
*/
if (eax == 1) { goto end; } // return false
b172:
/* comparison of following data members */
end:
return false;
Lo que equivale a:
// Note how the operands of || are inversed.
return static_cast<bool>(__lhs) == static_cast<bool>(__rhs)
&& (*__lhs == *__rhs || !__lhs);
Creo que el montaje es correcto, aunque extraño. Es decir, por lo que puedo ver, el resultado de la comparación entre valores no inicializados en realidad no influye en el resultado de la función (y, a diferencia de C o C ++, espero que comparar basura en el ensamblaje x86 NO sea UB):
- Si uno opcional es
nullopt
y el otro está configurado, entonces el salto condicional en+148
salta paraend
(return false
), OK. - Si se configuran ambas opciones, la comparación lee los valores inicializados, OK.
Entonces el único caso de interés es cuando ambos opcionales son nullopt
:
- si los valores se comparan igual, entonces el código concluye que los opcionales son iguales, lo cual es cierto ya que ambos son
nullopt
, - de lo contrario, el código concluye que los opcionales son iguales si
__lhs._M_engaged
es falso, lo cual es cierto.
En cualquier caso, el código por lo tanto concluye que ambos opcionales son iguales cuando ambos son nullopt
; CQFD.
Este es el primer ejemplo que veo de gcc que genera lecturas no inicializadas aparentemente "benignas" y, por lo tanto, tengo algunas preguntas:
- ¿Las lecturas sin inicializar son correctas en el ensamblaje (x84_64)?
- ¿Es este el síndrome de una optimización fallida (reversión
||
) que podría desencadenarse en circunstancias no benignas?
Por ahora, me inclino por anotar las pocas funciones con optimize(1)
como una solución alternativa para evitar que se activen las optimizaciones. Afortunadamente, las funciones identificadas no son críticas para el rendimiento.
Ambiente:
- compilador: gcc 7.3
- banderas de compilación:
-std=c++17 -g -Wall -Werror -O3 -flto
(+ incluye apropiado) - banderas de enlace:
-O3 -flto
(+ bibliotecas apropiadas)
Nota: puede aparecer con -O2
lugar de -O3
, pero nunca sin -flto
.
Hechos graciosos
En el código completo, este patrón aparece 32 veces en la función descrita anteriormente, para varias cargas útiles: std::uint8_t
, std::uint32_t
, std::uint64_t
e incluso una struct { std::int64_t; std::int8_t; }
struct { std::int64_t; std::int8_t; }
struct { std::int64_t; std::int8_t; }
.
Solo aparece en algunos operator==
grandes operator==
comparando tipos con ~ 40 miembros de datos, no en los más pequeños. Y no aparece para std::optional<std::string_view>
incluso en esas funciones específicas (que llaman a std::char_traits
para la comparación).
Finalmente, exasperadamente, aislar la función en cuestión en su propio binario hace desaparecer el "problema". El mítico MCVE está resultando esquivo.
No estaría tan seguro de que esté causado por un error del compilador. Posiblemente haya algo de UB en su código que permita al compilador optimizar su código de manera más agresiva. De todos modos, a las preguntas:
- UB no es un problema en el montaje. En la mayoría de los casos, se leerá lo que quede debajo de la dirección a la que se refiere. Por supuesto, la mayoría de los sistemas operativos llenan las páginas de memoria antes de entregarlas al programa, pero la variable probablemente reside en la pila, por lo que es muy probable que contenga datos de basura. Soo, siempre que estés de acuerdo con la comparación de datos aleatorios (lo cual es bastante malo, ya que puede dar resultados diferentes falsamente) el montaje es válido
- Lo más probable es que sea un síndrome de comparación inversa.
No hay valores de captura en formatos enteros x86, por lo que leer y comparar valores no inicializados genera valores de verdad / falsos impredecibles y ningún otro daño directo.
En un contexto criptográfico, el estado de los valores no inicializados que causan la toma de una rama diferente podría filtrarse en fugas de información de tiempo u otros ataques de canal lateral. Pero el endurecimiento criptográfico probablemente no sea lo que te preocupa.
El hecho de que gcc realice lecturas sin inicializar cuando no importa si la lectura da un valor incorrecto no significa que lo hará cuando sea importante.