Caçando Bugs de Memória com Valgrind e Sanitizers

[412] Caçando Bugs de Memória com Valgrind e Sanitizers

A distância entre a causa e o sintoma é o que torna caro um bug de memória: ele trava longe de onde nasceu. Esta aula lê o relatório do Valgrind linha a linha, mostra o que o AddressSanitizer troca por velocidade, diz quando cada um serve, e monta o hábito de rodar os dois no fluxo diário.
Linguagem C

12 min de leitura

Na aula anterior, o GDB nos deu poder sobre a lógica do programa — pausar, inspecionar, avançar. Mas há uma categoria de bugs em C que o GDB sozinho tem dificuldade de pegar: os erros de memória. São os mais traiçoeiros da linguagem, porque frequentemente não travam onde acontecem — um vazamento consome recursos silenciosamente, um acesso inválido corrompe dados que só causam estrago muito depois. Já encontramos as ferramentas certas para isso lá na aula Vazamentos de Memória e Como Caçá-los, quando fechamos a fase de memória. Hoje voltamos a elas com profundidade e as integramos ao nosso fluxo de trabalho profissional, porque, em C, dominá-las não é opcional.

Por que erros de memória são especiais

Vale relembrar por que essa categoria de bug merece ferramentas próprias. Um erro de lógica normalmente produz um resultado errado imediato e reproduzível — você vê a saída incorreta e investiga. Um erro de memória é diferente: ele viola as regras do gerenciamento de memória de formas que o C não detecta por conta própria. Ler além de um vetor, usar memória já liberada, esquecer um free — nada disso gera um erro claro na hora. O programa pode continuar como se nada fosse, produzir lixo esporadicamente, ou travar num ponto totalmente distante da causa. É essa distância entre a causa e o sintoma que torna esses bugs tão difíceis, e que justifica ferramentas especializadas capazes de vigiar cada acesso à memória.

Valgrind: revisão e aprofundamento

O Valgrind, que conhecemos na aula Vazamentos de Memória e Como Caçá-los, executa seu programa dentro de uma máquina virtual instrumentada, monitorando cada alocação e cada acesso à memória. O uso básico, relembrando:

gcc -g programa.c -o programa
valgrind --leak-check=full ./programa

A flag -g (a mesma do GDB) permite ao Valgrind apontar linhas exatas, e --leak-check=full pede o relatório detalhado de vazamentos. Já vimos que ele detecta vazamentos, mas seu alcance é maior. O Valgrind flagra toda a família de erros de memória: vazamentos (memória alocada e nunca liberada), acessos inválidos (leitura ou escrita fora dos limites de um bloco), uso de memória não inicializada (ler uma variável antes de atribuir-lhe valor), use-after-free (usar memória já liberada) e double-free (liberar duas vezes). Cada categoria vem com uma mensagem específica e a localização no código.

Interpretando os relatórios do Valgrind

O valor do Valgrind está em ler seus relatórios com fluência. Veja um caso de acesso inválido:

#include <stdlib.h>
int main(void) {
    int *v = malloc(5 * sizeof(int));
    v[10] = 42;  // escreve fora dos limites do bloco de 5 ints
    free(v);
    return 0;
}

O Valgrind produz algo como:

Invalid write of size 4
at 0x...: main (programa.c:4)
Address 0x... is 20 bytes after a block of size 20 alloc'd
at 0x...: malloc
by 0x...: main (programa.c:3)

Leia a história que ele conta: houve uma escrita inválida de 4 bytes (Invalid write of size 4) na linha 4; o endereço acessado está 20 bytes depois de um bloco de 20 bytes (5 inteiros × 4) alocado na linha 3. Em outras palavras, o Valgrind identificou exatamente que você escreveu além do vetor, e ligou o acesso inválido (linha 4) à alocação original (linha 3). Essa capacidade de conectar o sintoma à origem é o que torna o Valgrind tão valioso — ele transforma um comportamento indefinido invisível numa mensagem precisa e acionável.

Os sanitizers: velocidade e integração

A alternativa complementar, que também vimos na aula Vazamentos de Memória e Como Caçá-los, são os sanitizers — instrumentação embutida no próprio compilador, ativada por flags. O AddressSanitizer (ASan) é o principal:

gcc -g -fsanitize=address programa.c -o programa
./programa

Rodando o mesmo programa com o bug de acesso inválido, o ASan interrompe a execução no exato momento do erro e imprime um relatório detalhado, com a pilha de chamadas do ponto do erro e a descrição do problema (heap-buffer-overflow). A diferença central em relação ao Valgrind é o desempenho: os sanitizers deixam o programa apenas algumas vezes mais lento, enquanto o Valgrind pode torná-lo dezenas de vezes mais lento. Isso faz dos sanitizers a escolha ideal para uso frequente e para rodar em testes automatizados.

