From one command to many stages
In an earlier rung you learned that the compiler turns a translation unit into an object file, that the preprocessor runs first and the assembler and linker run after. That gave you the shape of the toolchain: source goes in, an executable comes out. This rung zooms inside the single step you called "the compiler" and finds that it is itself a pipeline — a sequence of stages, each consuming the previous stage's output and producing a cleaner, lower-level form. The whole point of naming the stages is that you stop seeing the compiler as one opaque verb and start seeing it as a place where specific, understandable transformations happen.
The cleanest way to carve it is into three big regions, and the whole rest of this rung lives inside them. The front end speaks your source language: it lexes, parses, and type-checks, and its job is to either reject your program with a diagnostic or hand a clean internal form to the next region. The middle end speaks no language in particular — it works on a neutral intermediate representation and runs the optimizations. The back end speaks the machine: it lowers that optimized IR to the instructions of a specific CPU. Front end in, machine out, middle end doing the clever bit in between.
The front end: from text to a checked tree
Walk a tiny line through the front end: int n = a + b * 2;. First, the lexer scans the characters and groups them into tokens — int, n, =, a, +, b, *, 2, ; — turning a flat stream of bytes into labelled words. The lexer does not care what the words mean; it only knows what shapes are legal words. Next, the parser reads those tokens against the grammar of C and builds a tree that captures structure: it knows b * 2 binds tighter than the +, so the tree has the multiply as a child of the add. That tree is the abstract syntax tree, and it is the first internal form where your program exists as structure rather than as text.
A tree that parses is not yet a tree that means anything, which is where semantic analysis comes in. This stage walks the tree asking the questions the grammar could not: is n declared? do the types of a and b combine legally under + and *? does an integer promotion apply? It fills in types on every node, resolves each name to its declaration, and it is the stage that emits most of the errors you actually hit — "undeclared identifier," "incompatible types." Only a program that survives semantic analysis is allowed to proceed; everything downstream gets to assume the program is well-typed and well-formed, which is what makes the later stages tractable.
The middle end: optimizing a language-neutral form
Once the front end has a checked tree, it lowers it into the intermediate representation — the IR — and hands that to the middle end. The IR is deliberately simpler and more uniform than your source: nested expressions are flattened into a sequence of tiny operations, each computing one thing into one temporary. This is roughly the shape the next guide calls three-address code, and a later guide sharpens it further into SSA form. The reason for a neutral IR is the same as the three-region split: optimizations written once on the IR work for every front end and benefit every back end, so the cleverness is built in exactly one place.
source: int n = a + b * 2;
front end -> IR (three operations, two temporaries):
t1 = b * 2
t2 = a + t1
n = t2
middle end -> optimize on the IR (constant folding, etc.)
back end -> pick real instructions for this CPU:
imul r8, rbx, 2
add r8, rax
mov [n], r8The middle end runs the optimizations you will spend two whole guides on: inlining, constant propagation, dead-code elimination, loop transforms, and more. It does not do them in one sweep; it runs a long sequence of small passes, each one a focused transformation that reads the IR and rewrites it. The passes are ordered so each cleans up for the next — propagating a constant exposes dead code, deleting dead code exposes a now-empty loop, and so on. Which passes run, and how aggressively, is governed by the optimization level: -O0 runs almost none and keeps the IR close to your source for debugging, while -O2 turns the full pipeline loose.
The back end: choosing real instructions
The optimized IR is still abstract — it has unlimited temporaries and generic operations, not the registers and opcodes of any real chip. The back end closes that gap in three classic jobs, each of which gets its own treatment in the last guide of this rung. Instruction selection maps IR operations onto actual machine instructions, often fusing several IR ops into one clever instruction the CPU provides. Register allocation assigns the unlimited temporaries to the CPU's small fixed set of physical registers (rax, rbx, and friends), spilling to the stack when there are not enough. Instruction scheduling then reorders the chosen instructions to keep the pipelined CPU busy, since one ordering can run measurably faster than another.
After the back end emits assembly text, the pipeline rejoins the toolchain you already know: the assembler turns that text into the bytes of an object file, and the linker stitches the object files and libraries into the final executable. So the famous one-liner gcc -O2 -Wall main.c is really driving this entire chain — preprocess, lex, parse, type-check, lower to IR, optimize across many passes, select and schedule instructions, allocate registers, assemble, and link — and printing a warning to your terminal whenever a stage notices something fishy.
The rule that licenses the whole optimizer
You might reasonably ask: how is the middle end allowed to rewrite your code at all? The license is the as-if rule. The standard does not describe your program in terms of registers or instructions; it describes it in terms of an abstract machine and a set of observable behaviors — what gets read from and written to volatile objects, what the program outputs, the side effects of I/O. The as-if rule says the compiler may transform your program in any way whatsoever, as long as the observable behavior of the resulting program matches that of the abstract machine. Everything else — the order of internal computations, whether a variable ever lives in memory, whether a loop runs at all — is fair game.
This is why your -O2 build can compute b * 2 at compile time, delete a variable you "clearly" wrote to, or run your loop in a totally different instruction order, and still be a correct compilation: none of that is observable, so the as-if rule permits it. But the rule has a dark twin you have met before. The compiler reasons about the C abstract machine, and that machine has no defined behavior once you trigger undefined behavior — so the optimizer is entitled to assume UB never happens and optimize on that assumption. That is precisely how a bug can be invisible at -O0 and corrupt at -O2: the extra passes lean on an assumption your buggy code violates. The last guide of this rung returns to this as UB-driven optimization in full; for now, just hold that the as-if rule is what makes the optimizer both powerful and, in the presence of UB, ruthless.