A Pilha e o Heap: Onde Cada Coisa Vive

[353] A Pilha e o Heap: Onde Cada Coisa Vive

Devolver o endereço de um vetor local compila, roda e entrega um ponteiro para memória que já não existe — o bug clássico que só o heap resolve. Antes dele vem o mapa: as quatro regiões de um programa em execução, por que a pilha é rápida mas pequena e de tamanho fixo, e onde o tempo de vida deixa de ser automático.
Linguagem C

10 min de leitura

Até agora, tratamos a memória como uma rua de casas numeradas — uma imagem útil, mas incompleta. Na verdade, essa rua tem bairros, cada um com regras próprias. Entender onde suas variáveis moram e por quanto tempo é o que separa quem usa malloc por imitação de quem sabe exatamente o que está fazendo. Hoje desenhamos o mapa desses bairros. Sem ele, a próxima aula seria mágica; com ele, será apenas lógica.

Os quatro bairros da memória de um programa

Quando seu programa roda, o sistema operacional lhe entrega um espaço de memória dividido em regiões, cada uma com um propósito. Simplificando para o que importa agora, são quatro:

O código guarda as instruções do programa — as próprias funções, traduzidas em código de máquina. É somente-leitura; você não altera o programa enquanto ele roda. A região de dados globais guarda as variáveis globais e estáticas, que existem durante toda a execução. E então vêm os dois protagonistas de hoje: a pilha (stack) e o heap. Praticamente tudo que você declara vive num desses dois — e a diferença entre eles é o coração desta aula.

A pilha: rápida, automática e temporária

A pilha é onde vivem as variáveis locais das funções. Toda vez que uma função é chamada, o C reserva na pilha um bloco — o stack frame — para guardar seus parâmetros e variáveis locais. Quando a função retorna, esse bloco é liberado automaticamente, e as variáveis somem. Você já viu isso em ação, na aula sobre escopo: variáveis locais nascem quando a função começa e morrem quando ela termina. Esse nascimento e morte acontecem na pilha.

#include <stdio.h>

void funcao_b(void) {
    int y = 20; // nasce na pilha ao entrar em funcao_b
    printf("Em B: y = %d\n", y);
} // y morre aqui, automaticamente

void funcao_a(void) {
    int x = 10; // nasce na pilha ao entrar em funcao_a
    printf("Em A: x = %d\n", x);
    funcao_b(); // empilha o frame de B por cima do de A
} // x morre aqui

int main(void) {
    funcao_a();
    return 0;
}

O nome "pilha" é literal: os frames se empilham um sobre o outro. Quando main chama funcao_a, o frame de a vai por cima do de main; quando a chama b, o de b vai por cima do de a. Ao retornar, desempilha-se na ordem inversa — o último a entrar é o primeiro a sair. Tudo isso é gerenciado pelo C sozinho, e é extremamente rápido. A pilha é o bairro do "não precisa pensar": você declara, usa, e some sem esforço.

Os limites da pilha

Se a pilha é tão conveniente, por que precisamos de outra coisa? Porque ela tem duas restrições sérias. Primeiro, ela é pequena — tipicamente alguns megabytes, definidos pelo sistema. Tente declarar um vetor gigante localmente e você estoura a pilha (o famoso stack overflow):

void perigoso(void) {
    int enorme[10000000]; // ~40 MB numa pilha de poucos MB: pode travar
}

Segundo, e mais limitante: o tamanho precisa ser conhecido antecipadamente, e a variável morre quando a função retorna. Mas e se você não sabe, ao escrever o programa, quantos dados vai precisar guardar? E se precisa que um dado sobreviva à função que o criou? Para isso existe o outro bairro.

O heap: flexível, duradouro e manual

O heap é uma grande área de memória livre que você pode requisitar sob demanda, em tempo de execução, na quantidade que precisar. O que você aloca no heap não morre quando a função retorna — ele permanece até que você o libere explicitamente. É essa a diferença que muda tudo: no heap, você controla o tempo de vida.

