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

54. Local variables and stack slots

Locals are born and die in strict last-in, first-out order: the same order as a stack. So the VM keeps them on the stack it already has, and the compiler turns every name into a plain number.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why local variables can live in slots of the VM’s value stack.
  • Trace the compiler’s locals array and scope depth through nested blocks.
  • Implement resolveLocal, including shadowing and the own-initializer check.
  • Read GET_LOCAL and SET_LOCAL in a disassembly and say which slot each touches.

Too much looking up

Every global access in the last chapter was a hash-table look-up. That is fine for a handful of top-level names. But most of the variables a program touches are locals: loop counters, parameters, temporaries. Paying for a look-up on every one of them would be a waste, because the compiler can already see exactly which declaration each name means.

The tree-walker had the same problem, and chapter 42’s resolver softened it: it turned each local into a distance, so the interpreter could hop straight to the right Environment. But there was still an Environment per block, with a Map inside. The VM can do much better.

Locals behave like a stack

Watch the order in which locals come and go:

{
  let a = 1;     // a is born
  {
    let b = 2;   // b is born
  }              // b dies
  let c = 3;     // c is born
}                // c dies, then a

A local is created when its let runs, and destroyed when its block ends. Blocks nest, so the last local created is always the first to go. That is exactly a stack. And the VM already has one.

Intuition

A stack of trays in a canteen. You can only add to the top and take from the top. Number the positions from the floor: 0, 1, 2. If you always know how many trays were already there when yours went on, you can find yours by its number, without reading any labels.

There is a second piece of luck. When the VM runs let a = 1;, the initializer’s code leaves 1 on top of the stack. If a lives in that slot, defining it needs no instruction at all. The value is already where the variable lives.

Definition

A stack slot is a fixed position in the VM’s value stack, counted from the start of the current function’s part of the stack. Each local variable lives in one slot from its let to the end of its block. The compiler decides the slot number, and instructions refer to the local by that number only: GET_LOCAL 2 means “push a copy of slot 2”. No name survives into the running program.

The compiler keeps a list

To hand out slot numbers, the compiler simulates the stack while it compiles. It keeps an array of the locals that are in scope, in declaration order, and a counter for how many blocks deep it is. From compiler.ts:

interface Local {
  name: VmToken;
  depth: number;
  isCaptured: boolean; // ch. 57
}
// in each function's compiler:
//   locals: Local[]
//   scopeDepth: number

A local’s index in locals is its slot. Entering a block only bumps a counter. Leaving one pops every local that was declared inside it, both from the array and, at run time, from the stack:

function beginScope() {
  fc().scopeDepth++;
}
function endScope() {
  const c = fc();
  c.scopeDepth--;
  while (c.locals.length > 0 &&
      c.locals.at(-1).depth >
        c.scopeDepth) {
    emit(OP.POP);
    c.locals.pop();
  }
}

(The real loop emits CLOSE_UPVALUE instead of POP for a captured local. That’s chapter 57.) At depth 0 nothing is a local: let there still makes a global, through the previous chapter’s DEFINE_GLOBAL.

Definition

Compile-time scope is the compiler’s own record of which locals exist at each point in the code: the locals array, each entry with the block depth it was declared at, plus the current scopeDepth. It mirrors, instruction by instruction, what the VM’s stack will hold at run time, which is how a name becomes a slot number. Slot 0 is reserved for the function being run.

Step through a program with two nested blocks. The compiler’s array is on the left, the VM’s stack on the right, and row k is slot k:

Step throughLocals live in stack slots
{
let a = 1;
{
let b = a + 2;
print b;
}
a = 5;
print a;
}
On the left, the compiler's locals array with each name and depth. On the right, the VM's stack at run time. Row k on the left is slot k on the right.COMPILER: localsscopeDepth = 0VM: stack0(fn)depth 0<script>12EMITTED
Before anything: the compiler’s locals array already has one entry, for slot 0. At run time slot 0 holds the running function (chapter 56). scopeDepth is 0: top level.
1/11

Finding a name

When the compiler meets a name, it searches the array from the end:

function resolveLocal(c, name) {
  for (let i = c.locals.length - 1;
       i >= 0; i--) {
    const l = c.locals[i];
    if (l.name.lexeme === name.lexeme) {
      if (l.depth === -1)
        error("Can't read local " +
          'variable in its own ' +
          'initializer.');
      return i;
    }
  }
  return -1; // not a local
}

Searching backwards gives shadowing for free: the innermost a was declared last, so it is found first. If nothing matches, the name is treated as a global.

