C++ Sanitizers (-fsanitize)

Na aula de Comportamento Indefinido você viu erros que passam calados: um índice fora do array que soma lixo, um estouro de int que vira negativo. Os sanitizers ("higienizadores") são opções do compilador que colocam um fiscal dentro do seu programa. Toda vez que ele lê ou escreve memória, ou faz uma conta perigosa, o fiscal confere. Achou um erro? O programa para na hora e mostra um relatório com o tipo do erro, o arquivo e a linha. Nesta aula você vai ligar os três sanitizers mais úteis e aprender a ler os relatórios.

Você vai aprender
  • O AddressSanitizer (-fsanitize=address): estouro de array e uso de memória já liberada.
  • O UndefinedBehaviorSanitizer (-fsanitize=undefined): estouro de inteiro, divisão por zero e outros UBs.
  • O LeakSanitizer (-fsanitize=leak): memória que nunca foi devolvida.
  • Como ler um relatório: onde está a linha que importa.
  • O custo (o programa fica mais lento) e quando usar: sempre durante o desenvolvimento.
  • O que é o Valgrind, uma alternativa sem recompilar.

Todos os relatórios desta aula são reais, gerados com o g++ 15 na máquina fictícia da Ana, na pasta ~/cpp/san. Os relatórios são longos; quando cortamos um pedaço, marcamos com «…». Os números em hexadecimal (0x7aa6…) são endereços de memória e mudam a cada execução. Os sanitizers não existem no editor do guia: lá, esses mesmos erros passam calados. As missões desta página corrigem, no editor, os erros que os sanitizers acharam no terminal.


A ideia: o fiscal da obra

Numa obra sem fiscal, um pedreiro pode levantar a parede 10 centímetros para dentro do terreno do vizinho e ninguém percebe até o dia em que o vizinho reclama, anos depois. Com um fiscal, o erro é apontado no mesmo dia, com a medida exata.

O sanitizer é esse fiscal. Ao compilar com -fsanitize=…, o g++ acrescenta conferências em volta de cada acesso à memória e de cada conta perigosa. O programa fica maior e mais lento, mas cada UB vira uma mensagem clara, no momento em que acontece.

SanitizerOpçãoAcha
AddressSanitizer (ASan)-fsanitize=addressÍndice fora de arrays e vetores, uso de memória já liberada, delete duas vezes, e também vazamentos.
UndefinedBehaviorSanitizer (UBSan)-fsanitize=undefinedEstouro de inteiro com sinal, divisão por zero, ponteiro nulo, deslocamentos inválidos…
LeakSanitizer (LSan)-fsanitize=leakMemória pedida com new e nunca devolvida (vazamento).

1. AddressSanitizer: -fsanitize=address

Estouro de array

Lembra do fora.cpp, que somava uma posição a mais e mostrava Soma: 73762, sem erro nenhum?

#include <iostream>

int main() {
    int notas[4] = {7, 8, 9, 10};
    int soma = 0;
    for (int i = 0; i <= 4; ++i) {
        soma += notas[i];
    }
    std::cout << "Soma: " << soma << '\n';
}

Compile com -fsanitize=address e -g (o -g faz o relatório mostrar arquivo e linha, como no GDB):

$ g++ -std=c++23 -Wall -Wextra -g -fsanitize=address fora.cpp -o fora-asan
$ ./fora-asan
=================================================================
==4==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x6ecb514f0030 at pc 0x61f87bf8e465 bp 0x7ffea08f3dd0 sp 0x7ffea08f3dc0
READ of size 4 at 0x6ecb514f0030 thread T0
    #0 0x61f87bf8e464 in main /home/ana/cpp/san/fora.cpp:7
    #1 0x72cb5362a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
    #2 0x72cb5362a717 in __libc_start_main_impl ../csu/libc-start.c:360
    #3 0x61f87bf8e1c4 in _start (/home/ana/cpp/san/fora-asan+0x11c4) (BuildId: 0ce73ab15d571af2144acce51e60052160266485)

Address 0x6ecb514f0030 is located in stack of thread T0 at offset 48 in frame
    #0 0x61f87bf8e298 in main /home/ana/cpp/san/fora.cpp: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/cpp/san/fora.cpp:7 in main
