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.

class Chunk: def __init__(self): self.code = bytearray() # the instructions -- raw bytes self.constants = [] # actual Wisp values (floats, etc.) self.lines = [] # one entry per byte in code def write(self, byte, line): self.code.append(byte) self.lines.append(line) def add_constant(self, value): self.constants.append(value) return len(self.constants) - 1 # the index the instruction will reference

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

OP_CONSTANT, OP_ADD, OP_SUBTRACT, OP_MULTIPLY, OP_DIVIDE, OP_NEGATE, OP_RETURN = range(7)

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.

def emit_constant(chunk, value, line): idx = chunk.add_constant(value) chunk.write(OP_CONSTANT, line) chunk.write(idx, line)
Verified directly — the real chunk format is meaningfully more compact than Chapter 1's own tuple sketch, for the identical program
Encoding the same 17-node expression from Chapter 1 (((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.

Verified directly — the ceiling is exactly 256, and Python's own bytearray enforces it for free
Calling 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.
A deliberate scope simplification, not an oversight
Real VMs solve this with a second, wider instruction (clox, the C-based bytecode VM this course's design is closest to, adds an 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.

def disassemble_instruction(chunk, offset): same_line = offset > 0 and chunk.lines[offset] == chunk.lines[offset-1] line_str = " |" if same_line else f"{chunk.lines[offset]:4d}" instr = chunk.code[offset] name = OPCODE_NAMES.get(instr, f"UNKNOWN({instr})") if instr == OP_CONSTANT: const_idx = chunk.code[offset+1] value = chunk.constants[const_idx] print(f"{offset:04d} {line_str} {name:<14} {const_idx:4d} '{value}'") return offset + 2 print(f"{offset:04d} {line_str} {name}") return offset + 1

Hand-building a chunk for -((1.2 + 3.4) / 2), all on source line 3, and disassembling it:

== test chunk == 0000 3 OP_CONSTANT 0 '1.2' 0002 | OP_CONSTANT 1 '3.4' 0004 | OP_ADD 0005 | OP_CONSTANT 2 '2.0' 0007 | OP_DIVIDE 0008 | OP_NEGATE 0009 | OP_RETURN
Verified directly — the offsets and the repeated-line marker both behave correctly
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.

def run_vm(chunk): stack = [] ip = 0 code, constants = chunk.code, chunk.constants while ip < len(code): instr = code[ip]; ip += 1 if instr == OP_CONSTANT: idx = code[ip]; ip += 1 stack.append(constants[idx]) elif instr == OP_ADD: b = stack.pop(); a = stack.pop(); stack.append(a + b) # ... OP_SUBTRACT, OP_MULTIPLY, OP_DIVIDE the same shape elif instr == OP_NEGATE: a = stack.pop(); stack.append(-a) elif instr == OP_RETURN: return stack.pop() if stack else None return stack.pop() if stack else None
Verified directly — hand-assembling Chapter 1's own expression on the real chunk format reproduces its exact result
Emitting the same 17-node expression ((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.

A genuine mistake, made while building this exact test, worth keeping
The first attempt to corrupt the 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.
Verified directly — corrupting the real OP_ADD produces a silently wrong answer, not a crash
Corrupting the genuine 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:

elif instr == OP_RETURN: return stack.pop() if stack else None else: raise RuntimeError(f"Unknown opcode: {instr} at offset {ip-1}")
Verified directly — the same corrupted program now fails loudly, and correct programs are unaffected
With the guard in place, corrupting 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 findingWhat it connects to
Chunks are hand-assembled here, one instruction at a timeChapter 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 arrayChapter 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 guardBoth 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 loopChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 2 Quick Reference

  • Chunk: a bytearray of 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 for OP_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.0 result
  • 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: raise guard 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