from source to a running process
The first program was ten lines long.
#include <tuple>
#include <utility>
template <class Tuple>
int score(Tuple t) {
return std::apply([](auto... xs) { return ((xs * xs) + ...); }, t);
}
int main() {
return score(std::tuple{1, 2, 3, 4, 5, 6, 7, 8});
}
One function template, one fold expression, one std::tuple, one std::apply. (If you don’t know what half of those are: good. Neither does the finished binary.)
I asked Clang for unoptimized assembly:
$ clang++ -std=c++20 -O0 -S template_blast.cpp -o template_blast_O0.s
$ wc -l template_blast_O0.s
893 template_blast_O0.s
Eight hundred and ninety-three lines.
Then I asked for optimized assembly:
$ clang++ -std=c++20 -O2 -S template_blast.cpp -o template_blast_O2.s
$ wc -l template_blast_O2.s
12 template_blast_O2.s
The important part of the optimized file is:
_main:
mov w0, #204
ret
The same source program became either 893 lines of assembly or “return 204”, depending on optimization.
That is the hook for this whole post. C++ isn’t a language where the compiler politely walks your source line by line and emits a proportional amount of machine code. The compiler expands includes, substitutes macros, parses declarations, checks overload sets, instantiates templates, evaluates constant expressions, applies concepts, emits object files with unresolved holes, lets a linker merge or reject definitions, and then leaves some work for the dynamic loader at process start.
Sometimes a one-line template expands into a small city of helper functions. Sometimes the optimizer proves the entire city was a painting on the wall and replaces it with one immediate constant.
The second weird thing was nastier.
I wrote two files that define the same inline function differently:
// odr_a.cpp
__attribute__((noinline)) inline int picked() {
return 111;
}
int from_a() {
return picked();
}
// odr_b.cpp
__attribute__((noinline)) inline int picked() {
return 222;
}
int from_b() {
return picked();
}
and a main:
#include <cstdio>
int from_a();
int from_b();
int main() {
std::printf("%d %d\n", from_a(), from_b());
}
Build and link in one order:
$ clang++ -std=c++20 -O0 -c odr_a.cpp -o odr_a.o
$ clang++ -std=c++20 -O0 -c odr_b.cpp -o odr_b.o
$ clang++ -std=c++20 -O0 -c odr_main.cpp -o odr_main.o
$ clang++ odr_a.o odr_b.o odr_main.o -o odr_ab
$ ./odr_ab
111 111
Reverse the first two object files:
$ clang++ odr_b.o odr_a.o odr_main.o -o odr_ba
$ ./odr_ba
222 222
Same source files. Same compiler. Same optimization level. Different link order, different program.
No diagnostic.
That isn’t the linker “being random.” It is us breaking the One Definition Rule in a way the linker isn’t required to diagnose. Both object files contain a weak definition of the same symbol:
$ nm -m odr_a.o
0000000000000000 (__TEXT,__text) external __Z6from_av
0000000000000014 (__TEXT,__text) weak external __Z6pickedv
$ nm -m odr_b.o
0000000000000000 (__TEXT,__text) external __Z6from_bv
0000000000000014 (__TEXT,__text) weak external __Z6pickedv
The linker coalesces weak definitions with the same name. If they’re truly identical, that’s how inline functions and template instantiations can live in headers without causing duplicate-symbol errors. If they aren’t identical, you invoked undefined behavior and got a linked binary anyway. The linker picked one definition for picked(). The calls in both files go there.
Two tiny programs, two alarming results: source that evaporates into a single number, and source that quietly contradicts itself and links clean anyway. Chasing each one down to the instruction it does or doesn’t become is most of what a C++ toolchain actually is.
The machine here is an arm64 Mac using Apple Clang:
Apple clang version 15.0.0 (clang-1500.3.9.4)
Target: arm64-apple-darwin24.6.0
So the local binary format is Mach-O and the dynamic loader is dyld. When the common Unix vocabulary is ELF-specific, like PLT/GOT, I will say so and map it to the Mach-O sections this machine actually produces: __stubs, __la_symbol_ptr, and __got.
clang++ is a driver, not one program
Before I could chase either program anywhere, one small confusion got in the way.
When I type:
clang++ -std=c++20 use_answer.cpp -o use_answer
it feels like clang++ compiles the program.
That is true in the command-line sense and false in the toolchain sense. clang++ is a driver. It decides which lower-level tools to run, with which flags, in which order. It knows about C++ defaults, system include paths, SDKs, target triples, startup files, standard libraries, runtime libraries, assembler behavior, linker behavior, and platform conventions. Clang’s own driver design document calls preprocessing, compilation, assembly, and linking separate actions before the driver selects the concrete tools that perform them.
Ask it to show its work:
$ clang++ -std=c++20 -### use_answer.cpp -o driver_demo
The output is long, but the shape is:
"/Library/Developer/CommandLineTools/usr/bin/clang" "-cc1" ...
"-emit-obj"
"-main-file-name" "use_answer.cpp"
"-triple" "arm64-apple-macosx14.4.0"
"-isysroot" "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk"
"-internal-isystem" ".../usr/include/c++/v1"
"-std=c++20"
"-o" "/var/folders/.../use_answer-d1f098.o"
"-x" "c++" "use_answer.cpp"
"/Library/Developer/CommandLineTools/usr/bin/ld" ...
"-dynamic"
"-arch" "arm64"
"-platform_version" "macos" "14.4.0" "14.4"
"-o" "driver_demo"
"/var/folders/.../use_answer-d1f098.o"
"-lc++"
"-lSystem"
".../libclang_rt.osx.a"
Two subprocesses are visible in there: clang -cc1, the actual compiler invocation that turns source into an object file, and ld, the linker invocation that turns object files and libraries into an executable. The driver also added libraries I didn’t type:
-lc++
-lSystem
libclang_rt.osx.a
-lc++ is the C++ standard library on this system. -lSystem is the umbrella system library on Darwin. libclang_rt.osx.a is Clang’s compiler runtime support library, used for helper routines the compiler may need.
This is why the same source command behaves differently on Linux, macOS, Windows, embedded targets, debug builds, sanitizer builds, and cross-compiles. The driver is platform policy encoded as a command-line program.
The phase-stopping flags are the clean way to see the pipeline:
-E:
stop after preprocessing
-S:
stop after generating assembly
-c:
stop after generating an object file
no stop flag:
compile and link
So these aren’t equivalent:
clang++ -E file.cpp
clang++ -S file.cpp
clang++ -c file.cpp
clang++ file.cpp -o file
They ask the driver to stop at different places.
That sounds obvious after seeing it, but it prevents a lot of muddled explanations. The compiler doesn’t “see linker errors.” The linker doesn’t “understand templates.” The dynamic loader doesn’t “compile code.” The driver may run all of them for one command, but the phases have different inputs, outputs, and powers.
The compiler never sees your .cpp alone
The first thing that happens to either program isn’t compilation. It’s a giant, dumb copy-paste. Here’s a file small enough to watch it happen:
#include <vector>
#define TWICE(x) ((x) + (x))
int f(int x) {
std::vector<int> values{1, 2, 3};
return TWICE(x) + values.size();
}
Ask Clang to stop after preprocessing:
$ clang++ -std=c++20 -E preprocess.cpp -o preprocess.ii
$ wc -l preprocess.ii
72626 preprocess.ii
Seventy-two thousand six hundred and twenty-six lines.
The end of the file is our code, but transformed:
int f(int x) {
std::vector<int> values{1, 2, 3};
return ((x) + (x)) + values.size();
}
TWICE(x) is gone. The preprocessor substituted tokens. It didn’t understand “evaluate x once” or “this is an expression.” It saw a macro invocation and rewrote text-ish token streams. If you call TWICE(i++), you asked for ((i++) + (i++)). The preprocessor won’t save you from yourself. It predates much of modern C++ and behaves exactly like that sentence suggests.
#include <vector> is also gone, replaced by the contents of headers, and headers included by those headers, and headers included by those headers. Most of the 72,626 lines are libc++ implementation machinery: traits, iterators, allocators, concepts, debug annotations, declarations, inline functions, and templates.
Seventy-two thousand lines from one #include sounds like an outlier until you weigh a few headers against each other. I preprocessed a two-line file, #include <X> and an empty main, for a handful of standard headers, and counted what came out the other side:
(no includes) 8
cstdint 409
cstdio 688
algorithm 47132
string 52515
unordered_map 53194
iostream 66432
vector 72621
map 84924
ranges 85286
regex 88899
An empty translation unit is eight lines. <cstdint>, which is basically a pile of typedefs, is 409. <cstdio>, mostly C, is 688. <vector> is seventy-two thousand. <regex> is almost eighty-nine thousand lines before you write a single character of your own logic. The number isn’t “how much of the header you use.” It’s how much text the preprocessor pastes and the compiler parses before it reaches your first real line, whether you touch one symbol from that header or none. Put #include <regex> in a header that a hundred .cpp files include, and that’s eighty-nine thousand lines parsed a hundred times, on every build, forever, until someone notices.
This is the first place the usual mental model is wrong:
wrong:
compiler compiles my .cpp file
closer:
preprocessor creates one giant translation unit from my .cpp plus included headers,
then the compiler proper compiles that
Headers aren’t separately imported in the normal old-school C++ model. They are pasted into each translation unit before compilation. Include guards and #pragma once prevent repeated inclusion in the same translation unit, but every .cpp that includes <vector> still gets its own preprocessed view of <vector>.
This is why changing a header can rebuild half a project. The header isn’t a module boundary in the compiled sense. It is part of every translation unit that includes it.
This is also why macros are so blunt. A macro has no namespace in the C++ sense. It is active after it’s defined until it’s undefined or the translation unit ends. It can rewrite tokens before the compiler sees classes, overloads, templates, or types.
When people say “prefer constexpr functions to macros,” this is the reason hiding under the advice:
#define SQUARE(x) ((x) * (x))
constexpr int square(int x) {
return x * x;
}
The macro is a token rewrite. The function is C++.
A translation unit is the compiler’s island
After preprocessing, Clang compiles one translation unit at a time.
A translation unit is, roughly, one source file after preprocessing. If your build has:
main.cpp
parser.cpp
runtime.cpp
then the compiler doesn’t normally compile them as one global program. It compiles each into an object file:
main.cpp -> main.o
parser.cpp -> parser.o
runtime.cpp -> runtime.o
Each .o is a partial program. It contains machine code and data the compiler could emit, plus symbol tables saying “I define these names” and “I need someone else to provide these names.” It also contains relocation records, which are places the linker must fix later because final addresses aren’t known yet.
Here is a tiny caller:
#include <cstdio>
extern "C" int answer();
int main() {
std::printf("answer=%d\n", answer());
}
Compile only:
$ clang++ -std=c++20 -O0 -c use_answer.cpp -o use_answer.o
$ nm -m use_answer.o
(undefined) external _answer
0000000000000000 (__TEXT,__text) external _main
(undefined) external _printf
0000000000000038 (__TEXT,__cstring) non-external l_.str
The object file defines _main. It doesn’t define _answer or _printf. It promises the linker those names will exist somewhere else.
Disassemble the object file:
_main:
0000000000000000 sub sp, sp, #0x20
0000000000000004 stp x29, x30, [sp, #0x10]
0000000000000008 add x29, sp, #0x10
000000000000000c bl 0xc
...
0000000000000024 bl 0x24
...
0000000000000034 ret
bl is arm64’s call instruction (branch-and-link). Those bl 0xc and bl 0x24 aren’t final calls to address 0xc and 0x24 in the running process. They are placeholders. The object file also has relocations:
$ otool -r use_answer.o
Relocation information (__TEXT,__text) 4 entries
address pcrel length extern type scattered symbolnum/value
00000024 1 2 1 2 0 6
00000020 0 2 1 4 0 1
0000001c 1 2 1 3 0 1
0000000c 1 2 1 2 0 5
The raw relocation table isn’t friendly, but it’s the important artifact. The compiler emitted code with holes. The linker will fill them.
So the slogan “the compiler makes the program” is already off by one tool. The compiler makes object files. The linker makes a program image out of those object files and libraries. The compiler is powerful, but each translation unit is still an island. The linker is the first tool that sees the archipelago.
An object file is a structured unfinished binary
The .o file isn’t a pile of bytes waiting to be pasted into an executable.
It is a structured binary file. On this machine, it’s a relocatable Mach-O object. otool -l use_answer.o shows sections:
Section
sectname __text
segname __TEXT
size 0x0000000000000038
reloff 576
nreloc 4
Section
sectname __cstring
segname __TEXT
size 0x000000000000000b
Section
sectname __compact_unwind
segname __LD
size 0x0000000000000020
reloff 608
nreloc 1
Even this tiny object has at least three categories of information:
__text:
machine instructions for main
__cstring:
string literal "answer=%d\n"
__compact_unwind:
metadata for stack unwinding
It also has symbol tables:
Load command 2
cmd LC_SYMTAB
nsyms 7
Load command 3
cmd LC_DYSYMTAB
nextdefsym 1
nundefsym 2
The object file knows:
I define _main
I contain a local string literal
I need _answer
I need _printf
these instruction offsets need relocation
this compact-unwind metadata also needs relocation
That “also needs relocation” part is worth noticing. Relocations aren’t only for call instructions. Debug info, exception tables, unwind metadata, vtables, jump tables, global pointers, and constant data can all contain addresses or symbol references that need fixing later.
This is why “linking” is more than concatenating .text sections. The linker lays out sections, merges compatible ones, resolves symbols, applies relocations, discards unused pieces when asked, coalesces weak definitions, builds dynamic-loader metadata, writes unwind info, emits load commands, and produces an executable file that the OS loader can understand.
Where 204 actually happens: the layer between C++ and assembly
I keep saying the optimizer “proved” the tuple program was 204. It proves things in a language that is neither C++ nor arm64. Between the two sits LLVM IR, and that’s where the erasure I’m chasing first becomes something you can read.
The frontend doesn’t usually jump straight from parsed C++ to final arm64 assembly in one conceptual move.
Clang lowers C++ into LLVM IR, an intermediate representation used by LLVM optimizers and backends. You can ask for it:
$ clang++ -std=c++20 -O0 -S -emit-llvm abi_demo.cpp -o abi_demo.ll
For:
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
extern "C" int c_add(int a, int b) {
return a + b;
}
struct Shape {
virtual int area() const;
virtual ~Shape();
};
(the Shape class rides along in the same file because it gets a starring role later), the IR starts like:
target datalayout = "e-m:o-i64:64-i128:128-n32:64-S128"
target triple = "arm64-apple-macosx14.4.0"
define i32 @_Z3addii(i32 noundef %0, i32 noundef %1) {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, ptr %3, align 4
store i32 %1, ptr %4, align 4
%5 = load i32, ptr %3, align 4
%6 = load i32, ptr %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}
define double @_Z3adddd(double noundef %0, double noundef %1) {
...
%7 = fadd double %5, %6
ret double %7
}
define i32 @c_add(i32 noundef %0, i32 noundef %1) {
...
}
Even at IR level, you can already see the ABI-flavored names:
@_Z3addii -> C++ add(int, int)
@_Z3adddd -> C++ add(double, double)
@c_add -> extern "C" function
The IR file also contains the vtable for Shape, the table of function pointers that makes virtual calls work, laid out as plain data:
@_ZTV5Shape = unnamed_addr constant { [5 x ptr] } {
[5 x ptr] [
ptr null,
ptr @_ZTI5Shape,
ptr @_ZNK5Shape4areaEv,
ptr @_ZN5ShapeD1Ev,
ptr @_ZN5ShapeD0Ev
]
}
That is a vtable as data. It contains a null-ish header entry, type info, the virtual function, and destructor entries. The exact shape is ABI-specific, but the important part is that virtual dispatch isn’t a spell. It becomes data and indirect calls arranged according to an ABI contract.
At -O0, the IR is intentionally clunky. It uses stack slots so debugging has sensible variable locations:
%3 = alloca i32
store i32 %0, ptr %3
%5 = load i32, ptr %3
At optimization levels like -O2, LLVM promotes those stack slots into registers, inlines functions, folds constants, removes dead code, unrolls loops, vectorizes, and performs target-aware transformations before the backend emits assembly.
This is the phase where the 893-line template program collapses to:
mov w0, #204
ret
The high-level operations were legal C++. The frontend lowered them. The optimizer proved the result. The backend emitted arm64.
That gives a more accurate model:
C++ source after preprocessing
-> Clang frontend parses, checks, instantiates, lowers
-> LLVM IR
-> optimization passes
-> target backend
-> assembly or object code
You don’t need to read LLVM IR every day, but knowing it exists makes compiler behavior less mystical. There is a place where “C++ things” become “compiler things” before they become “machine things.”
The 893 lines: what one line of template actually became
The score function from the opening is a function template:
template <class Tuple>
int score(Tuple t) {
return std::apply([](auto... xs) { return ((xs * xs) + ...); }, t);
}
A template isn’t a normal function waiting in a library. It is a pattern for making functions when concrete types are known. In this call:
score(std::tuple{1, 2, 3, 4, 5, 6, 7, 8});
class template argument deduction builds a std::tuple<int, int, int, int, int, int, int, int>. Then the compiler instantiates:
score<std::tuple<int, int, int, int, int, int, int, int>>
That instantiation pulls in std::apply, which pulls in tuple index machinery, std::get<0> through std::get<7>, tuple leaves, forwarding helpers, invocation helpers, and the lambda call operator for eight int references.
At -O0, Clang leaves much of that structure visible. The assembly contains symbols like:
__Z5scoreINSt3__15tupleIJiiiiiiiiEEEEiT_
__ZNSt3__15apply...
__ZNSt3__13get...ILm0E...
__ZNSt3__13get...ILm1E...
...
__ZZ5score...ENKUlDpT_E_clIJiiiiiiiiEEEDaS5_
The names are mangled, but the shape is visible even through the noise: score, tuple, apply, get<0>, get<1>, and the lambda call operator.
A key detail: many of those generated definitions are weak:
.weak_definition __Z5scoreINSt3__15tupleIJiiiiiiiiEEEEiT_
.weak_def_can_be_hidden __ZNSt3__15tupleIJiiiiiiiiEEC1...
.weak_definition __ZNSt3__15apply...
Why weak?
Because templates usually live in headers. If ten .cpp files instantiate the same template with the same types, each object file may contain a copy of the same generated function. The linker has to be allowed to coalesce those copies. On ELF systems you will often hear about COMDAT groups for this. On this Mach-O build, nm shows weak definitions.
Two numbers put the -O0 scaffolding in proportion. The assembly marks thirty-three definitions as weak:
$ grep -c 'weak_definition\|weak_def_can_be_hidden' template_blast_O0.s
33
and it contains sixty-seven bl instructions:
$ grep -c 'bl ' template_blast_O0.s
67
Sixty-seven calls. Three of them live in main itself; the other sixty-four are the tuple constructing itself leaf by leaf, apply unpacking indices, get<0> through get<7>, and the lambda being invoked, each a real function calling the next. That is the “small city of helper functions” from the opening, counted. The -O2 build makes zero calls at all.
The rule isn’t “templates are compiled once.” The rule is: each translation unit instantiates what it needs, the object file marks the generated code as duplicate-friendly, and the linker merges compatible duplicates.
This is also one reason template-heavy C++ can make builds slow. The same header machinery may be parsed and instantiated in many translation units. Link-time optimization can change the story, modules can change the story, precompiled headers can change the story, but the ordinary model is still translation-unit based.
Now look back at the optimized assembly:
_main:
mov w0, #204
ret
Why 204?
1^2 + 2^2 + 3^2 + 4^2 + 5^2 + 6^2 + 7^2 + 8^2
= 1 + 4 + 9 + 16 + 25 + 36 + 49 + 64
= 204
The compiler instantiated the templates, understood the operations, propagated constants, inlined calls, deleted dead structure, and reduced the whole program to a return value.
This is the weird dual personality of compile-time C++: at -O0 the compiler exposes the scaffolding so debugging remains plausible, and at -O2 it erases the scaffolding wherever observable behavior allows. Templates can explode code size. Templates can also give the optimizer enough static information to delete everything. Both are true.
I wanted to know how fragile that collapse was, so I swept every optimization level Clang has:
$ for O in O0 O1 O2 O3 Os Oz; do
clang++ -std=c++20 -$O -S template_blast.cpp -o tb_$O.s
printf "%-4s %5s lines\n" "$O" "$(wc -l < tb_$O.s)"
done
O0 893 lines
O1 12 lines
O2 12 lines
O3 12 lines
Os 12 lines
Oz 123 lines
-O1 already does it. The drop from 893 lines to mov w0, #204 isn’t a heroic -O2-only feat; the very first optimization level finds the answer, and -O2, -O3, and -Os all agree with it.
Then -Oz broke the pattern. One hundred and twenty-three lines. -Oz is “optimize for size, aggressively,” even more size-obsessed than -Os, and I assumed something this small and this constant would fold under it too. It doesn’t. nm on the -Oz object shows the scaffolding is still standing:
$ clang++ -std=c++20 -Oz -c template_blast.cpp -o tb_Oz.o
$ nm tb_Oz.o | grep -i score
... T __Z5scoreINSt3__15tupleIJiiiiiiiiEEEEiT_
... T __ZNSt3__118__apply_tuple_impl...
... T __ZNSt3__18__invoke...
score, __apply_tuple_impl, and __invoke survive as real out-of-line functions, and main builds the tuple in memory and calls them:
_main:
...
adrp x8, lCPI0_0@PAGE
ldr q0, [x8, lCPI0_0@PAGEOFF]
...
stp q0, q1, [sp]
mov x0, sp
bl __Z5scoreINSt3__15tupleIJiiiiiiiiEEEEiT_
...
The eight integers become a constant-pool blob loaded with adrp/ldr and passed to a genuine function call. At -Oz the tuple program computes nothing at compile time. It hands the whole sum of squares to runtime.
Why would the most aggressive size setting produce more code and less computation? Because the collapse to 204 depends entirely on inlining, and -Oz won’t pay for it. The optimizer only sees the constants flow into the arithmetic if it first pulls score, apply, get<0..7>, and the lambda into main. Inlining is a size gamble: paste a body in and maybe fold it to nothing, or maybe just bloat the caller. Each candidate gets a cost, and the cost is weighed against a threshold. I asked Clang to show its arithmetic:
$ clang++ -std=c++20 -O2 -S -Rpass=inline template_blast.cpp -o /dev/null
... remark: '..._tuple_leaf...get...' inlined into '...get<0UL,...>'
with (cost=-35, threshold=337) ...
Thirty-three of those remarks. Each get<N> accessor inlines at cost=-35, a negative cost, meaning inlining it makes the program smaller, because a call sequence is bigger than the single load it wraps. The threshold at -O2 is 337. At -Oz the threshold collapses to 5:
$ clang++ -std=c++20 -Oz -S -Rpass-missed=inline template_blast.cpp -o /dev/null 2>&1 \
| grep -oE 'threshold=[0-9]+' | sort -u
threshold=5
Five. The cheap get<N> accessors still clear it, but score itself, the one function that would let the constants meet the arithmetic, costs more than five to inline, so -Oz leaves it out of line. Seven candidates fail the threshold that way at -Oz. Once score is a real call, the constant folding never gets its opening. So the 204 in the -O2 binary isn’t the optimizer being clever about arithmetic. It’s the inliner being willing to spend code size, and constant folding sweeping up behind it. Turn the inliner’s budget low enough and the same source stops being a constant.
That reframed what “the optimizer proved 204” even means, so I tried to take the constants away from it entirely. If folding is the whole trick, a tuple the compiler can’t see through should leave the template tower standing. I fed the elements in from argc:
int main(int argc, char**) {
return score(std::tuple{argc, argc + 1, argc + 2, argc + 3});
}
Now the compiler can’t know the values, and it can’t return a constant. And it still deletes every function:
_main:
add w8, w0, #1
add w9, w0, #2
add w10, w0, #3
mul w11, w0, w0
madd w9, w9, w9, w11
madd w8, w8, w8, w9
madd w0, w10, w10, w8
ret
Four adds, a multiply, three madd (multiply-add) instructions, ret. No bl. No tuple in memory. No apply, no get, no lambda, no call of any kind. madd w9, w9, w9, w11 is w9*w9 + w11, the running sum of squares built inline with integer multiply-add instructions. This is not the floating-point fused operation whose single rounding step matters in numerical code.
That’s the finding that actually matters, and it’s larger than “constants fold.” The tuple, apply, the index sequence, the eight get calls, the lambda: all of it is scaffolding the optimizer removes whether or not the inputs are known. Constant folding is a last step that happens to apply when the inputs are constant. It isn’t the reason the abstraction disappears. At -O0 this same program defines thirty-four separate functions in its IR:
$ clang++ -std=c++20 -O0 -S -emit-llvm template_blast.cpp -o tb_O0.ll
$ grep -c '^define' tb_O0.ll
34
At -O2 the IR for main is one line:
define i32 @main() local_unnamed_addr #0 {
ret i32 204
}
Thirty-four functions to one. The 893-line assembly wasn’t exaggerating how much code the template generates. The optimizer just refuses to keep any of it.
One last thing bothered me about mov w0, #204: why w0, and why did that single instruction suffice? w0 is the low 32 bits of the x0 register; main returns int, which is 32 bits, so the result lands in w, not the full 64-bit x. And 204 fits. arm64’s mov of an immediate is really MOVZ, which loads a 16-bit value optionally shifted into place, and 204 is a 16-bit number, so one instruction covers it. Push the sum past 65535 and the encoding has to grow. A tuple of {100, 200, 300} has squares summing to 140000:
_main:
mov w0, #8928
movk w0, #2, lsl #16
ret
8928 + (2 << 16) = 8928 + 131072 = 140000. MOVZ loads the low 16 bits and zeroes the rest, then MOVK (keep) overwrites bits 16 through 31 without disturbing the low half. Two instructions to assemble one 32-bit constant, because no single arm64 immediate can hold an arbitrary 32-bit value. The number is still computed at compile time. It just doesn’t fit through one move.
And then the return value ran into Unix. main returns 140000, but a process exit status is one byte:
$ clang++ -std=c++20 -O2 big.cpp -o big && ./big; echo $?
224
140000 & 0xFF = 224. The original program returning 204 was quietly honest because 204 fits in a byte and survives the truncation intact. Return the sum of squares of {100, 200, 300} and the shell reports 224, which is a fact about the exit-status convention, not about the arithmetic. The compiler was right the whole way down; the eight-bit door on the way out just wasn’t wide enough to let the proof through.
There is a practical rule hiding here: for most templates, the compiler needs the definition at the point of instantiation. This is why template definitions usually live in headers.
Suppose you write:
// box.h
template <class T>
T twice(T x);
and:
// box.cpp
template <class T>
T twice(T x) {
return x + x;
}
Then in another translation unit:
#include "box.h"
int f() {
return twice(21);
}
The compiler sees the declaration of twice, but not the definition. It knows a template exists, but it can’t instantiate twice<int> because it doesn’t have the body. Unlike a normal function, the body isn’t merely implementation detail. The compiler needs it to generate the concrete specialization.
The ordinary fix is to put the template body in the header:
// box.h
template <class T>
T twice(T x) {
return x + x;
}
Now every translation unit that includes box.h can instantiate twice<T> as needed.
That creates duplicate emitted code. Which is why weak/coalescable template instantiations exist. The language model says the same template definition can be instantiated in multiple translation units. The object-file/linker model needs a way to merge those duplicates.
There is another path: explicit instantiation.
// box.h
template <class T>
T twice(T x);
extern template int twice<int>(int);
// box.cpp
template <class T>
T twice(T x) {
return x + x;
}
template int twice<int>(int);
Now box.cpp explicitly instantiates twice<int>, and other translation units can be told not to instantiate that specialization themselves. This can reduce compile time and code bloat for selected specializations, but it also makes the template less open-ended. If someone calls twice<std::string>, that specialization still needs a visible definition somewhere or its own explicit instantiation.
So template compilation has a real design tension. Definitions in headers are flexible, easy for users, and give the optimizer everything, at the price of re-parsing and re-instantiating the same machinery in many translation units. Explicit instantiation centralizes the generated code, at the price of bookkeeping and lost header-only convenience.
This is one reason C++ build systems feel heavy compared with C build systems. Headers aren’t cheap declarations anymore. They can contain entire compile-time programs.
The one rule behind both surprises
How did the optimizer legally turn the tuple program into:
mov w0, #204
ret
Because of the as-if rule.
The compiler may transform your program however it wants as long as the observable behavior is as if the original program ran. Observable behavior includes things like volatile accesses, I/O, calls whose effects can’t be removed, and the final result of the program. It doesn’t include preserving every temporary object or every source-level function call you imagined.
In the tuple example, the compiler can prove that the tuple holds compile-time constants, that score computes their sum of squares, that none of the erased machinery has observable side effects, and that main returns the sum. So the observable behavior is “return 204.” Everything else is scaffolding.
This is why reading unoptimized assembly can be misleading and reading optimized assembly can be shocking. Unoptimized assembly preserves source structure for debugging. Optimized assembly preserves behavior.
Undefined behavior is the dark side of the same rule.
If the C++ standard says a construct has undefined behavior, the optimizer may assume it doesn’t happen in any valid program. That assumption can erase checks or reorder code in ways that look insane if you expect undefined behavior to produce one particular runtime result.
Classic example:
int f(int x) {
return x + 1 > x;
}
For mathematical integers, this is always true. For fixed-width two’s-complement machine integers, INT_MAX + 1 wraps on many CPUs. But signed integer overflow in C++ is undefined behavior. The optimizer can assume x + 1 doesn’t overflow. Under that assumption, x + 1 > x is always true.
So an optimizer is allowed to compile f as:
return 1;
This isn’t the compiler being “clever at runtime.” It is compile-time reasoning under the language rules.
The ODR violation from the opening belongs in this category too. Two different definitions of an inline function with external linkage don’t create a well-defined program where the linker has to choose politely. They create undefined behavior. The fact that we got 111 111 or 222 222 is an artifact, not a contract.
This is the dangerous sentence:
but it worked on my machine
For undefined behavior, “worked” often means “one particular compiler, optimization level, link order, and loader environment happened not to expose the broken assumption today.”
The toolchain has many phases, but they all lean on the same premise: the input program follows the language rules. When it doesn’t, later phases may still produce a binary, and that binary may even seem reasonable. That doesn’t make the program defined.
The function that never became a symbol
The opening template produces thirty-three weak definitions at -O0. A different template can be rejected before it produces one.
Older template constraints often surfaced as error messages deep inside an attempted instantiation. Concepts give the compiler a place to reject a candidate while it is still deciding which overload a call means.
Concepts don’t make templates simple, but they give the compiler a place to say “this template isn’t even a candidate.”
Here is a tiny concept:
#include <concepts>
#include <string>
template <class T>
concept AddableToInt = requires(T x) {
x + 1;
};
template <AddableToInt T>
int bump(T x) {
return x + 1;
}
int main() {
return bump(std::string{"nope"});
}
Compile:
concept_fail.cpp:15:12: error: no matching function for call to 'bump'
return bump(std::string{"nope"});
^~~~
concept_fail.cpp:10:5: note: candidate template ignored: constraints not satisfied [with T = std::string]
int bump(T x) {
^
concept_fail.cpp:9:11: note: because 'std::string' does not satisfy 'AddableToInt'
template <AddableToInt T>
^
concept_fail.cpp:6:7: note: because 'x + 1' would be invalid: invalid operands to binary expression ('std::string' and 'int')
x + 1;
^
1 error generated.
That is a compile-time error. No runtime check. No hidden if. No object carrying a concept tag.
The compiler tests whether the constraint is satisfied during overload resolution. If not, that overload is removed from the viable set. This is the grown-up version of SFINAE, with syntax humans can read and diagnostics that have a fighting chance.
Placement in the toolchain: preprocessing and parsing already happened, overload resolution considered bump(...), the constraint was checked with T = std::string, the candidate was rejected, compilation stopped. Concepts are part of compile-time C++. They shape which programs are valid and which overloads are considered. Once you have a linked executable, the concept itself is gone.
constexpr is permission; consteval is a demand
constexpr and consteval are easy to blur because both point toward compile-time evaluation.
This file separates them:
consteval int sq(int x) {
return x * x;
}
constexpr int from_constexpr(int x) {
return x * x;
}
int forced = sq(12);
int runtime(int x) {
return from_constexpr(x);
}
int main() {
return forced + runtime(3);
}
At -O0, the assembly has data for forced:
_forced:
.long 144
There is no callable symbol for sq. sq(12) had to be evaluated at compile time because sq is consteval.
But from_constexpr does have a symbol:
$ nm -m consteval_demo.o
0000000000000024 (__TEXT,__text) weak external __Z14from_constexpri
0000000000000000 (__TEXT,__text) external __Z7runtimei
0000000000000078 (__DATA,__data) external _forced
0000000000000040 (__TEXT,__text) external _main
And the assembly for runtime calls it:
__Z7runtimei:
...
bl __Z14from_constexpri
...
That is the distinction:
constexpr function:
may be evaluated at compile time when used in a constant-evaluation context
may also be emitted and called at runtime
consteval function:
immediate function
every call must produce a compile-time constant
So constexpr isn’t “always compile time.” It is “can participate in compile-time evaluation.” Optimization may still inline and fold it away, but the language rule isn’t the same as consteval.
This matters because compile-time C++ isn’t one mechanism. Templates, concepts, overload resolution, constexpr, consteval, constinit, type traits, partial specialization, non-type template parameters, and the preprocessor all act at different phases with different rules.
The easiest way to get lost is to call all of it “metaprogramming” and stop distinguishing the machinery.
The failed concept check belongs on the same path as the opening program. It is one possible end of semantic analysis. The valid tuple call proceeds to instantiation, IR, and thirty-three weak definitions. The invalid bump call stops before there is an object file with a bump<std::string> symbol. The second opening program proceeds further still, through object generation and into a linker that can match names but cannot recover the source-level promise we broke.
Back to 111/222: what makes two files name the same function
The reason the second program links at all is that both object files independently arrive at the same binary name for picked (the mangled __Z6pickedv), and the linker matches on that string. That name, and everything else two separately compiled files have to agree on, is the ABI.
C has simple symbol names. C++ has overloads:
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
extern "C" int c_add(int a, int b) {
return a + b;
}
The object file symbols:
$ nm abi_demo.o
0000000000000020 T __Z3adddd
0000000000000000 T __Z3addii
0000000000000040 T _c_add
Demangle:
$ c++filt __Z3addii
add(int, int)
$ c++filt __Z3adddd
add(double, double)
extern "C" keeps the simpler C-style symbol:
_c_add
The leading underscore is Mach-O’s symbol spelling convention. On ELF you would usually see c_add without that leading underscore.
c++filt also unmasks the monsters from the first program. That unreadable -O0 symbol,
$ c++filt __Z5scoreINSt3__15tupleIJiiiiiiiiEEEEiT_
int score<std::__1::tuple<int, int, int, int, int, int, int, int>>(std::__1::tuple<int, int, int, int, int, int, int, int>)
is just score instantiated for an eight-int tuple, spelled out in full. The mangling encodes the entire template argument list, which is why the symbol is so long: the linker needs a name unique to that exact instantiation, so that score<std::tuple<double>> gets a different symbol and never collides with this one. One of the get accessors demangles with an odd tail:
$ c++filt __ZNSt3__112__tuple_leafILm0EiLb0EE3getB8ue170006Ev
std::__1::__tuple_leaf<0ul, int, false>::get[abi:ue170006]()
That [abi:ue170006] is an ABI tag. libc++ stamps internal functions with a tag tied to a standard-library ABI version, so code built against one libc++ ABI can’t silently link against an incompatible one. The tag becomes part of the mangled name, which turns a mismatch into an unresolved-symbol error at link time instead of a memory-corrupting surprise at runtime. The B8ue170006 in the raw symbol is that tag encoded: B, a length, then the tag string.
Name mangling is one piece of the C++ ABI. ABI means Application Binary Interface. It is the contract that lets separately compiled object files and libraries agree at the binary level.
The ABI covers things like:
- how function names are encoded into linker symbols
- which registers carry arguments and return values
- how the stack is aligned
- how classes are laid out in memory
- where a virtual pointer lives
- what a vtable looks like
- how exceptions unwind
- how RTTI is represented
- how destructors are named and called
- what standard library implementation and version tags are assumed
Add a virtual class:
struct Shape {
virtual int area() const;
virtual ~Shape();
};
The object file now contains symbols for type info, type name, and vtable:
0000000000000110 S __ZTI5Shape
0000000000000120 S __ZTS5Shape
00000000000000e8 S __ZTV5Shape
U __ZTVN10__cxxabiv117__class_type_infoE
Demangled:
__ZTI5Shape -> typeinfo for Shape
__ZTS5Shape -> typeinfo name for Shape
__ZTV5Shape -> vtable for Shape
This is why “compiled with a C++ compiler” isn’t enough. Two object files need to agree on the ABI. If one compiler thinks a class layout or calling convention is different, the linker may still produce a binary, but the running program can be nonsense.
ABI is also why library upgrades can be scary. Source compatibility says your code still compiles. Binary compatibility says already-compiled clients can still call into the new library safely. Those are different promises.
Here is the part that made ABI click for me: the linker checks symbol names, not C++ types.
If a function is declared in a header as:
int parse(const char* text);
and a separately compiled library defines something ABI-compatible with that declaration, calls work.
But imagine the header and library disagree in a way that still produces a symbol the linker can use. C is especially good at letting you shoot this foot:
// header seen by caller
extern "C" int scale(int);
// library actually built with
extern "C" double scale(double);
Both want the unmangled symbol name scale on many platforms. The linker may connect them. At runtime the caller passes an int in the integer argument register and expects an int back. The callee expects a double in a floating-point register and returns a double. The binary interface doesn’t match. The result is garbage.
C++ name mangling catches many of these because parameter types are encoded:
__Z3addii -> add(int, int)
__Z3adddd -> add(double, double)
But name mangling isn’t the whole ABI. Struct layout matters too.
struct Packet {
int kind;
long size;
};
If one side thinks long is 8 bytes and another side thinks it’s 4 bytes, the layout changes. If one side compiles with different packing pragmas, offsets change. If one side changes private fields in a class whose size is visible to clients, client code that allocates or indexes the object can become wrong without source-level errors.
Virtual functions make the contract even sharper. A call like:
shape->area();
is usually implemented as:
load vptr from object
load function pointer from vtable slot
indirect call through that pointer
If the caller and callee disagree about where the vptr is, or which slot holds area, the program can jump to the wrong function. This is why changing the order of virtual functions in a published ABI is dangerous. Adding a virtual function in the middle of a class can shift slots. Removing one can shift slots. Changing inheritance can change layout.
The ABI is the part of C++ that keeps separate compilation honest: the front end understands types, the object file records symbols, the linker resolves names, and the ABI defines what those names mean at runtime.
This is also why extern "C" is so useful at language boundaries. It removes C++ name mangling and uses the C ABI for the function boundary. That doesn’t make the function “written in C.” It makes the binary interface C-shaped enough that other languages and dynamic loaders can find and call it predictably.
Most plugin systems, foreign-function interfaces, and OS APIs lean on C ABI boundaries for exactly that reason. C++ ABI across compilers and standard libraries is much more fragile.
The linker resolves names, not intentions
Now back to the duplicate symbol trap.
If two object files define the same ordinary non-inline function, the linker complains:
// strong_a.cpp
int duplicate() {
return 1;
}
// strong_b.cpp
int duplicate() {
return 2;
}
Link:
$ clang++ strong_a.o strong_b.o strong_main.o -o strong_dup
duplicate symbol '__Z9duplicatev' in:
/private/tmp/toolchain-notes/strong_a.o
/private/tmp/toolchain-notes/strong_b.o
ld: 1 duplicate symbols
clang: error: linker command failed with exit code 1
Good. Loud. Boring. We like boring here.
Now look at what separates that loud error from our silent 111/222 program. In strong_a.cpp, duplicate() is an ordinary function. In odr_a.cpp, picked() is the same shape with one extra word in front of it: inline. In the measured Darwin object files, that language rule leads Clang to emit duplicate-friendly weak definitions that the linker may coalesce. inline is not a command to perform call-site inlining, and the C++ rule is not itself a Mach-O weak-symbol rule. The weak representation is how this compiler and ABI carry the language promise into separate object files. We broke the promise by giving those definitions different bodies.
Inline functions and templates need that permission because the same definition may legitimately appear in multiple translation units. The One Definition Rule says those definitions must be the same in the ways the standard requires. The linker isn’t a C++ semantic proof engine. It mostly sees symbols and linkage properties.
Our bad inline example created two weak definitions:
weak external __Z6pickedv
weak external __Z6pickedv
The linker is allowed to coalesce weak definitions. It doesn’t compare the function bodies and say “hold on, one returns 111 and one returns 222.” It picks one.
Link order a, b:
111 111
Link order b, a:
222 222
Two orders isn’t enough to know the rule, though. I’d been lazily calling it “link order,” but link order of what? I ran all six permutations of the three object files:
$ clang++ odr_a.o odr_b.o odr_main.o -o t && ./t # 111 111
$ clang++ odr_b.o odr_a.o odr_main.o -o t && ./t # 222 222
$ clang++ odr_main.o odr_a.o odr_b.o -o t && ./t # 111 111
$ clang++ odr_main.o odr_b.o odr_a.o -o t && ./t # 222 222
$ clang++ odr_a.o odr_main.o odr_b.o -o t && ./t # 111 111
$ clang++ odr_b.o odr_main.o odr_a.o -o t && ./t # 222 222
The rule is sharper than “link order.” odr_main.o can sit first, last, or in the middle and it changes nothing. What decides the winner is which of the two objects that actually define picked, odr_a.o or odr_b.o, appears first on the command line. Whichever weak definition the linker meets first is the one it keeps; the later one it discards silently. There is exactly one picked in the final binary:
$ nm t | grep picked
0000000100003f18 T __Z6pickedv
and it disassembles to mov w0, #0x6f (111 in hex) when a came first. Both callers reach that one symbol. Disassembling the a, b build, from_a and from_b branch to the same address:
$ otool -tvV t | grep -A6 _from
... bl 0x100003f80 ; symbol stub for: __Z6pickedv (inside from_a)
... bl 0x100003f80 ; symbol stub for: __Z6pickedv (inside from_b)
Same stub, same target. from_b calls the definition compiled from odr_a.cpp, with nothing anywhere in the binary recording that its own file’s picked was thrown away.
“First one wins” only holds when both definitions are weak. Make one of them strong, a plain non-inline int picked(), and the rule flips:
$ clang++ odr_b.o odr_a_strong.o odr_main.o -o t && ./t
111 111
odr_b.o is first on the line and still loses. A strong definition beats a weak one no matter the order, because coalescing only happens among weak symbols. A strong symbol isn’t a candidate to be coalesced away; it’s the answer. So the real hierarchy has three rungs: a single strong definition wins outright, among weak-only definitions the first on the command line wins, and two strong definitions are the loud duplicate-symbol error from a moment ago. Our 111/222 program lives on the middle rung, the one with no diagnostic.
The unsettling part is that this same coalescing is a feature working correctly almost all the rest of the time. I gave both files identical bodies, both returning 111, which is what a real header-only inline function looks like when two translation units both include it:
$ clang++ odr_a_same.o odr_b_same.o odr_main.o -o t && ./t # 111 111
$ clang++ odr_b_same.o odr_a_same.o odr_main.o -o t && ./t # 111 111
Now the order genuinely doesn’t matter, because both definitions are the same and the single coalesced symbol lands at the same address whichever comes first. That is exactly how one inline function in one header can be included by a thousand .cpp files and still produce one function in the final binary. The 111/222 bug isn’t a different mechanism from that everyday convenience. It’s the identical mechanism, handed a promise we broke. We told the linker “these definitions are the same, coalesce them freely,” and they weren’t the same.
This is the practical ODR consequence:
strong duplicate definitions:
usually diagnosed by the linker
multiple identical inline/template definitions:
normal, expected, coalesced
multiple different inline/template definitions:
undefined behavior
often no diagnostic
linker may silently choose one
This is why header-only code has to be careful. If a macro changes the body of an inline function in one translation unit but not another, you can create an ODR violation without any file visibly defining the same function twice.
Example shape:
// config.h
#ifdef FAST_MODE
inline int limit() { return 1024; }
#else
inline int limit() { return 64; }
#endif
If one .cpp sees FAST_MODE and another doesn’t, you have two different inline definitions with the same name. Maybe optimization inlines both and no external symbol remains. Maybe one weak symbol is picked. Maybe the behavior changes with optimization, link order, link-time optimization, or a small unrelated edit.
The linker resolves names. It doesn’t know your intention.
One way to keep names out of the linker’s global symbol fight is internal linkage.
static int helper() {
return 7;
}
or in C++:
namespace {
int helper() {
return 7;
}
}
Those definitions are local to the translation unit. Two .cpp files can each have their own internal helper() and the linker won’t treat them as duplicate global definitions. Their symbols may still exist in the object file, but they’re local/non-external. They aren’t candidates to satisfy another file’s undefined helper.
This is why anonymous namespaces are common in .cpp files. They make “private to this translation unit” explicit in C++ terms.
Headers are a different story. If you put an anonymous namespace or static function in a header, every translation unit that includes the header gets its own separate copy. Sometimes that’s fine. Sometimes it creates surprising state:
// counter.h
namespace {
int counter = 0;
}
Every .cpp gets a different counter. If you expected one global counter for the program, you just got many.
Linking static archives has another wrinkle: order can matter.
Suppose:
main.o needs symbol foo
libfoo.a contains foo
This usually works:
clang++ main.o libfoo.a -o app
The linker sees main.o, records that foo is undefined, then searches libfoo.a and pulls the member defining foo.
But this can fail on traditional Unix linkers:
clang++ libfoo.a main.o -o app
When the linker scans libfoo.a, it hasn’t yet seen main.o’s undefined foo, so it may not pull the archive member. Later it sees main.o, discovers foo is needed, but doesn’t go back. Modern linkers have options and behaviors that soften this, and Darwin’s linker isn’t identical to GNU ld, but the rule remains useful: object files and static libraries aren’t searched in the same way.
That is another difference between:
object file:
included directly in the link
static archive:
searched for members that satisfy unresolved symbols
dynamic library:
recorded as a runtime dependency, with symbols bound by the dynamic loader
This is why build-system link lines can look annoyingly order-sensitive. The link command is an algorithm wearing the costume of a file list.
There is one major caveat to the “translation units are islands” model: link-time optimization.
With ordinary compilation, each .cpp becomes machine-code object files before the linker sees the program. By then, cross-translation-unit optimization is limited. The linker can inline nothing in the usual sense because it’s mostly looking at machine code and symbols, not rich C++ or IR.
With LTO, the compiler stores optimizer-friendly IR in the object files. The linker invokes the compiler’s LTO machinery at link time. Now the optimizer can see across translation units:
ordinary build:
a.cpp -> machine-code-ish a.o
b.cpp -> machine-code-ish b.o
linker stitches symbols
LTO build:
a.cpp -> IR-containing a.o
b.cpp -> IR-containing b.o
linker asks optimizer to reason across both
backend emits final machine code late
This can enable inlining across .cpp files, dead-code elimination across library boundaries, devirtualization, and better constant propagation. It also means link time becomes more like compile time, because the linker is now hosting serious optimization work.
LTO doesn’t repeal the ODR, and I’ll admit I went in expecting it to catch our bug. Whole-program optimization sees both translation units at once. Surely it would notice that picked() disagrees with itself. So I rebuilt the whole 111/222 mess with -flto, threw -Wall -Wextra at every step, and linked it both ways:
$ clang++ -std=c++20 -O2 -flto -Wall -Wextra -c odr_a.cpp -o odr_a.o
$ clang++ -std=c++20 -O2 -flto -Wall -Wextra -c odr_b.cpp -o odr_b.o
$ clang++ -std=c++20 -O2 -flto -Wall -Wextra -c odr_main.cpp -o odr_main.o
$ clang++ -flto odr_a.o odr_b.o odr_main.o -o odr_ab && ./odr_ab
111 111
$ clang++ -flto odr_b.o odr_a.o odr_main.o -o odr_ba && ./odr_ba
222 222
Same silence. Same link-order roulette. The linker’s stderr was empty both times. Whatever the folklore says about LTO surfacing ODR problems, this toolchain on this afternoon didn’t say one word about mine. It still coalesced by name and kept whichever picked() it saw first. So I was wrong: LTO changes where optimization happens, not whether the linker grows a conscience about C++ semantics.
What it does change is real, just narrower: without LTO, translation units are mostly compiled independently; with it, the compiler delays some optimization until link time, which is what enables the cross-.cpp inlining and dead-code elimination above. The island boundary moves. It doesn’t vanish. It also doesn’t start reading your definitions for meaning.
A static archive isn’t a dynamic library
I built one tiny library:
// libanswer.cpp
extern "C" int answer() {
return 42;
}
Compile it:
$ clang++ -std=c++20 -O0 -c libanswer.cpp -o libanswer.o
Make a static archive:
$ ar rcs libanswer.a libanswer.o
Build a caller with the static archive:
$ clang++ use_answer.o libanswer.a -o use_static
$ ./use_static
answer=42
Now inspect the executable:
$ otool -L use_static
use_static:
/usr/lib/libc++.1.dylib
/usr/lib/libSystem.B.dylib
No libanswer. The archive member was copied into the executable because it satisfied the undefined _answer.
nm confirms _answer is now defined in the executable:
$ nm -m use_static
0000000100003f80 (__TEXT,__text) external _answer
0000000100003f48 (__TEXT,__text) external _main
(undefined) external _printf (from libSystem)
A static archive is basically a collection of object files plus an index. The linker doesn’t load the whole archive blindly. It pulls archive members needed to resolve currently undefined symbols. Archive order can matter on some linkers because of this one-pass style search behavior.
Now build a dynamic library:
$ clang++ -std=c++20 -dynamiclib libanswer.cpp \
-install_name @rpath/libanswer.dylib \
-o libanswer.dylib
Link the caller dynamically:
$ clang++ use_answer.o -L. -lanswer \
-Wl,-rpath,/private/tmp/toolchain-notes \
-o use_dynamic
$ ./use_dynamic
answer=42
Inspect:
$ otool -L use_dynamic
use_dynamic:
@rpath/libanswer.dylib
/usr/lib/libc++.1.dylib
/usr/lib/libSystem.B.dylib
Now the executable depends on @rpath/libanswer.dylib.
nm shows _answer remains undefined in the executable, but annotated as coming from libanswer:
$ nm -m use_dynamic
(undefined) external _answer (from libanswer)
0000000100003f4c (__TEXT,__text) external _main
(undefined) external _printf (from libSystem)
That is the static/dynamic split:
static archive:
linker copies selected object code into the executable
symbol becomes defined in the executable
no runtime dependency on that archive
dynamic library:
linker records dependency and unresolved dynamic symbols
dynamic loader maps the library at process start
calls bind to code in the shared library
Even use_static isn’t fully static. On macOS it still links libc++.1.dylib and libSystem.B.dylib. Fully static user executables aren’t the normal macOS path. “Static linking” in this example means “our answer code came from a static archive.”
The @rpath part deserves a closer look:
@rpath/libanswer.dylib
That isn’t a literal directory. It is a loader token. The dynamic library was built with:
-install_name @rpath/libanswer.dylib
The install name is the identity recorded for the dylib. When an executable links against it, that install name becomes the dependency string:
LC_LOAD_DYLIB
name @rpath/libanswer.dylib
Then the executable also records an rpath:
LC_RPATH
path /private/tmp/toolchain-notes
At runtime, dyld expands @rpath using the executable’s rpath list and finds:
/private/tmp/toolchain-notes/libanswer.dylib
This is why moving dynamic libraries can break programs even when the symbol names are fine. The loader has to find the file before it can bind the symbols.
Darwin has a few common path tokens:
@rpath:
search through runtime paths recorded in the loading image
@loader_path:
directory containing the image doing the loading
@executable_path:
directory containing the main executable
Linux has a different vocabulary: SONAME, RPATH, RUNPATH, LD_LIBRARY_PATH, /etc/ld.so.cache, and standard library directories. Windows has DLL search rules. Same category of problem, different loader policy.
Versioning also lives around this boundary. A dynamic library can expose a compatibility version and current version on Mach-O:
@rpath/libanswer.dylib (compatibility version 0.0.0, current version 0.0.0)
Real libraries use those fields to communicate ABI compatibility. The loader can reject a library whose compatibility version is too old for the client. That is a runtime ABI check, not a C++ source check.
Dynamic linking buys sharing and late binding: many processes map the same read-only library pages, security updates replace one shared library instead of rebuilding every app, plugins load at runtime, startup defers some binding work. It also buys new failure modes:
library not found
wrong library found first
symbol missing
ABI-compatible name with ABI-incompatible behavior
different C++ standard library than expected
global constructors in a loaded library doing surprising work
Static linking moves more into the executable. Dynamic linking moves more into the loader contract.
The executable contains loader instructions
A linked Mach-O executable isn’t just raw instructions.
It has load commands saying what segments exist, what dynamic libraries are needed, where the dynamic linker is, what rpaths to search, where symbol tables live, where fixups live, and where execution begins.
For the dynamic build:
$ otool -l use_dynamic_legacy
...
Load command 8
cmd LC_LOAD_DYLINKER
name /usr/lib/dyld
...
Load command 13
cmd LC_LOAD_DYLIB
name @rpath/libanswer.dylib
...
Load command 16
cmd LC_RPATH
path /private/tmp/toolchain-notes
LC_LOAD_DYLINKER says this process uses /usr/lib/dyld. LC_LOAD_DYLIB records the library dependency. LC_RPATH gives dyld a runtime search path for @rpath/libanswer.dylib.
The file also has segments and sections:
Section
sectname __text
segname __TEXT
...
Section
sectname __stubs
segname __TEXT
...
Section
sectname __got
segname __DATA_CONST
...
Section
sectname __la_symbol_ptr
segname __DATA
The names are Darwin/Mach-O names. On ELF systems, the analogous conversation usually mentions:
.text
.plt
.got
.got.plt
.rela.plt / .rela.dyn
Same family of idea, different file format.
This is where the dynamic loader enters.
The load commands give it a library graph, fixup records, and an executable entry. They do not imply that the entry is the first instruction the new process executes. I had been using those two meanings of entry as if they were interchangeable.
The entry point was not the first instruction
I compiled the lifecycle probe used later in this investigation and asked otool only for the two commands that describe beginnings:
$ clang++ -std=c++20 -O0 startup_trace.cpp -o startup_trace
$ otool -l startup_trace
...
cmd LC_LOAD_DYLINKER
cmdsize 32
name /usr/lib/dyld (offset 12)
...
cmd LC_MAIN
cmdsize 24
entryoff 13508
stacksize 0
LC_LOAD_DYLINKER names the image that must run first. LC_MAIN carries an offset to the executable entry.
The offset is decimal in this output. Converting it gives hexadecimal 0x34c4. nm reports:
$ nm -nm startup_trace | grep ' _main$'
00000001000034c4 (__TEXT,__text) external _main
The preferred base address of this executable’s __TEXT segment is 0x100000000. Add 0x34c4, the LC_MAIN offset, and the result is 0x1000034c4, exactly the preferred address of _main.
For this binary, LC_MAIN names main itself. There is no hidden executable function between the entry command and _main. ASLR can slide the mapped image to another runtime address, so the printed address is not a promise that every launch uses 0x1000034c4. The invariant relationship is:
mapped address of main
= mapped base of the main executable
+ LC_MAIN entryoff
That seemed to settle the first-instruction question. It did not. The same file says /usr/lib/dyld must be loaded, and dyld has work to finish before _main is safe to execute.
The operation that begins this replacement is execve:
int execve(const char* path, char* const argv[], char* const envp[]);
Its name encourages one bad mental picture: a process calls execve, a second process appears, and the first continues after the call. The POSIX exec specification describes a replacement instead. A successful call replaces the current process image. It does not return to the instruction after execve. Process identity survives, while the old code, stack, heap, and mapped images are replaced by the new executable image.
Something else must create a process when a separate parent and child are required. The controlled launcher below uses fork for that job, then calls execve inside the child. Modern launchers can use posix_spawn and other platform paths. Nothing in this experiment establishes how every shell launches every command.
The kernel side is visible in Apple’s published XNU source. This machine reports:
Darwin Kernel Version 24.6.0
root:xnu-11417.140.69.710.16~1/RELEASE_ARM64_T8132
Apple publishes the base release as tag xnu-11417.140.69. Its mach_loader.c has separate paths named load_main and load_dylinker, builds a new virtual-memory map, chooses independent ASLR slides for the executable and dyld, and records both executable and dynamic-linker state in its load result. The extra installed suffix, .710.16, is not a published source tag, so this is a source reading from the matching public base rather than a claim that I rebuilt the installed kernel.
The decisive comment is in Apple’s published dyldStartup.s. It says the kernel starts the process with the program counter at __dyld_start, not at the executable’s _main.
On arm64, that entry is only a few instructions:
__dyld_start:
mov x0, sp
and sp, x0, #~15
mov fp, #0
mov lr, #0
b start
The first instruction copies the stack pointer into x0, the first argument register. The next aligns the stack to 16 bytes. Setting the frame pointer and link register to zero marks the bottom of their chains. The branch enters dyld’s C++ start path.
The stack pointer does not point into the old caller’s stack. The kernel built a new initial stack for the replacement image. dyld’s source documents its shape:
lowest address, initial sp
main executable Mach-O header address
argc
argv pointers
null
environment pointers
null
Apple-specific auxiliary pointers
null
strings named by those pointers
highest address
argc is the count of command-line arguments. argv is an array of pointers to their strings. The environment is another null-terminated pointer array. The final Apple-specific array carries facts such as the executable path that dyld and system libraries need. None of this is a C++ object graph yet. It is words and pointers placed at an address the initial register state can name.
The next path is large because it has to make the process internally consistent before arbitrary application code runs. In Apple’s published dyldMain.cpp, prepare is explicitly documented as loading dependent dylibs, binding them together, and returning the address of main in the target. The source loads dependencies, applies fixups, publishes the image list to debuggers and the kernel, and calls runAllInitializersForMain. Only after that work does dyld transfer control to the LC_MAIN address.
So this measured Darwin path has two entries with two owners:
kernel-selected first instruction:
__dyld_start in /usr/lib/dyld
main executable entry:
mapped executable base + LC_MAIN entryoff
_main in this binary
The distinction explains why a dynamically linked program can fail without executing one instruction from its main. The kernel can reject the image. dyld can fail to find a dependency. A fixup can fail. An initializer can abort. _main is the executable’s entry, but it is not the beginning of everything the process must do.
Relocation is the art of not knowing addresses yet
Object files don’t know final addresses. Dynamic libraries don’t know where ASLR, the OS’s habit of loading images at randomized base addresses, so attackers can’t hardcode them, will place them. Executables may not know where all external functions will live. So binary formats carry relocation or fixup information.
There are two broad jobs:
rebase:
adjust pointers that refer to locations inside the same image
needed because the image may be loaded at a different base address
bind:
fill pointers or call targets that refer to symbols from another image
needed because external libraries are separate images
In our object file, _answer was just an undefined symbol plus relocations. After linking dynamically, main calls a symbol stub:
_main:
0000000100003f28 bl 0x100003f54 ; symbol stub for: _answer
...
0000000100003f40 bl 0x100003f60 ; symbol stub for: _printf
The direct branch goes to a stub inside the executable, not straight to libanswer.dylib.
The indirect symbol table shows the stubs:
$ otool -Iv use_dynamic_legacy
Indirect symbols for (__TEXT,__stubs) 2 entries
address index name
0x0000000100003f54 3 _answer
0x0000000100003f60 4 _printf
And the lazy symbol pointer section:
Indirect symbols for (__DATA,__la_symbol_ptr) 2 entries
address index name
0x0000000100008000 3 _answer
0x0000000100008008 4 _printf
dyldinfo shows the lazy bind table:
$ dyldinfo -lazy_bind use_dynamic_legacy
lazy binding information (from lazy_bind part of dyld info):
segment section address index dylib symbol
__DATA __la_symbol_ptr 0x100008000 0x0000 libanswer _answer
__DATA __la_symbol_ptr 0x100008008 0x000E libSystem _printf
The GOT-like non-lazy entry is dyld_stub_binder:
$ dyldinfo -bind use_dynamic_legacy
bind information:
segment section address type addend dylib symbol
__DATA_CONST __got 0x100004000 pointer 0 libSystem dyld_stub_binder
This is the Mach-O lazy-binding shape:
call site
-> __TEXT,__stubs
-> loads/jumps through __DATA,__la_symbol_ptr entry
-> first time, entry routes to dyld_stub_binder
-> dyld resolves the symbol
-> dyld patches the lazy symbol pointer
-> future calls jump directly through patched pointer
On ELF, the usual vocabulary is:
call site
-> PLT entry
-> GOT/GOT.PLT slot
-> dynamic resolver on first call
-> GOT slot patched
-> future calls go directly
That is what people mean by PLT/GOT lazy binding. PLT is Procedure Linkage Table, usually code stubs. GOT is Global Offset Table, data slots holding addresses. Lazy binding defers resolving a function until the first call instead of resolving everything at process startup.
Honesty note: my first attempt at this section produced nothing. The default build from Apple clang 15 on this arm64 macOS machine uses chained fixups, a newer, denser fixup encoding, and the legacy lazy-bind table comes back empty for it. I re-read my own link command a few times before finding the escape hatch: rebuild with
-Wl,-no_fixup_chains
which tells the linker to emit the older-style __la_symbol_ptr machinery this section dissects. So the tables above show the classic mechanism, deliberately resurrected, because it’s the version worth learning first; chained fixups answer the same questions with denser bytes.
I did go one level into what “denser bytes” costs you in structure, by diffing the sections of the two builds. The legacy build has the full classic lazy-binding apparatus:
$ otool -l use_dynamic_legacy | grep sectname
sectname __text
sectname __stubs
sectname __stub_helper
sectname __got
sectname __la_symbol_ptr
The default build, with chained fixups, is missing two of those sections entirely:
$ otool -l use_dynamic | grep sectname
sectname __text
sectname __stubs
sectname __got
No __stub_helper, no __la_symbol_ptr. In the legacy build, those two sections implement lazy binding: the helper that reaches dyld_stub_binder on first use, and the table of pointers it patches. The default build in this experiment has neither section and no lazy-bind records. Its imports are represented through LC_DYLD_CHAINED_FIXUPS and handled during launch instead. The legacy build carries LC_DYLD_INFO_ONLY. -no_fixup_chains therefore changed more than the byte encoding for this binary. It restored the old helper and lazy-pointer path that the default link did not emit.
I wanted to walk the chained-fixups format itself, the way the legacy tables above got walked, but the tool that decodes it, dyld_info -fixups, ships only with full Xcode, and this machine has just the Command Line Tools:
$ dyld_info -fixups use_dynamic
xcode-select: Failed to locate 'dyld_info'
So the pointer-chain layout stays a black box in this post. I can see the sections it lives in and the load command that describes it. I can’t dump the chain walk without installing several more gigabytes of Xcode, and I didn’t. That door stays shut for the same reason the modules door did.
The questions are the invariant part:
which images do I map?
where did they land?
which pointers need rebasing?
which external symbols need binding?
which calls can be bound lazily?
which initializers must run before main?
The syntax changes by platform. The job remains.
Before main, C++ can already run code
C programmers can mostly pretend execution starts at main, at least for small programs.
C++ makes that a little less true.
Consider:
#include <cstdio>
struct Logger {
Logger() {
std::puts("global constructor");
}
~Logger() {
std::puts("global destructor");
}
};
Logger logger;
int main() {
std::puts("main");
}
The object logger has static storage duration. Its constructor runs before main. Its destructor runs after main returns or std::exit runs the normal termination path.
Where does that call live in the binary?
Not in your main function. The compiler emits initialization functions and places pointers to them in special sections. On this Mach-O path, dyld walks those records before transferring control to main. On ELF you will hear about .init_array and .fini_array. On Mach-O, C++ static initializers appear through sections like __mod_init_func in __DATA or related segments depending on platform and linker mode.
This matters for dynamic libraries too. Loading a dylib can run its initializers before your code explicitly calls into it. Unloading can run destructors if unloading happens. Plugin systems live in this world.
This is one source of the static initialization order problem. Within one translation unit, globals are initialized in declaration order. Across translation units, the relative order isn’t something you should casually depend on.
// a.cpp
std::string config = load_config();
// b.cpp
Logger logger(config);
If logger’s initialization runs before config is ready, the program can fail before main. The source files look innocent because each file is locally valid. The problem exists at program initialization time, after linking has brought translation units together.
That failure mode isn’t hypothetical, and it’s quieter than “the program fails.” I built the smallest version I could. One file owns a global string:
// siof_a.cpp
#include <string>
std::string config = "PROD";
Another file has a global whose constructor reads it:
// siof_b.cpp
#include <cstdio>
#include <string>
extern std::string config;
struct Logger {
Logger() {
std::printf("Logger sees config = '%s' (len %zu)\n",
config.c_str(), config.size());
}
};
Logger logger;
Link a before b, so config is constructed first, and the Logger sees it:
$ clang++ siof_a.o siof_b.o siof_main.o -o siof_ab && ./siof_ab
Logger sees config = 'PROD' (len 4)
Swap the link order:
$ clang++ siof_b.o siof_a.o siof_main.o -o siof_ba && ./siof_ba
Logger sees config = '' (len 0)
No crash. No warning. An empty string with length zero. The Logger constructor ran before config’s constructor, so it read config while it was still in its zero-initialized state: a valid, empty std::string, not garbage, not a segfault, just wrong. This is the same link-order dependence as the 111/222 bug wearing a different coat. There, order picked which duplicate definition survived. Here, order picks which constructor runs first. Both compile clean, both link clean, both change behavior when you reorder the object files, and neither says a word. If config had been a type whose zero-initialized state is a null pointer you then dereference, that empty string would have been a crash. The bug is identical either way; whether it comes out quiet or loud is luck.
The usual C++ advice is to prefer function-local statics for cross-translation-unit dependencies:
Config& config() {
static Config c = load_config();
return c;
}
Since C++11, initialization of function-local statics is thread-safe. It happens the first time control reaches the declaration. This turns “global initialization before main” into “lazy initialization on first use”, which is often easier to reason about.
The toolchain point is broader: your source can request work before main, the compiler emits initializer records, the linker collects them, the loader and runtime run them, and only then does main start. A “running process” is already a partially executed C++ program by the time you reach its first line.
Returning from main was another runtime path
The opening program returns 204 from main, and the shell observes 204. That made return look like one machine instruction followed by process disappearance. The optimized _main really does end with ret, but a C++ return and a process exit are not the same event.
The current C++ wording for main gives its return statement special behavior. Returning first leaves the function, which destroys its automatic objects. It then calls std::exit with the returned value. Calling std::exit directly does not leave the current block, so those automatic objects are not destroyed.
The pinned dyld source makes the host implementation visible. Its start function calls the entry returned by prepare, stores the returned integer, and passes that integer to libSystem’s exit helper. The comment says exactly why: call main, and if it returns, call exit with the result. The ret at the bottom of an optimized _main returns into dyld’s startup frame. It does not return directly to the shell.
I made each possible cleanup action print one line:
#include <cstdlib>
#include <cstdio>
#include <string_view>
#include <unistd.h>
namespace {
void emit(std::string_view text) {
const char* next = text.data();
std::size_t remaining = text.size();
while (remaining != 0) {
const ssize_t written = ::write(STDOUT_FILENO, next, remaining);
if (written <= 0) {
std::_Exit(120);
}
next += written;
remaining -= static_cast<std::size_t>(written);
}
}
struct Global {
Global() {
emit("global constructor\n");
}
~Global() {
emit("global destructor\n");
}
};
struct Local {
~Local() {
emit("local destructor\n");
}
};
void registered_handler() {
emit("atexit handler\n");
}
Global global;
} // namespace
int main(int argc, char** argv) {
Local local;
std::atexit(registered_handler);
emit("main entered\n");
::dprintf(STDOUT_FILENO, "after execve: pid %d\n", getpid());
if (const char* origin = std::getenv("TRACE_ORIGIN")) {
emit("environment: ");
emit(origin);
emit("\n");
}
const std::string_view mode = argc > 1 ? argv[1] : "return";
if (mode == "exit") {
std::exit(23);
}
if (mode == "_Exit") {
std::_Exit(24);
}
if (mode == "_exit") {
::_exit(25);
}
return 22;
}
emit uses the POSIX write function rather than std::cout or printf. A successful write has already handed the bytes to the kernel. That choice matters here because _Exit and _exit do not perform the normal user-space stream flushing path. If a buffered line vanished, I would not know whether a destructor failed to run or a stream failed to flush. Direct writes remove that ambiguity.
The return mode prints:
global constructor
main entered
after execve: pid 99900
environment: execve
local destructor
atexit handler
global destructor
The global constructor is first because dyld ran the initializer before entering main. Returning from main leaves its scope, so the automatic local object is destroyed. The remaining work belongs to std::exit: it calls the registered handler and destroys the static global object.
The order of the last two lines is not an accident of this run. global was constructed before main registered registered_handler. The C++ static termination rules sequence the later registration before destruction of an object whose construction had already completed.
Calling std::exit(23) directly removes one line:
global constructor
main entered
after execve: pid 99902
environment: execve
atexit handler
global destructor
There is no local destructor. Control never leaves the block containing local. The library specification for std::exit says the same thing in a more operational order: destroy thread-storage objects for the current thread, destroy static-storage objects and invoke atexit functions, flush and close C streams, then return control to the host environment. Automatic objects are excluded.
std::_Exit(24) removes all three cleanup lines:
global constructor
main entered
after execve: pid 99904
environment: execve
The C++ specification says _Exit terminates without automatic, thread, or static object destruction and without invoking atexit functions. POSIX _exit(25) produced the same visible lifecycle:
global constructor
main entered
after execve: pid 99906
environment: execve
The spellings live in different standards and matter in portable source. std::_Exit is the C and C++ immediate-termination function. _exit is a POSIX system interface. Both bypass the C++ cleanup in this measured program. POSIX still requires the kernel-side process cleanup associated with termination, such as closing the process’s file descriptors. Skipping C++ destructors does not leave a dead process owning kernel resources forever. It does skip application cleanup whose code would have run before the final system termination.
The parent process makes that final boundary visible. This is the complete launcher used for the output above:
#include <cerrno>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char** argv) {
if (argc != 3) {
std::fprintf(stderr, "usage: %s PROGRAM MODE\n", argv[0]);
return 2;
}
const pid_t child = ::fork();
if (child == -1) {
std::perror("fork");
return 3;
}
if (child == 0) {
::dprintf(STDOUT_FILENO,
"child before execve: pid %d\n", getpid());
char* child_argv[] = {argv[1], argv[2], nullptr};
char origin[] = "TRACE_ORIGIN=execve";
char* child_env[] = {origin, nullptr};
::execve(argv[1], child_argv, child_env);
::dprintf(STDERR_FILENO,
"execve failed: errno %d\n", errno);
::_exit(127);
}
int status = 0;
while (::waitpid(child, &status, 0) == -1) {
if (errno != EINTR) {
std::perror("waitpid");
return 4;
}
}
if (WIFEXITED(status)) {
std::printf("parent observed: pid %d exited with %d\n",
child, WEXITSTATUS(status));
return 0;
}
if (WIFSIGNALED(status)) {
std::printf("parent observed: pid %d killed by signal %d\n",
child, WTERMSIG(status));
return 0;
}
std::printf("parent observed: pid %d changed state\n", child);
return 0;
}
The child prints its process ID immediately before execve. The replacement program prints it again after reaching main:
child before execve: pid 99900
global constructor
main entered
after execve: pid 99900
...
parent observed: pid 99900 exited with 22
The number stays 99900 across execve. The process did not become a new child. Its image changed.
Nothing executes after a successful execve inside the old child image. The error print and _exit(127) are reachable only when replacement fails. Using _exit there is deliberate. After fork, the child temporarily has copies of the parent’s user-space stream state. Running normal C++ cleanup in a failed pre-exec child can flush copied buffers or invoke handlers designed for the parent.
The parent calls waitpid with the child’s process ID. The integer written into status is encoded state, not just the value passed to return or an exit function. WIFEXITED asks whether termination was normal. WEXITSTATUS extracts the low eight-bit status. WIFSIGNALED and WTERMSIG represent a different ending where a signal terminated the process.
That is the eight-bit door the 140000 experiment hit earlier. The C++ computation returned 140000. The Darwin process interface preserved exit status 224. The parent, shell, or launcher decoded 224 from the wait status. Arithmetic, C++ termination, kernel process teardown, and parent observation are four different layers that happened to fit inside one return statement in the source.
A running process is the last link stage
It is tempting to say the linker creates the final program.
Not quite.
The linker creates an executable file. A running process still needs loader work.
For a dynamically linked executable, the file says “I need these libraries” and “these symbols must be bound.” dyld does that when the process starts. ASLR means load addresses aren’t fixed in the old absolute sense. Shared libraries are mapped into the process address space. Pages may be backed by files and loaded on demand. Some symbol bindings may be lazy. Some fixups happen before main. Some pages become read-only after binding.
The process isn’t just the executable bytes. It is:
virtual address space
mapped executable segments
mapped dynamic libraries
stack
heap
environment
thread state
file descriptors
loader state
runtime-initialized globals
This is why a crash before main is possible. A global constructor can run before main. A dynamic library can fail to load. A symbol can fail to bind. An incompatible library can be found first on a search path. A static initialization order bug can fire before the first line of your main.
By the time your first line of main runs, the program has been negotiating for a while.
The path again, with the lies removed
Here is the clean cartoon:
main.cpp
-> compiler
-> executable
-> run
Here is the version I now trust:
source files
-> preprocessor expands includes and macros
-> each preprocessed source becomes a translation unit
-> compiler parses, checks types, resolves overloads
-> concepts constrain viable templates
-> templates instantiate concrete functions/classes
-> constexpr/consteval evaluation runs where required or allowed
-> optimizer transforms the program under the as-if rule
-> backend emits assembly
-> assembler emits relocatable object files
-> object files contain defined symbols, undefined symbols, and relocations
-> linker resolves symbols across objects and libraries
-> weak/template/inline definitions may be coalesced
-> ODR violations may escape diagnostics
-> static archive members may be copied into the executable
-> dynamic library dependencies remain for the loader
-> executable records load commands, segments, rpaths, bindings, fixups
-> a launcher asks the kernel to replace a process image
-> kernel maps the executable and dyld, then starts at __dyld_start
-> dyld maps dylibs, applies fixups, and runs initializers
-> dyld transfers control to the executable's LC_MAIN entry
-> returning from main destroys automatic objects and enters std::exit
-> kernel terminates the process and encodes status for a waiting parent
The two opening artifacts fit into that path now.
The 893-line assembly file happened because template instantiation and the standard library created a lot of concrete code in one translation unit at -O0. The 12-line optimized file happened because the optimizer proved the result was the constant 204.
The 111 111 versus 222 222 program happened because this Darwin build emitted the inline definitions as weak duplicate-friendly symbols, and we violated the language rule that all definitions must match. The linker coalesced by symbol name and link order. It could not recover the promise the source had broken.
Once you see that, “compile C++” stops being one verb. It is a chain of contracts: the preprocessor’s (tokens in, tokens out), the language’s (a valid program after preprocessing), the ODR’s (definitions that must match actually match), the ABI’s (separately compiled code agrees on names, calls, layout, unwinding), the linker’s (every required symbol resolved or recorded), and the loader’s (every needed image mapped and fixed up before execution reaches it).
The compiler isn’t one wizard. It is a bureaucracy of very sharp clerks, and a running process is the paperwork coming out clean.
Sources and artifacts
The earlier compiler and linker command output came from local scratch files under:
/private/tmp/toolchain-notes
The opening program and the new process-lifecycle programs are preserved under:
writing-notes/probes/toolchain
Toolchain:
Apple clang version 15.0.0 (clang-1500.3.9.4)
Target: arm64-apple-darwin24.6.0
Local tools used:
clang++
nm
otool
dyldinfo
c++filt
ar
size
One tool I wanted and didn’t have: dyld_info -fixups, which decodes chained fixups, ships only with full Xcode. This machine has just the Command Line Tools, so the chained-fixups chain walk went unmeasured on purpose. The system otool, nm, and dyldinfo in /usr/bin were used explicitly, because an Anaconda install shadows all three earlier on this machine’s PATH with older LLVM builds that reject -bind and print a broken size.
The kernel source reading uses Apple’s public xnu-11417.140.69 tag, the base version named by the installed xnu-11417.140.69.710.16 kernel. The dyld startup reading uses Apple’s public dyld-1286.10 tag. Neither source tree was treated as a locally rebuilt copy of the installed operating system.
The most important artifacts:
template_blast.cpp: 893 assembly lines at-O0, 12 at-O1/-O2/-O3/-Os, 123 at-Oz; 34 IR functions at-O0collapse to oneret i32 204at-O2; 67blcalls and 33 weak definitions at-O0, zero calls at-O2runtime_tuple.cpp: elements fromargc, so nothing folds, yet the whole template tower still vanishes into 4 adds and 3madds with noblbig.cpp:{100, 200, 300}sums to 140000, which needsmovz+movk, and truncates to exit status 224- header preprocessing counts: empty TU 8 lines,
<cstdio>688,<vector>72,621,<map>84,924,<ranges>85,286,<regex>88,899 preprocess.cpp: 72,626 preprocessed lines after including<vector>concept_fail.cpp: concept-constrained template rejectionconsteval_demo.cpp:constevalvalue emitted as data,constexprfunction still callableodr_a.cppandodr_b.cpp: weak inline ODR violation, first-weak-defining-object-wins, strong beats weak; identical bodies coalesce order-independentlysiof_a.cppandsiof_b.cpp: static-initialization-order fiasco, an empty string of length 0 on reversed link order, no crash and no diagnosticuse_answer.o: undefined symbols and relocations before linkinguse_static:_answercopied into the executable fromlibanswer.ause_dynamic: default chained-fixups build, sections__stubsand__gotonly,LC_DYLD_CHAINED_FIXUPS, no lazy bindinguse_dynamic_legacy:-no_fixup_chainsbuild, dynamic dependency onlibanswer.dylib, stubs, GOT,__stub_helper, lazy symbol pointers, and dyld lazy-bind recordsstartup_trace.cpp:LC_MAINat_main, global construction beforemain, and distinct cleanup for return,std::exit,std::_Exit, and_exitexec_launcher.cpp: controlledfork, successful process-image replacement with the process ID preserved,waitpid, and decoded exit statusverify.sh: a clean temporary build of the 893-to-12 opening result, Mach-O entry inspection, disassembly, and all four termination modes