Shadow bytes around the buggy address:
«… 30 linhas com um mapa da memória e a legenda dele …»
==4==ABORTING
$ echo $?
1

Assusta, mas só quatro pedaços importam. Leia nesta ordem:

PedaçoO que diz
ERROR: AddressSanitizer: stack-buffer-overflowO tipo do erro: estouro (overflow) de um bloco de memória (buffer) na pilha (stack, onde ficam as variáveis locais; veja Como a Memória Funciona).
READ of size 4Foi uma leitura de 4 bytes (um int). WRITE seria uma escrita, ainda mais perigosa.
#0 … in main /home/ana/cpp/san/fora.cpp:7Onde: linha 7, a do soma += notas[i];. É a pilha de chamadas, como o backtrace do GDB; a primeira linha com um arquivo seu é a pista. As linhas __libc_start… e _start são o código que chama o main; ignore.
'notas' (line 4) <== Memory access at offset 48 overflows this variableQual variável: o acesso passou do fim de notas, declarada na linha 4. O array ocupa os bytes 32 a 48 (16 bytes = 4 int), e o acesso foi no byte 48, logo depois.

O SUMMARY repete o essencial numa linha só. O resto (o "mapa de sombra", shadow bytes) mostra como o ASan marca a memória por dentro; pode pular. Repare também: o programa parou no erro (ABORTING), com código 1, e nunca mostrou a soma errada.

Uso de memória já liberada (use-after-free)

Este é traiçoeiro, porque o código parece inocente. A Ana guarda uma referência para o primeiro da fila e depois acrescenta mais uma pessoa:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> fila = {10, 20, 30};
    int& primeiro = fila[0];
    fila.push_back(40);
    std::cout << "Primeiro da fila: " << primeiro << '\n';
}
$ ./depois
Primeiro da fila: 662828488

Por que lixo? Quando o push_back não tem mais espaço, o std::vector pede um bloco de memória maior, copia os elementos para lá e devolve o bloco antigo (veja std::vector). A referência primeiro continuou apontando para o bloco antigo, que já não pertence ao programa. O ASan mostra a história inteira:

$ g++ -std=c++23 -Wall -Wextra -g -fsanitize=address depois.cpp -o depois-asan
$ ./depois-asan
=================================================================
==6==ERROR: AddressSanitizer: heap-use-after-free on address 0x6df385de0010 at pc 0x5de7e199b619 bp 0x7ffed16b7b60 sp 0x7ffed16b7b50
READ of size 4 at 0x6df385de0010 thread T0
    #0 0x5de7e199b618 in main /home/ana/cpp/san/depois.cpp:8
«…»
0x6df385de0010 is located 0 bytes inside of 12-byte region [0x6df385de0010,0x6df385de001c)
freed by thread T0 here:
    #0 0x71d38732d89f in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:190
«… 8 linhas de dentro do std::vector …»
    #9 0x5de7e199b5b0 in main /home/ana/cpp/san/depois.cpp:7
«…»
previously allocated by thread T0 here:
    #0 0x71d38732c73f in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:109
«… 6 linhas de dentro do std::vector …»
    #7 0x5de7e199b52c in main /home/ana/cpp/san/depois.cpp:5
«…»
SUMMARY: AddressSanitizer: heap-use-after-free /home/ana/cpp/san/depois.cpp:8 in main
«… o mapa da memória …»
==6==ABORTING

Três blocos, três linhas do seu arquivo, contando a história de trás para a frente:

  • heap-use-after-free … depois.cpp:8: na linha 8, o programa leu uma memória (do heap, a memória dinâmica) depois de ela ter sido liberada.
  • freed by … depois.cpp:7: quem liberou foi a linha 7, o push_back (por dentro, o operator delete do vetor).
  • previously allocated by … depois.cpp:5: e ela tinha sido pedida na linha 5, quando o vetor foi criado com 3 elementos (12 bytes).

