Un estudio de un comportamiento vago.

El artículo explora las posibles manifestaciones de comportamiento indefinido que ocurre en c ++ cuando se completa una función no nula sin llamar a return con un valor adecuado. El artículo es más científico y entretenido que práctico.

¿A quién no le gusta divertirse saltando en un rastrillo? Pasamos, no nos detenemos.

Introducción


Todo el mundo sabe que al desarrollar código c ++, no debe permitir un comportamiento indefinido.
Sin embargo:

  • el comportamiento indefinido puede no parecer lo suficientemente peligroso debido a la abstracción de las posibles consecuencias;
  • no siempre está claro dónde está la línea.

Tratemos de especificar las posibles manifestaciones de comportamiento indefinido que ocurre en un caso bastante simple: en una función no nula, no hay retorno.

Para hacer esto, considere el código generado por los compiladores más populares en diferentes modos de optimización.

La investigación en Linux se llevará a cabo utilizando el Explorador de compiladores . Investigación sobre Windows y macOs X: sobre el hardware directamente disponible para mí.

Todas las compilaciones se realizarán para x86-x64.

No se tomarán medidas para mejorar o suprimir las advertencias / errores del compilador.

Habrá mucho código desmontado. Su diseño, lamentablemente, es abigarrado, porque Tengo que usar varias herramientas diferentes (bueno, al menos logré obtener la sintaxis de Intel en todas partes). Daré comentarios moderadamente detallados sobre el código desmontado, que, sin embargo, no eliminan la necesidad de conocer los registros del procesador y los principios de la pila.

Leer estándar


C ++ 11 borrador final n3797, C ++ 14 borrador final N3936:
6.6.3 La declaración de retorno
... El
flujo del final de una función es equivalente a un retorno sin valor; Esto da como resultado un
comportamiento indefinido en una función de retorno de valor.
...

Alcanzar el final de una función es equivalente a regresar sin un valor de retorno; para una función cuyo valor de retorno se proporciona, esto conduce a un comportamiento indefinido.

C ++ 17 draft n4713
9.6.3 La declaración de retorno
...
Que fluye del final de un constructor, un destructor o una función con un tipo de retorno vacío cv es equivalente a un retorno sin operando. De lo contrario, fluir fuera del final de una función que no sea main (6.8.3.1) da como resultado un comportamiento indefinido.
...

Alcanzar el final de un constructor, destructor o función con un valor de retorno nulo (posiblemente con calificadores constantes y volátiles) es equivalente a un retorno sin un valor de retorno. Para todas las demás funciones, esto conduce a un comportamiento indefinido (a excepción de la función principal).

¿Qué significa esto en la práctica?

Si la firma de la función proporciona un valor de retorno:

  • su ejecución debe terminar con una declaración de retorno con una instancia del tipo apropiado;
  • de lo contrario, comportamiento vago;
  • el comportamiento indefinido no comienza desde el momento en que se llama la función y no desde el momento en que se utiliza el valor devuelto, sino desde el momento en que la función no se completa correctamente;
  • si la función contiene rutas de ejecución correctas e incorrectas, el comportamiento indefinido solo ocurrirá en rutas incorrectas;
  • El comportamiento indefinido en cuestión no afecta la ejecución de las instrucciones contenidas en el cuerpo de la función.

La frase sobre la función principal no es una novedad de c ++ 17: en versiones anteriores del Estándar, se describió una excepción similar en la sección 3.6.1 Función principal.

Ejemplo 1 - bool


En c ++ no hay ningún tipo con un estado más simple que bool. Comencemos con él.

#include <iostream>

bool bad() {};

int main()
{
    std::cout << bad();

    return 0;
}

MSVC da un error de compilación C4716 para tal ejemplo, por lo que el código para MSVC tendrá que ser un poco complicado al proporcionar al menos una ruta de ejecución correcta:

#include <iostream>
#include <stdlib.h>

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    std::cout << bad();

    return 0;
}

Compilacion:

PlataformaCompiladorResultado de compilación
Linuxx86-x64 Clang 10.0.0advertencia: la función no nula no devuelve un valor [-Wreturn-type]
Linuxx86-x64 gcc 9.3advertencia: no hay declaración de retorno en la función que devuelve no vacío [-Wreturn-type]
Mac OS XApple clang versión 11.0.0advertencia: el control llega al final de la función no nula [-Wreturn-type]
VentanasMSVC 2019 16.5.4El ejemplo original es el error C4716, complicado: advertencia C4715: no todas las rutas de control devuelven un valor

