Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector

in StemSocial15 hours ago

Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector

zig.png

Project G: Assembler/Disassembler (3/3)

What will I learn

  • How to read a binary file and decode instructions back to human-readable assembly text;
  • Building a decoder function: decode(u16) -> Instruction that reverses the encoding from episode 59;
  • Pretty-printing disassembled output with instruction addresses and aligned columns;
  • Building a hex dump mode that shows raw bytes alongside decoded mnemonics;
  • Heuristics for detecting data vs code regions in a flat binary;
  • Round-trip testing: assemble source, disassemble the binary, compare with original;
  • A unified CLI tool (zigasm) that combines assembler, disassembler, and VM in one binary;
  • Project retrospective: what building an assembler/disassembler taught us about Zig.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Advanced

Curriculum (of the Learn Zig Series):

Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector

In episode 59 we built an instruction encoder and VM. In episode 60 we built a two-pass assembler that turns human-readable assembly text into binary. Today we close the loop: we're building a disassembler that reads binary files and produces readable assembly text, plus a hex dump inspector that shows you exactly what's inside every byte. And then we tie the whole project together with a unified CLI that lets you assemble, disassemble, inspect, and run programs -- all from one tool.

This is the final part of Project G and the satisfying conclusion. Going from binary back to text is the inverse of what the assembler does, which means our decoder is essentially the mirror image of our encoder. If the encoder packs an opcode and operands into a 16-bit word, the decoder unpacks that word back into its constituent parts. If we've done everything correctly, we should be able to assemble a program, disassemble it, and get back something functionally identical to the original source. That round-trip property is the ultimate test that our encoding scheme is consistent and lossless (at least for the instruction data -- comments and label names are of course lost in the binary).

Here we go!

The decoder: unpacking a 16-bit word

The decoder is the inverse of the Instruction.toU16() function from episode 59. Given a raw u16 value read from a binary file, we need to extract the opcode (bits 12-15), the destination register (bits 9-11), the mode flag (bit 8), and either the source register (bits 0-2) or the immediate value (bits 0-7). We already have the packed struct layout from episode 59, so we can reuse it directly:

const std = @import("std");

const Opcode = enum(u4) {
    hlt = 0,
    mov = 1,
    add = 2,
    sub = 3,
    mul = 4,
    cmp = 5,
    jmp = 6,
    jeq = 7,
    jne = 8,
    load = 9,
    store = 10,
    push = 11,
    pop = 12,
    call = 13,
    ret = 14,
    nop = 15,
};

const Instruction = packed struct(u16) {
    operand: u8,
    mode: u1,      // 0 = register, 1 = immediate
    dst: u3,
    opcode: u4,
};

const DecodedInstruction = struct {
    opcode: Opcode,
    dst: u3,
    mode: u1,
    operand: u8,

    fn fromU16(word: u16) DecodedInstruction {
        const instr: Instruction = @bitCast(word);
        return .{
            .opcode = @enumFromInt(instr.opcode),
            .dst = instr.dst,
            .mode = instr.mode,
            .operand = instr.operand,
        };
    }
};

The DecodedInstruction struct holds the unpacked fields in a form that's easy to work with. We use @bitCast to reinterpret the raw u16 as our packed struct -- the same trick from episode 17, just in reverse. The packed struct layout guarantees the bits map to the right fields without any manual shifting or masking.

One thing to note: @enumFromInt will panic at runtime if the opcode bits contain a value outside the valid range (0-15). Since we defined all 16 possible values in our Opcode enum, that can't actually happen -- every 4-bit value maps to a valid opcode. But if we ever changed the ISA to use fewer opcodes, we'd need to handle the "unknown opcode" case gracefully. For now we're fine.

Pretty-printing disassembled instructions

A raw struct dump isn't very useful. What we want is output that looks like actual assembly listing -- addresses on the left, mnemonics and operands on the right, nicely aligned. Something like:

0x0000: MOV  R0, 5
0x0002: MOV  R1, 10
0x0004: ADD  R0, R1
0x0006: HLT

The formatter needs to know which operand format each opcode uses. HLT, NOP, and RET take no operands. JMP, JEQ, JNE, and CALL take a single address. PUSH and POP take a single register. Everything else takes a destination register and either a source register or an immediate, depending on the mode bit:

