c++ - installing - llvm flags




O padrão C++ permite que um bool não inicializado trava um programa? (4)

Sim, o ISO C ++ permite (mas não requer) implementações para fazer essa escolha.

Mas também observe que o ISO C ++ permite que um compilador emita código que trava de propósito (por exemplo, com uma instrução ilegal) se o programa encontrar o UB, por exemplo, como uma maneira de ajudá-lo a encontrar erros. (Ou porque é um DeathStation 9000. Estar estritamente em conformidade não é suficiente para uma implementação C ++ ser útil para qualquer propósito real). Então, o ISO C ++ permitiria que um compilador fizesse um asm que caiu (por razões totalmente diferentes), mesmo em código similar que lê um uint32_t não uint32_t . Mesmo que seja necessário um tipo de layout fixo sem representações de interceptação.

É uma questão interessante sobre como implementações reais funcionam, mas lembre-se de que, mesmo que a resposta fosse diferente, seu código ainda seria inseguro, pois o C ++ moderno não é uma versão portátil da linguagem assembly.

Você está compilando para o x86-64 System V ABI , que especifica que um bool como uma função arg em um registrador é representado pelos padrões de bits false=0 e true=1 nos 8 bits baixos do registrador 1 . Na memória, bool é um tipo de 1 byte que novamente deve ter um valor inteiro de 0 ou 1.

(Uma ABI é um conjunto de opções de implementação que os compiladores da mesma plataforma concordam para que possam criar códigos que chamam as funções uns dos outros, incluindo tamanhos de tipo, regras de layout de estrutura e convenções de chamada.)

O ISO C ++ não especifica isso, mas esta decisão da ABI é generalizada porque torna a conversão bool-> int barata (apenas zero-extensão) . Eu não estou ciente de nenhuma ABIs que não deixe o compilador assumir 0 ou 1 para bool , para qualquer arquitetura (não apenas x86). Ele permite otimizações como !mybool com xor eax,1 para inverter o bit baixo: Qualquer código possível que possa inverter um bit / integer / bool entre 0 e 1 em instruções de CPU única . Ou compilar a&&b para um bit a bit e para tipos bool . Alguns compiladores realmente aproveitam valores booleanos como 8 bits em compiladores. As operações neles são ineficientes? .

Em geral, a regra as-if permite que o compilador aproveite as coisas que são verdadeiras na plataforma de destino que está sendo compilada , porque o resultado final será um código executável que implementa o mesmo comportamento visível externamente que a origem C ++. (Com todas as restrições que o Undefined Behavior coloca sobre o que é realmente "externamente visível": não com um depurador, mas de outro thread em um programa C ++ bem formado / legal.)

O compilador está definitivamente autorizado a tirar o máximo proveito de uma garantia ABI em seu código-gen, e fazer código como você encontrou que otimiza strlen(whichString) para
5U - boolValue . (BTW, essa otimização é inteligente, mas talvez míope vs. ramificação e inlining memcpy como armazenamentos de dados imediatos 2. )

Ou o compilador poderia ter criado uma tabela de ponteiros e indexado com o valor inteiro do bool , novamente assumindo que fosse 0 ou 1. ( Esta possibilidade é o que sugeriu a resposta de @Barmar ).

O seu __attribute((noinline)) com otimização ativada levou a um simples carregamento de um byte da pilha para ser usado como uninitializedBool . Ele criou espaço para o objeto em main com push rax (que é menor e, por várias razões, tão eficiente quanto sub rsp, 8 ), portanto, qualquer lixo que esteja em AL na entrada main é o valor usado para uninitializedBool . É por isso que você realmente obteve valores que não eram apenas 0 .

5U - random garbage pode ser facilmente quebrado em um grande valor não sinalizado, levando o memcpy a entrar na memória não mapeada. O destino está no armazenamento estático, não na pilha, então você não está sobrescrevendo um endereço de retorno ou algo assim.

Outras implementações podem fazer escolhas diferentes, por exemplo, false=0 e true=any non-zero value . Então, o clang provavelmente não criaria um código que falha nessa instância específica do UB. (Mas ainda assim seria permitido se eu quisesse.) Eu não sei de nenhuma implementação que escolha algo diferente do que o x86-64 faz para o bool , mas o padrão C ++ permite muitas coisas que ninguém faz ou até gostaria de fazer em hardware que é parecido com CPUs atuais.