Resultados de ejecución:
MejoramientoPrograma de retornoSalida de la consola
Linux x86-x64 Clang 10.0.0
-O0255Ninguna salida
-O1, -O20 0Ninguna salida
Linux x86-x64 gcc 9.3
-O00 089
-O1, -O2, -O30 0Ninguna salida
MacOs X Apple clang versión 11.0.0
-O0, -O1, -O20 00 0
Windows MSVC 2019 16.5.4, ejemplo original
/ Od, / O1, / O2No construirNo construir
Windows MSVC 2019 16.5.4 Ejemplo complicado
/ Od0 041
/ O1, / O20 01

Incluso en este ejemplo más simple, cuatro compiladores han demostrado al menos tres formas de mostrar un comportamiento indefinido.

Veamos qué compilaron estos compiladores allí.

Linux x86-x64 Clang 10.0.0, -O0


imagen

La última declaración en la función bad () es ud2 .

Descripción de las instrucciones del Manual del desarrollador de software de las arquitecturas Intel 64 e IA-32 :
UD2—Undefined Instruction
Generates an invalid opcode exception. This instruction is provided for software testing to explicitly generate an invalid opcode exception. The opcode for this instruction is reserved for this purpose.
Other than raising the invalid opcode exception, this instruction has no effect on processor state or memory.

Even though it is the execution of the UD2 instruction that causes the invalid opcode exception, the instruction pointer saved by delivery of the exception references the UD2 instruction (and not the following instruction).

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

En resumen, esta es una instrucción especial para lanzar una excepción.

Necesitas envolver la llamada bad () en un intento ... ¡atrapa!

No importa cómo. Esto no es una excepción de C ++.

¿Es posible atrapar ud2 en tiempo de ejecución?
En Windows, __try debería usarse para esto; en Linux y macOs X, el controlador de señal SIGILL.

Linux x86-x64 Clang 10.0.0, -O1, -O2


imagen

Como resultado de la optimización, el compilador simplemente tomó y descartó tanto el cuerpo de la función bad () como su llamada.

Linux x86-x64 gcc 9.3, -O0


imagen

Explicaciones (en orden inverso, porque en este caso la cadena es más fácil de analizar desde el final):

5. El operador de salida en la secuencia se llama bool (línea 14);

4. La dirección std :: cout se coloca en el registro edi: este es el primer argumento del operador de salida en la secuencia (línea 13);

3. El contenido del registro eax se coloca en el registro esi: este es el segundo argumento del operador de salida en la secuencia (línea 12);

2. Los tres bytes altos de eax se restablecen a cero, el valor de al no cambia (línea 11);

1. La función bad () se llama (línea 10);

0. La función bad () debe poner el valor de retorno en el registro al.

En cambio, la línea 4 muestra nop (Sin operación, ficticio).

Un byte de basura del registro al se envía a la consola. El programa termina normalmente.

Linux x86-x64 gcc 9.3, -O1, -O2, -O3


imagen

El compilador arrojó todo como resultado de la optimización.

MacOs X Apple clang versión 11.0.0, -O0


Función main ():

imagen

la ruta del argumento booleano del operador de salida al flujo (esta vez en el orden directo):

1. El contenido del registro al se coloca en el registro edx (línea 8);

2. Todos los bits del registro edx se ponen a cero, excepto el más bajo (línea 9);

3. Se coloca un puntero a std :: cout en el registro rdi; este es el primer argumento del operador de salida en la secuencia (línea 10);

4. El contenido del registro edx se coloca en el registro esi: este es el segundo argumento para el operador de salida en la secuencia (línea 11);

5. La declaración de salida se llama en secuencia para bool (línea 13);

La función principal espera obtener el resultado de la función bad () del registro al.

La función bad ():

imagen

1. El valor del siguiente byte de la pila, aún no asignado, se coloca en el registro al (línea 4);

2. Todos los bits del registro al están exceptuados, excepto el menos significativo (línea 5);

