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 supus 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 pegaria um parâmetro bool e copia a string true
ou false
para um buffer de destino existente.
Essa função estaria em uma revisão de código, não haveria como dizer que, de fato, poderia travar 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 esse código for executado com otimizações clang 5.0.0 +, ele poderá / poderá falhar.
O operador ternário esperado boolValue ? "true" : "false"
parecia seguro o suficiente para mim, eu estava assumindo: "Qualquer valor que o lixo boolValue
contenha não importa, pois ele 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 reprogramar o problema, a combinação que achei que funcionou foi usar 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: era inteligente o suficiente deduzir que as strings "true" e "false" diferem apenas em comprimento por 1. Portanto, em vez de realmente calcular o comprimento, ele usa o valor do próprio bool, que deve tecnicamente seja 0 ou 1 e é assim:
const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue; // clang clever optimization
Embora isso seja "inteligente", por assim dizer, minha pergunta é: 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?
Ou esse é um caso de implementação definida? Nesse caso, a implementação assumiu que todos os seus bools conterão apenas 0 ou 1 e qualquer outro valor é território de comportamento indefinido?
true
” é uma regra sobre operações booleanas, incluindo “atribuição a um bool” (que pode implicitamente chamar umstatic_cast<bool>()
dependendo de detalhes). No entanto, não é um requisito sobre a representação interna de umabool
escolhida pelo compilador.Respostas:
Sim, o ISO C ++ permite (mas não requer) implementações para fazer essa escolha.
Mas observe também 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 UB, por exemplo, como uma maneira de ajudá-lo a encontrar erros. (Ou porque é um DeathStation 9000. Estar em conformidade estrita não é suficiente para uma implementação de C ++ ser útil para qualquer finalidade real). Portanto, o ISO C ++ permitiria que um compilador fizesse o asm travar (por razões totalmente diferentes), mesmo em código semelhante que lesse um não inicializado
uint32_t
. Mesmo que seja necessário ser um tipo de layout fixo sem representações de interceptação.É uma pergunta interessante sobre como as implementações reais funcionam, mas lembre-se de que, mesmo que a resposta fosse diferente, seu código ainda não seria seguro porque o C ++ moderno não é uma versão portátil da linguagem assembly.
Você está compilando para a ABI do x86-64 System V , que especifica que a
bool
como uma função arg em um registro é representado pelos padrões de bitsfalse=0
etrue=1
nos baixos 8 bits do registro 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ódigo que chame 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 o especifica, mas essa decisão ABI é generalizada porque torna barata a conversão bool-> int (apenas extensão zero) . Não conheço nenhuma ABIs que não permita que o compilador assuma 0 ou 1 para
bool
, para qualquer arquitetura (não apenas x86). Ele permite otimizações como!mybool
comxor eax,1
para inverter o bit baixo: qualquer código possível que possa inverter um bit / número inteiro / bool entre 0 e 1 na instrução de CPU única . Ou compilandoa&&b
para um E bit a bit parabool
tipos. Alguns compiladores realmente aproveitam os valores booleanos como 8 bits nos compiladores. As operações neles são ineficientes? .Em geral, a regra como se permite permite que o compilador aproveite as coisas 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 fonte C ++. (Com todas as restrições que o comportamento indefinido impõe sobre o que é realmente "visível externamente": não com um depurador, mas com outro encadeamento em um programa C ++ legal / bem formado.)
O compilador é definitivamente autorizados a tirar o máximo proveito de uma garantia ABI em seu código-gen, e fazer um código como você encontrou o que otimiza
strlen(whichString)
a5U - boolValue
. (BTW, essa otimização é meio inteligente, mas talvez míope vs. ramificada e embutidamemcpy
como armazenamento de dados imediatos 2 ).Ou o compilador poderia ter criado uma tabela de ponteiros e a indexado com o valor inteiro de
bool
, novamente assumindo que fosse 0 ou 1. ( Essa possibilidade é o que a resposta de @ Barmar sugeriu .)Seu
__attribute((noinline))
construtor com otimização ativada levou ao clang apenas carregar um byte da pilha para usar comouninitializedBool
. Abriu espaço para o objetomain
compush rax
(que é menor e, por várias razões, tão eficiente quantosub rsp, 8
), portanto, qualquer lixo que estava no AL na entradamain
é o valor usado para eleuninitializedBool
. É por isso que você realmente obteve valores que não eram justos0
.5U - random garbage
pode agrupar facilmente em um grande valor não assinado, levando o memcpy a entrar na memória não mapeada. O destino está no armazenamento estático, não na pilha, portanto você não está substituindo um endereço de retorno ou algo assim.Outras implementações podem fazer escolhas diferentes, por exemplo,
false=0
etrue=any non-zero value
. Então o clang provavelmente não criaria código que trava para esta instância específica do UB. (Mas ainda assim seria permitido, se quisesse.) Não conheço nenhuma implementação que escolha outra coisa que o x86-64 façabool
, mas o padrão C ++ permite muitas coisas que ninguém faz ou até gostaria de fazer em hardware semelhante às CPUs atuais.O ISO C ++ não especifica o que você encontrará quando examinar ou modificar a representação do objeto de a
bool
. (por exemplo,memcpy
inserindo obool
intounsigned char
, o que você pode fazer porquechar*
pode ter qualquer apelido. Eunsigned char
é garantido que não há bits de preenchimento, portanto o padrão C ++ permite formalmente fazer o hexdump de representações de objetos sem qualquer UB. representação é diferente de atribuirchar foo = my_bool
, é claro, portanto a booleanização como 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 alinhado, 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á tornando uma biblioteca executável, não uma biblioteca compartilhada Unix, onde a interposição de símbolos pode acontecer. Segundo, a definição dentro daclass{}
definição, para que todas as unidades de tradução tenham a mesma definição. Como nainline
palavra - chave.)Portanto, um compilador pode emitir apenas uma
ret
ouud2
(instrução ilegal) como a definição paramain
, porque o caminho da execução que começa no topo domain
inevitável encontra o comportamento indefinido. (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 por toda a sua existência. Mas o UB dentro de uma função ou
if()
ramo que nunca é executado realmente não corrompe o restante do programa. Na prática, isso significa que os compiladores podem decidir emitir uma instrução ilegal, ou aret
, ou não, emitir algo e cair no próximo bloco / função, para todo o bloco básico que pode ser comprovado em tempo de compilação como contendo ou levar ao UB.O GCC e o Clang, na prática , às vezes emitem
ud2
no UB, em vez de tentarem gerar código para caminhos de execução que não fazem sentido. Ou para casos como cair no final de uma nãovoid
função, o gcc às vezes omite umaret
instrução. Se você estava pensando que "minha função retornará apenas com o que houver no RAX", você está muito enganado. Os compiladores C ++ modernos não tratam mais a linguagem como uma linguagem assembly portátil. Seu programa realmente precisa ser C ++ válido, sem fazer suposições sobre como uma versão independente e não embutida de sua função pode parecer em asm.Outro exemplo divertido é: por que o acesso desalinhado à memória mmap'ed às vezes falha na AMD64? . x86 não falha em números inteiros não alinhados, certo? Então, por que um desalinhado
uint16_t*
seria um problema? Porquealignof(uint16_t) == 2
, e violar essa suposição levou a um segfault ao vetorizar automaticamente com o SSE2.Consulte também O que todo programador C deve saber sobre o comportamento indefinido nº 1/3 , um artigo de um desenvolvedor de clang.
Ponto-chave: se o compilador notasse o UB em tempo de compilação, ele poderia "interromper" (emitir um asm surpreendente) o caminho através do código que causa o UB, mesmo se visar uma ABI em que qualquer padrão de bits é uma representação de objeto válida
bool
.Espere hostilidade total em relação a muitos erros do programador, especialmente as coisas que os compiladores modernos alertam. É por isso que você deve usar
-Wall
e corrigir avisos. O C ++ não é uma linguagem amigável e algo em C ++ pode não ser seguro, mesmo que seja seguro no destino que você está compilando. (por exemplo, o overflow assinado é UB em C ++ e os compiladores assumirão que isso não acontece, mesmo ao compilar o complemento x86 de 2, a menos que você useclang/gcc -fwrapv
.)O UB visível em tempo de compilação é sempre perigoso, e é realmente difícil ter certeza (com a otimização do tempo do link) que você realmente ocultou o UB do compilador e, portanto, pode raciocinar sobre que tipo de asm ele irá gerar.
Para não ser excessivamente dramático; frequentemente os compiladores permitem que você se dê bem com algumas coisas e emita códigos como você espera, mesmo quando algo é UB. Mas talvez seja um problema no futuro se os desenvolvedores do compilador implementarem alguma otimização que obtenha mais informações sobre intervalos de valores (por exemplo, que uma variável não é negativa, talvez seja possível 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 é otimizadoa<0
como sempre falso, apenas issotmp
é sempre negativo. (ComoINT_MIN
+a=INT_MAX
é negativo na meta de complemento deste 2 ea
não pode ser maior que isso.)Portanto, o gcc / clang atualmente não recua para obter informações de intervalo para as entradas de um cálculo, apenas nos resultados com base na suposição de que não haja excesso de sinal : exemplo no Godbolt . Não sei se essa 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) têm permissão para definir o comportamento que o ISO C ++ deixa indefinido . Por exemplo, todos os compiladores que suportam as intrínsecas da Intel (como
_mm_add_ps(__m128, __m128)
na vetorização SIMD manual) devem permitir a formação de ponteiros desalinhados, que são UB em C ++, mesmo que você não os desreferencie.__m128i _mm_loadu_si128(const __m128i *)
carrega desalinhados usando um__m128i*
argumento desalinhado , não umvoid*
ouchar*
. O `reinterpret_cast`ing entre o ponteiro de vetor de hardware e o tipo correspondente é um comportamento indefinido?O GNU C / C ++ também define o comportamento de alternar para a esquerda um número assinado negativo (mesmo sem
-fwrapv
), separadamente das regras normais do UB com excesso de sinal. ( Isso é UB no ISO C ++ , enquanto os turnos corretos dos números assinados são definidos pela implementação (lógico vs. aritmético); as implementações de boa qualidade escolhem a aritmética no HW que possui turnos aritméticos à direita, mas o ISO C ++ não especifica. Isso está documentado na seção Inteiro do manual do GCC , juntamente com a definição do comportamento definido pela implementação, de que os padrões C exigem que as implementações definam uma maneira ou de outra.Definitivamente, existem problemas de qualidade de implementação com os quais os desenvolvedores de compiladores se preocupam; eles geralmente não estão tentando criar compiladores que são intencionalmente hostis, mas tirar proveito de todos os buracos de 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 registro.
( Outros ABIs fazer fazer escolhas diferentes aqui . Alguns exigem tipos inteiros estreitas para ser zero ou para preencher um cadastro estendeu-sinal quando passados para ou retornados de funções, como MIPS64 e PowerPC64. Veja a última seção de esta resposta x86-64 que compara com os ISAs anteriores .)
Por exemplo, um chamador pode ter calculado
a & 0x01010101
no RDI e usado para outra coisa antes de chamarbool_func(a&1)
. O responsável pela chamada pode otimizar o processo&1
porque já fez isso com o byte baixo como parte deand edi, 0x01010101
, e sabe que o chamado é necessário para ignorar os bytes altos.Ou, se um bool é passado como terceiro argumento, talvez um chamador que otimize o tamanho do código o carregue em
mov dl, [mem]
vez demovzx edx, [mem]
, economizando 1 byte ao custo de uma falsa dependência do valor antigo do RDX (ou outro efeito de registro parcial, dependendo no modelo de CPU). Ou para o primeiro argumento, emmov dil, byte [r10]
vez demovzx edi, byte [r10]
, porque ambos exigem um prefixo REX de qualquer maneira.É por isso que o clang emite ,
movzx eax, dil
emSerialize
vez desub eax, edi
. (Para args inteiros, clang viola essa regra ABI, dependendo do comportamento não documentado de gcc e clang para números inteiros estreitos com extensão de zero ou sinal para 32 bits. É uma extensão de sinal ou zero necessária ao adicionar um deslocamento de 32 bits a um ponteiro para ABI x86-64? Então, eu estava interessado em ver que ele não faz a mesma coisabool
.)Nota de rodapé 2: após a ramificação, você teria um
mov
intermediário de 4 bytes ou um armazenamento de 4 bytes + 1 byte. O comprimento está implícito nas larguras da loja + compensações.OTOH, o glibc memcpy fará dois carregamentos / armazenamentos de 4 bytes com uma sobreposição que depende do comprimento, então isso realmente acaba deixando a coisa toda livre de ramificações condicionais no booleano. Veja o
L(between_4_7):
bloco em 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 for embutido, você pode usar o 2x
mov
-imediato +cmov
e um deslocamento condicional, ou pode deixar os dados da string na memória.Ou, se estiver ajustando para o Intel Ice Lake ( com o recurso Fast Short REP MOV ), um real
rep movsb
pode ser o ideal. O glibcmemcpy
pode começar a usarrep movsb
em tamanhos pequenos em CPUs com esse recurso, economizando muitas ramificações.Ferramentas para detectar UB e 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 avisará ou ocorrerá um erro no UB que acontece no tempo de execução. Isso não captura variáveis unitializadas, no entanto. (Como não aumenta o tamanho dos tipos para dar espaço a um bit "não inicializado").Consulte https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
Para encontrar o uso de dados não inicializados, há o Sanitizer de Endereço e o Sanitizer de Memória no clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer mostra exemplos de
clang -fsanitize=memory -fPIE -pie
detecção de leituras de memória não inicializadas. Pode funcionar melhor se você compilar sem otimização, para que todas as leituras de variáveis acabem sendo carregadas da memória no asm. Eles mostram que está sendo usado-O2
em um caso em que a carga não seria otimizada. Eu não tentei eu mesmo. (Em alguns casos, por exemplo, não inicializando um acumulador antes de somar uma matriz, o clang -O3 emitirá código que soma um registro vetorial que nunca foi inicializado. Portanto, com a otimização, você pode ter um caso em que não há memória lida associada ao UB . Mas-fsanitize=memory
altera o asm gerado e pode resultar em uma verificação para isso.)Deve funcionar nesse caso, porque a chamada para glibc
memcpy
com umalength
memória calculada a partir de não inicializada (dentro da biblioteca) resultará em uma ramificação baseada emlength
. Se tivesse descrito uma versão totalmente sem ramificação que apenas usavacmov
, indexava e duas lojas, talvez não funcionasse.O Valgrind
memcheck
também procurará esse tipo de problema, novamente sem reclamar se o programa simplesmente copiar dados não inicializados. Mas ele diz que detectará quando um "salto ou movimento condicional depende de valores não inicializados", para tentar capturar qualquer comportamento visível externamente que dependa de dados não inicializados.Talvez a idéia 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 de vetores não é um erro, mesmo que os membros individuais tenham sido 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.
fonte
O compilador pode assumir que um valor booleano passado como argumento é um valor booleano válido (ou seja, um que foi inicializado ou convertido em
true
oufalse
). Otrue
valor não precisa ser o mesmo que o número inteiro 1 - de fato, pode haver várias representações detrue
efalse
- mas o parâmetro deve ser uma representação válida de um desses dois valores, em que "representação válida" é implementada - definiram.Portanto, se você não inicializar um
bool
, ou se conseguir substituí-lo por algum ponteiro de um tipo diferente, as suposições do compilador estarão erradas e o Comportamento Indefinido será seguido. Você foi avisado:fonte
true
valor não precisa ser o mesmo que o número inteiro 1" é enganoso. Claro, o padrão de bits real pode ser outra coisa, mas quando convertido / promovido implicitamente (a única maneira de ver um valor diferente detrue
/false
),true
é sempre1
efalse
sempre0
. Obviamente, esse compilador também não seria capaz de usar o truque que esse compilador estava tentando usar (usando o fato de quebool
o padrão de bits real só poderia ser0
ou1
), por isso é meio irrelevante para o problema do OP.true
ao padrão de bits1
, é uma prerrogativa. Se escolher algum outro conjunto de representações, ele realmente não poderá usar a otimização observada aqui. Se ele escolher essa representação específica, poderá. Ele só precisa ser consistente internamente. Você pode examinar a representação de abool
copiando-a em uma matriz de bytes; que não é UB (mas é definido pela implementação)bool
padrão de bits0
ou1
. Eles não re-booleanizambool
toda vez que o lêem da memória (ou um registro contendo uma função arg). É isso que esta resposta está dizendo. exemplos : o gcc4.7 + pode otimizarreturn a||b
paraor eax, edi
uma função retornadabool
ou o MSVC pode otimizara&b
paratest cl, dl
. x86test
é um bit a bitand
, portanto, secl=1
edl=2
teste definir sinalizadores de acordo comcl&dl = 0
.A função em si está correta, mas em seu programa de teste, a instrução que chama a função causa um 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 do explorador do compilador, o compilador gcc 8.2 detecta o erro. (Talvez você possa enviar um relatório de erro contra o clang de que ele não encontra 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 "Um comportamento indefinido pode causar _____?" é sempre "sim". Essa é literalmente a definição de comportamento indefinido.
fonte
bool
UB de gatilho não inicializado ?bool
). A cópia requer a avaliação da fontetrue
,false
enot-a-thing
valores para booleans.Um bool é permitido apenas para manter os valores dependentes de implementação usados internamente para
true
efalse
, e o código gerado pode assumir que ele conterá apenas um desses dois valores.Normalmente, a implementação usará o número inteiro
0
parafalse
e1
paratrue
, para simplificar as conversões entrebool
eint
eif (boolvar)
gerar o mesmo código queif (intvar)
. Nesse caso, pode-se imaginar que o código gerado para o ternário na atribuição usaria o valor como índice em uma matriz de ponteiros para as duas strings, ou seja, poderia ser convertido para algo como:Se
boolValue
não for inicializado, ele poderá conter qualquer valor inteiro, o que causaria o acesso fora dos limites dastrings
matriz.fonte
bool
paraint
com*(int *)&boolValue
e imprima-o para fins de depuração, veja se é algo diferente de0
ou1
quando falha. Se for esse o caso, confirma praticamente a teoria de que o compilador está otimizando o inline-if como uma matriz que explica por que está travando.std::bitset<8>
não me dá nomes agradáveis para todas as minhas bandeiras diferentes. Dependendo do que são, isso pode ser importante.Resumindo muito sua pergunta, você está perguntando. O padrão C ++ permite que um compilador assuma
bool
que só pode ter uma representação numérica interna de '0' ou '1' e usá-la dessa maneira?O padrão não diz nada sobre a representação interna de a
bool
. Ele define apenas o que acontece ao converter umbool
para umint
(ou vice-versa). Principalmente, por causa dessas conversões integrais (e pelo fato de as pessoas confiarem bastante nelas), o compilador usará 0 e 1, mas não precisará (embora deva respeitar as restrições de qualquer ABI de nível inferior que use )Portanto, o compilador, quando vê a,
bool
tem o direito de considerar que o ditobool
contém um dos padrões de bits 'true
' ou 'false
' e fazer o que quiser. Portanto, se os valores paratrue
efalse
são 1 e 0, respectivamente, o compilador pode realmente otimizarstrlen
para5 - <boolean value>
. Outros comportamentos divertidos são possíveis!Como afirmado repetidamente aqui, o comportamento indefinido tem resultados indefinidos. Incluindo mas não limitado a
Consulte O que todo programador deve saber sobre comportamento indefinido
fonte