ADRIANOLAUREANO← Artigos

Construindo um compilador e uma máquina virtual do zero em Rust

A nossa linguagem Pulso já entende código. Agora trocaremos apenas seu motor: a mesma AST será transformada em bytecode e executada por uma máquina virtual criada por nós.

10artigo da série
1linguagem preservada
26opcodes
2novos executáveis

01 / NÃO COMEÇAREMOS NOVAMENTE

A linguagem já existe. Vamos trocar somente a forma de executá-la.

var resposta = dobro(21);
mostre(resposta);

No artigo anterior construímos a Pulso, nossa própria linguagem de programação. Criamos o leitor de caracteres, os tokens, a gramática, o parser, a AST, os tipos e o ambiente. No final, um interpretador caminhava pela árvore e executava cada nó imediatamente.

Antes de continuar, vamos relembrar as peças

Você não precisa voltar ao artigo anterior nem decorar siglas. Este pequeno mapa recupera o papel de cada etapa:

TEXTOCódigo-fonteas frases escritas no arquivo .pulso
LEITORLexerpercorre caracteres e reconhece peças
PEÇATokenum trecho classificado: número, nome ou operador
ORGANIZADORParserconfere a gramática e monta a estrutura
MAPA DO PROGRAMAASTAbstract Syntax Tree: árvore que representa o significado do código
NOVO MOTORVMVirtual Machine: computador construído em software

A AST é o ponto de chegada do artigo anterior. Ela não é texto nem resultado: é o mapa estruturado que os dois motores sabem receber.

Uma linguagem, dois motores
01fonte02lexer03tokens04parser05AST
A árvore pode seguir por dois caminhos
ARTIGO ANTERIORInterpretador

visita os nós da AST e executa imediatamente

AST → resultado
NESTE ARTIGOCompilador + VM

traduz a AST, salva o bytecode e executa depois

AST → arquivo compilado → resultado

O ponto de bifurcação é a AST. Tudo antes dela pertence à linguagem; os dois caminhos abaixo são motores diferentes para o mesmo programa.

A IDEIA QUE PRECISA FICAR

A sintaxe, o lexer, os tokens, o parser e a AST continuam exatamente os mesmos. Somente a última seta muda.

02 / POR QUE MEXER NO QUE JÁ FUNCIONA?

O interpretador precisa reler a árvore toda vez.

Imagine uma receita escrita em parágrafos. O interpretador lê um trecho, descobre o que fazer e executa. Na próxima vez, percorre a mesma estrutura novamente. Funciona e é excelente para aprender, mas podemos preparar uma lista muito menor de instruções antes da execução.

INTERPRETARentender enquanto executaa AST permanece no caminho
COMPILARtraduzir antesa VM recebe instruções simples

Os dois caminhos começam na mesma árvore. A diferença é o momento em que a tradução acontece.

Compilar significa transformar uma representação em outra. Não significa obrigatoriamente gerar um executável nativo. Nosso compilador receberá a AST e produzirá um arquivo portátil chamado program.pbc.

A extensão .pbc vem de Pulso Bytecode: é o código da nossa linguagem Pulso já traduzido para as instruções da máquina virtual. Dentro do arquivo, os quatro primeiros caracteres serão PBC1: “Pulso Bytecode, versão 1”. Essa assinatura permite que a máquina virtual reconheça o formato antes de tentar executá-lo.

AGORA O PROJETO PODE RECEBER UM NOME

Chamaremos nosso compilador de Cadência. A linguagem Pulso descreve o que o programa quer fazer; a Cadência organiza essas intenções numa sequência exata de instruções que outra máquina conseguirá acompanhar.

01NOSSA LINGUAGEMPulso entrega a ASTo programa já foi compreendido
02NOSSO COMPILADORCadência cria o PBC1constantes · instruções · funções
03NOSSA MÁQUINAA VM executa os bytespilha · posição · chamadas · resultado
Agora cada nome possui um papel: Pulso é a linguagem, Cadência é o compilador e a VM é a máquina que executa o arquivo.
[dependencies]
pulso = { path = "../../pulso" }

Essa linha torna a continuidade concreta. O projeto cadencia usa diretamente o projeto pulso; lexer, tokens, parser e AST não foram copiados nem reescritos.

03 / UMA LÍNGUA PARA A NOSSA MÁQUINA

Bytecode é código de máquina para uma máquina que inventamos.

CPU significa Unidade Central de Processamento: é o processador físico que executa instruções. x86 e ARM são famílias diferentes de arquiteturas de CPU, cada uma com seu próprio vocabulário de instruções. Se gerássemos código para uma delas, entraríamos agora em registradores, sistemas operacionais e formatos executáveis.

Em vez disso, criaremos uma CPU de software: nossa VM, ou Máquina Virtual. Como fomos nós que inventamos essa máquina, também podemos escolher quais instruções ela entende.

BYTE LIDO0000 0101decimal 5
VMDECODIFICADOR5 significa ADDretire dois valores da stack
EFEITO
23
5
O número não carrega significado sozinho. A tabela da VM decide que operação ele representa.
InstruçãoNúmeroO que a VM faz
Constant1coloca uma constante na pilha
Add5retira dois valores e empilha a soma
Print14retira e mostra o valor do topo
Jump21muda a próxima posição de leitura
Call22abre uma chamada de função
Halt26encerra o programa

Opcode vem de operation code: o número que identifica a operação. Alguns opcodes carregam operandos logo depois. Por exemplo,Constant 3 quer dizer “busque a constante de índice 3”.

IMPLEMENTAÇÃO 01 · CRIANDO O VOCABULÁRIO DA VM

Vamos transformar a tabela em código Rust.

cadencia/src/bytecode.rs
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OpCode {
    Constant = 1,
    Add = 5,
    Print = 14,
    Jump = 21,
    Call = 22,
    Return = 23,
    Halt = 26,
}
01
#[repr(u8)]

Ordena ao Rust que cada variante possa ser representada por um inteiro de oito bits — exatamente um byte.

02
pub enum OpCode

Cria um tipo que só aceita instruções conhecidas. pub permite que a máquina virtual use o mesmo contrato.

03
Add = 5

Fixa explicitamente o número. Se a ordem do enum mudar amanhã, o arquivo antigo continua significando a mesma coisa.

04
Copy

Um opcode é pequeno e não possui dados internos; copiá-lo é tão barato quanto copiar um byte.

O projeto completo possui 26 variantes. Começamos com as sete acima porque já conseguem carregar um valor, somar, imprimir, saltar, chamar, retornar e parar. As demais entram quando a linguagem exigir novas operações.

opcodesconstantesformatocompiladorVM
DO ATARI PARA A PULSO

No livro Atari 2600: do bit ao emulador, vimos que o processador 6507 reconhece opcodes reais: cada número escolhe uma operação elétrica da CPU, como carregar, somar ou saltar. Aqui fazemos a mesma ideia subir um andar. Os opcodes da Cadência não serão executados pelo 6507, pelo x86 ou pelo ARM; serão reconhecidos pela máquina virtual que escreveremos em Rust.

ATARI 2600opcode do 6507hardware decodifica a instrução
PULSO COMPILADAopcode PBC1a nossa VM decodifica a instrução

Mesmo modelo, máquinas diferentes. No Atari a máquina é física; na Pulso ela é um programa Rust.

Portanto, não estamos reutilizando apenas uma palavra conhecida. Estamos reutilizando o mesmo modelo mental: um número pequeno identifica uma ação precisa dentro de uma máquina.

04 / DA EXPRESSÃO À RECEITA MECÂNICA

Uma soma vira três instruções pequenas.

mostre(2 + 3);
1Constant 0empilhe 2
2Constant 1empilhe 3
3Add2 e 3 viram 5
4Printmostre 5

A soma não existe como uma instrução gigante. Ela vira uma pequena coreografia de empilhar, calcular e consumir.

A AST ainda é essencial: ela garante que a multiplicação esteja abaixo da soma correta, que chamadas tenham argumentos e que blocos estejam organizados. O compilador apenas percorre essa árvore uma vez e emite instruções na ordem em que a VM precisará executá-las.

05 / ONDE OS DADOS DO PROGRAMA MORAM?

As instruções dizem o que fazer; a Constant Pool guarda com o que fazer.

Poderíamos escrever os oito bytes do número 42.0 toda vez que ele aparecesse no código. Também poderíamos encaixar uma frase inteira no meio das instruções. A VM conseguiria ler, mas o bytecode ficaria difícil de percorrer: cada instrução teria um tamanho imprevisível.

A solução é separar ações de dados. A sequência de código permanece formada por instruções curtas. Números e textos ficam numa tabela lateral chamada Constant Pool, ou reservatório de constantes.

CONSTANT POOL0 → 2 · 1 → 3 · 2 → “resultado”dados com tamanho variável
BYTECODEConstant 0 · Constant 1 · Addinstruções usam índices pequenos

Dados de um lado, ações do outro. O índice é a ponte compacta entre as duas regiões.

Por que “constante”?

Porque esses valores não mudam dentro do arquivo compilado. Uma variável pode começar em 2 e depois receber 10; a entrada Number(2.0) da Constant Pool continua sendo 2. A variável guarda uma referência momentânea ao valor, não reescreve o programa.

