C Comportamento Indefinido

Em muitas linguagens, um erro de programação sempre dá uma mensagem: "índice fora do limite", "variável sem valor". Em C, vários erros não dão mensagem nenhuma. O programa compila, roda e mostra um número qualquer. Ou funciona hoje e quebra amanhã. Ou funciona no seu computador e quebra no do colega. Ou muda de resposta só porque você pediu ao compilador para otimizar. Isso tem um nome oficial: comportamento indefinido (em inglês, undefined behavior, ou só UB). Esta aula mostra o que é, por que o C tem isso, os casos que mais aparecem e, principalmente, como se proteger.

Você vai aprender
  • O que é comportamento indefinido e por que o compilador "pode fazer qualquer coisa".
  • Seis casos clássicos: variável sem valor, índice fora do array, estouro de int, i = i++, ponteiro solto e deslocamento grande demais.
  • Por que o mesmo programa dá resultados diferentes com -O0 e com -O2 (e no editor do guia).
  • Como se proteger: -Wall -Wextra, -O2 para ver mais avisos e os sanitizers -fsanitize=undefined,address.
  • A diferença entre indefinido, definido pela implementação e não especificado.

Todos os programas desta aula foram compilados com o gcc 15.2 na máquina fictícia da Ana (usuária ana, computador notebook), na pasta ~/c/ub, com o comando padrão do guia (gcc -std=c23 -Wall -Wextra arquivo.c -o programa, explicado em Compilar com gcc) e, às vezes, com -O2 no fim. O -O2 liga as otimizações: o compilador reescreve o seu programa por dentro para ele ficar mais rápido. Sem nada, vale -O0 ("nível zero", sem otimizar). Os resultados que você vai ver são os daquele dia, naquela máquina. Esse é justamente o ponto: com UB, outro dia, outro compilador ou outra opção podem dar outra coisa.

No editor do guia, muito UB passa calado

O editor compila com o Clang e roda o programa como WebAssembly, dentro do navegador, onde a memória é organizada de outro jeito. Lá, um ponteiro nulo derruba o programa (Falha de segmentação, código 139, como no Linux), mas índice fora do array, ponteiro para variável que já morreu e memória já liberada passam sem nenhum sinal. Os sanitizers também não existem no editor. Por isso as falhas desta aula aparecem nos terminais do laboratório; nas caixas "Experimente", o objetivo é ver as respostas mudarem e corrigir o código.


A ideia: um contrato com o compilador

A linguagem C é definida por um documento, o padrão (veja Versões do C). Ele funciona como um contrato. Para cada coisa que você escreve, o padrão diz o que deve acontecer: 2 + 3 dá 5; printf("oi") escreve oi.

Mas, para algumas situações, o padrão diz: "isto o programa não pode fazer; se fizer, não prometo nada". Ler uma variável que nunca recebeu valor, acessar a posição 4 de um array de 4, passar do maior int. É como o contrato de aluguel que diz "é proibido fazer churrasco na varanda": ele não explica o que acontece se você fizer, porque parte do princípio de que você não vai fazer.

E o compilador leva o contrato ao pé da letra. Para gerar um programa mais rápido, ele supõe que o UB nunca acontece. Se acontece, as suposições dele viram mentiras, e o resultado pode ser qualquer coisa: um número esquisito, um travamento, um laço infinito ou, o pior de todos, a resposta certa (por sorte).

Tipo de problemaQuando apareceExemplo
Erro de compilaçãoAo compilar, sempreFaltou um ; (veja Lendo Erros do Compilador)
Erro que o programa trataAo rodar, sempre do mesmo jeitofopen devolve NULL e o programa avisa (veja errno e perror)
Comportamento indefinidoTalvez nunca, talvez às vezes, talvez de jeitos diferentesnotas[4] num array de 4: pode mostrar lixo, pode travar, pode "funcionar"
"Funcionou" não prova nada

Com UB, um programa que deu a resposta certa no seu teste não está certo: ele só teve sorte desta vez. Trocar a otimização, o compilador, a versão do compilador ou até uma linha sem relação pode mudar o resultado. Por isso o objetivo não é "decorar o que o UB faz", e sim nunca escrever UB.


1. Variável sem valor inicial

Uma variável local criada sem valor (int pontos;) guarda o que já estava naquele pedaço da memória: o "lixo" (veja Variáveis). Ler esse valor antes de dar um valor de verdade é UB.

#include <stdio.h>