Essa liberdade tem um preço, e é o preço central do C: no heap, nada é automático. Você pede memória, você a usa, e você precisa devolvê-la. O C não tem coletor de lixo; se você esquecer de devolver, a memória fica presa até o programa terminar — um vazamento. A pilha é o bairro da conveniência; o heap é o bairro da responsabilidade.

Um retorno perigoso: o erro que o heap resolve

Para sentir por que o heap é necessário, veja este bug clássico. Uma função tenta criar um vetor e devolvê-lo:

#include <stdio.h>

int *criar_vetor_errado(void) {
    int v[3] = {1, 2, 3}; // v vive na PILHA de criar_vetor_errado
    return v;              // retorna o endereço de v...
} // ...mas v morre aqui! O endereço agora aponta para lixo.

int main(void) {
    int *p = criar_vetor_errado();
    printf("%d\n", p[0]); // comportamento indefinido: v já não existe
    return 0;
}

O problema: v mora na pilha da função criar_vetor_errado. Quando a função retorna, o frame dela é desempilhado e v deixa de existir. O ponteiro devolvido aponta para um endereço que já foi liberado — um dangling pointer (ponteiro pendurado). O compilador com -Wall até avisa ("function returns address of local variable"), mas o C deixa você fazer. Esse é precisamente o tipo de situação para a qual o heap foi feito: se v fosse alocado no heap, sobreviveria ao retorno da função, e o ponteiro continuaria válido. É o que aprenderemos a fazer na próxima aula.

Comparando os dois bairros

Vale fixar as diferenças, porque toda a próxima fase se apoia nelas. Na pilha: a alocação e a liberação são automáticas, o acesso é muito rápido, o espaço é pequeno, o tamanho precisa ser fixo e conhecido, e o tempo de vida é limitado ao escopo. No heap: a alocação e a liberação são manuais (sua responsabilidade), o acesso é um pouco mais lento, o espaço é grande (limitado pela memória da máquina), o tamanho pode ser decidido em tempo de execução, e o tempo de vida dura até você liberar. Nenhum é "melhor" — cada um serve a um propósito. A arte do C é saber qual usar em cada momento.

Vendo os bairros na prática

Este pequeno programa imprime os endereços de variáveis em regiões diferentes, para você ver que elas moram em lugares distintos da memória:

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

int global = 1; // região de dados globais

int main(void) {
    int local = 2;                       // pilha
    int *no_heap = malloc(sizeof(int));  // heap (veremos malloc já já)
    *no_heap = 3;

    printf("Endereço de global   (dados): %p\n", (void *) &global);
    printf("Endereço de local    (pilha): %p\n", (void *) &local);
    printf("Endereço de no_heap  (heap):  %p\n", (void *) no_heap);

    free(no_heap); // devolvemos o que pedimos
    return 0;
}

Você notará que os três endereços são bem diferentes entre si — muitas vezes o do heap e o da pilha ficam em extremos quase opostos do espaço de endereçamento. É a prova concreta de que "a memória" não é um lugar só, mas um território dividido.

O que vem a seguir

Hoje traçamos o mapa: a pilha, rápida e automática, para o efêmero; o heap, manual e duradouro, para o que precisa crescer sob demanda ou sobreviver a uma função. Vimos também o bug do ponteiro pendurado, que só o heap resolve. Na próxima aula, entramos de fato no heap com as quatro funções que definem o gerenciamento manual de memória em C: malloc, calloc, realloc e free. É a habilidade mais característica — e mais temida — da linguagem, e agora você tem o mapa para exercê-la com consciência.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um programa com três funções aninhadas (main chama a, que chama b, que chama c), cada uma com uma variável local, e imprima o endereço da variável local de cada uma. Observe como os endereços da pilha se aproximam ou se afastam a cada nível.

Ver resposta

✓ Resposta:

#include <stdio.h>
void c(void) { int vc = 3; printf("c: %p\n", (void *) &vc); }
void b(void) { int vb = 2; printf("b: %p\n", (void *) &vb); c(); }
void a(void) { int va = 1; printf("a: %p\n", (void *) &va); b(); }
int main(void) { int vm = 0; printf("main: %p\n", (void *) &vm); a(); return 0; }