Un poco de basura de la pila no asignada se envía a la consola. Dio la casualidad de que durante una ejecución de prueba resultó ser cero.

El programa termina normalmente.

MacOs X Apple clang versión 11.0.0, -O1, -O2


imagen

El argumento booleano del operador de salida en la secuencia está anulado (línea 5).

La llamada bad () se lanzó durante la optimización.

El programa siempre muestra cero en la consola y sale normalmente.

Windows MSVC 2019 16.5.4, Ejemplo avanzado, / Od


imagen

Se puede ver que la función bad () debería proporcionar un valor de retorno en el registro al.

imagen

El valor devuelto por la función bad () se inserta primero en la pila y luego en el registro edx para que la salida se transmita.

Un solo byte de basura del registro al se envía a la consola (si es un poco más preciso, entonces el byte bajo del resultado de rand ()). El programa termina normalmente.

Windows MSVC 2019 16.5.4 Ejemplo complicado, / O1, / O2


imagen

El compilador forzó a la fuerza la llamada bad (). Función principal:

  • copia un byte de ebx de la memoria ubicada en [rsp + 30h];
  • si rand () devolvió cero, copie la unidad de ecx a ebx (línea 11);
  • copia el mismo valor en dl (más precisamente, su byte menos significativo) (línea 13);
  • llama a la función de salida en flujo, que genera el valor dl (línea 14).

Un byte de basura de RAM (de la dirección rsp + 30h) se emite para transmitir.

La conclusión del ejemplo 1


Los resultados de la consideración de las listas de desensambladores se muestran en la tabla:
MejoramientoPrograma de retornoSalida de la consolaPorque
Linux x86-x64 Clang 10.0.0
-O0255Ninguna salidaud2
-O1, -O20 0Ninguna salidaLa salida de la consola y la llamada a la función bad () se lanzaron como resultado de la optimización
Linux x86-x64 gcc 9.3
-O00 089Un byte de basura del registro al
-O1, -O2, -O30 0Ninguna salidaLa salida de la consola y la llamada a la función bad () se lanzaron como resultado de la optimización
MacOs X Apple clang versión 11.0.0
-O00 00 0Un poco de basura de la RAM
-O1, -O20 00 0Llamada de función bad () reemplazada por cero
Windows MSVC 2019 16.5.4, ejemplo original
/ Od, / O1, / O2No construirNo construirNo construir
Windows MSVC 2019 16.5.4 Ejemplo complicado
/ Od0 041Un byte de basura del registro al
/ O1, / O20 01Un byte de basura de RAM

Resultó que los compiladores no demostraron 3, sino hasta 6 variantes de comportamiento indefinido; justo antes de considerar las listas de desensambladores, no pudimos distinguir algunas de ellas.

Ejemplo 1a - Gestión de comportamiento indefinido


Intentemos dirigirnos un poco con un comportamiento indefinido: afecta el valor devuelto por la función bad ().

Esto solo se puede hacer con compiladores que generan basura.
Para hacer esto, transfiera los valores deseados a los lugares desde donde los tomarán los compiladores.

Linux x86-x64 gcc 9.3, -O0


La función vacía bad () no modifica el valor de register al, como lo requiere el código de llamada. Por lo tanto, si colocamos un cierto valor en al antes de llamar a bad (), entonces esperamos ver exactamente este valor como resultado de ejecutar bad ().

Obviamente, esto se puede hacer llamando a cualquier otra función que devuelva bool. Pero también se puede hacer usando una función que devuelve, por ejemplo, sin caracteres char.

Código de ejemplo completo
#include <iostream>

bool bad() {}

bool goodTrue()
{
    return rand();
}

bool goodFalse()
{
    return !goodTrue();
}

unsigned char goodChar(unsigned char ch)
{
    return ch;
}

int main()
{
    goodTrue();
    std::cout << bad() << std::endl;

    goodChar(85);
    std::cout << bad() << std::endl;

    goodFalse();
    std::cout << bad() << std::endl;

    goodChar(240);
    std::cout << bad() << std::endl;

    return 0;
}


Salida a la consola:
1
85
0
240

Windows MSVC 2019 16.5.4, / Od


En el ejemplo de MSVC, la función bad () devuelve el byte bajo del resultado de rand ().