O ISO C ++ não especifica o que você encontrará quando examinar ou modificar a representação do objeto de um bool . (por exemplo, memcpy ing o bool em unsigned char , o que você está autorizado a fazer porque char* pode alias qualquer coisa. E unsigned char é garantido não ter bits de preenchimento, de modo que o padrão C ++ formalmente permite hexdump representações objeto sem qualquer UB Pointer-casting para copiar a representação do objeto é diferente de atribuir char foo = my_bool , é claro, então a booleanização para 0 ou 1 não aconteceria e você obteria a representação do objeto bruto.)

Você parcialmente "ocultou" o UB neste caminho de execução do compilador com noinline . Mesmo que não esteja em linha, as otimizações interprocedurais ainda podem fazer uma versão da função que depende da definição de outra função. (Primeiro, o clang está fazendo um executável, não uma biblioteca compartilhada Unix onde a interposição de símbolos pode acontecer. Segundo, a definição dentro da definição de class{} para que todas as unidades de tradução tenham a mesma definição. Como com a palavra-chave inline .

Assim, um compilador poderia emitir apenas um ret ou ud2 (instrução ilegal) como a definição para main , porque o caminho da execução que começa no início do main inevitavelmente encontra o Undefined Behavior. (Que o compilador pode ver em tempo de compilação se decidir seguir o caminho através do construtor não-inline).

Qualquer programa que encontre o UB é totalmente indefinido durante toda a sua existência. Mas UB dentro de uma função ou if() branch que nunca executa realmente não corrompe o resto do programa. Na prática, isso significa que os compiladores podem decidir emitir uma instrução ilegal, ou ret , ou não emitir nada e cair no próximo bloco / função, para todo o bloco básico que pode ser provado em tempo de compilação para conter ou levar ao UB.

O GCC e o Clang, na prática , às vezes emitem o ud2 no UB, em vez de tentar gerar código para caminhos de execução que não fazem sentido. Ou para casos como o fim de uma função não void , o gcc às vezes omitirá uma instrução ret . Se você estava pensando que "minha função só retornará com qualquer lixo que esteja em RAX", você está muito enganado. Os compiladores C ++ modernos não tratam mais a linguagem como uma linguagem de montagem portátil. Seu programa realmente precisa ser válido em C ++, sem fazer suposições sobre como uma versão independente não alinhada de sua função pode parecer em um asm.

Outro exemplo divertido é: Por que o acesso desalinhado à memória mmap'ed às vezes se afasta da AMD64? . x86 não falha em números inteiros não alinhados, certo? Então, por que um desalinhado uint16_t* seria um problema? Porque alignof(uint16_t) == 2 , e violar essa suposição levou a um segfault quando vetorização automática com SSE2.

Veja também O Que Todo Programador C Deve Saber Sobre o Comportamento Indefinido # 1/3 , um artigo de um desenvolvedor clang.

Ponto-chave: se o compilador notou o UB em tempo de compilação, ele poderia "quebrar" (emitir um asm surpreendente) o caminho através de seu código que causa o UB mesmo se alvejar uma ABI onde qualquer bit-padrão é uma representação válida para bool .

Espere total hostilidade em relação a muitos erros cometidos pelo programador, especialmente coisas sobre as quais os modernos compiladores avisam. É por isso que você deve usar -Wall e corrigir avisos. C ++ não é uma linguagem amigável, e algo em C ++ pode ser inseguro, mesmo que seja seguro em um alvo que você está compilando. (por exemplo, estouro assinado é UB em C ++ e os compiladores assumem que isso não acontece, mesmo quando compilando para complemento de x86 de 2, a menos que você use clang/gcc -fwrapv .)

UB com tempo de compilação visível é sempre perigoso, e é realmente difícil ter certeza (com otimização de tempo de link) que você realmente escondeu UB do compilador e pode, portanto, raciocinar sobre que tipo de asm ele irá gerar.

Não ser excessivamente dramático; Muitas vezes os compiladores permitem que você se afaste de algumas coisas e emita código como se estivesse esperando, mesmo quando algo é UB. Mas talvez seja um problema no futuro se o compilador desenvolver alguma otimização que obtenha mais informações sobre intervalos de valores (por exemplo, que uma variável não seja negativa, talvez permitindo otimizar a extensão de sinal para liberar a extensão zero em x86 64). Por exemplo, no atual gcc e clang, fazer tmp = a+INT_MIN não otimiza a<0 como sempre-false, apenas esse tmp é sempre negativo. (Porque INT_MIN + a=INT_MAX é negativo no alvo do complemento deste 2, e a não pode ser maior do que isso.)

Portanto, o gcc / clang não recua no momento para derivar informações de intervalo para as entradas de um cálculo, apenas nos resultados com base na suposição de nenhum estouro assinado: exemplo em Godbolt . Eu não sei se isso é otimização intencionalmente "perdida" em nome da facilidade de uso ou o quê.

Observe também que as implementações (também conhecidas como compiladores) podem definir o comportamento que o ISO C ++ deixa indefinido . Por exemplo, todos os compiladores que suportam intrínsecos da Intel (como _mm_add_ps(__m128, __m128) para vetorização SIMD manual) devem permitir a formação de ponteiros mal alinhados, que é UB em C ++, mesmo que você não os desreferencie. __m128i _mm_loadu_si128(const __m128i *) faz cargas não alinhadas tomando um __m128i* arg desalinhado, não um void* ou char* . É `reinterpret_cast`ing entre ponteiro vetorial de hardware e o tipo correspondente um comportamento indefinido?

O GNU C / C ++ também define o comportamento de deslocar para a esquerda um número assinado negativo (mesmo sem -fwrapv ), separadamente das regras UB de transbordamento com -fwrapv normal. ( Isso é UB em ISO C ++ , enquanto turnos à direita de números assinados são definidos pela implementação (lógica vs. aritmética); implementações de boa qualidade escolhem aritmética em HW que tenha mudanças aritméticas à direita, mas o ISO C ++ não especifica). Isso está documentado na seção Integer do manual do GCC , juntamente com a definição do comportamento definido pela implementação que os padrões C exigem implementações para definir de uma forma ou de outra.

Definitivamente, há problemas de qualidade de implementação com os quais os desenvolvedores de compiladores se importam; eles geralmente não estão tentando fazer compiladores que são intencionalmente hostis, mas tirar proveito de todos os buracos UB em C ++ (exceto aqueles que eles escolhem definir) para otimizar melhor pode ser quase indistinguível às vezes.

Nota de rodapé 1 : Os 56 bits superiores podem ser lixo que o receptor deve ignorar, como de costume para tipos mais estreitos que um registrador.

( Outras ABIs fazem escolhas diferentes aqui . Algumas exigem que tipos inteiros estreitos sejam estendidos em zero ou em sinal para preencher um registro quando passados ​​para ou retornados de funções, como MIPS64 e PowerPC64. Consulte a última seção desta resposta x86-64. que compara com os anteriores ISAs .)

Por exemplo, um chamador pode ter calculado a & 0x01010101 em RDI e usado para outra coisa, antes de chamar bool_func(a&1) . O chamador pode otimizar o &1 porque já fez isso para o byte baixo como parte de and edi, 0x01010101 , e sabe que o chamado é necessário para ignorar os bytes altos.

Ou se um bool for passado como o terceiro argumento, talvez um chamador que esteja otimizando o tamanho do código carregue com mov dl, [mem] vez de movzx edx, [mem] , salvando 1 byte ao custo de uma falsa dependência do antigo valor de RDX (ou outro efeito de registro parcial, dependendo do modelo da CPU). Ou para o primeiro argumento, mov dil, byte [r10] vez de movzx edi, byte [r10] , porque ambos exigem um prefixo REX de qualquer maneira.

É por isso que o clang emite movzx eax, dil em Serialize , em vez de sub eax, edi . (Para argumentos inteiros, o clang viola esta regra ABI, em vez disso, depende do comportamento não documentado do gcc e clang para números inteiros estreitos com extensão de zero ou de sinal . É necessário um sinal ou extensão zero ao adicionar um deslocamento de 32 bits a um ponteiro para o x86-64 ABI? Então, eu estava interessado em ver que não faz a mesma coisa para bool .)

Nota de rodapé 2: Após a ramificação, você teria apenas um armazenamento imediato de 4 bytes mov ou um armazenamento de 4 bytes + 1 byte. O comprimento está implícito nas larguras de loja + offsets.

OTOH, glibc memcpy fará duas cargas / armazenamentos de 4 bytes com uma sobreposição que depende do comprimento, então isso realmente acaba fazendo com que a coisa toda fique livre de ramificações condicionais no booleano. Veja o L(between_4_7): bloqueie no memcpy / memmove da glibc. Ou pelo menos, siga o mesmo caminho para booleano na ramificação do memcpy para selecionar um tamanho de bloco.

Se inlining, você poderia usar 2x mov cmov + cmov e um offset condicional, ou você poderia deixar os dados da string na memória.

Ou, se estiver sintonizando o Intel Ice Lake ( com o recurso Fast Short REP MOV ), um rep movsb real rep movsb pode ser ideal. O memcpy pode começar a usar o rep movsb para tamanhos pequenos em CPUs com esse recurso, economizando muita ramificação.

Ferramentas para detectar o UB e o uso de valores não inicializados

No gcc e no clang, você pode compilar com -fsanitize=undefined para adicionar instrumentação em tempo de execução que irá avisar ou errar no UB que acontece em tempo de execução. Isso não vai pegar variáveis ​​unitializadas, no entanto. (Porque não aumenta os tamanhos de tipo para criar espaço para um bit "não inicializado").

Veja https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

Para encontrar o uso de dados não inicializados, há o Address Sanitizer e o Memory Sanitizer em clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer mostra exemplos de clang -fsanitize=memory -fPIE -pie detectando leituras de memória não inicializadas. Pode funcionar melhor se você compilar sem otimização, portanto, todas as leituras de variáveis ​​acabam sendo realmente carregadas da memória no asm. Eles mostram que ele está sendo usado em -O2 em um caso em que a carga não seria otimizada. Eu não tentei isso sozinho. (Em alguns casos, por exemplo, não inicializar um acumulador antes de somar uma matriz, clang -O3 emitirá um código que soma um registrador vetorial que nunca foi inicializado. Portanto, com a otimização, você pode ter um caso em que não há memória associada à UB Mas -fsanitize=memory altera o asm gerado e pode resultar em uma verificação para isso.

Ele tolerará a cópia de memória não inicializada e também operações lógicas simples e aritméticas com ele. Em geral, o MemorySanitizer monitora silenciosamente a propagação de dados não inicializados na memória e reporta um aviso quando uma ramificação de código é executada (ou não) dependendo de um valor não inicializado.

O MemorySanitizer implementa um subconjunto de funcionalidades encontrado no Valgrind (ferramenta Memcheck).

Deve funcionar para este caso porque a chamada para o memcpy com um length calculado a partir da memória não inicializada irá (dentro da biblioteca) resultar num ramo baseado no length . Se tivesse incluído uma versão totalmente sem cmov que usasse cmov , indexação e duas lojas, talvez não funcionasse.

O memcheck de Valgrind também procura por esse tipo de problema, mais uma vez não reclamando se o programa simplesmente copia dados não inicializados. Mas ele diz que detectará quando um "salto ou movimento condicional depende de valor (ns) não inicializado", para tentar capturar qualquer comportamento visível externamente que dependa de dados não inicializados.

Talvez a ideia por trás de não sinalizar apenas uma carga seja que as estruturas possam ter preenchimento, e copiar toda a estrutura (incluindo preenchimento) com uma ampla carga / armazenamento vetorial não é um erro, mesmo se os membros individuais fossem escritos apenas um de cada vez. No nível ASM, as informações sobre o que estava preenchendo e o que realmente faz parte do valor foram perdidas.

Eu sei que um "comportamento indefinido" em C ++ pode permitir que o compilador faça o que quiser. No entanto, tive um acidente que me surpreendeu, pois assumi que o código era seguro o suficiente.

Nesse caso, o problema real aconteceu apenas em uma plataforma específica usando um compilador específico e somente se a otimização estivesse ativada.

Eu tentei várias coisas para reproduzir o problema e simplificá-lo ao máximo. Aqui está um extrato de uma função chamada Serialize , que usaria um parâmetro bool e copiaria a string true ou false para um buffer de destino existente.

Esta função estaria em uma revisão de código, não haveria nenhuma maneira de dizer que, de fato, poderia falhar se o parâmetro bool fosse um valor não 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);
}

