Build your own bytecode VM, and see how languages really run
When you run a Python file, Python does not execute your text. It first compiles it to bytecode, a compact list of simple instructions, and then runs that bytecode on a virtual machine. Java does the same (the JVM), so does C# (the CLR). "Virtual machine" sounds like a deep systems topic, but the core of a bytecode VM is almost embarrassingly small: a loop that reads instructions and pushes and pops a stack.
Build one and the phrase "compiles to bytecode" stops being mysterious. We will write a VM in about 30 lines, then a tiny compiler that turns an expression into bytecode for it to run.
The one idea: a stack machine
Most bytecode VMs are stack machines. There are no variables in the engine itself, just a stack and a handful of instructions that manipulate it:
PUSH xputs a value on the stack.ADDpops the top two, pushes their sum.MULpops the top two, pushes their product.PRINTshows the top of the stack.
To compute (2 + 3) * 4, you push 2, push 3, add (stack now has 5), push 4, multiply (stack now has 20). The expression became a flat list of stack operations. That flattening is the whole trick, and it is why bytecode is fast: each instruction is a tiny, fixed operation.
The VM: a loop over instructions
def run(program):
stack = []
for op, *arg in program:
if op == "PUSH":
stack.append(arg[0])
elif op == "ADD":
b = stack.pop(); a = stack.pop(); stack.append(a + b)
elif op == "MUL":
b = stack.pop(); a = stack.pop(); stack.append(a * b)
elif op == "SUB":
b = stack.pop(); a = stack.pop(); stack.append(a - b)
elif op == "PRINT":
print(stack[-1])
else:
raise ValueError(f"unknown op {op}")
return stack
That is a working virtual machine. Run our example:
run([("PUSH", 2), ("PUSH", 3), ("ADD",), ("PUSH", 4), ("MUL",), ("PRINT",)])
# prints 20
Two details that matter:
- Order matters for non-commutative ops. In
SUB, we popbthenaand computea - b, becauseawas pushed first and sits belowb. Get this backward and5 - 3becomes-2. The stack's last-in-first-out order is the whole reason the compiler must emit operands before operators. - The VM is dumb on purpose. It has no idea what an expression is; it just executes tiny steps. All the cleverness lives in whatever produces the bytecode. That separation, smart compiler, simple VM, is exactly how real language runtimes are built.
The compiler: from a tree to bytecode
Nobody writes bytecode by hand. A compiler turns a parsed expression (an abstract syntax tree) into it. Represent the tree as nested tuples, and compile it with a post-order walk, children first, then the operator, which is precisely the push-operands-then-operate order the stack needs:
def compile_expr(node):
if isinstance(node, (int, float)):
return [("PUSH", node)]
op, left, right = node # e.g. ("+", 2, ("*", 3, 4))
code = compile_expr(left) + compile_expr(right)
code.append({"+": ("ADD",), "-": ("SUB",), "*": ("MUL",)}[op])
return code
tree = ("*", ("+", 2, 3), 4) # (2 + 3) * 4
program = compile_expr(tree) + [("PRINT",)]
print(program)
# [('PUSH', 2), ('PUSH', 3), ('ADD',), ('PUSH', 4), ('MUL',), ('PRINT',)]
run(program) # 20
The recursion mirrors the tree, and because it emits children before their operator, the bytecode comes out in exactly the order the stack machine wants. This is the same shape as the JSON parser's recursion, one function walking a recursive structure, applied to code generation instead of parsing.
Why a VM at all?
Why not just walk the tree and evaluate it directly? Three reasons, and they are why real languages bother:
- Speed. A flat array of simple instructions is far faster to execute repeatedly than re-walking a tree of objects. This is why CPython compiles to bytecode once and runs it many times.
- Portability. Bytecode is the same regardless of CPU. Compile once, run the VM anywhere, which is Java's entire "write once, run anywhere" promise.
- A clean seam. The compiler and the runtime are decoupled, so you can optimize the bytecode, or even JIT-compile it to machine code later, without touching the language's front end.
What you just built
A stack, a loop, and a recursive compiler is the heart of how Python, Java, and C# actually run. Add instructions and you grow a real language: LOAD/STORE for variables, JUMP/JUMP_IF_FALSE for control flow, CALL/RETURN for functions. Each is a few more lines in the same loop. The fearsome "virtual machine" is just this, scaled up.
If you want to take it all the way, from source text to tokens to a tree to bytecode to a running language, that is exactly the arc the compilers track walks, and it starts with a loop over a stack just like this one.