Pilha e heap: entendendo a memória do processo

1. Visão Geral da Memória de um Processo em C

Quando um programa em C é executado, o sistema operacional cria um espaço de endereçamento virtual dividido em segmentos distintos. Cada segmento tem uma finalidade específica e regras de acesso próprias. Os principais segmentos são:

  • Segmento de texto (code): contém o código binário executável, geralmente em região somente leitura
  • Segmento de dados: armazena variáveis globais e estáticas inicializadas
  • Segmento BSS: contém variáveis globais e estáticas não inicializadas (zeradas pelo sistema)
  • Pilha (stack): gerencia chamadas de função e variáveis locais
  • Heap: região para alocação dinâmica de memória

O endereçamento virtual permite que cada processo tenha a ilusão de possuir um espaço contíguo de memória, isolado de outros processos. O sistema operacional, com auxílio da MMU (Memory Management Unit), traduz endereços virtuais para físicos.

Exemplo de mapa de memória de um programa simples:

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

int global_inicializada = 42;          // Segmento de dados
int global_nao_inicializada;           // Segmento BSS

void funcao() {
    int local = 10;                    // Pilha
    printf("Endereco de local: %p\n", &local);
}

int main() {
    int *ptr = malloc(sizeof(int));    // Heap
    *ptr = 100;

    printf("Texto (codigo): %p\n", (void*)main);
    printf("Dados: %p\n", (void*)&global_inicializada);
    printf("BSS: %p\n", (void*)&global_nao_inicializada);
    printf("Heap: %p\n", (void*)ptr);

    funcao();
    printf("Pilha (main): %p\n", &ptr);

    free(ptr);
    return 0;
}

2. A Pilha (Stack): Alocação Automática e Controlada

A pilha opera no modelo LIFO (Last In, First Out). Cada chamada de função cria um stack frame que contém:

  • Endereço de retorno
  • Parâmetros da função
  • Variáveis locais
  • Estado dos registradores (quando necessário)

O ponteiro de pilha (SP - Stack Pointer) mantém o topo da pilha. Quando uma função é chamada, o SP decrementa; quando retorna, o SP incrementa, liberando automaticamente o espaço.

Exemplo de rastreamento de chamadas:

#include <stdio.h>

void funcao_a() {
    int a = 10;
    printf("funcao_a: a = %d, endereco = %p\n", a, (void*)&a);
}

void funcao_b() {
    int b = 20;
    printf("funcao_b: b = %d, endereco = %p\n", b, (void*)&b);
    funcao_a();
}

int main() {
    int m = 30;
    printf("main: m = %d, endereco = %p\n", m, (void*)&m);
    funcao_b();
    return 0;
}

A saída mostrará endereços decrescentes, confirmando que cada novo frame é alocado em posição inferior na pilha.

3. A Pilha: Limitações e Comportamento

A pilha tem tamanho fixo (tipicamente 1MB a 8MB em sistemas modernos). Quando esse limite é excedido, ocorre stack overflow, geralmente resultando em falha de segmentação (segmentation fault).

A recursão profunda é a causa mais comum de estouro de pilha:

#include <stdio.h>

int fatorial_recursivo(int n) {
    int temp;
    printf("Chamada fatorial(%d) - pilha em: %p\n", n, &temp);

    if (n <= 1) return 1;
    return n * fatorial_recursivo(n - 1);
}

int main() {
    int n = 100000;  // Valor grande propositalmente
    int resultado = fatorial_recursivo(n);
    printf("Resultado: %d\n", resultado);
    return 0;
}

Para valores muito grandes de n, esse programa causará stack overflow. A cada chamada recursiva, um novo frame é alocado na pilha sem que os anteriores sejam liberados, consumindo todo o espaço disponível.

4. O Heap (Monte): Alocação Dinâmica e Flexível

O heap permite alocação e liberação manual de memória em tempo de execução. As funções principais são:

  • malloc: aloca memória não inicializada
  • calloc: aloca e inicializa com zeros
  • realloc: redimensiona bloco alocado
  • free: libera memória

Exemplo de alocação de array com tamanho determinado em execução:

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

int main() {
    int n;
    printf("Quantos elementos? ");
    scanf("%d", &n);

    // Alocação dinâmica
    int *array = (int*)malloc(n * sizeof(int));
    if (array == NULL) {
        fprintf(stderr, "Falha na alocacao\n");
        return 1;
    }

    // Inicialização
    for (int i = 0; i < n; i++) {
        array[i] = i * 10;
    }

    // Exibição
    for (int i = 0; i < n; i++) {
        printf("array[%d] = %d\n", i, array[i]);
    }

    // Liberação
    free(array);
    return 0;
}

