Um estudo de um comportamento vago

O artigo explora as possíveis manifestações de comportamento indefinido que ocorrem no c ++ quando uma função não nula é concluída sem chamar retorno com um valor adequado. O artigo é mais científico e divertido do que prático.

Quem não gosta de se divertir pulando em um ancinho - passamos, não paramos.

Introdução


Todo mundo sabe que, ao desenvolver código c ++, você não deve permitir um comportamento indefinido.
Contudo:

  • comportamento indefinido pode não parecer perigoso o suficiente devido à abstração das possíveis conseqüências;
  • nem sempre é claro onde está a linha.

Vamos tentar especificar as possíveis manifestações de comportamento indefinido que ocorrem em um caso bastante simples - em uma função não nula, não há retorno.

Para fazer isso, considere o código gerado pelos compiladores mais populares em diferentes modos de otimização.

A pesquisa no Linux será realizada usando o Compiler Explorer . Pesquisa no Windows e no macOs X - no hardware diretamente disponível para mim.

Todas as compilações serão feitas para x86-x64.

Nenhuma medida será tomada para aprimorar ou suprimir avisos / erros do compilador.

Haverá muito código desmontado. Seu design, infelizmente, é heterogêneo, porque Eu tenho que usar várias ferramentas diferentes (bem, pelo menos eu consegui obter a sintaxe da Intel em todos os lugares). Farei comentários moderadamente detalhados sobre código desmontado, que, no entanto, não eliminam a necessidade de conhecimento dos registros do processador e dos princípios da pilha.

Ler Padrão


Rascunho final C ++ 11 n3797, rascunho final C ++ 14 N3936:
6.6.3 A declaração de retorno
...
Fluir do final de uma função é equivalente a um retorno sem valor; isso resulta em
comportamento indefinido em uma função de retorno de valor.
...

Atingir o final de uma função é equivalente a retornar sem um valor de retorno; para uma função cujo valor de retorno é fornecido, isso leva a um comportamento indefinido.

Projeto C ++ 17 n4713
9.6.3 A declaração de retorno
...
Fluir do final de um construtor, destruidor ou função com um tipo de retorno cv void é equivalente a um retorno sem operando. Caso contrário, o fluxo do final de uma função que não seja main (6.8.3.1) resulta em comportamento indefinido.
...

Atingir o final de um construtor, destruidor ou função com um valor de retorno nulo (possivelmente com qualificadores const e voláteis) é equivalente a retornar sem um valor de retorno. Para todas as outras funções, isso leva a um comportamento indefinido (exceto para a função principal).

O que isso significa na prática?

Se a assinatura da função fornecer um valor de retorno:

  • sua execução deve terminar com uma declaração de retorno com uma instância do tipo apropriado;
  • caso contrário, comportamento vago;
  • o comportamento indefinido não inicia no momento em que a função é chamada e não no momento em que o valor retornado é usado, mas a partir do momento em que a função não é concluída corretamente;
  • se a função contiver caminhos de execução corretos e incorretos - o comportamento indefinido ocorrerá apenas em caminhos incorretos;
  • o comportamento indefinido em questão não afeta a execução das instruções contidas no corpo da função.

A frase sobre a função principal não é uma novidade do c ++ 17 - nas versões anteriores do Padrão, uma exceção semelhante foi descrita na seção 3.6.1 Função principal.

Exemplo 1 - bool


Em c ++, não existe um tipo com um estado mais simples que bool. Vamos começar com ele.

#include <iostream>

bool bad() {};

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

    return 0;
}

O MSVC fornece um erro de compilação C4716 para esse exemplo; portanto, o código do MSVC precisará ser um pouco complicado, fornecendo pelo menos um caminho de execução correto:

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

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

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

    return 0;
}

Compilação:

PlataformaCompiladorResultado da compilação
Linuxx86-x64 Clang 10.0.0aviso: a função non-void não retorna um valor [-Wreturn-type]
Linuxx86-x64 gcc 9.3aviso: nenhuma instrução return na função retornando non-void [-Wreturn-type]
Mac OS XApple clang versão 11.0.0aviso: o controle atinge o final da função não nula [-Wreturn-type]
janelasMSVC 2019 16.5.4O exemplo original é o erro C4716, complicado - aviso C4715: nem todos os caminhos de controle retornam um valor

