C++ / GETTING STARTED
From source to executable: preprocessing to linking
Follow a C++ build through preprocessing, compiling, assembling and linking, stop it at any stage with g++ flags, and tell compile errors from link errors.
What you will learn
- Stop a build after any stage with g++ -E, -S or -c and read the intermediate file
- Tell compile errors from link errors: one names a line, the other names a symbol
- Explain why a declaration satisfies the compiler but only a definition satisfies the linker
- Use nm -C on an object file to see which symbols are defined and which are undefined
Understanding From source to executable: preprocessing to linking
The command g++ build.cpp -o build looks like one program, but it drives four in a row. The preprocessor manipulates text and nothing else: it pastes headers in, expands macros, and drops the branches of conditionals it decided against, handing the compiler one long stream called a translation unit. The compiler proper is where C++ semantics live, so types get checked, overloads get resolved, and layout and instructions get chosen for one specific machine; its output is assembly text. The assembler converts that text into an object file of machine code, and the linker combines object files and libraries into the single executable you run.
An object file is machine code with holes in it. When the compiler compiles the call stage_name(i) it only needs the declaration to check the argument and return types, so it emits an instruction that transfers control to a symbol whose address is still unknown, together with a relocation entry saying patch this slot once the address is known. The linker gathers the definitions, assigns final addresses and fills the holes; a symbol nothing defines becomes undefined reference, and a symbol two object files define becomes multiple definition. That split is why a function can be defined below the code that calls it, why link errors quote a symbol instead of a line and column, and why that symbol looks mangled, since C++ encodes parameter types into names so overloads stay distinct.
Every stage can be stopped and inspected, which is what makes the model useful rather than trivia: -E stops after preprocessing, -S after compiling, -c after assembling, and no flag at all runs the whole chain and links against the standard library. Once you have seen the intermediate files, most build failures classify themselves. A header that cannot be found is a preprocessing failure, a type mismatch is a compile failure, a missing definition or a forgotten library is a link failure, and a segfault is none of them, because it happens after every stage already succeeded.
<iostream>
STAGE_COUNT
const char* stage_name(int index); // declaration: all the compiler needs
int main() {
std::cout << "stages: " << STAGE_COUNT << '\n';
for (int i = 0; i < STAGE_COUNT; ++i) {
std::cout << i + 1 << ". " << stage_name(i) << '\n';
}
std::cout << "this line is " << __LINE__ << " after preprocessing\n";
}
const char* stage_name(int index) { // definition: what the linker looks for
static const char* const names[] = {"preprocess", "compile", "assemble", "link"};
return names[index];
}
The compiler works on one translation unit and trusts declarations, while a separate later step, the linker, must find exactly one real definition for every symbol that unit referenced.
Worked examples
A declaration is enough to compile
Shows that the compile step accepts a function nobody has defined, and that the complaint only arrives at link time.
<iostream>
int helper(); // declared here, defined nowhere
int main() {
std::cout << "the compile step only had to trust the declaration\n";
// Uncomment the next line: g++ -c still succeeds, but linking reports
// undefined reference to `helper()'
// std::cout << helper() << '\n';
}
Example explained
Line 1int helper(); supplies a name, a parameter list and a return type, which is everything the compiler needs to check a call.
Line 2With the call commented out no reference to helper is emitted, so the object file asks the linker for nothing and the build finishes.
Line 3Uncomment the call and g++ -c still succeeds; the object file merely records helper as undefined, and the failure appears only when the linker searches for it.
Line 4The message shows helper() with parentheses because the real symbol is mangled to _Z6helperv and the linker demangles it for display.
One definition, one address
Demonstrates that linking gives each function and each static object a single location in the finished executable.
<iostream>
int next_id() {
static int counter = 0;
return ++counter;
}
int (*first)() = &next_id;
int (*second)() = &next_id;
int main() {
std::cout << next_id() << '\n';
std::cout << next_id() << '\n';
std::cout << std::boolalpha << (first == second) << '\n';
}
Example explained
Line 1next_id is compiled once into one block of machine code, and the linker places that block at exactly one spot in the executable.
Line 2first and second are initialised from the same symbol, so relocation writes the same address into both and the comparison is true.
Line 3counter is one object in the data area of the executable, which is why the second call continues at 2 instead of restarting at 1.
Line 4For a position-independent executable the loader shifts everything by one base offset, but the layout it shifts was decided by the linker.
Where library code enters the build
Separates the declaration that preprocessing pastes in from the compiled implementation that the linker attaches.
<cmath>
<iostream>
int main() {
volatile double area = 2.0; // volatile stops the compiler folding the call away
std::cout << std::sqrt(area) << '\n';
std::cout << "the header declared sqrt; its code was compiled years earlier\n";
}
Example explained
Line 1#include <cmath> is pure preprocessing: it inserts declarations of sqrt, not a single instruction of its implementation.
Line 2volatile forces the value of area to be read at run time; without it the compiler may compute the square root during the compile stage and store the answer.
Line 3The implementation ships prebuilt in the platform's math library, which the g++ driver puts on the link line for you, so you never type -lm.
Line 4Six significant digits appear because that is the default stream precision, a run-time formatting decision unrelated to any build stage.
Important notes
The four stages describe interfaces, not processes: GCC's preprocessor is built into cc1plus, and the line markers it emits are what keep later diagnostics pointing at your original file and line rather than at line 40000 of the preprocessed stream.
Templates and inline functions deliberately break the one-definition-per-object-file picture, since they may be compiled into many object files and the linker discards the duplicates, which is why they are allowed to live in headers.
Common mistakes
Reading 'undefined reference to ...' as a compiler complaint and hunting for a syntax slip; the compiler already accepted the code, so the real fix is a missing definition, a source file left off the command line, or a missing library flag.
Building a .cpp file with gcc instead of g++; the compile stage succeeds and then linking dies with a wall of undefined references to std::ostream, because the C driver does not link the C++ standard library.
Assuming g++ -c produced a program; the resulting .o has no start-up code and no execute permission, so ./build.o answers with 'Permission denied' and looks like a broken compiler.
Try it yourself
Change, predict, then run
In a browser editor, declare int total(int a, int b); at the top of a file, call it from main, and define it at the very bottom, confirming it runs. Then delete the definition and record whether the new error mentions a line and column or only a symbol name.
Open the C++ workspaceCheck your understanding
A single-file program compiles cleanly with g++ -c but fails when you link the object file. Which situation can explain that?
- A closing brace is missing at the end of the file
- An #include names a header that is not on the include path
- A function is declared and called but never defined in anything being linked
- A macro is used on a line above its #define
Show answer
Only the missing definition can survive the compile stage, because the compiler is entitled to emit a call to a symbol it has merely seen declared and leave the address for the linker to patch. The missing header is the tempting answer, but #include is resolved during preprocessing, so g++ -c would have failed before any object file existed.