Trilha 07 · Assembly e baixo nível

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.

① Intuição

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.

A pilha cresce para baixo: endereços menores ficam mais perto do topo. Quando você faz PUSH, o ESP diminui 4 bytes e o valor é escrito no novo endereço. Por isso "stack overflow" é literalmente ESP chegando a um endereço inválido (abaixo do limite da pilha alocada pelo SO para o processo).
② Visualização interativa

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

← endereços baixos / topo da pilha (ESP)
main← novo frame
resultado = ?
ret: 0x1004EBP: 0x1000
⬆ endereços altos (base da pilha)
CALLPasso 1 / 9

main() começa. Vai chamar factorial(4).

COMO FUNCIONA

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.

③ Explicação técnica

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
Tail call optimization (TCO): se uma função retorna diretamente o resultado de uma chamada recursiva (tail call), o compilador pode substituir o CALL por um JMP — reutilizando o frame atual em vez de empilhar um novo. Isso transforma recursão em iteração, eliminando o risco de stack overflow. GCC com -O2 faz TCO automaticamente para funções C que se qualificam; Clang/Rust também. Python propositalmente não faz TCO.
④ Projeto para programar

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.

⑤ Exercícios rápidos

Teste sua intuição

O que as instruções CALL e RET fazem exatamente?
Em que direção a pilha cresce e como PUSH afeta ESP?
O que causa um stack overflow?
⑥ Aplicações no mundo real

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.

← Anterior: Pipeline de execução Próxima: O que o compilador faz →