Há vários sanitizers, cada um focado numa categoria. O AddressSanitizer (-fsanitize=address) pega acessos inválidos, use-after-free e (com o LeakSanitizer acoplado) vazamentos. O UndefinedBehaviorSanitizer (-fsanitize=undefined) flagra comportamentos indefinidos como estouro de inteiros com sinal e deslocamentos inválidos — um prenúncio da próxima aula. Você pode combiná-los na compilação para cobertura ampla.

Valgrind versus sanitizers: quando usar cada um

Não é uma escolha excludente — programadores experientes usam ambos, em momentos diferentes. Os sanitizers brilham no ciclo de desenvolvimento diário e nos testes automatizados: são rápidos o suficiente para você compilar sempre com eles ligados durante o desenvolvimento, pegando erros no instante em que surgem. O Valgrind brilha nas auditorias profundas: não exige recompilar (roda sobre o binário já compilado), detecta algumas categorias que os sanitizers podem não pegar, e é excelente para uma varredura completa antes de considerar um código pronto. A combinação prática que muitos adotam: sanitizers ligados durante todo o desenvolvimento e nos testes contínuos, e uma passada final de Valgrind como verificação de qualidade. Ter as duas ferramentas no arsenal, e saber quando cada uma serve melhor, é parte de trabalhar profissionalmente em C.

Integrando ao fluxo de trabalho

A lição maior desta aula não é apenas saber que essas ferramentas existem, mas incorporá-las ao hábito. Em C, onde a linguagem confia em você e não vigia a memória, essas ferramentas são a rede de segurança que a linguagem não oferece. O fluxo profissional recomendado: durante o desenvolvimento, compile sempre com -g -fsanitize=address para pegar erros de memória assim que aparecem; ao escrever testes (tema da aula Testes Automatizados em C), rode-os sob sanitizers para que qualquer erro de memória falhe o teste; e, periodicamente ou antes de entregas importantes, faça uma auditoria completa com valgrind --leak-check=full. Adotar esse hábito muda a natureza da programação em C — em vez de descobrir erros de memória tarde, em produção, você os pega cedo, na sua própria máquina, com localização precisa. É a diferença entre software frágil e software confiável.

Uma nota sobre combinar com o GDB

As ferramentas de hoje e o GDB da aula anterior trabalham juntas. Um fluxo poderoso: quando o AddressSanitizer aponta um erro numa linha específica, você abre o programa no GDB, coloca um breakpoint naquela linha, e investiga o estado exato que levou ao problema — inspecionando variáveis e a pilha de chamadas. O sanitizer encontra o erro e sua localização; o GDB permite investigar o contexto que o causou. Juntos, eles cobrem tanto o "onde" quanto o "porquê" dos bugs de memória, formando o arsenal completo de depuração do programador C.

O que vem a seguir

Hoje aprofundamos e integramos ao fluxo de trabalho as ferramentas de caça a bugs de memória: o Valgrind, para auditorias profundas e leitura precisa de relatórios, e os sanitizers, para detecção rápida no dia a dia e nos testes. Vimos por que erros de memória exigem ferramentas próprias — a distância entre causa e sintoma — e como combiná-las com o GDB. Na próxima aula, deixamos temporariamente a depuração para resolver um problema que cresce com todo projeto: a compilação. Conforme acumulamos múltiplos arquivos (como na aula Compilação Separada: Cabeçalhos e Múltiplos Arquivos), recompilar tudo à mão vira um tormento. O Make vai automatizar esse processo, recompilando apenas o necessário com um único comando.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um programa que aloque um vetor de 5 inteiros e leia (com printf) a posição v[7], fora dos limites. Compile com -g -fsanitize=address, rode, e leia o relatório. Que tipo de erro o ASan reporta e em que linha?

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <stdlib.h>
int main(void) {
    int *v = malloc(5 * sizeof(int));
    printf("%d\n", v[7]); // leitura fora dos limites
    free(v);
    return 0;
}

Compilado com -g -fsanitize=address, o ASan reporta um erro heap-buffer-overflow (estouro de buffer no heap), do tipo READ, apontando a linha do printf como o local do acesso inválido. Ele também informa o tamanho do bloco alocado e a que distância do bloco o acesso ocorreu, além de mostrar onde o bloco foi alocado (a linha do malloc). O programa é interrompido no exato momento do acesso inválido.

Exercício 2

Escreva um programa com um vazamento claro (aloque memória numa função e não a libere). Rode sob valgrind --leak-check=full e identifique, no relatório, quantos bytes vazaram e em que linha foram alocados.

Ver resposta

