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.
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:
| Stage | Tool | Flag | Input | Output | What happens |
|---|---|---|---|---|---|
| Preprocess | cpp | -E | .cpp | .i (text) | Expand #include, #define, #ifdef |
| Compile | cc1plus | -S | .i | .s (text) | Parse, type-check, optimise, emit assembly |
| Assemble | as | -c | .s | .o (binary) | Encode assembly into machine code bytes |
| Link | ld | (no flag) | .o files + libs | executable | Resolve 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.
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.
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.
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):
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:
| Letter | Meaning |
|---|---|
T | Symbol is defined in this object file's text (code) section. |
D | Symbol is defined in the data section (initialised global). |
B | Symbol is defined in the BSS section (uninitialised global). |
U | Symbol is undefined. This file uses it but does not define it. |
W | Weak 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.
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.
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:
.cpp (or .o) to the link line.-lsomething).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).
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.
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.
When a build fails, the first useful question is "which stage produced this error?" The answer narrows down where to look.
| Error pattern | Stage | Typical cause |
|---|---|---|
fatal error: foo.h: No such file or directory | Preprocessor | Missing header or wrong -I path |
error: 'foo' was not declared in this scope | Compiler (semantic) | Misspelled name, missing declaration, missing #include |
error: expected ';' before | Compiler (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 '...' | Linker | Function or global declared but never defined, or .cpp not on link line |
multiple definition of '...' | Linker | Same symbol defined in two object files (ODR violation) |
cannot find -lfoo | Linker | Library 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.
10 quizzes