Menu

Comportamento indefinido em C: o que significa e por que ele morde

Comportamento indefinido é código sobre o qual a norma do C não impõe requisito nenhum - então qualquer coisa pode acontecer, inclusive o otimizador apagar suas verificações. Aqui está o que o causa, por que "funciona na minha máquina" não prova nada, e as flags que o pegam.

Esta página tem editores executáveis - edite, execute e veja a saída na hora.

"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 char tem sinal, qual o tamanho de um int.

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 -O0 por -O2 na 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 -Wextra desde 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 NULL num ponteiro depois de liberá-lo.
  • Use tipos unsigned onde o giro é o comportamento pretendido - ali ele é definido.
  • Prefira memcpy a 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.

Coddy programming languages illustration

Aprenda a programar com o Coddy

COMEÇAR