Real Worldtypical

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.

The question

What are all these steps between my .cpp file and the executable, and why does the error come from a different program each time?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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 .cpp after 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

typicalThis is the conventional GCC/Clang driver flow on a Unix-like system. Details differ: MSVC uses a different object format and a different name-mangling scheme, link-time optimization defers code generation to the linker so the object files contain IR rather than machine code, and a build with C++20 modules replaces the textual translation-unit model for the modularised parts entirely.

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.

A C++ program from source to executabletypical
  1. Source filesyou write it
    One or more .cpp files plus headers, which are not compiled themselves.
  2. Translation unitbuild time
    One self-contained source text: the .cpp with every #include textually 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 #line markers.
  3. AST + semantic analysisbuild time
    A 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.
  4. IR and optimizationbuild time
    A 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]].
  5. Assemblybuild time
    Target 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.
  6. Object filebuild time
    ELF/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.
  7. Executablebuild time
    One 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.
  8. Load and runload time
    A 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.

Stopping the driver after each stage
1$ g++ -E main.cpp -o main.ii # preprocess only: the translation unit
2$ wc -l main.ii # a two-line file that includes <iostream>
3 38215 main.ii
4
5$ g++ -S main.cpp -o main.s # compile to assembly
6$ g++ -c main.cpp -o main.o # assemble to an object file
7$ nm -C main.o # what this unit defines and needs
80000000000000000 T main
9 U std::cout
10
11$ g++ main.o -o main # link

Thirty-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.

Which mistake is caught by which programtypical
MistakeCaught byWhat you see
Header not on the include pathPreprocessorfatal error: foo.h: No such file or directory
Undeclared name, wrong argument typesCompiler frontendA type error naming the line and the candidates considered
Declared but never defined anywhereLinkerundefined reference to 'foo(int)' — with the mangled name in the raw output
Defined in two objectsLinkermultiple definition of 'foo', usually a definition left in a header
Struct with different layouts in two unitsspecNothingSilent memory corruption at runtime; the classic ODR violation
Shared library missing at run timeThe loadererror 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 #include textually, expands macros, and evaluates conditional compilation, producing one self-contained translation unit with #line markers 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 .cpp that was never added to the build, or because a C header was included without extern "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 .cpp files 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 (or clang -E) prints the translation unit. wc -l on the result is the most sobering build-time metric available.
  • g++ -S for assembly, -c for an object file, -save-temps to keep every intermediate from a normal build.
  • nm -C file.o lists defined (T, D) and undefined (U) symbols with names demangled; c++filt demangles a name from a raw link error.
  • objdump -d, readelf -s, readelf -r show disassembly, the symbol table and relocations. ldd binary shows the dynamic libraries the loader will need.
  • clang -Xclang -ast-dump prints 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.
  • "-O2 is 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.

The compiler and the linker are two parts of one program.
They are separate programs with separate input languages. The linker is usually shared with C, Rust, Fortran and anything else that emits object files, and it knows nothing about any of their type systems.
Header guards prevent a header from being compiled more than once.
They prevent it being included more than once *per translation unit*. Every unit still processes it fully — that is the cost [[modules]] addresses.
Declaring a function is the same as having it.
A declaration is a promise to the compiler. The linker checks whether anything kept it, and "undefined reference" is the report that nothing did.
Compiling with the same flags guarantees compatible objects.
Different compiler versions, standard-library versions or ABI-affecting flags can produce objects that link and then misbehave, because layout and mangling assumptions differ — see [[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.

typicalThe four-program flow describes GCC and Clang drivers on Unix-like systems. MSVC splits the work differently, uses COFF objects and a different mangling scheme, and its linker performs some of what GCC does in the compiler. With LTO enabled in any of them, object files hold IR and code generation happens at link time, which changes both the artifacts and where the time goes.
specThe One Definition Rule is a requirement of the C++ standard with "no diagnostic required" — the standard explicitly permits an implementation not to detect a violation. That is a specification-level licence, not a compiler limitation, which is why sanitizers and linkers can only catch a subset and why the remainder manifests as undefined behavior at run time.
implementationName mangling is not specified by the C++ standard. The Itanium C++ ABI scheme used by GCC and Clang and the MSVC scheme are mutually unintelligible, and objects compiled by one cannot generally be linked with the other. This is why prebuilt C++ libraries are distributed per compiler and why C is the lingua franca of binary interfaces.

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)" but foo is clearly defined. List the possible causes.
  • Why can the linker catch a missing definition but not a mismatched one?

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Build caching, distributed compilation and reproducible artifacts
    The 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.