ADRIANOLAUREANO← Artigos
LLVM Compiladores Código Nativo Rust

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.

ARTIGO 12 · DA AST AO CÓDIGO NATIVO

4estágios

3artefatos

1executável real

LINHA DE PRODUÇÃO · CADA ESTAÇÃO MUDA A FORMA, NÃO O PROGRAMA
01PULSOmostre(2 + 3)
02LLVM IRfadd · call
03OBJETOELF .o
04EXECUTÁVELcódigo x86-64

VOCABULÁRIO VISUAL

Antes de avançar, conheça as peças deste capítulo.

01Frontendparte que entende a linguagem Pulso
02Backendparte que conhece x86, ARM ou outro alvo
03LLVM IRlinguagem intermediária tipada
04Objetocódigo de máquina ainda incompleto
05Linkerresolve símbolos e combina arquivos
06ABIcontrato binário da plataforma
Você não precisa memorizar agora. Este mapa existe para consultar sempre que um termo reaparecer.
01 /
Não Começaremos do Zero
A AST e a CPU já existem. Vamos traduzir entre elas.

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 ideia que precisa ficar

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.

A GRANDE PONTEUma linguagem nossa, várias arquiteturas reaisO frontend entende a Pulso; o LLVM assume a tradução dependente da máquina.
01AST da Pulsosignificado do programaSoma(2, 3)
02LLVM IRrepresentação intermediáriafadd double
03Arquivo objetomáquina ainda incompletaprograma.o
04Executávelobjeto + bibliotecasELF · PE · Mach-O

Leitura 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.

02 /
O Que o LLVM Faz por Nós
Por que não escrevemos assembly manualmente?

Se quiséssemos gerar código nativo sem o LLVM, precisaríamos:

  1. Escolher uma arquitetura (x86-64? ARM64? RISC-V?)
  2. Aprender o conjunto de instruções dela em detalhe
  3. Gerar bytes de máquina corretos para cada operação
  4. Lidar com alocação de registradores (quem usa rax e quem usa rbx?)
  5. Otimizar o código (remover redundâncias, reordenar instruções)
  6. Gerar um arquivo executável no formato ELF, PE ou Mach-O
  7. 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 LLVMCom LLVM
Uma linguagem → um backend por arquiteturaUma linguagem → LLVM IR → todas as arquiteturas
Alocação manual de registradoresLLVM aloca registradores automaticamente
Otimizações próprias (difíceis e bugadas)Otimizações industriais testadas há décadas
Formato executável gerado na mãoLLVM gera objetos ELF/PE/Mach-O prontos
Debugar código gerado é quase impossívelLLVM produz informações de debug padronizadas
A ideia que precisa ficar

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.

03 /
LLVM IR: A Linguagem Intermediária
Assembly portátil que ainda parece com o que vimos na micro-cpu.

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
Constant 0
Constant 1
Add
Print
Load 0, 0x0100
Load 1, 0x0108
Add 2, 0, 1
Store 2, 0x0110
%a = load i64, ptr %x
%b = load i64, ptr %y
%sum = add i64 %a, %b
store i64 %sum, ptr %z

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.

A ideia que precisa ficar

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.

04 /
Da AST para LLVM IR
Cada nó da árvore vira uma ou mais instruções.
TRÊS FORMAS DO MESMO PROGRAMAA sintaxe desaparece; o significado permaneceLeia da esquerda para a direita e observe o aumento de precisão.
PESSOA ESCREVEPulsovar total = 2 + 3;
COMPILADOR ENTENDEASTVar(total, Add(2, 3))
LLVM RECEBEIR tipada%total = fadd double 2.0, 3.0

Para 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:

  • alloca reserva espaço na stack — como a micro-cpu reservava endereços na RAM
  • fadd soma dois floats — como Add na micro-cpu somava dois registradores
  • store e load movem dados entre memória e valores — como Store e Load da micro-cpu
  • call chama uma função — como Call na VM empilhava um frame
  • ret retorna — como Return desempilhava o frame
A ideia que precisa ficar

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.

05 /
Tipos e Valores no LLVM
Como LLVM representa números, textos e booleanos.

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.

PulsoLLVM IRTamanhoSignificado
numerodouble64 bitsponto flutuante IEEE 754
booleanoi11 bit0 = falso, 1 = verdadeiro
textoptr (ponteiro)64 bitsendereço para bytes na memória
nuloptr null64 bitsponteiro para endereço zero
inteiro genéricoi32, i6432/64 bitsusado 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
A ideia que precisa ficar

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.

06 /
Funções e Blocos Básicos
Como LLVM organiza código em funções e blocos.

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 VMmicro-cpuLLVM 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
A ideia que precisa ficar

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.

07 /
Compilando para Código de Máquina
Do IR ao objeto, do objeto ao executável.

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)
Do código-fonte ao executável: quatro estações, três ferramentas.

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).

A ideia que precisa ficar

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.

08 /
O Executável Real
O que acontece quando rodamos ./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:

  1. O shell chama execve pedindo ao kernel para carregar ./programa
  2. O kernel lê o cabeçalho ELF, valida magic bytes e identifica a arquitetura
  3. O loader mapeia segmentos do arquivo para memória virtual (código, dados, bss)
  4. O linker dinâmico resolve símbolos externos como printf da libc
  5. A CPU começa em _start, a libc inicializa e chama nossa main
  6. Nossa main executa — 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 │
└─────────────┘                   └─────────────┘
Da VM ao silício: a mesma linguagem, caminho completamente diferente.
A ideia que precisa ficar

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.

09 /
Implementação Completa
Código Rust que traduz AST da Pulso para executável via LLVM.

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
Cargo.toml
[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)
src/codegen.rs — gerador de LLVM IR
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();
    }
}
src/main.rs — orquestrando a compilação
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(())
}
O que o compilador faz
  • Recebe a AST da Pulso (nós Expr e Stmt)
  • Cria um módulo LLVM IR em memória usando inkwell
  • Declara printf como função externa da libc
  • Gera main como 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 clang para 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.

10 /
Laboratório: Inspecionando Cada Produto da Compilação
Não aceite o executável como uma caixa-preta: pare o pipeline em cada estágio.

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

SintomaCamada provávelPrimeira pergunta
IR rejeitadoGerador LLVMtipos e terminadores dos blocos estão corretos?
Objeto gerado, link falhaLinker/ABIhá símbolo externo sem implementação?
Executável abre e caiChamada/memóriaassinatura e ponteiros seguem a ABI?
Resultado incorretoAST ou codegena 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.

10 /
O Caminho Até Aqui — e o Runtime que Ainda Falta
Do código-fonte ao silício, antes de funções, closures e memória gerenciada.
09Pulsolinguagem com lexer, parser e AST
10Cadência + mini-vmcompilador e máquina virtual de bytecode
11micro-cpucomo uma CPU real executa instruções
12LLVMgerando código nativo a partir da AST ← você está aqui
O que construímos em quatro artigos

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.

11 /
Perguntas Frequentes
O que costuma ficar depois do primeiro executável nativo.
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.

Baixar código final deste artigo (.zip)Rust · Pulso · Cadência · micro-CPU · LLVM IR

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.