Resultados da execução:
OtimizaçãoRetorno do programaSaída do console
Linux x86-x64 Clang 10.0.0
-O0255Sem saída
-O1, -O20 0Sem saída
Linux x86-x64 gcc 9.3
-O00 089
-O1, -O2, -O30 0Sem saída
macOs X Apple clang versão 11.0.0
-O0, -O1, -O20 00 0
Windows MSVC 2019 16.5.4, exemplo original
/ Od, / O1, / O2Sem construçãoSem construção
Exemplo complicado do Windows MSVC 2019 16.5.4
/ Od0 041.
/ O1 / O20 01 1

Mesmo neste exemplo mais simples, quatro compiladores demonstraram pelo menos três maneiras de exibir um comportamento indefinido.

Vamos descobrir o que esses compiladores compilaram lá.

Linux x86-x64 Clang 10.0.0, -O0


imagem

A última instrução na função bad () é ud2 .

Descrição das instruções do Manual do desenvolvedor de software das arquiteturas 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.

Em suma, esta é uma instrução especial para lançar uma exceção.

Você precisa encerrar a chamada bad () em uma tentativa ... catch! Block

Não importa como. Esta não é uma exceção c ++.

É possível pegar o ud2 em tempo de execução?
No Windows, __try deve ser usado para isso; no Linux e macOs X, o manipulador de sinal SIGILL.

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


imagem

Como resultado da otimização, o compilador simplesmente pegou e jogou fora o corpo da função bad () e sua chamada.

Linux x86-x64 gcc 9.3, -O0


imagem

Explicações (na ordem inversa, porque neste caso a cadeia é mais fácil de analisar a partir do final):

5. O operador de saída no fluxo é chamado para bool (linha 14);

4. O endereço std :: cout é colocado no registrador edi - este é o primeiro argumento do operador de saída no fluxo (linha 13);

3. O conteúdo do registro eax é colocado no registro esi - este é o segundo argumento do operador de saída no fluxo (linha 12);

2. Os três bytes altos de eax são redefinidos para zero, o valor de al não muda (linha 11);

1. A função bad () é chamada (linha 10);

0. A função bad () deve colocar o valor de retorno no registro al.

Em vez disso, a linha 4 mostra nop (sem operação, simulado).

Um byte de lixo do registro al é enviado para o console. O programa termina normalmente.

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


imagem

O compilador jogou tudo como resultado da otimização.

macOs X Apple clang versão 11.0.0, -O0


Função main ():

imagem

O caminho do argumento booleano do operador de saída para o fluxo (desta vez na ordem direta):

1. O conteúdo do registro al é colocado no registro edx (linha 8);

2. Todos os bits do registrador edx são zerados, exceto o mais baixo (linha 9);

3. Um ponteiro para std :: cout é colocado no registrador rdi - este é o primeiro argumento do operador de saída no fluxo (linha 10);

4. O conteúdo do registrador edx é colocado no registrador esi - este é o segundo argumento para o operador de saída no fluxo (linha 11);

5. A instrução de saída é chamada no fluxo para bool (linha 13);

A função principal espera obter o resultado da função bad () do registro al.

A função bad ():

imagem

1. O valor do próximo byte da pilha, ainda não alocado, é colocado no registrador al (linha 4);

2. Todos os bits do registro al são excetuados, exceto os menos significativos (linha 5);

Um pouco de lixo da pilha não alocada é enviado para o console. Aconteceu que, durante uma execução de teste, foi zero.

O programa termina normalmente.

macOs X Apple clang versão 11.0.0, -O1, -O2


imagem

O argumento booleano do operador de saída no fluxo é anulado (linha 5).

A chamada bad () foi lançada durante a otimização.

O programa sempre exibe zero no console e sai normalmente.

Windows MSVC 2019 16.5.4, Exemplo avançado, / Od


imagem

Pode-se observar que a função bad () deve fornecer um valor de retorno no registro al.

imagem

O valor retornado pela função bad () é primeiro empurrado para a pilha e depois para o registrador edx para que a saída seja transmitida.

Um único byte de lixo do registrador al é enviado ao console (se um pouco mais precisamente, então o byte baixo do resultado de rand ()). O programa termina normalmente.

Exemplo complicado do Windows MSVC 2019 16.5.4, / O1, / O2


imagem

O compilador forçou forçosamente a chamada bad (). Função principal:

  • copia um byte do ebx da memória localizada em [rsp + 30h];
  • se rand () retornou zero, copie a unidade de ecx para ebx (linha 11);
  • copia o mesmo valor para dl (mais precisamente, seu byte menos significativo) (linha 13);
  • chama a função de saída no fluxo, que gera o valor dl (linha 14).

