COMPILERS SERIES · PART 08 OF 10

Code
Generation

Part 08 — The Back End: Lowering IR to Real Machine Code
Instruction Selection Register Allocation Scheduling ABI & Stack Frames
Optimised IR → Select → Allocate → Schedule → Emit

The back end turns optimised, machine-independent IR into a concrete instruction stream for a real ISA. Three classic, interacting subproblems dominate — choosing instructions (selection), mapping unbounded virtual values onto finite registers (allocation), and ordering instructions to keep the pipeline busy (scheduling) — all bound by the target's calling convention. We work each one, then walk a function from IR to x86-64 and AArch64.

Selection  ·  Allocation  ·  Scheduling  ·  ABI  ·  Emission
01

Topics

The back end is where portability ends and the silicon begins. By the close you will have walked optimised IR all the way to x86-64 and AArch64 assembly, naming every instruction and register chosen.

Targets & Selection

  • The back end's job; three interacting subproblems
  • RISC vs CISC; x86-64 / AArch64 / RISC-V primer
  • Instruction selection: tiling, BURS, SelectionDAG, GlobalISel

Register Allocation

  • Live ranges, interference graphs
  • Graph colouring: Chaitin–Briggs; spilling & coalescing
  • Linear scan; SSA-based allocation; PBQP

Scheduling & ABI

  • Pipeline hazards, latencies, list scheduling
  • Scheduling vs register pressure; software pipelining
  • System V AMD64 ABI; stack frames; prologue/epilogue

Putting It Together

  • A worked function: IR → x86-64 & AArch64
  • Emission: assembly text vs the LLVM MC layer
  • Relocations & the assembler/linker handoff
02

The Back End's Job

Optimisation (Part 07) left us with clean, machine-independent IR — unbounded virtual registers, abstract operations, no notion of the target. Code generation lowers this to a concrete instruction stream: real opcodes, real registers, a real stack frame, obeying a real ABI. Three subproblems dominate — and they interact, which is what makes the back end hard.

Optimised IR virtual regs, SSA Instruction Selection IR ops → real opcodes Register Allocation vregs → phys regs / spills Scheduling order for the pipeline Emission .s text / object code ASM selection & allocation are mutually constraining — a phase-ordering problem

The Phase-Ordering Dilemma

Selection fixes which instructions exist — and so which registers they demand. Allocation may then insert spill code, creating new loads and stores that selection never saw. Scheduling reorders to hide latency — but spreading live ranges apart raises register pressure, fighting the allocator. No order is globally optimal; real compilers iterate and approximate.

03

Target Architecture Primer

The ISA dictates everything downstream. A register is a small, fast, named store inside the CPU; an addressing mode is a rule for computing an operand's memory address. The deepest split is RISC vs CISC.

RISC — Reduced

  • Fixed-width instructions, simple to decode
  • Load/store architecture: ALU ops touch registers only
  • Few, orthogonal addressing modes
  • Many registers (32+); regular encoding
  • ARM64, RISC-V, MIPS, POWER, SPARC

CISC — Complex

  • Variable-length instructions (x86: 1–15 bytes)
  • Memory operands on arithmetic: add [rax], rbx
  • Rich addressing: base + index*scale + disp
  • Fewer architectural registers (16 GPRs)
  • x86-64 is CISC — but decodes to RISC-like µops

ISA choice reshapes the back end: CISC's memory operands make instruction selection matter more (one instruction can do a load + an add); RISC's load/store regularity makes scheduling and register pressure the main game. A good selector exploits exactly the addressing modes the target offers.

04

x86-64 · AArch64 · RISC-V

The three ISAs a modern back end is most likely to target. They differ in register count, encoding and addressing — each pushing codegen in a different direction.

