Skip to content

Step 1 of 3 · Reading · ~3 min

Expanding the Instruction Set

Bytecode Virtual Machine

VM Instructions — Push, Pop, Arithmetic

Your VM currently only knows about numbers. Real programs need nil, booleans, comparisons, negation of truth values, printing, and a way to discard values that expression statements produce but nobody uses. This lesson extends the instruction set and — just as importantly — introduces runtime type checking now that the compiler no longer has a type-checked AST to lean on.

New opcodes

python

OP_NIL/OP_TRUE/OP_FALSE push a literal value with no operand — unlike OP_CONSTANT, these don't need to look anything up in the constant pool because there's only ever one nil, one true, one false:

python

Why the VM must type-check at runtime

In the tree-walking interpreter, every visit_* method was written for a specific node kind and Python's own dynamic typing quietly handled the rest. In the bytecode VM, OP_ADD doesn't know what kind of values are sitting on top of the stack — it just pops two things and combines them. Nothing stops a malformed or maliciously-compiled program from pushing a string and a number and hitting OP_ADD. Each arithmetic instruction now has to check types itself and raise a runtime error rather than let Python raise a confusing TypeError:

python

This is the bytecode-VM equivalent of the type checks your tree-walker did in each visit_binary — the checks moved, but they're still mandatory, and arguably more important here because there's no AST left to have validated anything ahead of time.

Truthiness and OP_NOT

Reuse the same truthiness rule you've had since if/while: only nil and false are falsy.

python

Comparisons return booleans, arithmetic doesn't

OP_EQUAL, OP_GREATER, OP_LESS are the first instructions whose result is a boolean rather than a number — and OP_EQUAL in particular has to work across any pair of value types (comparing a number to nil should yield false, not a type error), while OP_GREATER/OP_LESS should still require both operands to be numbers, the same way OP_ADD does.

OP_PRINT and OP_POP

Every expression statement in your language evaluates to a value that lands on the stack — but a bare statement like 1 + 2; on its own line doesn't print or store anything, so that value has to be explicitly discarded. This is what OP_POP is for: the compiler emits it after every expression statement to keep the stack from silently growing forever. print expr; is almost the same, except instead of discarding the value it consumes and prints it:

python

stringify needs its own formatting rules — nil prints as nil, true/false print lowercase, and numbers that happen to be whole (3.0) typically print without the trailing .0 to match how a user would type them.

Edge cases to watch

  • nil == nil must be true, and nil compared to anything else (a number, a string, false) must be false — not a runtime error.
  • String vs. number arithmetic: "a" + 1 should be a clear runtime error ("Operands must be two numbers or two strings."), unless your language supports string concatenation via +, in which case that needs its own branch checking both operands are strings.
  • Stack cleanliness: forgetting to emit OP_POP after expression statements causes the stack to grow unboundedly over a long-running program even though nothing looks obviously wrong.
Up nextInstruction Dispatch & The Main LoopBytecode Virtual Machine

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…