AlgoMaster Logo

Compilation Process (Preprocessor, Compiler, Assembler, Linker)

Low Priority31 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

The Introduction section covered what each stage of the build pipeline does conceptually. This chapter is about working with the stages directly: invoking them, inspecting their outputs, and debugging build errors at each step. The goal is to stop the build at any stage, read the artifact it produced, and identify which tool is responsible when something fails.

Recap of the Pipeline

A g++ build runs four tools back to back. Each one consumes the output of the previous one and produces a new file format. The driver passes intermediate files between them silently, so most of the time you only see the source going in and the executable coming out.

The four flags -E, -S, -c, and "no flag" tell g++ where to stop. The same information as a table you can refer back to:

StageToolFlagInputOutputWhat happens
Preprocesscpp-E.cpp.i (text)Expand #include, #define, #ifdef
Compilecc1plus-S.i.s (text)Parse, type-check, optimise, emit assembly
Assembleas-c.s.o (binary)Encode assembly into machine code bytes
Linkld(no flag).o files + libsexecutableResolve symbols, produce runnable binary

All examples in this chapter use a tiny e-commerce program. Save this as cart.cpp so you can follow along.

The same five lines drive every stage that follows. Each section stops the build at a different point so you can see what that point produces.

Stage 1: Preprocessor (-E)

The -E flag tells g++ to stop right after the preprocessor and dump the expanded source to standard output, or to a file with -o.

cart.i is plain text. You can open it in any editor. It is huge, because #include <iostream> pulls in <ostream>, <istream>, <ios>, <streambuf>, and a long chain of other headers. A 9-line cart.cpp typically expands to 30,000+ lines on libstdc++.

Check the line count to see the scale.

The interesting bit is at the very bottom of cart.i, where your original main ends up. The tail of cart.i (some lines elided for space):

Two details stand out. First, DISCOUNT_RATE is gone. The preprocessor replaced every occurrence with the literal 10 before the compiler ever saw the file. Macros never appear in the compiled output. Second, those # 4 "cart.cpp" lines are line markers. They tell the compiler "the next line came from line 4 of cart.cpp", so error messages can point back to the original source instead of the bloated expansion.

The preprocessor does not know any C++. It does not care if int main is spelled correctly or if the program type-checks. It only handles directives that start with # and a few other text-level tasks (line splicing, trigraph translation, comment removal). Errors at this stage are limited to things like missing headers.

That fatal error: ... No such file or directory is the preprocessor's signature error. It means a header path did not resolve. The fix is always either correcting the header name or adding the right -I directory to the command line.

Stage 2: Compiler (-S)

The -S flag stops the build after the compiler proper. The output is a text file containing assembly language for your CPU's architecture.

cart.s is much smaller than cart.i because the compiler stripped out every standard library declaration that was not used. Only the assembly for main (and the data it references) ends up in the output.

A trimmed view of x86_64 assembly for main, assembled with g++ -S -O0 cart.cpp:

Reading assembly fluently is not required to get value out of -S. A few things stand out even on first exposure to x86_64.

The string literal "Cart total: $" lives in a labeled data section (.LC1). The main label marks the start of your function. The call instructions invoke other functions, but those names look like garbage: _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc. That is name mangling. C++ encodes namespaces, template parameters, and overload signatures into a unique symbol name so the linker can tell two overloads of operator<< apart. The mangled name above is roughly std::basic_ostream<char, std::char_traits<char>>::operator<<(char const*). You can decode mangled names with c++filt:

The -S stage is also where optimisation happens. Compare assembly built with -O0 (no optimisation, the default) to assembly built with -O2. With -O2, the compiler often constant-folds 100.0 - (100.0 * 10 / 100) into a single literal 90.0, eliminates the local variables, and the resulting main is shorter. Looking at -S output is the most direct way to see what the optimiser did to your code.

-O2 and -O3 make compilation noticeably slower (sometimes 2-3x) but produce binaries that often run 2-10x faster. For everyday "does my code compile?" iteration, use -O0. For benchmarking or release builds, switch to -O2.

Errors at this stage are the syntactic and semantic ones: missing semicolons, undeclared identifiers, type mismatches, no matching overload.

That message comes from the compiler's semantic analysis. The preprocessor was happy because subtotall does not start with #. The compiler tried to look up the name and failed.

Stage 3: Assembler (-c)

The -c flag runs the preprocessor, the compiler, and the assembler, then stops. The output is an object file, conventionally .o on Linux and macOS or .obj on Windows.

Object files are binary. They cannot be opened in a text editor. They contain three things bundled into a structured file format (ELF on Linux, Mach-O on macOS, PE/COFF on Windows):

  1. Machine code for every function defined in the translation unit, with placeholders left for any external references.
  2. A symbol table listing every function and global variable the file defines or uses.
  3. Relocation information telling the linker which addresses need patching once the final layout is known.

You inspect object files with command-line tools rather than text editors. The two most useful are nm (list symbols) and objdump (dump everything, including disassembly).

nm prints the symbol table.

The single letter in the middle column is the symbol type. The most common values:

LetterMeaning
TSymbol is defined in this object file's text (code) section.
DSymbol is defined in the data section (initialised global).
BSymbol is defined in the BSS section (uninitialised global).
USymbol is undefined. This file uses it but does not define it.
WWeak symbol. Used by something but can be overridden.

In the output above, main is T (defined here), and everything starting with _Z is U (referenced but defined elsewhere). Those _Z-prefixed names are the mangled standard library symbols. The linker's job in stage 4 is to find a T for every U.

Pipe nm through c++filt to demangle the names.