Propertyx86-64AArch64 (ARM64)RISC-V (RV64)
ClassCISCRISC (load/store)RISC (load/store)
GP registers16 (RAX…R15)31 + XZR/SP32 (x0=zero … x31)
Instr. width1–15 bytes (variable)4 bytes fixed (+ 16-bit Thumb on A32)4 bytes (+ 2-byte "C" compressed)
Mem operands on ALUyesnono
Addressingbase+index*scale+dispbase+offset, pre/post-indexbase+12-bit disp only
Flags registerRFLAGS (implicit)NZCV (explicit, opt-in)no flags — compare-and-branch
Calling-convention argsRDI, RSI, RDX, RCX, R8, R9X0–X7a0–a7 (x10–x17)

x86-64

Dense code, but variable-length decode and few registers stress the allocator. Two-operand form (dst is also a source) forces extra copies.

AArch64

Three-operand, 31 GPRs, clean encoding — the allocator breathes easy. x31 reads as zero or means SP by context.

RISC-V

Minimal, extensible base (RV64I) plus optional extensions (M, A, F, D, C, V). x0 is hard-wired zero — a free constant.

05

Instruction Selection — the Problem

Instruction selection maps IR operations to target instructions. It is rarely one-to-one. The mapping is both many-to-one (a multiply-by-4 plus add becomes a single lea) and one-to-many (a 64-bit divide may expand to a sequence). The selector must find a covering of the IR by available instructions — ideally the cheapest one.

Many-to-One

IR t1 = i*4; t2 = b + t1; v = load t2 — the whole address calculation and the load fold into a single x86 instruction:

mov rax, [rbx + rcx*4]   ; one instr

One-to-Many

A high-level IR op may have no single instruction. A 128-bit add, a select, or saturating arithmetic each expand to several real instructions.

Why It Matters

  • Exploiting complex instructions (lea, madd, fused multiply-add) cuts instruction count and register pressure
  • The same IR has many valid coverings of different cost
  • Cost ≈ latency × frequency, or code size — the selector minimises it

The Goal

Cover the IR DAG/tree completely, every node accounted for, choosing tiles (instructions) that minimise total cost. This is the tiling problem.

06

Selection Approaches — a Spectrum

From dead-simple to provably optimal, selection techniques trade implementation effort against code quality.

ApproachIdeaCode qualityNotes
Macro / template expansionEmit a fixed instruction sequence per IR op, independentlypoorTrivial; misses every cross-op opportunity. Needs heavy peephole cleanup
Tree pattern matching (tiling)Cover the expression tree with instruction "tiles"; maximal munchgoodGreedy; pick the largest matching tile at each node
BURS / DP tilingDynamic programming over the tree; bottom-up rewrite systemoptimal (per tree)iburg, BEG, lburg — cost-minimal tiling in linear time
DAG-based selectionMatch over the DAG, sharing common sub-expressionsbetter than treesNP-hard in general; LLVM's SelectionDAG

Maximal Munch

Top-down greedy: at each node pick the largest tile that matches, recurse on the leaves it left uncovered. Fast and produces good code; not guaranteed optimal because a locally-large tile can force costlier choices below.

BURS — Bottom-Up Rewrite

Two passes: a bottom-up labelling pass computes the minimum cost to produce each non-terminal at each node (dynamic programming), then a top-down pass reads off the optimal tiles. iburg (Fraser, Hanson, Proebsting) generates such selectors from a cost-annotated grammar.

07

Tree Tiling — Covering an Expression

Take a[i] = a[i] + 1 where a is a 4-byte array at base rbx and i is in rcx. The address tree MEM(+ (base, * (i, 4))) can be covered by tiles. On x86-64, a single addressing mode swallows the whole sub-tree.

MEM + rbx * rcx 4 tile T1 Chosen covering T1 = full address mode [rbx + rcx*4] ← base+index*scale load + add-immediate + store: mov rax, [rbx + rcx*4] add rax, 1 mov [rbx + rcx*4], rax or fold the +1 directly into memory: add qword [rbx + rcx*4], 1 one CISC instruction does load+add+store

A naive tiler would emit a lea to compute the address, a load, an add and a store. A good selector recognises the whole address sub-tree as one addressing-mode tile — and on x86 may fold the increment into a single memory-destination add.

08

LLVM: SelectionDAG & GlobalISel

Real selectors operate on a DAG, not a tree, so common sub-expressions are shared. LLVM has shipped two selectors; the newer one was built to fix the older's weaknesses.

