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:

  1. 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.
  2. extern "C" turns mangling off, so that C++ can call C and C can call C++.
  3. Templates and inline functions are compiled into every file that uses them. The linker keeps one copy and drops the rest (COMDAT groups).
  4. Code before and after main. Constructors of global objects run from .init_array; their destructors are registered with __cxa_atexit and run by exit. And the program needs the C++ runtime, libstdc++.so.6.
limit.c is compiled by cc1 into limit.o with the symbol c_limit; main.cpp and stats.cpp are compiled by cc1plus into main.o and stats.o with mangled symbols, each with its own copy of square<int>; ld keeps one copy and links in libstdc++ and libc; at run time ld.so loads libstdc++, .init_array runs Logger(), main runs, and exit runs ~Logger()
Same road as C; what C++ adds is marked on it: mangled names at compile time, duplicate copies dropped at link time, libstdc++ and constructors at run time.

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 .c file as C++ too. g++ -c limit.c gives _Z7c_limitv, not c_limit. That is why the C file is built with gcc -c (or g++ -x c).
  • It adds -lstdc++ -lm to 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:

_Z6squareIiET_S0_ split into pieces: _Z marks a mangled name, 6square is the 6-letter name, IiE are the template arguments <int>, T_ is the return type (the first template parameter) and S0_ is the parameter type, a back-reference to the same type; below it _Z11sum_squaresi split into _Z, 11sum_squares and i for int
A mangled name is the C++ name spelled out piece by piece; the parameter types are part of it.
C++ nameSymbolHow to read it
sum_squares(int)_Z11sum_squaresi_Z + name of length 11 + parameter i (int)
c_limit()_Z7c_limitvv: 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()_ZN6LoggerC1Evnested name N…E; C1 constructor (D1 destructor)
std::cout_ZSt4coutSt: in namespace std
std::ostream::operator<<(int)_ZNSolsEiSo = std::ostream, ls = operator<<
std::operator<<(std::ostream&, const char*)_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKca template instance; PKc = pointer to const char
main, loggermain, loggermain 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).

Before the link: main.o and stats.o each contain a COMDAT group _Z6squareIiET_S0_ with a 6-byte copy of square<int>. After the link: sum has one copy at 0x123e from main.o; stats.o's group is discarded and its call goes to 0x123e
Every file that uses square<int> brings a copy; the linker keeps the first and the calls of the others go to it.

This is the one-definition rule as the toolchain sees it:

Defined in a headerSymbolTwo 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 classW, COMDAT✓ implicitly inline
template <class T> T square(T) bodyW, COMDAT per instance✓ one copy per type kept
template declared only, body in a .cppU 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:

  1. ld.so loads libstdc++.so.6 and libc.so.6 (sum's NEEDED), then libm.so.6 and libgcc_s.so.1, which libstdc++ needs. It applies 1 797 relocations (the C program needed 90).
  2. It runs the libraries' initialisers, dependencies first: libc, libgcc_s, libm, libstdc++. libstdc++'s sets up std::cout.
  3. _start → __libc_start_main, which runs sum's .init_array: Logger() prints start, and ~Logger is registered.
  4. main runs and returns 0. (Falling off the end of main returns 0; no other function may do that.)
  5. exit(0) runs the atexit list, newest first: ~Logger() prints end. 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:

Mistakemain.o asks forDefinedld says
no extern "C" for a C function_Z7c_limitvc_limitundefined reference to `c_limit()'
linked with gcc_ZSt4cout, …nowhere (no -lstdc++)undefined reference to `std::cout'
template body in a .cpp_Z6squareIiET_S0_nowhereundefined reference to `int square<int>(int)'
non-inline function in a header—_Z6helperv twicemultiple definition of `helper()'
declaration ≠ definition_Z11sum_squaresl_Z11sum_squaresiundefined 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.