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 bits false=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
com xor 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 compilando a&&b
para um E bit a bit para bool
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)
a
5U - boolValue
. (BTW, essa otimização é meio inteligente, mas talvez míope vs. ramificada e embutida memcpy
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 como uninitializedBool
. Abriu espaço para o objeto main
com push rax
(que é menor e, por várias razões, tão eficiente quanto sub rsp, 8
), portanto, qualquer lixo que estava no AL na entrada main
é o valor usado para ele uninitializedBool
. É por isso que você realmente obteve valores que não eram justos 0
.
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
e true=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ça bool
, 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 abool
. (por exemplo, memcpy
inserindo o bool
into unsigned char
, o que você pode fazer porque char*
pode ter qualquer apelido. E unsigned 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 atribuir char 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 comnoinline
. 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 da class{}
definição, para que todas as unidades de tradução tenham a mesma definição. Como na inline
palavra - chave.)
Portanto, um compilador pode emitir apenas uma ret
ou ud2
(instrução ilegal) como a definição para main
, porque o caminho da execução que começa no topo do main
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 a ret
, 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ão void
função, o gcc às vezes omite uma ret
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? Porque alignof(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ê use clang/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 é otimizado a<0
como sempre falso, apenas isso tmp
é sempre negativo. (Como INT_MIN
+ a=INT_MAX
é negativo na meta de complemento deste 2 e a
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 um void*
ou char*
. 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 chamar bool_func(a&1)
. O responsável pela chamada pode otimizar o processo &1
porque já fez isso com 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 é passado como terceiro argumento, talvez um chamador que otimize o tamanho do código o carregue em mov dl, [mem]
vez de movzx 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, em 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
vez de sub 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 coisa bool
.)
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 glibc memcpy
pode começar a usar rep 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.)
Ele tolerará a cópia de memória não inicializada e também operações lógicas e aritméticas simples. Em geral, o MemorySanitizer rastreia silenciosamente a propagação de dados não inicializados na memória e relata um aviso quando uma ramificação de código é obtida (ou não), dependendo de um valor não inicializado.
O MemorySanitizer implementa um subconjunto de funcionalidades encontradas no Valgrind (ferramenta Memcheck).
Deve funcionar nesse caso, porque a chamada para glibc memcpy
com uma length
memória calculada a partir de não inicializada (dentro da biblioteca) resultará em uma ramificação baseada em length
. Se tivesse descrito uma versão totalmente sem ramificação que apenas usava cmov
, indexava e duas lojas, talvez não funcionasse.
O Valgrindmemcheck
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.