SelectionDAG (classic)

  • Builds a DAG per basic block from LLVM IR
  • Legalises types & operations the target can't handle natively
  • Pattern-matches via target .td TableGen rules + custom C++
  • Then schedules the DAG into a linear MachineInstr list
  • Block-local only — cannot select across BB boundaries

GlobalISel (newer)

  • A pipeline of passes, not one monolith: IRTranslator → Legalizer → RegBankSelect → InstructionSelect
  • Operates on Generic MIR (gMIR) — whole-function, not per-block
  • Faster compiles (no separate DAG build); better debuggability
  • Default for AArch64 at -O0; growing elsewhere

Peephole as Cleanup

Whatever the selector, a final peephole pass scans short instruction windows and rewrites local patterns — killing redundant moves, folding mov+add into lea, replacing mul-by-constant with shifts, removing xor reg,reg redundancies. It mops up what selection missed.

09

Register Allocation — the Problem

The IR has unbounded virtual registers; the machine has k physical ones (16 on x86-64, 31 on AArch64). Register allocation maps virtual registers onto physical registers, and when there are not enough, spills some values to memory (stack slots).

Live Range / Live Interval

A value is live from its definition to its last use. Its live range is the set of program points where it holds a needed value. Two values interfere if their live ranges overlap — they cannot share a register.

Spilling

When pressure exceeds k, a value is kept in a stack slot and loaded/stored around each use. Spills cost memory traffic — the allocator picks which values to spill to minimise that cost.

Liveness (from Part 07)

Computed by backward dataflow: a variable is live at a point if some path forward uses it before redefining it. Liveness gives the live ranges the allocator reasons about.

Caller- vs Callee-Saved

The ABI splits registers: callee-saved survive a call (the allocator prefers them for values live across calls); caller-saved must be spilled by the caller if needed after a call. The allocator is ABI-aware.

10

Allocation as Graph Colouring

Build the interference graph: one node per value, an edge between any two that interfere (overlapping live ranges). Assigning registers so that no two interfering values share one is exactly graph k-colouring — colour the nodes with k colours (registers) so no edge joins two same-coloured nodes. Chaitin (1981) made this the standard formulation.

Interference graph 3-colouring (k=3) a b c d e a b c d e a→RAX b,c→RBX d,e→RCX

k = Number of Registers

If the graph is k-colourable, every value fits in a register. b and c don't interfere, so they share RBX; likewise d,e.

It's NP-Complete

