(Por que) está usando uma variável não inicializada comportamento indefinido?

Se eu tiver:

unsigned int x; x -= x; 

é claro que x deve ser zero após essa expressão, mas em todos os lugares que eu olho, eles dizem que o comportamento desse código é indefinido, não apenas o valor de x (até antes da subtração).

Duas questões:

Claramente, o compilador poderia simplesmente usar qualquer valor de lixo que considerasse “útil” dentro da variável, e funcionaria como planejado … o que há de errado com essa abordagem?

Sim, este comportamento é indefinido, mas por razões diferentes das que a maioria das pessoas conhece.

Primeiro, usar um valor unitializado não é, por si só, um comportamento indefinido, mas o valor é simplesmente indeterminado. Acessar isso é UB se o valor for uma representação de trap para o tipo. Tipos não assinados raramente têm representações de armadilhas, então você seria relativamente seguro nesse lado.

O que torna o comportamento indefinido é uma propriedade adicional da sua variável, ou seja, “poderia ter sido declarado com register “, ou seja, seu endereço nunca é usado. Essas variables ​​são tratadas especialmente porque existem arquiteturas que possuem registradores de CPU reais que possuem uma espécie de estado extra que é “não inicializado” e que não corresponde a um valor no domínio de tipo.

Edit: A frase relevante da norma é 6.3.2.1p2:

Se o lvalue designa um object de duração de armazenamento automático que poderia ter sido declarado com a class de armazenamento de registrador (esse endereço nunca foi obtido), esse object é não inicializado (não declarado com um inicializador e nenhuma atribuição foi executada antes do uso ), o comportamento é indefinido.

E para tornar mais claro, o código a seguir é legal em todas as circunstâncias:

 unsigned char a, b; memcpy(&a, &b, 1); a -= a; 
  • Aqui os endereços de b são tomados, então seu valor é apenas indeterminado.
  • Uma vez que o unsigned char nunca possui representações de armadilhas que indeterminam o valor é simplesmente não especificado, qualquer valor de unsigned char pode acontecer.
  • No final a deve manter o valor 0 .

Edit2: a e b têm valores não especificados:

3.19.3 valor não especificado
valor válido do tipo relevante em que esta Norma não impõe requisitos sobre qual valor é escolhido em qualquer instância

O padrão C oferece aos compiladores muita latitude para executar otimizações. As conseqüências dessas otimizações podem ser surpreendentes se você assumir um modelo ingênuo de programas em que a memory não inicializada é definida como um padrão de bits randoms e todas as operações são executadas na ordem em que são gravadas.

Nota: os exemplos a seguir são válidos apenas porque x nunca tem seu endereço recebido, por isso é “semelhante a um registrador”. Eles também seriam válidos se o tipo de x tivesse representações de armadilha; esse raramente é o caso de tipos não assinados (requer “desperdício” de pelo menos um bit de armazenamento e deve ser documentado) e impossível para unsigned char . Se x tinha um tipo assinado, a implementação poderia definir o padrão de bits que não é um número entre – (2 n-1 -1) e 2 n-1 -1 como uma representação de trap. Veja a resposta de Jens Gustedt .

Compiladores tentam atribuir registros a variables, porque os registros são mais rápidos que a memory. Como o programa pode usar mais variables ​​do que o processador possui registradores, os compiladores realizam a alocação de registros, o que leva a diferentes variables ​​usando o mesmo registrador em momentos diferentes. Considere o fragment do programa

 unsigned x, y, z; /* 0 */ y = 0; /* 1 */ z = 4; /* 2 */ x = - x; /* 3 */ y = y + z; /* 4 */ x = y + 1; /* 5 */ 

Quando a linha 3 é avaliada, x ainda não é inicializado, portanto (a razão do compilador) a linha 3 deve ser algum tipo de fluke que não pode acontecer devido a outras condições que o compilador não era inteligente o suficiente para descobrir. Como z não é usado após a linha 4, e x não é usado antes da linha 5, o mesmo registrador pode ser usado para ambas as variables. Portanto, este pequeno programa é compilado para as seguintes operações nos registradores:

 r1 = 0; r0 = 4; r0 = - r0; r1 += r0; r0 = r1; 

O valor final de x é o valor final de r0 e o valor final de y é o valor final de r1 . Esses valores são x = -3 e y = -4, e não 5 e 4, como aconteceria se x tivesse sido inicializado corretamente.

Para um exemplo mais elaborado, considere o seguinte fragment de código:

 unsigned i, x; for (i = 0; i < 10; i++) { x = (condition() ? some_value() : -x); } 