✓ Resposta:

#include <stdlib.h>
void vazar(void) {
    int *p = malloc(100 * sizeof(int)); // 400 bytes
    p[0] = 1;
    // sem free
}
int main(void) {
    vazar();
    return 0;
}

Sob valgrind --leak-check=full, o relatório mostra 400 bytes in 1 blocks are definitely lost, apontando a linha do malloc dentro de vazar como o local da alocação perdida, e a cadeia de chamadas (vazar chamada por main). O resumo indica 1 allocs, 0 frees, evidenciando o desequilíbrio.

Exercício 3

Escreva um programa com um use-after-free (libere um ponteiro e depois o desreferencie). Rode-o sob o AddressSanitizer e descreva o que o relatório informa sobre onde a memória foi alocada, liberada e usada indevidamente.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <stdlib.h>
int main(void) {
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    printf("%d\n", *p); // use-after-free
    return 0;
}

O AddressSanitizer reporta um erro heap-use-after-free, interrompendo na linha do printf. O relatório do ASan é rico: ele mostra três pilhas de chamadas — onde o acesso inválido ocorreu (a linha do printf), onde a memória foi liberada (a linha do free), e onde a memória foi originalmente alocada (a linha do malloc). Essa tripla informação — alocação, liberação e uso indevido — deixa a sequência completa do erro clara, permitindo entender exatamente o que aconteceu.

Exercício 4

Explique, com suas palavras, por que erros de memória em C são mais difíceis de depurar do que erros de lógica. O que significa dizer que há uma "distância entre a causa e o sintoma"?

Ver resposta

✓ Resposta: Erros de lógica normalmente produzem um resultado incorreto imediato e reproduzível: a saída está errada logo ali, e você pode rastrear o cálculo de volta. Erros de memória são mais difíceis porque o C não os detecta: ao ler fora de um vetor ou usar memória liberada, o programa não gera erro nem para — ele simplesmente acessa o que estiver naquele endereço e continua. A "distância entre a causa e o sintoma" significa que o ponto onde o erro acontece (por exemplo, escrever além de um buffer, corrompendo memória vizinha) pode estar muito longe, no tempo e no código, do ponto onde o problema finalmente se manifesta (um valor estranho ou um crash numa função totalmente diferente, executada muito depois). Você observa o sintoma num lugar, mas a causa está em outro — e, sem ferramentas, não há pista ligando os dois. Um vazamento é ainda mais silencioso: seu sintoma (memória esgotada) pode aparecer horas ou dias após a causa. Essa desconexão é o que torna os erros de memória tão traiçoeiros e o que justifica ferramentas que vigiam cada acesso e ligam o sintoma à origem.

Exercício 5

Compare Valgrind e sanitizers em três aspectos: necessidade de recompilar, impacto no desempenho, e situação ideal de uso. Descreva um fluxo de trabalho que aproveite os pontos fortes de cada um.

Ver resposta

✓ Resposta: Necessidade de recompilar: os sanitizers exigem recompilar o programa com a flag apropriada (-fsanitize=...); o Valgrind não exige recompilação — roda sobre o binário já compilado (embora -g ajude a localizar os erros). Impacto no desempenho: os sanitizers deixam o programa moderadamente mais lento (algumas vezes); o Valgrind o deixa muito mais lento (dezenas de vezes), por emular cada acesso à memória. Situação ideal de uso: os sanitizers são ideais para o desenvolvimento diário e testes automatizados, por serem rápidos o bastante para rodar sempre; o Valgrind é ideal para auditorias profundas e ocasionais, por sua abrangência e por não exigir recompilação. Fluxo de trabalho combinado: durante o desenvolvimento, compile sempre com -g -fsanitize=address para pegar erros de memória no instante em que surgem; rode a suíte de testes sob sanitizers, para que qualquer erro de memória faça um teste falhar; e, antes de entregas importantes ou periodicamente, faça uma auditoria completa com valgrind --leak-check=full como verificação final de qualidade. Assim, os sanitizers dão feedback rápido e contínuo, e o Valgrind dá a varredura profunda de segurança — cada um no momento em que rende mais.

Comentários

Mais em Linguagem C

Sincronização: Mutexes e Variáveis de Condição
Sincronização: Mutexes e Variáveis de Condição

Esperar numa variável de condição pede while, nunca if: a thread pode acordar…

Recursão: Quando uma Função Chama a Si Mesma
Recursão: Quando uma Função Chama a Si Mesma

O fatorial recursivo é bonito; o Fibonacci recursivo é uma armadilha, porque…

Programação Defensiva e Contratos
Programação Defensiva e Contratos

Ponteiro nulo onde a função exige um válido é bug e pede assert; arquivo que…