Sin modificar la función bad (), el código externo puede afectar su valor de retorno al modificar el resultado de rand ().

Código de ejemplo completo
#include <iostream>
#include <stdlib.h>

void control(unsigned char value)
{
    uint32_t count = 0;
    srand(0);
    while ((rand() & 0xff) != value) {
        ++count;
    }

    srand(0);
    for (uint32_t i = 0; i < count; ++i) {
        rand();
    }
}

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    control(1);
    std::cout << bad() << std::endl;

    control(85);
    std::cout << bad() << std::endl;

    control(0);
    std::cout << bad() << std::endl;

    control(240);
    std::cout << bad() << std::endl;

    return 0;
}


Salida a la consola:
1
85
0
240


Windows MSVC 2019 16.5.4, / O1, / O2


Para influir no en el valor "devuelto" por la función bad (), es suficiente crear una variable de pila. Para que el registro no se elimine durante la optimización, debe marcarlo como volátil.
Código de ejemplo completo
#include <iostream>
#include <stdlib.h>

bool bad()
{
  if (rand() == 0) {
    return true;
  }
}

int main()
{
  volatile unsigned char ch = 1;
  std::cout << bad() << std::endl;

  ch = 85;
  std::cout << bad() << std::endl;

  ch = 0;
  std::cout << bad() << std::endl;

  ch = 240;
  std::cout << bad() << std::endl;

  return 0;
}


Salida a la consola:
1
85
0
240


MacOs X Apple clang versión 11.0.0, -O0


Antes de llamar a bad (), debe ingresar un cierto valor en esa celda de memoria, que será uno menos que la parte superior de la pila al momento de llamar a bad ().

Código de ejemplo completo
#include <iostream>

bool bad() {}

void putToStack(uint8_t value)
{
    uint8_t memory[1]{value};
}

int main()
{
    putToStack(20);
    std::cout << bad() << std::endl;

    putToStack(55);
    std::cout << bad() << std::endl;

    putToStack(0xfe);
    std::cout << bad() << std::endl;

    putToStack(11);
    std::cout << bad() << std::endl;

    return 0;
}

-O0, memory. , .

memory , — , , .

, .. , — putToStack .

Salida a la consola:
0
1
0
1

Parece haber sucedido: es posible cambiar la salida de la función bad (), y solo se tiene en cuenta el bit de orden inferior.

La conclusión del ejemplo 1a


Un ejemplo permitió verificar la interpretación correcta de las listas de desensambladores.

Ejemplo 1b - bool roto


Bueno, piensas en ello, "41" se mostrará en la consola en lugar de "1" ... ¿Es peligroso?

Comprobaremos dos compiladores que proporcionan un byte completo de basura.

Windows MSVC 2019 16.5.4, / Od


Código de ejemplo completo
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
    if (rand() == 0) {
        return true;
    }
}

int main()
{
    bool badBool1 = bad();
    bool badBool2 = bad();

    std::cout << "badBool1: " << badBool1 << std::endl;
    std::cout << "badBool2: " << badBool2 << std::endl;

    if (badBool1) {
      std::cout << "if (badBool1): true" << std::endl;
    } else {
      std::cout << "if (badBool1): false" << std::endl;
    }
    if (!badBool1) {
      std::cout << "if (!badBool1): true" << std::endl;
    } else {
      std::cout << "if (!badBool1): false" << std::endl;
    }

    std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
              << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
              << std::endl;
    std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
              << std::set<bool>{badBool1, badBool2, true, false}.size()
              << std::endl;
    std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
              << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
              << std::endl;

    return 0;
}


Salida a la consola:
badBool1: 41
badBool2: 35
if (badBool1): verdadero
if (! badBool1): falso
(badBool1 == verdadero || badBool1 == falso || badBool1 == badBool2): falso
std :: set <bool> {badBool1, badBool2 , verdadero, falso} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4

El comportamiento indefinido condujo a la aparición de una variable booleana que rompe al menos:
  • operadores de comparación para valores booleanos;
  • función hash de valor booleano.


Windows MSVC 2019 16.5.4, / O1, / O2


Código de ejemplo completo
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
  if (rand() == 0) {
    return true;
  }
}

