Viscosity
Review
Part IV · A bytecode virtual machine · Day 12

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.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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.

Definition

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.)

Definition

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;:

Step throughRunning print 1 + 2 * 3;
The VM runs the bytes 0 0 0 1 0 2 20 18 24 1 33. The instruction pointer moves along the bytes; the stack grows to 1, 2, 3, then becomes 1, 6, then 7, which is printed.code001020314052620718824911033ipstack (top is newest)0<script>← sp = 1output
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;
}
}
Before the loop starts: the instruction pointer (ip) is 0, the first byte. The stack already holds one thing, the script being run, in slot 0. Watch the ip and the stack.
1/9

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:

Try itArithmetic on the stack machine
print 10 - 4 - 3;
print (1 + 2) * -3;
print 7 / 2;
print "con" + "cat";
Definition

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.

Under the hood

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

ExerciseA small stack machine

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);
    }
  }
}
Extra practiceHow deep does the stack get?

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. peek looks 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. 1.

    The stack holds [script, 10, 4] (top on the right) and the next instruction is OP_SUBTRACT. What is on the stack afterwards?

  2. 2.

    What does the instruction pointer hold?

  3. 3.

    Why do most instructions in a stack machine have no operands?

  4. 4.

    OP_CONSTANT starts at offset 0 and OP_ADD is at offset 4. Which offsets does ip hold at the start of each loop iteration for the chunk 0, 0, 0, 1, 18?

  5. 5.

    Why does the VM check operand types with peek before popping?

  6. 6.

    Which cost does every bytecode interpreter pay, however well it is written?

References

  1. [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. [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. [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)