Pilha e chamadas de função
Toda vez que uma função chama outra, um frame é empilhado. Toda vez que retorna, o frame é desempilhado. Esta estrutura é a base de tudo: recursão, exceções, stack traces e stack overflows.
A pilha como caderno de contextos
Imagine um chef que precisa preparar um prato complexo. Ele lembra onde parou (endereço de retorno), coloca os ingredientes necessários à mão (variáveis locais) e anota o que precisa retornar para o próximo passo (valor de retorno em EAX). Quando termina a sub-tarefa, desfaz tudo e volta exatamente onde parou.
A pilha de chamadas (call stack) funciona assim: a instrução CALL empilha o
endereço da próxima instrução (para onde voltar), e RET desempilha e salta
para lá. No meio, cada função cria seu "frame" com variáveis locais e argumentos.
Animação da call stack
Avance passo a passo e observe os frames sendo criados e destruídos. Frames verdes estão sendo criados (CALL); frames vermelhos estão retornando (RET).
factorial(4) — cada chamada empilha um frame com o argumento n
main() começa. Vai chamar factorial(4).
CALL func: empilha EIP (próxima instrução) e salta para func. Cria novo frame.
Prólogo da função: PUSH EBP / MOV EBP, ESP / SUB ESP, N (reserva N bytes para variáveis locais).
Epílogo: MOV ESP, EBP / POP EBP. Restaura o frame do chamador.
RET: POP EIP — pega o endereço de retorno e volta ao chamador.
Prólogo e epílogo de função
; Prólogo e epílogo padrão de função x86-32 ; (cdecl calling convention) minha_funcao: ; === PRÓLOGO === push ebp ; salva o frame pointer do chamador mov ebp, esp ; EBP = topo da pilha = base do novo frame sub esp, 16 ; reserva 16 bytes para variáveis locais ; agora: [ebp-4] = var local 1 ; [ebp-8] = var local 2 ; [ebp+4] = 1º argumento (passado pelo chamador) ; [ebp+8] = 2º argumento ; [ebp+0] = EBP salvo do chamador ; [ebp-4 de ebp salvo] = endereço de retorno ; === CORPO === mov eax, [ebp+8] ; carrega 1º argumento add eax, [ebp+12] ; soma 2º argumento ; resultado em EAX (convenção cdecl) ; === EPÍLOGO === mov esp, ebp ; restaura ESP (descarta variáveis locais) pop ebp ; restaura EBP do chamador ret ; pop EIP → volta ao chamador
Convenções de chamada
// Convenções de chamada — onde ficam os argumentos? // x86-32 cdecl (C padrão Linux/GCC): argumentos na pilha // chamador faz: push 5 // 2º argumento por último (direita → esquerda) push 3 // 1º argumento call add // empilha EIP, salta add esp, 8 // cdecl: CHAMADOR limpa a pilha // x86-64 System V ABI (Linux/macOS): argumentos em registradores // 1º..6º argumento: RDI, RSI, RDX, RCX, R8, R9 // Além do 6º: pilha // Retorno: RAX (e RDX para valores de 128 bits) mov edi, 3 // 1º argumento mov esi, 5 // 2º argumento call add // resultado em EAX/RAX // Windows x64 (fastcall): RCX, RDX, R8, R9 (diferente!) // Shadow space: chamador reserva 32 bytes na pilha antes de CALL // mesmo que todos os args caibam em registradores
-O2 faz TCO automaticamente para
funções C que se qualificam; Clang/Rust também. Python propositalmente não faz TCO.
Inspecionando a pilha
Mini projeto: escreva uma função recursiva em C que imprime o endereço do seu próprio frame (printf("%p\n", &local_var)). Chame-a N vezes recursivamente. Observe como os endereços diminuem a cada nível — a pilha crescendo para baixo. Qual é o passo em bytes entre frames?
Projeto principal: implemente um "stack tracer" em C: use __builtin_return_address(0) (GCC) dentro de cada função para capturar o endereço de retorno. Resolva o símbolo com dladdr() e imprima o nome da função chamadora. Isso é o que backtrace() do glibc faz internamente.
Desafio extra: provoque um stack overflow controlado: escreva uma função recursiva sem caso base e meça quantas chamadas cabem antes do SIGSEGV. Depois compile com ulimit -s unlimited (Linux) e compare. Implemente a mesma função com TCO (retorno direto do resultado recursivo) e veja se o compilador otimiza o stack overflow.
Teste sua intuição
Onde você encontra isso
Stack traces em debuggers
Quando um programa trava, o stack trace mostra a sequência de chamadas que levou ao crash: cada linha é um frame da pilha. Ferramentas como GDB, lldb, e Python traceback percorrem EBP frame por frame (frame chaining), lendo o EBP salvo e o endereço de retorno de cada frame para reconstruir a cadeia de chamadas.
Stack canaries e proteção
Buffer overflow clássico: overflow de variável local sobrescreve o endereço de retorno. Stack canaries são valores aleatórios inseridos pelo compilador entre as variáveis locais e o endereço de retorno. Antes do RET, o canário é verificado — se mudou, há exploração em curso e o processo é abortado (__stack_chk_fail).
Goroutines e coroutines
Go, Rust (async) e Python (asyncio) implementam "pilhas leves" (lightweight stacks). Go começa com 8KB por goroutine e cresce dinamicamente. Isso permite centenas de milhares de goroutines concorrentes. Internamente, a runtime gerencia as pilhas manualmente — salva e restaura o contexto (registradores + pilha) na troca de goroutine, como um scheduler cooperativo.