IMPLEMENTAÇÃO 02 · MODELANDO O PROGRAMA COMPILADO

Agora criamos as caixas que existirão antes de virar arquivo.

cadencia/src/bytecode.rs
#[derive(Debug, Clone, PartialEq)]
pub enum Constant {
    Number(f64),
    Text(String),
}

pub struct Function {
    pub name: String,
    pub arity: u16,
    pub local_count: u16,
    pub code: Vec<u8>,
}

pub struct Program {
    pub constants: Vec<Constant>,
    pub globals: u16,
    pub functions: Vec<Function>,
}
01
Constant

Um enum permite que a mesma lista guarde números e textos sem confundir os dois tipos.

02
arity

Aridade é a quantidade de parâmetros esperados. Uma função dobro(numero) possui aridade 1.

03
local_count

Reserva a quantidade correta de espaços locais quando uma chamada começar.

04
Vec<u8>

É o bytecode da função: um vetor em que cada item ocupa exatamente um byte.

05
Program

Reúne tudo que será serializado: reservatório de constantes, quantidade de globais e funções.

opcodesconstantesformatocompiladorVM

O compilador ainda elimina duplicações simples. Antes de inserir uma constante, procura se ela já existe. Duas ocorrências de "Olá" podem apontar para a mesma entrada. É a mesma ideia de deduplicação que encontramos nos objetos do Rastro: conteúdo igual não precisa ocupar espaço duas vezes.

06 / BYTECODE TAMBÉM PRECISA DE UM FORMATO

Um vetor de números não explica onde cada parte começa.

Se salvarmos apenas opcodes, como a VM descobrirá a versão, os textos, os números e os limites de cada função? Voltamos ao mesmo problema dos artigos sobre PNG, arquivadores e bancos: precisamos de um contrato binário.

Anatomia física de program.pbc
4 BYTESPBC1Pulso Bytecode v1
2 BYTESv1contrato
2 BYTESglobaisquantidade
VARIÁVELConstant Poolnúmeros · textos
VARIÁVELFunções + bytecodeframes · opcodes

O arquivo é lido da esquerda para a direita. Cada tamanho informa exatamente onde a próxima região começa.

Constant pool significa reservatório de constantes. Se"Olá" aparece dez vezes, podemos guardá-lo uma vez e usar seu índice. Os inteiros são escritos em little-endian: o byte menos significativo vem primeiro. Isso não muda o número; apenas define a ordem dos seus pedaços no arquivo.

M B C 1 | versão | globais | constantes | funções
4 bytes       2 bytes   2 bytes    variável       variável
IMPLEMENTAÇÃO 03 · ESCREVENDO O PBC1

A função encode percorre o programa na mesma ordem do desenho.

cadencia/src/format.rs
pub fn encode(program: &Program) -> Vec<u8> {
    let mut out = Vec::new();

    out.extend(b"PBC1");
    put_u16(&mut out, 1);
    put_u16(&mut out, program.globals);

    put_u32(&mut out, program.constants.len() as u32);
    for constant in &program.constants {
        match constant {
            Constant::Number(value) => {
                out.push(1);
                out.extend(value.to_le_bytes());
            }
            Constant::Text(value) => {
                out.push(2);
                put_text(&mut out, value);
            }
        }
    }

    put_u16(&mut out, program.functions.len() as u16);
    // Em seguida escrevemos cada função.
    out
}
01
let mut out = Vec::new()

Começamos com um vetor vazio. Cada campo do formato será anexado ao final dele.

02
out.extend(b"PBC1")

O prefixo b transforma “Pulso Bytecode, versão 1” em quatro bytes ASCII, nossa assinatura.

03
Quantidade antes dos itens

Gravamos o tamanho da lista antes de percorrê-la. Assim a VM sabe quantas constantes precisa ler.

04
Tags 1 e 2

Um byte identifica o tipo da entrada: 1 significa número; 2 significa texto. Sem a tag, os bytes não diriam como devem ser interpretados.

05
to_le_bytes()

Converte o f64 em seus oito bytes na ordem little-endian definida pelo formato.

O código completo continua escrevendo nome, aridade, quantidade de variáveis locais, tamanho do bytecode e bytes de cada função. Não há serialização automática: cada campo do desenho possui uma linha correspondente.

opcodesconstantesformatocompiladorVM

07 / DO PROGRAMA EM MEMÓRIA AO ARQUIVO NO DISCO

Serializar é desmontar estruturas em uma sequência de bytes reproduzível.

Enquanto a Cadência está rodando, Program é uma estrutura Rust: possui vetores, textos e enums. O disco não sabe guardar “um enum Rust”. Ele guarda bytes. Precisamos escolher uma representação para cada campo e escrevê-los sempre na mesma ordem. Esse processo é serialização.

Desserializar é o caminho inverso: a VM lê os bytes, valida os limites e reconstrói constantes, funções e instruções em memória.

RUSTProgramstructs, Vec e String
SERIALIZARencode()campos em ordem definida
DISCOprogram.pbcPulso Bytecode
DESSERIALIZARdecode()bytes voltam a estruturas

Serialização e desserialização precisam concordar. Ordem, tamanho e endianess fazem parte do contrato PBC1.

A primeira dezena de bytes

Offset  Hexadecimal                 Significado
0x0000  50 42 43 31                 "PBC1"
0x0004  01 00                       versão 1
0x0006  02 00                       2 espaços globais
0x0008  03 00 00 00                 3 constantes

Offset é a distância desde o primeiro byte. No offset0x0004, já atravessamos os quatro bytes da assinatura. Hexadecimal é apenas uma forma compacta de escrever números; dois algarismos hexadecimais representam exatamente um byte.

Por que 01 00 representa o número 1?

O campo versão possui dois bytes e usa little-endian. Primeiro gravamos a parte menos significativa do número. Para 1, essa parte é01; o byte seguinte é 00. Para258, teríamos 02 01, pois258 = 1 × 256 + 2. A ordem precisa ser combinada entre quem grava e quem lê; não existe uma ordem universal escondida no arquivo.

fn put_u16(out: &mut Vec<u8>, value: u16) {
    out.extend(value.to_le_bytes());
}

u16 é um inteiro sem sinal de 16 bits, portanto ocupa dois bytes.to_le_bytes() separa o número na ordem little-endian.extend() acrescenta os dois bytes ao final do vetor.

IMPLEMENTAÇÃO 04 · LENDO SEM CONFIAR

Reader mantém um cursor e recusa atravessar o final do arquivo.

cadencia/src/format.rs
struct Reader<'a> {
    bytes: &'a [u8],
    at: usize,
}

impl<'a> Reader<'a> {
    fn take(&mut self, amount: usize) -> Result<&'a [u8], String> {
        let end = self.at
            .checked_add(amount)
            .ok_or("tamanho inválido")?;

        let value = self.bytes
            .get(self.at..end)
            .ok_or("arquivo PBC truncado")?;

        self.at = end;
        Ok(value)
    }
}
01
&'a [u8]

Reader não copia o arquivo. Ele mantém uma visão emprestada dos bytes enquanto a leitura durar.

02
at

É o cursor: o offset do próximo byte ainda não consumido.

03
checked_add

Soma sem permitir que um tamanho malicioso faça o inteiro dar a volta.

04
get(self.at..end)

Tenta obter o intervalo com segurança. Se faltarem bytes, devolve erro em vez de acessar memória inexistente.

05
self.at = end

Somente depois de uma leitura válida o cursor avança.

As funções u16(), u32() e text() usamtake(). Assim toda leitura do PBC1 herda a mesma proteção.

08 / A ÚNICA PEÇA SUBSTITUÍDA

O compilador recebe a mesma AST que antes ia ao interpretador.

let tokens = Lexer::new(&source).scan()?;
let ast = Parser::new(tokens).parse()?;

// Antes:
Interpreter::new().run(&ast)?;

// Agora:
let program = Compiler::compile(&ast)?;
fs::write("program.pbc", encode(&program))?;

As duas primeiras linhas vieram intactas do artigo anterior. Isso prova a arquitetura em vez de apenas descrevê-la. O compilador fazpattern matching nos mesmos tipos Expr eStmt que o interpretador recebia.

IMPLEMENTAÇÃO 05 · ABRINDO A OFICINA DA CADÊNCIA

O compilador precisa acumular constantes, globais e funções.

cadencia/src/compiler.rs
pub struct Compiler {
    constants: Vec<Constant>,
    globals: BTreeMap<String, u16>,
    function_ids: BTreeMap<String, u16>,
}

struct FunctionCompiler {
    code: Vec<u8>,
    locals: BTreeMap<String, u16>,
}
01
constants

Recebe os números e textos encontrados enquanto percorremos a AST.

02
BTreeMap<String, u16>

Relaciona um nome humano, como total, a um slot numérico, como 0.

03
function_ids

Faz com funções o mesmo que globals faz com variáveis: transforma nomes em identificadores compactos.

04
FunctionCompiler

Cada função possui seu próprio vetor de código e sua própria tabela de variáveis locais.

