Undefined behavior: os comportamentos que o C não define

1. O que é Undefined Behavior (UB) e por que ele existe?

No padrão C, undefined behavior é definido como "comportamento para o qual o padrão não impõe nenhum requisito". Isso significa que, ao encontrar uma operação classificada como UB, o compilador pode fazer literalmente qualquer coisa: gerar código que funciona por acaso, travar silenciosamente, ignorar a linha inteira, ou até mesmo fazer seu programa parecer funcionar corretamente em testes e falhar em produção.

Para entender o UB, é crucial distinguir quatro categorias de comportamento:

  • Comportamento definido: o padrão especifica exatamente o que acontece.
  • Comportamento não especificado: o padrão oferece opções, mas não exige documentação.
  • Comportamento implementation-defined: o compilador deve escolher e documentar a escolha (ex: sizeof(int)).
  • Undefined behavior: qualquer resultado é possível, incluindo nada.

A existência do UB não é um acidente. Ela permite que compiladores gerem código mais rápido, evitando verificações desnecessárias. Um compilador C assume que você nunca escreve UB, e otimiza agressivamente com base nessa premissa.

2. Acessos inválidos à memória

O exemplo clássico é desreferenciar um ponteiro nulo:

int *p = NULL;
*p = 42;  // UB: desreferencia ponteiro nulo

Ponteiros selvagens (não inicializados) são igualmente perigosos:

int *p;  // não inicializado
*p = 10; // UB: p pode apontar para qualquer lugar

Buffer overflow ocorre quando escrevemos além dos limites de um array:

int arr[5];
for (int i = 0; i <= 5; i++) {
    arr[i] = i;  // UB na última iteração (i=5)
}

Use-after-free é um dos UB mais traiçoeiros:

int *p = malloc(sizeof(int));
free(p);
*p = 42;  // UB: memória já foi liberada

Double-free também é UB:

int *p = malloc(sizeof(int));
free(p);
free(p);  // UB: segundo free em ponteiro já liberado

3. Violações de aliasing e regras de tipo

A strict aliasing rule diz que você não pode acessar um objeto através de um ponteiro de tipo incompatível. Exemplo clássico:

float f = 3.14f;
int *p = (int*)&f;
printf("%d\n", *p);  // UB: acessa float como int

A exceção principal é char*, que pode acessar qualquer tipo:

float f = 3.14f;
unsigned char *p = (unsigned char*)&f;
for (int i = 0; i < sizeof(f); i++) {
    printf("%02x ", p[i]);  // OK: char pode acessar qualquer tipo
}

Unions podem ser usadas para type-punning, mas com cuidado:

union {
    float f;
    int i;
} u;
u.f = 3.14f;
printf("%d\n", u.i);  // Comportamento implementation-defined, não UB

4. Operações aritméticas e shifts problemáticos

Overflow de inteiros com sinal é UB:

int a = INT_MAX;
int b = a + 1;  // UB: overflow com sinal

Shifts com valores negativos ou maiores que o número de bits:

int x = 1;
int y = x << 33;  // UB se int tem 32 bits
int z = x << -1;  // UB: shift negativo

Divisão por zero:

int a = 10;
int b = 0;
int c = a / b;  // UB
int d = a % b;  // UB também

5. Sequenciamento e efeitos colaterais

Modificar uma variável mais de uma vez entre sequence points:

int i = 0;
i = i++;  // UB: i é lido e modificado sem sequence point entre

Outro exemplo famoso:

int arr[] = {1, 2, 3};
int i = 0;
arr[i] = i++;  // UB: ordem de avaliação de i e i++ é indefinida

A ordem de avaliação de argumentos de função é apenas não especificada, não UB:

int x = 1;
printf("%d %d\n", x, x++);  // Comportamento não especificado, mas não UB

6. Ponteiros e aritmética de ponteiros

Aritmética de ponteiro só é válida dentro do array (mais um elemento após o fim):

int arr[5];
int *p = arr + 5;  // OK: aponta para "um após o último"
int *q = arr + 6;  // UB: além do permitido

Subtrair ponteiros de arrays diferentes:

int a[5], b[5];
int *p = a;
int *q = b;
ptrdiff_t d = q - p;  // UB: ponteiros de arrays diferentes

7. Comportamentos de biblioteca padrão

Usar especificadores de formato incompatíveis com printf:

int x = 42;
printf("%f\n", x);  // UB: espera double, recebe int

Chamar funções variádicas com tipos errados:

float f = 3.14f;
printf("%f\n", f);  // UB: float é promovido a double em variádicas, mas se passar double* seria UB

memcpy com regiões sobrepostas:

char str[] = "Hello";
memcpy(str + 1, str, 5);  // UB: regiões sobrepostas, deveria usar memmove

8. Como detectar e evitar UB no dia a dia

Compiladores modernos oferecem ferramentas excelentes. Compile sempre com:

gcc -Wall -Wextra -Wpedantic -fsanitize=undefined -fsanitize=address programa.c
  • -fsanitize=undefined: detecta UB em tempo de execução (overflow, shifts inválidos, etc.)
  • -fsanitize=address: detecta acessos inválidos à memória (buffer overflow, use-after-free)
  • -Wall -Wextra -Wpedantic: ativa avisos do compilador sobre construções suspeitas

Boas práticas para evitar UB:

  1. Inicialize sempre ponteiros e variáveis
  2. Use size_t para índices de arrays (tipo sem sinal, sem UB de overflow)
  3. Evite casts desnecessários, especialmente entre tipos incompatíveis
  4. Prefira memmove a memcpy quando houver possibilidade de sobreposição
  5. Use ferramentas de análise estática como cppcheck ou clang-tidy
  6. Mantenha o código simples: expressões complexas com múltiplos efeitos colaterais são mais propensas a UB

Lembre-se: UB não é "comportamento imprevisível" — é "comportamento que o padrão não define". O compilador pode assumir que UB nunca ocorre e otimizar seu programa de maneiras que podem quebrá-lo silenciosamente. Um programa com UB pode funcionar por anos e falhar após uma atualização do compilador.

Referências