General graph k-colouring is NP-complete (Chaitin's reduction). So allocators use heuristics — simplify, spill, select — rather than seeking an exact optimum.

Pre-Coloured Nodes

Some values are pinned to specific registers (ABI args in RDI/RSI…, the div result in RAX/RDX). These appear as pre-coloured nodes the rest must avoid.

11

Chaitin–Briggs: Simplify, Spill, Select

The classic colouring allocator works by graph simplification. The key lemma: a node with fewer than k neighbours (low-degree) can always be coloured after its neighbours, so remove it and recurse.

The Algorithm

  • Build the interference graph
  • Simplify: repeatedly remove a node of degree < k, push it on a stack
  • Spill: if only high-degree nodes remain, mark one a potential spill, remove it, continue
  • Select: pop nodes off the stack, giving each a colour free among its (already-coloured) neighbours
  • If a potential spill finds no free colour → actual spill; insert spill code & restart

Chaitin (1981)

Original formulation: a node that must be spilled is spilled immediately and the graph rebuilt.

Briggs Optimistic Colouring (1994)

Key improvement: a high-degree node is optimistically pushed onto the stack anyway. At select time it might still find a free colour — because several of its neighbours often share colours. Only if no colour is free does it actually spill. This colours many graphs Chaitin would have spilled.

Spill Cost

Choose the spill candidate minimising cost / degree, where cost ≈ Σ (use/def frequency, loop-depth weighted). Spill cheap, rarely-used, high-degree values.

12

Coalescing & Live-Range Splitting

Two refinements make colouring allocators competitive: removing pointless copies (coalescing) and breaking up troublesome ranges (splitting).

Coalescing — Move Elimination

If y = mov x and x,y don't otherwise interfere, give them the same register and the move vanishes. Merge their nodes in the graph.

But aggressive coalescing raises degree and can make a colourable graph un-colourable. Hence conservative coalescing:

  • Briggs: coalesce only if the merged node has < k neighbours of significant degree
  • George: coalesce if every neighbour of x already interferes with y or is low-degree

Live-Range Splitting

The opposite move: split one long live range into several shorter ones connected by copies. A value can then live in a register where pressure is low and be spilled only across the busy region — instead of spilling the whole range.

The Balance

Coalescing merges ranges (fewer moves, higher degree); splitting divides them (more moves, lower degree, easier colouring). A good allocator interleaves both — the iterated register coalescing of George & Appel (1996) is the canonical scheme.

13

Linear-Scan Allocation

Graph colouring is powerful but slow — building and simplifying the interference graph is super-linear. JITs and -O0 need speed. Linear scan (Poletto & Sarkar, 1999) skips the graph entirely.

The Idea

  • Approximate each value's live range by one live interval — [start, end] over a linearised instruction numbering
  • Sort intervals by start point
  • Sweep left-to-right; keep an active set of intervals currently holding registers
  • At each new interval, expire intervals that have ended (free their registers)
  • If a register is free, assign it; else spill the interval with the furthest end

Where Used

HotSpot's client (C1) compiler, early V8, many JITs and WebAssembly engines. Linear in practice; near-optimal for straight-line-heavy code. Extended forms (Wimmer–Mössenböck) handle holes and SSA.

live intervals over time → v1 v2 v3 v4 v5 cut: 4 live → spill?

At the dashed cut, v1,v2,v3,v5 overlap. With k=3 one must spill — pick the furthest-ending (v2).

14

SSA-Based Allocation & PBQP

A beautiful result reshaped allocation theory: the interference graph of a program in SSA form is chordal — and chordal graphs are colourable in polynomial time.

Why SSA Helps

  • In strict SSA, every value has one definition; live ranges align with the dominator tree
  • The interference graph is chordal (every cycle > 3 has a chord)
  • Optimal colouring of a chordal graph is polynomial — a perfect elimination order exists
  • So the hard part decouples: colour the chordal graph, then handle spilling and φ-related copies separately

The Catch

The chromatic number equals the maximum register pressure (max simultaneously-live values) — but spilling to get pressure below k, and resolving φ-copies on SSA destruction, remain NP-hard. SSA tames colouring, not the whole problem.

PBQP Allocation

Cast allocation as a Partitioned Boolean Quadratic Problem: each value gets a cost vector over its candidate registers/spill, and each interference edge a cost matrix. Minimise total cost.

  • Naturally models irregular ISAs — register classes, aliasing (AX/AL), pairing constraints
  • Solved heuristically; an alternative LLVM allocator (-regalloc=pbqp)

LLVM's Default: Greedy

LLVM's production allocator is Greedy — a global, priority-ordered allocator with live-range splitting and eviction, neither pure colouring nor pure linear scan, but fast and high-quality.

15

Instruction Scheduling — Hazards

Modern CPUs are pipelined: an instruction takes several cycles, and the next can start before the previous finishes — unless a hazard stalls it. Scheduling reorders instructions to hide latency and keep functional units busy, without changing the result.

Data Hazard

An instruction needs a result not yet ready. add after a load that misses cache stalls until the value arrives. RAW (read-after-write) is the common true dependence.

Structural Hazard

Two instructions want the same functional unit (one divider, one load port) in the same cycle. One must wait.

Control Hazard

A branch's direction isn't known until late, so the pipeline may fetch wrong instructions. Mitigated by branch prediction; the scheduler fills any branch delay slots (on older RISC).

Latencies Differ

Integer add: ~1 cycle. Load (L1 hit): ~4. Multiply: ~3. Divide: 20–40. FP varies. The scheduler knows the target's latency table and issues independent work into the shadow of a slow instruction.

In-Order vs Out-of-Order

An out-of-order core (most desktop x86/ARM) reorders dynamically, so scheduling matters less — but still helps fetch & register pressure. An in-order core (many embedded, some Atom/A55) depends critically on a good static schedule.

16

List Scheduling over the Dependence DAG

Within a basic block, build the data-dependence DAG: nodes are instructions, edges are dependences (true RAW, plus anti/output and memory ordering), weighted by latency. List scheduling greedily picks, each cycle, a ready instruction (all predecessors done) of highest priority — usually longest path to the end (critical path).

i1: ld r1,[a]lat 4 i2: ld r2,[b]lat 4 i3: mul r3,r1,r2lat 3 i4: add r4,r3,1lat 1 i5: st [c],r3lat 1 critical path i1→i3→i4 = 4+3+1 = 8 cyc

Why Reorder?

Issue i1 and i2 (the two independent loads) back-to-back so their 4-cycle latencies overlap; by the time i3 needs both, they're ready. A naive in-order emit would stall twice.

Priority = Critical Path

Among ready instructions, prefer the one on the longest remaining path to the block's end. Ties broken by latency, then by register-pressure impact.

Scope

Local (one block) is standard; superblock / trace scheduling crosses blocks along hot paths for more freedom.

17

Pressure Tension & Software Pipelining

Scheduling vs Register Pressure

Moving an instruction's definition earlier (to overlap latency) lengthens its live range — more values live at once — raising register pressure and risking spills. The two phases pull opposite ways.

  • Schedule too eagerly → spill code negates the latency win
  • Schedule too timidly → pipeline stalls
  • Modern back ends use pressure-aware schedulers, or schedule twice (pre- and post-allocation)

Two-Pass Practice

Many compilers schedule before allocation (to expose parallelism) and again after (to clean up spill code and finalise ordering).

Software Pipelining

For loops, overlap iterations: start iteration i+1's independent work while i finishes. The body becomes a steady-state kernel bracketed by a prologue and epilogue.

  • Modulo scheduling finds an initiation interval (II) — the cycle gap between successive iteration starts
  • II is bounded by resources (ResMII) and recurrences (RecMII)
  • Crucial on in-order VLIW/DSP targets; Itanium relied on it

Diminishing Returns

On big out-of-order cores the hardware already overlaps iterations dynamically, so software pipelining's payoff is largest on in-order and explicitly-parallel machines.

18

Calling Conventions & the ABI

The ABI (Application Binary Interface) is the contract that lets separately-compiled functions interoperate: who passes arguments where, who preserves which registers, how the stack is laid out. The back end must obey it exactly — it constrains the allocator and shapes every call.

System V AMD64 ABI (Linux/macOS/BSD)

  • Integer/pointer args: RDI, RSI, RDX, RCX, R8, R9 — then the stack
  • FP/vector args: XMM0–XMM7
  • Return value: RAX (and RDX for 128-bit / second word)
  • Stack must be 16-byte aligned at the point of a call
  • The red zone: 128 bytes below RSP a leaf function may use without adjusting RSP

Caller- vs Callee-Saved (SysV)

Caller-saved (volatile)Callee-saved (preserved)
RAX RCX RDXRBX RBP
RSI RDIR12 R13
R8 R9 R10 R11R14 R15

Other ABIs Differ

Windows x64 uses RCX, RDX, R8, R9 + 32-byte shadow space, no red zone. AArch64 AAPCS64 passes in X0–X7, returns in X0; X19–X28 callee-saved. Same idea, different registers.

19

Stack Frames, Prologue & Epilogue

Each call gets a stack frame: storage for spills, locals, callee-saved registers and outgoing arguments. The prologue sets it up; the epilogue tears it down. The stack grows down on x86-64.

higher addresses ↑ caller's frame arg 7,8… (stack args) return address (pushed by call) saved RBP ← RBP points here saved callee regs (RBX,R12…) local variables spill slots outgoing args ← RSP red zone (128 B, leaf) lower addresses ↓ (stack grows down)

Prologue

push rbp           ; save caller's RBP
mov  rbp, rsp      ; set frame pointer
sub  rsp, 48       ; reserve locals+spills
                   ; (keep 16-byte aligned)
push rbx           ; save callee-saved used

Epilogue

pop  rbx           ; restore callee-saved
mov  rsp, rbp      ; or: leave
pop  rbp
ret

RBP vs RSP-Relative

A frame pointer (RBP) gives stable offsets & easy unwinding; -fomit-frame-pointer frees RBP as a GPR and addresses locals off RSP — one more register for the allocator.

20

How the ABI Constrains the Allocator

The calling convention is not advisory — it injects hard constraints straight into register allocation.

Pinned Registers at Calls

  • Argument values must land in RDI, RSI, RDX… before a call — pre-coloured nodes
  • The return value must be in RAX after
  • A call clobbers all caller-saved registers: model it as defining (killing) them

Values Live Across Calls

A value needed after a call should live in a callee-saved register (it survives the call for free) — or be spilled. The allocator weighs the one-off cost of saving a callee-saved register in the prologue against repeated caller-saved spills.

Returning Structs

  • Small structs (≤ 16 bytes) returned in RAX:RDX (or XMM pair) per SysV field classification
  • Large structs: caller allocates space, passes a hidden pointer in RDI (sret); the other args shift right

Variadic & Alignment

Variadic calls set AL = number of vector args used. The 16-byte alignment requirement at call forces the prologue's sub rsp to round up — the allocator and frame-layout pass cooperate to keep it.

21

Worked Example — Source & IR

Let's take one small function all the way down. It sums squared differences — one parameter passed, a loop, a return value.

C source:

int sumsq(int *a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++)
        s += a[i] * a[i];
    return s;
}

