COMPILADORES · BYTECODE · MÁQUINAS VIRTUAIS · RUST
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.
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:
.pulsoA AST é o ponto de chegada do artigo anterior. Ela não é texto nem resultado: é o mapa estruturado que os dois motores sabem receber.
visita os nós da AST e executa imediatamente
AST → resultadotraduz a AST, salva o bytecode e executa depois
AST → arquivo compilado → resultadoO ponto de bifurcação é a AST. Tudo antes dela pertence à linguagem; os dois caminhos abaixo são motores diferentes para o mesmo programa.
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.
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.
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.
[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.
| Instrução | Número | O que a VM faz |
|---|---|---|
Constant | 1 | coloca uma constante na pilha |
Add | 5 | retira dois valores e empilha a soma |
Print | 14 | retira e mostra o valor do topo |
Jump | 21 | muda a próxima posição de leitura |
Call | 22 | abre uma chamada de função |
Halt | 26 | encerra 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”.
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,
}#[repr(u8)]Ordena ao Rust que cada variante possa ser representada por um inteiro de oito bits — exatamente um byte.
pub enum OpCodeCria um tipo que só aceita instruções conhecidas. pub permite que a máquina virtual use o mesmo contrato.
Add = 5Fixa explicitamente o número. Se a ordem do enum mudar amanhã, o arquivo antigo continua significando a mesma coisa.
CopyUm 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.
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.
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);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.
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.
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>,
}ConstantUm enum permite que a mesma lista guarde números e textos sem confundir os dois tipos.
arityAridade é a quantidade de parâmetros esperados. Uma função dobro(numero) possui aridade 1.
local_countReserva a quantidade correta de espaços locais quando uma chamada começar.
Vec<u8>É o bytecode da função: um vetor em que cada item ocupa exatamente um byte.
ProgramReúne tudo que será serializado: reservatório de constantes, quantidade de globais e funções.
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.
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ávelA função encode percorre o programa na mesma ordem do desenho.
cadencia/src/format.rspub 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
}let mut out = Vec::new()Começamos com um vetor vazio. Cada campo do formato será anexado ao final dele.
out.extend(b"PBC1")O prefixo b transforma “Pulso Bytecode, versão 1” em quatro bytes ASCII, nossa assinatura.
Gravamos o tamanho da lista antes de percorrê-la. Assim a VM sabe quantas constantes precisa ler.
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.
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.
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.
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 constantesOffset é 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.
Reader mantém um cursor e recusa atravessar o final do arquivo.
cadencia/src/format.rsstruct 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)
}
}&'a [u8]Reader não copia o arquivo. Ele mantém uma visão emprestada dos bytes enquanto a leitura durar.
atÉ o cursor: o offset do próximo byte ainda não consumido.
checked_addSoma sem permitir que um tamanho malicioso faça o inteiro dar a volta.
get(self.at..end)Tenta obter o intervalo com segurança. Se faltarem bytes, devolve erro em vez de acessar memória inexistente.
self.at = endSomente 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.
O compilador precisa acumular constantes, globais e funções.
cadencia/src/compiler.rspub struct Compiler {
constants: Vec<Constant>,
globals: BTreeMap<String, u16>,
function_ids: BTreeMap<String, u16>,
}
struct FunctionCompiler {
code: Vec<u8>,
locals: BTreeMap<String, u16>,
}constantsRecebe os números e textos encontrados enquanto percorremos a AST.
BTreeMap<String, u16>Relaciona um nome humano, como total, a um slot numérico, como 0.
function_idsFaz com funções o mesmo que globals faz com variáveis: transforma nomes em identificadores compactos.
FunctionCompilerCada 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.
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.
| Offset | Instrução | Estado depois | Motivo |
|---|---|---|---|
| 0 | Constant 0 | stack: [2] | lado esquerdo da soma |
| 3 | Constant 1 | stack: [2, 3] | lado direito |
| 6 | Add | stack: [5] | substitui operandos pelo resultado |
| 7 | StoreGlobal 0 | global[0]: 5 | a etiqueta “total” vira slot 0 |
| 10 | LoadGlobal 0 | stack: [5] | recupera o valor |
| 13 | Print | stack: [] | consome e mostra 5 |
| 14 | Halt | encerrado | nã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.
O nome desaparece do caminho quente. Depois da compilação, encontrar a variável significa acessar diretamente o slot 0.
Agora a tabela visual vira os métodos statement e expression.
cadencia/src/compiler.rsfn 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(())
}match exprAbre uma regra de tradução para cada tipo de nó existente na AST da Pulso.
O valor entra no reservatório e o bytecode recebe apenas seu índice.
A ordem coloca os dois operandos na stack exatamente como Add espera encontrá-los.
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.
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.
O compilador ainda não conhece o destino quando encontra o if.
cadencia/src/compiler.rsfn 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());
}emit(code, opcode)Grava o byte de Jump ou JumpIfFalse.
Reservam o lugar do destino u32. Ainda não sabemos qual será o offset final.
operand_atGuarda onde os quatro zeros começaram para que possamos encontrá-los novamente.
patch_jumpDepois 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.
ip = 12[ 2 · 3 ]0→2 · 1→3total→5main → dobroA 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.
A ilustração da VM agora vira estruturas Rust.
mini-vm/src/vm.rspub 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>,
}ValueÉ o valor vivo durante a execução. Não é a mesma coisa que Constant: variáveis e resultados também passam por aqui.
FrameGuarda qual função está ativa, em qual instrução ela parou e quais são seus valores locais.
programÉ o PBC1 já desserializado: constantes, funções e bytecode disponíveis em memória.
stack e framesSão duas pilhas diferentes: uma guarda valores temporários; a outra guarda chamadas de função.
outputAlé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.
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 cresce e encolhe a cada instrução. O desenho deve ser lido de cima para baixo, como três fotografias consecutivas.
Primeiro criamos as operações básicas da pilha.
mini-vm/src/vm.rsfn 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(())
}pop() pode falharUm bytecode inválido talvez peça uma soma sem dois valores. Em vez de quebrar, a VM devolve “stack vazia”.
O último valor empilhado corresponde ao operando direito. Isso importa em 10 - 3.
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.
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?
Começamos com três opcodes: Constant, Print e Halt.
mini-vm/src/vm.rspub 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()),
}
}
}read_u8()Lê o byte no offset atual e avança o Program Counter em um.
try_from(byte)Converte um número cru para OpCode e recusa valores fora de 1 a 26.
read_u16()Constant possui um operando de dois bytes: o índice dentro da Constant Pool.
O dado imutável do arquivo é clonado para a área de valores vivos da execução.
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ão | Guarda | Quanto tempo vive | Exemplo |
|---|---|---|---|
| Constant Pool | literais imutáveis | todo o programa | 42, "Olá" |
| Globals | variáveis globais | até Halt | resposta |
| Locals do frame | parâmetros e variáveis locais | até Return | numero |
| Stack de valores | operandos temporários | até serem consumidos | os dois lados de uma soma |
| Stack de frames | chamadas ativas | até cada função retornar | main → dobro |
“Memória da VM” é o conjunto destas regiões. Separá-las torna visível quem cria, usa e encerra cada valor.
Agora ensinamos a VM a guardar e recuperar variáveis.
mini-vm/src/vm.rsOpCode::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;
}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.
Load copia para a stackCarregar uma variável significa buscar seu valor na gaveta e colocá-lo no local em que a próxima instrução espera encontrá-lo.
Store faz o caminho inversoO valor calculado está no topo da stack. pop() o retira e a atribuição o guarda no slot indicado.
Globais pertencem à VM inteira. Locais pertencem apenas ao frame da função que está sendo executada.
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.
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.
Call cria um frame; Return devolve o resultado ao chamador.
mini-vm/src/vm.rsOpCode::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);
}O u16 identifica a função. O u8 informa quantos argumentos a chamada colocou na stack.
Se dobro declara um parâmetro, sua aridade é 1. Chamar sem argumento ou com dois deve produzir erro.
O laço retira os valores da stack na ordem inversa e os coloca nos primeiros slots do novo frame.
A função chamada começa em sua primeira instrução. O IP do frame anterior permanece guardado onde parou.
O valor retornado volta para a stack. Assim, a função anterior continua e pode consumir o resultado.
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.
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.
O nome PBC1 finalmente aparece nos quatro primeiros bytes.
cadencia/src/format.rsPBC é 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();b"PBC1" são bytesO prefixo b pede ao Rust uma sequência binária, não uma String. No arquivo veremos os valores hexadecimais 50 42 43 31.
Esses quatro bytes respondem “que tipo de arquivo é este?” antes de tentarmos interpretar o restante.
Uma futura PBC2 poderá mudar o layout sem fazer uma VM antiga interpretar bytes com significado errado.
Constantes, funções e opcodes são lidos apenas quando identidade e versão foram aceitas.
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 HALTOs 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.pulsoAbra 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.
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.
Cargo.tomlReúne compilador e VM no mesmo workspace
Ver código completo de Cargo.toml
[workspace]
members = ["cadencia", "mini-vm"]
resolver = "2"
cadencia/Cargo.tomlDeclara 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" }
cadencia/src/bytecode.rsDefine 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}")),
})
}
}
cadencia/src/compiler.rsTraduz 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()); }
cadencia/src/format.rsSerializa 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 })
}
cadencia/src/main.rsProduz 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(())
}
mini-vm/Cargo.tomlLiga 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" }
mini-vm/src/vm.rsImplementa 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(),
}
}
mini-vm/src/main.rsAbre 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(())
}
examples/programa.pulsoPrograma 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);
README.mdMostra 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.pbcA 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.
Unimos as peças nos dois programas de linha de comando.
cadencia/src/main.rs · mini-vm/src/main.rsPrimeiro, 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.
20 / TRANSFORMANDO LEITURA EM INVESTIGAÇÃO
Pequenas alterações revelam responsabilidades que o caminho feliz esconde.
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.
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.
| Ecossistema | Representação intermediária | Quem executa |
|---|---|---|
| Java | bytecode em arquivos .class | JVM |
| .NET | CIL | CLR |
| Lua | instruções da Lua VM | máquina virtual Lua |
| Python | bytecode da implementação | loop da VM do Python |
| Pulso | Pulso Bytecode PBC1 | mini-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.
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.
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.
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.
A CONVERSA CONTINUA
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.