Você verá quatro endereços distintos, geralmente descendo (ou subindo) de forma regular a cada chamada — cada frame ocupa um novo pedaço da pilha, empilhado sobre o anterior. A direção (crescente ou decrescente) depende da arquitetura; na maioria dos sistemas comuns, a pilha cresce "para baixo", em direção a endereços menores.

Exercício 2

Explique, com suas palavras, por que uma variável local declarada dentro de uma função não pode ser acessada com segurança depois que a função retorna.

Ver resposta

✓ Resposta: A variável local vive no stack frame da função. Quando a função retorna, esse frame é desempilhado e o espaço fica livre para ser reutilizado pela próxima chamada de função. A variável não é "apagada" — mas o endereço deixa de lhe pertencer, e a qualquer momento outro frame pode sobrescrevê-lo. Acessar esse endereço depois do retorno é ler memória que já não é sua: comportamento indefinido. O valor pode até parecer correto por sorte, e mudar no instante seguinte.

Exercício 3

Compile o exemplo criar_vetor_errado com gcc -Wall. Que aviso exato o compilador emite? Por que ele consegue detectar esse erro em particular?

Ver resposta

✓ Resposta: O gcc -Wall emite algo como "warning: function returns address of local variable [-Wreturn-local-addr]". Ele consegue detectar este caso porque o erro é estático e local: a função retorna literalmente o endereço de uma variável que ela mesma declarou na pilha, e isso é visível só olhando o código da função, sem precisar rastrear a execução. Nem todos os dangling pointers são detectáveis assim — este é um dos casos felizes em que o compilador enxerga o problema.

Exercício 4

Liste duas situações concretas em que você precisaria do heap em vez da pilha, e explique por quê para cada uma.

Ver resposta

✓ Resposta: Duas situações típicas: (a) Tamanho decidido em tempo de execução — se você só descobre quantos elementos precisa quando o programa já está rodando (por exemplo, o usuário informa quantos alunos cadastrar), não pode declarar um vetor de tamanho fixo na pilha; precisa alocar no heap a quantidade exata. (b) Dado que sobrevive à função — se uma função precisa criar uma estrutura e devolvê-la para quem a chamou (como o vetor do exemplo criar_vetor_errado), o dado não pode viver na pilha, pois morreria no retorno; alocado no heap, ele persiste até ser liberado. Estruturas de dados como listas encadeadas (próxima fase) dependem inteiramente disso.

Exercício 5

Descreva o que é um stack overflow e dê um exemplo de código que poderia causá-lo. (Não precisa executar — só raciocinar sobre por que estouraria.)

Ver resposta

✓ Resposta: Um stack overflow ocorre quando a pilha excede o tamanho reservado para ela. Como a pilha é pequena (poucos MB), duas causas comuns são: alocar uma variável local imensa, ou uma recursão infinita ou profunda demais, em que cada chamada empilha um novo frame sem nunca retornar. Exemplo:

void infinita(void) {
    int buffer[1000];
    infinita(); // nunca retorna: empilha frames até estourar
}

Cada chamada de infinita adiciona um frame (com o buffer de 1000 inteiros) à pilha, e nada é desempilhado, porque a função nunca termina. Em pouco tempo a pilha se esgota e o programa é encerrado com um erro.

Comentários

Mais em Linguagem C

Tratamento de Erros Idiomático: errno e Códigos de Retorno
Tratamento de Erros Idiomático: errno e Códigos de Retorno

Sem try nem catch, toda falha em C vira um retorno que alguém precisa conferir…

Listas Duplamente Encadeadas e Circulares
Listas Duplamente Encadeadas e Circulares

Numa lista dupla, toda ligação tem dois lados: fez A->proximo = B, tem de…

Threads com pthreads
Threads com pthreads

contador++ não é uma operação só: são ler, somar e escrever — e duas threads…