pub fn compile(ast: &AstProgram) -> Result<Program, String> {
    let mut compiler = Self {
        constants: Vec::new(),
        globals: BTreeMap::new(),
        function_ids: BTreeMap::new(),
    };

    let main = compiler.compile_main(ast)?;

    Ok(Program {
        constants: compiler.constants,
        globals: compiler.globals.len() as u16,
        functions: vec![main],
    })
}

A primeira versão compila apenas o programa principal. Mais adiante cadastraremos as funções declaradas antes de compilar seus corpos. Esse passo intermediário é importante: já conseguimos produzir um Programválido sem esconder chamadas e frames no mesmo momento.

opcodesconstantesformatocompiladorVM

09 / ACOMPANHANDO UMA FRASE INTEIRA

Vamos seguir o mesmo programa por todas as representações.

var total = 2 + 3;
mostre(total);

O lexer do artigo anterior separa o texto em tokens. O parser reconhece uma declaração seguida de uma chamada de impressão e entrega esta ideia à AST:

A árvore mostra dependência, não ordem de bytes. A Cadência percorre essa hierarquia para produzir uma sequência linear.

A Cadência percorre o nó Var. Antes de guardar a variável, precisa produzir seu valor. Por isso visita Add, que primeiro visita 2, depois 3 e só então emite a soma. O resultado fica no topo da stack eStoreGlobal 0 o transfere para o espaço global de índice zero.

OffsetInstruçãoEstado depoisMotivo
0Constant 0stack: [2]lado esquerdo da soma
3Constant 1stack: [2, 3]lado direito
6Addstack: [5]substitui operandos pelo resultado
7StoreGlobal 0global[0]: 5a etiqueta “total” vira slot 0
10LoadGlobal 0stack: [5]recupera o valor
13Printstack: []consome e mostra 5
14Haltencerradonão há outro comando

Onde foi parar o nome “total”?

Durante a compilação, um BTreeMap relaciona"total" ao slot 0. A VM não precisa pesquisar textos durante cada acesso; recebe diretamente o número do espaço. O nome ajuda pessoas e o compilador. O índice ajuda a máquina virtual a encontrar o valor rapidamente.

CÓDIGO-FONTEtotalnome humano
COMPILADOR“total” → 0tabela de símbolos
BYTECODELoadGlobal 0endereço compacto

O nome desaparece do caminho quente. Depois da compilação, encontrar a variável significa acessar diretamente o slot 0.

IMPLEMENTAÇÃO 06 · TRADUZINDO NÓ POR NÓ

Agora a tabela visual vira os métodos statement e expression.

cadencia/src/compiler.rs
fn expression(
    &mut self,
    expr: &Expr,
    function: &mut FunctionCompiler,
) -> Result<(), String> {
    match expr {
        Expr::Number(value) => {
            let id = self.constant(Constant::Number(*value));
            emit_u16(&mut function.code, OpCode::Constant, id);
        }
        Expr::Binary { left, operator, right } => {
            self.expression(left, function)?;
            self.expression(right, function)?;

            let opcode = match operator {
                BinaryOp::Add => OpCode::Add,
                BinaryOp::Multiply => OpCode::Multiply,
                _ => return Err("operador ainda não implementado".into()),
            };

            emit(&mut function.code, opcode);
        }
        _ => return Err("expressão ainda não implementada".into()),
    }

    Ok(())
}
01
match expr

Abre uma regra de tradução para cada tipo de nó existente na AST da Pulso.

02
Número → Constant

O valor entra no reservatório e o bytecode recebe apenas seu índice.

03
Esquerda antes da direita

A ordem coloca os dois operandos na stack exatamente como Add espera encontrá-los.

04
Erro para o que falta

A versão intermediária recusa nós ainda não implementados. Ela não finge ter compilado corretamente.

Stmt::Var { name, initializer } => {
    self.expression(initializer, function)?;
    let slot = slot_for(&mut self.globals, name);
    emit_u16(&mut function.code, OpCode::StoreGlobal, slot);
}

Stmt::Print(expr) => {
    self.expression(expr, function)?;
    emit(&mut function.code, OpCode::Print);
}

A declaração primeiro produz o valor e depois o guarda. A impressão primeiro produz o valor e depois o consome. Essa repetição revela a regra geral do compilador: compile os dados necessários antes de emitir a ação que os utiliza.

10 / COMO BYTECODE ESCOLHE UM CAMINHO?

Condições e repetições nascem de saltos.

No interpretador, um if era um comando Rust escolhendo qual nó visitar. No bytecode linear não existem galhos físicos. Criamos os galhos mudando o endereço da próxima instrução.

0–5avaliar condição
falsoJumpIfFalse 18pular o bloco
verdadeiro6–17 · executar blocoseguir normalmente

Um salto não move bytes. Ele muda apenas o número que aponta para a próxima instrução.

Para enquanto, guardamos o endereço do começo, compilamos a condição e o corpo, e emitimos um Jump de volta. O compilador deixa temporariamente quatro bytes vazios para o destino e os corrige quando descobre onde o bloco termina. Esse processo é chamado de patch de salto.

IMPLEMENTAÇÃO 07 · RESERVAR AGORA, CORRIGIR DEPOIS

O compilador ainda não conhece o destino quando encontra o if.

cadencia/src/compiler.rs
fn emit_jump(code: &mut Vec<u8>, opcode: OpCode) -> usize {
    emit(code, opcode);

    let operand_at = code.len();
    code.extend(0u32.to_le_bytes());

    operand_at
}

fn patch_jump(code: &mut [u8], operand_at: usize) {
    let target = code.len() as u32;
    code[operand_at..operand_at + 4]
        .copy_from_slice(&target.to_le_bytes());
}
01
emit(code, opcode)

Grava o byte de Jump ou JumpIfFalse.

02
Quatro zeros

Reservam o lugar do destino u32. Ainda não sabemos qual será o offset final.

03
operand_at

Guarda onde os quatro zeros começaram para que possamos encontrá-los novamente.

04
patch_jump

Depois de compilar o bloco, substitui os zeros pelo offset atual — o primeiro byte depois do bloco.

Para um while, além do salto de saída existe um salto de volta. O destino desse segundo salto já é conhecido: o offset onde a condição começou.

11 / CONSTRUINDO UMA CPU DE SOFTWARE

A máquina virtual é um computador pequeno vivendo dentro do nosso programa.

Anatomia da nossa máquina virtual
VMloop de execuçãobuscar · decodificar · executar
PRÓXIMA INSTRUÇÃOProgram Counterip = 12
MESA DE TRABALHOStack[ 2 · 3 ]
DADOS FIXOSConstant Pool0→2 · 1→3
MEMÓRIA LONGAGlobalstotal→5
CHAMADAS ATIVASFramesmain → dobro

A VM é estado mais regras. As caixas guardam o estado; os opcodes determinam como ele pode mudar.

Program Counter significa contador do programa: guarda a posição da próxima instrução. No código, usamos o nome ip, abreviação deinstruction pointer, ou ponteiro de instrução. Se vale 12, o próximo opcode está no byte 12. Ler um byte aumenta o índice. Um salto troca o índice pelo destino.

IMPLEMENTAÇÃO 08 · DANDO MEMÓRIA À MÁQUINA

A ilustração da VM agora vira estruturas Rust.

mini-vm/src/vm.rs
pub enum Value {
    Number(f64),
    Text(String),
    Bool(bool),
    Nil,
}

struct Frame {
    function: usize,
    ip: usize,
    locals: Vec<Value>,
}

pub struct Vm {
    program: Program,
    stack: Vec<Value>,
    globals: Vec<Value>,
    frames: Vec<Frame>,
    output: Vec<String>,
}
01
Value

É o valor vivo durante a execução. Não é a mesma coisa que Constant: variáveis e resultados também passam por aqui.

02
Frame

Guarda qual função está ativa, em qual instrução ela parou e quais são seus valores locais.

03
program

É o PBC1 já desserializado: constantes, funções e bytecode disponíveis em memória.

04
stack e frames

São duas pilhas diferentes: uma guarda valores temporários; a outra guarda chamadas de função.

05
output

Além de imprimir, armazenamos a saída para que os testes consigam conferir o resultado.

pub fn new(program: Program) -> Self {
    let globals = vec![Value::Nil; program.globals as usize];

    Self {
        program,
        stack: Vec::new(),
        globals,
        frames: vec![Frame {
            function: 0,
            ip: 0,
            locals: Vec::new(),
        }],
        output: Vec::new(),
    }
}

A função zero é o programa principal. Por isso a VM já nasce com um frame apontando para ela e com o contador de instruções em zero.

opcodesconstantesformatocompiladorVM

12 / A MESA DE TRABALHO DA VM

A stack é uma pilha: o último valor colocado é o primeiro retirado.

Pense numa pilha de pratos. Você coloca e retira pelo topo. Para calcular2 + 3, a VM empilha 2, empilha 3, retira 3, retira 2 e devolve 5. Esse modelo evita escolher registradores e torna cada instrução pequena.