Missão: corrija o depois.cpp. Há dois jeitos: guardar uma cópia do valor (int primeiro = fila[0];, sem o &) ou pegar a referência só depois do push_back. No editor, o erro original passa calado (pode até aparecer o 10 certo, por sorte); a missão confere a correção no código.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> fila = {10, 20, 30};
    int& primeiro = fila[0];
    fila.push_back(40);
    std::cout << "Primeiro da fila: " << primeiro << '\n';
}
#include <iostream>
#include <vector>

int main() {
    std::vector<int> fila = {10, 20, 30};
    int primeiro = fila[0];
    fila.push_back(40);
    std::cout << "Primeiro da fila: " << primeiro << '\n';
}

2. UndefinedBehaviorSanitizer: -fsanitize=undefined

O UBSan cuida dos UBs que não são de memória: contas que estouram, divisão por zero, deslocamentos de bits inválidos, entre outros. Este programa tem dois:

#include <climits>
#include <iostream>

int main() {
    int pontos = INT_MAX;
    pontos = pontos + 1;
    std::cout << "Pontos: " << pontos << '\n';

    int pessoas = 0;
    int pizza = 8;
    std::cout << "Fatias por pessoa: " << pizza / pessoas << '\n';
}
$ g++ -std=c++23 -Wall -Wextra -g -fsanitize=undefined ub.cpp -o ub-ubsan
$ ./ub-ubsan
ub.cpp:6:12: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
Pontos: -2147483648
ub.cpp:11:49: runtime error: division by zero
Exceção de ponto flutuante (imagem do núcleo gravada) ./ub-ubsan
$ echo $?
136

As mensagens do UBSan são curtas e no formato que você já conhece dos erros de compilação: arquivo:linha:coluna: runtime error: … ("erro em tempo de execução").

  • signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' = "estouro de inteiro com sinal: 2147483647 + 1 não cabe no tipo int". Repare que o programa continuou (mostrou Pontos: -2147483648): por padrão, o UBSan avisa e segue.
  • division by zero = "divisão por zero". Depois do aviso, a divisão aconteceu de verdade, e o processador a recusou: o sistema matou o programa com o sinal SIGFPE ("Exceção de ponto flutuante", um nome histórico, já que aqui a conta era de inteiros). Código 136 = 128 + 8.

Para o UBSan parar no primeiro erro, como o ASan, acrescente -fno-sanitize-recover=undefined ("não se recupere").

Missão: corrija os dois erros que o UBSan achou. Para o estouro, troque o tipo de pontos por um que caiba o resultado (a saída deve ser Pontos: 2147483648). Para a divisão, confira antes: se pessoas for 0, mostre Sem ninguém para dividir a pizza.. No editor, a divisão por zero encerra o programa sem mensagem, com o código 136.

#include <climits>
#include <iostream>

int main() {
    int pontos = INT_MAX;
    pontos = pontos + 1;
    std::cout << "Pontos: " << pontos << '\n';

    int pessoas = 0;
    int pizza = 8;
    std::cout << "Fatias por pessoa: " << pizza / pessoas << '\n';
}
#include <climits>
#include <iostream>

int main() {
    long long pontos = INT_MAX;
    pontos = pontos + 1;
    std::cout << "Pontos: " << pontos << '\n';

    int pessoas = 0;
    int pizza = 8;
    if (pessoas == 0) {
        std::cout << "Sem ninguém para dividir a pizza.\n";
    } else {
        std::cout << "Fatias por pessoa: " << pizza / pessoas << '\n';
    }
}

3. LeakSanitizer: -fsanitize=leak

Um vazamento de memória (memory leak) é memória pedida com new e nunca devolvida com delete (veja Memória Dinâmica). O programa não quebra; só vai ocupando cada vez mais memória, o que num programa que roda por dias (um servidor, um jogo aberto) vira um problema sério.

#include <iostream>

int* criar_placar() {
    int* p = new int[100];
    p[0] = 42;
    return p;
}

int main() {
    int* placar = criar_placar();
    std::cout << "Placar: " << placar[0] << '\n';
}
$ g++ -std=c++23 -Wall -Wextra -g -fsanitize=leak vazamento.cpp -o vaz-lsan
$ ./vaz-lsan
Placar: 42

