Considere a seguinte declaração:
*((char*)NULL) = 0; //undefined behavior
Evoca claramente um comportamento indefinido. A existência de tal instrução em um determinado programa significa que todo o programa é indefinido ou que o comportamento só se torna indefinido quando o fluxo de controle atinge essa instrução?
O programa a seguir estaria bem definido caso o usuário nunca inserisse o número 3
?
while (true) {
int num = ReadNumberFromConsole();
if (num == 3)
*((char*)NULL) = 0; //undefined behavior
}
Ou é um comportamento totalmente indefinido, não importa o que o usuário insira?
Além disso, o compilador pode assumir que o comportamento indefinido nunca será executado em tempo de execução? Isso permitiria raciocinar para trás no tempo:
int num = ReadNumberFromConsole();
if (num == 3) {
PrintToConsole(num);
*((char*)NULL) = 0; //undefined behavior
}
Aqui, o compilador pode raciocinar que, no caso num == 3
, sempre invocaremos um comportamento indefinido. Portanto, este caso deve ser impossível e o número não precisa ser impresso. Toda a if
declaração pode ser otimizada. Esse tipo de raciocínio reverso é permitido de acordo com o padrão?
const int i = 0; if (i) 5/i;
.PrintToConsole
não chama,std::exit
então ele tem que fazer a chamada.Respostas:
Nem. A primeira condição é muito forte e a segunda é muito fraca.
O acesso a objetos às vezes é sequenciado, mas o padrão descreve o comportamento do programa fora do tempo. Danvil já citado:
Isso pode ser interpretado:
Portanto, uma instrução inacessível com UB não dá ao programa UB. Uma declaração alcançável que (por causa dos valores das entradas) nunca é alcançada, não dá ao programa UB. É por isso que sua primeira condição é muito forte.
Agora, o compilador em geral não pode dizer o que UB tem. Portanto, para permitir que o otimizador reordene instruções com UB potencial que seria reordenado caso seu comportamento fosse definido, é necessário permitir que UB "volte no tempo" e dê errado antes do ponto de sequência anterior (ou em C ++ 11 terminologia, para o UB afetar coisas que são sequenciadas antes da coisa do UB). Portanto, sua segunda condição é muito fraca.
Um exemplo importante disso é quando o otimizador depende de aliasing estrito. O objetivo principal das regras de aliasing estritas é permitir que o compilador reordene operações que não poderiam ser reordenadas validamente se fosse possível que os ponteiros em questão usassem o alias da mesma memória. Portanto, se você usar ponteiros de aliasing ilegalmente, e o UB ocorrer, ele pode facilmente afetar uma instrução "antes" da instrução UB. No que diz respeito à máquina abstrata, a instrução UB ainda não foi executada. No que diz respeito ao código-objeto real, ele foi executado parcial ou totalmente. Mas o padrão não tenta entrar em detalhes sobre o que significa para o otimizador reordenar as instruções, ou quais são as implicações disso para o UB. Ele apenas dá a licença de implementação para que dê errado assim que for conveniente.
Você pode pensar nisso como "UB tem uma máquina do tempo".
Especificamente para responder aos seus exemplos:
PrintToConsole(3)
alguma forma tenha certeza de que retornará. Isso poderia lançar uma exceção ou algo assim.Um exemplo semelhante ao seu segundo é a opção gcc
-fdelete-null-pointer-checks
, que pode receber código como este (não verifiquei este exemplo específico, considere-o ilustrativo da ideia geral):e mude para:
Por quê? Porque if
p
é nulo, então o código tem UB de qualquer maneira, então o compilador pode assumir que não é nulo e otimizar de acordo. O kernel do linux tropeçou nisso ( https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897 ) essencialmente porque ele opera em um modo em que a desreferenciação de um ponteiro nulo não deveria ser UB, espera-se que resulte em uma exceção de hardware definida que o kernel pode manipular. Quando a otimização está habilitada, o gcc requer o uso de-fno-delete-null-pointer-checks
para fornecer aquela garantia além do padrão.PS A resposta prática à pergunta "quando ocorre o comportamento indefinido?" é "10 minutos antes de você planejava sair para o dia".
fonte
void can_add(int x) { if (x + 100 < x) complain(); }
pode ser otimizado afastado por completo, porque sex+100
doesn' estouro nada acontece, e sex+100
faz transbordamento, que é UB acordo com a norma, então nada pode acontecer.3
se quisesse, e despachar para casa durante o dia assim que vi um entrada.O padrão indica 1,9 / 4
O ponto interessante é provavelmente o que significa "conter". Um pouco mais tarde, em 1.9 / 5, afirma:
Aqui, ele menciona especificamente "execução ... com essa entrada". Eu interpretaria isso como um comportamento indefinido em uma ramificação possível que não é executada agora não influencia a ramificação atual de execução.
Um problema diferente, entretanto, são as suposições baseadas em um comportamento indefinido durante a geração do código. Veja a resposta de Steve Jessop para mais detalhes sobre isso.
fonte
Um exemplo instrutivo é
Tanto o GCC quanto o Clang atuais irão otimizar isso (em x86) para
porque eles deduzem que
x
é sempre zero do UB noif (x)
caminho de controle. O GCC nem mesmo dará a você um aviso de uso de valor não inicializado! (porque o passe que aplica a lógica acima é executado antes do passe que gera avisos de valor não inicializado)fonte
a
mesmo se em todas as circunstâncias onde um não inicializadoa
seria passado para a função que a função nunca faria nada com ele)?O atual rascunho de trabalho do C ++ diz em 1.9.4 que
Com base nisso, eu diria que um programa contendo comportamento indefinido em qualquer caminho de execução pode fazer qualquer coisa a cada momento de sua execução.
Existem dois artigos muito bons sobre comportamento indefinido e o que os compiladores costumam fazer:
fonte
int f(int x) { if (x > 0) return 100/x; else return 100; }
certamente nunca invoca um comportamento indefinido, embora100/0
seja, obviamente, indefinido.printf("Hello, World"); *((char*)NULL) = 0
não há garantia de impressão de nada. Isso ajuda na otimização, porque o compilador pode reordenar livremente as operações (sujeitas a restrições de dependência, é claro) que ele sabe que ocorrerão eventualmente, sem ter que levar em conta o comportamento indefinido.int x,y; std::cin >> x >> y; std::cout << (x+y);
é permitido dizer que "1 + 1 = 17", apenas porque há algumas entradas ondex+y
estoura (que é UB, poisint
é um tipo com sinal).A palavra "comportamento" significa que algo está sendo feito . Um statemenr que nunca é executado não é "comportamento".
Uma ilustração:
Esse é um comportamento indefinido? Suponha que estejamos 100% certos
ptr == nullptr
pelo menos uma vez durante a execução do programa. A resposta deve ser sim.Que tal isso?
Isso é indefinido? (Lembra-se de
ptr == nullptr
pelo menos uma vez?) Espero que não, caso contrário, você não conseguirá escrever nenhum programa útil.Nenhum srandardês foi prejudicado ao formular essa resposta.
fonte
O comportamento indefinido ocorre quando o programa irá causar comportamento indefinido, não importa o que aconteça a seguir. No entanto, você deu o seguinte exemplo.
A menos que o compilador conheça a definição de
PrintToConsole
, ele não pode removerif (num == 3)
condicional. Vamos supor que você tenha oLongAndCamelCaseStdio.h
cabeçalho do sistema com a seguinte declaração dePrintToConsole
.Nada muito útil, certo. Agora, vamos ver o quão mau (ou talvez não tão mau, o comportamento indefinido poderia ser pior) o fornecedor é, verificando a definição real desta função.
Na verdade, o compilador tem que assumir que qualquer função arbitrária que o compilador não sabe o que faz pode sair ou lançar uma exceção (no caso do C ++). Você pode notar que
*((char*)NULL) = 0;
não será executado, pois a execução não continuará após aPrintToConsole
chamada.O comportamento indefinido ataca quando
PrintToConsole
realmente retorna. O compilador espera que isso não aconteça (pois isso faria com que o programa executasse um comportamento indefinido, não importa o que acontecesse), portanto, tudo pode acontecer.No entanto, vamos considerar outra coisa. Digamos que estejamos fazendo uma verificação de nulos e use a variável após a verificação de nulos.
Nesse caso, é fácil perceber que
lol_null_check
requer um ponteiro não NULL. A atribuição àwarning
variável global não volátil não é algo que poderia sair do programa ou lançar qualquer exceção. Opointer
também não é volátil, portanto, não pode mudar magicamente seu valor no meio da função (se mudar, é um comportamento indefinido). A chamadalol_null_check(NULL)
irá causar um comportamento indefinido que pode fazer com que a variável não seja atribuída (porque neste ponto, o fato de que o programa executa o comportamento indefinido é conhecido).No entanto, o comportamento indefinido significa que o programa pode fazer qualquer coisa. Portanto, nada impede o comportamento indefinido de voltar no tempo e travar seu programa antes da
int main()
execução da primeira linha . É um comportamento indefinido, não precisa fazer sentido. Pode muito bem travar após digitar 3, mas o comportamento indefinido voltará no tempo e travará antes mesmo de você digitar 3. E quem sabe, talvez o comportamento indefinido substituirá a RAM do sistema e fará com que o sistema trave 2 semanas depois, enquanto seu programa indefinido não está em execução.fonte
PrintToConsole
é minha tentativa de inserir um efeito colateral externo ao programa que é visível mesmo após travamentos e é fortemente sequenciado. Eu queria criar uma situação em que possamos dizer com certeza se esta declaração foi otimizada. Mas você está certo quanto ao fato de que pode nunca mais voltar Seu exemplo de gravação para um global pode estar sujeito a outras otimizações não relacionadas ao UB. Por exemplo, um global não utilizado pode ser excluído. Você tem uma ideia para criar um efeito colateral externo de uma forma que garanta o retorno do controle?volatile
variável poderia legitimamente acionar uma operação de E / S que poderia, por sua vez, interromper imediatamente o thread atual; o manipulador de interrupção pode então matar o thread antes que ele tenha a chance de realizar qualquer outra coisa. Não vejo nenhuma justificativa pela qual o compilador poderia empurrar o comportamento indefinido antes desse ponto.Se o programa atinge uma instrução que invoca um comportamento indefinido, nenhum requisito é colocado em qualquer saída / comportamento do programa; não importa se eles ocorreriam "antes" ou "depois" de um comportamento indefinido ser invocado.
Seu raciocínio sobre todos os três trechos de código está correto. Em particular, um compilador pode tratar qualquer instrução que invoca incondicionalmente um comportamento indefinido da forma como o GCC trata
__builtin_unreachable()
: como uma dica de otimização de que a instrução é inalcançável (e, portanto, todos os caminhos de código que levam incondicionalmente a ela também são inalcançáveis). É claro que outras otimizações semelhantes são possíveis.fonte
__builtin_unreachable()
começou a ter efeitos que recuaram e avançaram no tempo? Dado algo comoextern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); }
eu poderia considerar que obuiltin_unreachable()
é bom deixar o compilador saber que ele pode omitir areturn
instrução, mas isso seria bem diferente de dizer que o código anterior poderia ser omitido.__builtin_unreachable
foi alcançado. Este programa está definido.restrict
ponteiro ativo, sejam escritos usando umunsigned char*
.Muitos padrões para muitos tipos de coisas gastam muito esforço na descrição de coisas que as implementações DEVEM ou NÃO devem fazer, usando nomenclatura semelhante àquela definida na IETF RFC 2119 (embora não necessariamente citando as definições naquele documento). Em muitos casos, as descrições das coisas que as implementações devem fazer, exceto nos casos em que seriam inúteis ou impraticáveis, são mais importantes do que os requisitos aos quais todas as implementações em conformidade devem estar em conformidade.
Infelizmente, os padrões C e C ++ tendem a evitar descrições de coisas que, embora não sejam 100% exigidas, devem ser esperadas de implementações de qualidade que não documentam comportamento contrário. Uma sugestão de que as implementações devem fazer algo pode ser vista como implicando que aquelas que não são inferiores, e nos casos em que geralmente seria óbvio quais comportamentos seriam úteis ou práticos, versus impraticáveis e inúteis, em uma determinada implementação, havia pouca necessidade percebida de o Padrão interferir em tais julgamentos.
Um compilador inteligente poderia estar em conformidade com o Padrão, eliminando qualquer código que não teria efeito, exceto quando o código recebe entradas que inevitavelmente causariam comportamento indefinido, mas "inteligente" e "burro" não são antônimos. O fato de os autores do Padrão decidirem que pode haver alguns tipos de implementações em que se comportar de maneira útil em uma determinada situação seria inútil e impraticável, não implica qualquer julgamento se tais comportamentos devem ser considerados práticos e úteis para os outros. Se uma implementação pudesse manter uma garantia comportamental sem nenhum custo além da perda de uma oportunidade de poda de "galho morto", quase qualquer valor que o código de usuário poderia receber dessa garantia excederia o custo de fornecê-la. A eliminação de galhos mortos pode ser boa nos casos em que não exigiria desistência, mas se em uma determinada situação o código do usuário pudesse ter manipulado quase qualquer comportamento possível, exceto a eliminação do ramo morto, qualquer esforço que o código do usuário tivesse que despender para evitar o UB provavelmente excederia o valor obtido do DBE.
fonte
x*y < z
quandox*y
não estourou, e em caso de estouro produz 0 ou 1 de forma arbitrária, mas sem efeitos colaterais, não há razão na maioria das plataformas para que atender ao segundo e terceiro requisitos deva ser mais caro do que atender ao primeiro, mas qualquer maneira de escrever a expressão para garantir o comportamento definido pelo padrão em todos os casos, em alguns casos, adicionaria um custo significativo. Escrever a expressão(int64_t)x*y < z
poderia mais do que quadruplicar o custo de computação ...(int)((unsigned)x*y) < z
evitaria que um compilador empregue o que poderia ter sido substituições algébricas úteis (por exemplo, se ele souber dissox
ez
for igual e positivo, pode simplificar a expressão original paray<0
, mas a versão usando não sinal forçaria o compilador a realizar a multiplicação). Se o compilador puder garantir, mesmo que o padrão não o obrigue, ele manterá o requisito "rendimento 0 ou 1 sem efeitos colaterais", o código do usuário pode dar ao compilador oportunidades de otimização que de outra forma não poderia obter.x*y
com que seja emitido um valor normal em caso de estouro, mas qualquer valor. UB configurável em C / C ++ parece importante para mim.