int main()
{
  volatile unsigned char ch = 213;
  bool badBool1 = bad();
  ch = 137;
  bool badBool2 = bad();

  std::cout << "badBool1: " << badBool1 << std::endl;
  std::cout << "badBool2: " << badBool2 << std::endl;

  if (badBool1) {
    std::cout << "if (badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (badBool1): false" << std::endl;
  }
  if (!badBool1) {
    std::cout << "if (!badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (!badBool1): false" << std::endl;
  }

  std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
    << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
    << std::endl;
  std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;
  std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;

  return 0;
}


Salida a la consola:
badBool1: 213
badBool2: 137
if (badBool1): verdadero
if (! badBool1): falso
(badBool1 == verdadero || badBool1 == falso || badBool1 == badBool2): falso
std :: set <bool> {badBool1, badBool2 , verdadero, falso} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4

El trabajo con una variable booleana corrupta no cambió cuando se activó la optimización.

Linux x86-x64 gcc 9.3, -O0


Código de ejemplo completo
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>

bool bad()
{
}

unsigned char goodChar(unsigned char ch)
{
  return ch;
}

int main()
{
  goodChar(213);
  bool badBool1 = bad();

  goodChar(137);
  bool badBool2 = bad();

  std::cout << "badBool1: " << badBool1 << std::endl;
  std::cout << "badBool2: " << badBool2 << std::endl;

  if (badBool1) {
    std::cout << "if (badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (badBool1): false" << std::endl;
  }
  if (!badBool1) {
    std::cout << "if (!badBool1): true" << std::endl;
  }
  else {
    std::cout << "if (!badBool1): false" << std::endl;
  }

  std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
    << std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
    << std::endl;
  std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;
  std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
    << std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
    << std::endl;

  return 0;
}


Salida a la consola:
badBool1: 213
badBool2: 137
if (badBool1): verdadero
if (! badBool1): verdadero
(badBool1 == verdadero || badBool1 == falso || badBool1 == badBool2): falso
std :: set <bool> {badBool1, badBool2 , verdadero, falso} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, true, false} .size (): 4


En comparación con MSVC, gcc también agregó la operación incorrecta del operador no.

La conclusión del ejemplo 1b


La interrupción de las operaciones básicas con valores booleanos puede tener serias consecuencias para la lógica de alto nivel.

¿Por qué sucedió?

Debido a que algunas operaciones con variables booleanas se implementan bajo el supuesto de que verdadero es estrictamente una unidad.

No consideraremos este problema en el desensamblador: el artículo resultó ser voluminoso.

Una vez más, aclaramos la tabla con el comportamiento de los compiladores:
MejoramientoPrograma de retornoSalida de la consolaPorqueConsecuencias de usar el resultado de bad ()
Linux x86-x64 Clang 10.0.0
-O0255Ninguna salidaud2
-O1, -O20 0Ninguna salidaLa salida de la consola y la llamada a la función bad () se lanzaron como resultado de la optimización
Linux x86-x64 gcc 9.3
-O00 089Un byte de basura del registro alViolación del trabajo:
no; ==; ! =; <; >; <=; > =; std :: hash.
-O1, -O2, -O30 0Ninguna salidaLa salida de la consola y la llamada a la función bad () se lanzaron como resultado de la optimización
MacOs X Apple clang versión 11.0.0
-O00 00 0Un poco de basura de la RAM
-O1, -O20 00 0Llamada de función bad () reemplazada por cero
Windows MSVC 2019 16.5.4, ejemplo original
/ Od, / O1, / O2No construirNo construirNo construir
Windows MSVC 2019 16.5.4 Ejemplo complicado
/ Od0 041Un byte de basura del registro alViolación de trabajo:
==; ! =; <; >; <=; > =; std :: hash.
/ O1, / O20 01Un byte de basura de RAMViolación de trabajo:
==; ! =; <; >; <=; > =; std :: hash.

Cuatro compiladores dieron 7 manifestaciones diferentes de comportamiento indefinido.

Ejemplo 2 - struct


Tomemos un ejemplo un poco más complicado:

#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == 1) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();
    std::cout << "rnd: " << rnd << std::endl;

    std::cout << bad(rnd).value << std::endl;

    return 0;
}