Suponha que o compilador detecte que a condition não tem efeito colateral. Como a condition não modifica x , o compilador sabe que a primeira execução através do loop não pode estar acessando x pois ainda não está inicializado. Portanto, a primeira execução do corpo do loop é equivalente a x = some_value() , não há necessidade de testar a condição. O compilador pode compilar este código como se você tivesse escrito

 unsigned i, x; i = 0; /* if some_value() uses i */ x = some_value(); for (i = 1; i < 10; i++) { x = (condition() ? some_value() : -x); } 

A maneira como isso pode ser modelado dentro do compilador é considerar que qualquer valor dependendo de x tem qualquer valor que seja conveniente , desde que x seja não inicializado. Como o comportamento quando uma variável não inicializada é indefinida, em vez de a variável ter apenas um valor não especificado, o compilador não precisa controlar qualquer relação matemática especial entre valores que sejam convenientes. Assim, o compilador pode analisar o código acima dessa maneira:

  • durante a primeira iteração do loop, x é não inicializado pelo tempo -x é avaliado.
  • -x tem comportamento indefinido, então seu valor é o que for conveniente.
  • A condition ? value : value regra de otimização condition ? value : value condition ? value : value se aplica, então este código pode ser simplificado para condition ; value condition ; value .

Quando confrontado com o código em sua pergunta, este mesmo compilador analisa que quando x = - x é avaliado, o valor de -x é o que for conveniente. Assim, a tarefa pode ser otimizada.

Eu não procurei por um exemplo de um compilador que se comporta como descrito acima, mas é o tipo de otimizações que bons compiladores tentam fazer. Eu não ficaria surpreso em encontrar um. Aqui está um exemplo menos plausível de um compilador com o qual seu programa trava. (Pode não ser tão implausível se você compilar seu programa em algum tipo de modo de debugging avançado).

Esse compilador hipotético mapeia todas as variables ​​em uma página de memory diferente e configura atributos de página para que a leitura de uma variável não inicializada cause um trap de processador que invoque um depurador. Qualquer atribuição a uma variável primeiro garante que sua página de memory seja mapeada normalmente. Este compilador não tenta executar nenhuma otimização avançada - está em um modo de debugging, destinado a localizar facilmente erros como variables ​​não inicializadas. Quando x = - x é avaliado, o lado direito causa um trap e o depurador triggers.

Sim, o programa pode falhar. Pode haver, por exemplo, representações de traps (padrões de bit específicos que não podem ser manipulados) que podem causar uma interrupção da CPU, que não tratada pode travar o programa.

(6.2.6.1 em um rascunho C11 final diz) Certas representações de object não precisam representar um valor do tipo de object. Se o valor armazenado de um object tiver tal representação e for lido por uma expressão lvalue que não tenha um tipo de caractere, o comportamento é indefinido. Se tal representação é produzida por um efeito colateral que modifica toda ou qualquer parte do object por uma expressão lvalue que não possui um tipo de caractere, o comportamento é indefinido.50) Essa representação é chamada de representação de interceptação.

(Esta explicação aplica-se apenas a plataformas onde o unsigned int pode ter representações de armadilhas, o que é raro em sistemas do mundo real; veja comentários para detalhes e referências a causas alternativas e talvez mais comuns que levam ao texto atual do padrão.)

(Esta resposta endereça C 1999. Para C 2011, veja a resposta de Jens Gustedt.)

O padrão C não diz que usar o valor de um object de duração de armazenamento automático que não é inicializado é um comportamento indefinido. O padrão C 1999 diz, em 6.7.8 10: “Se um object que tem duração de armazenamento automático não é inicializado explicitamente, seu valor é indeterminado.” (Este parágrafo continua definindo como objects estáticos são inicializados, então os únicos objects não inicializados estamos preocupados com objects automáticos.)

3.17.2 define “valor indeterminado” como “um valor não especificado ou uma representação de armadilha”. 3.17.3 define “valor não especificado” como “valor válido do tipo relevante em que esta Norma não impõe requisitos sobre qual valor é escolhido em qualquer instância”.

Então, se o uninitialized unsigned int x tiver um valor não especificado, então x -= x deverá produzir zero. Isso deixa a questão de saber se pode ser uma representação de armadilha. Acessar um valor de trap causa um comportamento indefinido, conforme 6.2.6.1.

Alguns tipos de objects podem ter representações de armadilhas, como os NaNs de sinalização de números de ponto flutuante. Mas inteiros sem sinal são especiais. Por 6.2.6.2, cada um dos N bits de valor de um unsigned int representa uma potência de 2, e cada combinação dos bits de valor representa um dos valores de 0 a 2 N -1. Portanto, inteiros não assinados podem ter representações de interceptações apenas devido a alguns valores em seus bits de preenchimento (como um bit de paridade).

