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

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) -> Instructionthat 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):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector (this post)
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 binaryzigasm disasm input.bin-- disassemble binary to textzigasm hexdump input.bin-- hex dump with decoded instructionszigasm 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:
@bitCaston a rawu16extracts 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!