The depth === -1 check is chapter 42’s “not ready” flag in a new form. addLocal appends a new local with depth -1, and only after its initializer is compiled does markInitialized set the real depth. So in let a = a; the inner a finds the half-made local and reports an error, instead of quietly reading a slot that holds nothing yet. A similar backwards scan in declareVariable rejects two lets of one name in the same block.

Try itA local in its own initializer
let a = "outer";
{
  let a = a;
  print a;
}

Two instructions, one addition

Here is the real disassembly of the program from the step-through:

== script ==
0000    2 OP_CONSTANT         0 '1'
0002    4 OP_GET_LOCAL        1
0004    | OP_CONSTANT         1 '2'
0006    | OP_ADD
0007    5 OP_GET_LOCAL        2
0009    | OP_PRINT
0010    6 OP_POP
0011    7 OP_CONSTANT         2 '5'
0013    | OP_SET_LOCAL        1
0015    | OP_POP
0016    8 OP_GET_LOCAL        1
0018    | OP_PRINT
0019    9 OP_POP
0020   10 OP_NIL
0021    | OP_RETURN

No names anywhere, and no constants spent on them. The VM’s side is two lines of vm.ts:

case OP.GET_LOCAL:
  this.push(this.stack[
    frame.slots + readByte()]);
  break;
case OP.SET_LOCAL:
  this.stack[frame.slots +
    readByte()] = this.peek(0);
  break;

frame.slots is where the current function’s slots begin; for the top-level script it is 0, and chapter 56 explains the rest. Like SET_GLOBAL, SET_LOCAL leaves the value on the stack. Because the operand is one byte, a function can have at most 256 locals in scope at once, and the compiler reports Too many local variables in function. past that.

Under the hood

Real VMs make the same split between names and numbers. CPython’s LOAD_GLOBAL names a global through a table of names, while LOAD_FAST pushes a local chosen by a plain index[2]. V8’s Ignition interpreter goes further: it is a register machine, and its instructions name their inputs and outputs as explicit registers[3], which play much the role that Drip’s slots do. Drip’s locals array, the depth -1 trick and the one-byte slot operands follow clox from Crafting Interpreters[1].

Your turn

ExerciseresolveLocal

Write resolve(locals, name), the compiler's resolveLocal. locals is the compile-time array: each entry has a name (a string) and a depth. Return the index of the matching local, which is its stack slot, or -1 if the name isn't a local. When a name appears more than once, the innermost declaration wins. If the local found has depth -1, throw new Error(SELF).

const SELF = "Can't read local " +
  'variable in its own initializer.';

// locals: [{ name, depth }, ...]
// Return the slot of name, or -1.
function resolve(locals, name) {
  // your code here
}
Extra practiceendScope

Write endScope(c). c.locals is the compile-time array (entries have a name and a depth), and c.scopeDepth is the current depth. Leave one block: lower scopeDepth by 1, then remove from the end of locals every entry whose depth is greater than the new scopeDepth. Return how many you removed: that's how many POPs the compiler emits.

// c = { locals: [{ name, depth }],
//       scopeDepth: number }
// Leave one block. Return how many
// POPs the compiler should emit.
function endScope(c) {
  // your code here
}

What you learned

  • Locals are created and destroyed in last-in, first-out order, so each one can live in a stack slot, and let needs no instruction: the initializer’s value is already in place.
  • The compiler’s compile-time scope (a locals array plus scopeDepth) mirrors the run-time stack. A local’s index is its slot.
  • beginScope costs nothing at run time; endScope emits one POP per local leaving scope.
  • resolveLocal searches from the end, which gives shadowing; depth -1 catches a local read in its own initializer.
  • GET_LOCAL n and SET_LOCAL n are an index into the stack: no names, no hashing.

Next: jumps, so the VM can make decisions and loop.

Check your understanding

0/6
  1. 1.

    Which slot does b get?

    {
      let a = 1;
      let b = 2;
    }
  2. 2.

    What does the compiler emit when it reaches the } of a block that declared two locals?

  3. 3.

    In { let x = 1; { let x = 2; print x; } }, what does print x compile to?

  4. 4.

    Why is let a = a; inside a block a compile error?

  5. 5.

    Which is true of GET_LOCAL compared with GET_GLOBAL?

  6. 6.

    What does beginScope do?

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] Python Software Foundation. dis: Disassembler for Python bytecode. The Python Standard Library documentation (Python 3, current development version). docs.python.org/3/library/dis.html (checked 2026-10-31)
  3. [3] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (checked 2026-10-31)