A stack durante Constant 2, Constant 3 e Add
FOTOGRAFIA 01Constant 2
2
topo
FOTOGRAFIA 02Constant 3
32
3 está no topo
FOTOGRAFIA 03Add
5
2 e 3 foram consumidos

A stack cresce e encolhe a cada instrução. O desenho deve ser lido de cima para baixo, como três fotografias consecutivas.

IMPLEMENTAÇÃO 09 · ENSINANDO A VM A SOMAR

Primeiro criamos as operações básicas da pilha.

mini-vm/src/vm.rs
fn pop(&mut self) -> Result<Value, String> {
    self.stack.pop().ok_or("stack vazia".into())
}

fn binary(
    &mut self,
    operation: impl Fn(Value, Value) -> Result<Value, String>,
) -> Result<(), String> {
    let right = self.pop()?;
    let left = self.pop()?;
    let result = operation(left, right)?;
    self.stack.push(result);
    Ok(())
}
01
pop() pode falhar

Um bytecode inválido talvez peça uma soma sem dois valores. Em vez de quebrar, a VM devolve “stack vazia”.

02
Direita sai primeiro

O último valor empilhado corresponde ao operando direito. Isso importa em 10 - 3.

03
Função recebida

binary coordena a pilha; a pequena função operation decide se faremos soma, comparação ou outra operação.

OpCode::Add => self.binary(|left, right| {
    match (left, right) {
        (Value::Number(a), Value::Number(b)) =>
            Ok(Value::Number(a + b)),

        (Value::Text(a), Value::Text(b)) =>
            Ok(Value::Text(a + &b)),

        _ => Err("soma exige dois números ou dois textos".into()),
    }
})?,

A linguagem Pulso permite somar números e concatenar textos. A VM precisa preservar essa mesma regra; trocar o motor não pode mudar o significado da linguagem.

13 / O CORAÇÃO QUE NUNCA PARA

Buscar, decodificar, executar — até encontrar Halt.

O ciclo que mantém a VM viva
1FETCHbuscarler code[ip]
2DECODEdecodificarbyte vira opcode
3EXECUTEexecutaralterar o estado
atualizar IP e buscar novamente

Este ciclo é o pulso da máquina virtual. Halt é a única instrução que não volta ao começo.

loop {
    let byte = self.read_u8()?;
    match OpCode::try_from(byte)? {
        OpCode::Constant => { /* empilhar */ }
        OpCode::Add => { /* retirar, somar, empilhar */ }
        OpCode::Jump => { /* mudar ip */ }
        OpCode::Halt => return Ok(self.output),
        // ...
    }
}

Fetch busca, decode descobre qual instrução o número representa e execute aplica seu efeito. CPUs reais também são explicadas por esse ciclo. A eletrônica é muito mais sofisticada, mas a pergunta central é a mesma: qual é a próxima instrução e o que ela manda fazer?

IMPLEMENTAÇÃO 10 · FAZENDO O PRIMEIRO CICLO

Começamos com três opcodes: Constant, Print e Halt.

mini-vm/src/vm.rs
pub fn run(mut self) -> Result<Vec<String>, String> {
    loop {
        let byte = self.read_u8()?;
        let opcode = OpCode::try_from(byte)?;

        match opcode {
            OpCode::Constant => {
                let id = self.read_u16()? as usize;
                let constant = self.program.constants
                    .get(id)
                    .ok_or("constante inexistente")?;

                let value = match constant {
                    Constant::Number(n) => Value::Number(*n),
                    Constant::Text(text) => Value::Text(text.clone()),
                };

                self.stack.push(value);
            }
            OpCode::Print => {
                let value = self.pop()?;
                self.output.push(display(&value));
            }
            OpCode::Halt => return Ok(self.output),
            _ => return Err("opcode ainda não implementado".into()),
        }
    }
}
01
read_u8()

Lê o byte no offset atual e avança o Program Counter em um.

02
try_from(byte)

Converte um número cru para OpCode e recusa valores fora de 1 a 26.

03
read_u16()

Constant possui um operando de dois bytes: o índice dentro da Constant Pool.

04
Constant vira Value

O dado imutável do arquivo é clonado para a área de valores vivos da execução.

05
Halt retorna a saída

Encerrar não é erro. É o caminho normal que sai do loop e entrega o que o programa imprimiu.

Neste ponto já podemos executar mostre("Olá");. A soma entra ao substituir o erro provisório pelo braço OpCode::Add que acabamos de construir.

14 / “MEMÓRIA” NÃO É UMA COISA SÓ

Cada região guarda valores com uma duração e uma finalidade diferentes.

Dizer apenas que a VM “tem memória” esconde decisões importantes. O texto"Olá" pertence ao programa e nunca muda. Uma variável global pode mudar e precisa sobreviver a chamadas. Um parâmetro só existe enquanto sua função está ativa. Um resultado intermediário talvez viva por apenas duas instruções.

RegiãoGuardaQuanto tempo viveExemplo
Constant Poolliterais imutáveistodo o programa42, "Olá"
Globalsvariáveis globaisaté Haltresposta
Locals do frameparâmetros e variáveis locaisaté Returnnumero
Stack de valoresoperandos temporáriosaté serem consumidosos dois lados de uma soma
Stack de frameschamadas ativasaté cada função retornarmain → dobro

“Memória da VM” é o conjunto destas regiões. Separá-las torna visível quem cria, usa e encerra cada valor.

IMPLEMENTAÇÃO 11

Agora ensinamos a VM a guardar e recuperar variáveis.

mini-vm/src/vm.rs
OpCode::LoadGlobal => {
    let slot = self.read_u16()? as usize;
    let value = self.globals
        .get(slot)
        .ok_or("global inválida")?
        .clone();
    self.stack.push(value);
}
OpCode::StoreGlobal => {
    let slot = self.read_u16()? as usize;
    self.globals[slot] = self.pop()?;
}
OpCode::LoadLocal => {
    let slot = self.read_u16()? as usize;
    let value = self.frame()?
        .locals
        .get(slot)
        .ok_or("local inválida")?
        .clone();
    self.stack.push(value);
}
OpCode::StoreLocal => {
    let slot = self.read_u16()? as usize;
    let value = self.pop()?;
    self.frame_mut()?.locals[slot] = value;
}
01
Slot é uma gaveta numerada

O compilador transforma o nome resposta em um número como 0. A VM trabalha com essa posição, não com o texto do nome.

02
Load copia para a stack

Carregar uma variável significa buscar seu valor na gaveta e colocá-lo no local em que a próxima instrução espera encontrá-lo.

03
Store faz o caminho inverso

O valor calculado está no topo da stack. pop() o retira e a atribuição o guarda no slot indicado.

04
Global ou local?

Globais pertencem à VM inteira. Locais pertencem apenas ao frame da função que está sendo executada.

O QUE JÁ FUNCIONAliterais → contas → impressão → variáveis globais e locais

Essa separação impede que tudo vire um grande vetor sem significado. Também prepara conceitos futuros: linguagens reais precisam decidir onde alocar objetos, quando liberá-los e como um coletor de lixo descobre o que ainda está vivo.

15 / UMA FUNÇÃO INTERROMPE UMA HISTÓRIA E COMEÇA OUTRA

Frames guardam o ponto de retorno e as variáveis de cada chamada.

Quando dobro(21) é chamado, a VM não pode esquecer onde estava. Ela cria um frame, uma ficha da chamada contendo a função, seu próprio Program Counter e suas variáveis locais. O argumento 21 ocupa o espaço do parâmetro numero.

MAINCall dobro, 1pausa no próximo byte
NOVO FRAMEnumero = 21IP começa em zero
RETURN42remove o frame
MAINcontinua42 fica na stack

Call troca o contexto; Return o restaura. O resultado atravessa a fronteira pela stack.

Por que uma pilha de frames?

Porque uma função pode chamar outra, que pode chamar uma terceira. A chamada mais recente precisa terminar primeiro — exatamente o comportamento de uma pilha. Se principal chama dobro, guardamos o frame de principal embaixo e colocamos dobro no topo. Return remove somente o topo e revela o ponto em que principal estava pausada.

FRAME 0<main> · IP 18aguarda o resultado
sobre ele
FRAME 1dobro · IP 0 · numero=21função em execução
IMPLEMENTAÇÃO 12

Call cria um frame; Return devolve o resultado ao chamador.

mini-vm/src/vm.rs
OpCode::Call => {
    let function = self.read_u16()? as usize;
    let argc = self.read_u8()? as usize;
    let meta = self.program.functions
        .get(function)
        .ok_or("função inválida")?;

    if argc != meta.arity as usize {
        return Err("quantidade de argumentos incorreta".into());
    }

    let mut locals = vec![Value::Nil; meta.local_count as usize];
    for slot in (0..argc).rev() {
        locals[slot] = self.pop()?;
    }

    self.frames.push(Frame {
        function,
        ip: 0,
        locals,
    });
}
OpCode::Return => {
    let value = self.pop()?;
    self.frames.pop();

    if self.frames.is_empty() {
        return Ok(self.output);
    }

    self.stack.push(value);
}
01
Dois operandos acompanham Call

O u16 identifica a função. O u8 informa quantos argumentos a chamada colocou na stack.

