"Comportamento indefinido" soa como jargão para "imprevisível". É mais forte que isso. Quando a norma do C diz que uma construção tem comportamento indefinido, ela quer dizer que a norma não impõe requisito algum sobre o que o programa faz - nem sobre o valor, nem sobre a instrução, nem sobre o programa.
É essa a parte que surpreende: o comportamento indefinido não fica confinado à linha ofensiva. Um compilador tem permissão de supor que ele nunca acontece e de reescrever o código ao redor com base nessa suposição. O resultado pode ser um programa em que uma verificação que você claramente escreveu não existe no binário.
O modelo de contrato
Pense na norma como um contrato entre você e o compilador. Você promete não fazer certas coisas; em troca, o compilador promete que o seu programa significa o que diz.
Não indexe fora de um array. Não estoure um inteiro com sinal. Não leia um valor não inicializado. Não use um ponteiro depois de liberá-lo. Não modifique o mesmo objeto duas vezes numa expressão sem um ponto de sequência entre as modificações.
Quebre uma cláusula e o acordo cai - para o programa inteiro, não só para aquela linha. Não existe "recurso razoável" nem obrigação de travar.
Vale separar três termos relacionados:
- Comportamento indefinido - qualquer coisa pode acontecer. Acesso fora dos limites, estouro com sinal, uso após liberação.
- Comportamento não especificado - um entre vários resultados válidos, e o compilador não precisa dizer qual. A ordem em que os argumentos de uma função são avaliados, por exemplo.
- Comportamento definido pela implementação - a implementação escolhe, e precisa documentar a escolha. Se
chartem sinal, qual o tamanho de umint.
Só o primeiro é perigoso no sentido de "o otimizador apagou meu código".
As grandes fontes
Estouro de inteiro com sinal
A aritmética sem sinal gira, e a norma diz isso. A aritmética com sinal não - passar da faixa é indefinido.
A verificação if (a > INT_MAX - b) roda inteiramente dentro da faixa válida, e é isso que faz dela um teste de estouro correto. Escrever if (a + b < 0) realiza o estouro primeiro e depois pergunta sobre o resultado - e o compilador, com direito de supor que o estouro nunca aconteceu, pode remover a verificação.
Acesso fora dos limites
Ler ou escrever fora de um array é indefinido, travando ou não:
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5]; /* UB: o indice 5 nao existe */
arr[-1] = 0; /* UB */
int *p = arr + 10; /* UB ate mesmo calcular esse ponteiro */
Repare nessa última linha: formar um ponteiro mais de uma posição além do fim é indefinido mesmo que você nunca o desreferencie. A norma permite arr + 5 (uma posição além do fim, para terminar laços), mas não arr + 6.
Os estouros pequenos são os perigosos. Eles normalmente não dão segfault; sobrescrevem em silêncio uma variável vizinha, e a resposta errada aparece em algum ponto sem relação.
Leituras não inicializadas
int x;
printf("%d\n", x); /* UB: lendo um valor indeterminado */
int *p;
*p = 42; /* UB: desreferenciando um ponteiro indeterminado */
É tentador pensar "ele só guarda lixo", mas não é isso que a norma diz, e os compiladores exploram a diferença. Sabe-se que o GCC já concluiu que uma variável lida antes da atribuição pode guardar qualquer valor que lhe convier - inclusive o que faz um ramo sumir.
Ponteiros pendurados
int *p = malloc(sizeof *p);
free(p);
*p = 42; /* UB: uso apos liberacao */
free(p); /* UB: liberacao dupla */
int *q;
{
int local = 10;
q = &local;
}
printf("%d\n", *q); /* UB: o tempo de vida do objeto acabou */
As consequências em tempo de execução estão em falha de segmentação; o ponto aqui é que o travamento é o desfecho de sorte.
O especificador errado no printf
printf("%d\n", 3.14); /* UB: %d com um double */
printf("%s\n", 42); /* UB: %s com um int - costuma travar */
printf("%d %d\n", 1); /* UB: menos argumentos que especificadores */
long n = 5;
printf("%d\n", n); /* UB em sistemas onde long e mais largo que int */
O printf é variádico: ele lê argumentos conforme a string de formato e não consegue verificá-los. Uma incompatibilidade faz com que ele leia o número errado de bytes do lugar errado. Compile com -Wall e o compilador confere a string de formato para você - esse é um dos avisos de maior valor do conjunto.
Modificar um objeto duas vezes numa expressão
int i = 0;
i = i++ + ++i; /* UB */
arr[i] = i++; /* UB */
printf("%d %d\n", i++, i); /* UB */
Isso é indefinido, não apenas "dependente do compilador". Quebra-cabeças de livro perguntando quanto vale i = i++ + ++i não têm resposta correta.
Aliasing estrito
Acessar um objeto por um ponteiro de tipo incompatível é indefinido, e esse surpreende programadores experientes:
float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p); /* UB: lendo um float por um int * */
O compilador supõe que um int * e um float * nunca apontam para a mesma memória, e reordena conforme isso. A forma definida de reinterpretar bytes é o memcpy (que otimiza para as mesmas instruções) ou uma union:
Um char * é a exceção - você sempre pode inspecionar os bytes de qualquer objeto por um unsigned char *.
Por que "funciona na minha máquina" não prova nada
O comportamento indefinido muitas vezes parece funcionar, e é isso que o torna perigoso. O programa roda corretamente durante o desenvolvimento e os testes, e depois quebra quando algo totalmente sem relação muda:
- Uma nova versão do compilador com um otimizador mais esperto.
- Trocar
-O0por-O2na build de release. - Acrescentar uma função sem relação, deslocando o leiaute da pilha de modo que um estouro agora caia em algo que importa.
- Uma máquina diferente, uma libc diferente, um sistema operacional diferente.
A aparência de funcionar não é evidência de correção, porque a norma nunca prometeu nada. É um bug em estado dormente, e o gatilho normalmente é a build de release.
Como o otimizador explora isso
Eis o exemplo que costuma encerrar a discussão. Um programador escreve uma verificação de nulo:
void process(int *p) {
int value = *p; /* desreferencia */
if (p == NULL) { /* e so depois verifica o nulo */
return;
}
printf("%d\n", value * 2);
}
A ordem está errada - a verificação vem depois da desreferência - mas certamente a verificação ainda roda, não?
Não precisa rodar. O compilador raciocina: *p foi desreferenciado, logo p não pode ser NULL (desreferenciar NULL é indefinido, então em qualquer programa de comportamento definido ele não é NULL), logo p == NULL é sempre falso, logo o corpo inteiro do if é código morto e pode ser apagado.
A função compilada não tem verificação de nulo nenhuma. Uma instância real desse padrão no kernel do Linux virou a CVE-2009-1897, em que o GCC removeu exatamente uma verificação dessas e transformou um erro de ordenação de aparência benigna numa vulnerabilidade explorável.
Um segundo exemplo, menor:
/* Uma verificacao de estouro que nao funciona */
int safe_add(int a, int b) {
int sum = a + b;
if (sum < a) { /* "deu a volta?" */
return -1;
}
return sum;
}
Para tipos com sinal o compilador pode supor que a + b não estourou, caso em que sum < a só é possível quando b < 0. Com essa suposição a verificação testa algo diferente do que o autor quis dizer, e com b >= 0 ela pode ser otimizada para fora por completo. A versão que funciona verifica antes:
Os dois testes ficam dentro da faixa representável, então nenhum estouro ocorre e não há nada que o otimizador possa supor para fora. (GCC e clang também fornecem __builtin_add_overflow, que faz isso numa instrução.)
Detectando
Avisos estáticos primeiro - eles são de graça:
gcc -Wall -Wextra -Wpedantic programa.c -o programa
Isso pega incompatibilidades de string de formato, algumas leituras não inicializadas, comparações suspeitas e código inalcançável.
Depois os sanitizadores, que instrumentam o programa e reportam no momento da violação:
gcc -g -fsanitize=undefined programa.c -o programa
./programa
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'
Combine com o AddressSanitizer para a metade de memória:
gcc -g -Wall -Wextra -fsanitize=address,undefined programa.c -o programa
Juntos eles pegam acessos fora dos limites, uso após liberação, liberações duplas, estouro com sinal, deslocamentos inválidos, ponteiros desalinhados e desreferências de nulo - cada um com arquivo, linha e rastro de pilha. Aproximadamente 2x mais lento, o que é nada durante o desenvolvimento.
valgrind ./programa não exige recompilação e pega leituras não inicializadas e erros de memória, embora não o UB aritmético. clang --analyze e gcc -fanalyzer encontram parte disso sem sequer rodar o programa.
A regra prática: rode seus testes sob os sanitizadores na CI. UB que parece funcionar localmente é exatamente o que eles existem para expor.
Convivendo com isso
Você não consegue evitar comportamento indefinido só sendo cuidadoso - todo mundo escreve um cedo ou tarde. O que funciona é torná-lo barulhento:
- Compile com
-Wall -Wextradesde o primeiro dia, e corrija todos os avisos. - Rode os testes sob
-fsanitize=address,undefined. - Inicialize toda variável na declaração, e todo ponteiro com
NULL. - Confira os índices de array contra o comprimento, e faça laços com
i < n. - Verifique estouro antes da aritmética, usando
<limits.h>. - Coloque
NULLnum ponteiro depois de liberá-lo. - Use tipos
unsignedonde o giro é o comportamento pretendido - ali ele é definido. - Prefira
memcpya casts de ponteiro ao reinterpretar bytes.
A velocidade do C vem de o compilador ter permissão para supor que você cumpriu o contrato. Isso é uma troca genuína, não um defeito de projeto - e as ferramentas acima devolvem boa parte da segurança por um custo de desenvolvimento igual a zero.
Para a face em tempo de execução dessas regras, veja falha de segmentação; para os erros de compilação que vêm antes, erros comuns.
Perguntas frequentes
O que é comportamento indefinido em C?
É código para o qual a norma do C não impõe requisito algum. O compilador fica livre para produzir qualquer coisa: um travamento, uma resposta errada, código que parece funcionar, ou código com o trecho ofensivo removido por completo. Não é "definido pela implementação" nem "aleatório" - é um contrato que você quebrou, e nada é prometido depois disso.
Por que o C tem comportamento indefinido em vez de simplesmente definir tudo?
Velocidade e portabilidade. Exigir uma verificação de limites em todo acesso a array custaria um desempenho que o C foi projetado para não pagar; definir o estouro com sinal como giro forçaria instruções extras em hardware que em vez disso dispara uma exceção. Deixar esses casos indefinidos permite ao compilador supor que eles nunca acontecem e otimizar de acordo.
O estouro de inteiro com sinal é comportamento indefinido em C?
Sim. INT_MAX + 1 é indefinido - não há garantia de que ele gire para INT_MIN. O estouro sem sinal é diferente: é totalmente definido e gira módulo 2^N. É por isso que compiladores podem supor que x + 1 > x é sempre verdadeiro para um x com sinal, e apagar uma verificação de estouro escrita assim.
Como detecto comportamento indefinido no meu programa em C?
Compile com -Wall -Wextra para pegar o que o compilador consegue ver estaticamente, e depois rode seus testes com -fsanitize=address,undefined, que reporta acessos fora dos limites, uso após liberação, estouro com sinal e mais, no momento em que acontecem, nomeando arquivo e linha. O valgrind pega um conjunto parecido sem recompilar.