Se, em sua plataforma de destino, um int não assinado não tiver bits de preenchimento, um int não assinado não inicializado não poderá ter uma representação de interceptação e usar seu valor não poderá causar um comportamento indefinido.

Sim, é indefinido. O código pode falhar. C diz que o comportamento é indefinido porque não há razão específica para fazer uma exceção à regra geral. A vantagem é a mesma vantagem que todos os outros casos de comportamento indefinido – o compilador não precisa gerar código especial para fazer isso funcionar.

Claramente, o compilador poderia simplesmente usar qualquer valor de lixo que considerasse “útil” dentro da variável, e funcionaria como planejado … o que há de errado com essa abordagem?

Por que você acha que isso não acontece? Essa é exatamente a abordagem adotada. O compilador não é necessário para que funcione, mas não é necessário para fazê-lo falhar.

Para qualquer variável de qualquer tipo, que não seja inicializada ou por outras razões, possui um valor indeterminado, aplica-se o seguinte para a leitura de código desse valor:

  • Caso a variável tenha duração de armazenamento automático e não tenha seu endereço recebido, o código sempre invoca o comportamento indefinido [1].
  • Caso contrário, no caso de o sistema suportar representações de traps para o tipo de variável fornecido, o código sempre invoca o comportamento indefinido [2].
  • Caso contrário, se não houver representações de interceptação, a variável recebe um valor não especificado. Não há garantia de que esse valor não especificado seja consistente sempre que a variável for lida. No entanto, é garantido que não é uma representação de armadilha e, portanto, é garantido que não invoca um comportamento indefinido [3].

    O valor pode ser usado com segurança sem causar uma falha de programa, embora esse código não seja portável para sistemas com representações de interceptação.


[1]: C11 6.3.2.1:

Se o lvalue designa um object de duração de armazenamento automático que poderia ter sido declarado com a class de armazenamento de registrador (esse endereço nunca foi obtido), esse object é não inicializado (não declarado com um inicializador e nenhuma atribuição foi executada antes do uso ), o comportamento é indefinido.

[2]: C11 6.2.6.1:

Certas representações de object não precisam representar um valor do tipo de object. Se o valor armazenado de um object tiver tal representação e for lido por uma expressão lvalue que não tenha um tipo de caractere, o comportamento é indefinido. Se tal representação é produzida por um efeito colateral que modifica toda ou qualquer parte do object por uma expressão lvalue que não possui um tipo de caractere, o comportamento é indefinido.50) Essa representação é chamada de representação de interceptação.

[3] C11:

3.19.2
valor indeterminado
quer um valor não especificado ou uma representação de armadilha

3.19.3
valor não especificado
valor válido do tipo relevante em que esta Norma não impõe requisitos sobre qual valor é escolhido em qualquer instância
NOTA Um valor não especificado não pode ser uma representação de trap.

3.19.4
representação de armadilha
uma representação de object que não precisa representar um valor do tipo de object

Enquanto muitas respostas se concentram em processadores que capturam em access de registro não-inicializado, comportamentos peculiares podem surgir até mesmo em plataformas que não possuem tais armadilhas, usando compiladores que não fazem nenhum esforço particular para explorar o UB. Considere o código:

 volatile uint32_t a,b; uin16_t moo(uint32_t x, uint16_t y, uint32_t z) { uint16_t temp; if (a) temp = y; else if (b) temp = z; return temp; } 

um compilador para uma plataforma como o ARM, onde todas as instruções que não sejam cargas e armazenamentos operam em registradores de 32 bits, pode razoavelmente processar o código de maneira equivalente a:

 volatile uint32_t a,b; // Note: y is known to be 0..65535 // x, y, and z are received in 32-bit registers r0, r1, r2 uin32_t moo(uint32_t x, uint32_t y, uint32_t z) { // Since x is never used past this point, and since the return value // will need to be in r0, a compiler could map temp to r0 uint32_t temp; if (a) temp = y; else if (b) temp = z & 0xFFFF; return temp; } 

Se as leituras voláteis produzirem um valor diferente de zero, r0 será carregado com um valor no intervalo 0 … 65535. Caso contrário, ele produzirá o que quer que tenha sido realizado quando a function foi chamada (ou seja, o valor passado para x), que pode não ser um valor no intervalo 0.65535. O Padrão não possui nenhuma terminologia para descrever o comportamento do valor cujo tipo é uint16_t, mas cujo valor está fora do intervalo de 0..65535, exceto para dizer que qualquer ação que poderia produzir tal comportamento invoca o UB.