02
Arity é a quantidade esperada

Se dobro declara um parâmetro, sua aridade é 1. Chamar sem argumento ou com dois deve produzir erro.

03
Os argumentos viram variáveis locais

O laço retira os valores da stack na ordem inversa e os coloca nos primeiros slots do novo frame.

04
O novo IP começa em zero

A função chamada começa em sua primeira instrução. O IP do frame anterior permanece guardado onde parou.

05
Return remove somente o frame atual

O valor retornado volta para a stack. Assim, a função anterior continua e pode consumir o resultado.

O QUE JÁ FUNCIONAvariáveis → desvios → laços → funções → retorno

16 / E SE O ARQUIVO ESTIVER QUEBRADO?

Executar bytes sem validar seria entregar o volante a dados desconhecidos.

Um arquivo pode ser truncado durante uma cópia, alterado manualmente ou produzido por outra versão da Cadência. A VM precisa desconfiar dele. Antes do primeiro opcode, decode() confirma identidade, versão, tamanhos e limites.

MAGICPBC1?é um Pulso Bytecode?
VERSÃO1?conhecemos estas regras?
LIMITEShá bytes suficientes?evitar leitura além do arquivo
REFERÊNCIASíndices existem?constante, global e função válidas
OPCODE1…26?operação reconhecida

A validação acontece em camadas. Passar pela assinatura não significa que índices e opcodes internos estejam corretos.

if reader.take(4)? != b"PBC1" {
    return Err("magic bytes não são PBC1".into());
}

if reader.u16()? != VERSION {
    return Err("versão PBC incompatível".into());
}

take(n) não presume que os bytes existem. Calcula o final, tenta obter o intervalo e devolve erro se o arquivo acabar cedo. Essa é a diferença entre um parser binário e um conjunto de acessos otimistas ao vetor.

IMPLEMENTAÇÃO 13

O nome PBC1 finalmente aparece nos quatro primeiros bytes.

cadencia/src/format.rs

PBC é a abreviação de Pulso Bytecode. O número 1 identifica a primeira versão das regras. Ele não é uma tecnologia externa: é o nome que acabamos de dar ao formato binário criado para nossa própria linguagem.

// bytecode.rs
pub const MAGIC: &[u8; 4] = b"PBC1";
pub const VERSION: u16 = 1;

// O início de decode(), em format.rs
let mut r = Reader { bytes, at: 0 };

if r.take(4)? != MAGIC {
    return Err("magic bytes não são PBC1".into());
}
if r.u16()? != VERSION {
    return Err("versão PBC incompatível".into());
}

let globals = r.u16()?;
let mut constants = Vec::new();
01
b"PBC1" são bytes

O prefixo b pede ao Rust uma sequência binária, não uma String. No arquivo veremos os valores hexadecimais 50 42 43 31.

02
Magic identifica a família

Esses quatro bytes respondem “que tipo de arquivo é este?” antes de tentarmos interpretar o restante.

03
Version identifica as regras

Uma futura PBC2 poderá mudar o layout sem fazer uma VM antiga interpretar bytes com significado errado.

04
Só depois vem o conteúdo

Constantes, funções e opcodes são lidos apenas quando identidade e versão foram aceitas.

UMA VM DIDÁTICA AINDA PRECISA SER DEFENSIVA

Formato simples não significa formato sem validação. Quanto menos confiável a entrada, mais explícitas devem ser as fronteiras.

17 / COMO ENXERGAR O QUE A CADÊNCIA PRODUZIU?

Um disassembler troca números por nomes legíveis.

O arquivo hexadecimal mostra a verdade física, mas é cansativo para estudar fluxo. Um disassembler percorre o bytecode sem executá-lo e imprime cada offset, opcode e operando. Ele não recupera o código-fonte original; cria uma visão humana das instruções compiladas.

0000  CONSTANT       0   ; 2
0003  CONSTANT       1   ; 3
0006  ADD
0007  STORE_GLOBAL   0
0010  LOAD_GLOBAL    0
0013  PRINT
0014  HALT

Os números à esquerda são offsets, não números de linha. Repare queConstant ocupa três bytes: um para o opcode e dois para o índice.Add ocupa apenas um. Por isso os offsets saltam de 0 para 3, de 3 para 6 e depois para 7.

Essa ferramenta mental é tão importante quanto a VM. Quando o resultado estiver errado, podemos perguntar: a AST estava correta? A Cadência emitiu os opcodes corretos? A VM executou corretamente? O disassembler separa a segunda pergunta das outras duas.

18 / DOIS PROJETOS, UMA ÚNICA LINGUAGEM

Todo o código usado no artigo está disponível abaixo.

pulso-bytecode/
├── Cargo.toml
├── cadencia/
│   ├── Cargo.toml
│   └── src/
│       ├── bytecode.rs
│       ├── compiler.rs
│       ├── format.rs
│       ├── lib.rs
│       └── main.rs
├── mini-vm/
│   ├── Cargo.toml
│   └── src/
│       ├── lib.rs
│       ├── main.rs
│       └── vm.rs
└── examples/
    └── programa.pulso

Abra cada arquivo para acompanhar a implementação completa. O compilador usa o projeto projects/pulso do artigo “Construindo uma linguagem de programação do zero em Rust”; não existe uma segunda cópia do lexer ou do parser escondida aqui.

Baixar Pulso, compilador e máquina virtual completos
LINGUAGEMpulsolexer → tokens → parser → AST
TRADUÇÃOcadenciaAST → constantes → opcodes → PBC1
EXECUÇÃOmini-vmarquivo → frames → stack → resultado

Leia o projeto na direção dos dados. A Pulso entrega a árvore, a Cadência cria o arquivo e a VM consome o resultado.

Os painéis abaixo trazem os arquivos completos, não versões resumidas. Comece por bytecode.rs, que define o vocabulário da máquina. Depois leiacompiler.rs e format.rs. Só então abravm.rs: nessa ordem, cada tipo usado já terá sido apresentado.

01
Cargo.toml

Reúne compilador e VM no mesmo workspace

Ver código completo de Cargo.toml
[workspace]
members = ["cadencia", "mini-vm"]
resolver = "2"
02
cadencia/Cargo.toml

Declara o compilador Cadência e sua dependência da Pulso

Ver código completo de cadencia/Cargo.toml
[package]
name = "cadencia"
version = "0.1.0"
edition = "2021"

[dependencies]
pulso = { path = "../../pulso" }
03
cadencia/src/bytecode.rs

Define opcodes, constantes, funções e programa

Ver código completo de cadencia/src/bytecode.rs
pub const MAGIC: &[u8; 4] = b"PBC1";
pub const VERSION: u16 = 1;

#[derive(Debug, Clone, PartialEq)]
pub enum Constant {
    Number(f64),
    Text(String),
}

#[derive(Debug, Clone, PartialEq)]
pub struct Function {
    pub name: String,
    pub arity: u16,
    pub local_count: u16,
    pub code: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct Program {
    pub constants: Vec<Constant>,
    pub globals: u16,
    pub functions: Vec<Function>,
}

#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OpCode {
    Constant = 1,
    True = 2,
    False = 3,
    Nil = 4,
    Add = 5,
    Subtract = 6,
    Multiply = 7,
    Divide = 8,
    Equal = 9,
    Greater = 10,
    Less = 11,
    Not = 12,
    Negate = 13,
    Print = 14,
    Pop = 15,
    LoadGlobal = 16,
    StoreGlobal = 17,
    LoadLocal = 18,
    StoreLocal = 19,
    JumpIfFalse = 20,
    Jump = 21,
    Call = 22,
    Return = 23,
    And = 24,
    Or = 25,
    Halt = 26,
}

impl TryFrom<u8> for OpCode {
    type Error = String;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        use OpCode::*;
        Ok(match value {
            1 => Constant, 2 => True, 3 => False, 4 => Nil, 5 => Add,
            6 => Subtract, 7 => Multiply, 8 => Divide, 9 => Equal,
            10 => Greater, 11 => Less, 12 => Not, 13 => Negate,
            14 => Print, 15 => Pop, 16 => LoadGlobal, 17 => StoreGlobal,
            18 => LoadLocal, 19 => StoreLocal, 20 => JumpIfFalse,
            21 => Jump, 22 => Call, 23 => Return, 24 => And, 25 => Or, 26 => Halt,
            other => return Err(format!("opcode desconhecido: {other}")),
        })
    }
}
04
cadencia/src/compiler.rs

Traduz a AST existente para instruções

Ver código completo de cadencia/src/compiler.rs
use std::collections::BTreeMap;
use pulso::ast::{BinaryOp, Expr, FunctionDecl, Program as AstProgram, Stmt, UnaryOp};
use crate::bytecode::{Constant, Function, OpCode, Program};

pub struct Compiler {
    constants: Vec<Constant>,
    globals: BTreeMap<String, u16>,
    function_ids: BTreeMap<String, u16>,
}

struct FunctionCompiler {
    code: Vec<u8>,
    locals: BTreeMap<String, u16>,
}