const InstructionFormatter = struct {
    fn formatInstruction(decoded: DecodedInstruction, address: u16, writer: anytype) !void {
        // address prefix
        try writer.print("0x{X:0>4}: ", .{address});

        const mnemonic = opcodeName(decoded.opcode);

        switch (decoded.opcode) {
            .hlt, .nop, .ret => {
                try writer.print("{s}\n", .{mnemonic});
            },

            .jmp, .jeq, .jne, .call => {
                try writer.print("{s:<5} 0x{X:0>4}\n", .{ mnemonic, @as(u16, decoded.operand) });
            },

            .push, .pop => {
                try writer.print("{s:<5} R{d}\n", .{ mnemonic, decoded.dst });
            },

            .mov, .add, .sub, .mul, .cmp, .load, .store => {
                if (decoded.mode == 1) {
                    // immediate mode
                    try writer.print("{s:<5} R{d}, {d}\n", .{
                        mnemonic,
                        decoded.dst,
                        decoded.operand,
                    });
                } else {
                    // register mode
                    const src: u3 = @truncate(decoded.operand);
                    try writer.print("{s:<5} R{d}, R{d}\n", .{
                        mnemonic,
                        decoded.dst,
                        src,
                    });
                }
            },
        }
    }

    fn opcodeName(op: Opcode) []const u8 {
        return switch (op) {
            .hlt => "HLT",
            .mov => "MOV",
            .add => "ADD",
            .sub => "SUB",
            .mul => "MUL",
            .cmp => "CMP",
            .jmp => "JMP",
            .jeq => "JEQ",
            .jne => "JNE",
            .load => "LOAD",
            .store => "STORE",
            .push => "PUSH",
            .pop => "POP",
            .call => "CALL",
            .ret => "RET",
            .nop => "NOP",
        };
    }
};

The {s:<5} format specifier left-aligns the mnemonic in a 5-character field, so shorter mnemonics like ADD get padded with spaces to line up with STORE. This is purely cosmetic but it makes the output much easier to read. We covered Zig's formatting system in detail back in episode 24 -- the left-align syntax is something that comes up a lot when building CLI tools.

Jump targets are displayed as hex addresses (0x0004) rather than decimal. This is standard for disassembly output -- addresses are conceptually hex values, and showing them that way makes it easy to cross-reference with a hex dump.

The disassembler: reading binary and producing text

The disassembler reads a binary file two bytes at a time, decodes each pair into an instruction, and formats it. It's surprisingly compact because the decoder does the heavy lifting:

const Disassembler = struct {
    allocator: std.mem.Allocator,
    output: std.ArrayList(u8),

    fn init(allocator: std.mem.Allocator) Disassembler {
        return .{
            .allocator = allocator,
            .output = std.ArrayList(u8).init(allocator),
        };
    }

    fn deinit(self: *Disassembler) void {
        self.output.deinit();
    }

    fn disassemble(self: *Disassembler, binary: []const u8) ![]const u8 {
        self.output.clearRetainingCapacity();
        const writer = self.output.writer();

        if (binary.len % 2 != 0) {
            try writer.print("; WARNING: binary size {d} is not a multiple of 2\n", .{binary.len});
        }

        var address: u16 = 0;
        var i: usize = 0;

        while (i + 1 < binary.len) {
            const lo: u16 = binary[i];
            const hi: u16 = binary[i + 1];
            const word = lo | (hi << 8);

            const decoded = DecodedInstruction.fromU16(word);
            try InstructionFormatter.formatInstruction(decoded, address, writer);

            address += 2;
            i += 2;
        }

        // trailing byte (if odd length)
        if (i < binary.len) {
            try writer.print("0x{X:0>4}: .byte 0x{X:0>2}\n", .{ address, binary[i] });
        }

        return self.output.items;
    }
};

A couple of design decisions here. First, we read little-endian -- the low byte comes first, then the high byte. This matches how the assembler from episode 60 writes its output. If you read the bytes in the wrong order you get garbage instructions. Endianness bugs are among the most annoying things in systems programming because the output looks "almost right" but every instruction decodes to something slightly wrong.

Second, we handle the edge case of odd-length files. If the binary has an extra byte at the end (which shouldn't happen if it was produced by our assembler, but we're being defensive), we emit it as a .byte directive -- a common convention in disassembly listings for raw data.

The output goes into an ArrayList(u8) which acts as a string builder. We use the ArrayList's writer interface, which is the same pattern we used in the markdown renderer back in episode 39. The writer interface is one of those Zig patterns that once you learn it, you use it everywhere.