Um byte de lixo da RAM (do endereço rsp + 30h) é enviado para o fluxo.

A conclusão do exemplo 1


Os resultados da consideração das listagens de desmontagem são mostrados na tabela:
OtimizaçãoRetorno do programaSaída do consoleCausa
Linux x86-x64 Clang 10.0.0
-O0255Sem saídaud2
-O1, -O20 0Sem saídaA saída do console e a chamada para a função bad () foram lançadas como resultado da otimização
Linux x86-x64 gcc 9.3
-O00 089Um byte de lixo do registro al
-O1, -O2, -O30 0Sem saídaA saída do console e a chamada para a função bad () foram lançadas como resultado da otimização
macOs X Apple clang versão 11.0.0
-O00 00 0Um pouco de lixo da RAM
-O1, -O20 00 0Chamada de função inválida () substituída por zero
Windows MSVC 2019 16.5.4, exemplo original
/ Od, / O1, / O2Sem construçãoSem construçãoSem construção
Exemplo complicado do Windows MSVC 2019 16.5.4
/ Od0 041.Um byte de lixo do registro al
/ O1 / O20 01 1Um byte de lixo da RAM

Como se viu, os compiladores não demonstraram 3, mas até 6 variantes de comportamento indefinido - pouco antes de considerar as listagens dos desmontadores, não conseguimos distinguir algumas delas.

Exemplo 1a - Gerenciando comportamento indefinido


Vamos tentar orientar um pouco com comportamento indefinido - afetar o valor retornado pela função bad ().

Isso só pode ser feito com compiladores que produzem lixo.
Para fazer isso, remova os valores desejados dos locais de onde os compiladores os levarão.

Linux x86-x64 gcc 9.3, -O0


A função bad () vazia não modifica o valor do registrador al, como o código de chamada exige. Portanto, se colocarmos um certo valor em al antes de chamar bad (), esperamos ver esse valor como resultado da execução de bad ().

Obviamente, isso pode ser feito chamando qualquer outra função que retorne bool. Mas isso também pode ser feito usando uma função que retorna, por exemplo, char não cantado.

Código de exemplo 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;
}


Saída para o console:
1
85
0
240

Windows MSVC 2019 16.5.4, / Od


No exemplo para MSVC, a função bad () retorna o byte baixo do resultado de rand ().

Sem modificar a função bad (), o código externo pode afetar seu valor de retorno modificando o resultado de rand ().

Código de exemplo 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;
}


Saída para o console:
1
85
0
240


Windows MSVC 2019 16.5.4, / O1, / O2


Para não influenciar o valor "retornado" pela função bad (), basta criar uma variável de pilha. Para que o registro não seja descartado durante a otimização, você deve marcá-lo como volátil.
Código de exemplo 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;
}


Saída para o console:
1
85
0
240


macOs X Apple clang versão 11.0.0, -O0


Antes de chamar bad (), você deve inserir um determinado valor nessa célula de memória, que será um a menos que o topo da pilha no momento de chamar bad ().

Código de exemplo 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 .

Saída para o console:
0
1
0
1

Parece que aconteceu: é possível alterar a saída da função bad (), e apenas o bit de baixa ordem é levado em consideração.

A conclusão do exemplo 1a


Um exemplo tornou possível verificar a interpretação correta das listagens de desmontadores.

Exemplo 1b - bool quebrado


Bem, você pensa: "41" será exibido no console em vez de "1" ... Isso é perigoso?

Vamos verificar dois compiladores que fornecem um byte inteiro de lixo.

Windows MSVC 2019 16.5.4, / Od


Código de exemplo 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;
}


Saída para o console:
badBool1: 41
badBool2: 35
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , verdadeiro, falso} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, verdadeiro, falso} .size (): 4

O comportamento indefinido levou ao aparecimento de uma variável booleana que quebra pelo menos:
  • operadores de comparação para valores booleanos;
  • função hash de valor booleano.


Windows MSVC 2019 16.5.4, / O1, / O2


Código de exemplo 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;
}


Saída para o console:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , verdadeiro, falso} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, verdadeiro, falso} .size (): 4

O trabalho com uma variável booleana corrompida não mudou quando a otimização foi ativada.

Linux x86-x64 gcc 9.3, -O0


Código de exemplo 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;
}


