The C++ Pipeline
Preprocessor, compiler, assembler, linker: four programs, not one. The translation unit is the compilation boundary, headers are copied into every unit that includes them, and the linker is the only stage that sees the whole program.
What are all these steps between my .cpp file and the executable, and why does the error come from a different program each time?
A sequence of independent artifacts, each written to disk and readable: a source file, a preprocessed translation unit (one enormous self-contained source text with every included header pasted in), an AST inside the compiler, an object file containing machine code plus a symbol table and relocations, and finally an executable with those symbols resolved. The translation unit is the crucial one: it is the largest thing the compiler ever sees, and everything outside it is a name the compiler must take on trust.
The compiler may transform a translation unit however it likes provided the observable behavior specified by the standard — the abstract machine's I/O, volatile accesses and program termination — is preserved; that is the as-if rule, and it is the only constraint. Across translation units it may assume the One Definition Rule holds: every entity used has exactly one definition in the whole program and all definitions agree. That assumption is unchecked, and violating it is undefined behavior that typically manifests as a wrong function being called rather than as a diagnostic.
Key points
- Building C++ runs four programs — preprocessor, compiler, assembler, linker — each with its own errors, and identifying which one spoke is most of the diagnosis.
- A translation unit is one
.cppafter preprocessing; it is the compiler's entire world, and everything else in the model follows from that. - Headers exist because the compiler sees only one unit and needs declarations for everything else; they are copied textually into each unit.
- The linker knows mangled symbol names and almost nothing else, so it catches missing and duplicate definitions but not mismatched ones.
- The One Definition Rule is a global invariant nothing checks; violating it is undefined behavior that links cleanly.
- Separate compilation buys parallel builds and incremental rebuilds and pays with reparsing, no cross-unit optimization by default, and unchecked ODR.
Four programs, one command line
Typing g++ main.cpp -o main runs at least four distinct programs, each with its own input language, its own errors and its own flags. The preprocessor produces a translation unit from a source file plus everything it includes. The compiler proper turns that translation unit into assembly. The assembler encodes the assembly into an object file. The linker composes object files and libraries into an executable.
Knowing which program produced an error is most of diagnosing it. "No such file or directory" for a header is the preprocessor. "Undeclared identifier" is the compiler frontend. "Undefined reference to foo" is the linker, and it means the compiler was told a function exists and the linker could not find it — a fundamentally different problem from anything the compiler could have caught. "Multiple definition of foo" is the linker too, and it usually means a definition landed in a header.
The driver hides all of this by default, which is convenient and is why the pipeline is invisible until it breaks. -E, -S and -c stop it after each stage and hand you the intermediate artifact.
- Source filesyou write itOne or more
.cppfiles plus headers, which are not compiled themselves. - Translation unitbuild timeOne self-contained source text: the
.cppwith every#includetextually replaced and macros expanded.Self-containment. The compiler needs nothing else on disk to compile it.The file boundaries. A diagnostic must reconstruct which header a line came from via#linemarkers. - AST + semantic analysisbuild timeA typed tree with overload resolution done, templates instantiated and constant expressions evaluated.Types, name lookup across namespaces and bases, and which overload each call means.
- IR and optimizationbuild timeA middle-end IR (LLVM IR for Clang, GIMPLE for GCC) over one translation unit.Analysable form; optimizations legal under the as-if rule.Source structure. Inlining and unrolling detach the code from the lines you wrote — see
[[debugging-optimized-code]]. - Assemblybuild timeTarget instructions in text form, with mangled symbol names.A commitment to one architecture and ABI.Portability, and the C++ names —
foo(int)is now_Z3fooi. - Object filebuild timeELF/Mach-O/COFF: machine code, a symbol table of defined and undefined names, and relocations.A record of exactly what this unit provides and what it still needs.Most of the type system. The mangled name is nearly all the linker knows.
- Executablebuild timeOne image with symbols resolved, relocations applied and a program header for the loader.The first moment anything sees the whole program.The separation. Which object file a function came from survives only in debug info.
- Load and runload timeA process image, with dynamic libraries mapped and their symbols bound.Addresses. Everything before this was position-independent or relocatable.
Read it asThe loses column explains the error messages. By the object-file stage the linker knows a mangled name and nothing else, which is why a mismatched declaration produces "undefined reference" instead of a type error — the two units simply agreed to disagree, and no one checked.
The translation unit is the compilation boundary
A translation unit is one .cpp file after preprocessing. It is the unit the compiler processes, the unit it optimizes within, and the unit it emits an object file for. Everything about C++ builds follows from this being the boundary.
It explains why headers exist. Since the compiler sees only this unit, any function defined elsewhere must be *declared* here so the compiler knows its signature; the declaration goes in a header and is textually copied in. It explains why a header change rebuilds everything that includes it, directly or transitively — and why include graphs are the dominant term in C++ build times. It explains why compilation parallelises perfectly (units are independent) and why linking does not (it is the one global step).
And it explains the One Definition Rule. Each unit is compiled in isolation, so nothing checks that the declaration you wrote in unit A matches the definition in unit B. The standard makes the mismatch undefined behavior rather than an error, because catching it would require exactly the whole-program view the model gives up. In practice the symptom is a program that links and then behaves as though a different function ran — because one did.
1$ g++ -E main.cpp -o main.ii # preprocess only: the translation unit2$ wc -l main.ii # a two-line file that includes <iostream>3 38215 main.ii4 5$ g++ -S main.cpp -o main.s # compile to assembly6$ g++ -c main.cpp -o main.o # assemble to an object file7$ nm -C main.o # what this unit defines and needs80000000000000000 T main9 U std::cout10 11$ g++ main.o -o main # linkThirty-eight thousand lines from a two-line file, and every other translation unit that includes <iostream> produces its own copy of them. That is the cost model of textual inclusion, and it is the argument for [[modules]].
What the linker knows, and what it does not
The compiler encodes a C++ function's signature into its symbol name — name mangling — so that overloads, namespaces and templates get distinct symbols and the linker can distinguish them without understanding C++. foo(int) becomes something like _Z3fooi. That is the entire extent to which type information crosses the translation-unit boundary in the classic model.
It is enough to catch a signature typo, because a call to foo(int) and a definition of foo(long) produce different mangled names and the reference goes unresolved. It is not enough to catch a struct whose layout differs between two units, a function declared inline in one and not another, or an enum with a different underlying type — all of which mangle identically and all of which are ODR violations that link cleanly and misbehave at runtime.
extern "C" turns mangling off, which is how C++ interoperates with C and with anything that speaks the C ABI. It also removes the overload distinction, which is why an extern "C" function cannot be overloaded — there would be no way to name the two.
| Mistake | Caught by | What you see |
|---|---|---|
| Header not on the include path | Preprocessor | fatal error: foo.h: No such file or directory |
| Undeclared name, wrong argument types | Compiler frontend | A type error naming the line and the candidates considered |
| Declared but never defined anywhere | Linker | undefined reference to 'foo(int)' — with the mangled name in the raw output |
| Defined in two objects | Linker | multiple definition of 'foo', usually a definition left in a header |
| Struct with different layouts in two unitsspec | Nothing | Silent memory corruption at runtime; the classic ODR violation |
| Shared library missing at run time | The loader | error while loading shared libraries — after a successful build |
Why the model persists
Textual inclusion and separate compilation are frequently described as historical accidents, and the first half of that is fair — the model dates from an era when a compiler could not hold a large program in memory. But the properties it produces are real and are still valuable: compilation of independent units parallelises without coordination, a change to one .cpp rebuilds one object file, and the object-file format is a stable interface that lets compilers, languages and decades interoperate.
What it costs is equally real. Every unit reparses every header it includes, so build time scales with the include graph rather than with the code you wrote. The compiler cannot optimize across the boundary, which is why [[link-time-optimization]] exists to buy some of it back at the cost of a much slower link. And the ODR is an unchecked global invariant, which is a category of bug no other stage can find.
C++20 modules are the language's answer: a module is compiled once into a binary interface that other units import rather than re-parse, so the include graph stops being a rebuild multiplier and macros stop leaking across the boundary. Adoption has been slow because the model touches every build system, and because a codebase must migrate as a graph rather than a file at a time — see [[modules]] and [[interface-files]].
How it works
The steps, in the order the compiler takes them.
- The preprocessor expands
#includetextually, expands macros, and evaluates conditional compilation, producing one self-contained translation unit with#linemarkers recording origins. - The frontend parses that unit, performs name lookup and overload resolution, instantiates templates, evaluates constant expressions and produces a typed AST.
- Lowering emits a middle-end IR for the unit; optimization passes run within it under the as-if rule, seeing nothing outside the unit.
- The backend selects instructions, allocates registers and emits assembly with mangled symbol names.
- The assembler encodes that into an object file: a text section of machine code, a symbol table of defined and undefined names, and relocations marking every place an address must be patched.
- The linker resolves undefined symbols against other objects and libraries in command-line order, applies relocations, merges sections, and writes an executable with a program header.
- At run time the loader maps the image, maps any dynamic libraries, and binds their symbols — lazily, for functions, unless told otherwise.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A change to one widely included header triggers a twenty-minute rebuild of a project in which three lines changed.
- "Undefined reference" for a function that is visibly defined — because the definition is in a
.cppthat was never added to the build, or because a C header was included withoutextern "C"and the C++ mangling does not match. - A struct gains a field in one component and not in another, both compile, the program links, and it corrupts memory at run time with no diagnostic anywhere.
- A definition placed in a header produces "multiple definition" at link time in a project that previously included it only once, and the error names a file that has not changed.
- The build succeeds and the program fails to start because a shared library is missing or the wrong version — a failure the compiler had no way to predict.
- Two libraries define the same symbol, the linker silently takes the first in command-line order, and behaviour depends on link order rather than on any source file.
When it helps
- Reading a build failure: the wording tells you which of the four programs failed and therefore what class of fix applies.
- Reasoning about build times, since they are dominated by the include graph and by template instantiation rather than by lines of code.
- Designing a library's physical structure — what goes in the header, what stays in the
.cpp— which decides both compile time and ABI stability for consumers. - Interoperating with other languages, all of which meet C++ at the object-file and C-ABI level rather than at the source level — see
[[interoperability]].
When it hurts
- Reasoning about a template-heavy codebase as though the compiler sees a small file. The preprocessed unit is often tens of thousands of lines before any template is instantiated.
- Assuming the compiler will catch inconsistencies between units. It will not; that is precisely what it gave up in exchange for separate compilation.
- Trying to reduce build times by optimizing code rather than the include graph; the graph is almost always the dominant term.
What it costs
Every one of these is paid by something.
- Separate compilation buys perfectly parallel builds and rebuilds proportional to the changed file, and pays by reparsing every included header in every unit and by giving up cross-unit optimization unless LTO is enabled — which then makes the link the slowest step in the build.
- Textual inclusion buys an extremely simple, language-agnostic mechanism that needs no build-system cooperation, and pays with combinatorial reparsing, macro leakage into every including unit, and order-dependent behaviour.
- Name mangling buys overloading, namespaces and templates over a linker that understands none of them, and pays with unreadable link errors and an ABI that is compiler- and version-specific — see
[[abi-stability]]. - Making ODR violations undefined rather than diagnosed buys the separate-compilation model at all, and pays with a class of bug that is invisible to every stage and manifests as corruption far from its cause.
What else you could do
What a different compiler or language does instead, and when that is better.
- C++20 modules compile an interface once into a binary artifact that importers read instead of re-parsing, removing both the reparse cost and macro leakage — at the cost of a build-system-visible dependency order. See
[[modules]]. - A whole-program compiler that reads all sources at once, as Go effectively does per package and as
[[go-pipeline]]describes, gives up incrementality at the file level and gains a much simpler dependency model. - Unity builds concatenate many
.cppfiles into one translation unit, trading incrementality and symbol isolation for a large reduction in total parse work — a pragmatic hack that works and hurts. - Link-time optimization keeps the model but defers code generation, so the optimizer finally sees across units; the bill is a link step that can take longer than the entire compile phase — see
[[link-time-optimization]].
See it for yourself
The flag, dump or tool that shows you this directly.
g++ -E file.cpp(orclang -E) prints the translation unit.wc -lon the result is the most sobering build-time metric available.g++ -Sfor assembly,-cfor an object file,-save-tempsto keep every intermediate from a normal build.nm -C file.olists defined (T,D) and undefined (U) symbols with names demangled;c++filtdemangles a name from a raw link error.objdump -d,readelf -s,readelf -rshow disassembly, the symbol table and relocations.ldd binaryshows the dynamic libraries the loader will need.clang -Xclang -ast-dumpprints the tree after semantic analysis, including every template instantiation the unit forced.- Compiler Explorer for the compiler stage in isolation, with the source and the assembly linked line by line.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The compiler builds my program." It compiles one translation unit at a time and has never seen your program. The linker is the first stage with a whole-program view, and it understands almost nothing about C++.
- "A header is compiled." A header is copied into every unit that includes it and compiled once per unit. That multiplication is the C++ build-time story.
- "Undefined reference means my code is wrong." It means a promise made in a declaration was not kept by any object file on the link line. The cause is frequently a build configuration, not a source file.
- "If it links, the units agree." They agree on mangled names. They may disagree about layout, inlining and enum sizes, and that disagreement is undefined behavior with no diagnostic.
- "
-O2is applied to my whole program." It is applied within each translation unit. Cross-unit optimization requires LTO and is off by default.
Misconceptions
The claim, and what is actually true.
[[modules]] addresses.[[abi-stability]].Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Building a C++ program runs four tools in a row. The preprocessor pastes headers into your file. The compiler turns that one big file into machine code. The assembler encodes it. The linker joins all the pieces and fills in the addresses. Each tool produces different errors, and knowing which one complained tells you what kind of problem you have.
practical
When a build breaks, read the error for which tool produced it. Missing file: include path. Unknown name or wrong types: compiler. Undefined or multiple definition: linker, and the fix is in the build configuration or in what you put in a header. When builds are slow, run -E on your worst file and count the lines — the include graph, not your code, is nearly always the problem, and forward declarations and pimpl are the classic levers. When two libraries misbehave together, check ABI and link order before suspecting either library.
advanced
The model's deep property is that it exports a *binary* interface rather than a source one. An object file is a contract expressed in mangled names, layouts and calling conventions, and it is stable across decades and languages precisely because it carries so little. Every C++ feature that crosses the boundary — templates, inline functions, virtual tables, exceptions — needed a scheme for expressing itself in that impoverished vocabulary, and each of those schemes is where the hard build problems live: COMDAT folding for duplicate template instantiations, vague linkage for inline functions, unwind tables for exceptions. [[template-instantiation]] and [[abi]] are the two lessons that take this apart.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- What is a translation unit, and why does the answer explain C++ build times?
- You get "undefined reference to
foo(int)" butfoois clearly defined. List the possible causes. - Why can the linker catch a missing definition but not a mismatched one?
Connections
- DevOps / Production Engineering — Build caching, distributed compilation and reproducible artifactsThe separate-compilation model is what makes ccache, distcc and remote execution possible at all — each translation unit is a pure function of its preprocessed input. The tooling that exploits that is owned there, and
[[hermetic-compilation]]is the compiler-side requirement it depends on.