Se este código for executado com otimizações do clang 5.0.0 +, ele irá / poderá falhar.

O boolValue ? "true" : "false" operador ternário esperado boolValue ? "true" : "false" boolValue ? "true" : "false" parecia seguro o suficiente para mim, eu estava assumindo, "Qualquer valor de lixo está em boolValue não importa, uma vez que será avaliado como verdadeiro ou falso de qualquer maneira."

Eu configurei um exemplo do Compiler Explorer que mostra o problema na desmontagem, aqui o exemplo completo. Nota: para refazer o problema, a combinação que encontrei que funcionou é usando o Clang 5.0.0 com otimização -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;
}

O problema surge por causa do otimizador: Foi inteligente o suficiente para deduzir que as strings "true" e "false" só diferem em comprimento por 1. Então, ao invés de realmente calcular o comprimento, ele usa o valor do bool em si, que deveria tecnicamente ser 0 ou 1, e é assim:

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

Enquanto isso é "inteligente", por assim dizer, minha pergunta é: O padrão C ++ permite que um compilador assuma que um bool só pode ter uma representação numérica interna de '0' ou '1' e usá-lo de tal maneira?

Ou este é um caso de implementação definida, em cujo caso a implementação assumiu que todos os seus bools sempre conterão 0 ou 1, e qualquer outro valor é um território de comportamento indefinido?