Saída para o console:
badBool1: 213
badBool2: 137
if (badBool1): true
if (! badBool1): true
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std :: set <bool> {badBool1, badBool2 , verdadeiro, falso} .size (): 4
std :: unordered_set <bool> {badBool1, badBool2, verdadeiro, falso} .size (): 4


Comparado ao MSVC, o gcc também adicionou a operação incorreta do operador not.

A conclusão do exemplo 1b


A interrupção das operações básicas com valores booleanos pode ter sérias conseqüências para a lógica de alto nível.

Por que isso aconteceu?

Como algumas operações com variáveis ​​booleanas são implementadas sob a suposição de que true é estritamente uma unidade.

Não vamos considerar essa questão no desmontador - o artigo acabou sendo volumoso.

Mais uma vez, esclareceremos a tabela com o comportamento dos compiladores:
OtimizaçãoRetorno do programaSaída do consoleCausaConsequências do uso do resultado de bad ()
Linux x86-x64 Clang 10.0.0
-O0255Sem saídaud2
-O1, -O20 0Sem saídaA saída do console e a chamada para a função bad () foram lançadas como resultado da otimização
Linux x86-x64 gcc 9.3
-O00 089Um byte de lixo do registro alViolação do trabalho:
não; ==; ! =; <; >; <=; > =; std :: hash.
-O1, -O2, -O30 0Sem saídaA saída do console e a chamada para a função bad () foram lançadas como resultado da otimização
macOs X Apple clang versão 11.0.0
-O00 00 0Um pouco de lixo da RAM
-O1, -O20 00 0Chamada de função inválida () substituída por zero
Windows MSVC 2019 16.5.4, exemplo original
/ Od, / O1, / O2Sem construçãoSem construçãoSem construção
Exemplo complicado do Windows MSVC 2019 16.5.4
/ Od0 041.Um byte de lixo do registro alViolação do trabalho:
==; ! =; <; >; <=; > =; std :: hash.
/ O1 / O20 01 1Um byte de lixo da RAMViolação do trabalho:
==; ! =; <; >; <=; > =; std :: hash.

Quatro compiladores deram 7 manifestações diferentes de comportamento indefinido.

Exemplo 2 - struct


Vamos dar um exemplo um pouco mais 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;
}

A estrutura de teste requer um único parâmetro do tipo int para construir. As mensagens de diagnóstico são enviadas pelo construtor e destruidor. A função bad (int) possui dois caminhos de execução válidos, nenhum dos quais será implementado em uma única chamada.

Desta vez - primeiro a tabela, depois a análise do desmontador em pontos obscuros.
OtimizaçãoProgram 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
Test :: ~ Test ()
Lixo de um local de memória cujo endereço está em rax

Novamente, vemos muitas opções: além do já conhecido ud2, existem pelo menos 4 comportamentos diferentes.

O manuseio do compilador com um construtor é muito interessante:

  • em alguns casos, a execução continuou sem chamar o construtor - nesse caso, o objeto estava em algum estado aleatório;
  • em outros casos, uma chamada de construtor não foi fornecida no caminho de execução, o que é bastante estranho.

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


imagem

Somente uma comparação é feita no código (linha 14) e há apenas um salto condicional (linha 15). O compilador ignorou a segunda comparação e o segundo salto condicional.
Isso leva à suspeita de que o comportamento indefinido começou mais cedo do que o padrão prescreve.

Mas verificar a condição do segundo se não contém efeitos colaterais, e a lógica do compilador funcionou da seguinte maneira:

  • se a segunda condição for verdadeira - você precisará chamar o construtor Test com o argumento 142;
  • se a segunda condição não for verdadeira, a função sairá sem retornar um valor, o que significa comportamento indefinido no qual o compilador pode fazer qualquer coisa. Incluindo - chame o mesmo construtor com o mesmo argumento;
  • verificação é supérflua; o construtor Test com argumento 142 pode ser chamado sem verificar a condição.

Vamos ver o que acontece se a segunda verificação contiver uma condição com efeitos colaterais:

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;
}


imagem

O compilador reproduziu honestamente todos os efeitos colaterais pretendidos, chamando rand () (linha 16), dissipando assim dúvidas sobre o início inadequado inadequado de um comportamento indefinido.

Windows MSVC 2019 16.5.4, / Od / RTCs


A opção / RTCs permite a verificação de erros em tempo de execução do quadro da pilha. Esta opção está disponível apenas no conjunto de depuração. Considere o código desmontado do segmento main ():

