Máquina Virtual Stack-Based em Zig — Tutorial Passo a Passo

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

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

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.