Three-Address Code

A parser hands you a tree; a processor wants a list. Somewhere between the two, a compiler chooses an intermediate representation — a private, machine-agnostic language that is low enough to reason about like assembly, yet high enough to stay portable across every target chip. The workhorse of that middle ground is three-address code (TAC): a linear sequence of instructions, each of which does almost nothing.

The defining rule is right there in the name. Every instruction has at most one operator on the right-hand side and refers to at most three addresses — typically two operands and one result. A rich source expression like x = (a + b) \times (c - d) can not be one instruction; it must be broken into a chain of tiny steps, each computing a single sub-result into a fresh temporary. That deliberate flattening is the whole point: once every instruction is atomic, control flow is explicit jumps, and sub-expressions have names, the later phases — optimisation and code generation — have something simple and uniform to chew on.

From tree to triple-address list

Watch a single assignment melt into TAC. The source

x = (a + b) \times (c - d)

holds three operators, so it becomes three instructions, threaded together by two temporaries t_1 and t_2 that hold the intermediate values:

t1 = a + b t2 = c - d x = t1 * t2

Notice what happened. The nested structure of the tree became evaluation order in the list — the leaves are computed first, and each operator waits until its arguments already sit in a temporary. This is exactly a post-order walk of the expression tree, emitting one instruction per interior node. Each temporary is written once and then read; the last instruction lands the answer in the real variable x. Three addresses per line, no more.

The instruction set

TAC is small. A handful of instruction forms covers every construct a source language can throw at it — arithmetic, assignment, branching, arrays, pointers and procedure calls. Here is the canonical repertoire (the Dragon Book's set), written with symbolic addresses x, y, z:

FormShapeMeaning
Binary assignmentx = y op zarithmetic/logical op on two operands
Unary assignmentx = op ynegation, logical not, type conversion
Copyx = ymove a value with no operator
Unconditional jumpgoto Ltransfer control to label L
Conditional jumpif x relop y goto Lbranch when the relation holds
Conditional jump (unary)if x goto L / ifFalse x goto Lbranch on a boolean
LabelL:a named jump target (not executed)
Procedure callparam xcall p, npush n args, then call p
Returnreturn yreturn (optionally a value)
Indexed copy (read)x = y[i]fetch element at offset i
Indexed copy (write)x[i] = ystore into element at offset i
Address-ofx = &ytake the address of y
Pointer readx = *yload through pointer y
Pointer write*x = ystore through pointer x

That is essentially the entire language. Everything else — a while loop, a switch, a two-dimensional array access — is lowered into a pattern of these primitive forms. The discipline of "at most one operator" is what makes later analysis tractable: there is nowhere for a hidden sub-computation to hide.

Addresses: names, constants, temporaries

An address in TAC is one of exactly three things, and keeping them straight is half the game:

Temporaries are the connective tissue of TAC. Because each holds a single sub-expression's value, an optimiser can treat "the value in t_1" as a first-class object — copy it, move it, prove two temporaries hold the same thing, or delete it when nobody reads it. They are the reason three-address code is such fertile ground for data-flow analysis.

Executing TAC: a tiny interpreter

Because TAC is so regular, you can execute it directly with a short interpreter — a program counter, a map of variable values, and a dispatch on the instruction form. Below, we hand-write a TAC program that computes \text{sum} = 1 + 2 + \dots + n using a conditional jump and a back-edge (a real loop), then run it. Watch the labels and gotos do the work that a while would in the source.

// A TAC instruction is a tagged tuple. Addresses are strings; a numeric // literal string like "1" is read as a constant, anything else as a name. type Instr = | { op: "assign"; x: string; a: string; bop: string; b: string } // x = a bop b | { op: "copy"; x: string; a: string } // x = a | { op: "ifgoto"; a: string; rel: string; b: string; label: string } // if a rel b goto L | { op: "goto"; label: string } | { op: "label"; name: string } | { op: "print"; a: string }; // Hand-written TAC for: sum = 0; i = 1; while (i <= n) { sum = sum + i; i = i + 1; } print sum const program: Instr[] = [ { op: "copy", x: "sum", a: "0" }, { op: "copy", x: "i", a: "1" }, { op: "label", name: "L1" }, { op: "ifgoto", a: "i", rel: ">", b: "n", label: "L2" }, // exit when i > n { op: "assign", x: "sum", a: "sum", bop: "+", b: "i" }, { op: "assign", x: "i", a: "i", bop: "+", b: "1" }, { op: "goto", label: "L1" }, { op: "label", name: "L2" }, { op: "print", a: "sum" }, ]; function run(prog: Instr[], env: Record<string, number>): void { // Pre-scan label positions so a goto is O(1). const labels: Record<string, number> = {}; prog.forEach((ins, k) => { if (ins.op === "label") labels[ins.name] = k; }); const val = (addr: string) => (/^-?\d+$/.test(addr) ? Number(addr) : env[addr]); let pc = 0; while (pc < prog.length) { const ins = prog[pc]; switch (ins.op) { case "copy": env[ins.x] = val(ins.a); pc++; break; case "assign": { const a = val(ins.a), b = val(ins.b); env[ins.x] = ins.bop === "+" ? a + b : ins.bop === "-" ? a - b : ins.bop === "*" ? a * b : a / b; pc++; break; } case "ifgoto": { const a = val(ins.a), b = val(ins.b); const hit = ins.rel === ">" ? a > b : ins.rel === "<" ? a < b : a === b; pc = hit ? labels[ins.label] : pc + 1; break; } case "goto": pc = labels[ins.label]; break; case "label": pc++; break; case "print": console.log(`sum = ${val(ins.a)}`); pc++; break; } } } run(program, { n: 10 }); // 1 + 2 + ... + 10 run(program, { n: 100 });

Run it: the same nine-instruction program prints sum = 55 and then sum = 5050. The loop is nothing but two labels and a conditional jump — precisely the shape into which a source while is lowered, which is the subject of a later page.

The number three is not sacred; it is convenient. A single binary operation z = x \; op \; y naturally names three things — two inputs and one output — so three addresses is the smallest count that lets one instruction carry a complete arithmetic step without an implicit accumulator. Two-address codes exist (they overwrite an operand, like x86's add x, y meaning x = x + y) and save space, but they destroy an input and muddy analysis. One-address codes are stack machines: elegant, but every value's location is implicit in the stack depth. Three-address code hits the sweet spot where every value has an explicit name, which is exactly what an optimiser wants to talk about.

The single most common misreading of TAC is to see t_1, t_2, t_3, \dots and think "registers". They are not. A TAC generator invents temporaries without limit — a big expression might produce t1 through t500 — as if the machine had infinitely many registers. That is deliberate: at IR level you should not be worrying about the target's real register file (which might have only 16 or 32 registers). Deciding which virtual temporaries live in real registers, and which must spill to memory, is a completely separate later phase called register allocation. Confusing the two leads you to prematurely reuse temporaries and destroy the very independence that makes TAC easy to optimise. At the IR level: mint freely, worry later.