impl Compiler {
    pub fn compile(ast: &AstProgram) -> Result<Program, String> {
        let mut compiler = Self {
            constants: Vec::new(), globals: BTreeMap::new(), function_ids: BTreeMap::new()
        };
        let declarations: Vec<_> = ast.statements.iter().filter_map(|stmt| {
            if let Stmt::Function(function) = stmt { Some(function.clone()) } else { None }
        }).collect();
        compiler.function_ids.insert("<main>".into(), 0);
        for function in &declarations {
            let id = compiler.function_ids.len() as u16;
            compiler.function_ids.insert(function.name.clone(), id);
        }
        let mut functions = vec![compiler.compile_main(ast)?];
        for function in &declarations { functions.push(compiler.compile_function(function)?); }
        Ok(Program { constants: compiler.constants, globals: compiler.globals.len() as u16, functions })
    }

    fn compile_main(&mut self, ast: &AstProgram) -> Result<Function, String> {
        let mut fc = FunctionCompiler { code: Vec::new(), locals: BTreeMap::new() };
        for stmt in &ast.statements {
            if !matches!(stmt, Stmt::Function(_)) { self.statement(stmt, &mut fc, true)?; }
        }
        emit(&mut fc.code, OpCode::Halt);
        Ok(Function { name: "<main>".into(), arity: 0, local_count: 0, code: fc.code })
    }

    fn compile_function(&mut self, function: &FunctionDecl) -> Result<Function, String> {
        let mut fc = FunctionCompiler { code: Vec::new(), locals: BTreeMap::new() };
        for parameter in &function.parameters {
            let slot = fc.locals.len() as u16;
            fc.locals.insert(parameter.clone(), slot);
        }
        for stmt in &function.body { self.statement(stmt, &mut fc, false)?; }
        emit(&mut fc.code, OpCode::Nil);
        emit(&mut fc.code, OpCode::Return);
        Ok(Function {
            name: function.name.clone(),
            arity: function.parameters.len() as u16,
            local_count: fc.locals.len() as u16,
            code: fc.code,
        })
    }

    fn statement(&mut self, stmt: &Stmt, fc: &mut FunctionCompiler, global: bool) -> Result<(), String> {
        match stmt {
            Stmt::Var { name, initializer } => {
                self.expression(initializer, fc)?;
                let slot = if global {
                    slot_for(&mut self.globals, name)
                } else {
                    slot_for(&mut fc.locals, name)
                };
                emit_u16(&mut fc.code, if global { OpCode::StoreGlobal } else { OpCode::StoreLocal }, slot);
            }
            Stmt::Assign { name, value } => {
                self.expression(value, fc)?;
                if let Some(slot) = fc.locals.get(name) {
                    emit_u16(&mut fc.code, OpCode::StoreLocal, *slot);
                } else {
                    let slot = slot_for(&mut self.globals, name);
                    emit_u16(&mut fc.code, OpCode::StoreGlobal, slot);
                }
            }
            Stmt::Print(expr) => { self.expression(expr, fc)?; emit(&mut fc.code, OpCode::Print); }
            Stmt::Expression(expr) => { self.expression(expr, fc)?; emit(&mut fc.code, OpCode::Pop); }
            Stmt::Block(statements) => for stmt in statements { self.statement(stmt, fc, global)?; },
            Stmt::If { condition, then_branch, else_branch } => {
                self.expression(condition, fc)?;
                let jump_false = emit_jump(&mut fc.code, OpCode::JumpIfFalse);
                self.statement(then_branch, fc, global)?;
                let jump_end = emit_jump(&mut fc.code, OpCode::Jump);
                patch_jump(&mut fc.code, jump_false);
                if let Some(other) = else_branch { self.statement(other, fc, global)?; }
                patch_jump(&mut fc.code, jump_end);
            }
            Stmt::While { condition, body } => {
                let start = fc.code.len() as u32;
                self.expression(condition, fc)?;
                let exit = emit_jump(&mut fc.code, OpCode::JumpIfFalse);
                self.statement(body, fc, global)?;
                emit_u32(&mut fc.code, OpCode::Jump, start);
                patch_jump(&mut fc.code, exit);
            }
            Stmt::Return(value) => {
                if let Some(value) = value { self.expression(value, fc)?; } else { emit(&mut fc.code, OpCode::Nil); }
                emit(&mut fc.code, OpCode::Return);
            }
            Stmt::Function(_) => {}
        }
        Ok(())
    }

    fn expression(&mut self, expr: &Expr, fc: &mut FunctionCompiler) -> Result<(), String> {
        match expr {
            Expr::Number(value) => { let id = self.constant(Constant::Number(*value)); emit_u16(&mut fc.code, OpCode::Constant, id); }
            Expr::String(value) => { let id = self.constant(Constant::Text(value.clone())); emit_u16(&mut fc.code, OpCode::Constant, id); }
            Expr::Bool(true) => emit(&mut fc.code, OpCode::True),
            Expr::Bool(false) => emit(&mut fc.code, OpCode::False),
            Expr::Variable(name) => {
                if let Some(slot) = fc.locals.get(name) { emit_u16(&mut fc.code, OpCode::LoadLocal, *slot); }
                else if let Some(slot) = self.globals.get(name) { emit_u16(&mut fc.code, OpCode::LoadGlobal, *slot); }
                else { return Err(format!("variável não declarada: {name}")); }
            }
            Expr::Unary { operator, right } => {
                self.expression(right, fc)?;
                emit(&mut fc.code, match operator { UnaryOp::Negate => OpCode::Negate, UnaryOp::Not => OpCode::Not });
            }
            Expr::Binary { left, operator, right } => {
                self.expression(left, fc)?;
                self.expression(right, fc)?;
                emit(&mut fc.code, match operator {
                    BinaryOp::Add => OpCode::Add, BinaryOp::Subtract => OpCode::Subtract,
                    BinaryOp::Multiply => OpCode::Multiply, BinaryOp::Divide => OpCode::Divide,
                    BinaryOp::Equal => OpCode::Equal,
                    BinaryOp::NotEqual => { emit(&mut fc.code, OpCode::Equal); OpCode::Not },
                    BinaryOp::Greater => OpCode::Greater,
                    BinaryOp::Less => OpCode::Less,
                    BinaryOp::GreaterEqual => { emit(&mut fc.code, OpCode::Less); OpCode::Not },
                    BinaryOp::LessEqual => { emit(&mut fc.code, OpCode::Greater); OpCode::Not },
                    BinaryOp::And => OpCode::And,
                    BinaryOp::Or => OpCode::Or,
                });
            }
            Expr::Call { name, arguments } => {
                for argument in arguments { self.expression(argument, fc)?; }
                let function = *self.function_ids.get(name).ok_or_else(|| format!("função desconhecida: {name}"))?;
                emit(&mut fc.code, OpCode::Call);
                fc.code.extend(function.to_le_bytes());
                fc.code.push(arguments.len() as u8);
            }
        }
        Ok(())
    }

    fn constant(&mut self, value: Constant) -> u16 {
        if let Some(index) = self.constants.iter().position(|item| item == &value) { return index as u16; }
        self.constants.push(value);
        (self.constants.len() - 1) as u16
    }
}

fn slot_for(map: &mut BTreeMap<String, u16>, name: &str) -> u16 {
    if let Some(slot) = map.get(name) { *slot } else {
        let slot = map.len() as u16; map.insert(name.into(), slot); slot
    }
}
fn emit(code: &mut Vec<u8>, op: OpCode) { code.push(op as u8); }
fn emit_u16(code: &mut Vec<u8>, op: OpCode, value: u16) { emit(code, op); code.extend(value.to_le_bytes()); }
fn emit_u32(code: &mut Vec<u8>, op: OpCode, value: u32) { emit(code, op); code.extend(value.to_le_bytes()); }
fn emit_jump(code: &mut Vec<u8>, op: OpCode) -> usize { emit(code, op); let at = code.len(); code.extend(0u32.to_le_bytes()); at }
fn patch_jump(code: &mut [u8], at: usize) { let target = code.len() as u32; code[at..at + 4].copy_from_slice(&target.to_le_bytes()); }
05
cadencia/src/format.rs

Serializa e desserializa o arquivo PBC1

Ver código completo de cadencia/src/format.rs
use crate::bytecode::{Constant, Function, Program, MAGIC, VERSION};

fn put_u16(out: &mut Vec<u8>, value: u16) { out.extend(value.to_le_bytes()); }
fn put_u32(out: &mut Vec<u8>, value: u32) { out.extend(value.to_le_bytes()); }
fn put_text(out: &mut Vec<u8>, text: &str) {
    put_u32(out, text.len() as u32);
    out.extend(text.as_bytes());
}

pub fn encode(program: &Program) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend(MAGIC);
    put_u16(&mut out, VERSION);
    put_u16(&mut out, program.globals);
    put_u32(&mut out, program.constants.len() as u32);
    for constant in &program.constants {
        match constant {
            Constant::Number(value) => { out.push(1); out.extend(value.to_le_bytes()); }
            Constant::Text(value) => { out.push(2); put_text(&mut out, value); }
        }
    }
    put_u16(&mut out, program.functions.len() as u16);
    for function in &program.functions {
        put_text(&mut out, &function.name);
        put_u16(&mut out, function.arity);
        put_u16(&mut out, function.local_count);
        put_u32(&mut out, function.code.len() as u32);
        out.extend(&function.code);
    }
    out
}

