Chunks, Opcodes & a Stack-Based VM
Writing a Compiler/Interpreter: Advanced
Chapter 2 · Chunks, Opcodes & a Stack-Based VM
Chapter 1's own VM was a sketch — instructions as Python tuples like ('PUSH', 2.0), opcodes as strings, just enough to make an honest measurement. Nothing about that format is how a real bytecode VM actually represents a program. This chapter replaces it with the real thing: a chunk — a flat array of small-integer opcodes with a separate table for constant values — plus a disassembler for actually reading it, and a fetch-decode-execute loop that runs it. By the end, this VM will reproduce Chapter 1's own verified 14.0 result for the exact same expression, on the real representation the rest of this course builds on.
A Real Instruction Format: the Chunk
A Chunk holds three parallel pieces: the instruction bytes themselves, a separate list of the actual values the program uses (its constant pool), and a list recording which source line each byte came from, for error messages later.
code is a Python bytearray, not a list — every element must be a small unsigned integer, 0-255. That's a deliberate constraint, not an oversight: real bytecode is exactly this, a dense run of single bytes, which is what makes it compact and fast to scan compared to a list of arbitrary Python objects.
Opcodes as Small Integers
Chapter 1 used the string 'ADD' as an opcode. A real chunk uses the integer 1 instead — a value that fits in a single byte of code, matching how CPython's own compiled functions, the JVM, and Lua all represent an opcode: one small number, not a string comparison.
Constants Live Separately
An arithmetic literal like 2.0 doesn't fit in one byte — so instead of embedding it directly in the instruction stream (Chapter 1's own approach), OP_CONSTANT takes a one-byte index into the chunk's own constants list. The value itself lives once, in the constant pool; the instruction stream only ever holds small integers.
((1+2)*3-4)/5 + ((6-1)*2+3)) both ways and measuring deep memory size (sys.getsizeof, recursively, over every nested object): Chapter 1's tuple-based encoding takes 1,173 bytes; this chapter's chunk-based encoding — 26 code bytes plus a 9-entry constant pool plus the line array — takes 751 bytes. A 1.56× reduction, from replacing 17 separate Python tuple objects with one dense bytearray and a shared constant pool.
A Real Limitation, Found by Actually Hitting It
A single-byte operand for OP_CONSTANT means the constant-pool index has to fit in one byte too — which caps how many distinct constants a single chunk can hold.
emit_constant in a loop, adding a genuinely new, unique value each time: the 256th constant (index 255) is added successfully. The 257th fails immediately with ValueError: byte must be in range(0, 256) — not a bug in this chapter's own code, but bytearray itself refusing to store an index that no longer fits in one byte.
OP_CONSTANT_LONG using a 3-byte operand for chunks with more than 256 constants). This course doesn't implement that variant — a single Wisp program exercising this course's own chapters is never going to need 257 distinct literal values in one chunk. The ceiling is real, verified, and deliberately left as a known, documented boundary rather than solved for a case this course never actually hits.
Debugging Bytecode: a Disassembler
A bytearray of small integers is unreadable on sight. A disassembler turns it back into something a person can check by eye — essential for verifying the compiler in Chapter 3 is actually emitting what it's supposed to.
Hand-building a chunk for -((1.2 + 3.4) / 2), all on source line 3, and disassembling it:
OP_CONSTANT correctly advances the offset by 2 (opcode plus operand byte) while every other instruction advances by 1 — confirmed by the printed offsets running 0, 2, 4, 5, 7, 8, 9. The | marker (borrowed directly from this course's own established real-tool style) correctly suppresses a repeated line number: building a second chunk spanning two source lines instead of one produced 1 on its first instruction, then 2 on the next (a genuinely new line), then | on the instruction after that (same line as the one before it) — verified against the actual line numbers passed in, not just visually plausible output.
The VM: Fetch, Decode, Execute
The VM itself is one loop: read the byte at the instruction pointer, advance the pointer, act on what was read. No recursion, no call stack growth per instruction — exactly the property Chapter 1 verified matters once expressions get deep.
((1+2)*3-4)/5 + ((6-1)*2+3) as a real Chunk, using emit_constant and the arithmetic opcodes directly, then running it through run_vm: 14.0 — matching both Chapter 1's tree-walking evaluator and its own tuple-based VM, exactly. Three independently-built representations of the same program, three matching answers.
What Happens When Bytecode Is Wrong
Chapter 3 will generate chunks automatically, so hand corruption should never occur in practice — but deliberately breaking a chunk on purpose is the fastest way to find out what this VM actually does with bad input, rather than assuming.
OP_ADD instruction in [0, 0, 0, 1, 1, 6] used chunk.code.index(OP_ADD) — searching for a byte equal to 1. It found offset 3, not offset 4. Offset 3 isn't OP_ADD at all — it's the constant-pool index operand of the second OP_CONSTANT, which happens to equal 1 for an unrelated reason (it's the second constant, index 1). Bytes carry no built-in label saying "I am an opcode" versus "I am data" — the VM only knows which is which by tracking position through the loop. Searching by raw value instead of by position corrupted the wrong thing entirely.
OP_ADD byte (confirmed at its true offset, 4) to an unrecognized value, 42, then running the program: result 3.0 — the second operand alone, quietly returned as if nothing had gone wrong, instead of the correct 5.0. The dispatch chain has no else branch, so an unmatched instr value simply falls through the loop, doing nothing, and the next instruction runs on a stack that's now missing a step.
Adding one guard fixes it:
OP_ADD to 42 now raises RuntimeError: Unknown opcode: 42 at offset 4 instead of silently returning 3.0. Re-running the original, uncorrupted 2 + 3 program afterward still correctly returns 5.0 — the guard only fires on a value none of the real opcodes match.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Chunks are hand-assembled here, one instruction at a time | Chapter 3's compiler emits every chunk in this course automatically from Wisp source — hand-assembly was only ever a way to test the format in isolation |
| The instruction pointer is a plain integer offset into a flat array | Chapter 5's jump instructions (for if/while) work by directly overwriting an operand byte to change where ip lands — only possible because offsets are simple integers, not tree positions |
| The 256-constant ceiling, and the unknown-opcode guard | Both are examples of a theme Course 1's own Chapter 9 established directly: fail loudly and specifically, at the exact point a problem is knowable, rather than letting it surface somewhere unrelated later |
run_vm()'s own flat, non-recursive loop | Chapter 1's own verified finding — this loop's Python call-stack depth never grows with program size, unlike Course 1's tree-walking Interpreter |
Hands-On Exercises
Hand-assemble a chunk for the Wisp expression -(2 + 3) * 4 using this chapter's own emit_constant and opcode helpers, disassemble it to confirm the instruction sequence looks correct, then run it through run_vm and verify the result against a hand-computed expected value.
Modify add_constant to check whether a value is already in self.constants before appending, reusing the existing index if so. Build a chunk that pushes the literal 1.0 three hundred times in a row and confirm the 256-constant ceiling from this chapter no longer applies. Then confirm it still applies when the 300 pushed values are genuinely all different from each other.
This chapter's own unknown-opcode guard catches a corrupted opcode byte. Using the fixed VM (with the else: raise guard added), corrupt the operand byte of an OP_CONSTANT instruction instead (its constant-pool index) to an out-of-range value, and determine whether the guard added in this chapter catches that case too, or whether a different, uncaught error results. Explain precisely why, in terms of which branch of the dispatch chain actually runs.
Chapter 2 Quick Reference
- Chunk: a
bytearrayof small-integer opcodes, a separate constant pool, a parallel line-number array - Verified: the chunk format is 1.56× more compact than Chapter 1's own tuple sketch, for the identical program
- Verified: a single-byte operand caps a chunk at exactly 256 distinct constants — confirmed by actually hitting it
- Disassembler:
disassemble_instruction— verified correct offset advancement (2 bytes forOP_CONSTANT, 1 for everything else) and correct repeated-line suppression - Verified: hand-assembling Chapter 1's own 17-node expression on this chapter's real chunk format reproduces its exact
14.0result - Verified: a naive byte-value search corrupted the wrong thing — opcodes and operands share the same numeric space, distinguished only by dispatch position, not by value
- Verified: an unmatched opcode silently produced a wrong answer until an
else: raiseguard was added — then failed loudly and specifically instead - Next chapter: Compiling Expressions to Bytecode — replacing hand-assembly with a real single-pass compiler over Wisp source