15.2 Bytecode Interpreters
An interpreter repeatedly fetches an opcode, decodes its operands, performs the operation, and selects the next instruction. That loop looks small, but it sits on every executed bytecode and coordinates the VM's value representation, call frames, exceptions, garbage collector, debugger, and native interface. Interpreter engineering is therefore dominated by clear invariants and measured hot paths.
A simple stack interpreter maintains an instruction pointer ip, operand-stack pointer sp, current frame, and bytecode array:
while true:
opcode = code[ip++]
switch opcode:
CONST: stack[sp++] = constants[read_u16()]
ADD: stack[sp-2] = add(stack[sp-2], stack[sp-1]); sp--
JUMP: ip += read_i16()
CALL: push_frame(...)
RET: restore_caller(...)Production implementations also validate bounds, preserve source positions, poll for interrupts or GC safepoints, and route exceptional results without corrupting VM state.
Dispatch strategies shape the hottest loop
Portable C/C++ interpreters commonly use a switch. The compiler may translate it into a jump table, but every opcode returns to the central dispatch site. Direct-threaded dispatch, available through compiler extensions on some platforms, stores handler addresses in a table and lets each handler jump directly to the next. This can improve branch prediction because distinct opcode transitions have distinct indirect-branch sites, at the cost of portability and larger handler code.
Superinstructions fuse frequent sequences such as load_local; load_const; add into one internal opcode. They reduce dispatches and expose local optimization, but increase the opcode set and instruction-cache footprint. A VM can generate superinstructions ahead of time, rewrite decoded bytecode after profiling, or use a quickening scheme that replaces a generic operation with a specialized one once types are observed.
Frames make calls, exceptions, and GC concrete
Each function activation needs a frame containing its return instruction pointer, caller link, arguments, locals or virtual registers, operand-stack base, closure environment, and possibly an exception-handler cursor. The representation may be one contiguous VM stack for cache locality, heap-allocated frames for continuations, or a hybrid. Recursion and large frames require explicit overflow checks; relying on a native crash is not a language-level stack-overflow diagnostic.
A call checks arity, creates or reuses a frame, installs parameters, and transfers ip to the callee. A return moves the result to the caller's expected position and restores its state. Tail calls can reuse a frame when the language's debugging and stack-inspection semantics permit it. Closures keep captured environments alive independently from the activation that created them.
Exception throwing searches handler tables for a protected range and matching type or category. Frames without a handler are unwound, which may run cleanup/finally logic before continuing the search. Every transition must leave the operand stack at the handler's declared state. The same frames are roots for garbage collection: at a safepoint, the collector must distinguish references from integers, floats, and uninitialized slots using tags or stack maps.
Values, specialization, and correctness boundaries
Dynamically typed VMs need a compact value representation. A tagged union stores a tag and payload explicitly. Pointer tagging uses otherwise-aligned low bits. NaN-boxing uses unused IEEE-754 NaN payload patterns to encode pointers, integers, booleans, and null alongside doubles. Each approach has architecture, GC, debugging, and foreign-function implications; the runtime must never confuse arbitrary bits with a live reference.
Generic ADD may implement integer addition, floating arithmetic, string concatenation, or user-defined dispatch. A specialized ADD_INT can check tags and execute the common path cheaply, falling back on overflow or unexpected types. Quickening must remain semantically equivalent and reversible when assumptions change.
Testing an interpreter goes beyond opcode unit tests. Verify malformed-bytecode rejection, stack-height joins, arithmetic boundaries, branch offsets, recursion overflow, nested exceptions, finally blocks, GC at every safepoint, native-call reentrancy, debugger stepping, and asynchronous interruption. Differential testing can execute the same generated program in a reference interpreter and optimized VM. Fuzzers should mutate byte streams only after the verifier boundary is explicit: untrusted malformed input must produce a controlled rejection, never memory corruption.