A Simple Code Generator

We have carved the program into basic blocks. Now comes the moment the whole front end was building toward: turning a block of three-address code into actual machine instructions. The naïve way — load both operands, do the op, store the result, for every single statement — works, but it is embarrassingly slow: memory traffic everywhere, registers touched and immediately abandoned. The simple code generator of the Dragon Book does far better with one modest ambition: keep values in registers as long as they are useful, and touch memory only when you must.

To pull that off, the generator maintains a running picture of where every value currently lives. It answers two questions at every step — "which registers hold a copy of variable x?" and "where can I find an up-to-date value of x?" — and it uses next-use information to decide which registers it is safe to reuse. Get those bookkeeping structures right and good code almost falls out.

Two descriptors track reality

These two are inverse views of the same fact and must be kept consistent: if the register descriptor says R1 holds x, then x's address descriptor must list R1, and vice versa. Every instruction the generator emits is immediately followed by a small descriptor update. The discipline of updating both, correctly, after every instruction is the skill of writing a code generator — most bugs are a descriptor that quietly drifted out of sync with the code actually emitted.

Next-use: when is a value dead?

Before generating code, we scan the block backward and annotate each use of each variable with its next use — the location of the next instruction in the block that reads it — or the tag dead if no later instruction in the block reads it. A value that is dead (and not live on exit from the block, i.e. not needed by a successor block) does not need to be preserved: its register can be scavenged freely, and it never needs storing back to memory.

This single piece of information is what turns a memory-thrashing translator into a decent one. If t is a compiler-generated temporary computed into a register and dead immediately after, we simply leave the result in that register and reuse it — no load, no store, no wasted instruction. The backward scan is why we work one block at a time: within a block, next-use is a purely local, single-pass computation.

The getReg heuristic and the code-gen rule

To emit code for x = y \;\text{op}\; z, the generator calls getReg to choose a register R to hold the result, then arranges for y and z to be in registers, emits OP R, Ry, Rz, and updates the descriptors. getReg picks a register by a small priority list:

  1. if y is already in a register R_y that holds only y, and y is dead after this instruction — reuse R_y (the result lands right on top of the operand);
  2. otherwise, if there is an empty register, take it;
  3. otherwise spill: pick an occupied register, store its variables to memory if those values are not already safe, and steal it.

The full statement rule then reads: get registers for the operands (loading from memory if needed), emit the operation, mark the result as living only in R (its memory copy is now stale), and — if any operand was a variable that is now dead — free its register in the descriptors. At the end of the block, any variable that is live on exit but currently only in a register must be stored back to its memory home.

Worked example: d = (a-b) + (a-c) + (a-c)

This is the Dragon Book's set piece. The block compiles to four TAC statements; assume a, b, c live in memory on entry and only d is live on exit, with two registers R1, R2 available. Here t, u, v are temporaries, each dead right after its last use:

t = a - b, \quad u = a - c, \quad v = t + u, \quad d = v + u.

Following the descriptors instruction by instruction gives this trace — watch how a stays cached in R2 and gets reused, and how R1 is recycled the moment a temporary dies:

TACEmitted codeR1 holdsR2 holdsNote
t = a - bLD R1,a; LD R2,b; SUB R1,R1,R2tbresult in R1
u = a - cLD R2,a; SUB R2,R2,c*tureload a into R2, subtract c
v = t + uADD R1,R1,R2vut dead → reuse R1; u still live
d = v + uADD R1,R1,R2duv dead → reuse R1
block exitST d,R1dd live on exit → store

No load appears unless a value is genuinely needed from memory, and the only store is the single one demanded by d's liveness. That is the whole game: registers held as long as they pay, memory touched as little as the semantics allow.

A generator you can run

The code below is a compact but honest version: two descriptor maps, a getReg that prefers a dead operand's register then an empty one then spills, and the per-statement rule that emits loads, the operation, and the final live-on-exit stores. It compiles the block above and prints the emitted pseudo-assembly.