Hex dump mode: bytes alongside instructions

Sometimes you need to see the raw bytes. A hex dump that shows the encoded bytes next to the decoded instruction is invaluable for debugging encoding issues. Here's what the output looks like:

ADDR   HEX      INSTRUCTION
0x0000 0105     MOV  R0, 5
0x0002 0A11     MOV  R1, 10
0x0004 0022     ADD  R0, R1
0x0006 0000     HLT

And the implementation:

fn hexDump(self: *Disassembler, binary: []const u8) ![]const u8 {
    self.output.clearRetainingCapacity();
    const writer = self.output.writer();

    try writer.print("ADDR   HEX      INSTRUCTION\n", .{});

    var address: u16 = 0;
    var i: usize = 0;

    while (i + 1 < binary.len) {
        const lo = binary[i];
        const hi = binary[i + 1];
        const word: u16 = @as(u16, lo) | (@as(u16, hi) << 8);

        const decoded = DecodedInstruction.fromU16(word);

        // address and raw bytes
        try writer.print("0x{X:0>4} {X:0>2}{X:0>2}     ", .{ address, lo, hi });

        // instruction mnemonic
        const mnemonic = InstructionFormatter.opcodeName(decoded.opcode);

        switch (decoded.opcode) {
            .hlt, .nop, .ret => {
                try writer.print("{s}\n", .{mnemonic});
            },
            .jmp, .jeq, .jne, .call => {
                try writer.print("{s:<5} 0x{X:0>4}\n", .{
                    mnemonic,
                    @as(u16, decoded.operand),
                });
            },
            .push, .pop => {
                try writer.print("{s:<5} R{d}\n", .{ mnemonic, decoded.dst });
            },
            else => {
                if (decoded.mode == 1) {
                    try writer.print("{s:<5} R{d}, {d}\n", .{
                        mnemonic, decoded.dst, decoded.operand,
                    });
                } else {
                    const src: u3 = @truncate(decoded.operand);
                    try writer.print("{s:<5} R{d}, R{d}\n", .{
                        mnemonic, decoded.dst, src,
                    });
                }
            },
        }

        address += 2;
        i += 2;
    }

    if (i < binary.len) {
        try writer.print("0x{X:0>4} {X:0>2}       .byte 0x{X:0>2}\n", .{
            address, binary[i], binary[i],
        });
    }

    return self.output.items;
}

The hex dump is essentially the same loop as the disassembler but with the raw byte columns prepended. Real tools like objdump -d or ndisasm produce similar output. It's redundant information (the hex bytes encode the same thing the mnemonic shows) but seeing both simultaneously is extremely useful when debugging instruction encoding. You can visually verify that MOV R0, 5 is indeed encoded as 0105 and not something else.

Detecting data vs code regions

Real binaries often mix code and data. A flat binary might have instructions from address 0x0000 to 0x0020, then a block of string data from 0x0022 to 0x0040, then more code. Blindly disassembling data bytes as instructions produces nonsense -- the opcode field of a random data byte migth map to MUL but the operands will be garbage.

For our simple binary format we don't have section headers to tell us where code ends and data begins. But we can use heuristics. The approach: scan through instructions and track control flow. If we see a HLT or RET, the next bytes might be data (or a separate function). If we see an impossible instruction -- say, a register index that doesn't exist -- it's probably data:

const RegionKind = enum { code, data, unknown };

const Region = struct {
    start: u16,
    end: u16,
    kind: RegionKind,
};

