all_lessons/SICP JavaScript/20 · A register-machine simulatorlesson 20 / 22

A register-machine simulator

Lesson 19 left us with machines on paper — controllers we could trace by hand and claim a stack depth and instruction count for. A claim that precise deserves to be checked, and a machine too big to trace deserves to be run. This lesson builds the machine that runs the machines: a simulator. Its heart is an assembler that walks the instruction array from lesson 19 and turns each instruction into an execution function — a closure over the machine's registers and stack that, when called, does that instruction's work and returns where to go next. This is closures (lessons 03 and 10) cashed out: the machine's behavior is a list of closures wired to shared state. And because we own the loop that calls them, we drop in performance counters that make lesson 19's pencil cost model into numbers the machine reports itself.

Book source
Maps to SICP §5.2 (A Register-Machine Simulator) — the machine model as a data structure, the assembler that converts controller text to executable instructions, how labels and operations are resolved, and instrumentation for tracing and stack/instruction statistics. We follow that arc, but the JavaScript machine object, the assembler-as-closure-factory, the execution-thunk derivation, and every counter trace are original.
Linear position
Prerequisite: Lesson 19 (the register-machine model: registers, operations, tests, branches, labels, the explicit stack, save/restore) and Lessons 03 & 10 (functions as first-class values, and closures that capture their defining environment).
New capability: implement a register machine as a program — a machine object holding registers/stack/op-table, an assembler that compiles each instruction into a closure over that machine, label resolution, and counters that measure stack depth and instruction counts so lesson 19's cost model becomes empirically checkable.
The plan
Six moves. (1) The machine model as a data structure. (2) The two-pass shape of the assembler and how labels resolve. (3) The key move: each instruction becomes a closure over machine state. (4) Worked — deriving the execution thunk for one assign and one branch. (5) The execution loop that runs the thunks. (6) Instrumentation — tracing and the stack/instruction counters that measure lesson 19. Then the pressure that forces lesson 21.

1 · The machine as a data structure

Before we can run a controller we need the hardware as a live object. A machine is just a record bundling the mutable state — registers, the stack, a program counter — with the lookup tables the assembler needs:

function makeMachine(registerNames, ops) {
  const regs  = {};                       // name -> current value
  registerNames.forEach(n => regs[n] = null);
  const stack = [];                        // the explicit stack from lesson 19
  let   flag  = false;                     // set by tests, read by branch
  let   pc    = 0;                          // program counter: index into the thunk list
  const labels = {};                        // label name -> index in the instruction list
  let   thunks = [];                        // the ASSEMBLED execution functions
  let   stats  = { instr: 0, maxDepth: 0 }; // performance counters
  return { regs, stack, ops,
           getFlag:()=>flag, setFlag:b=>flag=b,
           getPc:()=>pc,    setPc:i=>pc=i,
           labels, thunks, stats };
}

Two fields deserve attention. ops is the operation table — a map from operation name ("+", "rem", "=") to an actual JavaScript function; it is how the abstract {fn:"rem"} of lesson 19 connects to real arithmetic. thunks is empty for now — it gets filled by the assembler, and it is where the controller-as-data becomes controller-as-behavior.

2 · The assembler and label resolution

The assembler reads the instruction array (the data from lesson 19) and produces the parallel thunks array. It must solve one ordering problem: an instruction can goto a label that appears later in the text, so we cannot resolve a label to an index until we have seen the whole program. The classic fix is two passes:

pass 1 — locate labels
Walk the array. Each bare string is a label; record labels[name] = the index the next real instruction will occupy. Each instruction object reserves one slot in the thunks array. After pass 1 every label maps to a position.
pass 2 — build thunks
Walk the instructions again, now able to resolve any label to its index. Turn each instruction object into an execution closure (next section) and store it at its slot. A goto (label "done") compiles to a closure that, when run, sets pc = labels["done"].

So a label is not a runtime search — it is resolved once at assembly time into a numeric index, then a jump is a single assignment to pc. This is the same "do the work once, not on every execution" idea the analyzing evaluator introduced in lesson 15, now applied to addresses.

3 · Each instruction becomes a closure over machine state

Here is the conceptual core of the whole simulator. The assembler does not interpret an instruction every time it runs. It interprets each instruction's shape exactly once and returns a thunk: a zero-argument function that closes over the machine and, when called, performs that instruction and returns the next pc. The kind of work an assign does is decided once, at assembly; only the actual value flows at run time.

