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

53. Global variables in the VM

The VM can add and print, but it can’t remember a single name. Three new instructions and the hash table you just built give it memory, and one of them hides a neat little trick.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain how the compiler turns a variable name into a constant-pool operand.
  • Trace DEFINE_GLOBAL, GET_GLOBAL and SET_GLOBAL against the VM’s globals table.
  • Explain SET_GLOBAL’s trick: set first, and undo if the name was new.
  • Say why a function can use a global that is defined after it.

A VM with no memory

By the end of chapter 52 the VM is a good calculator. It compiles print (1 + 2) * 3; to bytecode and runs it. But try this:

let total = 10;
total = total + 5;
print total;

The compiler has no idea what let means, and the VM has nowhere to put total. In chapter 36 the tree-walker solved this with a Map from names to values. The VM will do the same thing, with one change: the table is the hash table you built in chapter 51, and its keys are the interned strings of chapter 52.

Intuition

A cloakroom. You hand over your coat, and it goes on a hook. To get it back you say your name, and the attendant looks it up. Globals work exactly like that: the name is the ticket, and every visit means a look-up at the counter.

A name becomes a constant

A bytecode instruction can’t carry a string inside it: its operands are single bytes. So the compiler puts the variable’s name into the chunk’s constant pool, as a string, and the instruction refers to it by index. Here is the helper in compiler.ts:

const identifierConstant =
  (name: VmToken) =>
    makeConstant(
      heap.copyString(name.lexeme));

copyString interns the name, so every mention of total in the program becomes a pointer to the same string object. And here is how a let is compiled, slightly simplified:

function letDeclaration() {
  const global = parseVariable(
    'Expect variable name.');
  if (match('=')) expression();
  else emit(OP.NIL);
  consume(';', ...);
  emit(OP.DEFINE_GLOBAL, global);
}

parseVariable consumes the name and returns its constant index. Then the initializer is compiled, which leaves its value on top of the stack at run time. No initializer? Push nil. Finally, one instruction says “store the top of the stack under this name”.

Reading and assigning go through namedVariable. When a name isn’t a local (the next chapter), it becomes a global:

arg = identifierConstant(name);
if (canAssign && match('=')) {
  expression();
  emit(OP.SET_GLOBAL, arg);
} else {
  emit(OP.GET_GLOBAL, arg);
}

Here is the real disassembly of a four-line program:

let a = 1;
print a;
a = a + 2;
print a;
== script ==
0000    1 OP_CONSTANT         1 '1'
0002    | OP_DEFINE_GLOBAL    0 'a'
0004    2 OP_GET_GLOBAL       2 'a'
0006    | OP_PRINT
0007    3 OP_GET_GLOBAL       4 'a'
0009    | OP_CONSTANT         5 '2'
0011    | OP_ADD
0012    | OP_SET_GLOBAL       3 'a'
0014    | OP_POP
0015    4 OP_GET_GLOBAL       6 'a'
0017    | OP_PRINT
0018    5 OP_NIL
0019    | OP_RETURN

Look at the constant numbers after the global instructions: 0, 2, 4, 3, 6. Each mention of a added a new entry to the pool. They are all the same interned string, though, so the waste is a few slots of the pool, not a few copies of the text. (Constant 0 comes before constant 1 because the name is read before the initializer.) And a = a + 2 ends in POP: an assignment produces a value, and an expression statement throws it away.

Three instructions

Now the VM side, from the dispatch loop in vm.ts:

case OP.DEFINE_GLOBAL:
  this.globals.set(readString(),
    this.peek(0));
  this.pop();
  break;
case OP.GET_GLOBAL: {
  const name = readString();
  const v = this.globals.get(name);
  if (v === undefined)
    this.runtimeError(`Undefined ` +
      `variable '${name.chars}'.`);
  this.push(v);
  break;
}

readString reads the operand byte and fetches that constant. DEFINE_GLOBAL peeks at the value, stores it, and only then pops. That order looks fussy, but it keeps the value on the stack, where the garbage collector of chapter 58 can see it, until it is safely in the table. DEFINE_GLOBAL never complains about an existing name: like the tree-walker, Drip lets a second let at the top level simply replace the first.

Definition

The VM’s global table is one hash table (vm.globals, a Table) mapping interned name strings to values. DEFINE_GLOBAL stores and pops the value on top of the stack. GET_GLOBAL pushes the value stored under a name, or reports Undefined variable. SET_GLOBAL overwrites an existing name and leaves the value on the stack. Each takes one operand: the constant-pool index of the name.

The SET_GLOBAL trick

Assigning to a name that was never defined must be an error. The obvious code looks the name up, checks it is there, and then stores: two hash look-ups. The VM does it in one:

case OP.SET_GLOBAL: {
  const name = readString();
  if (this.globals.set(name,
      this.peek(0))) {
    this.globals.delete(name);
    this.runtimeError(`Undefined ` +
      `variable '${name.chars}'.`);
  }
  break;
}

Table.set returns true when the key was new. For a correct program that never happens, so the common case costs a single set. For a mistaken one, the VM has just created a variable that shouldn’t exist, so it deletes it again before reporting the error. A program that is about to stop anyway can afford the extra work. Notice also that there is no pop: the value stays, because print a = 3; needs it.

