Bytecode & VM: Making It Run

Lesson, slides, and applied problem sets.

View Slides

Lesson

Bytecode & VM: Building a Correct Runtime Engine

Why this module is the hardest switch in the pipeline

Up to parsing/semantic stages, you mostly manipulate syntax trees.
In the VM, every decision is observable immediately:

  • one pop from the wrong place
  • one wrong environment lookup
  • one bad jump target

...and the whole program semantics change.

This module defines the VM as a contract instead of just “some code that executes instructions.”


1) VM state model (the contract surface)

Think of the VM as two pieces:

  1. A global machine:
  • operand stack
  • call frame stack
  • function registry / code memory
  1. One current frame:
  • instruction list ([]Instr) being executed
  • instruction pointer (ip)
  • current lexical scope

Execution loop:

  1. read instruction at ip
  2. increment ip before dispatch
  3. execute opcode logic
  4. write back updated state (ip, stack, scopes, frames)

Why increment-first matters:

  • errors and debug traces naturally report “next instruction” position
  • CALL/JUMP can overwrite ip directly without special-case math

2) Invariants that prevent subtle bugs

State invariants the VM should enforce after each step:

  • ip is always in range for the current frame’s instruction list
  • operand stack pointer is non-negative
  • stack underflow never occurs silently
  • each frame has exactly one active scope when it is executing
  • return from a function leaves both caller frame and caller ip restored

If any invariant fails, stop immediately with a runtime error.
This is not “strictness for strictness”: it is reproducibility.


3) Stack discipline and binary op order

For binary operations:

  1. pop right operand first (rhs from top of stack)
  2. pop left operand (lhs)
  3. compute lhs op rhs
  4. push result

Example for 1 + 2 * 3:

PUSH_NUM 1
PUSH_NUM 2
PUSH_NUM 3
MUL
ADD

At MUL, stack is [1, 2, 3]:

  • pop rhs = 3
  • pop lhs = 2
  • push 6
  • stack becomes [1, 6]

At ADD, stack becomes [7].

Quick trace rule

Whenever a binary op fails, print:

  • operands consumed
  • operand types found
  • operation attempted

This alone catches >80% of beginner runtime crashes.


4) Scope chain and mutation semantics

Runtime scope is a stack of maps:

  • each frame owns a scope stack
  • lookups walk from current scope outward
  • shadowing = same name in inner scope

Canonical op semantics:

  • DEFINE_VAR x: create mutable binding in current scope
  • DEFINE_CONST x: create immutable binding in current scope
  • LOAD x: walk outward and copy value
  • STORE x: walk outward and mutate nearest mutable binding

If STORE encounters a const, raise const assignment error even if an outer mutable binding exists.

ENTER_SCOPE / EXIT_SCOPE:

  • push new map
  • pop map; discarded locals disappear

That makes lifetime explicit and avoids accidental state bleed between blocks.


5) Function calling protocol in depth

CALL is the VM’s contract boundary between compile-time and runtime truth.

Given a function with arity n:

  1. pop arguments in reverse order (because stack is LIFO)
  2. allocate a new frame for callee body
  3. bind popped values to parameter list [p0 ... pn-1]
  4. run callee frame to completion
  5. receive exactly one return value
  6. resume caller at instruction after CALL

Return modes:

  • explicit RETURN: value is whatever is popped and placed for caller
  • implicit end-of-code return: nil

What must not happen:

  • multiple return values from one function body
  • leftover evaluation values leaking into caller (unless explicitly pushed/preserved)
  • implicit mutation of global operand stack by callee frame

A call that is correct on paper but leaks one temporary value will still “work,” and that is precisely why this section has strict rules.


6) Control flow semantics and jump correctness

JUMP is absolute and sets ip directly. JUMP_IF_FALSE pops one condition and jumps only when condition is falsey (nil or false).

Why absolute for this stage:

  • easier to compile from CFG labels and patch in one pass
  • safer for reasoning when hand-inspecting bytecode
  • future compilers can still emit absolute offsets as a stable contract

Falsey contract:

  • only false and nil are falsey
  • every other value is truthy

Every branch that falls through without JUMP must continue with the next sequential instruction.


7) Error model and diagnostic quality

Every runtime error should be a specific class:

  • undefined name: x
  • stack underflow
  • invalid store target (const assignment)
  • bad arity / non-function call target
  • arithmetic/type mismatch
  • division by zero
  • invalid jump target (out of bounds)

Good diagnostics include:

  • opcode name
  • instruction pointer
  • operands involved
  • active frame/function name

This turns opaque failures into teachable moments and stable golden-test outputs.


8) Debugging with traces (without changing semantics)

The runtime should optionally expose a deterministic trace mode:

  • ip
  • current instruction
  • operand stack snapshot
  • scopes depth

A simple rule: trace output must not change VM behavior.
No hidden random IDs, no map iteration nondeterminism leaks.


9) Practice checkpoints (almost solved)

1) Stack pop order
Sequence:

PUSH_NUM 10
PUSH_NUM 20
PUSH_NUM 2
SUB
ADD

Question: what is final stack?
Worked answer: SUB uses 20 - 2 = 18, then 10 + 18 = 28[28].

2) Scope shadowing + exit
Program:

define x = 1
define y = "outer"
enter scope
define x = 2
store y = "inner"
exit scope
load x
load y

Expected: x = 1, y = "outer" because x is shadowed only inside scope and y is not.

3) Const behavior

define_const pi = 3.14
store pi = 2.71

Expected: runtime error "cannot assign to const 'pi'" and no stack change.

4) Control flow truth model
Condition value pushed is 0 (integer zero).
JUMP_IF_FALSE L1 should:

  • evaluate truthiness (non-falsey), so no jump
  • continue sequentially
  • not pop by mistake before checking falsey

5) Function return edge case
Function body: PUSH_NUM 99 without RETURN.
Question: what does caller receive?
Expected: nil by implicit return rule (unless your VM chooses explicit default-return behavior everywhere).

6) Error hunting
ADD executes with values [7, "x"].
Expected: arithmetic type error naming both operands and opcode.
If your error says stack underflow or nil, you likely mutated order or validation checks.

7) CALL arity check
Function f(a, b) called with one argument.
Expected: arity mismatch error before creating callee frame in a strict VM.

This module’s depth is not “more opcodes.”
It is confidence in execution guarantees.


Module Items

  • Compile to Bytecode

    Lower a full program AST into stack-based bytecode.

    hard Upgrade to Pro to access hard problems
  • Bytecode VM

    Execute stack-based bytecode with scopes, calls, and control flow.

    hard Upgrade to Pro to access hard problems
Join Discord