What the Back End Must Decide

  • Which instructions realise a[i], the multiply, the add
  • Which registers hold a, n, i, s, the loaded element
  • Where to load a[i] only once (CSE already shared it)

Optimised SSA-ish IR (loop body):

loop:
  %i   = phi i32 [0, %entry], [%i.n, %loop]
  %s   = phi i32 [0, %entry], [%s.n, %loop]
  %p   = getelementptr i32, i32* %a, i32 %i
  %x   = load i32, i32* %p
  %sq  = mul i32 %x, %x
  %s.n = add i32 %s, %sq
  %i.n = add i32 %i, 1
  %c   = icmp slt i32 %i.n, %n
  br i1 %c, label %loop, label %exit
22

Worked Example — the Assembly

Args arrive per the ABI: x86-64 a→RDI, n→ESI, return in EAX; AArch64 a→X0, n→W1, return in W0. Selection picks scaled loads and a fused multiply on ARM; allocation keeps the hot values in registers — no spills needed.

x86-64 (System V):

sumsq:
    xor   eax, eax        ; s = 0  (return reg)
    xor   ecx, ecx        ; i = 0
    test  esi, esi        ; n <= 0 ?
    jle   .done
.loop:
    mov   edx, [rdi+rcx*4] ; x = a[i]  (scaled)
    imul  edx, edx         ; x*x
    add   eax, edx         ; s += x*x
    add   ecx, 1           ; i++
    cmp   ecx, esi
    jl    .loop