This is why lessons 03 and 10 were prerequisites. A closure is a function plus the environment it captured. Every thunk captures the same machine object, so all thunks read and write one shared set of registers and one shared stack — exactly the wiring of a real machine's data paths. The controller text became a list of behaviors permanently bonded to the hardware. We build a small helper that compiles a source (a constant, a register, or an operation) into a closure that yields its value, then build per-instruction thunks on top of it:

// Compile a source expression into a () => value closure over the machine.
function makeValue(m, src) {
  if ("const" in src) return () => src.const;            // captured once
  if ("reg"   in src) return () => m.regs[src.reg];      // read shared register
  if ("label" in src) return () => m.labels[src.label];  // a return address
  if ("fn"    in src) {                                   // an operation
    const fn   = m.ops[src.fn];                           // resolved ONCE
    const args = src.args.map(a => makeValue(m, a));      // sub-closures
    return () => fn(...args.map(a => a()));                // apply at run time
  }
}

4 · Worked example — one instruction becomes a thunk

Worked example — assembling an assign and a branch
Take {op:"assign", reg:"t", src:{fn:"rem", args:[{reg:"a"},{reg:"b"}]}} from gcd. The assembler runs once: it resolves the op rem to its JS function and builds the argument closures. It returns a thunk that, every time it runs, reads the live a and b, computes, stores into t, and advances:
function asmAssign(m, ins) {
  const value = makeValue(m, ins.src);    // built ONCE at assembly
  return (pc) => {                          // the thunk: run MANY times
    m.regs[ins.reg] = value();             // read regs, op, write reg
    return pc + 1;                          // next pc = fall through
  };
}
function asmBranch(m, ins) {
  const target = m.labels[ins.label];      // label -> index, resolved ONCE
  return (pc) => m.getFlag() ? target : pc + 1;   // jump iff the flag is set
}
The split is the lesson in one image. Resolving the operation and the label happens once when asmAssign/asmBranch run; reading registers and choosing the jump happens every time the returned thunk runs. The thunk is a closure: value, ins.reg, and target are baked into it. This is precisely the analyze-once / execute-many pattern of lesson 15, now lowered to the register-machine level.

5 · The execution loop

With every instruction now a thunk that returns the next pc, running the machine is a tight loop: fetch the thunk at pc, run it, set pc to what it returns, repeat until pc runs off the end. save/restore are thunks that push/pop the shared stack:

function run(m) {
  while (m.getPc() < m.thunks.length) {
    const thunk = m.thunks[m.getPc()];
    m.stats.instr++;                         // count every instruction executed
    m.setPc(thunk(m.getPc()));               // run it; it returns the next pc
  }
  return m.regs["val"] ?? m.regs["a"];       // answer lives in a register
}
// save / restore thunks (assembled from {op:"save"/"restore"}):
const saveThunk    = (reg) => (pc) => {
  m.stack.push(m.regs[reg]);
  m.stats.maxDepth = Math.max(m.stats.maxDepth, m.stack.length);  // measure!
  return pc + 1;
};
const restoreThunk = (reg) => (pc) => { m.regs[reg] = m.stack.pop(); return pc + 1; };

Notice there is no recursion in the simulator's loop. The simulated machine recurses by goto-ing its own label and pushing the explicit stack — but the host loop is flat. We have finally stopped borrowing the host's call stack: the machine's recursion lives entirely in m.stack, an array we control, exactly as lesson 19 demanded.

6 · Instrumentation — measuring lesson 19

Because we own both the loop and the stack thunks, instrumentation is nearly free. Two counters already appear above: stats.instr increments once per executed instruction, and stats.maxDepth tracks the high-water mark of the stack inside save. A trace mode just prints the instruction before running its thunk. Now the claims of lesson 19 become checkable numbers:

Worked example — confirming lesson 19's fact(3) by measurement
Run factMachine with n = 3 and read the counters off the machine:
trace                        stack         maxDepth   instr
fact n=3  test(=3,1) false                  0          1
          save n=3           [3]            1          ...
          n=2; save cont     [3, c]         2
fact n=2  save n=2           [3,c,2]        3
          n=1; save cont     [3,c,2,c]      4
fact n=1  test(=1,1) true -> base
          val=1                             4
