Bytecode Virtual Machines

A bytecode virtual machine executes a language-defined instruction representation while maintaining runtime state. Define each instruction's operands and stack effect, then make calls, returns, captured variables, and errors explicit. Begin with a small correct execution model before optimizing dispatch.

The design is more than a switch over opcodes. A useful VM must explain which activation owns a local value, how a closure keeps a captured value alive, how invalid instructions fail, and how diagnostics point back to the source. A fast wrong answer is still a wrong answer, just delivered with enthusiasm.

TL;DR

Quick Example

This teaching VM uses Python tuples as instructions and returns 42 from an immutable captured value; it also checks for stack underflow. Test unknown opcodes, missing returns, and malformed return values the same way.

This implementation has one activation per call to run, a local operand stack, and a supplied capture tuple. It does not implement byte encoding, nested VM calls, mutable upvalues, garbage collection, verification, resource limits, or a security sandbox. Host-language addition and indexing still determine some behavior. The capture argument illustrates an environment input, not a complete closure runtime.

Core Concepts

An instruction has a contract

Specify the opcode, operand representation, stack requirement, stack result, control-flow behavior, and possible errors. ADD in the example consumes two values and produces one. RETURN requires exactly one remaining value. Those rules make malformed programs diagnosable instead of allowing accidental host exceptions to define the language.

Keep the instruction pointer meaningful. It identifies which instruction is executing and supports error reporting and control-flow transitions. For encoded bytecode, distinguish byte offsets from instruction indices and validate jump destinations against the chosen representation. The tuple VM uses instruction indices only and has no jump instruction.

Do not assume an existing implementation's bytecode is a stable interchange format. CPython explicitly documents bytecode as an implementation detail that may change across Python versions or VMs. A language runtime needs its own compatibility decision rather than a borrowed assumption. Python dis documentation

Operand stacks and call frames serve different roles

The JVM specification provides a concrete design example: each method invocation creates a frame with local variables and an operand stack, and normal or abrupt completion ends the activation. That describes JVM semantics, not a requirement that every VM use identical layouts. JVM runtime structure

For your own VM, define the return location, local slots, arguments, operand base and current function for each activation. Test that a nested call restores the caller's state correctly and cannot accidentally consume unrelated caller operands. The article's miniature runner does not implement those nested-call transitions, so it cannot validate them.

This is a proposed test matrix, not a completed implementation audit. Only the explicitly described tuple-runner cases were executed.

Closures require a lifetime policy

A closure pairs executable behavior with the environment needed for captured variables. Decide whether a captured binding behaves like a copied value or a shared mutable cell under the language's rules. If a closure outlives the function that created it, the captured storage must remain valid after that activation ends.

Test sibling closures that share a binding, closures created in repeated iterations, and closures called after their parent returns. These cases can reveal a representation that works for one immediate call but fails when lifetimes diverge. A tuple of integers avoids many of those difficulties and therefore cannot establish correct mutable-closure behavior.

Best Practices

Write instruction semantics before optimizing the dispatch loop. Keep a small reference evaluator or direct semantic model for representative programs where practical. Compare outputs and errors for valid and invalid programs so optimization preserves the language's behavior rather than merely its happy-path results.

Attach source locations to the right execution positions. Preserve function names, relevant frame context and a useful error cause without indiscriminately dumping private runtime values. Test errors during decoding, argument handling, arithmetic, nested calls and returns. Diagnostics are part of the developer experience and deserve explicit expected results.

Separate malformed-program validation from execution checks. Static validation can establish some control-flow and stack properties for a defined bytecode format, while runtime checks still handle dynamic values and host operations. Neither automatically creates a secure boundary against hostile programs. Capability restrictions and resource enforcement need their own design and review.

Use representative workloads before changing dispatch representation. A microbenchmark dominated by one arithmetic opcode may miss call overhead, object allocation, closure lifetimes, or host integration. Record the workload and implementation version before claiming a speed improvement. No dispatch-performance comparison was performed here.

Reverse a compact representation choice when diagnostics, compatibility or safety requirements demand more metadata. Conversely, a learning VM may reasonably begin with readable tuples before byte encoding. The important point is to name which requirements the implementation addresses and which remain future work.

Common Mistakes

Treating the operand stack as the call stack

Capturing temporary frame storage

Leaking host exceptions

Calling an interpreter a sandbox

FAQ

Is bytecode portable by definition?

No. Portability depends on a specified format, semantics and compatible runtimes. CPython's documented implementation detail is an example of why a bytecode representation should not automatically be treated as a cross-version contract.

Does the example implement closures?

It accepts an immutable capture tuple and reads it during one activation. It does not implement closure creation, shared mutable cells, nested VM calls or capture lifetime management. Those require additional semantics and tests.

What should come before dispatch optimization?

Correct instruction contracts, state transitions, error behavior and representative tests. Then measure the real workload. A small, understandable runner is a useful place to discover semantics before committing to a harder-to-debug representation.

Related Topics

References