int main(void) {
    int pontos;
    printf("Pontos: %d\n", pontos);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra lixo.c -o lixo
lixo.c: In function ‘main’:
lixo.c:5:5: warning: ‘pontos’ is used uninitialized [-Wuninitialized]
    5 |     printf("Pontos: %d\n", pontos);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
lixo.c:4:9: note: ‘pontos’ was declared here
    4 |     int pontos;
      |         ^~~~~~
$ ./lixo
Pontos: 0                « zero por acaso: era o que estava na memória

O -Wall avisou: used uninitialized = "usada sem ter sido inicializada". E o programa mostrou 0. Parece inofensivo, mas aquele zero não é garantido: era só o que havia na memória naquele momento. Num programa maior, no mesmo lugar, aparece 32767 ou -1094795586.

O caso traiçoeiro é quando a variável recebe valor só às vezes:

#include <stdio.h>

int main(void) {
    int nota = 7;
    int bonus;
    if (nota > 8) {
        bonus = 2;
    }
    printf("Nota final: %d\n", nota + bonus);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra talvez.c -o talvez      « nenhum aviso!
$ ./talvez
Nota final: 7
$ gcc -std=c23 -Wall -Wextra -O2 talvez.c -o talvez-O2
talvez.c: In function ‘main’:
talvez.c:9:5: warning: ‘bonus’ is used uninitialized [-Wuninitialized]
    9 |     printf("Nota final: %d\n", nota + bonus);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
talvez.c:5:9: note: ‘bonus’ was declared here
    5 |     int bonus;
      |         ^~~~~
$ ./talvez-O2
Nota final: 7

Sem otimização, o gcc não avisou nada. Com -O2, ele analisa o programa com mais cuidado (para otimizar, precisa saber de onde vem cada valor) e aí percebe o problema. Essa é a primeira lição prática: compile de vez em quando com -O2 só para ver os avisos extras. O Clang do editor é mais esperto aqui: já sem otimização ele avisa variable 'bonus' is used uninitialized whenever 'if' condition is false ("usada sem valor sempre que a condição do if for falsa").

Como evitar: toda variável nasce com valor. int bonus = 0; custa nada e elimina o problema.


2. Índice fora do array

Um array de 4 posições tem os índices 0, 1, 2 e 3 (veja Arrays). O C não confere o índice: notas[4] lê o que estiver logo depois do array na memória. Ler ou escrever ali é UB. O erro mais comum é o <= no lugar do <:

#include <stdio.h>

int main(void) {
    int notas[4] = {7, 8, 6, 9};
    int soma = 0;
    for (int i = 0; i <= 4; i++) {
        soma += notas[i];
    }
    printf("Soma: %d\n", soma);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra fora.c -o fora
$ ./fora
Soma: 30                « 7 + 8 + 6 + 9 = 30: "certo", por sorte

O laço passou por i = 4 e somou o que havia depois do array. Desta vez, por acaso, era um zero, e a soma saiu certa. Nenhum aviso, nenhuma mensagem. É o pior tipo de erro: o que esconde que existe. Se o vizinho na memória fosse outra variável, a soma viria errada; se o índice fosse 1000, o programa provavelmente morreria.

Agora o mesmo arquivo com -O2:

$ gcc -std=c23 -Wall -Wextra -O2 fora.c -o fora-O2
fora.c: In function ‘main’:
fora.c:7:22: warning: iteration 4 invokes undefined behavior [-Waggressive-loop-optimizations]
    7 |         soma += notas[i];
      |                 ~~~~~^~~
fora.c:6:23: note: within this loop
    6 |     for (int i = 0; i <= 4; i++) {
      |                     ~~^~~~

Iteration 4 invokes undefined behavior = "a volta número 4 provoca comportamento indefinido"; within this loop = "dentro deste laço". O otimizador do gcc percebeu que o laço lê notas[4]. Ele não precisa avisar (o padrão não obriga), mas desta vez avisou. Não conte com isso em todos os casos.

Como evitar: use sempre i < tamanho, e calcule o tamanho com sizeof notas / sizeof notas[0] em vez de escrever o número à mão (veja sizeof). Para achar o erro quando ele já existe, o sanitizer da seção Como se proteger aponta a linha exata.


3. Estouro de inteiro com sinal

Um int vai até 2.147.483.647 (INT_MAX, de limits.h; veja Inteiros). Passar disso num tipo com sinal (int, long, long long) é UB. Na prática o número costuma "dar a volta" e virar negativo, mas o compilador tem o direito de supor que isso nunca acontece, e ele usa esse direito:

#include <limits.h>
#include <stdio.h>

int cabe_mais_um(int x) {
    return x + 1 > x;
}

int main(void) {
    int x = INT_MAX;
    printf("INT_MAX     = %d\n", x);
    printf("INT_MAX + 1 = %d\n", x + 1);
    printf("cabe mais um? %d\n", cabe_mais_um(x));
    return 0;
}
cabe_mais_um
Pergunta: "x + 1 é maior que x?". Em C, uma comparação vale 1 (verdade) ou 0 (falso).
x + 1
Com x = INT_MAX, essa conta passa do limite: é aqui que mora o UB.
$ gcc -std=c23 -Wall -Wextra estouro.c -o estouro
$ ./estouro
INT_MAX     = 2147483647
INT_MAX + 1 = -2147483648      « deu a volta e ficou negativo
cabe mais um? 1                « mas aqui diz que x + 1 é MAIOR que x!

O programa se contradiz: a segunda linha mostra que INT_MAX + 1 virou negativo, e a terceira diz que x + 1 > x. O gcc raciocinou assim: "x + 1 > x é sempre verdade, porque estouro não existe", e trocou a conta inteira por 1, mesmo sem -O2. Nenhum aviso.

No editor do guia, que usa o Clang, a resposta depende da otimização. Rode as duas caixas abaixo: o código é o mesmo, só muda a linha de compilação.

Experimente (sem otimização): clique em Rodar e veja a última linha.

#include <limits.h>
#include <stdio.h>

int cabe_mais_um(int x) {
    return x + 1 > x;
}

int main(void) {
    int x = INT_MAX;
    printf("INT_MAX + 1 = %d\n", x + 1);
    printf("cabe mais um? %d\n", cabe_mais_um(x));
    return 0;
}

Experimente (com -O2): o mesmo código. Compare a última linha com a da caixa de cima.

#include <limits.h>
#include <stdio.h>

int cabe_mais_um(int x) {
    return x + 1 > x;
}

int main(void) {
    int x = INT_MAX;
    printf("INT_MAX + 1 = %d\n", x + 1);
    printf("cabe mais um? %d\n", cabe_mais_um(x));
    return 0;
}

No editor, sem otimização, o Clang faz a conta de verdade: -2147483648 > 2147483647 é falso, e sai cabe mais um? 0. Com -O2, ele faz o mesmo raciocínio do gcc e sai 1. Três compilações, duas respostas, nenhum erro.

Pode ficar pior. Este laço dobra i até ele estourar e ficar negativo:

#include <stdio.h>

int main(void) {
    int passos = 0;
    for (int i = 1; i > 0; i *= 2) {
        passos++;
    }
    printf("Passos: %d\n", passos);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra laco.c -o laco
$ ./laco
Passos: 31
$ gcc -std=c23 -Wall -Wextra -O2 laco.c -o laco-O2
$ timeout 3 ./laco-O2
$ echo $?
124                            « o timeout teve de matar o programa: laço infinito

Sem otimização, depois de 31 dobras o i deu a volta, ficou negativo, e o laço parou. Com -O2, o gcc concluiu que "um número positivo dobrado continua positivo" (verdade, se não houver estouro) e apagou o teste i > 0: o laço virou infinito. O timeout 3 mata o programa depois de 3 segundos e devolve o código 124. No editor do guia, com -O2, o Clang foi por outro caminho ainda: o programa morre com Falha de segmentação (código 139). Um código, três comportamentos.

Como evitar: escolha um tipo em que o resultado caiba (long long vai até 9,2 × 1018) e confira antes de fazer contas perto do limite. Os tipos sem sinal (unsigned) não têm UB no estouro: o padrão manda dar a volta (UINT_MAX + 1 vale 0). Mas uma conta que dá a volta sem você querer também é um erro, só que definido.

Missão: a lojinha da Ana vendeu R$ 1 bilhão em cada um de três meses (sonhar não custa). Rode e veja o total negativo: a soma estourou o int. Troque o tipo de total para long long e o formato do printf para %lld, para a saída ser Total: 3000000000. (No editor, long tem só 4 bytes e também não serve; veja sizeof.)

#include <stdio.h>

int main(void) {
    int vendas[3] = {1000000000, 1000000000, 1000000000};
    int total = 0;
    for (int i = 0; i < 3; i++) {
        total += vendas[i];
    }
    printf("Total: %d\n", total);
    return 0;
}
#include <stdio.h>

int main(void) {
    int vendas[3] = {1000000000, 1000000000, 1000000000};
    long long total = 0;
    for (int i = 0; i < 3; i++) {
        total += vendas[i];
    }
    printf("Total: %lld\n", total);
    return 0;
}

4. i = i++: mudar a mesma variável duas vezes

O ++ faz duas coisas: devolve um valor e muda a variável (veja ++ e --). Se a mesma expressão muda a mesma variável duas vezes, ou muda e lê em outro pedaço, o padrão não diz em que ordem as coisas acontecem. Isso é UB.

#include <stdio.h>

int main(void) {
    int i = 5;
    i = i++;
    printf("i = %d\n", i);
    int n = 1;
    printf("%d %d\n", n++, n++);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra incr.c -o incr
incr.c: In function ‘main’:
incr.c:5:7: warning: operation on ‘i’ may be undefined [-Wsequence-point]
    5 |     i = i++;
      |     ~~^~~~~
incr.c:8:29: warning: operation on ‘n’ may be undefined [-Wsequence-point]
    8 |     printf("%d %d\n", n++, n++);
      |                            ~^~
$ ./incr
i = 5
2 1                            « o gcc calculou o segundo n++ primeiro
$ clang -std=c23 -Wall -Wextra incr.c -o incr-clang
incr.c:5:10: warning: multiple unsequenced modifications to 'i' [-Wunsequenced]
    5 |     i = i++;
      |       ~  ^
incr.c:8:24: warning: multiple unsequenced modifications to 'n' [-Wunsequenced]
    8 |     printf("%d %d\n", n++, n++);
      |                        ^    ~~
2 warnings generated.
$ ./incr-clang
i = 5
1 2                            « o clang, na ordem da esquerda para a direita

Os dois compiladores avisaram, cada um com suas palavras: operation on ‘i’ may be undefined = "a operação em i pode ser indefinida"; multiple unsequenced modifications = "várias modificações sem ordem definida". E a segunda linha saiu diferente: 2 1 no gcc, 1 2 no Clang (e no editor do guia, que também é Clang).

Como evitar: uma mudança por linha. Em vez de i = i++; (que não faz sentido nem quando "funciona"), escreva i++;. Em vez de printf("%d %d\n", n++, n++);, faça as contas antes:

int primeiro = n;
int segundo = n + 1;
n += 2;
printf("%d %d\n", primeiro, segundo);

5. Ponteiro solto

Um ponteiro solto (em inglês, dangling pointer, "ponteiro pendurado") aponta para uma memória que já não é mais sua. Usá-lo é UB. Dois jeitos clássicos de criar um:

Endereço de uma variável local que já morreu

As variáveis locais de uma função somem quando ela termina (veja Pilha e Heap). Devolver o endereço de uma delas entrega um endereço de uma "casa demolida":

#include <stdio.h>

int *criar_contador(void) {
    int contador = 10;
    return &contador;
}

int main(void) {
    int *p = criar_contador();
    printf("Contador: %d\n", *p);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra solto.c -o solto
solto.c: In function ‘criar_contador’:
solto.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
    5 |     return &contador;
      |            ^~~~~~~~~
$ ./solto
Falha de segmentação     (imagem do núcleo gravada) ./solto
$ echo $?
139

Function returns address of local variable = "a função devolve o endereço de uma variável local". O gcc sabe que esse endereço não vale nada depois do return, então fez a função devolver um ponteiro nulo no lugar dele (dá para ver isso no código de máquina com gcc -S). Por isso o programa morreu com Falha de segmentação (código 139 = 128 + 11, o número do sinal SIGSEGV; veja Ponteiro Nulo). No editor do guia, o mesmo código mostra o aviso do Clang (address of stack memory associated with local variable 'contador' returned) e depois imprime Contador: 10, como se estivesse tudo bem.

Memória usada depois do free

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *precos = malloc(3 * sizeof(int));
    if (precos == NULL) {
        return 1;
    }
    precos[0] = 5;
    free(precos);
    printf("Primeiro preço: %d\n", precos[0]);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra usado.c -o usado
usado.c: In function ‘main’:
usado.c:11:5: warning: pointer ‘precos’ used after ‘free’ [-Wuse-after-free]
   11 |     printf("Primeiro preço: %d\n", precos[0]);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
usado.c:10:5: note: call to ‘free’ here
   10 |     free(precos);
      |     ^~~~~~~~~~~~
$ ./usado
Primeiro preço: -158117063     « o 5 sumiu: a memória já foi reaproveitada

Used after ‘free’ = "usado depois do free". Depois do free, a biblioteca pode reaproveitar aquela memória para o que quiser, e foi o que ela fez. O número muda a cada execução. No editor do guia, o mesmo código imprime Primeiro preço: 5, sem aviso nenhum.

Como evitar: nunca devolva o endereço de uma variável local (devolva o valor, ou use malloc; veja malloc e free). Depois de free(p);, escreva p = NULL;: se alguém usar p de novo, o programa morre na hora, no lugar certo, em vez de ler lixo em silêncio.


6. Deslocamento grande demais

O operador << empurra os bits de um número para a esquerda (veja Operadores de Bits). Um unsigned int tem 32 bits, então só dá para deslocar de 0 a 31 posições. Deslocar 32 ou mais (ou um número negativo de posições) é UB.

#include <stdio.h>

int main(void) {
    int bits = 32;
    unsigned int um = 1;
    printf("1 << %d = %u\n", bits, um << bits);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra desloca.c -o desloca
$ ./desloca
1 << 32 = 1                    « o processador usou só "32 resto 32" = 0 posições
$ gcc -std=c23 -Wall -Wextra -O2 desloca.c -o desloca-O2
$ ./desloca-O2
1 << 32 = 0                    « o compilador fez a conta antes e "empurrou tudo para fora"

Sem otimização, o deslocamento foi feito pelo processador, que só olha os 5 últimos bits do 32 (ou seja, desloca 0 posições) e devolve 1. Com -O2, o próprio gcc calculou a conta na hora de compilar e deu 0. No editor do guia acontece igual: 1 sem otimização, 0 com -O2. E nenhum dos dois compiladores avisou, porque bits é uma variável.

Como evitar: confira o tamanho antes (if (bits < 32)) ou use um tipo maior (uint64_t, de stdint.h, aceita até 63).


Por que o C tem comportamento indefinido?

Parece um defeito, e de certo modo é. Mas foi uma escolha, por três motivos:

  1. Velocidade. Conferir cada índice, cada conta e cada ponteiro custa tempo. O C deixa a conferência por sua conta e, em troca, gera um programa tão rápido quanto o processador permite. É por isso que o C roda em sistemas operacionais, jogos e aparelhos com pouca memória.
  2. Processadores diferentes. Em 1989 havia máquinas que, num estouro de int, davam a volta, outras que paravam o programa, outras que guardavam os números negativos de outro jeito. Em vez de obrigar todas a fazer igual (e deixar algumas mais lentas), o padrão disse: "não faça isso".
  3. Otimização. Saber que o UB "não acontece" deixa o compilador simplificar o código: apagar testes que "sempre dão verdade", trocar contas por constantes, reorganizar laços. Foi exatamente o que você viu no laco.c.

O preço é que a responsabilidade é sua. A boa notícia: existem ferramentas que fazem a conferência por você enquanto você testa.


Como se proteger

1. Avisos: -Wall -Wextra (e -O2 de vez em quando)

O comando padrão do guia já pega muita coisa: variável sem valor, i = i++, endereço de variável local, uso depois do free (veja Avisos). Compilar também com -O2 mostra avisos que só a análise do otimizador encontra, como o do talvez.c e o do fora.c. Regra do guia: trate todo aviso como erro. Se quiser que o gcc trate de verdade, acrescente -Werror.

2. Sanitizers: -fsanitize=undefined,address

Avisos olham o código antes de rodar e não pegam tudo (o desloca.c e o laco.c passaram calados). Os sanitizers ("higienizadores") fazem o contrário: acrescentam conferências ao programa, que vigiam cada operação enquanto ele roda. São dois:

  • -fsanitize=undefined (o UBSan): estouro de inteiro com sinal, deslocamento grande, índice fora de um array de tamanho conhecido, divisão por zero…
  • -fsanitize=address (o ASan): leitura ou escrita fora de um array ou de um bloco do malloc, uso depois do free, free duplo.

Junte os dois e acrescente -g, que guarda o nome do arquivo e o número das linhas para o relatório. Veja o laco.c e o desloca.c:

$ gcc -std=c23 -Wall -Wextra -g -fsanitize=undefined laco.c -o laco-san
$ ./laco-san
laco.c:5:30: runtime error: signed integer overflow: 1073741824 * 2 cannot be represented in type 'int'
Passos: 31
$ gcc -std=c23 -Wall -Wextra -g -fsanitize=undefined desloca.c -o desloca-san
$ ./desloca-san
desloca.c:6:39: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
1 << 32 = 1
laco.c:5:30
Arquivo, linha e coluna exatos do problema.
runtime error
"Erro em tempo de execução": aconteceu enquanto o programa rodava, não ao compilar.
signed integer overflow: 1073741824 * 2 cannot be represented in type 'int'
"Estouro de inteiro com sinal: 1073741824 × 2 não cabe num int". Até os números da conta aparecem.
shift exponent 32 is too large for 32-bit type
"O tamanho do deslocamento, 32, é grande demais para um tipo de 32 bits".

O UBSan avisa e deixa o programa continuar. O ASan é mais bravo: no primeiro erro de memória, ele mostra um relatório e para o programa. Veja o fora.c, aquele da soma "certa por sorte":

$ gcc -std=c23 -Wall -Wextra -g -fsanitize=address,undefined fora.c -o fora-san
$ ./fora-san
fora.c:7:22: runtime error: index 4 out of bounds for type 'int [4]'
fora.c:7:22: runtime error: load of address 0x72640a8f0030 with insufficient space for an object of type 'int'
0x72640a8f0030: note: pointer points here
 09 00 00 00  00 00 00 00 00 00 00 00  00 80 8e 0a 64 72 00 00  00 00 00 00 00 00 00 00  00 00 00 00
              ^
=================================================================
==34762==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x72640a8f0030 at pc 0x59b429016554 bp 0x7ffdae8321d0 sp 0x7ffdae8321c0
READ of size 4 at 0x72640a8f0030 thread T0
    #0 0x59b429016553 in main /home/ana/c/ub/fora.c:7
    #1 0x76640ce2a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
    #2 0x76640ce2a717 in __libc_start_main_impl ../csu/libc-start.c:360
    #3 0x59b429016204 in _start (/home/ana/c/ub/fora-san+0x1204) (BuildId: 08a9be074c613b3a7c8f96ae0799dcaea7be8e21)

Address 0x72640a8f0030 is located in stack of thread T0 at offset 48 in frame
    #0 0x59b4290162d8 in main /home/ana/c/ub/fora.c:3

  This frame has 1 object(s):
    [32, 48) 'notas' (line 4) <== Memory access at offset 48 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow /home/ana/c/ub/fora.c:7 in main
Shadow bytes around the buggy address:
…                              « mais umas 30 linhas com o mapa da memória

Parece muito, mas você só precisa de três linhas:

index 4 out of bounds for type 'int [4]'
(do UBSan) "Índice 4 fora dos limites do tipo int[4]". Já diz tudo.
stack-buffer-overflow … READ of size 4
(do ASan) "Estouro de um array na pilha: leitura de 4 bytes" (um int).
#0 … in main /home/ana/c/ub/fora.c:7
Onde: a função main, linha 7 do fora.c. O #0 é sempre o lugar do erro; as linhas #1, #2 são quem chamou quem.
'notas' (line 4) <== Memory access at offset 48 overflows this variable
"O acesso passou do fim desta variável": o array notas, criado na linha 4.

O uso depois do free também é pego, e o relatório mostra três lugares: onde a memória foi usada, onde foi liberada e onde foi reservada:

$ gcc -std=c23 -Wall -Wextra -g -fsanitize=address usado.c -o usado-san
usado.c: In function ‘main’:
usado.c:11:5: warning: pointer ‘precos’ used after ‘free’ [-Wuse-after-free]
   11 |     printf("Primeiro preço: %d\n", precos[0]);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
usado.c:10:5: note: call to ‘free’ here
   10 |     free(precos);
      |     ^~~~~~~~~~~~
$ ./usado-san
=================================================================
==34781==ERROR: AddressSanitizer: heap-use-after-free on address 0x7963053e0010 at pc 0x652e7a1af2f8 bp 0x7ffde9e17c00 sp 0x7ffde9e17bf0
READ of size 4 at 0x7963053e0010 thread T0
    #0 0x652e7a1af2f7 in main /home/ana/c/ub/usado.c:11      « usou aqui
…
freed by thread T0 here:
    #0 0x7d430652a3ff in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:51
    #1 0x652e7a1af2c0 in main /home/ana/c/ub/usado.c:10      « liberou aqui
…
previously allocated by thread T0 here:
    #0 0x7d430652b60f in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:67
    #1 0x652e7a1af25e in main /home/ana/c/ub/usado.c:5       « reservou aqui
…
SUMMARY: AddressSanitizer: heap-use-after-free /home/ana/c/ub/usado.c:11 in main

Heap-use-after-free = "uso, depois do free, de memória do heap". Os … marcam linhas que tiramos para caber na tela (as do sistema, iguais em todo relatório). Os sanitizers têm aula própria: Sanitizers. Outra ferramenta que pega erros de memória, sem recompilar, é o Valgrind.

Quando usar cada coisa

Enquanto escreve e testa: gcc -std=c23 -Wall -Wextra -g -fsanitize=undefined,address. O programa fica umas duas vezes mais lento e gasta mais memória, mas avisa na hora, na linha certa. Para entregar a versão final: tire os sanitizers e use -O2. Um programa sem UB dá a mesma resposta nos dois jeitos; se a resposta muda, é sinal de UB escondido.

E no editor do guia?

Os sanitizers não existem no editor. Com -fsanitize=undefined, a compilação falha na ligação: wasm-ld: error: … undefined symbol: __ubsan_handle_add_overflow ("símbolo não definido"), porque a biblioteca do UBSan não foi feita para WebAssembly. Use-os no seu computador (veja Instalar no Linux).


Indefinido, definido pela implementação e não especificado

O padrão tem três graus de "não sei dizer exatamente". Só o primeiro é proibido:

NomeO que o padrão dizExemplosPode usar?
Comportamento indefinido (undefined)"Não prometo nada."Índice fora, estouro de int, variável sem valor, i = i++Nunca
Definido pela implementação (implementation-defined)"Cada compilador escolhe, mas tem de escolher uma coisa só e escrever no manual."sizeof(long), se char tem sinal, -8 >> 1Sim, sabendo que muda de máquina para máquina
Não especificado (unspecified)"Há algumas opções certas; o compilador escolhe qualquer uma, e nem precisa avisar qual."A ordem em que os argumentos de uma função são calculadosSim, desde que o resultado não dependa da escolha

Definido pela implementação

#include <limits.h>
#include <stdio.h>

int main(void) {
    printf("sizeof(int)  = %zu\n", sizeof(int));
    printf("sizeof(long) = %zu\n", sizeof(long));
    printf("LONG_MAX     = %ld\n", LONG_MAX);
    printf("char vai de %d a %d\n", CHAR_MIN, CHAR_MAX);
    printf("-8 >> 1      = %d\n", -8 >> 1);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra impl.c -o impl
$ ./impl
sizeof(int)  = 4
sizeof(long) = 8
LONG_MAX     = 9223372036854775807
char vai de -128 a 127
-8 >> 1      = -4

Nada disso é erro, e cada resposta é sempre a mesma neste compilador, nesta máquina. Mas mude de máquina e muda a resposta. Rode no editor do guia, que é uma máquina de 32 bits:

Experimente: rode e compare com a saída do gcc acima. Quais linhas mudaram?

#include <limits.h>
#include <stdio.h>

int main(void) {
    printf("sizeof(int)  = %zu\n", sizeof(int));
    printf("sizeof(long) = %zu\n", sizeof(long));
    printf("LONG_MAX     = %ld\n", LONG_MAX);
    printf("char vai de %d a %d\n", CHAR_MIN, CHAR_MAX);
    printf("-8 >> 1      = %d\n", -8 >> 1);
    return 0;
}

No editor, sizeof(long) dá 4 e LONG_MAX dá 2147483647. O resto é igual. É por isso que o guia usa long long (pelo menos 64 bits em qualquer lugar) ou os tipos de stdint.h (int64_t, exatamente 64 bits) quando o tamanho importa.

Não especificado

#include <stdio.h>

int mostra(const char *nome, int valor) {
    printf("calculando %s\n", nome);
    return valor;
}

int main(void) {
    printf("soma = %d\n", mostra("a", 1) + mostra("b", 2));
    printf("%d %d\n", mostra("primeiro", 1), mostra("segundo", 2));
    return 0;
}
$ gcc -std=c23 -Wall -Wextra ordem.c -o ordem
$ ./ordem
calculando a
calculando b
soma = 3
calculando segundo             « o gcc calculou o 2º argumento antes do 1º
calculando primeiro
1 2
$ clang -std=c23 -Wall -Wextra ordem.c -o ordem-clang
$ ./ordem-clang
calculando a
calculando b
soma = 3
calculando primeiro            « o clang, da esquerda para a direita
calculando segundo
1 2

O padrão não diz qual argumento é calculado primeiro, e cada compilador escolheu um jeito. Isso não é UB: as duas respostas finais (soma = 3 e 1 2) estão certas e iguais. O problema só aparece quando o resultado depende da ordem, como no printf("%d %d\n", n++, n++) da seção 4. Aí a mistura de "ordem não especificada" com "mudar a mesma variável duas vezes" vira UB.

Regra prática: se a ordem importa, faça cada cálculo numa linha, guarde em variáveis e só depois use.


Quando dá errado

Funciona com -O0 e quebra com -O2 (ou o contrário)

O que significa: quase sempre há UB no programa. O otimizador não "estragou" nada: ele confiou no contrato, e o seu código quebrou o contrato.

Como consertar: compile com -g -fsanitize=undefined,address, rode de novo com as mesmas entradas e leia a primeira linha runtime error ou ERROR: AddressSanitizer. Ela aponta a linha.

warning: ‘x’ is used uninitialized (gcc) / variable 'x' is uninitialized when used here (clang)

O que significa: o programa lê uma variável antes de dar valor a ela. O valor lido é lixo.

Como consertar: dê um valor na declaração apontada pela note: ‘x’ was declared here: int x = 0;. Lembre que o gcc só vê alguns casos com -O2.

warning: iteration 4 invokes undefined behavior

O que significa: o otimizador descobriu que, em alguma volta do laço, o código faz UB (quase sempre um índice que passa do fim do array).

Como consertar: olhe a condição apontada por note: within this loop. Troque i <= N por i < N.

warning: operation on ‘i’ may be undefined [-Wsequence-point]

O que significa: a mesma expressão muda i duas vezes (ou muda e lê em outro pedaço), como em i = i++ ou f(i++, i). No Clang: multiple unsequenced modifications to 'i'.

Como consertar: separe em linhas: uma mudança por linha.

warning: function returns address of local variable

O que significa: a função devolve &variavel_local, que deixa de existir no return. Com o gcc, o resultado vira um ponteiro nulo e o programa morre com Falha de segmentação.

Como consertar: devolva o valor (int em vez de int *), receba um ponteiro para onde guardar o resultado (veja Passar por Ponteiro) ou reserve com malloc.

runtime error: signed integer overflow: … cannot be represented in type 'int'

O que significa: (do -fsanitize=undefined) uma conta passou do maior (ou do menor) valor do tipo.

Como consertar: use um tipo maior (long long, int64_t) ou confira antes da conta: if (x > INT_MAX - y) quer dizer que x + y não cabe.

ERROR: AddressSanitizer: stack-buffer-overflow / heap-buffer-overflow / heap-use-after-free

O que significa: (do -fsanitize=address) leitura ou escrita fora de um array local (stack, pilha), fora de um bloco do malloc (heap) ou num bloco já liberado.

Como consertar: vá para a linha #0 … arquivo.c:N do primeiro bloco. Confira o índice (< e não <=), o tamanho pedido ao malloc ou se o free veio cedo demais.


Referência rápida

UBExemploQuem pegaComo evitar
Variável sem valorint x; printf("%d", x);-Wall (alguns casos só com -O2)int x = 0;
Índice fora do arraynotas[4] num int notas[4]-fsanitize=address, às vezes -O2i < tamanho
Estouro de inteiro com sinalINT_MAX + 1-fsanitize=undefinedTipo maior; conferir antes
Mudar a variável duas vezesi = i++;-Wall (-Wsequence-point)Uma mudança por linha
Endereço de local que morreureturn &local;-Wall (-Wreturn-local-addr)Devolver o valor ou usar malloc
Uso depois do freefree(p); p[0];-Wall (casos simples), -fsanitize=addressp = NULL; logo depois do free
Ponteiro nuloint *p = NULL; *p = 1;O sistema (Falha de segmentação)Conferir if (p != NULL)
Deslocamento grande1u << 32-fsanitize=undefinedbits < 32, ou uint64_t
Divisão inteira por zerox / 0O sistema (Exceção de ponto flutuante)Conferir o divisor

Exercícios

1. A condição certa

Complete a condição para o laço percorrer as 5 posições de int v[5] sem passar do fim:

for (int i = 0; i ; i++)

2. O sanitizer de UB

Complete a opção do gcc que confere estouro de int e deslocamento grande enquanto o programa roda:

gcc -g conta.c -o conta

3. Um programa mostra a resposta certa com -O0 e uma resposta errada com -O2. O que é mais provável?

4. Qual destas linhas tem comportamento indefinido (supondo int x = INT_MAX; e unsigned int u = UINT_MAX;)?

5. sizeof(long) dá 8 no gcc do laboratório e 4 no editor do guia. Isso é:

6. Um programa com um índice fora do array deu a resposta certa em todos os seus testes. O que se pode concluir?

Missão final: a média das notas

Missão: o programa da Ana calcula a média de 4 notas, mas tem dois comportamentos indefinidos: uma variável sem valor inicial e um laço que passa do fim do array. Conserte os dois: o compilador não pode dar nenhum aviso, o laço tem de usar i < 4 e a saída tem de ser Média: 7.5. (No editor, o laço errado pode até dar a média certa, por sorte; por isso a missão confere o código também.)

#include <stdio.h>

int main(void) {
    int notas[4] = {7, 8, 6, 9};
    int soma;
    for (int i = 0; i <= 4; i++) {
        soma += notas[i];
    }
    printf("Média: %.1f\n", soma / 4.0);
    return 0;
}
#include <stdio.h>

int main(void) {
    int notas[4] = {7, 8, 6, 9};
    int soma = 0;
    for (int i = 0; i < 4; i++) {
        soma += notas[i];
    }
    printf("Média: %.1f\n", soma / 4.0);
    return 0;
}

Resumo

  • Comportamento indefinido (UB) é o que o padrão proíbe sem dizer o que acontece. O compilador supõe que nunca acontece; se acontece, o resultado pode ser qualquer coisa, inclusive a resposta certa por sorte.
  • Casos clássicos: variável sem valor, índice fora do array, estouro de int com sinal, i = i++, ponteiro solto (local que morreu, uso depois do free), ponteiro nulo e deslocamento de 32 bits ou mais.
  • O mesmo programa pode mudar com -O0 e -O2, com gcc e Clang, no laboratório e no editor do guia: o laco.c parou, travou e caiu, conforme a compilação.
  • Proteja-se: -Wall -Wextra sempre, -O2 de vez em quando para ver mais avisos, e -g -fsanitize=undefined,address enquanto testa (no seu computador; o editor não tem sanitizers).
  • Definido pela implementação (sizeof(long)) muda de máquina para máquina, mas é documentado; não especificado (a ordem dos argumentos) tem várias respostas certas. Só o indefinido é proibido.