Now the picture is clear: main calls std::cout's operator<< for double and for char const*, plus std::endl, plus the constructors of the Init helper that sets up std::cout before main runs.

objdump -t is similar to nm but prints richer metadata. objdump -d disassembles the machine code back into assembly so you can see what got encoded.

The hex bytes on the left (55, 48 89 e5, ...) are the machine code the assembler produced from the .s file. The text on the right is the disassembled equivalent. They should match what was in cart.s. To confirm "did the assembler emit what the compiler wrote?", objdump -d is how you check.

Object files are tiny compared to executables (no library code, no startup runtime). For a multi-file project, building the .o files separately and then linking is what lets make and cmake skip rebuilding files that have not changed.

Object files are not runnable. Try ./cart.o and the OS refuses with "exec format error" or "permission denied" (the file is not even marked executable). They are parts waiting to be assembled into a whole.

Stage 4: Linker

The linker takes one or more .o files plus any libraries you ask for and produces an executable. There is no special g++ flag for "linker only"; invoke the linker by running g++ with .o files as input and no -c/-S/-E flag.

The linker's core job is symbol resolution. For every U in every object file, it finds a T (or equivalent) somewhere on its input and patches the address into the machine code. For a single-file program like cart.cpp, the only object file is cart.o and most of the U symbols come from the C++ standard library, which g++ adds to the link line automatically.

To see what g++ invokes, pass -v (verbose).

collect2 is a thin wrapper around ld (the actual linker). Note the -lstdc++, -lm, -lc, and related entries: those are the libraries g++ adds for you. -lstdc++ is the C++ standard library (where std::cout lives). -lc is the C runtime. Without these, the linker would not find std::cout's definition and would fail.

The two error messages most often seen at this stage are undefined reference and multiple definition.

Undefined Reference

Drop a function call to something that nobody defines, and the linker complains.

The error comes from ld. The compiler was happy because it saw the declaration. The linker walked every input looking for a T applyDiscount(double&) symbol, found none, and gave up. The fix is one of:

  • Provide a definition somewhere and add that .cpp (or .o) to the link line.
  • Link against a library that defines it (-lsomething).
  • Remove the call if it should not be there.

Multiple Definition

The opposite problem: two object files both define the same symbol. The linker does not know which one to use, so it refuses.

order.cpp:

shipping.cpp:

main.cpp:

This is the classic violation of the One Definition Rule: a symbol may be declared many times but defined exactly once across all translation units. The fix here is to pick one .cpp to own the definition and have the other only declare it (with extern).

Order Matters (For Libraries)

For object files, the order on the link line does not usually matter. For libraries it does. The linker walks its input left to right, and when it encounters a library it pulls in only the symbols needed to satisfy currently-unresolved references. If a later object file needs a symbol from an earlier library, the linker has already moved past that library and will not go back.

The rule of thumb: object files first, then libraries, with libraries that depend on other libraries listed before the ones they depend on.

Putting It Together: A Two-File Build

A single-file program hides what the linker is really for. To see all four stages doing real work, split the program across two files plus a header.

cart.h declares a function:

cart.cpp defines it:

main.cpp uses it:

The fast path is one command:

But to see what each stage actually does, run them separately. First, compile each .cpp into its own .o:

Now look at the symbols in each object file:

cart.o defines applyDiscount (T = defined in text section). It needs nothing from elsewhere.

main.o defines main and references applyDiscount (with a U) plus the standard library symbols. Critically, main.o does not contain any code for applyDiscount. The compiler only saw the declaration in cart.h, so it emitted a call instruction with a placeholder address and recorded a relocation entry that says "fix this up at link time, target is applyDiscount(double, double)".

The link step combines them:

The linker walked the symbol tables of both object files, matched main.o's U applyDiscount to cart.o's T applyDiscount, patched the call instruction in main.o with the actual address, then pulled in libstdc++ to resolve the std::cout-related symbols. The output is a fully linked executable.

The full multi-file flow as a diagram:

Two source files become two object files, then the linker combines them with the standard library into one executable. If you forget cart.o on the link line, the linker fails with undefined reference to 'applyDiscount(double, double)' because no input file provides the definition.

This per-file compile-then-link pattern is what build systems automate. When you change only cart.cpp, a build system can recompile only cart.o and re-run the linker. main.o does not need to be rebuilt because main.cpp did not change. For a project with hundreds of source files, that incremental rebuild is the difference between a 2-second build and a 5-minute build.

Mapping Errors to Stages

When a build fails, the first useful question is "which stage produced this error?" The answer narrows down where to look.

Error patternStageTypical cause
fatal error: foo.h: No such file or directoryPreprocessorMissing header or wrong -I path
error: 'foo' was not declared in this scopeCompiler (semantic)Misspelled name, missing declaration, missing #include
error: expected ';' beforeCompiler (parser)Syntax error in the source
error: invalid conversion from ... to ...Compiler (type-check)Type mismatch
error: no matching function for call to ...Compiler (overload)No overload matches the arguments
undefined reference to '...'LinkerFunction or global declared but never defined, or .cpp not on link line
multiple definition of '...'LinkerSame symbol defined in two object files (ODR violation)
cannot find -lfooLinkerLibrary libfoo not found in the linker's search paths

The pattern: anything mentioning headers or #include is the preprocessor. Anything about names, types, or syntax is the compiler. Anything about "reference", "definition", or -l is the linker. Identifying the responsible tool cuts the debugging space dramatically, especially in larger projects where the same kind of error can have very different fixes depending on the stage.

Quiz

Compilation Process Quiz

10 quizzes