Diferente da pilha, o heap permite que o programador controle exatamente quando alocar e liberar memória, mas isso traz responsabilidades adicionais.

5. Heap: Fragmentação, Vazamentos e Boas Práticas

Fragmentação interna: ocorre quando blocos alocados são maiores que o necessário (ex: alocar 100 bytes num bloco de 128 bytes, desperdiçando 28).

Fragmentação externa: ocorre quando a memória livre está espalhada em pequenos fragmentos, impossibilitando alocações grandes mesmo havendo espaço total suficiente.

Vazamentos de memória (memory leaks): ocorrem quando memória alocada não é liberada.

Exemplo com vazamento intencional:

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

void funcao_com_vazamento() {
    int *ptr = (int*)malloc(100 * sizeof(int));
    // Esqueceu de chamar free(ptr)
    // A memória permanece alocada até o fim do programa
}

int main() {
    for (int i = 0; i < 1000; i++) {
        funcao_com_vazamento();
    }
    printf("Memoria vazada!\n");
    return 0;
}

Para detectar vazamentos, use ferramentas como Valgrind:

valgrind --leak-check=full ./programa

Versão corrigida:

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

void funcao_sem_vazamento() {
    int *ptr = (int*)malloc(100 * sizeof(int));
    // Usa o ponteiro...
    free(ptr);  // Libera a memória
}

int main() {
    for (int i = 0; i < 1000; i++) {
        funcao_sem_vazamento();
    }
    printf("Sem vazamento!\n");
    return 0;
}

6. Comparação Prática: Pilha vs. Heap

Característica Pilha Heap
Velocidade Muito rápida (simples incremento/decremento de SP) Mais lenta (busca por bloco livre)
Gerenciamento Automático (entrada/saída de função) Manual (malloc/free)
Tamanho Fixo e limitado Limitado pela memória disponível
Escopo Apenas dentro da função Persiste até free() ou fim do programa
Fragmentação Não ocorre Pode ocorrer

Quando usar cada um:

Use pilha para:
- Variáveis locais com tamanho conhecido em compilação
- Dados pequenos e temporários
- Quando velocidade é crítica

Use heap para:
- Estruturas de dados dinâmicas (listas, árvores)
- Arrays com tamanho determinado em execução
- Dados que precisam persistir além do escopo da função
- Objetos grandes que poderiam estourar a pilha

Exemplo prático de decisão:

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

// Uso correto da pilha para dados pequenos e temporários
void processa_temporario() {
    int buffer[100];  // 400 bytes na pilha - OK
    // processa...
}

// Uso correto do heap para dados grandes ou de tamanho variável
void processa_dinamico(int tamanho) {
    int *buffer = (int*)malloc(tamanho * sizeof(int));
    if (buffer == NULL) {
        fprintf(stderr, "Erro de alocacao\n");
        return;
    }
    // processa...
    free(buffer);
}

int main() {
    processa_temporario();
    processa_dinamico(1000000);  // 4MB no heap
    return 0;
}

7. Depuração de Problemas de Memória

Erros comuns e como identificá-los:

Uso após free (use-after-free):

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

int main() {
    int *ptr = (int*)malloc(sizeof(int));
    *ptr = 42;
    free(ptr);
    *ptr = 100;  // ERRO: uso após liberar
    return 0;
}

Double free:

#include <stdlib.h>

int main() {
    int *ptr = (int*)malloc(sizeof(int));
    free(ptr);
    free(ptr);  // ERRO: double free
    return 0;
}

Buffer overflow no heap:

#include <stdlib.h>
#include <string.h>

int main() {
    char *buffer = (char*)malloc(10);
    strcpy(buffer, "Esta string e muito longa para o buffer");  // ERRO
    free(buffer);
    return 0;
}

Ferramentas essenciais:

  • GDB: permite inspecionar pilha de chamadas com backtrace, examinar variáveis locais, e identificar segmentation faults
  • Valgrind: detecta vazamentos, uso após free, buffer overflows, e acesso a memória não inicializada

Exemplo de debugging com GDB:

gcc -g -o programa programa.c
gdb ./programa
(gdb) run
(gdb) backtrace   # Mostra pilha de chamadas no crash
(gdb) info locals # Mostra variáveis locais
(gdb) print variavel # Examina valor de variável

Referências