La estructura de prueba requiere un único parámetro de tipo int para construir. Los mensajes de diagnóstico salen de su constructor y destructor. La función bad (int) tiene dos rutas de ejecución válidas, ninguna de las cuales se implementará en una sola llamada.

Esta vez, primero la tabla, luego el análisis del desensamblador en puntos oscuros.
MejoramientoProgram returnConsole output
Linux x86-x64 Clang 10.0.0
-O0255rnd: 1804289383ud2
-O1, -O20rnd: 1804289383
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
Linux x86-x64 gcc 9.3
-O00rnd: 1804289383
4198608
Test::~Test()
nop .
value .
-O1, -O2, -O30rnd: 1804289383
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
macOs X Apple clang version 11.0.0
-O0The program has unexpectedly finished.rnd: 16807ud2
-O1, -O20rnd: 16807
Test::Test(142)
142
Test::~Test()
if (v == 1) . else if else.
Windows MSVC 2019 16.5.4
/Od /RTCsAccess violation reading location 0x00000000CCCCCCCCrnd: 41MSVC stack frame run-time error checking
/Od, /O1, /O20rnd: 41
8791061810776
Prueba :: ~ Prueba ()
Basura de una ubicación de memoria cuya dirección está en rax

Nuevamente vemos muchas opciones: además del ud2 ya conocido, hay al menos 4 comportamientos diferentes.

El manejo del compilador con un constructor es muy interesante:

  • en algunos casos, la ejecución continuó sin llamar al constructor; en este caso, el objeto estaba en algún estado aleatorio;
  • en otros casos, no se proporcionó una llamada de constructor en la ruta de ejecución, lo cual es bastante extraño.

Linux x86-x64 Clang 10.0.0, -O1, -O2


imagen

Solo se hace una comparación en el código (línea 14), y solo hay un salto condicional (línea 15). El compilador ignoró la segunda comparación y el segundo salto condicional.
Esto lleva a sospechar que el comportamiento indefinido comenzó antes de lo que prescribe el Estándar.

Pero al verificar la condición del segundo si no contiene ningún efecto secundario, y la lógica del compilador funcionó de la siguiente manera:

  • si la segunda condición es verdadera, debe llamar al constructor Prueba con el argumento 142;
  • Si la segunda condición no es verdadera, la función se cerrará sin devolver un valor, lo que significa un comportamiento indefinido en el que el compilador puede hacer cualquier cosa. Incluyendo: llamar al mismo constructor con el mismo argumento;
  • la verificación es superflua; se puede llamar al constructor de prueba con el argumento 142 sin verificar la condición.

Veamos qué sucede si la segunda verificación contiene una condición con efectos secundarios:

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == rand()) {
        return {142};
    }
}

Código completo
#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
    if (v == 0) {
        return {42};
    } else if (v == rand()) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();
    std::cout << "rnd: " << rnd << std::endl;

    std::cout << bad(rnd).value << std::endl;

    return 0;
}


imagen

El compilador reprodujo honestamente todos los efectos secundarios previstos al llamar a rand () (línea 16), disipando así las dudas sobre el comienzo temprano inapropiado de un comportamiento indefinido.

Windows MSVC 2019 16.5.4, / Od / RTC


La opción / RTCs permite la comprobación de errores de tiempo de ejecución del marco de la pila. Esta opción solo está disponible en el ensamblaje de depuración. Considere el código desensamblado del segmento main ():

imagen

antes de llamar a bad (int) (línea 4), los argumentos están preparados: el valor de la variable rnd se copia en el registro edx (línea 2), y la dirección efectiva de alguna variable local ubicada en la dirección se carga en el registro rcx rsp + 28h (línea 3).

Presumiblemente, rsp + 28 es la dirección de una variable temporal que almacena el resultado de llamar a bad (int).

Esta suposición es confirmada por las líneas 19 y 20: la dirección efectiva de la misma variable se carga en rcx, después de lo cual se llama al destructor.

Sin embargo, en el intervalo de las líneas 4 a 18, no se accede a esta variable, a pesar de la salida del valor de su campo de datos para transmitir.

Como vimos en listados anteriores de MSVC, el argumento para el operador de salida de flujo debería esperarse en el registro rdx. El registro rdx obtiene el resultado de desreferenciar la dirección ubicada en rax (línea 9).