.done:
    ret                    ; result in EAX

AArch64 (AAPCS64):

sumsq:
    mov   w8, wzr          // s = 0
    mov   w9, wzr          // i = 0
    cmp   w1, #0
    b.le  .done
.loop:
    ldr   w10, [x0, w9, sxtw #2] // x = a[i]
    madd  w8, w10, w10, w8       // s += x*x (FMA-int)
    add   w9, w9, #1             // i++
    cmp   w9, w1
    b.lt  .loop
.done:
    mov   w0, w8           // return s
    ret

xor eax,eax is the idiomatic zero on x86 (smaller, breaks dependences); wzr is the AArch64 zero register. madd fuses multiply-add into one instruction — selection exploited an ISA feature x86 lacks for integers. Both loops are leaf-free of spills, so neither needs a frame: pure red-zone/frameless code.

23

Emission — Text vs Object Code

The final step turns the scheduled MachineInstr stream into bytes. Two paths: write assembly text and shell out to an external assembler, or emit an object file directly. LLVM's MC layer does both from one representation.

Assembly Text (.s)

  • Human-readable; what -S prints
  • Portable across assemblers; easy to inspect/debug
  • Slower — re-parses text, spawns an assembler process

Direct Object Emission

  • Compiler writes ELF/Mach-O/COFF (.o) itself — the integrated assembler
  • No fork/exec, no re-parse — faster builds
  • The default for clang -c

The LLVM MC Layer

MCInst is a target-neutral instruction record. The same MC stream drives:

  • MCAsmStreamer → textual .s
  • MCObjectStreamer → .o via an MCCodeEmitter (encodes opcodes to bytes) and an MCAsmBackend (relaxation, fixups)

One encoder, two outputs — the assembler and the compiler share machinery.

Encoding

The emitter resolves each instruction to its binary form — opcode, ModRM/SIB on x86, fixed fields on AArch64 — and lays out sections (.text, .data, .rodata).

24

Relocations & the Linker Handoff

The compiler emits one object file at a time and cannot know the final address of anything outside it — an external function, a global, a string. It leaves a relocation: a note telling the linker "patch this field once you know the address". This is the bridge to Part 09.

What a Relocation Is

  • A record: (offset in section, symbol, type)
  • Type encodes the patch arithmetic: absolute64, PC-relative32, GOT/PLT-relative…
  • Emitted whenever an operand references a symbol whose address isn't yet fixed
call  printf       ; → R_X86_64_PLT32 reloc
lea   rax, [rip + msg] ; → R_X86_64_PC32

The Assembler/Linker Split

  • Assembler: instructions → bytes + symbol table + relocations → .o
  • Linker: combines objects, assigns final addresses, applies relocations, resolves symbols, builds the executable/shared object

Teaser → Part 09

Linking, dynamic loading, the GOT/PLT for position-independent code, and just-in-time compilation — where code is generated, relocated and executed at run time — are the subject of the next deck.

25

Summary & Further Reading

Key Takeaways

  • The back end lowers optimised IR to target code via three interacting subproblems — a phase-ordering puzzle
  • Selection: cover the IR DAG with instruction tiles — maximal munch (greedy), BURS/iburg (optimal per tree), LLVM SelectionDAG & GlobalISel; peephole cleans up
  • Allocation: map vregs → k registers as graph k-colouring (Chaitin–Briggs: simplify/spill/select, optimistic colouring); coalescing (Briggs/George) removes moves; linear scan is the fast JIT choice; SSA → chordal graphs colour in P; PBQP for irregular ISAs
  • Scheduling: list-schedule the dependence DAG by critical path to hide latency — in tension with register pressure; software pipelining overlaps loop iterations
  • The SysV AMD64 ABI pins args to RDI/RSI/RDX/RCX/R8/R9, returns in RAX, requires 16-byte stack alignment at call, offers a 128-byte red zone — and constrains the allocator
  • Emission via the MC layer → .s or .o; relocations defer addresses to the linker

Further Reading

  • Aho, Lam, Sethi & Ullman — the Dragon Book, ch. 8 (codegen) & 10 (scheduling)
  • Cooper & Torczon — Engineering a Compiler, ch. 11–13
  • Appel — Modern Compiler Implementation, ch. 9–11 (selection, allocation, scheduling)
  • Chaitin et al. (1981); Briggs, Cooper & Torczon — Improvements to Graph Colouring Register Allocation (1994)
  • George & Appel — Iterated Register Coalescing (1996)
  • Poletto & Sarkar — Linear Scan Register Allocation (1999)
  • Fraser, Hanson & Proebsting — iburg (BURS code generation)
  • The System V AMD64 ABI document; the AArch64 AAPCS64; the LLVM Code Generator & MC docs

Next → Part 09

Runtimes, JITs & Modern Backends — linking & loading, garbage collection, the GOT/PLT, just-in-time compilation, tiered execution and tracing JITs.

Part 08 ✓ → Part 09: Runtimes, JITs & Modern Backends