47. A stack-based VM
Three moving parts run every Drip program: a stack of values, a number saying where you are in the code, and a loop with a switch in it. Here they are, from the real VM.
- Explain how a stack machine evaluates an expression without naming any temporary values.
- Trace the instruction pointer through a chunk, including its operand bytes.
- Read the VM’s dispatch loop and say what each part costs.
- Write a small stack machine in JavaScript, popping operands in the right order.
Where do the half-finished answers go?
The tree-walker never had to think about this. To evaluate 1 + 2 * 3
it called itself for 2 * 3, and the 6 came back as a return value,
held in a JavaScript local until the + needed it. The host language’s
call stack was quietly storing every half-finished answer.
Bytecode has no recursion to lean on. CONSTANT 1, CONSTANT 2, CONSTANT 3, MULTIPLY, ADD is a flat list, and the 1 has to wait
somewhere while 2 * 3 happens. The answer is a stack the VM keeps for
itself.
A stack of values
A pile of plates in a canteen. You only ever touch the top: put a plate on, take a plate off. An instruction like MULTIPLY is a cook who takes the top two plates, combines what’s on them, and puts one plate back. Nobody needs to know where any plate is, only that it’s on top.
Drip’s stack is a big array plus a stack pointer, sp, the index of
the next free slot. From companion/ts/src/vm/vm.ts:
readonly stack: Value[] =
new Array(STACK_MAX).fill(null);
sp = 0;
push(v: Value) {
this.stack[this.sp++] = v;
}
pop(): Value {
return this.stack[--this.sp];
}
peek(distance: number): Value {
return this.stack[this.sp - 1 - distance];
}
The array is made once, at full size. Pushing writes a slot and moves
sp up; popping moves it down. Nothing is allocated or freed per value.
peek(0) is the top, peek(1) the one below, without removing either.
A stack machine is a VM whose instructions take their inputs from the top of a value stack and push their results back onto it. Because operands are always “the top few values”, instructions don’t need to say where their inputs are, so most need no operands at all.
The instruction pointer
The VM also needs to know where it is in the code. That’s one number:
const readByte = () => code[frame.ip++];
const readConstant = () =>
constants[readByte()];
ip always holds the index of the next byte to read. Every read
moves it on, so reading an instruction’s operand also steps over it.
After OP_CONSTANT, ip has moved by two, and lands on the next
opcode. (ip lives in frame because each function call will get its
own, in chapter 56. For now there is one frame: the script.)
The instruction pointer (ip) is the position in the bytecode of the
next byte to execute. Reading an opcode or an operand advances it; an
instruction finishes with ip pointing at the following instruction.
Later, jump instructions will change it on purpose.
The dispatch loop
Now the heart of the VM. It reads an opcode and switches on it, forever,
until an instruction tells it to stop. Step through it running the real
chunk for print 1 + 2 * 3;:
for (;;) {const op = readByte();switch (op) {case OP.CONSTANT:push(readConstant()); break;case OP.ADD: {const b = pop(), a = pop();push(a + b); break;}case OP.MULTIPLY: {const b = pop(), a = pop();push(a * b); break;}case OP.PRINT:print(pop()); break;case OP.NIL: push(null); break;case OP.RETURN: return;}}
Each case is short. Here is the real SUBTRACT:
case OP.SUBTRACT: {
numbers();
const b = this.pop() as number;
const a = this.pop() as number;
this.push(a - b);
break;
}
Two details matter. b is popped first: the right operand was pushed
last, so it’s on top. And numbers() checks the types with peek
before anything is popped:
const numbers = () => {
if (typeof this.peek(0) !== 'number' ||
typeof this.peek(1) !== 'number') {
this.runtimeError(
'Operands must be numbers.');
}
};
Checking in place leaves the stack untouched if the program fails, and later, when the garbage collector arrives, values still on the stack are safe from it. Try some arithmetic on the VM:
print 10 - 4 - 3; print (1 + 2) * -3; print 7 / 2; print "con" + "cat";
The dispatch loop is the VM’s main loop: fetch the opcode at ip,
dispatch on it (here with a switch) to the code for that
instruction, run it, and repeat. Its cost per instruction, decoding and
jumping to the right case, is paid on top of the instruction’s real
work.
V8’s Ignition chose differently. It is a register machine: its
instructions name the registers they read and write, with a special
accumulator as the implicit input and output of many of them, which
keeps long chains of operations short[2]. Its
stack frame keeps a pointer to the bytecode array and the offset of the
current bytecode, its version of ip. The V8 team also describe what
no interpreter escapes: decoding operands and dispatching to the next
bytecode, which leans on the CPU’s branch prediction to stay
fast[3]. Drip’s VM, like clox from Crafting
Interpreters, keeps the simpler stack design[1].
Your turn
Finish run(chunk). CONST (0) has one operand byte, an index into chunk.constants; push that constant. ADD, SUB and MUL (1, 2, 3) pop two values and push the result. NEG (4) pops one value and pushes its negation. RETURN (5) pops and returns the top value. CONST and RETURN are done.
const OP = {
CONST: 0, ADD: 1, SUB: 2, MUL: 3,
NEG: 4, RETURN: 5,
};
function run(chunk) {
const { code, constants } = chunk;
const st = [];
let ip = 0;
for (;;) {
const op = code[ip++];
if (op === OP.CONST) {
st.push(constants[code[ip++]]);
} else if (op === OP.RETURN) {
return st.pop();
} else {
throw new Error('bad op ' + op);
}
}
}Using the same opcodes (CONST 0 with one operand, ADD/SUB/MUL 1–3, NEG 4, RETURN 5), write maxDepth(code): the largest number of values that will ever be on the stack while the code runs. You don’t need the constants, only each instruction’s effect on the depth.
function maxDepth(code) {
return 0;
}What you learned
- A stack machine keeps intermediate values on a stack. Each instruction pops its inputs and pushes its result, so most need no operands.
- The stack is one preallocated array and
sp, the next free slot.peeklooks without popping. - The instruction pointer is the index of the next byte; reading operands moves it past them.
- The dispatch loop fetches an opcode, switches on it and runs the case, forever. Right operands are popped first. Type checks peek before popping.
Next: the compiler that writes these bytes, straight from the source, with no tree in between.
Check your understanding
0/6- 1.
The stack holds
[script, 10, 4](top on the right) and the next instruction isOP_SUBTRACT. What is on the stack afterwards? - 2.
What does the instruction pointer hold?
- 3.
Why do most instructions in a stack machine have no operands?
- 4.
OP_CONSTANTstarts at offset 0 andOP_ADDis at offset 4. Which offsets doesiphold at the start of each loop iteration for the chunk0, 0, 0, 1, 18? - 5.
Why does the VM check operand types with
peekbefore popping? - 6.
Which cost does every bytecode interpreter pay, however well it is written?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
- [2] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (checked 2026-10-31)
- [3] Leszek Swirski (2021). Sparkplug — a non-optimizing JavaScript compiler. V8 blog (v8.dev), 27 May 2021. v8.dev/blog/sparkplug (checked 2026-10-31)