struct Reader<'a> { bytes: &'a [u8], at: usize }
impl<'a> Reader<'a> {
    fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
        let end = self.at.checked_add(n).ok_or("tamanho inválido")?;
        let value = self.bytes.get(self.at..end).ok_or("arquivo PBC truncado")?;
        self.at = end; Ok(value)
    }
    fn u16(&mut self) -> Result<u16, String> {
        Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
    }
    fn u32(&mut self) -> Result<u32, String> {
        Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
    }
    fn text(&mut self) -> Result<String, String> {
        let len = self.u32()? as usize;
        String::from_utf8(self.take(len)?.to_vec()).map_err(|_| "texto UTF-8 inválido".into())
    }
}

pub fn decode(bytes: &[u8]) -> Result<Program, String> {
    let mut r = Reader { bytes, at: 0 };
    if r.take(4)? != MAGIC { return Err("magic bytes não são PBC1".into()); }
    if r.u16()? != VERSION { return Err("versão PBC incompatível".into()); }
    let globals = r.u16()?;
    let mut constants = Vec::new();
    for _ in 0..r.u32()? {
        constants.push(match r.take(1)?[0] {
            1 => Constant::Number(f64::from_le_bytes(r.take(8)?.try_into().unwrap())),
            2 => Constant::Text(r.text()?),
            tag => return Err(format!("constante desconhecida: {tag}")),
        });
    }
    let mut functions = Vec::new();
    for _ in 0..r.u16()? {
        let name = r.text()?;
        let arity = r.u16()?;
        let local_count = r.u16()?;
        let code_len = r.u32()? as usize;
        functions.push(Function { name, arity, local_count, code: r.take(code_len)?.to_vec() });
    }
    if r.at != bytes.len() { return Err("bytes extras após o programa".into()); }
    Ok(Program { constants, globals, functions })
}
06
cadencia/src/main.rs

Produz program.pbc a partir de .pulso

Ver código completo de cadencia/src/main.rs
use std::{env, fs};
use cadencia::{encode, Compiler};
use pulso::{lexer::Lexer, parser::Parser};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let source_path = env::args().nth(1).ok_or("uso: cadencia arquivo.pulso [saida.pbc]")?;
    let output_path = env::args().nth(2).unwrap_or_else(|| "program.pbc".into());
    let source = fs::read_to_string(source_path)?;
    let tokens = Lexer::new(&source).scan()?;
    let ast = Parser::new(tokens).parse()?;
    let program = Compiler::compile(&ast)?;
    fs::write(&output_path, encode(&program))?;
    println!("bytecode salvo em {output_path}");
    Ok(())
}
07
mini-vm/Cargo.toml

Liga a VM ao formato compartilhado

Ver código completo de mini-vm/Cargo.toml
[package]
name = "mini-vm"
version = "0.1.0"
edition = "2021"

[dependencies]
cadencia = { path = "../cadencia" }
08
mini-vm/src/vm.rs

Implementa stack, frames, PC e loop de execução

Ver código completo de mini-vm/src/vm.rs
use cadencia::{Constant, OpCode, Program};

#[derive(Debug, Clone, PartialEq)]
pub enum Value { Number(f64), Text(String), Bool(bool), Nil }

struct Frame { function: usize, ip: usize, locals: Vec<Value> }

pub struct Vm {
    program: Program,
    stack: Vec<Value>,
    globals: Vec<Value>,
    frames: Vec<Frame>,
    output: Vec<String>,
}

impl Vm {
    pub fn new(program: Program) -> Self {
        let globals = vec![Value::Nil; program.globals as usize];
        let main_locals = program.functions[0].local_count as usize;
        Self {
            program, stack: Vec::new(), globals,
            frames: vec![Frame { function: 0, ip: 0, locals: vec![Value::Nil; main_locals] }],
            output: Vec::new(),
        }
    }

    pub fn run(mut self) -> Result<Vec<String>, String> {
        loop {
            let byte = self.read_u8()?;
            match OpCode::try_from(byte)? {
                OpCode::Constant => {
                    let id = self.read_u16()? as usize;
                    let value = match self.program.constants.get(id).ok_or("constante inexistente")? {
                        Constant::Number(value) => Value::Number(*value),
                        Constant::Text(value) => Value::Text(value.clone()),
                    };
                    self.stack.push(value);
                }
                OpCode::True => self.stack.push(Value::Bool(true)),
                OpCode::False => self.stack.push(Value::Bool(false)),
                OpCode::Nil => self.stack.push(Value::Nil),
                OpCode::Add => self.binary(|a, b| match (a, b) {
                    (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)),
                    (Value::Text(a), Value::Text(b)) => Ok(Value::Text(a + &b)),
                    _ => Err("soma exige dois números ou dois textos".into()),
                })?,
                OpCode::Subtract => self.numbers(|a, b| a - b)?,
                OpCode::Multiply => self.numbers(|a, b| a * b)?,
                OpCode::Divide => self.numbers(|a, b| a / b)?,
                OpCode::Equal => self.binary(|a, b| Ok(Value::Bool(a == b)))?,
                OpCode::Greater => self.compare(|a, b| a > b)?,
                OpCode::Less => self.compare(|a, b| a < b)?,
                OpCode::Not => { let value = self.pop()?; self.stack.push(Value::Bool(!truthy(&value))); }
                OpCode::And => self.binary(|a, b| Ok(Value::Bool(truthy(&a) && truthy(&b))))?,
                OpCode::Or => self.binary(|a, b| Ok(Value::Bool(truthy(&a) || truthy(&b))))?,
                OpCode::Negate => { let Value::Number(value) = self.pop()? else { return Err("negação exige número".into()) }; self.stack.push(Value::Number(-value)); }
                OpCode::Print => { let text = display(&self.pop()?); println!("{text}"); self.output.push(text); }
                OpCode::Pop => { self.pop()?; }
                OpCode::LoadGlobal => { let slot = self.read_u16()? as usize; self.stack.push(self.globals.get(slot).ok_or("global inválida")?.clone()); }
                OpCode::StoreGlobal => { let slot = self.read_u16()? as usize; self.globals[slot] = self.pop()?; }
                OpCode::LoadLocal => { let slot = self.read_u16()? as usize; let value = self.frame()?.locals.get(slot).ok_or("local inválida")?.clone(); self.stack.push(value); }
                OpCode::StoreLocal => { let slot = self.read_u16()? as usize; let value = self.pop()?; self.frame_mut()?.locals[slot] = value; }
                OpCode::JumpIfFalse => { let target = self.read_u32()? as usize; if !truthy(self.stack.last().ok_or("stack vazia")?) { self.frame_mut()?.ip = target; } self.pop()?; }
                OpCode::Jump => { let target = self.read_u32()? as usize; self.frame_mut()?.ip = target; }
                OpCode::Call => {
                    let function = self.read_u16()? as usize;
                    let argc = self.read_u8()? as usize;
                    let meta = self.program.functions.get(function).ok_or("função inválida")?;
                    if argc != meta.arity as usize { return Err("quantidade de argumentos incorreta".into()); }
                    let mut locals = vec![Value::Nil; meta.local_count as usize];
                    for slot in (0..argc).rev() { locals[slot] = self.pop()?; }
                    self.frames.push(Frame { function, ip: 0, locals });
                }
                OpCode::Return => {
                    let value = self.pop()?;
                    self.frames.pop();
                    if self.frames.is_empty() { return Ok(self.output); }
                    self.stack.push(value);
                }
                OpCode::Halt => return Ok(self.output),
            }
        }
    }

    fn frame(&self) -> Result<&Frame, String> { self.frames.last().ok_or("sem frame".into()) }
    fn frame_mut(&mut self) -> Result<&mut Frame, String> { self.frames.last_mut().ok_or("sem frame".into()) }
    fn read_u8(&mut self) -> Result<u8, String> {
        let frame = self.frame_mut()?;
        let function = frame.function;
        let ip = frame.ip;
        frame.ip += 1;
        self.program.functions[function].code.get(ip).copied().ok_or("fim inesperado".into())
    }
    fn read_u16(&mut self) -> Result<u16, String> { Ok(u16::from_le_bytes([self.read_u8()?, self.read_u8()?])) }
    fn read_u32(&mut self) -> Result<u32, String> { Ok(u32::from_le_bytes([self.read_u8()?, self.read_u8()?, self.read_u8()?, self.read_u8()?])) }
    fn pop(&mut self) -> Result<Value, String> { self.stack.pop().ok_or("stack vazia".into()) }
    fn binary(&mut self, operation: impl Fn(Value, Value) -> Result<Value, String>) -> Result<(), String> {
        let right = self.pop()?; let left = self.pop()?; self.stack.push(operation(left, right)?); Ok(())
    }
    fn numbers(&mut self, operation: impl Fn(f64, f64) -> f64) -> Result<(), String> {
        self.binary(|a, b| match (a, b) { (Value::Number(a), Value::Number(b)) => Ok(Value::Number(operation(a, b))), _ => Err("operação exige números".into()) })
    }
    fn compare(&mut self, operation: impl Fn(f64, f64) -> bool) -> Result<(), String> {
        self.binary(|a, b| match (a, b) { (Value::Number(a), Value::Number(b)) => Ok(Value::Bool(operation(a, b))), _ => Err("comparação exige números".into()) })
    }
}