fn analyzeRegions(binary: []const u8, allocator: std.mem.Allocator) ![]Region {
    var regions = std.ArrayList(Region).init(allocator);
    var i: usize = 0;
    var region_start: u16 = 0;
    var in_code = true;
    var consecutive_suspicious: usize = 0;

    while (i + 1 < binary.len) {
        const lo: u16 = binary[i];
        const hi: u16 = binary[i + 1];
        const word = lo | (hi << 8);

        const decoded = DecodedInstruction.fromU16(word);

        // heuristics for "is this likely data?"
        const suspicious = blk: {
            // after HLT, the next bytes are probably data or a new function
            if (decoded.opcode == .hlt and i + 2 < binary.len) {
                break :blk false; // HLT itself is valid, but mark transition
            }

            // all-zero word could be HLT (opcode 0) -- but also could be null data
            if (word == 0 and i > 0) {
                // check if previous instruction was also zero
                const prev_lo: u16 = binary[i - 2];
                const prev_hi: u16 = binary[i - 1];
                const prev = prev_lo | (prev_hi << 8);
                if (prev == 0) break :blk true; // consecutive zeros = probably data
            }

            break :blk false;
        };

        if (suspicious) {
            consecutive_suspicious += 1;
        } else {
            consecutive_suspicious = 0;
        }

        // transition from code to data after 3+ suspicious words
        if (in_code and consecutive_suspicious >= 3) {
            try regions.append(.{
                .start = region_start,
                .end = @truncate(i - 6),
                .kind = .code,
            });
            region_start = @truncate(i - 6);
            in_code = false;
        }

        // transition from data to code if we see a clear instruction
        if (!in_code and !suspicious and decoded.opcode != .hlt) {
            try regions.append(.{
                .start = region_start,
                .end = @truncate(i),
                .kind = .data,
            });
            region_start = @truncate(i);
            in_code = true;
        }

        // HLT marks potential code/data boundary
        if (decoded.opcode == .hlt) {
            try regions.append(.{
                .start = region_start,
                .end = @truncate(i + 2),
                .kind = .code,
            });
            region_start = @truncate(i + 2);
            // next region is unknown until we classify it
            in_code = true;
            consecutive_suspicious = 0;
        }

        i += 2;
    }

    // final region
    if (region_start < binary.len) {
        try regions.append(.{
            .start = region_start,
            .end = @truncate(binary.len),
            .kind = if (in_code) .code else .data,
        });
    }

    return regions.toOwnedSlice();
}

This is intentionally simple and imperfect. Real disassemblers (IDA Pro, Ghidra, radare2) use much more sophisticated techniques: recursive descent following control flow, pattern matching for function prologues/epilogues, cross-referencing jump targets. Our linear sweep with "consecutive suspicious" heuristic catches the most obvious cases -- runs of zero bytes, or garbage after a HLT -- but it won't catch every code/data boundary.

Having said that, for the programs produced by our assembler this heuristic is perfectly adequate. Our programs don't embed data in the instruction stream, so every word is a valid instruction. The region analysis becomes more interesting when inspecting binaries from external sources or when we eventually add data sections to our assembler.

Round-trip testing: the ultimate correctness check