type Reg = "R1" | "R2"; const REGS: Reg[] = ["R1", "R2"]; // regHolds[R] = variables currently in R. addr[v] = places holding v ("mem" or a register). const regHolds: Record<Reg, Set<string>> = { R1: new Set(), R2: new Set() }; const addr: Record<string, Set<string>> = {}; const code: string[] = []; const isTemp = (v: string) => /^[tuv]$/.test(v); // temporaries are dead after last use function place(v: string): Set<string> { if (!(v in addr)) addr[v] = new Set([isTemp(v) ? "" : "mem"]); return addr[v]; } function emit(line: string) { code.push(line); } // Ensure variable v is in some register; return that register. function intoReg(v: string): Reg { for (const R of REGS) if (regHolds[R].has(v)) return R; // already resident const R = getReg(v); emit(`LD ${R}, ${v}`); regHolds[R] = new Set([v]); place(v).add(R); return R; } // Choose a register to define `target`. function getReg(target: string): Reg { for (const R of REGS) if (regHolds[R].size === 0) return R; // empty register // Spill the register whose variable has a safe memory copy (or store it first). for (const R of REGS) { for (const held of regHolds[R]) { if (!place(held).has("mem") && held !== target) { emit(`ST ${held}, ${R} (spill)`); place(held).add("mem"); } } regHolds[R] = new Set(); place(target); // ensure record exists return R; } return "R1"; } // x = y op z function gen(x: string, y: string, op: string, z: string, deadAfter: string[]) { const Ry = intoReg(y); const Rz = intoReg(z); const Rx = Ry; // define result on top of the (assumed dead) left operand's register emit(`${op} ${Rx}, ${Ry}, ${Rz}`); // Result x now lives ONLY in Rx; its memory copy is stale. for (const R of REGS) regHolds[R].delete(x); regHolds[Rx] = new Set([x]); addr[x] = new Set([Rx]); // Free registers of operands that are now dead. for (const d of deadAfter) { for (const R of REGS) if (regHolds[R].has(d) && d !== x) regHolds[R].delete(d); } } // d = (a-b)+(a-c)+(a-c) as t=a-b; u=a-c; v=t+u; d=v+u gen("t", "a", "SUB", "b", ["a", "b"]); gen("u", "a", "SUB", "c", ["a", "c"]); gen("v", "t", "ADD", "u", ["t"]); gen("d", "v", "ADD", "u", ["v", "u"]); // d is live on exit: store it back. for (const R of REGS) if (regHolds[R].has("d")) emit(`ST d, ${R}`); console.log(code.join("\n"));

The exact instructions differ a little from the hand trace (a real generator's getReg is smarter about which operand to reuse), but the shape is identical: a handful of loads, the arithmetic, and one closing store. Tinker with the deadAfter lists and watch the store traffic change — that is next-use information steering the whole thing.

Inside a single block, next-use tells you when a temporary dies. But at the edge of the block, control leaves for a successor — and any variable a successor might read is live on exit, so its latest value must actually exist in memory when the block ends. That is why the generator issues closing ST instructions only for live-on-exit variables currently stranded in registers. Compute liveness too conservatively (mark everything live) and you drown the block in needless stores; compute it wrong (mark a needed value dead) and a successor reads stale memory. Correct liveness is what makes register-resident code both fast and correct.

When getReg finds no free register it must spill — but you cannot just overwrite a register. First check the victim's address descriptor: if its value is only in that register (no memory copy), you must emit a ST to save it, then update the descriptor to record that memory now holds it. Skip the store and you have silently destroyed a live value.

The subtler, more common bug is descriptor drift. After emitting OP R, Ry, Rz you must do all of: remove the old occupant(s) of R, record that R now holds only the result, set the result's address descriptor to just R (its memory copy is now stale!), and drop any dead operand from its register. Forget the "memory copy is now stale" step and a later spill will "helpfully" skip storing a value that memory no longer actually has — a classic, maddening miscompile. Update both descriptors, fully, after every instruction.