Por lo tanto, el código de llamada espera de bad (int):

  • rellenando una variable cuya dirección se pasa a través del registro rcx (aquí vemos RVO en acción);
  • devolviendo la dirección de esta variable a través del registro rax.

Pasemos a enumerar bad (int):

imagen

  • en eax, se ingresa el valor 0xCCCCCCCC, que vimos en el mensaje de infracción de acceso (línea 9) (tenga en cuenta que solo tiene 4 bytes, mientras que en el mensaje de AccessViolation la dirección consta de 8 bytes);
  • Se llama al comando rep stos, que ejecuta ciclos 0xC de escritura de los contenidos de eax en la memoria comenzando desde la dirección rdi (línea 10). Estos son 48 bytes, exactamente la cantidad asignada en la pila en la línea 6;
  • en las rutas de ejecución correctas, el valor de rsp + 40h se ingresa en rax (líneas 23, 36);
  • el valor del registro rcx (a través del cual main () pasó la dirección de destino) se inserta en la pila en rsp + 8 (línea 4);
  • rdi es empujado a la pila, lo que reduce rsp en 8 (línea 5);
  • Se asignan 30h bytes en la pila disminuyendo rsp (línea 6).

Entonces rsp + 8 en la línea 4 y rsp + 40h en el resto del código tienen el mismo valor.
El código es bastante confuso ya que no usa rbp.

Hay dos accidentes en el mensaje de Infracción de acceso:

  • ceros en la parte superior de la dirección: puede haber basura;
  • la dirección resultó accidentalmente incorrecta.

Aparentemente, la opción / RTC permitió la sobrescritura de la pila con ciertos valores distintos de cero, y el mensaje de Infracción de acceso fue solo un efecto secundario aleatorio.

Veamos cómo el código con la opción / RTC activada difiere del código sin él.

imagen

El código para las secciones de main () difiere solo en las direcciones de las variables locales en la pila.

imagen

(para mayor claridad, coloqué dos versiones de la función bad (int) al lado: con / RTC y sin)
Sin los / RTC, la instrucción rep stos desapareció y preparé argumentos para ello al comienzo de la función.

Ejemplo 2a


Nuevamente, intente controlar el comportamiento indefinido. Esta vez para solo un compilador.

Windows MSVC 2019 16.5.4, / Od / RTC


Con la opción / RTCs, el compilador inserta código al comienzo de la función incorrecta (int) que llena la mitad inferior de rax con un valor fijo, lo que puede conducir a una infracción de acceso.

Para cambiar este comportamiento, simplemente complete rax con alguna dirección válida.
Esto se puede lograr con una modificación muy simple: agregue la salida de algo a std :: cout al cuerpo incorrecto (int).

Código de ejemplo completo
#include <iostream>
#include <stdlib.h>

struct Test
{
    Test(uint64_t v)
        : value(v)
    {
        std::cout << "Test::Test(" << v << ")" << std::endl;
    }
    ~Test()
    {
        std::cout << "Test::~Test()" << std::endl;
    }

    uint64_t value;
};

Test bad(int v)
{
  std::cout << "rnd: " << v << std::endl;
  
  if (v == 0) {
        return {42};
    } else if (v == 1) {
        return {142};
    }
}

int main()
{
    const auto rnd = rand();

    std::cout << bad(rnd).value << std::endl;

    return 0;
}


rnd: 41
8791039331928
Prueba :: ~ Prueba ()

El operador << devuelve un enlace a stream, que se implementa al colocar la dirección std :: cout en rax. La dirección es correcta, puede ser desreferenciada. Se evita la violación de acceso.

Conclusión


Usando los ejemplos más simples, pudimos:

  • recolectar alrededor de 10 manifestaciones diferentes de comportamiento indefinido;
  • aprenda en detalle exactamente cómo se ejecutarán estas opciones.

Todos los compiladores demostraron una estricta adherencia al Estándar; en ningún caso el comportamiento indefinido comenzó antes de lo esperado. Pero no puedes rechazar una fantasía para compilar desarrolladores.

A menudo, la manifestación depende de sutiles matices: vale la pena agregar o eliminar una línea de código aparentemente irrelevante, y el comportamiento del programa cambia significativamente.

Obviamente, es más fácil no escribir ese código que resolver acertijos más tarde.

All Articles