The best test for our assembler/disassembler pair is round-trip verification: assemble source text into binary, disassemble the binary back into text, and verify the instructions match. Label names will be lost (they exist only in the assembler's symbol table, not in the binary) but the instruction mnemonics and operands should be identical:

const testing = std.testing;

test "round-trip: assemble then disassemble" {
    const allocator = testing.allocator;

    const source =
        \\    MOV R0, 0
        \\    MOV R1, 1
        \\    MOV R2, 10
        \\loop:
        \\    ADD R0, R1
        \\    ADD R1, 1
        \\    CMP R1, R2
        \\    JNE loop
        \\    ADD R0, R1
        \\    HLT
    ;

    // step 1: assemble
    var asm_state = Assembler.init(allocator);
    defer asm_state.deinit();

    const asm_result = try asm_state.assemble(source);
    const binary = switch (asm_result) {
        .failure => return error.AssemblyFailed,
        .success => |s| s.binary,
    };

    // step 2: disassemble
    var disasm = Disassembler.init(allocator);
    defer disasm.deinit();

    const output = try disasm.disassemble(binary);

    // step 3: verify -- each instruction should appear in the output
    try testing.expect(std.mem.indexOf(u8, output, "MOV  R0, 0") != null);
    try testing.expect(std.mem.indexOf(u8, output, "MOV  R1, 1") != null);
    try testing.expect(std.mem.indexOf(u8, output, "MOV  R2, 10") != null);
    try testing.expect(std.mem.indexOf(u8, output, "ADD  R0, R1") != null);
    try testing.expect(std.mem.indexOf(u8, output, "ADD  R1, 1") != null);
    try testing.expect(std.mem.indexOf(u8, output, "CMP  R1, R2") != null);
    try testing.expect(std.mem.indexOf(u8, output, "HLT") != null);

    // JNE target should be an address, not "loop" (label names are lost)
    try testing.expect(std.mem.indexOf(u8, output, "JNE") != null);
}

test "round-trip: subroutine with CALL/RET" {
    const allocator = testing.allocator;

    const source =
        \\    MOV R0, 7
        \\    MOV R1, 3
        \\    CALL multiply
        \\    HLT
        \\multiply:
        \\    MUL R0, R1
        \\    RET
    ;

    var asm_state = Assembler.init(allocator);
    defer asm_state.deinit();

    const asm_result = try asm_state.assemble(source);
    const binary = switch (asm_result) {
        .failure => return error.AssemblyFailed,
        .success => |s| s.binary,
    };

    var disasm = Disassembler.init(allocator);
    defer disasm.deinit();

    const output = try disasm.disassemble(binary);

    try testing.expect(std.mem.indexOf(u8, output, "MOV  R0, 7") != null);
    try testing.expect(std.mem.indexOf(u8, output, "MOV  R1, 3") != null);
    try testing.expect(std.mem.indexOf(u8, output, "CALL") != null);
    try testing.expect(std.mem.indexOf(u8, output, "MUL  R0, R1") != null);
    try testing.expect(std.mem.indexOf(u8, output, "RET") != null);
    try testing.expect(std.mem.indexOf(u8, output, "HLT") != null);
}

The round-trip test proves that our encoding scheme is self-consistent. If the encoder puts the opcode in bits 12-15 and the decoder reads bits 12-15 for the opcode, the round-trip works. If there's a mismatch -- say, one side uses big-endian and the other uses little-endian -- the decoded instructions will be wrong and the string comparisons will fail.

Notice we don't test for exact string equality on the entire output. That would be brittle -- spacing changes, address format changes, even a newline difference would break the test. Instead we check that each expected mnemonic/operand pair appears somewhere in the output. This tests the semantics (correct decoding) without coupling to the formatting details.

The unified CLI: zigasm

Time to package everything into a single tool. The zigasm CLI has four subcommands:

  • zigasm asm input.asm output.bin -- assemble text to binary
  • zigasm disasm input.bin -- disassemble binary to text
  • zigasm hexdump input.bin -- hex dump with decoded instructions
  • zigasm run input.asm -- assemble and execute in the VM
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer {
        const check = gpa.deinit();
        if (check == .leak) @panic("memory leak detected");
    }
    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len < 3) {
        printUsage();
        std.process.exit(1);
    }

    const command = args[1];
    const input_path = args[2];

    if (std.mem.eql(u8, command, "asm")) {
        const output_path = if (args.len >= 4) args[3] else "out.bin";
        try cmdAssemble(allocator, input_path, output_path);
    } else if (std.mem.eql(u8, command, "disasm")) {
        try cmdDisassemble(allocator, input_path);
    } else if (std.mem.eql(u8, command, "hexdump")) {
        try cmdHexdump(allocator, input_path);
    } else if (std.mem.eql(u8, command, "run")) {
        try cmdRun(allocator, input_path);
    } else {
        std.debug.print("Unknown command: {s}\n", .{command});
        printUsage();
        std.process.exit(1);
    }
}

fn printUsage() void {
    std.debug.print(
        \\Usage:
        \\  zigasm asm <input.asm> [output.bin]   Assemble source to binary
        \\  zigasm disasm <input.bin>              Disassemble binary to text
        \\  zigasm hexdump <input.bin>             Hex dump with decoded instructions
        \\  zigasm run <input.asm>                 Assemble and execute
        \\
    , .{});
}

Each subcommand is a separate function. Let me show the run command since it ties everything together -- assemble, load, execute, and display the final register state:

fn cmdRun(allocator: std.mem.Allocator, input_path: []const u8) !void {
    const source = std.fs.cwd().readFileAlloc(
        allocator, input_path, 1024 * 1024,
    ) catch |err| {
        std.debug.print("error: could not read '{s}': {}\n", .{ input_path, err });
        std.process.exit(1);
    };
    defer allocator.free(source);

    // assemble
    var asm_state = Assembler.init(allocator);
    defer asm_state.deinit();

    const result = try asm_state.assemble(source);

    switch (result) {
        .failure => |f| {
            std.debug.print("Assembly failed with {d} error(s):\n", .{f.errors.len});
            for (f.errors) |err| {
                err.format(std.io.getStdErr().writer()) catch {};
            }
            std.process.exit(1);
        },
        .success => |s| {
            std.debug.print("Assembled {d} instructions ({d} bytes)\n", .{
                s.instruction_count, s.binary.len,
            });

            // load into VM
            var vm = try VM.init(allocator);
            defer vm.deinit();

            @memcpy(vm.memory[0..s.binary.len], s.binary);

            // execute
            std.debug.print("Running...\n", .{});
            vm.run();

            // show final state
            std.debug.print("\n--- Final Register State ---\n", .{});
            var r: u4 = 0;
            while (r < 8) : (r += 1) {
                const val = vm.regs.get(@truncate(r));
                if (val != 0) {
                    std.debug.print("  R{d} = {d} (0x{X:0>4})\n", .{ r, val, val });
                }
            }
            std.debug.print("  PC = 0x{X:0>4}\n", .{vm.pc});
        },
    }
}