=================================================================
==8==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 400 byte(s) in 1 object(s) allocated from:
    #0 0x7bae83617708 in operator new[](unsigned long) ../../../../src/libsanitizer/lsan/lsan_interceptors.cpp:281
    #1 0x5e3f513611be in criar_placar() /home/ana/cpp/san/vazamento.cpp:4
    #2 0x5e3f513611e3 in main /home/ana/cpp/san/vazamento.cpp:10
    #3 0x7bae82e2a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
    #4 0x7bae82e2a717 in __libc_start_main_impl ../csu/libc-start.c:360
    #5 0x5e3f513610e4 in _start (/home/ana/cpp/san/vaz-lsan+0x10e4) (BuildId: 672d1bffe70ea59d36e9c9026cc6d857db225edc)

SUMMARY: LeakSanitizer: 400 byte(s) leaked in 1 allocation(s).
$ echo $?
23

O programa rodou inteiro (Placar: 42), e só no fim o LSan conferiu o que ficou sem devolver: Direct leak of 400 byte(s) in 1 object(s) allocated from = "vazamento direto de 400 bytes em 1 objeto, pedido em…" a linha 4 (new int[100]: 100 × 4 bytes), dentro de criar_placar, chamada pela linha 10. O código de saída 23 é o jeito do LSan dizer "achei vazamentos".

Você não precisa ligar o LSan à parte: o ASan já inclui a busca por vazamentos no Linux. Compilado com -fsanitize=address, o mesmo programa mostra o mesmo relatório, terminando com SUMMARY: AddressSanitizer: 400 byte(s) leaked in 1 allocation(s). e código 1.

A correção de verdade não é lembrar do delete: é não usar new. Um std::vector (ou um ponteiro inteligente) devolve a memória sozinho.


4. O custo, e quando usar

O fiscal trabalha, e isso custa. Um programa que faz 50 milhões de contas num vetor, compilado sem e com -fsanitize=address,undefined (os dois juntos, separados por vírgula):

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v(1'000'000);
    long long soma = 0;
    for (int volta = 0; volta < 50; ++volta) {
        for (std::size_t i = 0; i < v.size(); ++i) {
            v[i] = volta + static_cast<int>(i % 7);
            soma += v[i];
        }
    }
    std::cout << "Soma: " << soma << '\n';
}
$ g++ -std=c++23 -Wall -Wextra -g custo.cpp -o custo
$ g++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined custo.cpp -o custo-san
$ ls -l custo custo-san
-rwxrwxr-x 1 ana ana  80752 out 16 22:16 custo
-rwxrwxr-x 1 ana ana 142456 out 16 22:16 custo-san
$ time ./custo
Soma: 1374999850

real	0m1,160s
user	0m1,156s
sys	0m0,004s
$ time ./custo-san
Soma: 1374999850

real	0m2,220s
user	0m2,209s
sys	0m0,012s

O time do Bash mede quanto o comando demorou (a linha real é o tempo de relógio). Com os sanitizers, o programa ficou quase duas vezes mais lento e o executável, quase duas vezes maior. O ASan também usa bem mais memória. Em geral, conte com um programa de 2 a 3 vezes mais lento.

Por isso a regra é:

  • Durante o desenvolvimento e nos testes: sempre com sanitizers. Um bom comando do dia a dia é g++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined arquivo.cpp -o programa.
  • Na versão final, que vai para os usuários: sem sanitizers, com otimização (-O2).

Esses dois "jeitos de compilar" costumam ganhar nomes, Debug e Release, que você vai reencontrar no CMake.

Os sanitizers só acham o que acontece

O fiscal só confere o que o programa de fato executou. Se o erro está num if que não rodou no seu teste, o sanitizer não vê. Teste os caminhos do programa (entradas erradas, listas vazias, o último elemento) com os sanitizers ligados. E nem todo par de sanitizers combina: o -fsanitize=thread, para erros com threads, não pode ser usado junto com o address.


5. Valgrind: o fiscal sem recompilar

