Erros de memória em C são alguns dos bugs mais difíceis de encontrar, pois muitas vezes não causam falhas imediatas, mas levam a comportamentos imprevisíveis, corrupção de dados e vulnerabilidades de segurança. Ferramentas como o Valgrind são essenciais para detectar esses problemas de forma sistemática. Nesta aula, vamos mergulhar no uso prático do Valgrind para identificar memory leaks, acessos inválidos e como interpretar sua saída para corrigir o código com confiança.

O Valgrind é um framework de instrumentação dinâmica que fornece ferramentas como Memcheck, a mais popular, que detecta erros de gerenciamento de memória. Ele executa o programa em um ambiente controlado, monitorando cada alocação e desalocação de memória, bem como cada acesso a regiões de memória. Isso permite que ele relate problemas com precisão, incluindo o local exato no código onde ocorreram.

Valgrind na prática

Para começar a usar o Valgrind, é necessário que o programa seja compilado com informações de depuração. A flag -g do GCC inclui símbolos e linhas de código no executável, permitindo que o Valgrind aponte exatamente onde os erros ocorrem. Além disso, é recomendável desabilitar otimizações (-O0) para que o código não seja reorganizado, o que poderia mascarar erros.

O comando básico para executar o Valgrind é:

valgrind --leak-check=full ./programa

A opção --leak-check=full ativa a verificação detalhada de vazamentos de memória. Existem outras opções úteis, como --show-leak-kinds=all para exibir todos os tipos de vazamentos e --error-exitcode=1 para que o programa retorne um código de erro se houver problemas, facilitando a integração com scripts de automação.

Vamos considerar um exemplo simples de programa com um memory leak:

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

int main() {
    int *ptr = malloc(sizeof(int));
    *ptr = 42;
    printf("%d\n", *ptr);
    // Esquecemos de liberar a memória!
    return 0;
}

Ao executar com Valgrind, a saída mostrará um bloco de 4 bytes definitivamente perdido, indicando a linha do malloc e a falta do free.

Memory leaks

Um memory leak ocorre quando a memória alocada dinamicamente não é liberada, mesmo que o ponteiro que a referencia saia de escopo ou seja sobrescrito. Isso faz com que a memória fique inacessível até o término do programa, podendo esgotar os recursos do sistema em programas de longa duração.

O Valgrind classifica os vazamentos em várias categorias:

  • definitely lost: a memória foi alocada e não há mais ponteiros para ela. É o tipo mais grave.
  • indirectly lost: a memória está perdida porque está referenciada por outra memória que já está perdida.
  • possibly lost: o Valgrind não tem certeza se a memória está perdida, pois pode haver ponteiros não inicializados.
  • still reachable: a memória ainda é referenciada por ponteiros globais ou estáticos, mas não foi liberada. Geralmente não é um problema, mas pode indicar falta de limpeza.

Para corrigir vazamentos, é fundamental parear cada malloc/calloc/realloc com um free correspondente, mesmo em caso de erros ou retornos antecipados. Uma boa prática é usar funções de limpeza e estruturas de dados que gerenciem sua própria memória.

Exemplo de vazamento indireto:

struct Node {
    int data;
    struct Node *next;
};

void create_list() {
    struct Node *head = malloc(sizeof(struct Node));
    head->next = malloc(sizeof(struct Node));
    // Se não liberarmos head, o próximo também fica perdido
}

Acessos inválidos

Acessos inválidos ocorrem quando tentamos ler ou escrever em áreas de memória que não foram alocadas ou que já foram liberadas. Isso inclui:

  • Ler ou escrever além dos limites de um array (buffer overflow).
  • Usar um ponteiro após o free (use-after-free).
  • Desreferenciar um ponteiro nulo ou não inicializado.
  • Ler memória não inicializada.

O Valgrind detecta esses acessos e reporta mensagens como Invalid read ou Invalid write, mostrando o endereço, o tamanho do acesso e a linha do código. Por exemplo:

int *arr = malloc(5 * sizeof(int));
arr[5] = 10; // Acesso inválido: índice fora dos limites

Ao executar, o Valgrind reporta um Invalid write of size 4 na linha do arr[5] = 10.

Para evitar esses erros, é crucial validar índices, inicializar ponteiros e sempre verificar se o ponteiro é válido antes de usá-lo. Além disso, ferramentas como AddressSanitizer (do GCC) podem ser usadas como alternativa, mas o Valgrind é mais abrangente.

Interpretando saída

A saída do Valgrind pode ser extensa, mas é estruturada. Vamos analisar um exemplo típico:

==12345== Memcheck, a memory error detector
==12345== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12345== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==12345== Command: ./programa
==12345== 
==12345== Invalid write of size 4
==12345==    at 0x4005E6: main (exemplo.c:6)
==12345==  Address 0x5204044 is 0 bytes after a block of size 20 alloc'd
==12345==    at 0x4C2BBAF: malloc (vg_replace_malloc.c:299)
==12345==    by 0x4005D2: main (exemplo.c:5)
==12345== 
==12345== HEAP SUMMARY:
==12345==     in use at exit: 0 bytes in 0 blocks
==12345==   total heap usage: 1 allocs, 0 frees, 20 bytes allocated
==12345== 
==12345== All heap blocks were freed -- no leaks are possible
==12345== 
==12345== For lists of detected and suppressed errors, rerun with: -s
==12345== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Vamos decifrar:

  • Invalid write of size 4: indica que houve uma escrita de 4 bytes (tamanho de um int) em um local inválido.
  • at 0x4005E6: main (exemplo.c:6): o endereço da instrução e a localização no código (linha 6).
  • Address 0x5204044 is 0 bytes after a block of size 20 alloc'd: o endereço acessado está logo após um bloco de 20 bytes alocado, ou seja, fora dos limites.
  • alloc'd: mostra onde a memória foi originalmente alocada (linha 5).

No resumo final, vemos o número total de alocações e liberações, e o resumo de erros. No exemplo, não há vazamentos, mas há um erro de acesso. A opção --track-origins=yes pode ajudar a rastrear a origem de valores não inicializados.

É importante prestar atenção ao contexto: erros podem ser reportados em funções de biblioteca, mas a causa está no seu código. Sempre analise a pilha de chamadas para localizar a origem.

Boas práticas e observações finais

Além de usar o Valgrind, adote hábitos que previnem erros de memória:

  • Sempre inicialize ponteiros com NULL e verifique antes de usar.
  • Use free imediatamente após o último uso e defina o ponteiro como NULL.
  • Prefira funções seguras como strncpy em vez de strcpy.
  • Considere usar valgrind --leak-check=full --show-leak-kinds=all para uma análise completa.
  • Integre o Valgrind em seu fluxo de desenvolvimento, rodando-o após cada alteração significativa.

Lembre-se de que o Valgrind pode ser mais lento que a execução normal, mas é uma ferramenta indispensável para garantir a robustez do código em C.

Referências

Exercícios

  1. Escreva um programa C que aloca um array de 10 inteiros com malloc, preenche com valores e esquece de liberar. Compile com -g e execute com Valgrind. Qual é a mensagem de erro?

    ✓ Resposta: O Valgrind reportará algo como
    ==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
    ==12345==    at 0x4C2BBAF: malloc (vg_replace_malloc.c:299)
    ==12345==    by 0x4005D2: main (exemplo.c:5)
    Indicando que 40 bytes (10 * 4) foram alocados e não liberados.
  2. Considere o código:
    int *p = malloc(10 * sizeof(int));
    free(p);
    printf("%d\n", p[0]);
    Que tipo de erro o Valgrind detecta? Explique a mensagem.

    ✓ Resposta: O Valgrind detecta um Invalid read of size 4 porque estamos lendo memória que já foi liberada (use-after-free). A mensagem mostrará o endereço e a linha do printf, e indicará que o bloco foi liberado anteriormente.
  3. Escreva um código que cause um buffer overflow em um array estático. Use Valgrind para encontrar o problema. Qual é a saída?

    ✓ Resposta: Exemplo:
    int arr[5];
    for (int i = 0; i <= 5; i++) arr[i] = i;
    O Valgrind reporta Invalid write of size 4 na linha do arr[5] = i, pois o índice 5 está fora dos limites do array de 5 elementos (índices 0-4).
  4. Como você usaria o Valgrind para verificar apenas vazamentos de memória, sem relatar outros erros? Dê o comando.

    ✓ Resposta: Use a opção --leak-check=full e, para suprimir outros erros, você pode usar --undef-value-errors=no e --track-origins=no, mas o mais comum é usar --errors-for-leak-kinds=definite,possible e --error-exitcode=0 para ignorar outros erros. Porém, a forma mais simples é:
    valgrind --leak-check=full --show-leak-kinds=all ./programa
    E interpretar apenas a seção HEAP SUMMARY.
  5. Explique a diferença entre "definitely lost" e "still reachable" com exemplos.

    ✓ Resposta: "Definitely lost" ocorre quando não há nenhum ponteiro para a memória alocada. Exemplo:
    void f() { int *p = malloc(4); } // p some de escopo, memória perdida
    "Still reachable" ocorre quando ainda existe um ponteiro global ou estático apontando para a memória, mas ela não é liberada no final. Exemplo:
    int *global_ptr;
    int main() { global_ptr = malloc(4); return 0; }
    O Valgrind reporta "still reachable" porque o ponteiro global ainda existe, mas a memória não foi liberada.