fn cmdAssemble(allocator: std.mem.Allocator, input_path: []const u8, output_path: []const u8) !void {
    const source = std.fs.cwd().readFileAlloc(
        allocator, input_path, 1024 * 1024,
    ) catch |err| {
        std.debug.print("error: could not read '{s}': {}\n", .{ input_path, err });
        std.process.exit(1);
    };
    defer allocator.free(source);

    var asm_state = Assembler.init(allocator);
    defer asm_state.deinit();

    const result = try asm_state.assemble(source);

    switch (result) {
        .failure => |f| {
            std.debug.print("Assembly failed with {d} error(s):\n", .{f.errors.len});
            for (f.errors) |err| {
                err.format(std.io.getStdErr().writer()) catch {};
            }
            std.process.exit(1);
        },
        .success => |s| {
            const out_file = try std.fs.cwd().createFile(output_path, .{});
            defer out_file.close();
            try out_file.writeAll(s.binary);

            std.debug.print("Assembled {d} instructions ({d} bytes) -> {s}\n", .{
                s.instruction_count, s.binary.len, output_path,
            });
            asm_state.symbols.dump();
        },
    }
}

fn cmdDisassemble(allocator: std.mem.Allocator, input_path: []const u8) !void {
    const binary = std.fs.cwd().readFileAlloc(
        allocator, input_path, 1024 * 1024,
    ) catch |err| {
        std.debug.print("error: could not read '{s}': {}\n", .{ input_path, err });
        std.process.exit(1);
    };
    defer allocator.free(binary);

    var disasm = Disassembler.init(allocator);
    defer disasm.deinit();

    const output = try disasm.disassemble(binary);
    const stdout = std.io.getStdOut().writer();
    try stdout.writeAll(output);
}

fn cmdHexdump(allocator: std.mem.Allocator, input_path: []const u8) !void {
    const binary = std.fs.cwd().readFileAlloc(
        allocator, input_path, 1024 * 1024,
    ) catch |err| {
        std.debug.print("error: could not read '{s}': {}\n", .{ input_path, err });
        std.process.exit(1);
    };
    defer allocator.free(binary);

    var disasm = Disassembler.init(allocator);
    defer disasm.deinit();

    const output = try disasm.hexDump(binary);
    const stdout = std.io.getStdOut().writer();
    try stdout.writeAll(output);
}

With this CLI you can go through the full development cycle. Write your program in a .asm file, assemble it with zigasm asm, inspect the binary with zigasm hexdump, and run it with zigasm run. If something goes wrong, zigasm disasm the binary to see exactly what instructions were encoded. This is exactly the workflow used by developers working with real assemblers and debuggers -- just scaled down to our 16-bit teaching ISA.

The register dump at the end of cmdRun only shows non-zero registers. This avoids cluttering the output with six lines of "R2 = 0, R3 = 0, R4 = 0" that don't tell you anything. For debugging you might want to see all registers, but for normal runs the non-zero filter keeps things clean.

Testing the full CLI pipeline

Let's verify the entire workflow with a complete test that exercises assemble, disassemble, and hexdump together:

test "CLI pipeline: asm -> disasm -> verify" {
    const allocator = testing.allocator;

    // original source
    const source =
        \\    MOV R0, 0
        \\    MOV R1, 1
        \\    MOV R3, 5
        \\loop:
        \\    ADD R0, R1
        \\    ADD R1, 1
        \\    CMP R1, R3
        \\    JNE loop
        \\    HLT
    ;

    // assemble
    var asm_state = Assembler.init(allocator);
    defer asm_state.deinit();
    const result = try asm_state.assemble(source);
    const binary = switch (result) {
        .failure => return error.AssemblyFailed,
        .success => |s| s.binary,
    };

    try testing.expectEqual(@as(usize, 9), binary.len / 2); // 9 instructions

    // disassemble
    var disasm = Disassembler.init(allocator);
    defer disasm.deinit();
    const text = try disasm.disassemble(binary);

    // verify key instructions survived the round trip
    try testing.expect(std.mem.indexOf(u8, text, "MOV") != null);
    try testing.expect(std.mem.indexOf(u8, text, "ADD") != null);
    try testing.expect(std.mem.indexOf(u8, text, "CMP") != null);
    try testing.expect(std.mem.indexOf(u8, text, "JNE") != null);
    try testing.expect(std.mem.indexOf(u8, text, "HLT") != null);

    // hexdump
    const hex = try disasm.hexDump(binary);
    try testing.expect(std.mem.indexOf(u8, hex, "ADDR") != null);

    // run in VM and verify
    var vm = try VM.init(allocator);
    defer vm.deinit();
    @memcpy(vm.memory[0..binary.len], binary);
    vm.run();

    // sum of 1+2+3+4 = 10 (loop runs for R1 = 1,2,3,4, stops when R1 reaches 5)
    try testing.expectEqual(@as(u16, 10), vm.regs.get(0));
}

This test does everything: assemble text, disassemble the result, generate a hex dump, and run the binary in the VM. If any stage produces wrong output the test fails. It's a confidence test -- when this passes, you know the entire pipeline is consistent.

Project retrospective: what we built

Project G gave us three things across three episodes:

Episode 59 -- the foundation. We designed a 16-opcode ISA, built packed struct encoding/decoding, and wrote a VM that fetches, decodes, and executes instructions. The key Zig concepts: packed structs for bit-level control (episode 17), @bitCast for type-punning between integers and structs, enum-based dispatch for the execution loop.

Episode 60 -- the assembler. A two-pass architecture that reads human-readable text and produces binary. Pass 1 collects label addresses into a symbol table (using hash maps from episode 22), pass 2 resolves forward references and encodes instructions. Key concept: separating "where is everything?" from "what does everything mean?" is a foundational principle in compiler construction.

Episode 61 -- the disassembler and tooling. The inverse of the assembler: binary to text. Plus a hex dump inspector and a unified CLI that ties the whole project together. The round-trip test (text -> binary -> text) validates that encoding and decoding are mirror operations.

What's remarkable is how much of this project reused concepts from earlier in the series. File I/O from episode 10. Testing from episode 12. Tagged unions from episode 6. Error handling from episode 4. Memory management from episode 7. Each concept built on the last, and now they all come together in a real tool.

That's the thing about systems programming -- you don't just learn individual features and forget them. Each concept layers on top of the others. Packed structs are useful for encoding. Encoding is useful for assemblers. Assemblers are useful for understanding how computers actually work. And understanding how computers work makes you a better programmer in every language, not just Zig ;-)

Wat we geleerd hebben

  • The decoder is the mirror of the encoder: @bitCast on a raw u16 extracts the packed struct fields (opcode, destination, mode, operand) without manual bit shifting
  • Pretty-printing uses Zig's format strings with left-alignment ({s:<5}) to produce properly columned output
  • The disassembler reads binary two bytes at a time in little-endian order, decodes each word, and formats it as an assembly listing with hex addresses
  • Hex dump mode shows raw byte pairs alongside decoded instructions -- invaluable for verifying that the encoding matches expectations
  • Region detection uses heuristics (consecutive zeros, HLT boundaries) to guess where code ends and data begins, though real disassemblers use much more sophisticated techniques
  • Round-trip testing (assemble, then disassemble) validates encoding consistency -- labels are lost but instruction mnemonics and operands should survive the trip
  • The unified CLI (zigasm) wraps assemble, disassemble, hexdump, and run commands behind a single binary, giving a complete development workflow for our teaching ISA
  • This project combined packed structs, hash maps, file I/O, error handling, tagged unions, testing, and CLI argument parsing -- concepts from across the entire series working together in one tool

This wraps up Project G and the assembler/disassembler mini-project. We went from raw bit encoding to human-readable assembly and back again, building every layer ourselves. Next time we're moving into a completely diferent domain -- working with the file system at a low level, reading directory contents and metadata.

Bedankt en tot de volgende keer!

@scipio