A função em si está correta, mas no seu programa de teste, a instrução que chama a função causa comportamento indefinido usando o valor de uma variável não inicializada.

O bug está na função de chamada e pode ser detectado por revisão de código ou análise estática da função de chamada. Usando o link explorer do compilador, o compilador do gcc 8.2 detecta o bug. (Talvez você possa enviar um relatório de bug contra o clang que ele não encontre o problema).

Comportamento indefinido significa que tudo pode acontecer, o que inclui o programa travando algumas linhas após o evento que acionou o comportamento indefinido.

NB A resposta para "O comportamento indefinido pode causar _____?" é sempre "sim". Isso é literalmente a definição de comportamento indefinido.


Resumindo muito sua pergunta, você está perguntando: O padrão C ++ permite que um compilador assuma que um bool pode ter apenas uma representação numérica interna de '0' ou '1' e usá-lo dessa maneira?

O padrão não diz nada sobre a representação interna de um bool . Ele define apenas o que acontece quando um bool é bool para um int (ou vice-versa). Principalmente, devido a essas conversões integrais (e ao fato de que as pessoas confiam bastante nelas), o compilador usará 0 e 1, mas não precisa (embora tenha que respeitar as restrições de qualquer ABI de nível inferior que use) ).

Então, o compilador, quando ele vê um, bool tem o direito de considerar que o dito bool contém qualquer um dos padrões de bits ' true ' ou ' false ' e faz qualquer coisa que pareça. Portanto, se os valores for true e false forem 1 e 0, respectivamente, o compilador poderá otimizar strlen para 5 - <boolean value> . Outros comportamentos divertidos são possíveis!

Como se afirma repetidamente aqui, o comportamento indefinido tem resultados indefinidos. Incluindo mas não limitado a

  • Seu código funcionando como você esperava
  • Seu código falha em momentos aleatórios
  • Seu código não está sendo executado em tudo.

Veja O que todo programador deve saber sobre comportamento indefinido


Um bool só pode manter os valores 0 ou 1 , e o código gerado pode assumir que ele conterá apenas um desses dois valores. O código gerado para o ternário na atribuição poderia usar o valor como o índice em uma matriz de ponteiros para as duas seqüências, ou seja, ele pode ser convertido em 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];

Se boolValue não for inicializado, ele poderá, na verdade, conter qualquer valor inteiro, o que causaria o acesso fora dos limites da matriz de strings .





abi