imagem

Antes de chamar bad (int) (linha 4), os argumentos são preparados - o valor da variável rnd é copiado para o registrador edx (linha 2) e o endereço efetivo de alguma variável local localizada no endereço é carregado no registrador rcx rsp + 28h (linha 3).

Presumivelmente, rsp + 28 é o endereço de uma variável temporária que armazena o resultado da chamada incorreta (int).

Essa suposição é confirmada pelas linhas 19 e 20 - o endereço efetivo da mesma variável é carregado no rcx, após o qual o destruidor é chamado.

No entanto, no intervalo das linhas 4-18, essa variável não é acessada, apesar da saída do valor do seu campo de dados para transmitir.

Como vimos nas listagens anteriores da MSVC, o argumento para o operador de saída do fluxo deve ser esperado no registro rdx. O registro rdx obtém o resultado da desreferencia do endereço localizado em rax (linha 9).

Assim, o código de chamada espera de bad (int):

  • preenchendo uma variável cujo endereço é passado através do registro rcx (aqui vemos o RVO em ação);
  • retornando o endereço dessa variável através do registro rax.

Vamos passar a listar bad (int):

imagem

  • em eax, o valor 0xCCCCCCCC é inserido, o que vimos na mensagem de violação de acesso (linha 9) (observe que são apenas 4 bytes, enquanto na mensagem de AccessViolation o endereço consiste em 8 bytes);
  • o comando rep stos é chamado, executando ciclos 0xC de gravação do conteúdo do eax na memória, iniciando no endereço rdi (linha 10). São 48 bytes - exatamente o que está alocado na pilha na linha 6;
  • nos caminhos de execução corretos, o valor de rsp + 40h é inserido em rax (linhas 23, 36);
  • o valor do registrador rcx (através do qual main () passou o endereço de destino) é empurrado para a pilha em rsp + 8 (linha 4);
  • rdi é empurrado para a pilha, o que reduz a rsp em 8 (linha 5);
  • Os bytes de 30 h são alocados na pilha diminuindo a rsp (linha 6).

Portanto, rsp + 8 na linha 4 e rsp + 40h no restante do código têm o mesmo valor.
O código é bastante confuso como não usa rbp.

Há dois acidentes na mensagem de violação de acesso:

  • zeros na parte superior do endereço - pode haver algum lixo;
  • acidentalmente, o endereço estava incorreto.

Aparentemente, a opção / RTCs ativou a substituição da pilha com certos valores diferentes de zero, e a mensagem Violação de acesso foi apenas um efeito colateral aleatório.

Vamos ver como o código com a opção / RTCs ativada difere do código sem ele.

imagem

O código para seções de main () difere apenas nos endereços de variáveis ​​locais na pilha.

imagem

(para maior clareza, coloquei duas versões da função ruim (int) lado a lado - com / RTCs e sem)
Sem os / RTCs, a instrução rep stos desapareceu e preparou argumentos para ela no início da função.

Exemplo 2a


Mais uma vez, tente controlar o comportamento indefinido. Desta vez para apenas um compilador.

Windows MSVC 2019 16.5.4, / Od / RTCs


Com a opção / RTCs, o compilador insere código no início da função incorreta (int) que preenche a metade inferior do rax com um valor fixo, o que pode levar a uma violação do Access.

Para alterar esse comportamento, basta preencher rax com algum endereço válido.
Isso pode ser alcançado com uma modificação muito simples: adicione a saída de algo para std :: cout no corpo ruim (int).

Código de exemplo 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
Test :: ~ Test ()

operador << retorna um link para o fluxo, que é implementado como a colocação do endereço std :: cout em rax. O endereço está correto, pode ser desreferenciado. A violação de acesso é impedida.

Conclusão


Usando os exemplos mais simples, fomos capazes de:

  • coletar cerca de 10 manifestações diferentes de comportamento indefinido;
  • aprenda em detalhes exatamente como essas opções serão executadas.

Todos os compiladores demonstraram adesão estrita ao Padrão - em nenhum exemplo o comportamento indefinido começou mais cedo. Mas você não pode recusar uma fantasia para desenvolvedores de compiladores.

Freqüentemente, a manifestação depende de nuances sutis: vale a pena adicionar ou remover uma linha de código aparentemente irrelevante - e o comportamento do programa muda significativamente.

Obviamente, é mais fácil não escrever esse código do que resolver quebra-cabeças mais tarde.

All Articles