Step through the whole story, including a slot collision and the tombstone that chapter 51’s delete leaves behind:

Step throughGlobals in a hash table
0000 CONSTANT '1'
0002 DEFINE_GLOBAL 'a'
0004 GET_GLOBAL 'a'
0006 PRINT
0007 GET_GLOBAL 'a'
0009 CONSTANT '2'
0011 ADD
0012 SET_GLOBAL 'a'
0014 POP
0015 CONSTANT '5'
0017 SET_GLOBAL 'b'
The value stack on the left and the eight-slot globals table on the right. Global instructions read a name from the constant pool and look it up in the table.STACK<script>slot 0 at the bottomGLOBALS TABLE (8 slots)012345clock<native>67
Before the first instruction. The table already has 8 slots and one entry: the native clock, defined by the VM itself. Slot 0 of the stack holds the running script.
1/11

Found late, on purpose

A global is looked up by name when the instruction runs, not when it is compiled. That has a useful consequence. A function body can mention a global that doesn’t exist yet, as long as it exists by the time the function is called:

Try itA global used before its let
fn show() {
  print greeting;
}

let greeting = "hello";
show();
greeting = "bye";
show();

When show is compiled, greeting is just a string in its constant pool. By the time show() runs GET_GLOBAL, the let has run and the name is in the table. That is what lets two top-level functions call each other, whichever comes first in the file.

Under the hood

Every global access hashes into a table at run time. Interning makes this cheaper than it sounds: each string object carries its hash, so nothing is rehashed while the program runs, and keys are compared by identity, not character by character. CPython does the same kind of thing: its LOAD_GLOBAL instruction names a global by an index into a table of names[2], while local variables use a different instruction that takes a plain index. Drip is about to make the same split. The three global instructions, and the set-then-undo trick, follow clox from Crafting Interpreters[1].

Your turn

The core exercise is a small VM loop: you write the three global instructions. The extra one is a Drip program for the real VM.

ExerciseThree global instructions

run(code, k, g) is a tiny VM. code is an array of bytes, k is the constant pool, and g is a globals Table whose set returns true when the key was new, just like the real one. CONST, POP, PRINT and ADD work already. Each of DEF, GET and SET has one operand: the index in k of the variable's name. Fill them in:

  • DEF stores the top of the stack under the name, then pops it.
  • GET pushes the name's value, or calls undef(name) if there is none.
  • SET stores the top of the stack under an existing name and leaves it on the stack. If the name was new, remove it again and call undef(name).
// Opcodes of a tiny VM.
const CONST = 0, POP = 1, PRINT = 2;
const ADD = 3, DEF = 4, GET = 5;
const SET = 6;

// set() returns true if k was new.
class Table {
  constructor() { this.m = new Map(); }
  get(k) { return this.m.get(k); }
  set(k, v) {
    const isNew = !this.m.has(k);
    this.m.set(k, v);
    return isNew;
  }
  delete(k) { this.m.delete(k); }
}

const undef = (n) => {
  throw new Error(
    `Undefined variable '${n}'.`);
};

function run(code, k, g) {
  const stack = [];
  const out = [];
  const top = () => stack.at(-1);
  let ip = 0;
  while (ip < code.length) {
    const op = code[ip++];
    if (op === CONST) {
      stack.push(k[code[ip++]]);
    } else if (op === POP) {
      stack.pop();
    } else if (op === PRINT) {
      out.push(String(stack.pop()));
    } else if (op === ADD) {
      const b = stack.pop();
      stack.push(stack.pop() + b);
    } else if (op === DEF) {
      // your code here
    } else if (op === GET) {
      // your code here
    } else if (op === SET) {
      // your code here
    }
  }
  return { out, left: stack.length };
}
Extra practiceA counter in a global

Write the body of tick() so that it adds 1 to the global count and prints the new value. Notice that count is defined after tick. The program should print 1, 2, 3 and then 3.

fn tick() {
  // your code here
}

let count = 0;
tick();
tick();
tick();
print count;

What you learned

  • The compiler stores a global’s name in the constant pool, and the global instructions take its index as their operand.
  • The VM’s global table is a hash table keyed by interned strings.
  • DEFINE_GLOBAL stores then pops. GET_GLOBAL pushes or reports Undefined variable. SET_GLOBAL stores and leaves the value on the stack.
  • SET_GLOBAL sets first and deletes again if the key was new: one look-up for correct programs.
  • Globals are found by name at run time, so a function can use a global defined after it.

Next: local variables, which skip the table entirely.

Check your understanding

0/6
  1. 1.

    In this disassembly line, what is the 0?

    0002    | OP_DEFINE_GLOBAL    0 'a'
  2. 2.

    What does print a = 3; compile to, after the CONSTANT for 3?

  3. 3.

    The program is just b = 5;, and b was never defined. What does the VM do?

  4. 4.

    Why does the trick in SET_GLOBAL make correct programs faster?

  5. 5.

    What does this print on the VM?

    fn f() { print x; }
    let x = 1;
    f();
    x = 2;
    f();
  6. 6.

    Which statement about the VM’s globals is true?

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)