Construindo um Compilador Nativo do Zero:
Da Pulso ao Executável com LLVM
No artigo “Como uma CPU funciona por dentro”, em que fomos da nossa VM ao silício, vimos que uma máquina virtual imita, em software, a anatomia essencial de uma CPU. Agora vamos tirar a VM do caminho. Em vez de interpretar bytecode, vamos pedir ao LLVM que traduza a AST da Pulso para código de máquina — o mesmo código que um processador x86-64 ou ARM64 executa. O LLVM não executa nosso programa: ele produz outra representação, que ainda passa por arquivo objeto, linker, carregador do sistema e só então chega à CPU.
mostre(2 + 3)fadd · callELF .ocódigo x86-64VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
Nos dois artigos anteriores construímos dois mundos:
- Pulso + Cadência: nossa linguagem compilada para bytecode PBC1, executada pela mini-vm
- micro-cpu: uma CPU emulada em Rust que mostrou como registradores, RAM e o ciclo fetch-decode-execute funcionam no silício
O bytecode PBC1 era uma ponte inteligente, mas ainda precisava da mini-vm para rodar. A VM lia cada opcode, decodificava e executava em um loop Rust. Funcionava, mas não era código nativo: o processador real executava o programa da VM, não o programa Pulso.
Neste artigo vamos remover essa camada intermediária. A AST que a Pulso produz será traduzida diretamente para LLVM IR — uma linguagem intermediária que o LLVM transforma em código de máquina real para x86-64, ARM64 ou qualquer arquitetura que o LLVM suporte.
A VM era um tradutor em tempo real: lia bytecode e executava ações. O LLVM é um tradutor antes da execução: transforma nossa AST em instruções de máquina uma única vez, produzindo um arquivo que o sistema operacional carrega e executa diretamente.
Soma(2, 3)fadd doubleprograma.oELF · PE · Mach-OLeitura técnica: o LLVM IR é independente o bastante para otimizações, mas preserva tipos e controle de fluxo suficientes para gerar instruções adequadas ao alvo.
Se quiséssemos gerar código nativo sem o LLVM, precisaríamos:
- Escolher uma arquitetura (x86-64? ARM64? RISC-V?)
- Aprender o conjunto de instruções dela em detalhe
- Gerar bytes de máquina corretos para cada operação
- Lidar com alocação de registradores (quem usa
raxe quem usarbx?) - Otimizar o código (remover redundâncias, reordenar instruções)
- Gerar um arquivo executável no formato ELF, PE ou Mach-O
- Resolver endereços de funções e variáveis no momento da linkagem
Isso é um projeto de anos. O LLVM existe exatamente para evitar que cada linguagem reinvente essa roda.
| Sem LLVM | Com LLVM |
|---|---|
| Uma linguagem → um backend por arquitetura | Uma linguagem → LLVM IR → todas as arquiteturas |
| Alocação manual de registradores | LLVM aloca registradores automaticamente |
| Otimizações próprias (difíceis e bugadas) | Otimizações industriais testadas há décadas |
| Formato executável gerado na mão | LLVM gera objetos ELF/PE/Mach-O prontos |
| Debugar código gerado é quase impossível | LLVM produz informações de debug padronizadas |
O LLVM é uma fábrica de compiladores. Nosso trabalho é traduzir a AST da Pulso para a linguagem que a fábrica entende: LLVM IR. A fábrica faz o resto — otimiza, aloca registradores, gera código de máquina e produz o arquivo executável.
LLVM IR é uma linguagem de representação intermediária. Ela se parece com assembly, mas é independente de arquitetura. O mesmo LLVM IR pode virar código x86-64, ARM64, RISC-V, WebAssembly ou até código para GPUs.
Vamos comparar como a mesma operação se parece nos três mundos que já conhecemos:
| Nossa VM (PBC1) | micro-cpu (registradores) | LLVM IR |
|---|---|---|
|
|
|
Note como LLVM IR é mais parecido com a micro-cpu do que com a stack machine da VM. Ele usa valores nomeados (%a, %sum) em vez de uma pilha implícita. Esses valores são como registradores virtuais: o LLVM decide depois quais registradores físicos da CPU real usará.
Anatomia de uma instrução LLVM IR
%result = add i64 %left, %right
↑ ↑ ↑ ↑ ↑
nome opcode tipo operando operando
destino
%result é um valor virtual (SSA — Static Single Assignment). Cada valor é atribuído exatamente uma vez. Isso simplifica enormemente as otimizações do LLVM.
LLVM IR é o assembly universal. Ele fala em termos que já conhecemos da micro-cpu: carregar da memória, operar em registradores, armazenar de volta. A diferença é que os "registradores" são virtuais e ilimitados — o LLVM decide os reais depois.
var total = 2 + 3;Var(total, Add(2, 3))%total = fadd double 2.0, 3.0Para quem está começando: AST é o mapa do significado. LLVM IR é esse mapa reescrito com operações pequenas, tipos explícitos e destinos nomeados.
Vamos acompanhar a tradução de um programa Pulso simples:
var total = 2 + 3;
mostre(total);
A AST que o parser entrega é a mesma que construímos na linguagem Pulso e reaproveitamos no compilador Cadência:
Programa
├── Var "total"
│ └── Binary Add
│ ├── Number 2.0
│ └── Number 3.0
└── Print
└── Variable "total"
O compilador LLVM percorre essa árvore e emite LLVM IR. Vamos ver o resultado antes de explicar cada parte:
; Declaração da função printf da libc
declare i32 @printf(ptr, ...)
; String de formato para mostrar números
@fmt = private unnamed_addr constant [4 x i8] c"%gA", align 1
; Função principal
define i32 @main() {
entry:
; Aloca espaço para a variável 'total' na stack
%total = alloca double, align 8
; total = 2.0 + 3.0
%sum = fadd double 2.0, 3.0
store double %sum, ptr %total, align 8
; Carrega total para passar a printf
%val = load double, ptr %total, align 8
; Converte double para double (promoção para vararg)
%fmt_ptr = getelementptr [4 x i8], ptr @fmt, i64 0, i64 0
call i32 (ptr, ...) @printf(ptr %fmt_ptr, double %val)
ret i32 0
}
Se você leu o artigo da micro-cpu, reconhece os conceitos:
allocareserva espaço na stack — como a micro-cpu reservava endereços na RAMfaddsoma dois floats — comoAddna micro-cpu somava dois registradoresstoreeloadmovem dados entre memória e valores — comoStoreeLoadda micro-cpucallchama uma função — comoCallna VM empilhava um frameretretorna — comoReturndesempilhava o frame
Cada conceito da micro-cpu tem um equivalente em LLVM IR. Os nomes mudam, a notação muda, mas a semântica é idêntica. Isso não é coincidência: LLVM IR foi desenhado para expressar exatamente o que uma CPU real faz.
A Pulso tem três tipos primitivos: numero (f64), texto (String) e booleano (bool). O LLVM precisa saber o tamanho e a forma de cada um para gerar código correto.
| Pulso | LLVM IR | Tamanho | Significado |
|---|---|---|---|
numero | double | 64 bits | ponto flutuante IEEE 754 |
booleano | i1 | 1 bit | 0 = falso, 1 = verdadeiro |
texto | ptr (ponteiro) | 64 bits | endereço para bytes na memória |
nulo | ptr null | 64 bits | ponteiro para endereço zero |
| inteiro genérico | i32, i64 | 32/64 bits | usado para índices e retornos |
Por que número vira double e não f64?
LLVM usa nomes próprios para tipos. double é o ponto flutuante de 64 bits. float seria 32 bits. Nossa Pulso usa números de 64 bits, então mapeamos para double.
Por que texto vira ptr?
Strings em LLVM são arrays de bytes na memória. Um ptr (ponteiro) guarda o endereço do primeiro caractere. Isso é idêntico a como a micro-cpu usava endereços de 16 bits para apontar para dados na RAM.
Constantes em LLVM IR
Valores literais da Pulso viram constantes LLVM:
; Número: direto na instrução
%x = fadd double 2.0, 3.0
; Texto: array global de bytes
@ola = private constant [4 x i8] c"OlA", align 1
; Booleano: inteiro de 1 bit
%cond = icmp eq double %a, %b ; comparação → i1
LLVM não tem conceito de "string" ou "booleano" de alto nível. Ele tem bytes, ponteiros e bits. Nosso compilador é responsável por traduzir os tipos ricos da Pulso para esses blocos fundamentais — exatamente como a Cadência traduzia a AST para opcodes da VM.
LLVM IR organiza o código em duas hierarquias:
- Funções (
define): equivalentes às funções da Pulso - Blocos básicos (
label:): sequências de instruções que sempre executam do início ao fim sem desvios internos
Um bloco básico é como um parágrafo que não pode ser interrompido no meio. Se há um if, precisamos de três blocos: um para a condição, um para o entao e um para continuar depois.
Exemplo: if/else em LLVM IR
; Pulso: se (a > b) { mostre("maior") } senao { mostre("menor") }
define i32 @main() {
entry:
%a = load double, ptr @a_global
%b = load double, ptr @b_global
%cond = fcmp ogt double %a, %b
br i1 %cond, label %then_block, label %else_block
then_block: ; bloco do entao
call i32 @puts(ptr @str_maior)
br label %merge_block
else_block: ; bloco do senao
call i32 @puts(ptr @str_menor)
br label %merge_block
merge_block: ; continuação
ret i32 0
}
Note como br (branch) é o equivalente aos Jump e JumpIfFalse da nossa VM. A diferença é que em LLVM IR os destinos são rótulos nomeados (%then_block), enquanto na VM eram offsets numéricos no bytecode.
Comparando os três mundos: desvios condicionais
| Nossa VM | micro-cpu | LLVM IR |
|---|---|---|
JumpIfFalse <offset> |
JumpIfFalse <addr> |
br i1 %cond, label %sim, label %nao |
| salta se o topo da stack for falso | salta se registrador for zero | salta baseado em um valor i1 |
| destino é número de bytes | destino é endereço na RAM | destino é rótulo nomeado |
Blocos básicos são a unidade de controle de fluxo do LLVM. Cada bloco termina com uma instrução de desvio (br ou ret). O LLVM otimiza e reordena blocos, mas nunca quebra um bloco no meio. Isso é o equivalente ao patch_jump da Cadência, só que em nível muito mais sofisticado.
LLVM IR é texto. Um processador não entende texto. Precisamos de três passos para chegar a um programa executável:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ AST da │ │ LLVM IR │ │ Arquivo .o │ │ Executável │ │ Pulso │ ──→ │ (texto) │ ──→ │ (objeto) │ ──→ │ (ELF) │ │ │ │ │ │ │ │ │ │ nossa │ │ llc / │ │ clang / │ │ ld / │ │ linguagem │ │ inkwell │ │ llvm::MC │ │ lld │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ Compilador Otimizador Backend Linker (nosso) (LLVM) (LLVM) (sistema)
Passo 1: Gerar LLVM IR (nosso compilador)
Nosso código Rust, usando a crate inkwell, percorre a AST e constrói o módulo LLVM IR em memória.
Passo 2: Otimizar (LLVM Opt)
O LLVM aplica otimizações no IR: remove código morto, propaga constantes, inline de funções pequenas, alocação de registradores. Tudo isso acontece no nível IR, antes de saber qual é a arquitetura final.
Passo 3: Gerar código de máquina (LLVM Backend)
O backend traduz o IR otimizado para instruções x86-64, ARM64, etc. Ele decide quais registradores físicos usar, calcula offsets de stack e emite o código binário.
Passo 4: Linkar (Linker do sistema)
O arquivo .o (objeto) contém código de máquina mas ainda não é executável. O linker resolve endereços de funções externas (como printf da libc), combina múltiplos arquivos objeto e produz o executável final no formato ELF (Linux), PE (Windows) ou Mach-O (macOS).
Nosso trabalho termina no Passo 1. Os passos 2, 3 e 4 são feitos pelo LLVM e pelo linker do sistema operacional. É por isso que o LLVM é tão poderoso: ele nos libera para pensar na linguagem, enquanto cuida de décadas de engenharia de compiladores.
./programa no terminal.No artigo “Como um computador executa um programa por dentro”, vimos como o sistema operacional carrega um executável ELF. Vamos relembrar o que acontece agora que nós estamos produzindo esse executável:
- O shell chama
execvepedindo ao kernel para carregar./programa - O kernel lê o cabeçalho ELF, valida magic bytes e identifica a arquitetura
- O loader mapeia segmentos do arquivo para memória virtual (código, dados, bss)
- O linker dinâmico resolve símbolos externos como
printfda libc - A CPU começa em
_start, a libc inicializa e chama nossamain - Nossa
mainexecuta — agora é código de máquina real, fetch-decode-execute puro
A diferença crucial em relação à VM: não existe mais um programa Rust interpretando opcodes. O processador físico busca as instruções diretamente da memória, as decodifica em circuitos e as executa. Nosso compilador LLVM foi o arquiteto; o silício é o construtor.
ANTES (com VM) AGORA (com LLVM)
┌─────────────┐ ┌─────────────┐
│ programa │ │ programa │
│ .pulso │ │ .pulso │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Cadência │ │ Compilador │
│ (compila) │ │ LLVM │
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ program.pbc │ │ programa │
│ (bytecode) │ │ (executável)│
└──────┬──────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ mini-vm │ │ CPU real │
│ (executa) │ │ (executa) │
│ em Rust │ │ em silício │
└─────────────┘ └─────────────┘
O executável gerado pelo LLVM é um cidadão de primeira classe do sistema operacional. Ele não precisa de interpretador, não precisa de VM, não precisa de runtime especial. É um arquivo binário que o kernel carrega e a CPU executa — exatamente como ls, gcc ou qualquer outro programa nativo.
Vamos usar a crate inkwell — bindings idiomáticos e seguros do LLVM para Rust. Ela nos permite construir módulos LLVM IR, otimizá-los e emitir código de máquina sem tocar na API C diretamente.
pulso-llvm/
├── Cargo.toml
└── src/
├── main.rs
└── codegen.rs
[package]
name = "pulso-llvm"
version = "0.1.0"
edition = "2021"
[dependencies]
inkwell = { version = "0.5", features = ["llvm18-0"] }
# pulso = { path = "../pulso" } # nossa linguagem (lexer + parser + ast)
use inkwell::builder::Builder;
use inkwell::context::Context;
use inkwell::module::Module;
use inkwell::targets::{CodeModel, FileType, InitializationConfig, RelocMode, Target, TargetMachine};
use inkwell::types::BasicMetadataTypeEnum;
use inkwell::values::{BasicMetadataValueEnum, BasicValue, FloatValue, PointerValue};
use inkwell::OptimizationLevel;
use std::collections::HashMap;
// Simplificação da AST da Pulso (mesma do artigo anterior)
pub enum Expr {
Number(f64),
StringLiteral(String),
Boolean(bool),
Variable(String),
Binary { op: String, left: Box<Expr>, right: Box<Expr> },
}
pub enum Stmt {
Var(String, Box<Expr>),
Print(Box<Expr>),
If { cond: Box<Expr>, then_branch: Vec<Stmt>, else_branch: Vec<Stmt> },
}
pub struct CodeGen<'ctx> {
context: &'ctx Context,
module: Module<'ctx>,
builder: Builder<'ctx>,
variables: HashMap<String, PointerValue<'ctx>>,
}
impl<'ctx> CodeGen<'ctx> {
pub fn new(context: &'ctx Context, module_name: &str) -> Self {
Self {
context,
module: context.create_module(module_name),
builder: context.create_builder(),
variables: HashMap::new(),
}
}
// Compila um programa inteiro (lista de statements)
pub fn compile_program(&mut self, stmts: &[Stmt]) -> Result<(), String> {
// Declara printf: i32 printf(ptr, ...)
let i32_type = self.context.i32_type();
let ptr_type = self.context.ptr_type(inkwell::AddressSpace::default());
let printf_type = i32_type.fn_type(&[ptr_type.into()], true);
self.module.add_function("printf", printf_type, None);
// Cria main: i32 main()
let main_type = i32_type.fn_type(&[], false);
let main_fn = self.module.add_function("main", main_type, None);
let entry = self.context.append_basic_block(main_fn, "entry");
self.builder.position_at_end(entry);
// Compila cada statement
for stmt in stmts {
self.compile_stmt(stmt)?;
}
// Retorna 0
self.builder.build_return(Some(&i32_type.const_int(0, false)))
.map_err(|e| e.to_string())?;
Ok(())
}
fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), String> {
match stmt {
Stmt::Var(name, expr) => {
let val = self.compile_expr(expr)?;
let ptr = self.builder.build_alloca(self.context.f64_type(), name)
.map_err(|e| e.to_string())?;
self.builder.build_store(ptr, val)
.map_err(|e| e.to_string())?;
self.variables.insert(name.clone(), ptr);
}
Stmt::Print(expr) => {
let val = self.compile_expr(expr)?;
let printf = self.module.get_function("printf").unwrap();
// Cria string de formato "%g
" como constante global
let fmt = self.builder.build_global_string_ptr("%g
", "fmt")
.map_err(|e| e.to_string())?;
let args: Vec<BasicMetadataValueEnum> = vec![
fmt.as_pointer_value().into(),
val.into(),
];
self.builder.build_call(printf, &args, "printf_call")
.map_err(|e| e.to_string())?;
}
Stmt::If { cond, then_branch, else_branch } => {
let cond_val = self.compile_expr(cond)?;
let fn_val = self.builder.get_insert_block().unwrap().get_parent();
let then_bb = self.context.append_basic_block(fn_val, "then");
let else_bb = self.context.append_basic_block(fn_val, "else");
let merge_bb = self.context.append_basic_block(fn_val, "merge");
// Converte double para i1 (0.0 = falso, != 0.0 = verdadeiro)
let zero = self.context.f64_type().const_float(0.0);
let cmp = self.builder.build_float_compare(
inkwell::FloatPredicate::ONE, // ordered and not equal
cond_val, zero, "ifcond"
).map_err(|e| e.to_string())?;
self.builder.build_conditional_branch(cmp, then_bb, else_bb)
.map_err(|e| e.to_string())?;
// Then block
self.builder.position_at_end(then_bb);
for s in then_branch { self.compile_stmt(s)?; }
self.builder.build_unconditional_branch(merge_bb)
.map_err(|e| e.to_string())?;
// Else block
self.builder.position_at_end(else_bb);
for s in else_branch { self.compile_stmt(s)?; }
self.builder.build_unconditional_branch(merge_bb)
.map_err(|e| e.to_string())?;
// Merge block
self.builder.position_at_end(merge_bb);
}
}
Ok(())
}
fn compile_expr(&self, expr: &Expr) -> Result<FloatValue<'ctx>, String> {
match expr {
Expr::Number(n) => Ok(self.context.f64_type().const_float(*n)),
Expr::Variable(name) => {
let ptr = self.variables.get(name)
.ok_or_else(|| format!("variável '{}' não encontrada", name))?;
let val = self.builder.build_load(self.context.f64_type(), *ptr, name)
.map_err(|e| e.to_string())?;
Ok(val.into_float_value())
}
Expr::Binary { op, left, right } => {
let l = self.compile_expr(left)?;
let r = self.compile_expr(right)?;
match op.as_str() {
"+" => self.builder.build_float_add(l, r, "addtmp")
.map_err(|e| e.to_string()),
"-" => self.builder.build_float_sub(l, r, "subtmp")
.map_err(|e| e.to_string()),
"*" => self.builder.build_float_mul(l, r, "multmp")
.map_err(|e| e.to_string()),
"/" => self.builder.build_float_div(l, r, "divtmp")
.map_err(|e| e.to_string()),
_ => Err(format!("operador desconhecido: {}", op)),
}
}
_ => Err("expressão não suportada ainda".to_string()),
}
}
// Emite código de máquina para um arquivo objeto
pub fn emit_object(&self, path: &str) -> Result<(), String> {
Target::initialize_native(&InitializationConfig::default())
.map_err(|e| e.to_string())?;
let triple = TargetMachine::get_default_triple();
let target = Target::from_triple(&triple)
.map_err(|e| e.to_string())?;
let machine = target.create_target_machine(
&triple,
"generic",
"",
OptimizationLevel::Default,
RelocMode::Default,
CodeModel::Default,
).ok_or("falha ao criar target machine")?;
machine.write_to_file(&self.module, FileType::Object, path.as_ref())
.map_err(|e| e.to_string())?;
Ok(())
}
// Emite LLVM IR como texto (útil para debug)
pub fn print_ir(&self) {
self.module.print_to_stderr();
}
}
use inkwell::context::Context;
use std::process::Command;
mod codegen;
use codegen::{CodeGen, Expr, Stmt};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Programa Pulso de exemplo:
// var total = 2 + 3;
// mostre(total);
let program = vec![
Stmt::Var(
"total".to_string(),
Box::new(Expr::Binary {
op: "+".to_string(),
left: Box::new(Expr::Number(2.0)),
right: Box::new(Expr::Number(3.0)),
}),
),
Stmt::Print(Box::new(Expr::Variable("total".to_string()))),
];
// Compila para LLVM IR
let context = Context::create();
let mut codegen = CodeGen::new(&context, "pulso_module");
codegen.compile_program(&program)?;
// Mostra o LLVM IR gerado (debug)
println!("=== LLVM IR gerado ===");
codegen.print_ir();
// Emite arquivo objeto (.o)
codegen.emit_object("programa.o")?;
println!("
=== Arquivo objeto gerado: programa.o ===");
// Linka com libc para produzir executável
let status = Command::new("clang")
.args(&["programa.o", "-o", "programa", "-lm"])
.status()?;
if status.success() {
println!("=== Executável gerado: ./programa ===");
println!("
Rodando...");
Command::new("./programa").status()?;
} else {
eprintln!("Falha na linkagem");
}
Ok(())
}
- Recebe a AST da Pulso (nós Expr e Stmt)
- Cria um módulo LLVM IR em memória usando inkwell
- Declara
printfcomo função externa da libc - Gera
maincomo ponto de entrada - Aloca variáveis na stack com
alloca - Compila expressões aritméticas para
fadd,fsub, etc. - Gera desvios condicionais com blocos básicos
- Otimiza e emite arquivo objeto (.o) para a arquitetura nativa
- Linka com
clangpara produzir executável ELF
Compilando e rodando
$ cargo run
=== LLVM IR gerado ===
; ModuleID = 'pulso_module'
source_filename = "pulso_module"
declare i32 @printf(ptr, ...)
define i32 @main() {
entry:
%total = alloca double, align 8
%addtmp = fadd double 2.0, 3.0
store double %addtmp, ptr %total, align 8
%total1 = load double, ptr %total, align 8
%printf_call = call i32 (ptr, ...) @printf(ptr @fmt, double %total1)
ret i32 0
}
=== Arquivo objeto gerado: programa.o ===
=== Executável gerado: ./programa ===
Rodando...
5
O número 5 foi impresso por código de máquina real. Não há VM interpretando bytecode. O processador executou instruções x86-64 (ou ARM64, dependendo da sua máquina) que o LLVM gerou a partir da nossa AST.
O projeto da sequência inclui três representações que devem ser comparadas: o arquivo Pulso original, o LLVM IR emitido e o executável final. O valor didático está em provar que as três formas descrevem o mesmo programa, apesar de terem públicos diferentes.
# 1. Gere e leia o LLVM IR
cargo run -- exemplo.pulso --emit-ir programa.ll
# 2. Transforme IR em objeto sem linkar
clang -c programa.ll -o programa.o
# 3. Veja símbolos que ainda precisam ser resolvidos
nm programa.o
# 4. Produza e execute o binário
clang programa.o -o programa
./programa
Como diagnosticar quando algo quebra
| Sintoma | Camada provável | Primeira pergunta |
|---|---|---|
| IR rejeitado | Gerador LLVM | tipos e terminadores dos blocos estão corretos? |
| Objeto gerado, link falha | Linker/ABI | há símbolo externo sem implementação? |
| Executável abre e cai | Chamada/memória | assinatura e ponteiros seguem a ABI? |
| Resultado incorreto | AST ou codegen | a ordem dos operandos foi preservada? |
Essa separação impede o diagnóstico por tentativa e erro. Em vez de culpar “o compilador”, localizamos qual contrato — AST, IR, objeto ou executável — deixou de ser cumprido.
09 — Uma linguagem completa com sintaxe própria, lexer e parser recursivo descendente.
10 — Um compilador que traduz a AST para bytecode PBC1 e uma VM que executa esse bytecode.
11 — Uma CPU emulada que mostrou como registradores, RAM e fetch-decode-execute funcionam no hardware.
12 — Um backend LLVM que gera código de máquina real, produzindo executáveis nativos que rodam diretamente no sistema operacional.
O pipeline nativo está completo, mas a linguagem ainda não. O artigo “Como uma chamada de função funciona por dentro” acrescenta funções, frames e convenções de chamada; o artigo “Como closures funcionam por dentro: quando a stack não basta” transforma funções em valores que capturam ambientes no heap. Essa alocação abre a última dívida deste ciclo: descobrir quais objetos continuam alcançáveis e liberar com segurança os que morreram.
Por que usamos inkwell em vez da API C do LLVM diretamente?
A API C do LLVM é poderosa mas verbosa e insegura (ponteiros brutos, gerenciamento manual de memória). O inkwell oferece bindings seguros e idiomáticos em Rust, com ownership e lifetimes garantidos pelo compilador. É a forma mais produtiva de usar LLVM em Rust.
O executável gerado depende do Rust ou do inkwell para rodar?
Não. O executável gerado contém código de máquina e não precisa do Rust, do Inkwell nem do LLVM instalados para rodar. Neste projeto ele ainda chama funções da biblioteca C, como printf, então o sistema precisa fornecer a biblioteca e o carregador compatíveis com o binário produzido. Outras estratégias de linkedição podem mudar essas dependências.
LLVM IR é interpretado ou compilado?
LLVM IR pode ser ambos. O LLVM inclui um interpretador de IR (lli) que executa o código diretamente, útil para debug. Mas no fluxo normal, o IR é compilado para código de máquina nativo — é isso que fazemos neste artigo.
Por que precisamos do clang para linkar? Não dá para o Rust fazer isso?
O inkwell/LLVM gera arquivos objeto (.o), que contêm código de máquina mas ainda não são executáveis. A linkagem — resolver endereços de funções externas como printf, combinar segmentos, produzir o cabeçalho ELF — é feita por um linker. Usamos clang como linker porque ele já sabe onde encontrar a libc. Também poderíamos usar ld ou lld diretamente.
Como debugar um programa Pulso compilado para nativo?
O LLVM pode gerar informações de debug no formato DWARF (Linux) ou PDB (Windows). Basta habilitar o debug info no inkwell. Depois, use gdb ou lldb normalmente: breakpoints em nomes de funções, inspeção de variáveis, stack traces. O LLVM cuida de mapear os nomes do nosso IR para os endereços do executável.
E se quiséssemos compilar para WebAssembly em vez de x86?
Basta mudar o target triple no TargetMachine. O LLVM suporta WebAssembly nativamente. O mesmo LLVM IR que gera código x86-64 pode virar bytecode WASM com uma única mudança de configuração. É exatamente esse o poder do LLVM: escrevemos o frontend uma vez, e ganhamos dezenas de backends de graça.
O que vem depois?
A série completa cobriu do código-fonte ao executável nativo. Próximos passos naturais incluem: closures (funções que capturam variáveis do escopo pai), structs e tipos definidos pelo usuário, sistema de módulos para programas maiores, JIT compilation para executar Pulso em tempo real, e garbage collection ou ownership system para gerenciamento de memória. Cada um desses tópicos merece seu próprio artigo.
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.