after...  restore; val=2*1=2  drains
after...  restore; val=3*2=6  drains
RESULT  val = 6     stats.maxDepth = 4     stats.instr = 26
Lesson 19 predicted the stack mirrors the recursion depth; the simulator reports maxDepth = 4 (two saves — n and continue — at each of two recursive levels), and instr grows linearly in n. The pencil cost model is now an empirical fact the machine measures about itself — and you can rerun with n = 50 where hand-tracing is hopeless.

Common mistakes / failure modes

Re-parsing the instruction every run
Writing the loop so it inspects ins.op and re-resolves the operation on every iteration throws away the entire point. The assembler exists to do that dispatch once; the thunk must already know what it does. This is the lesson-15 mistake recapitulated.
Resolving labels in one pass
Trying to build thunks in a single forward pass fails the moment a goto targets a label defined later — the index is unknown. Two passes (locate labels, then build thunks) is the minimal fix; one pass forces back-patching.
Thunks closing over copies, not the machine
If a thunk captures a register's value at assembly time instead of reading m.regs[name] at run time, it freezes stale data. Only constants and resolved operations/labels are captured once; live register reads must happen inside the thunk.
Counting assembly, not execution
Incrementing instr while assembling counts instructions in the text, not instructions run. A loop body of 5 instructions run 100 times is 500 executed; the counter must live in the run loop, and maxDepth must update inside save.

Checkpoint exercise

Try it
(1) Write asmGoto and asmTest as thunk-factories matching the style of asmAssign/asmBranch — which of their work happens once at assembly and which happens every run? (2) For the iterative gcdMachine of lesson 19 running gcd(206, 40), predict stats.maxDepth and roughly how stats.instr scales. (3) Explain in one sentence why the simulator's own run loop contains no recursion even though it runs a recursive factorial.
function asmGoto(m, ins) { /* ... */ }
function asmTest(m, ins) { /* ... */ }
Answers: (1) asmGoto resolves m.labels[ins.label] to an index once and returns () => target; asmTest resolves m.ops[ins.fn] and builds arg closures once, returning a thunk that each run reads the registers, applies the predicate, calls m.setFlag(...), and returns pc+1 — resolution once, register reads and flag-set every time. (2) maxDepth = 0: gcd is iterative and executes no save, so the stack stays empty; instr scales with the number of Euclid steps, i.e. roughly logarithmic in the inputs (≈ a few iterations × the loop's instruction count). (3) The simulated machine's recursion is carried entirely by pushes onto m.stack (an array we manage), so the host only needs a flat fetch-execute loop — we no longer borrow the host's call stack.

Where this points next

We now have an executable, self-measuring register machine: feed it the instruction data from lesson 19 and it reports the exact stack depth and instruction count, for any input size. But the simulator still rests on two host-language conveniences we have not yet paid for. The stack is a JavaScript array that grows without limit, and whenever the simulated programs build pairs or lists, they lean on the host's heap and garbage collector — memory is still effectively infinite and free. Real hardware has neither: storage is a finite block of cells, allocation can run out, and reclaiming dead memory is a job someone must do explicitly. Lesson 21 confronts this head on — it represents pairs as indices into finite vectors, builds a garbage collector, and then turns the lesson-14 metacircular evaluator itself into a register machine (the explicit-control evaluator), with its stack saving exactly the registers lesson 19 said it must. The machine stops pretending memory is magic.

Takeaway
A register-machine simulator represents the machine as a data structure — registers, an explicit stack, a program counter, a label table, and an operation table mapping op names to real functions — and an assembler that turns the instruction array from lesson 19 into a parallel array of thunks. The conceptual core: each instruction becomes a closure over the shared machine state, so the assembler interprets every instruction's shape exactly once (resolving operations and labels) and the run loop merely calls thunks that read/write live registers many times — the analyze-once / execute-many split of lesson 15 lowered to hardware, and the payoff of closures from lessons 03 and 10. Labels resolve in a two-pass assembly to numeric indices, so a jump is one assignment to pc. The simulator's own loop is flat — the simulated machine's recursion lives entirely in the array m.stack, so we have finally stopped borrowing the host's call stack. And because we own the loop and the stack, free instrumentation (instruction count, max stack depth, tracing) turns lesson 19's pencil cost model into numbers the machine measures about itself.

Interview prompts