C++ Part I · Module 2 · Building and Running C++ Programs
Translation Units, Object Files and the Linker
Chapter 1.7 gave you the shape of a build: many source files become many object files, and a linker resolves the names between them. It deliberately stopped there, because the shape is what the rest of Part I needs and the mechanics would have been noise at that point. This chapter is the mechanics. We take the two-file project from Chapter 2.4 and stop the build after each stage, keeping the file that stage produced, so that nothing here has to be imagined — the preprocessed text, the assembly, the object file and the finished program are all things you can list and open. Along the way two facts land that are hard to believe until you see them: an object file can contain correct, finished machine code and still be impossible to run, and the thing that finally joins your two files together never sees a single line of your source.
Foundation17 min readC++Part IToolchainLinking
Part I · Module 2 · Chapter 2.6 · Translation Units, Object Files and the Linker
1. What Is New Here
Chapter 1.7 already told you the shape of a build, and it is worth saying plainly what this chapter adds so you know what to pay attention to.
From 1.7, and still true: the compiler reads one translation unit at a time and knows nothing about the others; the linker works on names rather than source; a declaration is a promise and the linker is what checks it was kept.
New here: the four stages, each one stopped at and inspected, with the file it produced. The sizes. Why an object file cannot be run. And a table saying exactly what each stage is able to check — which is the thing that makes Chapter 2.7 readable, because an error you cannot place in a stage is an error you cannot reason about.
2. The Translation Unit
Chapter 2.5 ended with the preprocessor's output: your source file with every included file pasted into it. That text has a name.
A translation unit is one source file together with everything its
#includedirectives brought in. It is the unit the compiler actually processes.
This is a real term from the C++ standard, not jargon. It matters because it is the boundary of what the compiler knows. When Chapter 1.7 said the compiler sees one translation unit at a time, this is the thing it sees.
The project we will follow is the one built in Chapter 2.4. Here it is again, so this chapter stands on its own:
#ifndef CYCLES_HPP
#define CYCLES_HPP
int cycles_for(int transfers, int cycles_per_transfer);
#endif#include "cycles.hpp"
int cycles_for(int transfers, int cycles_per_transfer)
{
const int setup_cycles = 3;
return setup_cycles + transfers * cycles_per_transfer;
}#include "cycles.hpp"
#include <iostream>
int main()
{
const int transfers = 8;
const int cycles_per_transfer = 2;
std::cout << "transfers = " << transfers << '\n';
std::cout << "cycles = " << cycles_for(transfers, cycles_per_transfer) << '\n';
return 0;
}That project has two translation units, because it has two .cpp files:
main.cpppluscycles.hppplus all of<iostream>— 64,206 lines after preprocessing.cycles.cpppluscycles.hpp— 21 lines.
Note that cycles.hpp is part of both. A header is not a translation unit of its own; it has no separate existence at build time. It is text that gets pasted into the units that include it. That is why a header with a mistake in it produces errors in every file that includes it, and why Chapter 2.4's guard failure was reported at the header with notes naming the including file.
3. The Four Stages
Each arrow is a separate program run by the driver. Chapter 2.3 called c++ a driver for exactly this reason: one command, four tools.
4. Stopping at Each Stage
Four commands, each stopping one step later. All sizes below are from the machine used to write this chapter.
Stage 1 — preprocess
$ c++ -std=c++17 -E cycles.cpp -o cycles.icycles.cpp 7 lines, 173 bytes
cycles.i 21 lines, 389 bytesChapter 2.5 covered this in full. Seven lines became twenty-one, because cycles.hpp is small. The same command on main.cpp produced 64,206 lines, because <iostream> is not.
Stage 2 — compile
$ c++ -std=c++17 -S cycles.cpp -o cycles.sThe compiler proper runs here. It reads the translation unit, checks it against the rules of C++, and produces instructions for one particular kind of processor — as text.
cycles.s 22 lines, 599 bytesTwenty-two lines of assembly from a function with two statements. You are not expected to read assembly and this curriculum will never ask you to write it. Look at it once for one reason only: there is no fixed relationship between a line of C++ and a machine instruction. One line can become many, several can collapse into one, and a line the compiler can prove has no observable effect can become none at all.
This is also the stage where your portable C++ stops being portable. cycles.s is for the processor you compiled on. Compile the same source on a different kind of machine and this file is different; the four lines you wrote are not.
Stage 3 — assemble
$ c++ -std=c++17 -c cycles.cpp -o cycles.oThe assembler turns those instructions into the numeric form the processor actually accepts, and writes an object file.
cycles.o 560 bytesSmaller than the assembly text it came from, because numbers are more compact than the words that name them.
Stage 4 — link
$ c++ cycles.o main.o -o app
$ ./apptransfers = 8
cycles = 19Notice what is missing from that command: no -std=c++17, no warning options. Those are instructions for the compiler, and the compiler finished two stages ago. This is a different tool with a different job.
5. Why an Object File Cannot Be Run
cycles.o contains finished machine code. It still cannot be run, and the reason is the whole point of the next stage.
Try it and the system refuses outright:
$ ./cycles.o
exec format error: ./cycles.oIt is not a program and does not have the structure of one. But the deeper reason is visible if you ask the object file what it still needs. The tool is nm, which lists the symbols in an object file:
$ nm -u main.o | grep cycles
__Z10cycles_forii-u asks for the symbols this file uses and does not define. main.o needs something called __Z10cycles_forii and does not have it.
Now ask the other file what it provides:
$ nm -gU cycles.o
0000000000000000 T __Z10cycles_foriiIt has exactly that. The T means the symbol is defined, in the code section.
So:
The linker's job, in one sentence: take the "needs" lists and the "provides" lists from every object file, and make them cancel out.
That odd name is your function. C++ encodes the parameter types into the symbol, and the encoding can be read back:
$ nm -gU cycles.o | awk '{print $NF}' | c++filt
cycles_for(int, int)Chapter 1.7 §5 explained why the types are in there and what it buys you. Module 24.5 covers the mechanism properly.
6. What Happens When a Promise Is Not Kept
Compile main.cpp on its own and try to make a program from it alone:
$ c++ main.o -o app
Undefined symbols for architecture arm64:
"cycles_for(int, int)", referenced from:
_main in main.o
ld: symbol(s) not found for architecture arm64main.cpp compiled perfectly. It had the declaration from cycles.hpp, the call matched it, and the compiler was satisfied — correctly, because a declaration is a promise and checking the promise is somebody else's job.
The linker is that somebody. It looked for cycles_for(int, int), found no object file providing it, and stopped.
Look at the shape of that message and compare it with a compiler error:
- No line number, and no source file. The linker never saw your source. It cannot tell you where in
main.cppthe call was written. - It names a symbol, not a statement.
- It says which object file referenced it — that is the level it works at.
ldis the linker's own name in its own message.
That difference is not a curiosity. It is the fastest classification tool you have, and Chapter 2.7 builds a method on it.
7. What Each Stage Can and Cannot Check
This table is the practical result of the whole chapter.
| Stage | Can catch | Cannot catch |
|---|---|---|
| Preprocessing | A header that does not exist; a malformed directive | Anything about C++ — it does not understand the language |
| Compiling | Everything about this translation unit: syntax, types, whether a call matches a visible declaration | Anything in another translation unit, including whether a declared function exists anywhere |
| Assembling | Essentially nothing of yours — it is a mechanical translation | — |
| Linking | A name promised and never defined; a name defined twice | Whether the code is correct — it checks names, not behaviour |
| Running | — | Everything above already passed. A wrong answer here is a bug in your logic, not in the build |
Two rows deserve attention.
Compiling cannot see other translation units. This is Chapter 1.7's thesis, now with a mechanism behind it. The compiler was handed one preprocessed text. Nothing else was in the room.
Linking checks names, not behaviour. A program that links successfully can still be completely wrong. The build finishing means every promise was kept, not that the promises were sensible.
8. Mistakes Worth Making Once
Compiling one file and expecting a program. c++ -c main.cpp gives you an object file, not something to run. Beginners often forget the final link step and then wonder why ./main does not exist.
Forgetting a source file at the link step. You add helpers.cpp to the project, forget to compile and link it, and get an undefined-symbol error naming a function you can see with your own eyes. The function exists; it is just not in any object file you handed the linker.
Passing compiler options to the link step and expecting them to matter. -Wall on the link command does nothing. The compiler has finished.
Reading a linker error as a compiler error. Looking for a line number that is not there, and concluding the message is broken. It is not — different tool, different information.
Believing a successful build means correct code. cycles_for could return setup_cycles - transfers * cycles_per_transfer and the build would be just as clean. The toolchain checked that everything referred to exists, not that any of it is right.
9. What You Should Be Able to Say Now
- A translation unit is one source file plus everything its
#includes brought in. It is the unit the compiler processes. - A header is not a translation unit. It is text pasted into the units that include it, which is why its errors appear in every file that includes it.
- Four stages: preprocess, compile, assemble, link.
-E,-S,-cstop after the first three. - Measured on one unit: 7 lines → 21 lines → 22 lines of assembly → a 560-byte object file → a 38,656-byte program.
- There is no fixed relationship between a line of C++ and a machine instruction.
- The assembly stage is where portable C++ becomes instructions for one kind of processor.
- An object file holds finished machine code plus a list of names it still needs. That is why it cannot be run, and it is the point rather than a limitation.
- The linker makes the "needs" and "provides" lists cancel.
main.oneeded__Z10cycles_forii;cycles.oprovided it;c++filtreads it back ascycles_for(int, int). - A compiler error carries a file, line and column. A linker error carries a symbol and no line, and says
ld. - Compiling cannot see other translation units; linking checks names, not behaviour.
- A successful build means every promise was kept, not that the code is correct.
10. Exercises
Exercise 1 — name the stage. For each, give the stage that reports it and one line of reasoning: (a) 'cycles.hpp' file not found; (b) cannot initialize a variable of type 'int' with an lvalue of type 'const char *'; (c) Undefined symbols: "cycles_for(int, int)"; (d) the program runs and prints 35 where you expected 38.
Exercise 2 — explain the size gap. In the same project, cycles.i was 21 lines and main.i was 64,206. Explain the cause in one sentence, and say which of the two source files you would expect to compile faster and why.
Exercise 3 — predict the symbol lists. Before running anything, say what you would expect nm -u to report for an object file built from a main.cpp that calls two functions defined elsewhere. Then say what would have to be true for the link to succeed.
Exercise 4 — the file you forgot. You add report.cpp defining print_summary(), call it from main.cpp, and build with c++ main.o -o app. Describe the exact error, say which tool produced it, and give the fix. Then explain why the compiler did not complain.
Exercise 5 — the useless flag. A colleague adds -Wall to the link command to catch more problems. Explain why it changes nothing, with reference to which tool runs when.
Exercise 6 — a clean build of wrong code. Change cycles_for to return setup_cycles - transfers * cycles_per_transfer. Does the build succeed? What does the program print for 8 transfers at 2 cycles? State what this tells you about the limits of what a toolchain checks.
Exercise 7 — why unfinished is useful. Give two concrete advantages of compiling each translation unit into its own object file rather than compiling the whole program at once. Relate at least one to how long a rebuild takes after a one-line change.
11. Summary
- A translation unit is a source file plus its includes — the unit the compiler processes, and the boundary of what it knows.
- A header has no separate existence: it is pasted into every unit that includes it.
- Four stages, each one stoppable:
-Epreprocess,-Scompile,-cassemble, then link. - Measured: 7 lines → 21 → 22 lines of assembly → 560 bytes of object file → a 38,656-byte program.
- No fixed line-to-instruction correspondence, and the assembly stage is where portability ends.
- An object file is unfinished by design: machine code plus a list of names it still needs. That is what makes separate compilation, and fast rebuilds, possible.
- The linker makes "needs" and "provides" cancel.
main.oneeded__Z10cycles_forii;cycles.ohad it. - Compiler errors carry a file and line; linker errors carry a symbol and say
ld. - Each stage checks something different, and nothing checks whether your code is right.
You have now seen every stage of a build and one error from each of two of them. What you do not yet have is a method — a way to take an unfamiliar message, place it in a stage, and know which assumption to check first. That matters more than it sounds, because the single most common way to lose an hour in C++ is to read a message carefully, look in entirely the wrong place, and find nothing wrong there. Chapter 2.7 turns the table in §7 into a routine.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
