Como uma Chamada de Função Funciona por Dentro:
Da Stack ao LLVM
Nos artigos anteriores compilamos expressões e variáveis para código nativo. Mas uma linguagem sem funções é apenas uma calculadora com memória. Neste artigo a Pulso ganha funções com parâmetros e retorno — e vamos ver como a mesma mecânica de frames e endereço de retorno que existia na nossa VM e na micro-cpu se materializa em LLVM IR e, finalmente, em silício.
main()soma(2, dobro(3))↑ RET
VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
No artigo “Construindo um compilador nativo do zero com LLVM”, nosso compilador LLVM sabia lidar com:
- variáveis locais (
alloca+store/load) - expressões aritméticas (
fadd,fsub, etc.) - desvios condicionais (
brcom blocos básicos) - chamadas externas como
printf
O que ainda não tínhamos era a capacidade de definir nossas próprias funções, passar argumentos para elas e receber valores de volta. Sem isso, cada programa Pulso era um único bloco main. Vamos corrigir isso agora.
Uma função é apenas um bloco de código nomeado que recebe valores pela stack (ou em registradores) e devolve um valor para quem chamou. A Pulso já sabia fazer isso na VM (via Call e Return). O LLVM faz o mesmo — só que o hardware executa diretamente.
3 · 4arg0 · arg1call @somaa + bret 7Leitura técnica: quem define registradores, alinhamento e preservação de valores é a convenção de chamada da plataforma, também chamada de ABI.
Adicionamos três construções à gramática da Pulso:
fn dobro(x) {
retorne x * 2;
}
fn soma(a, b) {
retorne a + b;
}
fn main() {
var x = soma(3, 4);
var y = dobro(x);
mostre(y); // imprime 14
}
Regras simples:
- Toda função começa com
fn, seguida do nome e dos parâmetros entre parênteses. - O corpo é um bloco entre chaves.
retornedevolve um valor para quem chamou. Se omitido, a função retorna0.0.maincontinua sendo o ponto de entrada do programa.- Funções devem ser declaradas antes de serem chamadas? Não. Nosso compilador fará duas passadas: uma para descobrir os nomes, outra para gerar o corpo.
A sintaxe é deliberadamente minimalista. Não temos tipos explícitos nos parâmetros porque a Pulso é dinamicamente tipada (tudo é double por enquanto). Não temos sobrecarga nem genéricos. O foco é entender a mecânica da chamada.
O parser agora entrega uma AST com três novos tipos de nó:
pub enum Stmt {
Var(String, Box<Expr>),
Print(Box<Expr>),
If { cond: Box<Expr>, then_branch: Vec<Stmt>, else_branch: Vec<Stmt> },
// NOVO:
Function {
name: String,
params: Vec<String>,
body: Vec<Stmt>,
},
Return(Option<Box<Expr>>),
}
pub enum Expr {
Number(f64),
StringLiteral(String),
Boolean(bool),
Variable(String),
Binary { op: String, left: Box<Expr>, right: Box<Expr> },
// NOVO:
Call { name: String, args: Vec<Expr> },
}
Note que Function é um Stmt de nível superior — ele não aparece dentro de outras funções (ainda). Call é um Expr porque uma chamada de função produz um valor: soma(3, 4) vale 7.
Programa
├── Function "dobro" ["x"]
│ └── Return
│ └── Binary "*"
│ ├── Variable "x"
│ └── Number 2.0
├── Function "soma" ["a", "b"]
│ └── Return
│ └── Binary "+"
│ ├── Variable "a"
│ └── Variable "b"
└── Function "main" []
├── Var "x"
│ └── Call "soma"
│ ├── Number 3.0
│ └── Number 4.0
├── Var "y"
│ └── Call "dobro"
│ └── Variable "x"
└── Print
└── Variable "y"
Detalhe importante: “destruir o frame” significa mover o topo lógico da stack. Os bytes podem continuar ali por algum tempo, mas já não pertencem à função encerrada.
Na mini-vm do artigo “Construindo um compilador e uma máquina virtual do zero em Rust”, quando encontrávamos um opcode Call, o que acontecia por baixo?
- Empilhar os argumentos na stack de valores (o chamador fazia isso antes).
- Criar um novo frame na stack de chamadas, guardando:
- o endereço de retorno (onde o
ipdeve voltar quando a função terminar) - o endereço base das variáveis locais da função chamada
- o endereço de retorno (onde o
- Atualizar o
ippara o primeiro opcode da função. - Executar o corpo normalmente, até encontrar
Return. - Desempilhar o frame, restaurar o
ippara o endereço de retorno, e colocar o valor de retorno no topo da stack.
Essa dança é invisível para o programador Pulso, mas é o coração de toda chamada de função em qualquer sistema computacional.
| Estado | Ação da VM | O que muda na stack |
|---|---|---|
Antes do Call | Empilha argumentos | [... args ...] |
Durante Call | Cria frame, salva ip | [... args ...] + [frame] |
| No corpo | Executa instruções | Locais alocadas no frame |
No Return | Calcula valor, limpa frame | Frame removido, valor no topo |
Depois do Call | Continua em quem chamou | [... resultado ...] |
Call e Return são operações de stack. A VM fazia isso explicitamente com Vec<Frame>. A CPU real faz com a stack de hardware (rsp no x86-64). O LLVM abstrai isso em call e ret, mas por baixo a mesma dança acontece.
Nossa micro-cpu do artigo “Como uma CPU funciona por dentro” não implementou Call e Return, mas vimos que ela tinha um Jump. Call nada mais é do que um Jump que guarda o endereço de onde veio.
Em uma CPU real (x86-64, por exemplo):
; Chamador empilha argumentos (convenção de chamada)
push 3.0 ; argumento b
push 4.0 ; argumento a
call soma ; empilha endereço de retorno e salta
; quando 'soma' retornar, o resultado estará em xmm0
; Dentro de 'soma':
soma:
push rbp ; salva frame anterior
mov rbp, rsp ; novo frame base
; ... corpo da função ...
pop rbp ; restaura frame anterior
ret ; desempilha endereço de retorno e salta
Note o trio sagrado:
call=pushendereço de retorno +jmppara funçãoret=popendereço de retorno +jmppara elerbp/rsp= registradores que delimitam o frame atual na stack
A stack de hardware é a mesma ideia da nossa VM. A diferença é que a CPU tem registradores dedicados (rsp, rbp) e instruções específicas (call, ret) para tornar isso extremamente rápido — uma operação de um ciclo de clock.
Vamos ver como o programa exemplo se parece em LLVM IR:
define double @dobro(double %x) {
entry:
%mul = fmul double %x, 2.0
ret double %mul
}
define double @soma(double %a, double %b) {
entry:
%add = fadd double %a, %b
ret double %add
}
define i32 @main() {
entry:
; x = soma(3.0, 4.0)
%call_soma = call double @soma(double 3.0, double 4.0)
; y = dobro(x)
%call_dobro = call double @dobro(double %call_soma)
; mostre(y) → printf
%fmt = call ptr @printf(ptr @fmt_num, double %call_dobro)
ret i32 0
}
Compare os três mundos:
| Nossa VM (PBC1) | Micro-CPU (assembly) | LLVM IR |
|---|---|---|
Call <função> |
call endereço |
call tipo @nome(args) |
Return |
ret |
ret tipo valor |
| Argumentos na stack | Registradores / stack (ABI) | Parâmetros nomeados (%a, %b) |
| Endereço de retorno no frame | Empilhado por call |
Gerenciado automaticamente |
LLVM IR abstrai a stack. Não vemos push e pop explícitos porque o LLVM sabe, de acordo com a ABI (Application Binary Interface) do sistema operacional, se os argumentos devem ir em registradores (x86-64 System V usa %xmm0, %xmm1 para floats) ou na stack. Nosso trabalho é apenas declarar os parâmetros e usá-los.
define cria uma função. call a invoca. ret devolve o controle. O LLVM traduz isso para o par call/ret da CPU real, que por sua vez manipula a stack de hardware. São três níveis de abstração do mesmo conceito.
O desafio ao compilar funções é a referência futura: main pode chamar soma, mas no AST main pode aparecer antes de soma no vetor de statements. O LLVM exige que uma função seja declarada antes de ser chamada.
Por isso usamos duas passadas:
- Passada 1 — Declaração: Percorremos todas as funções e criamos seus protótipos no módulo LLVM (
add_function). Guardamos os ponteiros em um HashMap. - Passada 2 — Implementação: Percorremos novamente, agora gerando o corpo (bloco
entry, alocação de parâmetros, compilação dos statements).
Isso também permite recursão: uma função pode se chamar porque seu próprio nome já está no HashMap quando estamos compilando seu corpo.
O HashMap de funções
pub struct CodeGen<'ctx> {
context: &'ctx Context,
module: Module<'ctx>,
builder: Builder<'ctx>,
variables: HashMap<String, PointerValue<'ctx>>,
// NOVO: funções declaradas no módulo
functions: HashMap<String, FunctionValue<'ctx>>,
}
Passada 1: Declarar protótipos
fn declare_functions(&mut self, stmts: &[Stmt]) {
let f64_type = self.context.f64_type();
for stmt in stmts {
if let Stmt::Function { name, params, .. } = stmt {
// Tipo da função: (double, double, ...) -> double
let param_types: Vec<BasicMetadataTypeEnum> =
params.iter().map(|_| f64_type.into()).collect();
let fn_type = f64_type.fn_type(¶m_types, false);
let fn_val = self.module.add_function(name, fn_type, None);
self.functions.insert(name.clone(), fn_val);
}
}
}
Passada 2: Compilar corpos
fn compile_function(&mut self, name: &str, params: &[String], body: &[Stmt])
-> Result<(), String>
{
let fn_val = *self.functions.get(name)
.ok_or_else(|| format!("função {} não declarada", name))?;
// Cria bloco entry
let entry = self.context.append_basic_block(fn_val, "entry");
self.builder.position_at_end(entry);
// Limpa variáveis do escopo anterior e mapeia parâmetros
self.variables.clear();
for (i, param_name) in params.iter().enumerate() {
// Pega o parâmetro LLVM
let param = fn_val.get_nth_param(i as u32)
.ok_or("parâmetro não encontrado")?
.into_float_value();
// Aloca espaço na stack e armazena o parâmetro
// (isso permite que o parâmetro seja tratado como variável mutável)
let alloca = self.builder.build_alloca(
self.context.f64_type(), param_name
).map_err(|e| e.to_string())?;
self.builder.build_store(alloca, param)
.map_err(|e| e.to_string())?;
self.variables.insert(param_name.clone(), alloca);
}
// Compila o corpo
for stmt in body {
self.compile_stmt(stmt)?;
}
// Se a função não tiver retorno explícito, retorna 0.0
if self.builder.get_insert_block().unwrap().get_terminator().is_none() {
self.builder.build_return(
Some(&self.context.f64_type().const_float(0.0))
).map_err(|e| e.to_string())?;
}
Ok(())
}
Por que alocamos os parâmetros na stack? Em LLVM IR, parâmetros são valores SSA (imutáveis por natureza). Se quisermos permitir que uma função Pulso faça var a = a + 1 (redefinir um parâmetro), precisamos de um endereço de memória. Alocar na stack com alloca e fazer store do parâmetro é o padrão LLVM para "promover parâmetros a variáveis locais".
Duas passadas resolvem o problema da galinha e do ovo. Na primeira, anunciamos que a função existe. Na segunda, usamos essa existência para gerar chamadas e corpos. É o mesmo princípio do patch_jump da Cadência, só que em escala de funções inteiras.
Vamos ver o código completo que compila nosso exemplo. A estrutura do projeto:
pulso-funcoes/
├── Cargo.toml
└── src/
├── main.rs
└── codegen.rs
fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), String> {
match stmt {
// ... Var, Print, If como antes ...
Stmt::Return(expr) => {
let val = match expr {
Some(e) => self.compile_expr(e)?,
None => self.context.f64_type().const_float(0.0),
};
self.builder.build_return(Some(&val))
.map_err(|e| e.to_string())?;
}
Stmt::Function { .. } => {
// Funções são compiladas na passada 2, não aqui
}
}
Ok(())
}
fn compile_expr(&self, expr: &Expr) -> Result<FloatValue<'ctx>, String> {
match expr {
// ... Number, Variable, Binary como antes ...
Expr::Call { name, args } => {
let func = self.functions.get(name)
.ok_or_else(|| format!("função '{}' não encontrada", name))?;
let mut compiled_args: Vec<BasicMetadataValueEnum> = vec![];
for arg in args {
compiled_args.push(self.compile_expr(arg)?.into());
}
let call = self.builder.build_call(
*func, &compiled_args, "calltmp"
).map_err(|e| e.to_string())?;
Ok(call.try_as_basic_value()
.left()
.unwrap()
.into_float_value())
}
}
}
use inkwell::context::Context;
use std::process::Command;
mod codegen;
use codegen::{CodeGen, Expr, Stmt};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// AST do programa:
// fn dobro(x) { retorne x * 2; }
// fn soma(a, b) { retorne a + b; }
// fn main() { var x = soma(3, 4); var y = dobro(x); mostre(y); }
let program = vec![
Stmt::Function {
name: "dobro".to_string(),
params: vec!["x".to_string()],
body: vec![
Stmt::Return(Some(Box::new(Expr::Binary {
op: "*".to_string(),
left: Box::new(Expr::Variable("x".to_string())),
right: Box::new(Expr::Number(2.0)),
}))),
],
},
Stmt::Function {
name: "soma".to_string(),
params: vec!["a".to_string(), "b".to_string()],
body: vec![
Stmt::Return(Some(Box::new(Expr::Binary {
op: "+".to_string(),
left: Box::new(Expr::Variable("a".to_string())),
right: Box::new(Expr::Variable("b".to_string())),
}))),
],
},
Stmt::Function {
name: "main".to_string(),
params: vec![],
body: vec![
Stmt::Var(
"x".to_string(),
Box::new(Expr::Call {
name: "soma".to_string(),
args: vec![Expr::Number(3.0), Expr::Number(4.0)],
}),
),
Stmt::Var(
"y".to_string(),
Box::new(Expr::Call {
name: "dobro".to_string(),
args: vec![Expr::Variable("x".to_string())],
}),
),
Stmt::Print(Box::new(Expr::Variable("y".to_string()))),
],
},
];
let context = Context::create();
let mut codegen = CodeGen::new(&context, "pulso_funcoes");
// Declara printf e as funções do programa
codegen.declare_externals();
codegen.declare_functions(&program);
// Compila cada função
for stmt in &program {
if let Stmt::Function { name, params, body } = stmt {
codegen.compile_function(name, params, body)?;
}
}
// Mostra o IR gerado
println!("=== LLVM IR gerado ===");
codegen.print_ir();
// Emite .o e linka
codegen.emit_object("programa.o")?;
let status = Command::new("clang")
.args(&["programa.o", "-o", "programa", "-lm"])
.status()?;
if status.success() {
println!("\n=== Executável gerado. Rodando... ===");
Command::new("./programa").status()?;
}
Ok(())
}
Compilando e executando
$ cargo run
=== LLVM IR gerado ===
define double @dobro(double %x) {
entry:
%x1 = alloca double, align 8
store double %x, ptr %x1, align 8
%x2 = load double, ptr %x1, align 8
%multmp = fmul double %x2, 2.000000e+00
ret double %multmp
}
define double @soma(double %a, double %b) {
entry:
%a1 = alloca double, align 8
store double %a, ptr %a1, align 8
%b2 = alloca double, align 8
store double %b, ptr %b2, align 8
%a3 = load double, ptr %a1, align 8
%b4 = load double, ptr %b2, align 8
%addtmp = fadd double %a3, %b4
ret double %addtmp
}
define i32 @main() {
entry:
%x = alloca double, align 8
%calltmp = call double @soma(double 3.000000e+00, double 4.000000e+00)
store double %calltmp, ptr %x, align 8
%y = alloca double, align 8
%x1 = load double, ptr %x, align 8
%calltmp2 = call double @dobro(double %x1)
store double %calltmp2, ptr %y, align 8
%y3 = load double, ptr %y, align 8
%printf_call = call i32 (ptr, ...) @printf(ptr @fmt, double %y3)
ret i32 0
}
=== Executável gerado. Rodando...
14
O número 14 foi calculado por três funções nativas. Nenhuma VM interpretou bytecode. O processador executou @soma, depois @dobro, depois @printf — tudo em código de máquina real, com frames de stack reais gerenciados pelo LLVM de acordo com a ABI do sistema.
- Declaração de funções com múltiplos parâmetros
- Retorno de valores via
retorne - Chamada de funções como expressões (
var x = soma(3, 4)) - Encadeamento de chamadas (
dobro(soma(3, 4))) - Duas passadas de compilação (declaração + implementação)
- Cada função vira um
defineLLVM IR independente
Funções introduzem estado temporário e desvios de controle. Por isso um único programa que imprime 14 não cobre os erros mais perigosos. O mesmo compilador precisa preservar a ordem dos argumentos, isolar variáveis locais de chamadas diferentes e restaurar o chamador depois de cada retorno.
- Zero argumentos: prova que o ponto de entrada e o retorno vazio funcionam.
- Vários argumentos: detecta inversões entre
aeb. - Chamadas aninhadas:
dobro(soma(3, 4))exige que resultados virem novos argumentos. - Recursão: fatorial cria vários frames da mesma função sem misturar seus valores locais.
- Erro de aridade: chamar
soma(1)deve falhar na compilação, antes de chegar ao LLVM. - Caminho sem retorno: toda função precisa terminar em
ret; o verificador do LLVM deve confirmar isso.
A recursão é o teste de estresse conceitual
Em fat(4), cada chamada guarda um n diferente e um endereço de retorno diferente. O frame de fat(1) termina primeiro; depois os resultados voltam na ordem inversa: 1, 2, 6 e 24. Visualizar essa descida e subida transforma “recursão” de truque abstrato em movimentação concreta da stack.
Os frames resolvem valores que vivem durante uma chamada. Eles não resolvem uma função devolvida que ainda precisa de uma variável do frame já encerrado. É exatamente aí que entram as closures e o heap do próximo artigo.
As funções que criamos são estáticas: seus parâmetros vivem apenas durante a chamada. Uma closure é uma função que lembra do ambiente onde foi criada — mesmo depois que esse ambiente deixou de existir na stack. Isso exige que variáveis capturadas vivam no heap, não na stack. É o próximo salto: do frame de chamada para a alocação dinâmica de memória.
call nativo.Por que precisamos de duas passadas? Não dá pra compilar de uma vez?
Se main aparece antes de soma no AST, e tentássemos gerar o corpo de main imediatamente, o LLVM ainda não saberia que @soma existe. A primeira passada cria os protótipos (só o nome e os tipos), permitindo que qualquer função referencie qualquer outra. A segunda passada preenche os corpos. Isso também habilita recursão.
Os parâmetros são passados por valor ou por referência?
Por valor. Em LLVM IR, call copia os argumentos para os parâmetros da função chamada. Como usamos double (64 bits), a cópia é barata. Se tivéssemos structs grandes, a ABI poderia passar por referência implicitamente. Para a Pulso atual, tudo é por valor.
E se eu quiser retornar mais de um valor?
LLVM IR permite retornar structs, mas a Pulso ainda não tem tipos compostos. Uma alternativa comum em linguagens simples é retornar um array ou usar parâmetros de saída (ponteiros). Isso será coberto quando introduzirmos structs na série.
Recursão funciona?
Sim. Como declaramos o protótipo da função antes de compilar seu corpo, ela já existe no HashMap functions quando o gerador de código processa seu próprio corpo. Uma chamada recursiva como fn fat(n) { se n <= 1 { retorne 1; } retorne n * fat(n-1); } funciona sem alterações no compilador.
O LLVM não otimiza as alloca dos parâmetros?
Otimiza. A passada mem2reg do LLVM promove automaticamente alloca que só têm um store e um load para registradores SSA. O código de máquina final não faz loads e stores desnecessários. Por isso podemos gerar IR "ingênuo" e confiar no LLVM para limpar.
E funções anônimas (lambdas)?
Elas são o próximo passo — e exigem closures. Uma função anônima sem captura de variáveis externas é tecnicamente só um ponteiro de função. Mas o poder real vem quando ela captura o ambiente: aí precisamos de heap, structs de captura e, eventualmente, garbage collection. É o artigo “Como closures funcionam por dentro: quando a stack não basta”.
PROJETO COMPLETO · CÓDIGO DO CAPÍTULO
Continue exatamente de onde paramos.
O pacote reúne a linguagem Pulso, o compilador Cadência, a máquina virtual, a micro-CPU e os exemplos de LLVM IR usados nesta sequência. O README indica a ordem dos estágios e os comandos para executar cada experiência.