The idea: the C road, plus four things
A C++ program goes down the same road as a C program: preprocess, compile, assemble, link, load, start. How a C Program Is Compiled, Linked, Loaded and Started follows that road in detail: the holes the assembler leaves, how the linker fills them with S + A − P, the PLT and GOT, and ld.so. This page takes it with g++ and stops only where C++ does something C does not:
- Name mangling. The linker compares plain names. The compiler turns every C++ name, with its class, namespace, template arguments and parameter types, into one plain symbol.
extern "C"turns mangling off, so that C++ can call C and C can call C++.- Templates and inline functions are compiled into every file that uses them. The linker keeps one copy and drops the rest (COMDAT groups).
- Code before and after
main. Constructors of global objects run from.init_array; their destructors are registered with__cxa_atexitand run byexit. And the program needs the C++ runtime,libstdc++.so.6.
The program has four files. limit.c is plain C, built with gcc -c limit.c; the rest is built with g++ -O1 -fno-inline main.cpp stats.cpp limit.o -o sum. It prints three lines: start, sum = 14, sq = 4, end.
// stats.h // stats.cpp // limit.c (C)
#ifndef STATS_H #include "stats.h" int c_limit(void) {
#define STATS_H int sum_squares(int n) { return 3;
template <typename T> int s = 0; }
T square(T x) { return x * x; } for (int i = 1; i <= n; i++)
int sum_squares(int n); s += square(i);
extern "C" int c_limit(void); return s;
#endif }
// main.cpp
#include <iostream>
#include "stats.h"
struct Logger {
Logger() { std::cout << "start\n"; }
~Logger() { std::cout << "end\n"; }
};
Logger logger;
int main() {
int r = sum_squares(c_limit());
std::cout << "sum = " << r << ", sq = " << square(2) << '\n';
}
Every name, size, address and error message on the page is real output of g++ 14.2.0, GNU ld 2.44 and glibc 2.41 for x86-64 Linux (Debian). -fno-inline keeps square<int> a real function, so that you can see its copies; at -O2 it would be inlined and vanish.
Reading the canvas
The strip at the top is the pipeline: one row per source file (limit.c, main.cpp, stats.cpp), the tools between the files they read and write, the linker with its libraries, and on the right the run time: ld.so, before main, main, after main. A box turns yellow while it works, green when done, red when it fails. The three panels below show what the current stage reads, what it writes, and the tables it keeps. Colours stay the same everywhere: blue for main.cpp, orange for stats.cpp, teal for limit.c, grey for the start files, purple for libstdc++, brown for libc, red for an undefined name, green for a resolved one.
Step moves one small step, Next Stage a whole stage. The program menu loads the correct program or one of five broken ones, each with one edit; all five build every file without complaint and fail at the link.
1. g++ is gcc with another compiler
gcc and g++ are the same driver program. Both pick the compiler by the file name: .c goes to cc1, .cpp (and .cc, .cxx, .C) to cc1plus. g++ differs in two ways only:
- It compiles a
.cfile as C++ too.g++ -c limit.cgives_Z7c_limitv, notc_limit. That is why the C file is built withgcc -c(org++ -x c). - It adds
-lstdc++ -lmto the link line.gcc main.o stats.o …compiles C++ fine but does not link the C++ library: Demo: linked with gcc → std::cout undefined.
The preprocessor works exactly as for C. It only shows how much a C++ header brings in: #include <iostream> follows 238 #include lines and turns the 11-line main.cpp into a 37 331-line, 950 KB main.ii. #include <stdio.h> gives 824 lines. Each .cpp that includes <iostream> compiles all of it again, which is why big C++ builds are slow and why precompiled headers and modules exist.
2. Name mangling
An object file's symbol table is a list of plain strings, and the linker only checks that a used string is defined exactly once. C gets away with that because a C name is unique in a program. C++ is not: sum_squares(int) and sum_squares(long) can both exist, so can A::f and B::f, and square<int> and square<double>. So the compiler encodes the whole name into one string. On Linux (and almost everywhere except Windows) the encoding is the Itanium C++ ABI:
| C++ name | Symbol | How to read it |
|---|---|---|
sum_squares(int) | _Z11sum_squaresi | _Z + name of length 11 + parameter i (int) |
c_limit() | _Z7c_limitv | v: no parameters |
int square<int>(int) | _Z6squareIiET_S0_ | template arguments I…E, return type T_ (1st template parameter), parameter S0_ (the same type again) |
Logger::Logger() | _ZN6LoggerC1Ev | nested name N…E; C1 constructor (D1 destructor) |
std::cout | _ZSt4cout | St: in namespace std |
std::ostream::operator<<(int) | _ZNSolsEi | So = std::ostream, ls = operator<< |
std::operator<<(std::ostream&, const char*) | _ZStlsISt11char_traits | a template instance; PKc = pointer to const char |
main, logger | main, logger | main is never mangled, nor is a variable at global scope |
c++filt _Z6squareIiET_S0_ and nm -C turn symbols back into C++ names, and the linker's error messages are already demangled. Because the parameter types are in the symbol, a declaration that does not match its definition compiles fine and fails at the link: if stats.h says int sum_squares(long n); but stats.cpp defines the int version, main.o asks for _Z11sum_squaresl and gets undefined reference to `sum_squares(long)'. In C the same mistake links and calls the function with the wrong argument type.
The page builds with -fno-asynchronous-unwind-tables like the C page, but C++ keeps .eh_frame anyway: exceptions need it to unwind the stack. The __gxx_personality_v0 symbol in main.o is the routine that reads those tables during unwinding.
3. extern "C"
extern "C" before a declaration tells the C++ compiler to use the C name: no mangling, so no overloading for that function. limit.o, made by the C compiler, defines c_limit; with extern "C" int c_limit(void); in stats.h, main.o asks for exactly c_limit. Without it, main.o asks for _Z7c_limitv, and ld reports undefined reference to `c_limit()' while c_limit sits unused in limit.o (Demo: forgot extern "C" → undefined c_limit()). C headers meant for both languages wrap their declarations:
#ifdef __cplusplus
extern "C" {
#endif
int c_limit(void);
#ifdef __cplusplus
}
#endif
The other direction works too: a C++ function declared extern "C" can be called from C, or looked up by name with dlsym.
4. Templates, inline functions and COMDAT
A template is a recipe, not code. The compiler makes code for square<int> only in a file that uses it with int and can see its body. Each file is compiled alone, so main.cpp (for square(2)) and stats.cpp (for square(i)) each compile their own copy. The same happens to Logger() and ~Logger(): functions defined inside a class are inline, and an inline function may be defined in every file that includes it.
Two copies of a normal function would be a multiple definition error. These are not, because the compiler marks them: each copy is a weak symbol (W in nm) in its own section, .text._Z6squareIiET_S0_, inside a COMDAT group named after the symbol (readelf -g main.o). When ld meets a group whose name it has already seen, it drops the whole group. The link map lists it under "Discarded input sections": .text._Z6squareIiET_S0_ 0x6 stats.o. stats.o's call to square<int> is then patched to main.o's copy at 0x123e like any other call (Demo: one template, two copies).
This is the one-definition rule as the toolchain sees it:
| Defined in a header | Symbol | Two files include it |
|---|---|---|
int helper() { return 1; } | T, strong | ✗ multiple definition of `helper()' (Demo: function defined in a header → multiple definition) |
inline int helper() { return 1; } | W, COMDAT | ✓ one copy kept |
| member function defined in the class | W, COMDAT | ✓ implicitly inline |
template <class T> T square(T) body | W, COMDAT per instance | ✓ one copy per type kept |
template declared only, body in a .cpp | U in the users | ✗ undefined reference to `int square<int>(int)' |
The last row is the classic: put the template body in sq.cpp and only the declaration in the header, and nobody instantiates square<int>. sq.cpp has the body but never uses it with int, so sq.o is empty (Demo: template body in a .cpp → undefined square<int>). Either keep the body in the header, or instantiate it explicitly in sq.cpp: template int square<int>(int);. ld does not check that the dropped copies were the same code; if two files define an inline function differently, the program silently uses one of them. That is undefined behaviour, and the reason the rule says "identical definitions".
5. Before and after main
Logger logger; at global scope must be constructed before main uses anything. The compiler writes a hidden function, _GLOBAL__sub_I_logger, which calls Logger::Logger() on logger and then __cxa_atexit(Logger::~Logger, &logger, __dso_handle), and puts a pointer to it in the .init_array section. ld concatenates the .init_array sections of all inputs (here two entries: frame_dummy from crtbeginS.o, then ours), and the dynamic section points at the result: INIT_ARRAY 0x3db8, 16 bytes.
At run time the order is:
ld.soloadslibstdc++.so.6andlibc.so.6(sum'sNEEDED), thenlibm.so.6andlibgcc_s.so.1, which libstdc++ needs. It applies 1 797 relocations (the C program needed 90).- It runs the libraries' initialisers, dependencies first: libc, libgcc_s, libm, libstdc++. libstdc++'s sets up
std::cout. _start→__libc_start_main, which runssum's.init_array:Logger()printsstart, and~Loggeris registered.mainruns and returns 0. (Falling off the end ofmainreturns 0; no other function may do that.)exit(0)runs theatexitlist, newest first:~Logger()printsend. Then the libraries' finalisers run and the stream buffers are flushed.
Demo: before and after main shows these steps. Across files, the C++ standard does not say whose global objects are constructed first, and in practice it follows the link order. A global object whose constructor uses a global from another file may see it not yet constructed: the static initialization order fiasco. The usual fix is a function that returns a local static (Logger& log() { static Logger l; return l; }), constructed on first use.
6. The C++ runtime: libstdc++
std::cout, the operator<< functions, exceptions, new/delete and RTTI live in libstdc++.so.6 (with help from libgcc_s.so.1 for unwinding). readelf -d sum shows NEEDED libstdc++.so.6 and NEEDED libc.so.6. libm is on the link line but not in the file: Debian's gcc passes --as-needed, so a library nobody uses directly is left out.
Two details. std::cout is a variable in the library, and main uses it through a fixed offset, so ld gives it a slot in sum's own .bss (0x4040, 272 bytes) and a R_X86_64_COPY relocation: ld.so copies the library's initial object there, and the library itself uses that copy. And each operator<< is called through the PLT, bound on first call, exactly like printf on the C page. -static-libstdc++ copies the library into the program instead: 1.4 MB instead of 17 KB, and no NEEDED libstdc++.so.6.
Which stage reports which error
Each file compiles alone, so every one of these mistakes passes the compiler and is caught by the linker, which only sees strings:
| Mistake | main.o asks for | Defined | ld says |
|---|---|---|---|
no extern "C" for a C function | _Z7c_limitv | c_limit | undefined reference to `c_limit()' |
linked with gcc | _ZSt4cout, … | nowhere (no -lstdc++) | undefined reference to `std::cout' |
template body in a .cpp | _Z6squareIiET_S0_ | nowhere | undefined reference to `int square<int>(int)' |
| non-inline function in a header | — | _Z6helperv twice | multiple definition of `helper()' |
| declaration ≠ definition | _Z11sum_squaresl | _Z11sum_squaresi | undefined reference to `sum_squares(long)' |
The errors that stop the preprocessor and the compiler (a missing header, a syntax error) work as in C; see the C page.
See it on your own machine
gcc -c limit.c g++ -v -save-temps -O1 -fno-inline main.cpp stats.cpp limit.o -o sum -Wl,-Map=sum.map nm main.o ; nm -C main.o # mangled and demangled symbols (T, W, U, B) c++filt _Z6squareIiET_S0_ # → int square<int>(int) readelf -gW main.o # the COMDAT groups grep -A12 'Discarded input' sum.map readelf -d sum ; readelf -rW sum # NEEDED, INIT_ARRAY, COPY and JUMP_SLOT relocations LD_DEBUG=libs,statistics ./sum # libraries, init order, relocation count
For static vs dynamic linking in general see Static vs Dynamic Linking.
What the page leaves out
Exceptions and how unwinding reads .eh_frame and .gcc_except_table; virtual functions, vtables and RTTI (also emitted as COMDAT, or with the class's first non-inline virtual function); static locals and their thread-safe guards (__cxa_guard_acquire); constexpr and other work done at compile time; link-time optimisation; C++20 modules and precompiled headers; the two libstdc++ ABIs for std::string (_GLIBCXX_USE_CXX11_ABI, the [abi:cxx11] tag in names); symbol versions such as GLIBCXX_3.4; MSVC's different mangling scheme; and everything the C page already covers: the assembler's holes, S + A − P, the PLT and GOT, loading.