Máquina Virtual Stack-Based em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir uma máquina virtual baseada em stack com conjunto de instruções bytecode em Zig. A VM executa programas compilados para um formato de bytecode customizado, similar a como a JVM e o Python VM funcionam.
O Que Vamos Construir
Nossa máquina virtual vai:
- Definir um conjunto de instruções (bytecode) de 1 byte cada
- Implementar uma stack de operandos para cálculos
- Suportar aritmética, comparações, saltos e variáveis locais
- Incluir um assembler simples para converter texto em bytecode
- Executar programas com loop fetch-decode-execute
Por Que Este Projeto?
Máquinas virtuais são a base de linguagens como Java, Python, Lua e C#. Entender como funcionam — fetch-decode-execute, stack de operandos, frames de chamada — é fundamental para design de linguagens. Em Zig, temos controle total sobre cada byte do bytecode.
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Conhecimento de enums e arrays
- Familiaridade com o compilador de expressões
Passo 1: Estrutura do Projeto
mkdir virtual-machine
cd virtual-machine
zig init
Passo 2: Conjunto de Instruções (ISA)
const std = @import("std");
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;
/// Conjunto de instruções da VM.
/// Cada instrução é codificada em 1 byte (opcode).
/// Algumas instruções têm operandos de 1 ou 2 bytes extras.
const OpCode = enum(u8) {
// Stack
PUSH = 0x01, // Push constante (2 bytes: valor i16)
POP = 0x02, // Descarta o topo da stack
DUP = 0x03, // Duplica o topo da stack
// Aritmética
ADD = 0x10, // a + b
SUB = 0x11, // a - b
MUL = 0x12, // a * b
DIV = 0x13, // a / b
MOD = 0x14, // a % b
NEG = 0x15, // -a
// Comparação (resultado: 0 ou 1)
EQ = 0x20, // a == b
NEQ = 0x21, // a != b
LT = 0x22, // a < b
GT = 0x23, // a > b
LTE = 0x24, // a <= b
GTE = 0x25, // a >= b
// Variáveis locais
LOAD = 0x30, // Carrega variável local (1 byte: índice)
STORE = 0x31, // Armazena em variável local (1 byte: índice)
// Controle de fluxo
JMP = 0x40, // Salto incondicional (2 bytes: endereço)
JZ = 0x41, // Salto se zero (2 bytes: endereço)
JNZ = 0x42, // Salto se não-zero (2 bytes: endereço)
// I/O
PRINT = 0x50, // Imprime o topo da stack
PRINT_CHAR = 0x51, // Imprime como caractere ASCII
// Controle
HALT = 0xFF, // Para a execução
NOP = 0x00, // Não faz nada
pub fn nome(self: OpCode) []const u8 {
return switch (self) {
.PUSH => "PUSH",
.POP => "POP",
.DUP => "DUP",
.ADD => "ADD",
.SUB => "SUB",
.MUL => "MUL",
.DIV => "DIV",
.MOD => "MOD",
.NEG => "NEG",
.EQ => "EQ",
.NEQ => "NEQ",
.LT => "LT",
.GT => "GT",
.LTE => "LTE",
.GTE => "GTE",
.LOAD => "LOAD",
.STORE => "STORE",
.JMP => "JMP",
.JZ => "JZ",
.JNZ => "JNZ",
.PRINT => "PRINT",
.PRINT_CHAR => "PRINTC",
.HALT => "HALT",
.NOP => "NOP",
};
}
};
const TAMANHO_STACK = 256;
const TAMANHO_MEMORIA = 256; // variáveis locais
const TAMANHO_PROGRAMA = 4096;
Passo 3: A Máquina Virtual
/// Máquina Virtual Stack-Based.
/// Executa bytecode usando o ciclo fetch-decode-execute.
const VM = struct {
stack: [TAMANHO_STACK]i32,
sp: usize, // stack pointer
programa: [TAMANHO_PROGRAMA]u8,
programa_len: usize,
pc: usize, // program counter
variaveis: [TAMANHO_MEMORIA]i32,
rodando: bool,
instrucoes_executadas: u64,
const Self = @This();
pub fn init() Self {
return .{
.stack = [_]i32{0} ** TAMANHO_STACK,
.sp = 0,
.programa = [_]u8{0} ** TAMANHO_PROGRAMA,
.programa_len = 0,
.pc = 0,
.variaveis = [_]i32{0} ** TAMANHO_MEMORIA,
.rodando = false,
.instrucoes_executadas = 0,
};
}
/// Carrega bytecode no programa.
pub fn carregar(self: *Self, bytecode: []const u8) void {
const len = @min(bytecode.len, TAMANHO_PROGRAMA);
@memcpy(self.programa[0..len], bytecode[0..len]);
self.programa_len = len;
self.pc = 0;
self.sp = 0;
}
fn push(self: *Self, valor: i32) !void {
if (self.sp >= TAMANHO_STACK) return error.StackOverflow;
self.stack[self.sp] = valor;
self.sp += 1;
}
fn pop(self: *Self) !i32 {
if (self.sp == 0) return error.StackUnderflow;
self.sp -= 1;
return self.stack[self.sp];
}
fn peek(self: *const Self) !i32 {
if (self.sp == 0) return error.StackUnderflow;
return self.stack[self.sp - 1];
}
fn lerU8(self: *Self) u8 {
const val = self.programa[self.pc];
self.pc += 1;
return val;
}
fn lerI16(self: *Self) i16 {
const high: i16 = @as(i16, @intCast(self.programa[self.pc])) << 8;
const low: i16 = self.programa[self.pc + 1];
self.pc += 2;
return high | low;
}
fn lerU16(self: *Self) u16 {
const high: u16 = @as(u16, self.programa[self.pc]) << 8;
const low: u16 = self.programa[self.pc + 1];
self.pc += 2;
return high | low;
}
/// Executa o programa carregado.
pub fn executar(self: *Self, writer: anytype) !void {
self.rodando = true;
self.instrucoes_executadas = 0;
while (self.rodando and self.pc < self.programa_len) {
const opcode_byte = self.lerU8();
const opcode: OpCode = @enumFromInt(opcode_byte);
self.instrucoes_executadas += 1;
switch (opcode) {
.NOP => {},
.HALT => {
self.rodando = false;
},
.PUSH => {
const valor = self.lerI16();
try self.push(@as(i32, valor));
},
.POP => {
_ = try self.pop();
},
.DUP => {
const valor = try self.peek();
try self.push(valor);
},
.ADD => {
const b = try self.pop();
const a = try self.pop();
try self.push(a + b);
},
.SUB => {
const b = try self.pop();
const a = try self.pop();
try self.push(a - b);
},
.MUL => {
const b = try self.pop();
const a = try self.pop();
try self.push(a * b);
},
.DIV => {
const b = try self.pop();
const a = try self.pop();
if (b == 0) return error.DivisaoPorZero;
try self.push(@divTrunc(a, b));
},
.MOD => {
const b = try self.pop();
const a = try self.pop();
if (b == 0) return error.DivisaoPorZero;
try self.push(@mod(a, b));
},
.NEG => {
const a = try self.pop();
try self.push(-a);
},
.EQ => {
const b = try self.pop();
const a = try self.pop();
try self.push(if (a == b) @as(i32, 1) else 0);
},
.NEQ => {
const b = try self.pop();
const a = try self.pop();
try self.push(if (a != b) @as(i32, 1) else 0);
},
.LT => {
const b = try self.pop();
const a = try self.pop();
try self.push(if (a < b) @as(i32, 1) else 0);
},
.GT => {
const b = try self.pop();
const a = try self.pop();
try self.push(if (a > b) @as(i32, 1) else 0);
},
.LTE => {
const b = try self.pop();
const a = try self.pop();
try self.push(if (a <= b) @as(i32, 1) else 0);
},
.GTE => {
const b = try self.pop();
const a = try self.pop();
try self.push(if (a >= b) @as(i32, 1) else 0);
},
.LOAD => {
const idx = self.lerU8();
try self.push(self.variaveis[idx]);
},
.STORE => {
const idx = self.lerU8();
self.variaveis[idx] = try self.pop();
},
.JMP => {
const endereco = self.lerU16();
self.pc = endereco;
},
.JZ => {
const endereco = self.lerU16();
const valor = try self.pop();
if (valor == 0) self.pc = endereco;
},
.JNZ => {
const endereco = self.lerU16();
const valor = try self.pop();
if (valor != 0) self.pc = endereco;
},
.PRINT => {
const valor = try self.pop();
try writer.print("{d}\n", .{valor});
},
.PRINT_CHAR => {
const valor = try self.pop();
const c: u8 = @intCast(valor & 0xFF);
try writer.writeByte(c);
},
}
}
}
/// Descompila o bytecode em formato legível.
pub fn descompilar(self: *const Self, writer: anytype) !void {
var pc: usize = 0;
while (pc < self.programa_len) {
const endereco = pc;
const opcode: OpCode = @enumFromInt(self.programa[pc]);
pc += 1;
try writer.print(" {d:0>4}: {s}", .{ endereco, opcode.nome() });
switch (opcode) {
.PUSH => {
const val: i16 = @bitCast(@as(u16, self.programa[pc]) << 8 | self.programa[pc + 1]);
pc += 2;
try writer.print(" {d}", .{val});
},
.LOAD, .STORE => {
try writer.print(" [{d}]", .{self.programa[pc]});
pc += 1;
},
.JMP, .JZ, .JNZ => {
const addr: u16 = @as(u16, self.programa[pc]) << 8 | self.programa[pc + 1];
pc += 2;
try writer.print(" @{d:0>4}", .{addr});
},
.HALT => {
try writer.print("", .{});
break; // parar descompilação no HALT
},
else => {},
}
try writer.print("\n", .{});
}
}
};
Passo 4: Assembler Simples
/// Assembler que converte texto em bytecode.
fn montar(fonte: []const u8, bytecode: []u8) !usize {
var pos: usize = 0;
var linhas = mem.splitScalar(u8, fonte, '\n');
while (linhas.next()) |linha_raw| {
const linha = mem.trim(u8, linha_raw, " \t\r");
if (linha.len == 0 or linha[0] == ';') continue; // comentário
var it = mem.splitScalar(u8, linha, ' ');
const instrucao = it.first();
if (mem.eql(u8, instrucao, "PUSH")) {
const val_str = it.next() orelse return error.OperandoFaltando;
const val = try fmt.parseInt(i16, val_str, 10);
bytecode[pos] = @intFromEnum(OpCode.PUSH);
bytecode[pos + 1] = @intCast(@as(u16, @bitCast(val)) >> 8);
bytecode[pos + 2] = @intCast(@as(u16, @bitCast(val)) & 0xFF);
pos += 3;
} else if (mem.eql(u8, instrucao, "POP")) {
bytecode[pos] = @intFromEnum(OpCode.POP);
pos += 1;
} else if (mem.eql(u8, instrucao, "DUP")) {
bytecode[pos] = @intFromEnum(OpCode.DUP);
pos += 1;
} else if (mem.eql(u8, instrucao, "ADD")) {
bytecode[pos] = @intFromEnum(OpCode.ADD);
pos += 1;
} else if (mem.eql(u8, instrucao, "SUB")) {
bytecode[pos] = @intFromEnum(OpCode.SUB);
pos += 1;
} else if (mem.eql(u8, instrucao, "MUL")) {
bytecode[pos] = @intFromEnum(OpCode.MUL);
pos += 1;
} else if (mem.eql(u8, instrucao, "DIV")) {
bytecode[pos] = @intFromEnum(OpCode.DIV);
pos += 1;
} else if (mem.eql(u8, instrucao, "PRINT")) {
bytecode[pos] = @intFromEnum(OpCode.PRINT);
pos += 1;
} else if (mem.eql(u8, instrucao, "PRINTC")) {
bytecode[pos] = @intFromEnum(OpCode.PRINT_CHAR);
pos += 1;
} else if (mem.eql(u8, instrucao, "STORE")) {
const idx_str = it.next() orelse return error.OperandoFaltando;
const idx = try fmt.parseInt(u8, idx_str, 10);
bytecode[pos] = @intFromEnum(OpCode.STORE);
bytecode[pos + 1] = idx;
pos += 2;
} else if (mem.eql(u8, instrucao, "LOAD")) {
const idx_str = it.next() orelse return error.OperandoFaltando;
const idx = try fmt.parseInt(u8, idx_str, 10);
bytecode[pos] = @intFromEnum(OpCode.LOAD);
bytecode[pos + 1] = idx;
pos += 2;
} else if (mem.eql(u8, instrucao, "JMP")) {
const addr_str = it.next() orelse return error.OperandoFaltando;
const addr = try fmt.parseInt(u16, addr_str, 10);
bytecode[pos] = @intFromEnum(OpCode.JMP);
bytecode[pos + 1] = @intCast(addr >> 8);
bytecode[pos + 2] = @intCast(addr & 0xFF);
pos += 3;
} else if (mem.eql(u8, instrucao, "JZ")) {
const addr_str = it.next() orelse return error.OperandoFaltando;
const addr = try fmt.parseInt(u16, addr_str, 10);
bytecode[pos] = @intFromEnum(OpCode.JZ);
bytecode[pos + 1] = @intCast(addr >> 8);
bytecode[pos + 2] = @intCast(addr & 0xFF);
pos += 3;
} else if (mem.eql(u8, instrucao, "HALT")) {
bytecode[pos] = @intFromEnum(OpCode.HALT);
pos += 1;
}
}
return pos;
}
Passo 5: Função Main
pub fn main() !void {
const stdout = io.getStdOut().writer();
try stdout.print(
\\
\\ ==========================================
\\ VIRTUAL MACHINE - Stack-Based - Zig
\\ ==========================================
\\
\\
, .{});
var vm = VM.init();
// Programa de exemplo: calcular 10! (fatorial de 10)
const programa_asm =
\\; Calcula 10! (fatorial de 10)
\\; var[0] = n (contador)
\\; var[1] = resultado
\\PUSH 10
\\STORE 0
\\PUSH 1
\\STORE 1
\\; loop: while n > 1
\\LOAD 0
\\PUSH 1
\\GT
\\JZ 34
\\; resultado = resultado * n
\\LOAD 1
\\LOAD 0
\\MUL
\\STORE 1
\\; n = n - 1
\\LOAD 0
\\PUSH 1
\\SUB
\\STORE 0
\\JMP 7
\\; print resultado
\\LOAD 1
\\PRINT
\\HALT
;
var bytecode: [TAMANHO_PROGRAMA]u8 = undefined;
const len = montar(programa_asm, &bytecode) catch |err| {
try stdout.print("Erro ao montar: {}\n", .{err});
return;
};
vm.carregar(bytecode[0..len]);
// Descompilar
try stdout.print(" --- Bytecode ---\n", .{});
try vm.descompilar(stdout);
// Executar
try stdout.print("\n --- Execucao ---\n ", .{});
vm.executar(stdout) catch |err| {
try stdout.print("\n Erro de execucao: {}\n", .{err});
};
try stdout.print("\n --- Estatisticas ---\n", .{});
try stdout.print(" Instrucoes executadas: {d}\n", .{vm.instrucoes_executadas});
try stdout.print(" Stack pointer final: {d}\n", .{vm.sp});
// Programa 2: Hello World
try stdout.print("\n --- Hello World ---\n ", .{});
const hello = "Hello, VM!";
var hello_bytecode: [256]u8 = undefined;
var hello_pos: usize = 0;
for (hello) |c| {
hello_bytecode[hello_pos] = @intFromEnum(OpCode.PUSH);
hello_bytecode[hello_pos + 1] = 0;
hello_bytecode[hello_pos + 2] = c;
hello_pos += 3;
hello_bytecode[hello_pos] = @intFromEnum(OpCode.PRINT_CHAR);
hello_pos += 1;
}
// Newline
hello_bytecode[hello_pos] = @intFromEnum(OpCode.PUSH);
hello_bytecode[hello_pos + 1] = 0;
hello_bytecode[hello_pos + 2] = '\n';
hello_pos += 3;
hello_bytecode[hello_pos] = @intFromEnum(OpCode.PRINT_CHAR);
hello_pos += 1;
hello_bytecode[hello_pos] = @intFromEnum(OpCode.HALT);
hello_pos += 1;
var vm2 = VM.init();
vm2.carregar(hello_bytecode[0..hello_pos]);
try vm2.executar(stdout);
}
Testes
test "vm - push e pop" {
var vm = VM.init();
vm.carregar(&[_]u8{
@intFromEnum(OpCode.PUSH), 0, 42,
@intFromEnum(OpCode.HALT),
});
var buf: [256]u8 = undefined;
var fbs = io.fixedBufferStream(&buf);
try vm.executar(fbs.writer());
try std.testing.expectEqual(@as(usize, 1), vm.sp);
try std.testing.expectEqual(@as(i32, 42), vm.stack[0]);
}
test "vm - adicao" {
var vm = VM.init();
vm.carregar(&[_]u8{
@intFromEnum(OpCode.PUSH), 0, 3,
@intFromEnum(OpCode.PUSH), 0, 4,
@intFromEnum(OpCode.ADD),
@intFromEnum(OpCode.HALT),
});
var buf: [256]u8 = undefined;
var fbs = io.fixedBufferStream(&buf);
try vm.executar(fbs.writer());
try std.testing.expectEqual(@as(i32, 7), vm.stack[0]);
}
test "vm - variaveis locais" {
var vm = VM.init();
vm.carregar(&[_]u8{
@intFromEnum(OpCode.PUSH), 0, 99,
@intFromEnum(OpCode.STORE), 0,
@intFromEnum(OpCode.LOAD), 0,
@intFromEnum(OpCode.HALT),
});
var buf: [256]u8 = undefined;
var fbs = io.fixedBufferStream(&buf);
try vm.executar(fbs.writer());
try std.testing.expectEqual(@as(i32, 99), vm.stack[0]);
}
test "assembler - montar instrucao simples" {
var bytecode: [64]u8 = undefined;
const len = try montar("PUSH 42\nHALT\n", &bytecode);
try std.testing.expect(len > 0);
try std.testing.expectEqual(@as(u8, @intFromEnum(OpCode.PUSH)), bytecode[0]);
}
Compilando e Executando
zig build run
zig build test
Conceitos Aprendidos
- Bytecode e design de conjunto de instruções
- Stack machine com push/pop de operandos
- Fetch-decode-execute loop
- Assembler para converter texto em bytecode
- Enums com valores para opcodes tipados
Próximos Passos
- Veja o Compilador de Expressões para gerar bytecode
- Explore o Interpretador Brainfuck para outra VM
- Construa o Protocolo Binário para serialização
- Consulte enums na documentação