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.
- 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.
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.
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:
0000 CONSTANT '1'0002 DEFINE_GLOBAL 'a'0004 GET_GLOBAL 'a'0006 PRINT0007 GET_GLOBAL 'a'0009 CONSTANT '2'0011 ADD0012 SET_GLOBAL 'a'0014 POP0015 CONSTANT '5'0017 SET_GLOBAL 'b'
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:
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.
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.
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:
DEFstores the top of the stack under the name, then pops it.GETpushes the name's value, or callsundef(name)if there is none.SETstores the top of the stack under an existing name and leaves it on the stack. If the name was new, remove it again and callundef(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 };
}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_GLOBALstores then pops.GET_GLOBALpushes or reportsUndefined variable.SET_GLOBALstores and leaves the value on the stack.SET_GLOBALsets 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.
In this disassembly line, what is the
0?0002 | OP_DEFINE_GLOBAL 0 'a' - 2.
What does
print a = 3;compile to, after theCONSTANTfor 3? - 3.
The program is just
b = 5;, andbwas never defined. What does the VM do? - 4.
Why does the trick in
SET_GLOBALmake correct programs faster? - 5.
What does this print on the VM?
fn f() { print x; } let x = 1; f(); x = 2; f(); - 6.
Which statement about the VM’s globals is true?
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] 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)