[The World of Linkers—Theory 14] What C++ and Rust Ask of a Linker
This chapter observes language features through the files a linker receives; it does not assume mastery of the C++ or Rust ABI. The foundations are symbol identity from Theory 03, groups and deduplication from Theory 05, and startup before main from Theory 06. A translation unit is one independently compiled source and its included contents. A crate is Rust's compilation and dependency unit, not necessarily one source file.
Language support divides into three responsibilities. The compiler expresses entity identity, data structures, and runtime requirements in object files. The linker processes their symbols, sections, and relocations. Runtime code performs initialization, synchronization, and exception handling. A linker generally does not interpret class definitions or generic source code; it preserves the relationships encoded in their binary interfaces.
| Language requirement | Compiler representation | Linking and runtime responsibilities |
|---|---|---|
| One inline function or template instance appears in several files | Mangled names, bindings, COMDAT groups | The linker preserves one entity identity and redirects references |
| Virtual calls and runtime type identification | Vtables, typeinfo, pointer relocations | The linker places data and patches pointers; generated code and runtime routines use them |
| Initialization, local statics, and panic | Initialization arrays, guards, helper references, unwind information | The linker connects data and dependencies; startup, guard protocols, and unwind routines execute the behavior |
Use these responsibilities to follow each example's inputs and consequences. Detailed mangling grammar can serve as a reference on a later reading. The Rust portion then follows crate, object/archive, and linker command before comparing panic and no_std runtime requirements. Theory 08 supplies the caller-recovery rules needed for exception cleanup.
A C header usually declares a function whose implementation lives in one source file. A C++ header often contains the implementation itself: an inline function, a template, or a class with inline virtual methods. Two files including that header can each produce machine code for the same entity.
The linker cannot simply reject every repeated name. Some repetitions must merge; some similar-looking functions are distinct overloads and must coexist. Compilers express these language distinctions through names, binding, section groups, metadata, and runtime calls. This chapter reads those messages directly from the files.
Three definitions, one counter
The language meaning of inline is more than an optimization hint. An external inline function can have matching definitions in several translation units while remaining one function. Its local static storage must also be shared. A template similarly gives several compilers a recipe from which to instantiate the same entity.
// common.h: included by both .cpp filesinline int twice(int x) { return 2 * x; }
template <typename T>T add(T a, T b) { return a + b; }
inline int next_id() { static int n = 0; // function-local static storage return ++n;}Both source files use the header:
// a.cpp#include "common.h"int fa(int x) { return twice(x) + add<int>(x, 1) + next_id(); }// b.cpp#include <cstdio>#include "common.h"int fa(int x);int fb(int x) { return twice(x) + add<int>(x, 2) + next_id(); }int main() { int a = fa(1), b = fb(1); std::printf("fa=%d fb=%d next_id=%d\n", a, b, next_id());}At -O0, GCC emits callable copies of the functions. If each file also kept an independent counter, the final call would report 2. Sharing all three calls should report 3:
$ g++ -fno-pie -O0 -c a.cpp$ g++ -fno-pie -O0 -c b.cpp$ g++ -fno-pie -static a.o b.o -o ab$ nm a.o0000000000000000 T _Z2fai0000000000000000 W _Z3addIiET_S0_S0_0000000000000000 W _Z5twicei0000000000000000 W _Z7next_idv0000000000000000 u _ZZ7next_idvE1n$ readelf -gW a.o
COMDAT group section [ 1] `.group' [_Z5twicei] contains 1 sections: [Index] Name [ 9] .text._Z5twicei
COMDAT group section [ 2] `.group' [_ZZ7next_idvE1n] contains 1 sections: [Index] Name [ 10] .bss._ZZ7next_idvE1n
COMDAT group section [ 3] `.group' [_Z7next_idv] contains 2 sections: [Index] Name [ 11] .text._Z7next_idv [ 12] .rela.text._Z7next_idv
COMDAT group section [ 4] `.group' [_Z3addIiET_S0_S0_] contains 1 sections: [Index] Name [ 13] .text._Z3addIiET_S0_S0_$ nm ab | grep -E 'twice|addI|next_id'00000000004018b0 W _Z3addIiET_S0_S0_000000000040187f W _Z5twicei0000000000401891 W _Z7next_idv00000000004b1a90 u _ZZ7next_idvE1n$ ./abfa=5 fb=7 next_id=3The functions are weak, and their sections belong to COMDAT1 groups. The counter has GNU-unique binding and its own group. next_id's group includes its code and relocation section, but not the counter's independent storage group. The resulting count of 3 verifies the shared entity, not merely the absence of a linker diagnostic.
Clang2 makes a different binding choice for the counter:
$ clang++ -fno-pie -O0 -c a.cpp -o a_clang.o$ nm a_clang.o0000000000000000 T _Z2fai0000000000000000 W _Z3addIiET_S0_S0_0000000000000000 W _Z5twicei0000000000000000 W _Z7next_idv0000000000000000 V _ZZ7next_idvE1n$ readelf -sW a.o | grep _ZZ 7: 0000000000000000 4 OBJECT UNIQUE DEFAULT 10 _ZZ7next_idvE1nGCC's u is STB_GNU_UNIQUE; Clang's V is a weak object. GNU-unique binding has additional dynamic-loader identity behavior and can affect unloading; -fno-gnu-unique disables that GCC choice. In this static fixture, COMDAT plus either binding yields the intended single counter. Do not generalize that to identical dynamic-loading semantics.
A symbol name that carries a type
A linker compares strings. It does not know namespaces, overloads, or const-qualified member functions. The compiler therefore encodes enough identity into a name to distinguish those entities.
The Itanium C++ ABI3, widely used beyond the original Itanium architecture, begins its external encoding with:
<mangled-name> ::= _Z <encoding>C-linkage entities and global-namespace variables are exceptions. For ordinary C++ functions, _Z begins the encoded name. The ABI's external-name specification supplies the grammar.
#include <string>namespace geo {struct Point { int x, y; int norm() const; };int Point::norm() const { return x * x + y * y; }double dist(const Point &a, const Point &b) { return 0; }void move(Point &p, const Point &d) {}}void report(int) {}void report(double) {}void report(const char *) {}void report(const std::string &) {}template <typename T> T biggest(T a, T b) { return a > b ? a : b; }template long biggest<long>(long, long);template double biggest<double>(double, double);extern "C" void report_c(int) {}int counter = 0;namespace geo { int counter = 0; }$ g++ -fno-pie -O0 -c names.cpp$ nm names.o | grep -v ' U '0000000000000079 T _Z6reportPKc0000000000000088 T _Z6reportRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE0000000000000069 T _Z6reportd000000000000005b T _Z6reporti0000000000000000 W _Z7biggestIdET_S0_S0_0000000000000000 W _Z7biggestIlET_S0_S0_0000000000000032 T _ZN3geo4distERKNS_5PointES2_0000000000000048 T _ZN3geo4moveERNS_5PointERKS0_0000000000000004 B _ZN3geo7counterE0000000000000000 T _ZNK3geo5Point4normEv0000000000000000 B counter0000000000000097 T report_c$ nm names.o | grep -v ' U ' | awk '{print $3}' | c++filtreport(char const*)report(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)report(double)report(int)double biggest<double>(double, double)long biggest<long>(long, long)geo::dist(geo::Point const&, geo::Point const&)geo::move(geo::Point&, geo::Point const&)geo::countergeo::Point::norm() constcounterreport_c6report is a length-prefixed identifier. Builtin type codes include i for int, d for double, c for char, l for long, and v for an empty parameter list. Prefixes compose types: P is pointer, R is lvalue reference, and K is const, so PKc means pointer to const char.
N...E encloses a nested name. N3geo4moveE names geo::move; NK3geo5Point4normE includes the const qualification of norm's implicit object. A namespaced variable's type is not encoded, so changing geo::counter from int to long does not necessarily change its symbol.
Template arguments use I...E. biggest<long> includes IlE; T_ refers to its first template parameter. Repeated structures can then use a substitution dictionary.
Reading substitutions without guessing
For geo::dist(const Point&, const Point&), the dictionary grows from smaller components to enclosing types:
_ZN 3geo 4dist E R K N S_ 5Point E S2_ S_ = geo S0_ = geo::Point S1_ = geo::Point const S2_ = geo::Point const&S_ is entry zero, S0_ entry one, and so on. The second complete reference type can reuse S2_. The function's own ordinary name is not simply another arbitrary dictionary entry. Template prefixes are eligible, which explains the dictionary for biggest and add.
These diagnostic test strings make the distinction visible:
_Z7biggestIlET_S0_S_ long biggest<long>(long, biggest)_Z7biggestIlET_S0_S0_ long biggest<long>(long, long)_Z7biggestIlET_S0_S1_ (neither tool decodes this: the dictionary has only two entries)Some substitutions are fixed abbreviations: St for std, Sa for allocator, and Ss for the old standard string type. A new-ABI libstdc++ string uses std::__cxx11::basic_string, so that old shortcut does not describe its full identity.
The platform also matters. A Linux-hosted Clang can emit header-free Mach-O for inspection, where the external spelling adds another underscore. A COFF object for the Microsoft ABI encodes report(int) as ?report@@YAXH@Z, including its return type. These are format/ABI observations, not non-Linux execution steps.
C linkage and return types
A shared C/C++ declaration commonly uses:
#ifdef __cplusplusextern "C" {#endifint area(int w, int h);#ifdef __cplusplus}#endifextern "C" gives the declaration C language linkage, avoiding the C++ name encoding. Two overloads cannot then share the same C symbol.
Ordinary Itanium function names omit return types because C++ does not overload ordinary functions solely by return type. Function types appearing as parameter types still encode their returns, and function templates can require return-type distinctions:
$ nm tget1.o tget2.o | grep gettget1.o:0000000000000000 W _Z3getIiET_itget2.o:0000000000000000 W _Z3getIiEPT_i0000000000000000 u _ZZ3getIiEPT_iE1vHere T_ and PT_ distinguish otherwise similarly named template instantiations. Without that distinction, they could collide as COMDAT identities.
Omitting ordinary return types also leaves a diagnostic hole:
$ nm ratio.o use_ratio.o | grep ratioratio.o:0000000000000000 T _Z5ratioiuse_ratio.o: U _Z5ratioi$ ./ratio_badratio(10) = -1282999152$ objdump -d --no-show-raw-insn ratio.o
ratio.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_Z5ratioi>: 0: endbr64 4: pxor %xmm0,%xmm0 8: cvtsi2sd %edi,%xmm0 c: mulsd 0x0(%rip),%xmm0 # 14 <_Z5ratioi+0x14> 14: retSymbol resolution matches the mangled name, but the two translation units disagree on the return convention. The implementation returns this double in xmm0; the incorrectly declared caller reads an int from eax instead of reading the computed floating-point result. The declarations violate cross-translation-unit type consistency, so an observed integer is not a defined result of the intended program. Resolving the name does not establish a compatible call ABI. A wrong parameter type instead changes the name:
$ ld.lld -static -o ratio_bad2 ratio.o use_ratio2.old.lld: error: undefined symbol: ratio(long)>>> referenced by use_ratio.cpp>>> use_ratio2.o:(main)>>> did you mean: ratio(int)>>> defined in: ratio.oA common header included by both implementation and caller lets the compiler catch these inconsistencies earlier. Mangling carries some type information; it does not turn the linker into a C++ type checker.
Vague linkage is a language-to-object-file contract
The ODR4 permits certain repeated definitions under strict consistency conditions, including matching tokens and name lookup. It does not authorize different implementations that merely happen to share a name. Externally linked inline functions, their static storage, template instantiations, vtables, and typeinfo can need emission in more than one object; the ABI describes this as vague linkage.
ELF5 section groups provide one selection mechanism. Weak binding provides another, related mechanism. Separate them experimentally:
# twice.S (excerpt)#if defined(COMDAT) .section .text._Z5twicei,"axG",@progbits,_Z5twicei,comdat#else .section .text._Z5twicei,"ax",@progbits#endif#if defined(WEAK) .weak _Z5twicei#else .globl _Z5twicei#endif .type _Z5twicei,@function_Z5twicei: leal (%rdi,%rdi), %eax ret======== weak+comdat0000000000000000 W _Z5twiceild.bfd: ok, copies of twice = 1ld.lld: ok, copies of twice = 1======== comdat0000000000000000 T _Z5twiceild.bfd: ok, copies of twice = 1ld.lld: ok, copies of twice = 1======== weak0000000000000000 W _Z5twiceild.bfd: ok, copies of twice = 2ld.lld: ok, copies of twice = 2======== neither0000000000000000 T _Z5twiceild.bfd: t2_neither.o: in function `twice(int)':(.text._Z5twicei+0x0): multiple definition of `twice(int)'; t1_neither.o:(.text._Z5twicei+0x0): first defined hereld.lld: error: duplicate symbol: twice(int)>>> defined at t1_neither.o:(twice(int))>>> defined at t2_neither.o:(.text._Z5twicei+0x0)With strong symbols and no groups, duplicate definitions fail. With COMDAT and strong symbols, discarding the duplicate group removes the second definition before it becomes a conflict; one code copy remains. Weak symbols alone allow resolution, but leave both code sections in the file even though calls select one definition. Weak plus COMDAT both permits resolution and removes duplicate payload.
Section GC can later remove the unused weak-only copy:
======== weak + --gc-sections(_start calls user1 and user2)ld.bfd: 2 copies without GC, 1 with --gc-sectionsld.lld: 2 copies without GC, 1 with --gc-sectionsThe distinctions matter when implementing a linker: selecting a symbol and discarding an associated section group are not interchangeable operations.
A vtable is ordinary data with extraordinary consequences
A simple virtual class generates several linked structures:
// odr_a.cppstruct Greeter { virtual const char *hello() { return "hello (A)"; } virtual const char *bye() { return "bye (A)"; }};const char *call_a(Greeter *g) { return g->hello(); }Greeter *make_a() { return new Greeter; }$ readelf -SW odr_a.o | grep -E '_ZT[VIS]7Greeter' [18] .rodata._ZTV7Greeter PROGBITS 0000000000000000 000140 000020 00 AG 0 0 8 [19] .rela.rodata._ZTV7Greeter RELA 0000000000000000 0005c8 000048 18 IG 28 18 8 [20] .rodata._ZTI7Greeter PROGBITS 0000000000000000 000160 000010 00 AG 0 0 8 [21] .rela.rodata._ZTI7Greeter RELA 0000000000000000 000610 000030 18 IG 28 20 8 [22] .rodata._ZTS7Greeter PROGBITS 0000000000000000 000170 000009 00 AG 0 0 8$ readelf -rW odr_a.o | sed -n '/rela.rodata._ZTV7Greeter/,/^$/p;/rela.rodata._ZTI7Greeter/,/^$/p'Relocation section '.rela.rodata._ZTV7Greeter' at offset 0x5c8 contains 3 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000000008 0000001100000001 R_X86_64_64 0000000000000000 _ZTI7Greeter + 00000000000000010 0000000800000001 R_X86_64_64 0000000000000000 _ZN7Greeter5helloEv + 00000000000000018 0000000900000001 R_X86_64_64 0000000000000000 _ZN7Greeter3byeEv + 0
Relocation section '.rela.rodata._ZTI7Greeter' at offset 0x610 contains 2 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000000000 0000001200000001 R_X86_64_64 0000000000000000 _ZTVN10__cxxabiv117__class_type_infoE + 100000000000000008 0000001300000001 R_X86_64_64 0000000000000000 _ZTS7Greeter + 0$ objdump -s -j .rodata._ZTS7Greeter odr_a.o | tail -1 0000 37477265 65746572 00 7Greeter.$ objdump -dr --no-show-raw-insn -j .text._ZN7GreeterC2Ev odr_a.o | grep -A1 'mov \$0x0,%edx' c: mov $0x0,%edx d: R_X86_64_32 _ZTV7Greeter+0x10The 32-byte table has four eight-byte entries: offset-to-top, typeinfo pointer, and two virtual function pointers. The first is zero for this simple class; the others need relocations. The object's vptr points 16 bytes into the table, at the first function slot. Negative offsets from that address reach typeinfo and offset-to-top.
Typeinfo is itself a C++ object: its first word points into the runtime's __class_type_info vtable, and its second points to the encoded class-name string. RTTI, casts, and exception matching use it. In this explicitly non-PIE fixture the table can be read-only data; PIE construction commonly uses .data.rel.ro so relocations can be applied before RELRO protection.
The following deliberately defines a conflicting same-named class in another translation unit. It violates the ODR; the standard does not require a diagnostic for this cross-translation-unit inconsistency. The resulting output illustrates one build, not a rule for valid C++ programs (C++ draft: basic.def.odr):
// odr_b.cppstruct Greeter { virtual const char *bye() { return "bye (B)"; } virtual const char *hello() { return "hello (B)"; }};const char *call_b(Greeter *g) { return g->hello(); }Greeter *make_b() { return new Greeter; }$ nm -C odr_a.o | grep -v ' U '0000000000000000 T call_a(Greeter*)0000000000000025 T make_a()0000000000000000 W Greeter::bye()0000000000000000 W Greeter::hello()0000000000000000 W Greeter::Greeter()0000000000000000 W Greeter::Greeter()0000000000000000 n Greeter::Greeter()0000000000000000 V typeinfo for Greeter0000000000000000 V typeinfo name for Greeter0000000000000000 V vtable for Greeter$ readelf -gW odr_a.o | grep 'group section'COMDAT group section [ 1] `.group' [_ZN7Greeter5helloEv] contains 2 sections:COMDAT group section [ 2] `.group' [_ZN7Greeter3byeEv] contains 2 sections:COMDAT group section [ 3] `.group' [_ZN7GreeterC5Ev] contains 2 sections:COMDAT group section [ 4] `.group' [_ZTV7Greeter] contains 2 sections:COMDAT group section [ 5] `.group' [_ZTI7Greeter] contains 2 sections:COMDAT group section [ 6] `.group' [_ZTS7Greeter] contains 1 sections:$ objdump -d --no-show-raw-insn odr_b.o | sed -n '/<_Z6call_bP7Greeter>:/,/ret/p' | grep -E '\(%rax\)|add|call'0000000000000000 <_Z6call_bP7Greeter>: 14: mov (%rax),%rax 17: add $0x8,%rax 1b: mov (%rax),%rdx 25: call *%rdx$ g++ -fno-pie -static odr_main.o odr_a.o odr_b.o -o ab$ ./abcall_a(make_a()) = hello (A)call_b(make_b()) = bye (A)$ g++ -fno-pie -static odr_main.o odr_b.o odr_a.o -o ba$ ./bacall_a(make_a()) = bye (B)call_b(make_b()) = hello (B)This is an ODR violation. The ELF linker does not compare class definitions. It selects matching COMDAT identities, including constructor and vtable copies, while each caller retains the slot number compiled from its own definition. With A first, B's call to slot one reaches A's bye; reverse order and A's slot-zero call reaches B's bye.
Constructor variants C1 and C2 can share instructions while retaining separate ABI names. GCC groups them under a synthetic signature such as C5. A group-signature symbol is not necessarily an allocated program object. File-local helper classes should use an anonymous namespace when no cross-file identity is intended; public class definitions must actually satisfy the ODR.
Why an undefined vtable often means a missing function
A key function gives the ABI a place to emit a class's vtable: in this model, the first non-pure virtual function that is not inline at the class definition.
// shape.hstruct Shape { virtual double area() const; // first non-inline, non-pure virtual: the key function virtual const char *name() const { return "shape"; } virtual ~Shape() {}};$ nm -C shape.o | grep -v ' U '0000000000000000 W Shape::~Shape()0000000000000000 W Shape::~Shape()0000000000000000 W Shape::~Shape()0000000000000000 n Shape::~Shape()0000000000000000 T Shape::area() const0000000000000000 W Shape::name() const0000000000000000 V typeinfo for Shape0000000000000000 V typeinfo name for Shape0000000000000000 V vtable for Shape$ nm -C use_shape.o U _Unwind_Resume0000000000000000 W Shape::~Shape()0000000000000000 W Shape::~Shape()0000000000000000 W Shape::~Shape()0000000000000000 n Shape::~Shape() U vtable for Shape U operator delete(void*, unsigned long) U __gxx_personality_v0 U __stack_chk_fail0000000000000000 T main U printf$ ./okshape 0shape.cpp defines area and therefore owns vtable emission. Other users reference it. The table may still be weak/COMDAT for compatibility with established compiler practice, even though a valid program has a unique emitting translation unit.
Leave out that definition or its object file:
$ g++ -fno-pie -static use_shape.o -o bad.../ld: use_shape.o: in function `main':use_shape.cpp:(.text+0x1d): undefined reference to `vtable for Shape'.../ld: use_shape.o: in function `Shape::~Shape()':use_shape.cpp:(.text._ZN5ShapeD2Ev[_ZN5ShapeD5Ev]+0xd): undefined reference to `vtable for Shape'collect2: error: ld returned 1 exit status$ ld.lld -static -o bad use_shape.old.lld: error: undefined symbol: vtable for Shape>>> referenced by use_shape.cpp>>> use_shape.o:(main)...The error names the vtable, not necessarily area, because the caller invokes through a table slot rather than a direct symbol reference. Work backward from vtable ownership: was the key function defined, and did its translation unit reach the link? Typeinfo failures can have the same origin.
Destructors have related variants: D1 destroys a complete object, D2 a base subobject, and D0 additionally deallocates storage. The same source destructor can therefore produce several symbols and grouped sections.
Initialization order is visible in the linked file
Static initialization first supplies constant initialization where possible and zero initialization otherwise. Remaining dynamic initialization requires runtime code. The compiler places pointers to that code in .init_array, which startup invokes before main in these examples.
The following four files form one program. A shared header gives every translation unit the same class definition. base_value is an integer initialized to zero before dynamic initialization; the BaseInitializer constructor later writes 40. Another translation unit computes derived from the value visible at that moment.
// base.h#pragma oncestruct BaseInitializer { BaseInitializer(); };extern int base_value;extern int derived;// base.cpp#include "base.h"#include <cstdio>int base_value;BaseInitializer::BaseInitializer() { std::puts("init base"); base_value = 40;}#ifdef PRIO__attribute__((init_priority(PRIO)))#endifBaseInitializer base_initializer;// derived.cpp#include "base.h"#include <cstdio>static int make_derived() { std::puts("init derived"); return base_value + 2;}int derived = make_derived();// main.cpp#include "base.h"#include <cstdio>int main() { std::printf("base=%d derived=%d\n", base_value, derived);}Compile in the directory containing these files:
for f in base derived main; do g++ -fno-pie -O1 -c "$f.cpp"donebase_value needs no dynamic initialization. The constructor of base_initializer does. The compiler emits _GLOBAL__sub_I_base_value and an eight-byte .init_array entry referring to it. This GNU-tool output belongs to the current build; section numbers and addresses can change with the toolchain.
$ nm base.o0000000000000021 t _GLOBAL__sub_I_base_value0000000000000000 T _ZN15BaseInitializerC1Ev0000000000000000 T _ZN15BaseInitializerC2Ev0000000000000000 B base_initializer0000000000000004 B base_value U puts$ readelf -SW base.o | grep init_array [ 6] .init_array INIT_ARRAY 0000000000000000 000088 000008 08 WA 0 0 8 [ 7] .rela.init_array RELA 0000000000000000 0002f8 000018 18 I 13 6 8$ readelf -rW base.o | grep -A2 'rela.init_array'Relocation section '.rela.init_array' at offset 0x2f8 contains 1 entry: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000000000 0000000200000001 R_X86_64_64 0000000000000000 .text + 21The relocation's r_offset=0 names the first .init_array entry. S+A is the .text section symbol plus 0x21, where the initialization function begins. The input array holds a pointer to be relocated; the output array holds a callable runtime address.
$ g++ -fno-pie -static main.o base.o derived.o -o bd$ ./bdinit baseinit derivedbase=40 derived=42$ g++ -fno-pie -static main.o derived.o base.o -o db$ ./dbinit derivedinit basebase=40 derived=2$ objdump -s -j .init_array bd
bd: file format elf64-x86-64
Contents of section .init_array: 4ac130 70184000 00000000 f9184000 00000000 p.@.......@..... 4ac140 10194000 00000000 40174000 00000000 ..@.....@.@.....$ nm bd | grep -E '_GLOBAL__sub_I|frame_dummy'00000000004018f9 t _GLOBAL__sub_I_base_value0000000000401910 t _GLOBAL__sub_I_derived00000000004ac130 d __frame_dummy_init_array_entry0000000000401870 t frame_dummyDecode each eight-byte entry as little-endian. At 0x4ac130, 70 18 40 00 00 00 00 00 is frame_dummy at 0x401870. The following f9 18 40 00 00 00 00 00 points to 0x4018f9, then the next entry points to 0x401910. This default layout concatenates same-priority .init_array inputs in object order; the static glibc runtime contributes further initialization entries. Using -fuse-ld=lld produced the same 42/2 contrast for the two orders.
The value 2 comes from reading the already zero-initialized integer base_value, without accessing an unconstructed class object. If base_initializer runs first, it writes 40 and derived becomes 42. In the reverse order, derived becomes 2; the later assignment to base_value does not recalculate it.
Static initialization precedes dynamic initialization. The dynamic initialization rules do not establish the required dependency between these ordinary translation units. The observed object-order behavior belongs to this compiler and default link layout; it is not a C++ guarantee about command-line order. This dependency hazard is known as the static initialization order fiasco.
Make the dependency explicit through a function call. This complete replacement program initializes a local static on the first call to base() and returns the same integer thereafter:
// base_fix.h#pragma onceint &base();extern int derived;// base_fix.cpp#include "base_fix.h"#include <cstdio>static int make_base() { std::puts("init base"); return 40;}int &base() { static int value = make_base(); return value;}// derived_fix.cpp#include "base_fix.h"#include <cstdio>static int make_derived() { std::puts("init derived"); return base() + 2;}int derived = make_derived();// main_fix.cpp#include "base_fix.h"#include <cstdio>int main() { std::printf("base=%d derived=%d\n", base(), derived);}for f in base_fix derived_fix main_fix; do g++ -fno-pie -O1 -c "$f.cpp"doneg++ -fno-pie -static main_fix.o derived_fix.o base_fix.o -o fix_dbg++ -fno-pie -static main_fix.o base_fix.o derived_fix.o -o fix_bd./fix_db./fix_bdBoth programs print:
init derivedinit basebase=40 derived=42make_derived cannot finish its calculation until base() returns. Object order no longer carries that dependency. The next section explains the guard mechanism behind one-time local-static initialization.
Explicit priorities are another contract
GCC's init_priority attribute places initialization pointers in numbered sections. The base.cpp above already contains a PRIO-controlled attribute on the class object base_initializer, not on the integer base_value. Defining PRIO=101 enables that declaration; a preprocessor macro alone would not tell the linker anything.
$ g++ -fno-pie -O1 -DPRIO=101 -c base.cpp -o base_p.o$ readelf -SW base_p.o | grep init_array [ 6] .init_array.00101 INIT_ARRAY 0000000000000000 000088 000008 08 WA 0 0 8 [ 7] .rela.init_array.00101 RELA 0000000000000000 000300 000018 18 I 13 6 8$ g++ -fno-pie -static main.o derived.o base_p.o -o db_prio && ./db_prioinit baseinit derivedbase=40 derived=42The GNU default script sorts them:
$ ld.bfd --verbose | grep -A4 '^ .init_array' .init_array : { PROVIDE_HIDDEN (__init_array_start = .); KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*))) KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors))Lower numbers execute first; 0–100 are reserved. Unnumbered entries follow the numbered entries, retaining their applicable input order. GNU also understands the reversed numbering of legacy .ctors.N; LLD implements corresponding init-array ordering directly, with separate handling for legacy constructors.
A custom SECTIONS script assumes responsibility for these rules. A lexical --sort-section=name does not order identical .init_array names into a dependency graph. Priorities are shared program-wide numbers, so independently chosen library priorities can still collide.
The guard behind a local static
A function-local static requiring dynamic initialization, such as static int value = make_base();, needs one successful initialization, including synchronization between competing threads. A constant-initialized local static does not need this runtime path. The ABI supplies an eight-byte guard whose first byte indicates completion:
<special-name> ::= GV <object name> # Guard variable for one-time initializationstatic int compute() { std::puts("compute"); return 42; }
int &config() { static int value = compute(); return value;}
inline int &config_inl() { // inline version: both value and guard need vague linkage static int value = compute(); return value;}int use_inl() { return config_inl(); }$ nm guard.o U _Unwind_Resume0000000000000000 T _Z6configv0000000000000064 T _Z7use_inlv0000000000000000 u _ZGVZ10config_inlvE5value0000000000000000 b _ZGVZ6configvE5value0000000000000000 u _ZZ10config_inlvE5value0000000000000008 b _ZZ6configvE5value U __cxa_guard_abort U __cxa_guard_acquire U __cxa_guard_release U __gxx_personality_v0 U puts$ nm guard.o | awk '{print $NF}' | grep _ZGV | c++filtguard variable for config_inl()::valueguard variable for config()::value$ readelf -gW guard.o | grep -A3 _ZGVCOMDAT group section [ 1] `.group' [_ZGVZ10config_inlvE5value] contains 1 sections: [Index] Name [ 9] .bss._ZGVZ10config_inlvE5value
COMDAT group section [ 2] `.group' [_ZZ10config_inlvE5value] contains 1 sections: [Index] Name$ readelf -sW guard.o | grep -E '_ZGV|_ZZ' 5: 0000000000000000 8 OBJECT LOCAL DEFAULT 6 _ZGVZ6configvE5value 6: 0000000000000008 4 OBJECT LOCAL DEFAULT 6 _ZZ6configvE5value 16: 0000000000000000 8 OBJECT UNIQUE DEFAULT 9 _ZGVZ10config_inlvE5value 17: 0000000000000000 4 OBJECT UNIQUE DEFAULT 10 _ZZ10config_inlvE5valueThe ordinary function has local storage and guard. The inline function can be emitted in multiple objects, so its object and guard also require a shared identity; GCC uses separate GNU-unique COMDAT entities here.
$ objdump -dr --no-show-raw-insn guard.o | sed -n '/<_Z6configv>:/,/^$/p'0000000000000000 <_Z6configv>: 0: endbr64 4: movzbl 0x0(%rip),%eax # b <_Z6configv+0xb> 7: R_X86_64_PC32 .bss-0x4 b: test %al,%al d: je 15 <_Z6configv+0x15> f: mov $0x0,%eax 10: R_X86_64_32 .bss+0x8 14: ret 15: push %rbx 16: mov $0x0,%edi 17: R_X86_64_32 .bss 1b: call 20 <_Z6configv+0x20> 1c: R_X86_64_PLT32 __cxa_guard_acquire-0x4 20: test %eax,%eax 22: jne 2b <_Z6configv+0x2b> 24: mov $0x0,%eax 25: R_X86_64_32 .bss+0x8 29: pop %rbx 2a: ret 2b: mov $0x0,%edi 2c: R_X86_64_32 .rodata.str1.1 30: call 35 <_Z6configv+0x35> 31: R_X86_64_PLT32 puts-0x4 35: movl $0x2a,0x0(%rip) # 3f <_Z6configv+0x3f> 37: R_X86_64_PC32 .bss 3f: mov $0x0,%edi 40: R_X86_64_32 .bss 44: call 49 <_Z6configv+0x49> 45: R_X86_64_PLT32 __cxa_guard_release-0x4 49: jmp 24 <_Z6configv+0x24> 4b: endbr64 4f: mov %rax,%rbx 52: mov $0x0,%edi 53: R_X86_64_32 .bss 57: call 5c <_Z6configv+0x5c> 58: R_X86_64_PLT32 __cxa_guard_abort-0x4 5c: mov %rbx,%rdi 5f: call 64 <_Z7use_inlv> 60: R_X86_64_PLT32 _Unwind_Resume-0x4The fast path checks the completion byte. The slow path calls __cxa_guard_acquire; the winning thread computes and stores the value, then __cxa_guard_release publishes completion and releases waiters. If construction throws, __cxa_guard_abort leaves initialization retryable, and unwinding resumes. Its cleanup path is described through the usual LSDA6 machinery.
Those references pull runtime support from the static library:
$ g++ -fno-pie -static main.o guard.o -o g -Wl,--trace-symbol=__cxa_guard_acquire.../ld: guard.o: reference to __cxa_guard_acquire.../ld: .../libstdc++.a(guard.o): definition of __cxa_guard_acquire$ ./gcomputecompute42 42 42Two calls to config compute once; the separate inline entity computes once more. Disabling thread-safe statics removes the runtime protocol:
$ nm guard_nts.o | grep -E 'cxa|_ZGV'0000000000000000 u _ZGVZ10config_inlvE5value0000000000000000 b _ZGVZ6configvE5value$ objdump -dr --no-show-raw-insn guard_nts.o | sed -n '/<_Z6configv>:/,/^$/p'0000000000000000 <_Z6configv>: 0: endbr64 4: cmpb $0x0,0x0(%rip) # b <_Z6configv+0xb> 6: R_X86_64_PC32 .bss-0x5 b: je 13 <_Z6configv+0x13> d: mov $0x0,%eax e: R_X86_64_32 .bss+0x8 12: ret 13: sub $0x8,%rsp 17: mov $0x0,%edi 18: R_X86_64_32 .rodata.str1.1 1c: call 21 <_Z6configv+0x21> 1d: R_X86_64_PLT32 puts-0x4 21: movl $0x2a,0x0(%rip) # 2b <_Z6configv+0x2b> 23: R_X86_64_PC32 .bss 2b: movb $0x1,0x0(%rip) # 32 <_Z6configv+0x32> 2d: R_X86_64_PC32 .bss-0x5 32: mov $0x0,%eax 33: R_X86_64_32 .bss+0x8 37: add $0x8,%rsp 3b: retThe guard remains, but code simply tests and sets its byte. Mixing such objects with thread-safe ones does not preserve a program-wide synchronization guarantee. The ABI layout alone is not the synchronization algorithm.
Rust moves the compilation boundary to the crate
Rust can compile many source modules together as one crate. It also carries dependency metadata forward, allowing downstream compilation to instantiate generics or inline bodies without C++-style source headers.
The recorded Rust version is 1.93.1. Commands state their edition, target, and required mangling options explicitly; the legacy comparison does not request v0. The musl examples examine native Linux startup and static runtime arrangements. Before reading a generated linker command, distinguish rustc's target options from arguments it later passes to a C driver or linker. These observations are pinned to the stated compiler and target.
What an rlib contains
An rlib is an intermediate Rust library: rustc reads its metadata during downstream compilation, while the final linker consumes the required native-object members of its archive. Metadata describes types and generic bodies; it is not a runtime table that the ELF loader interprets.
This section needs the Rust standard library for the musl target; installing musl-gcc alone is insufficient. Check compilation first. If std is missing, use a rustup-managed matching toolchain and install its target component (rustup target components):
rustc --edition 2024 --target x86_64-unknown-linux-musl --crate-type rlib --emit metadata -o /dev/null - <<'RS'pub fn target_check() {}RS# Only when this component is missing and rustup manages the current rustc:rustup target add --toolchain 1.93.1 x86_64-unknown-linux-muslA distribution's system rustc may not be managed by rustup. Installing a component for another compiler does not repair that system compiler. Pass the compilation check before generating the archive below. The optional x86_64-unknown-none comparison requires its own target component; it is not a prerequisite for the main path.
// geom.rs: a library cratepub struct Point { pub x: i64, pub y: i64 }impl Point { pub fn norm2(&self) -> i64 { self.x * self.x + self.y * self.y }}pub fn biggest<T: PartialOrd>(a: T, b: T) -> T { if a > b { a } else { b } }#[inline(never)]pub fn area(w: i64, h: i64) -> i64 { w * h }$ rustc --edition 2024 --target x86_64-unknown-linux-musl -C symbol-mangling-version=v0 --crate-type rlib -O geom.rs$ mkdir -p x; (cd x && ar x ../libgeom.rlib)$ ar tv libgeom.rlibrw-r--r-- 0/0 4272 Jan 1 00:00 1970 lib.rmetarw-r--r-- 0/0 3488 Jan 1 00:00 1970 geom.geom.6df4528c38c6272b-cgu.0.rcgu.o$ file x/*x/geom.geom.6df4528c38c6272b-cgu.0.rcgu.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not strippedx/lib.rmeta: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped$ nm x/*.rcgu.o0000000000000000 T _RNvCs9rhE2iFS63T_4geom4area$ readelf -SW x/lib.rmeta | grep '\.rmeta' [ 2] .rmeta PROGBITS 0000000000000000 000060 000e7c 00 E 0 0 1An rlib is an ar archive that the compiler, not just the final linker, understands. Here it contains an ELF metadata member and a code-generation-unit object. The metadata describes types, signatures, generics, dependencies, and paths. Deterministic archive timestamps do not erase embedded source paths: moving this source from rp/a/ to rp/longer-directory-name/ changed metadata size from 4,280 to 4,296 bytes. --remap-path-prefix made the compared rlibs byte-identical.
This version's lib.rmeta defines no ordinary symbols that a function reference would extract. Its .rmeta is SHF_EXCLUDE, also relevant under whole-archive loading. An rlib's precise members are a compiler-internal arrangement, not a stable external format promise.
Only area appears as generated code here. A small public norm2 body is available for downstream inlining; the generic biggest has no concrete type yet and remains compiler metadata. A crate can also split into multiple codegen units, each producing a .rcgu.o; that partition does not map one-to-one onto source files.
Legacy names and v0 names
$ rustc --edition 2024 --target x86_64-unknown-linux-musl --crate-type rlib -O -C symbol-mangling-version=v0 geom.rs$ nm libgeom.rlib 2>/dev/null | grep ' T '0000000000000000 T _RNvCs9rhE2iFS63T_4geom4area$ rustc --edition 2024 --target x86_64-unknown-linux-musl --crate-type rlib -O geom.rs -o libgeom_legacy.rlib$ nm libgeom_legacy.rlib 2>/dev/null | grep ' T '0000000000000000 T _ZN4geom4area17h76b2d1f845cbc28eELegacy Rust names reuse a nested Itanium-like path and append a hash component. The hash distinguishes entities but hides generic type arguments from a demangler. v0 uses a distinct _R grammar:
_R v0 prefixN v nested path; v is the value namespace (functions, statics) C s 9rhE2iFS63T_ crate root; s<base62>_ encodes its disambiguator 4geom crate name 4area function nameThe crate disambiguator identifies the crate instance. Generic arguments are encoded explicitly, and an instantiation can also name the crate generating it. Builtin codes differ from C++'s grammar: v0 l is i32, x is i64, and j is usize. A fixed assert_failed test vector can be decoded structurally without claiming that exact spelling is exported by this machine's standard library.
$ for s in _ZN4geom4area17h76b2d1f845cbc28eE _RNvCs9rhE2iFS63T_4geom4area; do echo "== $s"; echo "$s" | llvm-cxxfilt; echo "$s" | c++filt; done== _ZN4geom4area17h76b2d1f845cbc28eEgeom::area::h76b2d1f845cbc28egeom::area::h76b2d1f845cbc28e== _RNvCs9rhE2iFS63T_4geom4areageom::areageom[6df4528c38c6272b]::areaBoth installed demanglers understand these inputs, but choose different formatting for the disambiguator. The v0 RFC explicitly does not establish a stable Rust ABI.
What happens when a downstream crate uses the generic body?
$ rustc --edition 2024 --target x86_64-unknown-linux-musl -C symbol-mangling-version=v0 -O --extern geom=libgeom.rlib app.rs --emit obj -o app.o -C codegen-units=1$ nm app.o | grep -v ' U '0000000000000000 r .Lanon.f2ce8d21ce0ff0a75c5b48f8ce03a450.10000000000000000 V DW.ref.rust_eh_personality0000000000000000 r GCC_except_table50000000000000000 t _RINvNtCs6nLojwqrxvg_4core3ptr13drop_in_placeNtNtCsjVrS1ivfjpm_3std3env4ArgsECs341ZQ2MzMSq_3app0000000000000000 T _RINvNtCsjVrS1ivfjpm_3std2rt10lang_startuECs341ZQ2MzMSq_3app0000000000000000 t _RINvNtNtCsjVrS1ivfjpm_3std3sys9backtrace28___rust_begin_short_backtraceFEuuECs341ZQ2MzMSq_3app0000000000000000 t _RNCINvNtCsjVrS1ivfjpm_3std2rt10lang_startuE0Cs341ZQ2MzMSq_3app0000000000000000 t _RNSNvYNCINvNtCsjVrS1ivfjpm_3std2rt10lang_startuE0INtNtNtCs6nLojwqrxvg_4core3ops8function6FnOnceuE9call_once6vtableCs341ZQ2MzMSq_3app0000000000000000 T _RNvCs341ZQ2MzMSq_3app4main0000000000000000 T main$ readelf -gW app.o | grep 'group section'COMDAT group section [ 20] `.group' [DW.ref.rust_eh_personality] contains 2 sections:At this optimization level, biggest and norm2 inline. Other generated generic instances are local symbols belonging to the instantiating crate, so there is no cross-crate global-name collision for the linker to merge. The observed COMDAT group is instead the indirect reference to rust_eh_personality.
Compiler metadata can enable sharing too:
== opt-level=00000000000000000 T _RINvCs9rhE2iFS63T_4geom7biggestxEB2_ (libgeom0.rlib) U _RINvCs9rhE2iFS63T_4geom7biggestxEB2_ (app0.o)== opt-level=2(neither output has a biggest symbol)In the recorded compiler, generic-sharing defaults differ by optimization level. At level zero, an upstream global instance can satisfy a downstream undefined reference. At level two, the illustrated uses inline away. In the v0 suffix B2_, the encoded backreference selects the crate path using the grammar's offset/count conventions; it is not a literal C++ substitution index. The compiler coordinates which instance exists, leaving ordinary symbol resolution to the linker.
Name, calling convention, retention, and placement are separate
The language exposes four separate controls. Name spelling determines which linker symbol is found; calling convention determines how arguments and results cross that symbol's boundary. #[used] guarantees that a static reaches the object file, while final retention is a separate linking decision. #[unsafe(link_section = ...)] chooses an input section, not its final address.
#[unsafe(no_mangle)] controls the external spelling. extern "C" selects the calling convention. One without the other does not supply a normal C interface: the sample includes both a C-convention mangled function and an unmangled Rust-convention function to expose the distinction. Rust 2024 requires the unsafe-attribute spelling because the author must account for global symbol and placement obligations.
The Rust language reference permits a linker to remove an object retained by #[used]. In ELF, a root, a live reference, or a supported retention flag can prevent section GC. SHF_GNU_RETAIN is such a linker-level flag; the language attribute and that ELF flag express different contracts. Archive extraction is another decision: retention inside an unextracted member does not itself resolve an undefined symbol.
#[unsafe(link_section = ...)] selects an input section; output placement still belongs to the linker. The example's eight bytes in .note.mytag illustrate placement, not a valid structured ELF note. A real note needs the appropriate header, owner, descriptor, and alignment. Registration tables should use suitable data sections and separately address compiler retention, archive extraction, and section GC7.
#![crate_type = "rlib"]
#[unsafe(no_mangle)]pub extern "C" fn rs_area(w: i64, h: i64) -> i64 { w * h }
#[unsafe(no_mangle)]pub fn rs_plain(x: i64) -> i64 { x + 1 } // unmangled, but still uses the Rust calling convention
#[inline(never)]pub extern "C" fn rs_c_mangled(x: i64) -> i64 { x + 2 } // C calling convention, still mangled
fn helper(x: i64) -> i64 { x * 3 } // private function
#[used]static KEEP_ME: [u8; 4] = *b"keep"; // retain in the object even without references
static DROP_ME: [u8; 4] = *b"drop"; // unreferenced; removed by the compiler
#[unsafe(link_section = ".note.mytag")]#[used]static TAG: [u8; 8] = *b"tag-v1\0\0";
#[inline(never)]pub fn uses_helper(x: i64) -> i64 { helper(x) }$ rustc --edition 2024 --target x86_64-unknown-linux-musl -C symbol-mangling-version=v0 -O -C codegen-units=1 attrs.rs --emit obj -o attrs.owarning: static `DROP_ME` is never used$ nm attrs.o0000000000000000 T _RNvCsa6tzDnAu7nc_5attrs11uses_helper0000000000000000 T _RNvCsa6tzDnAu7nc_5attrs12rs_c_mangled0000000000000000 R _RNvCsa6tzDnAu7nc_5attrs3TAG0000000000000000 R _RNvCsa6tzDnAu7nc_5attrs7KEEP_ME0000000000000000 T rs_area0000000000000000 T rs_plain$ readelf -SW attrs.o | grep -E 'rs_|note.mytag|KEEP' [ 4] .text._RNvCsa6tzDnAu7nc_5attrs12rs_c_mangled PROGBITS 0000000000000000 000050 000005 00 AX 0 0 16 [ 5] .text.rs_area PROGBITS 0000000000000000 000060 000008 00 AX 0 0 16 [ 6] .text.rs_plain PROGBITS 0000000000000000 000070 000005 00 AX 0 0 16 [ 7] .note.mytag NOTE 0000000000000000 000075 000008 00 AR 0 0 1 [ 8] .rodata._RNvCsa6tzDnAu7nc_5attrs7KEEP_ME PROGBITS 0000000000000000 00007d 000004 00 AR 0 0 1The listing below shows that the generated KEEP_ME section carries AR: allocated storage plus GNU retention. Once that section is included in the link, the retain flag prevents GC. The retention rule explains the result; it does not make every #[used] implementation produce the same ELF flags.
Reading rustc's real linker command
A compiler driver and a linker executable do not accept the same option syntax. A driver such as cc uses -Wl, to forward comma-separated arguments: cc -Wl,-z,now ultimately passes -z and now as two arguments to the linker. A directly invoked ld-style executable expects those arguments themselves. Changing only the executable path while retaining driver syntax can make a valid link request unparseable.
rustc’s -C linker=PATH selects the executable; -C linker-flavor=ld selects the GNU ld-style protocol. Objects, archives, search directories, entry and output names describe a job that still passes through input reading, symbol resolution, layout, and relocation. Replacing the linker neither reimplements the Rust compiler nor permits silently ignoring the effects of arbitrary options.
For long invocations, a caller can encode arguments in a response file and pass @FILE. The receiver tokenizes quotes, whitespace, and escapes; the file is not executed by a shell. Expansion produces an argument sequence. Linux filenames may contain non-UTF-8 bytes, which argument and path handling must preserve. These interface concerns occur at the linker’s boundary, while generic instantiation from rlib metadata remains compiler work.
The musl target can use cc as a driver while supplying its own startup objects and libraries:
$ rustc --edition 2024 --target x86_64-unknown-linux-musl -C symbol-mangling-version=v0 -O hello.rs -o hello_cc$ file hello_cchello_cc: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, BuildID[sha1]=3f4f083719c9b8c0305bdd81179bde4f3686b5ce, with debug_info, not stripped$ ./hello_cchello from rust--print link-args makes the contract inspectable. Here the driver is explicitly musl-gcc, and the sysroot path is abbreviated:
$ rustc --edition 2024 --target x86_64-unknown-linux-musl -C symbol-mangling-version=v0 -O -C linker=musl-gcc -C link-arg=-Wl,-Map=hello.map --print link-args hello.rs -o hello"musl-gcc""-m64""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/rcrt1.o""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crti.o""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crtbeginS.o""<tmp>/symbols.o""hello.hello.9147ff074b2eabb7-cgu.0.rcgu.o""hello.ad2xtbupsynivepn4m1xa2e1j.rcgu.o""-Wl,--as-needed""-Wl,-Bstatic""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libstd-634518e4795a2324.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libpanic_unwind-11507e695b2b843d.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libobject-dca24d1f0faddef7.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libmemchr-9a53f1578a8f71d5.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libaddr2line-2c123bd1b498fe61.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libgimli-0238f1f66963a3ed.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libcfg_if-5aab300aa628865b.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_demangle-724073041b57a6c5.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libstd_detect-075b671e7641a49f.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libhashbrown-5707c7f16b3346e3.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_std_workspace_alloc-9e38103f4bd196fd.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libminiz_oxide-a8a8a653e72107be.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libadler2-0b03fc5f4ee238a5.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libunwind-47b4e79fe3947812.rlib""-lunwind""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/liblibc-320a9eda352b7dd4.rlib""-lc""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_std_workspace_core-7f77c942e80b7d20.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/liballoc-9ca90d62630d0c82.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libcore-93f650a07d7b3333.rlib""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libcompiler_builtins-cedbb9d1f1211435.rlib""-L""<tmp>/raw-dylibs""-Wl,-Bdynamic""-Wl,--eh-frame-hdr""-Wl,-z,noexecstack""-nostartfiles""-L""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained""-L""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib""-o""hello""-Wl,--gc-sections""-static-pie""-Wl,-z,relro,-z,now""-Wl,-O1""-nodefaultlibs""-Wl,-Map=hello.map""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crtendS.o""$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crtn.o"hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, with debug_info, not strippedhello from rustThe rcrt1.o startup path performs static-PIE self-relocation. crti.o/crtn.o and crtbeginS.o/crtendS.o supply other runtime boundaries. Because these come from Rust's self-contained runtime, rustc passes -nostartfiles and -nodefaultlibs to stop the C driver adding another set. -C link-self-contained=no changes that responsibility.
The temporary symbols.o introduces symbol references needed for the intended library extraction. It is not a declaration that every associated section becomes a GC root. A printed link command establishes that it exists on that invocation; saved codegen outputs need not leave a conveniently named copy in the current directory.
-C linker chooses a program; -C linker-flavor tells rustc which option syntax that program expects. Driver arguments such as -Wl,-z,now differ from direct-linker arguments such as -z now. Other targets use other shapes, including direct rust-lld invocation for a bare-metal target. Always inspect the target's actual command before substituting a linker.
Dependency ordering and bundled native code
// deps/geom.rs#[link(name = "cfoo", kind = "static")]unsafe extern "C" { fn cfoo_twice(x: i64) -> i64; }#[link(name = "m")]unsafe extern "C" { fn cbrt(x: f64) -> f64; }#[inline(never)]pub fn twice_cube_root(x: f64) -> i64 { unsafe { cfoo_twice(cbrt(x) as i64) } }$ ar t libgeom.rlib; ar t libutil.rliblib.rmetageom.geom.6df4528c38c6272b-cgu.0.rcgu.ocfoo.olib.rmetautil.util.62cc8149a2355e59-cgu.0.rcgu.o7:"app.app.23b34b41e532b418-cgu.0.rcgu.o"8:"app.41x6plzhbt3n17pvcfz0zcwp0.rcgu.o"9:"-Wl,--as-needed"10:"-Wl,-Bstatic"11:"libutil.rlib"12:"./libgeom.rlib"13:"-lm"14:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libstd-634518e4795a2324.rlib"15:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libpanic_unwind-11507e695b2b843d.rlib"16:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libobject-dca24d1f0faddef7.rlib"17:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libmemchr-9a53f1578a8f71d5.rlib"18:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libaddr2line-2c123bd1b498fe61.rlib"19:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libgimli-0238f1f66963a3ed.rlib"20:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libcfg_if-5aab300aa628865b.rlib"21:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_demangle-724073041b57a6c5.rlib"22:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libstd_detect-075b671e7641a49f.rlib"23:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libhashbrown-5707c7f16b3346e3.rlib"24:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_std_workspace_alloc-9e38103f4bd196fd.rlib"25:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libminiz_oxide-a8a8a653e72107be.rlib"26:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libadler2-0b03fc5f4ee238a5.rlib"27:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libunwind-47b4e79fe3947812.rlib"28:"-lunwind"29:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/liblibc-320a9eda352b7dd4.rlib"30:"-lc"31:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_std_workspace_core-7f77c942e80b7d20.rlib"32:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/liballoc-9ca90d62630d0c82.rlib"33:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libcore-93f650a07d7b3333.rlib"34:"$SYSROOT/lib/rustlib/x86_64-unknown-linux-musl/lib/libcompiler_builtins-cedbb9d1f1211435.rlib"37:"-Wl,-Bdynamic"7util precedes its dependency geom; downstream runtime crates follow in dependency order. This serves traditional left-to-right archive extraction. The native static libcfoo.a has been bundled into libgeom.rlib, so its member appears there rather than as a separate command-line archive. The ordinary native -lm remains a link option.
An rlib does not simply contain every upstream crate. Rust's compilation/linkage rules coordinate dependency instances to avoid including conflicting copies in one artifact. The linker sees the resulting ordered inputs; rustc knows the crate graph that produced them.
Panic changes cleanup requirements
Unwinding executes cleanup such as Drop while walking frames. It uses .eh_frame, language-specific exception tables, and rust_eh_personality. Abort ends the process without that walk. Compile a fixture that builds a Vec<String> under both strategies:
== work_unwind.otext 1805, .eh_frame 576, .gcc_except_table 60(2 drop_in_place symbols)== work_abort.otext 1387, .eh_frame 360, .gcc_except_table 0(0 drop_in_place symbols)The abort object loses 418 code bytes and its LSDA entirely. It still has 360 bytes of frame information useful for backtraces. “No panic unwinding” does not imply “no unwind descriptions.”
The complete program shows a smaller difference:
$ size hello_unwind_s hello_abort_s text data bss dec hex filename 403371 36800 6256 446427 6cfdb hello_unwind_s 400267 36408 6256 442931 6c233 hello_abort_s$ for f in hello_unwind_s hello_abort_s; do readelf -SW $f | grep -E 'eh_frame|gcc_except'; done [11] .eh_frame_hdr PROGBITS 0000000000061178 061178 001264 00 A 0 0 4 [12] .gcc_except_table PROGBITS 00000000000623dc 0623dc 0010b4 00 A 0 0 4 [13] .eh_frame PROGBITS 0000000000064b10 063b10 005eb4 00 WA 0 0 8 [11] .eh_frame_hdr PROGBITS 000000000005fe58 05fe58 00122c 00 A 0 0 4 [12] .gcc_except_table PROGBITS 0000000000061084 061084 00104c 00 A 0 0 4 [13] .eh_frame PROGBITS 0000000000063c98 062c98 005d2c 00 WA 0 0 8The stripped size totals differ by 3,496 bytes, less than 1%; these totals are not file lengths. The abort executable still contains 4,172 bytes of exception tables because its precompiled standard-library dependency remains the same unwind-built rlib. The option changes the current crate and selected panic runtime; it cannot retroactively recompile every dependency. Rebuilding the standard library under another strategy is a separate operation.
Compatible panic strategy is also a linkage constraint. Combinations that may unwind through code built without the required support can be invalid. rustc checks supported combinations it assembles; independently loaded components require the same care at their boundary.
A Rust program without std or a C runtime
no_std removes the standard library, not all compiler support. core and compiler_builtins remain available; no_main avoids the usual generated entry path. Supply a panic handler and a raw entry that uses Linux syscalls:
#![no_std]#![no_main]
use core::arch::asm;use core::panic::PanicInfo;
fn write(fd: usize, buf: &[u8]) -> isize { let ret: isize; unsafe { asm!("syscall", inlateout("rax") 1isize => ret, in("rdi") fd, in("rsi") buf.as_ptr(), in("rdx") buf.len(), lateout("rcx") _, lateout("r11") _, options(nostack)); } ret}
fn exit(code: i32) -> ! { unsafe { asm!("syscall", in("rax") 60, in("rdi") code, options(noreturn, nostack)) }}
#[panic_handler]fn panic(_info: &PanicInfo) -> ! { exit(101) }
// Linux enters this symbol by a jump, not a C ABI call. Establish a regular// function-call frame before entering compiler-generated Rust code.#[unsafe(naked)]#[unsafe(no_mangle)]pub extern "C" fn _start() -> ! { core::arch::naked_asm!( "and rsp, -16", "call {entry}", "ud2", entry = sym rust_entry, );}
#[unsafe(no_mangle)]extern "C" fn rust_entry() -> ! { write(1, b"hello from no_std\n"); exit(42)}The loader jumps to an ELF entry without pushing a return address. The naked _start shim therefore aligns the stack and uses call to enter rust_entry with the ordinary C calling convention. The Rust function terminates rather than returning to the shim.
Use the system Rust target on native x86-64 Linux, invoke an ld-style linker directly, and explicitly select fixed-address static output. Omitting std, supplying an entry, and omitting C startup files are distinct decisions. no_std does not automatically supply an operating-system entry, libc initialization, or PIE self-relocation.
rustc --edition 2024 -C panic=abort -C opt-level=2 \ -C linker-flavor=ld -C link-arg=-static -C relocation-model=static \ tiny.rs -o tiny-staticreadelf -h tiny-staticreadelf -rW tiny-static./tiny-staticThis output is ET_EXEC, enters at 0x401000, and has no remaining relocations. It prints hello from no_std and exits 42. The linker has resolved this input’s addresses under a fixed layout; the entry shim establishes the function-call convention. Exact addresses belong to the fixture. Correctness depends on satisfying its declared loading model and references.
PIE address fixups still need an executor
RIP-relative instructions can use the runtime instruction pointer, but static pointer tables store address values. tiny_tbl.rs reads write from such a table through read_volatile, preventing the compiler from eliminating the table access. Produce a PIE without an interpreter:
rustc --edition 2024 -C panic=abort -C opt-level=2 -C linker-flavor=ld \ -C link-arg=-static -C link-arg=-pie -C link-arg=--no-dynamic-linker \ tiny_tbl.rs -o tiny-table-piereadelf -rW tiny-table-pieThe remaining R_X86_64_RELATIVE has field RVA 0x3ed0 and addend 0x1010. Its file slot already contains 0x1010, but the required runtime value is B + 0x1010, where B is the load bias. The Linux kernel maps ELF segments without applying this program’s dynamic relocations. There is no interpreter or self-relocation startup, so the indirect call uses an unadjusted address. This run terminates by signal; the shell reports status 139. Whether a slot initially holds zero or the addend depends on its encoding; the missing load-bias adjustment is the governing failure.
The two inputs expose distinct acceptance levels. A working syscall-only program establishes its entry and references; a pointer-bearing program additionally requires relocation processing by a loader or startup. A reliable static PIE must supply that executor. One run with no dynamic relocations cannot validate it, and the linker must not silently turn requested PIE into ET_EXEC. panic=abort changes panic policy, not these startup responsibilities.
What supporting a language actually means
Even a minimal Rust program requires archive selection, metadata handling, generated undefined references, relocations, and a valid entry convention. The compiler_builtins archive distributes low-level helpers across members so that the linker can extract members satisfying unresolved symbol demand. Member counts vary with the toolchain and target configuration; they do not define correct archive behavior. Undefined references in the compiler-generated symbols.o participate in selection, but an extracted section does not thereby become a garbage-collection root.
A standard-library program adds initialization arrays, static-PIE startup, COMDAT, unwind records, and TLS8 templates and relocations. Accepting a command-line option while ignoring its semantics is not support. A successful println! proves one path; panic cleanup, thread-local state, and initialization need their own tests.
The next chapter compares how other object formats express these language requirements.
Exercises
Build the key-function object before inspecting the virtual table. The Rust examples read the supplied source and native toolchain directly.
-
Inspect generated structures. For
shape.o, determine vtable size and slots 0–3 relative to the vptr. Why are D1 and D0 in the table, but not D2? Do D1 and D2 share code? Then inspect the native toolchain’sstd,core, andcompiler_builtinsrlibs. Find__udivti3and its binding. Search for__popcountdi2; if absent, explain why absence alone does not establish a damaged archive. How does member granularity affect extraction? -
Decode names manually. Decode
_ZN5shape6circle6resizeERKS0_d, writing its substitution dictionary first. Encodevoid geo::scale(geo::Point *, const geo::Point *, double). Finally decode_RNvNtCs1234_7mycrate3foo3bar: what does the extraNtrepresent? -
Break generated ownership. Add
virtual void draw() const;beforearea, define nodraw, and leave the rest of the key-function fixture unchanged. Why can an uncalled method break linking? Then removeno_manglefrom the minimal Rust_start. Inspect the entry field and actually run the output rather than inferring success from a quiet compiler.
Answers
1. Vtable slots and archive granularity
[16] .rodata._ZTV5Shape PROGBITS 0000000000000000 0000f8 000030 00 AG 0 0 8 [17] .rela.rodata._ZTV5Shape RELA 0000000000000000 0004e8 000078 18 IG 26 16 8Relocation section '.rela.rodata._ZTV5Shape' at offset 0x4e8 contains 5 entries: Offset Info Type Symbol's Value Symbol's Name + Addend0000000000000008 0000000f00000001 R_X86_64_64 0000000000000000 _ZTI5Shape + 00000000000000010 0000000e00000001 R_X86_64_64 0000000000000000 _ZNK5Shape4areaEv + 00000000000000018 0000000800000001 R_X86_64_64 0000000000000000 _ZNK5Shape4nameEv + 00000000000000020 0000000b00000001 R_X86_64_64 0000000000000000 _ZN5ShapeD1Ev + 00000000000000028 0000000c00000001 R_X86_64_64 0000000000000000 _ZN5ShapeD0Ev + 0
0000000000000000 W _ZN5ShapeD0Ev0000000000000000 W _ZN5ShapeD1Ev0000000000000000 W _ZN5ShapeD2Ev0000000000000000 n _ZN5ShapeD5EvCOMDAT group section [ 2] `.group' [_ZN5ShapeD5Ev] contains 4 sections: [Index] Name [ 12] .text._ZN5ShapeD2Ev [ 13] .rela.text._ZN5ShapeD2Ev [ 14] .text._ZN5ShapeD0Ev [ 15] .rela.text._ZN5ShapeD0EvThe vtable is 48 bytes: offset-to-top, typeinfo, then area, name, D1, and D0. D2 is called directly when a derived destructor destroys its known base subobject, so it needs no virtual slot. For this class without virtual bases, D1 and D2 share an address and one code section. D0 has additional deallocation work; the variants share a destructor COMDAT group.
Member counts reflect the toolchain build rather than a Rust or ELF requirement. One native GNU-target toolchain reports:
libstd: 17 members, of which .rcgu.o: 16libcore: 17 members, of which .rcgu.o: 16libcompiler_builtins: 265 members, of which .rcgu.o: 264__udivti3: Wlib.rmeta carries compiler metadata; .rcgu.o members carry codegen-unit object code. Archive selection extracts whole members. Finer members can reduce unrelated code pulled in for a helper; section GC subsequently removes unreachable sections. These are distinct stages.
Builtins supply helpers for operations such as 128-bit unsigned division. The observed __udivti3 is weak, allowing a compatible strong definition to prevail. This archive does not define __popcountdi2. Target instructions, compiler lowering, and runtime configuration determine whether an operation needs a separate helper. Diagnose missing support from actual unresolved references rather than requiring every toolchain to share a member list.
2. Two grammars, two dictionaries
For the C++ name, S_ is shape; S0_ is shape::circle. The parameters are R K S0_ and d:
shape::circle::resize(shape::circle const&, double)The spelling alone does not distinguish a non-const class member from a function in a similarly named namespace. The fixture makes it a member.
For geo::scale, the dictionary begins with geo, geo::Point, and pointer-to-Point; the const-qualified second pointer reuses the Point entry:
0000000000000000 T _ZN3geo5scaleEPNS_5PointEPKS0_dRust v0's Nv names a value in a path; Nt introduces a type-namespace path component, which can be a module. The result is mycrate::foo::bar. GNU displays crate disambiguator 0x3c1c0: the raw base-62 value of 1234 is 246,206, then the number encoding and explicit disambiguator convention each add one, yielding 246,208. LLVM's display omits that detail.
3. Missing ownership and missing entry identity
0000000000000000 T Shape::area() const 2 undefined reference to `vtable for Shape'draw becomes the first eligible key function. Since no translation unit defines it, none owns vtable emission. Defining area no longer suffices. Object construction still needs the vtable even if no code directly calls draw.
0000000000000012 T Shape::draw() const0000000000000000 V vtable for Shape U vtable for __cxxabiv1::__class_type_infoshape 0Defining draw repairs emission. Making it pure virtual changes the class contract and makes this direct Shape instantiation invalid; that is not a drop-in repair.
Remove only the no_mangle attribute on _start, retaining the attribute on rust_entry. The source-level name no longer supplies the linker’s required _start spelling. Without an entry root, section GC can also discard the related code. Successful compilation alone does not establish a valid executable entry.
With the native fixed-address command, the relevant results are:
U _start Entry point address: 0x0exit=139U denotes an undefined symbol. This entry does not point to executable code, and the process terminates with SIGSEGV. Missing-entry diagnostics and fallback addresses depend on linker options and available sections; zero is not a universal outcome. Check whether the requested entry symbol is defined, whether e_entry lies in an executable mapping, and whether the entry establishes the required startup convention. The script disables core files and bounds execution with a timeout.
Appendix: terms and tools
-
COMDAT identifies duplicate definition groups from which the linker may retain one copy. ELF expresses this with section groups and signatures; related group members must be selected consistently. ELF section groups. ↩
-
Clang provides C-family language frontends and a compiler driver within the LLVM project. It commonly uses an integrated assembler; linker selection still depends on the target and configuration. Clang toolchain documentation. ↩
-
ABI, Application Binary Interface, specifies how compiled components cooperate, including calling conventions, data layout, and object-format rules. It governs the machine-level boundary rather than only the source API. System V ABI. ↩
-
ODR — The One Definition Rule specifies when C++ entities may have definitions in several translation units and what consistency those definitions require. C++ draft. ↩
-
ELF, the Executable and Linkable Format, specifies object files, executables, and shared objects. The gABI supplies generic rules; a processor-specific ABI supplies architecture-dependent rules such as relocation encodings. ELF specification. ↩
-
LSDA, Language-Specific Data Area, carries exception-handling information such as protected regions and actions. The generic unwinder and language personality cooperate to use it. Exception-handling ABI. ↩
-
Section GC, section garbage collection, retains sections reachable from the entry and other roots and discards unused sections during linking. It is distinct from runtime heap garbage collection. GNU ld options. ↩
-
TLS, Thread-Local Storage, gives each thread its own instance of a variable. The linker describes an initialization template and processes access models; the runtime establishes per-thread instances. This is unrelated to Transport Layer Security. ELF TLS design. ↩