fn truthy(value: &Value) -> bool { !matches!(value, Value::Bool(false) | Value::Nil) }
fn display(value: &Value) -> String {
    match value {
        Value::Number(value) if value.fract() == 0.0 => format!("{value:.0}"),
        Value::Number(value) => value.to_string(),
        Value::Text(value) => value.clone(),
        Value::Bool(value) => value.to_string(),
        Value::Nil => "nulo".into(),
    }
}
09
mini-vm/src/main.rs

Abre e executa o arquivo compilado

Ver código completo de mini-vm/src/main.rs
use std::{env, fs};
use cadencia::decode;
use mini_vm::Vm;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = env::args().nth(1).ok_or("uso: mini-vm program.pbc")?;
    let bytes = fs::read(path)?;
    let program = decode(&bytes)?;
    Vm::new(program).run()?;
    Ok(())
}
10
examples/programa.pulso

Programa completo usado no percurso

Ver código completo de examples/programa.pulso
funcao dobro(numero) {
    retorne numero * 2;
}

var resposta = dobro(21);
mostre("Pulso compilada:");
mostre(resposta);

11
README.md

Mostra como compilar e executar

Ver código completo de README.md
# Pulso Bytecode

Continuação direta do projeto Pulso. O lexer, os tokens, o parser e a AST vêm do
projeto `../pulso`. A **Cadência** transforma a AST em um arquivo PBC1 e o
`mini-vm` executa esse arquivo.

```bash
cargo run -p cadencia -- examples/programa.pulso program.pbc
cargo run -p mini-vm -- program.pbc
```

19 / COMPILAR UMA VEZ, EXECUTAR DEPOIS

Agora as duas etapas estão fisicamente separadas.

cd projects/pulso-bytecode

cargo run -p cadencia -- examples/programa.pulso program.pbc
cargo run -p mini-vm -- program.pbc
ENTRADAprograma.pulsotexto legível
COMPILAÇÃOprogram.pbcPulso Bytecode v1
EXECUÇÃOmini-vmcarrega, valida e executa
SAÍDAPulso compilada: 42mesmo comportamento

A VM não recebe o arquivo .pulso. Ela não importa Lexer nem Parser. Isso é importante: depois de compilado, o programa pode ser distribuído sem o código-fonte e sem repetir a análise sintática.

IMPLEMENTAÇÃO 14

Unimos as peças nos dois programas de linha de comando.

cadencia/src/main.rs · mini-vm/src/main.rs

Primeiro, a Cadência produz o arquivo .pbc

let source = fs::read_to_string(source_path)?;
let tokens = Lexer::new(&source).scan()?;
let ast = Parser::new(tokens).parse()?;
let program = Compiler::compile(&ast)?;
let bytes = encode(&program);
fs::write(&output_path, bytes)?;

Aqui está a continuidade com o artigo anterior em seis linhas: o Lexer e o Parser da nossa linguagem Pulso continuam iguais. A novidade começa emCompiler::compile. Depois encode transforma a representação em bytes e fs::write cria program.pbc.

Depois, a máquina virtual consome o arquivo

let bytes = fs::read(path)?;
let program = decode(&bytes)?;
let vm = Vm::new(program);
vm.run()?;

A VM começa do outro lado da ponte. Ela lê bytes, valida PBC1, reconstrói constantes e funções e só então inicia o ciclo de instruções. Ela não precisa conhecer tokens, gramática ou AST.

PROJETO COMPLETOPulso → Cadência → arquivo .pbc → VM → resultado

20 / TRANSFORMANDO LEITURA EM INVESTIGAÇÃO

Pequenas alterações revelam responsabilidades que o caminho feliz esconde.

01troque apenas uma constantecompare a Constant Pool antes e depois
02repita o mesmo textoconfirme que ele recebe um único índice
03compile um se/senaolocalize os dois destinos de salto
04compile um enquantoencontre o salto que volta para a condição
05altere PBC1 no hexadecimalobserve a validação recusar o arquivo
06apague o último byteprovoque “arquivo PBC truncado”
07chame dobro duas vezesacompanhe frames nascerem e morrerem
08adicione um opcodealtere enum, compilador e VM juntos

Um novo opcode atravessa três contratos

Suponha que desejamos criar Resto para o operador%. Primeiro a linguagem precisa reconhecer a sintaxe — essa é a parte que realmente alteraria a Pulso. Depois a Cadência precisa traduzir o nó para um número. Por fim a VM precisa decodificar esse número e calcular o resto.

PULSO% vira BinaryOpsintaxe e AST
CADÊNCIABinaryOp vira Restogeração
PBC1novo númeroformato Pulso Bytecode
VMpop · calcular · pushexecução

Esse experimento mostra por que a arquitetura separada é valiosa. Uma mudança na sintaxe não deveria obrigar a VM a conhecer tokens; uma mudança na execução não deveria obrigar o lexer a conhecer a stack.

21 / A MESMA IDEIA EM SISTEMAS REAIS

Nossa oficina é pequena; a arquitetura é industrial.

EcossistemaRepresentação intermediáriaQuem executa
Javabytecode em arquivos .classJVM
.NETCILCLR
Luainstruções da Lua VMmáquina virtual Lua
Pythonbytecode da implementaçãoloop da VM do Python
PulsoPulso Bytecode PBC1mini-vm

Os sistemas reais adicionam verificação, coleta de lixo, módulos, exceções, depuração e compilação JIT. Mas a separação fundamental permanece reconhecível: entender o código, gerar uma representação mais simples e executá-la em um motor apropriado.

JIT: quando a VM também vira compiladora

JIT significa Just-In-Time. Em vez de interpretar o mesmo trecho de bytecode para sempre, uma VM pode perceber que determinada função está muito ativa e traduzi-la para código nativo durante a execução. Assim ela combina portabilidade no início com velocidade nos trechos mais usados.

SEM JITbytecode → VMtodo opcode passa pelo loop
COM JITtrecho quente → código nativoa CPU executa diretamente

Nossa VM não implementa JIT — isso esconderia o mecanismo que queremos enxergar. Mas agora você já sabe de onde ele parte: mede bytecode executado, escolhe regiões “quentes”, compila essas regiões e guarda o resultado.

22 / A PRÓXIMA PONTE DA SÉRIE

Nossa VM se parece com uma CPU porque máquinas virtuais imitam máquinas.

CPU REALregistradores · memória · PC · instruçõescircuitos executam código de máquina
NOSSA VMstack · constantes · IP · opcodesRust executa bytecode

Não é coincidência. Ambas precisam guardar valores, localizar a próxima instrução, decodificá-la e alterar o estado da máquina. No próximo artigo, Da nossa VM ao silício, tiraremos essa ideia de dentro do software e veremos como uma CPU real organiza registradores, memória, unidade de controle e execução. Essa distinção é a chave: o bytecode PBC1 é a linguagem da nossa VM; código de máquina é a linguagem da ISA que o processador físico implementa. A CPU nunca executa PBC1 diretamente — ela executa as instruções nativas que compilaram a própria mini-vm escrita em Rust.

O QUE CONSTRUÍMOS

Uma mesma linguagem com dois motores: interpretação direta da AST e compilação para uma VM portátil.

23 / PERGUNTAS FREQUENTES

O que costuma ficar depois da primeira máquina virtual.

A linguagem Pulso mudou neste artigo?

Não. A linguagem que construímos no artigo anterior mantém a mesma sintaxe, lexer, tokens, parser e AST. Substituímos somente o interpretador por um compilador de bytecode e uma máquina virtual.

Bytecode é código de máquina?

Não. Código de máquina pertence a uma CPU real. Bytecode é um conjunto de instruções criado para uma máquina virtual.

O que é um opcode?

É o número que identifica uma operação da VM, como carregar uma constante, somar, imprimir ou saltar.

O que é Program Counter?

É a posição da próxima instrução no bytecode. Depois de ler uma instrução, a VM avança esse contador; saltos podem mudá-lo.

O que é uma stack machine?

É uma máquina que guarda valores temporários em uma pilha. Operações retiram operandos do topo e colocam o resultado de volta.

Por que criar o arquivo .pbc?

O arquivo Pulso Bytecode separa compilação e execução. Podemos compilar uma vez, salvar o programa e executá-lo depois sem reler o código-fonte.

A JVM e a CLR funcionam exatamente assim?

Compartilham a ideia geral de bytecode, carregamento e máquina virtual, mas possuem formatos, verificações, otimizações e runtimes muito mais sofisticados.

Por que não gerar x86 ou ARM?

O bytecode mantém o projeto independente da CPU e deixa visíveis os conceitos essenciais antes de enfrentarmos detalhes de arquiteturas reais.

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.