Learn Zig Series (#135) - Bytecode Design

What will I learn?
- Why a tree-walking interpreter is slow by construction, and what a flat instruction stream buys you that a tree never can;
- What bytecode actually is -- a compact array of one-byte opcodes plus their operands -- and why every serious language runtime you have used compiles to something exactly like it;
- How to design a small stack-machine instruction set as a Zig
enum(u8), and why the stack model makes the compiler almost embarrassingly simple; - How to model a chunk -- the code bytes, a constant pool, and per-instruction line info -- as a tidy Zig struct built on the unmanaged
ArrayList; - How to write a compiler that walks the ep133 AST bottom-up and emits bytecode, so
2 + 3becomesconst, const, addwithout any cleverness; - How to build a disassembler so the opaque bytes turn back into readable mnemonics -- the single most useful debugging tool you will write this arc;
- How C, CPython, the JVM, Lua and Rust structure their real bytecode, and why yours is the same idea scaled down;
- Three exercises that push the instruction set toward something a machine can actually execute next episode.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The AST from episode 133 (the
Exprstruct with aspanand akindunion) and the type checker from episode 134 fresh in mind -- today we stop walking that tree and start compiling it; - Tagged unions from episode 6, enums from episode 6, allocators from episode 7, and the disassembler instinct from episode 61;
- 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 (#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
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design (this post)
Learn Zig Series (#135) - Bytecode Design
Last episode I closed with a small promise. Once the type checker knows the type of every expression, I said, you know things the tree could only guess at -- how many bytes a value needs, whether a + is an integer add or a float add -- and that knowledge is precisely the input the next stage needs, because we were about to stop interpreting the tree by walking it and start compiling it down to something lower and faster. "The tree got us to meaning; the next brick turns meaning into something that runs." This is that brick. Today the tree goes flat.
Flat is the operative word. For four episodes we have lived inside a tree -- lexer to tokens, tokens to a tree, a checker over the tree, a folder over the tree. A tree is a wonderful thing to reason about and a genuinely lousy thing to execute fast, and this episode is about swapping the first virtue for the second. We compile the tree ONCE into a compact array of bytes -- bytecode -- that a tiny machine can chew through without ever chasing a pointer again. We will not build that machine today (that is the very next brick), but we will design its language, write the compiler that speaks it, and build the disassembler that lets us read it back. Let's dive right in!
Solutions to Episode 134 Exercises
As always, the three exercises first, with complete code. All three build on the type checker from last episode -- the Type enum, the Checker struct with its env, and the checkBinary switch that holds the language's type policy in one place.
Exercise 1 -- A string type and typed concatenation. The task was to add a string variant to Type, teach the checker that a string literal has type string, and make + legal on two strings (producing a string) while "a" + 1 stays a mismatch. The enum grows by exactly one arm, and so does its name helper -- and because I wired the checker to return Type.name() strings in error messages, that one addition also makes string errors print nicely:
const Type = enum {
int,
float,
boolean,
string,
fn name(self: Type) []const u8 {
return switch (self) {
.int => "int",
.float => "float",
.boolean => "boolean",
.string => "string",
};
}
};
Exercise 2 -- Name the two conflicting types in the message. Last episode checkBinary failed with a generic "arithmetic needs two operands of the same numeric type". The task was to report the actual offenders -- something like "cannot combine int and boolean" -- by formatting the two Type.name() strings into an allocated message. That means the checker now needs an allocator, and a mismatch helper that builds the string. Here it is, together with the + rule that accepts two strings but nothing mixed:
fn mismatch(self: *Checker, span: Span, l: Type, r: Type) TypeError {
self.err_span = span;
self.err_msg = try std.fmt.allocPrint(self.alloc, "cannot combine {s} and {s}", .{ l.name(), r.name() });
return error.TypeMismatch;
}
fn checkBinary(self: *Checker, span: Span, op: TokenKind, l: Type, r: Type) TypeError!Type {
switch (op) {
.plus => {
if (l == .string and r == .string) return .string; // typed concatenation
if (l == r and (l == .int or l == .float)) return .int;
return self.mismatch(span, l, r);
},
.minus, .star, .slash => {
if (l == r and (l == .int or l == .float)) return l;
return self.mismatch(span, l, r);
},
else => return self.mismatch(span, l, r),
}
}
The satisfying part is that mismatch returns a TypeError, so at every call site I just return self.mismatch(...) and the error propagates with the message already stashed. Because allocPrint can fail, TypeError had to gain OutOfMemory -- which it already carried from episode 134. The test asserts both that "foo" + "bar" checks to string, and that "foo" + 1 is rejected with a message mentioning both offending type names:
test "string + string checks to string; string + int is a mismatch" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const s1 = try makeExpr(a, sp, .{ .string = "foo" });
const s2 = try makeExpr(a, sp, .{ .string = "bar" });
const cat = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = s1, .rhs = s2 } });
var checker = Checker{ .env = std.StringHashMap(Type).init(a), .alloc = a };
defer checker.env.deinit();
try std.testing.expectEqual(Type.string, try checker.checkExpr(cat));
const one = try makeExpr(a, sp, .{ .int = 1 });
const bad = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = s1, .rhs = one } });
try std.testing.expectError(error.TypeMismatch, checker.checkExpr(bad));
try std.testing.expect(std.mem.indexOf(u8, checker.err_msg, "string") != null);
try std.testing.expect(std.mem.indexOf(u8, checker.err_msg, "int") != null);
}
Exercise 3 -- Block scoping with a stack of environments. The single flat env from episode 134 meant a name, once defined, stayed defined forever. The task was to give the checker a stack of scopes: push on entering a block, pop on leaving, and look a name up from the innermost scope outward -- so a name introduced inside a block disappears when the block closes. I added a minimal block: []const Stmt statement variant to drive it, and a ScopedChecker that owns the stack:
const ScopedChecker = struct {
scopes: std.ArrayList(std.StringHashMap(Type)),
alloc: std.mem.Allocator,
err_msg: []const u8 = "",
const Err = error{ UndefinedVariable, TypeMismatch, OutOfMemory };
fn pushScope(self: *ScopedChecker) !void {
try self.scopes.append(self.alloc, std.StringHashMap(Type).init(self.alloc));
}
fn popScope(self: *ScopedChecker) void {
var top = self.scopes.pop().?;
top.deinit();
}
fn define(self: *ScopedChecker, nm: []const u8, t: Type) !void {
try self.scopes.items[self.scopes.items.len - 1].put(nm, t);
}
fn lookup(self: *ScopedChecker, nm: []const u8) ?Type {
var i = self.scopes.items.len;
while (i > 0) {
i -= 1;
if (self.scopes.items[i].get(nm)) |t| return t;
}
return null;
}
};
The whole idea lives in lookup: it counts down from the top of the stack, so an inner x shadows an outer x, and once the block's scope is popped its names are simply gone. The test builds a program that defines inside within a block and then uses it after the block, and asserts the use is caught as undefined:
test "a name bound inside a block is invisible after it closes" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const inner_val = try makeExpr(a, sp, .{ .int = 1 });
const use_after = try makeExpr(a, sp, .{ .ident = "inside" });
const block_body = [_]Stmt{
.{ .let = .{ .name = "inside", .value = inner_val, .span = sp } },
};
const program = [_]Stmt{
.{ .block = &block_body },
.{ .expr = use_after },
};
var sc = ScopedChecker{ .scopes = .empty, .alloc = a };
try sc.pushScope(); // global scope
try std.testing.expectError(error.UndefinedVariable, sc.checkStmts(&program));
}
A stack of hash maps is exactly how real compilers model lexical scope -- every language with { } blocks does some version of this. Nota bene: notice scopes is initialised with .empty and every append takes the allocator explicitly. That is the modern unmanaged ArrayList, and it is the same collection we lean on for the rest of this episode, so keep the shape in mind.
Why leave the tree behind at all?
Here is the honest question a good student asks: the tree-walker works. Episode 133 walks it, 134 checks it, and we could bolt an evaluator onto the same visitor and be done. Why complicate life with a whole new representation?
Speed, and the reason is architecture, not micro-optimisation. A tree is a cloud of little heap nodes connected by pointers. To evaluate 2 + 3 * 4, the interpreter visits the + node, follows a pointer to the left child, follows another to the right, which is a * node, follows two more pointers to its children -- and every one of those pointer hops is a potential cache miss, a jump to some unrelated address the CPU could not predict. Worse, you pay that cost every single time the expression runs. A loop that runs a body a million times re-walks the same tree a million times, re-dispatching on each node's tag over and over. We talked about cache behaviour and branch prediction back in episode 34, and a tree is close to the worst-case shape for both.
Bytecode fixes this by separating compile time from run time. You walk the tree once, up front, and flatten it into a contiguous array of bytes laid out in execution order. Then the machine runs that array in a tight loop: fetch a byte, decode which operation it is, do it, advance. The instructions sit next to each other in memory (cache-friendly), the dispatch is one predictable switch, and the expensive tree-walking happened exactly once. This compile-once-run-many split is why CPython, the JVM, Lua, Ruby, C#, WebAssembly and countless others all compile to bytecode rather than interpret an AST directly. Having said that, we are not chasing raw metal today -- we are designing the language that flat machine will speak.
An instruction set for a stack machine
Before a single byte, a design decision: what does an instruction operate on? The simplest, most classic answer is a stack machine. There are no registers to name and no addresses to juggle -- instructions push values onto an operand stack and pop them off. add means "pop two, push their sum". A literal means "push this value". That is the entire mental model, and it makes the compiler almost trivially simple, as you will see in a moment.
Our values are the same three the checker knew about, wrapped in a tagged union so the stack can hold any of them:
const Value = union(enum) {
int: i64,
float: f64,
boolean: bool,
};
And the instruction set itself is an enum(u8) -- one byte per opcode, which is where the byte in bytecode comes from. Each variant is a verb the machine will understand. Most take no operand (they just act on the stack); constant is special -- it carries a one-byte index into a side table of literals:
const OpCode = enum(u8) {
constant,
add,
sub,
mul,
div,
negate,
not,
less,
less_equal,
greater,
greater_equal,
equal,
not_equal,
op_and,
op_or,
push_true,
push_false,
ret,
};
Two design choices worth calling out. First, why not store the literal 2 directly in the instruction stream? Because values can be big (an f64 is eight bytes) and repeated, so we keep the code stream lean -- one byte of opcode, one byte of index -- and park the actual values in a constant pool. constant 0 means "push whatever lives at slot 0 of the pool". Second, push_true and push_false are their own opcodes rather than constants, because booleans are so common that giving them a dedicated one-byte instruction (no pool lookup at all) is a cheap, classic win -- CPython and the JVM both do exactly this with their LOAD_CONST-versus-dedicated-ICONST style split. The _ suffix dance (op_and, op_or) is just because and/or are Zig keywords -- a small naming tax, nothing deeper.
The chunk: bytes, constants, and lines
An opcode enum on its own is not runnable. It needs to live in a container that also holds the constant pool it references and -- for good error messages later -- the source line each byte came from. That container is universally called a chunk, and ours is a struct wrapping three unmanaged ArrayLists:
const Chunk = struct {
alloc: std.mem.Allocator,
code: std.ArrayList(u8) = .empty,
constants: std.ArrayList(Value) = .empty,
lines: std.ArrayList(usize) = .empty,
fn deinit(self: *Chunk) void {
self.code.deinit(self.alloc);
self.constants.deinit(self.alloc);
self.lines.deinit(self.alloc);
}
fn writeByte(self: *Chunk, byte: u8, line: usize) !void {
try self.code.append(self.alloc, byte);
try self.lines.append(self.alloc, line);
}
fn writeOp(self: *Chunk, op: OpCode, line: usize) !void {
try self.writeByte(@intFromEnum(op), line);
}
fn addConstant(self: *Chunk, v: Value) !u8 {
const idx = self.constants.items.len;
try self.constants.append(self.alloc, v);
return @intCast(idx);
}
};
Three small deliberate things here. writeOp is a thin wrapper over writeByte that does the @intFromEnum conversion, so the compiler never has to think about the fact that an opcode is "really" a byte -- it writes OpCode values and the chunk handles the encoding. lines grows in lockstep with code: one line number per byte. That is wasteful (a run-length encoding would be tighter, and I leave that as an exercise) but it is dead simple and means that given any code offset, we can name the source line instantly -- the same span-carrying discipline we started in episode 133, carried one layer deeper. And addConstant returns the u8 index it just wrote to, which is exactly the operand the constant opcode needs. Storing the allocator in the chunk keeps every call site clean: chunk.writeOp(.add, line) reads like a verb, not a memory-management chore.
Compiling the tree into a flat stream
Now the payoff, and it is beautiful how little code it takes. The compiler walks the AST exactly like the checker did -- one recursive function, one switch over the node kind -- but instead of returning a type it emits bytes. And the stack model makes the emission order almost write itself: to compute a binary operation, you emit the code for the left operand, then the right operand, then the operator. Post-order. The operands leave their results on the stack; the operator pops them:
const Compiler = struct {
chunk: *Chunk,
const Err = error{ UnsupportedExpr, BadOperator, OutOfMemory };
fn emitConstant(self: *Compiler, v: Value, line: usize) !void {
const idx = try self.chunk.addConstant(v);
try self.chunk.writeOp(.constant, line);
try self.chunk.writeByte(idx, line);
}
fn compileExpr(self: *Compiler, e: *const Expr) Err!void {
switch (e.kind) {
.int => |v| try self.emitConstant(.{ .int = v }, e.span.line),
.float => |v| try self.emitConstant(.{ .float = v }, e.span.line),
.boolean => |b| try self.chunk.writeOp(if (b) .push_true else .push_false, e.span.line),
.string, .ident => return error.UnsupportedExpr,
.unary => |u| {
try self.compileExpr(u.rhs);
const op: OpCode = switch (u.op) {
.minus => .negate,
.bang => .not,
else => return error.BadOperator,
};
try self.chunk.writeOp(op, e.span.line);
},
.binary => |b| {
try self.compileExpr(b.lhs);
try self.compileExpr(b.rhs);
try self.chunk.writeOp(binaryOp(b.op) orelse return error.BadOperator, e.span.line);
},
}
}
};
Read the .binary arm slowly, because it is the whole trick. compileExpr(b.lhs) recursively emits everything needed to leave the left value on the stack. Then compileExpr(b.rhs) does the same for the right. Then one opcode for the operator, which at run time will pop both and push the result. For 2 + 3 * 4, the recursion naturally emits const 2, then descends into 3 * 4 to emit const 3, const 4, mul, then add -- and the operator precedence we fought for in the parser (episode 132) is now baked into the order of the bytes. The tree's shape became the stream's order. That is not a coincidence you have to engineer; it falls out of post-order traversal for free.
The operator mapping lives in its own little function, the same "all the policy in one place" move I keep making, so adding an opcode is a one-line edit:
fn binaryOp(op: TokenKind) ?OpCode {
return switch (op) {
.plus => .add,
.minus => .sub,
.star => .mul,
.slash => .div,
.lt => .less,
.lte => .less_equal,
.gt => .greater,
.gte => .greater_equal,
.eq => .equal,
.neq => .not_equal,
.kw_and => .op_and,
.kw_or => .op_or,
else => null,
};
}
Notice .string and .ident return error.UnsupportedExpr for now. Strings need heap-managed objects and identifiers need a notion of variable storage the flat machine does not have yet -- both are jobs for later bricks in this arc. Refusing them loudly, in stead of silently emitting garbage, is the honest Zig thing to do: an unfinished feature should be a compile error you can see, not a mystery at run time.
Reading the bytes back: a disassembler
Here is a hard truth about bytecode: the moment you have it, you cannot read it. 04 00 04 01 01 means nothing to human eyes. So the very first tool you build alongside any bytecode compiler is a disassembler -- the exact inverse of the assembler's disassembler from episode 61 -- that turns the bytes back into named mnemonics. You will use it constantly to debug the compiler, and again next episode to debug the machine. Skipping it is the classic beginner mistake; writing it first is the mark of someone who has done this before.
The disassembler walks the code array, and here is the subtle bit that teaches you how the stream is really structured: it advances by a variable amount. A plain opcode is one byte, so we step by one. But constant is followed by its index byte, so we step by two. The decode loop has to know each instruction's width -- which is precisely what the machine will need to know too:
fn appendLine(out: *std.ArrayList(u8), a: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void {
const s = try std.fmt.allocPrint(a, fmt, args);
defer a.free(s);
try out.appendSlice(a, s);
}
fn disassembleInstruction(chunk: *const Chunk, offset: usize, out: *std.ArrayList(u8), a: std.mem.Allocator) !usize {
const op: OpCode = @enumFromInt(chunk.code.items[offset]);
switch (op) {
.constant => {
const idx = chunk.code.items[offset + 1];
try appendLine(out, a, "{d:0>4} {s:<14} {d}\n", .{ offset, @tagName(op), idx });
return offset + 2;
},
else => {
try appendLine(out, a, "{d:0>4} {s}\n", .{ offset, @tagName(op) });
return offset + 1;
},
}
}
fn disassemble(chunk: *const Chunk, a: std.mem.Allocator) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(a);
var offset: usize = 0;
while (offset < chunk.code.items.len) {
offset = try disassembleInstruction(chunk, offset, &out, a);
}
return out.toOwnedSlice(a);
}
The lovely part is @tagName(op), from episode 32's reflection toolkit: it hands us the string "constant" or "negate" straight from the enum, so we never maintain a parallel table of names that can drift out of sync with the opcodes. The disassembleInstruction function returns the next offset, and the outer loop just keeps assigning that back -- so the loop itself contains zero knowledge of instruction widths, all of which lives in the one switch. Compiling -5 and disassembling it prints constant 0, then negate, then ret: the flat, honest truth of what the machine will do, in the order it will do it.
Stack-based, or register-based?
I picked a stack machine, and it is worth a paragraph on the alternative because it is a real fork in the road. The other main design is a register-based bytecode, where instructions name their operands: add r3, r1, r2 means "add registers 1 and 2, store in register 3". Lua famously switched to a register VM in version 5.0 and got a nice speedup, because register code needs fewer instructions -- no separate push/pop churn -- which means fewer trips around the dispatch loop.
So why did I choose the stack? Because the compiler is dramatically simpler. Look again at our compileExpr: it never allocates a register, never tracks which ones are free, never spills anything to memory. Post-order emission and an implicit stack do all the bookkeeping. A register compiler has to solve register allocation, which is a genuinely hard problem (a whole later brick in this arc, in fact). For learning -- and for plenty of production runtimes, CPython and the JVM among them -- the stack machine's simplicity wins. You reach for registers when you have profiled and know the dispatch overhead is your bottleneck, not before. Premature register allocation is a fine way to spend a week and learn nothing.
Testing: compile, then assert on the bytes
Bytecode is wonderfully testable precisely because it is data, not behaviour. You do not need to run anything to check the compiler -- you compile an expression and assert the exact byte sequence it produced. This is the tightest possible feedback loop, and it catches emission-order bugs the instant they appear:
test "compiling 2 + 3 emits const, const, add with a filled constant pool" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const two = try makeExpr(a, sp, .{ .int = 2 });
const three = try makeExpr(a, sp, .{ .int = 3 });
const sum = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = two, .rhs = three } });
var chunk = Chunk{ .alloc = a };
defer chunk.deinit();
var compiler = Compiler{ .chunk = &chunk };
try compiler.compileExpr(sum);
try chunk.writeOp(.ret, sp.line);
const expected = [_]u8{
@intFromEnum(OpCode.constant), 0,
@intFromEnum(OpCode.constant), 1,
@intFromEnum(OpCode.add),
@intFromEnum(OpCode.ret),
};
try std.testing.expectEqualSlices(u8, &expected, chunk.code.items);
try std.testing.expectEqual(@as(usize, 2), chunk.constants.items.len);
try std.testing.expectEqual(@as(i64, 2), chunk.constants.items[0].int);
try std.testing.expectEqual(@as(i64, 3), chunk.constants.items[1].int);
}
That expectEqualSlices is asserting on the literal bytes -- constant, index 0, constant, index 1, add, ret -- and on the constant pool holding 2 and 3 at exactly those slots. If a future refactor accidentally emits the right operand before the left, this test screams immediately. The second test aims at the disassembler, checking the human-readable output is actually human-readable:
test "disassembly is human readable" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const five = try makeExpr(a, sp, .{ .int = 5 });
const neg = try makeExpr(a, sp, .{ .unary = .{ .op = .minus, .rhs = five } });
var chunk = Chunk{ .alloc = a };
defer chunk.deinit();
var compiler = Compiler{ .chunk = &chunk };
try compiler.compileExpr(neg);
try chunk.writeOp(.ret, sp.line);
const text = try disassemble(&chunk, a);
defer a.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "constant") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "negate") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "ret") != null);
}
Between these two -- assert the bytes, assert the disassembly -- you have a compiler you can trust before you have written a single line of the machine that runs it. That is the whole reason we build the disassembler first: it makes the invisible visible.
Where this sits among the grown-ups
None of this is a toy dialect of how real runtimes work -- it is the same idea with a bigger opcode table. In CPython, your .py file is compiled to exactly this: a code object holding a co_code bytes blob and a co_consts constant tuple, and you can literally see it by running import dis; dis.dis(func) -- the output is a disassembly listing that looks strikingly like ours, LOAD_CONST, BINARY_ADD and friends. The JVM compiles Java to .class files full of stack-machine bytecode (iconst, iadd, invokevirtual), which is why the same .jar runs on any platform with a JVM -- the bytecode is the portable layer. Lua compiles to a register bytecode, the road not taken above. WebAssembly is, at heart, a standardized stack-machine bytecode for the web. And Rust? rustc lowers your code through MIR (a control-flow-graph IR, closer in spirit to the register world) before machine code -- a reminder that "bytecode" is one point on a whole spectrum of intermediate representations, all serving the same goal: a form that is easier to optimise and execute than a syntax tree.
The vocabulary is shared too. "Constant pool", "opcode", "operand stack", "disassembly" -- these are the exact words the CPython and JVM source use. What you built today is the small, honest heart of the thing every one of these runtimes stands on.
Where this is heading
Take stock of what we have. A Value union for the things the machine pushes around. An OpCode enum -- a one-byte instruction set for a stack machine. A Chunk that holds the code bytes, a constant pool, and per-byte line info. A Compiler that walks the ep133 tree bottom-up and flattens it into that chunk, turning operator precedence into byte order for free. And a disassembler that reads the whole thing back so you are never staring at raw hex. The tree has become a flat, linear, cache-friendly program.
But it does not run yet. We have written the machine's language and not the machine. Everything today produced bytes and asserted on bytes; nothing pushed a value or popped one or computed 2 + 3 to actually get 5. That is the missing half, and it is the next brick: a small loop that holds an operand stack, fetches one opcode at a time, and does what each one says -- constant pushes, add pops two and pushes their sum, ret hands back the answer on top of the stack. The disassembler you built today will be the flashlight you debug it with. Build the compiler, feed it a handful of expressions, disassemble the output until the bytes read exactly the way you expect -- and you will be holding a program that is finally ready to execute. ;-)
Exercises
Add a modulo opcode end to end. Introduce a
modvariant toOpCode, map the%token (add a.percentTokenKindif you need it) to it inbinaryOp, and confirm that compiling7 % 3emitsconstant, constant, mod. Write a test asserting the exact byte sequence, and a second test asserting the disassembly contains"mod". You are wiring one operation through all three layers (opcode, compiler, disassembler) -- the same path every future instruction takes.Deduplicate the constant pool. Right now compiling
2 + 2stores the value2twice. ChangeaddConstantso that before appending, it scans the existing pool for an equalValueand returns that index instead. Write a test that compiles2 + 2and assertschunk.constants.items.len == 1while the code still emits twoconstant 0instructions. Think about whichValuevariants can be compared with==and which (floats!) deserve a wary eye.Compute the stack effect of a chunk. Every opcode has a net effect on the operand stack depth:
constantandpush_trueadd one (+1),add/sub/mul/divremove two and add one (-1),negateandnotleave depth unchanged (0),retneeds one already there. Write a function that walks a chunk and returns the maximum stack depth it ever reaches -- the exact number of slots the machine will need to pre-allocate. Test it against the compiled form of2 + 3 * 4(which should peak at depth 3). This number is not busywork -- it is precisely what the next brick uses to size its stack.
Bedankt en tot de volgende keer -- the tree is flat, the bytes are written, and next time we finally make them run! ;-)