O Valgrind é um programa que roda o seu executável numa espécie de máquina simulada e confere cada acesso à memória. A vantagem: não precisa recompilar; funciona até com programas que você não compilou. O uso é valgrind ./programa (ou valgrind --leak-check=full ./programa para vazamentos), e ele também mostra o arquivo e a linha se o programa tiver -g. A desvantagem: o programa fica muito mais lento que com os sanitizers (dezenas de vezes). Ele não vem instalado no Ubuntu (sudo apt install valgrind) e não está na máquina da Ana, por isso não há saída dele aqui. Para quem compila o próprio código, os sanitizers costumam ser a primeira escolha.


Quando dá errado

O relatório não mostra arquivo e linha, só endereços

O que aconteceu: faltou o -g.

Como resolver: compile com -g -fsanitize=….

Compilei com o sanitizer e não apareceu nada, mas o programa continua errado

O que aconteceu: o erro não está no caminho que rodou, ou não é do tipo que aquele sanitizer procura (um erro de lógica, como dividir errado, não é UB).

Como resolver: teste outras entradas; ligue também o outro sanitizer (address,undefined); para erros de lógica, use o GDB.

"runtime error: …" aparece e o programa continua

O que aconteceu: é o comportamento padrão do UBSan: avisa e segue.

Como resolver: trate como erro do mesmo jeito. Para parar no primeiro, compile com -fno-sanitize-recover=undefined.


Referência rápida

Opção / mensagemSignificado
-fsanitize=addressASan: memória (estouro, use-after-free, double free) e vazamentos.
-fsanitize=undefinedUBSan: estouro de inteiro, divisão por zero e outros UBs.
-fsanitize=leakLSan: só vazamentos (o ASan já inclui).
-fsanitize=address,undefinedOs dois juntos: a combinação do dia a dia.
-fno-sanitize-recover=undefinedFaz o UBSan parar no primeiro erro.
stack-buffer-overflow / heap-buffer-overflowAcesso além do fim (ou antes do começo) de um array/vetor, na pilha ou no heap.
heap-use-after-freeUso de memória já liberada.
detected memory leaksMemória que nunca foi devolvida.
runtime error: signed integer overflowEstouro de inteiro com sinal.
runtime error: division by zeroDivisão por zero.

Exercícios

1. Ligar o AddressSanitizer

Complete a opção:

g++ -std=c++23 -Wall -Wextra -g prog.cpp -o prog

2. Os dois juntos

Escreva uma única opção que liga o ASan e o UBSan:

3. O relatório diz ERROR: AddressSanitizer: heap-use-after-free. O que aconteceu?

4. Quando usar os sanitizers?

5. No relatório do ASan, qual linha diz onde está o erro no seu código?

Missão final: o placar sem vazamento

Missão: o LeakSanitizer achou 400 bytes vazados neste programa. Em vez de acrescentar um delete[], troque o new int[100] por um std::vector<int> de 100 posições, que a função devolve por valor (e o main guarda num std::vector<int>). A saída continua Placar: 42, agora sem nenhum new.

#include <iostream>

int* criar_placar() {
    int* p = new int[100];
    p[0] = 42;
    return p;
}

int main() {
    int* placar = criar_placar();
    std::cout << "Placar: " << placar[0] << '\n';
}
#include <iostream>
#include <vector>

std::vector<int> criar_placar() {
    std::vector<int> p(100);
    p[0] = 42;
    return p;
}

int main() {
    std::vector<int> placar = criar_placar();
    std::cout << "Placar: " << placar[0] << '\n';
}

Resumo

  • Sanitizers são fiscais que o compilador põe dentro do programa: o UB vira uma mensagem clara, com arquivo e linha, na hora em que acontece.
  • -fsanitize=address (ASan): estouro de array/vetor, uso de memória liberada e vazamentos.
  • -fsanitize=undefined (UBSan): estouro de inteiro, divisão por zero e outros UBs; avisa e segue, a menos que você use -fno-sanitize-recover=undefined.
  • -fsanitize=leak (LSan): vazamentos, no fim do programa.
  • Compile com -g; no relatório, procure o tipo do erro e a primeira linha com um arquivo seu.
  • Custam tempo e memória (o programa ficou 2× mais lento): use sempre no desenvolvimento, nunca na versão final.
  • O Valgrind faz um trabalho parecido sem recompilar, só que bem mais devagar.