The World of Linkers/ Theory/ 17 articles
43 min readPublic

[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 requirementCompiler representationLinking and runtime responsibilities
One inline function or template instance appears in several filesMangled names, bindings, COMDAT groupsThe linker preserves one entity identity and redirects references
Virtual calls and runtime type identificationVtables, typeinfo, pointer relocationsThe linker places data and patches pointers; generated code and runtime routines use them
Initialization, local statics, and panicInitialization arrays, guards, helper references, unwind informationThe 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 files
inline 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.o
0000000000000000 T _Z2fai
0000000000000000 W _Z3addIiET_S0_S0_
0000000000000000 W _Z5twicei
0000000000000000 W _Z7next_idv
0000000000000000 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 _Z5twicei
0000000000401891 W _Z7next_idv
00000000004b1a90 u _ZZ7next_idvE1n
$ ./ab
fa=5 fb=7 next_id=3

The 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.o
0000000000000000 T _Z2fai
0000000000000000 W _Z3addIiET_S0_S0_
0000000000000000 W _Z5twicei
0000000000000000 W _Z7next_idv
0000000000000000 V _ZZ7next_idvE1n
$ readelf -sW a.o | grep _ZZ
7: 0000000000000000 4 OBJECT UNIQUE DEFAULT 10 _ZZ7next_idvE1n

GCC'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 _Z6reportPKc
0000000000000088 T _Z6reportRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
0000000000000069 T _Z6reportd
000000000000005b T _Z6reporti
0000000000000000 W _Z7biggestIdET_S0_S0_
0000000000000000 W _Z7biggestIlET_S0_S0_
0000000000000032 T _ZN3geo4distERKNS_5PointES2_
0000000000000048 T _ZN3geo4moveERNS_5PointERKS0_
0000000000000004 B _ZN3geo7counterE
0000000000000000 T _ZNK3geo5Point4normEv
0000000000000000 B counter
0000000000000097 T report_c
$ nm names.o | grep -v ' U ' | awk '{print $3}' | c++filt
report(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::counter
geo::Point::norm() const
counter
report_c

6report 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 __cplusplus
extern "C" {
#endif
int area(int w, int h);
#ifdef __cplusplus
}
#endif

extern "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 get
tget1.o:
0000000000000000 W _Z3getIiET_i
tget2.o:
0000000000000000 W _Z3getIiEPT_i
0000000000000000 u _ZZ3getIiEPT_iE1v

Here 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 ratio
ratio.o:
0000000000000000 T _Z5ratioi
use_ratio.o:
U _Z5ratioi
$ ./ratio_bad
ratio(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: ret

Symbol 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.o
ld.lld: error: undefined symbol: ratio(long)
>>> referenced by use_ratio.cpp
>>> use_ratio2.o:(main)
>>> did you mean: ratio(int)
>>> defined in: ratio.o

A 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+comdat
0000000000000000 W _Z5twicei
ld.bfd: ok, copies of twice = 1
ld.lld: ok, copies of twice = 1
======== comdat
0000000000000000 T _Z5twicei
ld.bfd: ok, copies of twice = 1
ld.lld: ok, copies of twice = 1
======== weak
0000000000000000 W _Z5twicei
ld.bfd: ok, copies of twice = 2
ld.lld: ok, copies of twice = 2
======== neither
0000000000000000 T _Z5twicei
ld.bfd: t2_neither.o: in function `twice(int)':
(.text._Z5twicei+0x0): multiple definition of `twice(int)'; t1_neither.o:(.text._Z5twicei+0x0): first defined here
ld.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-sections
ld.lld: 2 copies without GC, 1 with --gc-sections

The 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.cpp
struct 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 + Addend
0000000000000008 0000001100000001 R_X86_64_64 0000000000000000 _ZTI7Greeter + 0
0000000000000010 0000000800000001 R_X86_64_64 0000000000000000 _ZN7Greeter5helloEv + 0
0000000000000018 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 + Addend
0000000000000000 0000001200000001 R_X86_64_64 0000000000000000 _ZTVN10__cxxabiv117__class_type_infoE + 10
0000000000000008 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+0x10

Byte relationships between a Greeter object, its vtable address point, typeinfo, and name string

The 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.cpp
struct 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 Greeter
0000000000000000 V typeinfo name for Greeter
0000000000000000 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
$ ./ab
call_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
$ ./ba
call_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.h
struct 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() const
0000000000000000 W Shape::name() const
0000000000000000 V typeinfo for Shape
0000000000000000 V typeinfo name for Shape
0000000000000000 V vtable for Shape
$ nm -C use_shape.o
U _Unwind_Resume
0000000000000000 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_fail
0000000000000000 T main
U printf
$ ./ok
shape 0

shape.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.o
ld.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 once
struct 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)))
#endif
BaseInitializer 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"
done

base_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.o
0000000000000021 t _GLOBAL__sub_I_base_value
0000000000000000 T _ZN15BaseInitializerC1Ev
0000000000000000 T _ZN15BaseInitializerC2Ev
0000000000000000 B base_initializer
0000000000000004 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 + Addend
0000000000000000 0000000200000001 R_X86_64_64 0000000000000000 .text + 21

The 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
$ ./bd
init base
init derived
base=40 derived=42
$ g++ -fno-pie -static main.o derived.o base.o -o db
$ ./db
init derived
init base
base=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_value
0000000000401910 t _GLOBAL__sub_I_derived
00000000004ac130 d __frame_dummy_init_array_entry
0000000000401870 t frame_dummy

Byte relationships from an input relocation to the linked initialization array

Decode 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 once
int &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"
done
g++ -fno-pie -static main_fix.o derived_fix.o base_fix.o -o fix_db
g++ -fno-pie -static main_fix.o base_fix.o derived_fix.o -o fix_bd
./fix_db
./fix_bd

Both programs print:

init derived
init base
base=40 derived=42

make_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_prio
init base
init derived
base=40 derived=42

The 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 initialization
static 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_Resume
0000000000000000 T _Z6configv
0000000000000064 T _Z7use_inlv
0000000000000000 u _ZGVZ10config_inlvE5value
0000000000000000 b _ZGVZ6configvE5value
0000000000000000 u _ZZ10config_inlvE5value
0000000000000008 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++filt
guard variable for config_inl()::value
guard variable for config()::value
$ readelf -gW guard.o | grep -A3 _ZGV
COMDAT 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_inlvE5value

The 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-0x4

The 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
$ ./g
compute
compute
42 42 42

Two 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_inlvE5value
0000000000000000 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: ret

The 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.

How a crate, an rlib, and the final link fit together

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-musl

A 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 crate
pub 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.rlib
rw-r--r-- 0/0 4272 Jan 1 00:00 1970 lib.rmeta
rw-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 stripped
x/lib.rmeta: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ nm x/*.rcgu.o
0000000000000000 T _RNvCs9rhE2iFS63T_4geom4area
$ readelf -SW x/lib.rmeta | grep '\.rmeta'
[ 2] .rmeta PROGBITS 0000000000000000 000060 000e7c 00 E 0 0 1

An 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 _ZN4geom4area17h76b2d1f845cbc28eE

Legacy 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 prefix
N 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 name

The 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
== _ZN4geom4area17h76b2d1f845cbc28eE
geom::area::h76b2d1f845cbc28e
geom::area::h76b2d1f845cbc28e
== _RNvCs9rhE2iFS63T_4geom4area
geom::area
geom[6df4528c38c6272b]::area

Both 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.1
0000000000000000 V DW.ref.rust_eh_personality
0000000000000000 r GCC_except_table5
0000000000000000 t _RINvNtCs6nLojwqrxvg_4core3ptr13drop_in_placeNtNtCsjVrS1ivfjpm_3std3env4ArgsECs341ZQ2MzMSq_3app
0000000000000000 T _RINvNtCsjVrS1ivfjpm_3std2rt10lang_startuECs341ZQ2MzMSq_3app
0000000000000000 t _RINvNtNtCsjVrS1ivfjpm_3std3sys9backtrace28___rust_begin_short_backtraceFEuuECs341ZQ2MzMSq_3app
0000000000000000 t _RNCINvNtCsjVrS1ivfjpm_3std2rt10lang_startuE0Cs341ZQ2MzMSq_3app
0000000000000000 t _RNSNvYNCINvNtCsjVrS1ivfjpm_3std2rt10lang_startuE0INtNtNtCs6nLojwqrxvg_4core3ops8function6FnOnceuE9call_once6vtableCs341ZQ2MzMSq_3app
0000000000000000 T _RNvCs341ZQ2MzMSq_3app4main
0000000000000000 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=0
0000000000000000 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.o
warning: static `DROP_ME` is never used
$ nm attrs.o
0000000000000000 T _RNvCsa6tzDnAu7nc_5attrs11uses_helper
0000000000000000 T _RNvCsa6tzDnAu7nc_5attrs12rs_c_mangled
0000000000000000 R _RNvCsa6tzDnAu7nc_5attrs3TAG
0000000000000000 R _RNvCsa6tzDnAu7nc_5attrs7KEEP_ME
0000000000000000 T rs_area
0000000000000000 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 1

The 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_cc
hello_cc: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, BuildID[sha1]=3f4f083719c9b8c0305bdd81179bde4f3686b5ce, with debug_info, not stripped
$ ./hello_cc
hello 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 stripped
hello from rust

The 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.rlib
lib.rmeta
geom.geom.6df4528c38c6272b-cgu.0.rcgu.o
cfoo.o
lib.rmeta
util.util.62cc8149a2355e59-cgu.0.rcgu.o
7:"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"
7

util 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.o
text 1805, .eh_frame 576, .gcc_except_table 60
(2 drop_in_place symbols)
== work_abort.o
text 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 8

The 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-static
readelf -h tiny-static
readelf -rW tiny-static
./tiny-static

This 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-pie
readelf -rW tiny-table-pie

The 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.

  1. 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’s std, core, and compiler_builtins rlibs. Find __udivti3 and its binding. Search for __popcountdi2; if absent, explain why absence alone does not establish a damaged archive. How does member granularity affect extraction?

  2. Decode names manually. Decode _ZN5shape6circle6resizeERKS0_d, writing its substitution dictionary first. Encode void geo::scale(geo::Point *, const geo::Point *, double). Finally decode _RNvNtCs1234_7mycrate3foo3bar: what does the extra Nt represent?

  3. Break generated ownership. Add virtual void draw() const; before area, define no draw, and leave the rest of the key-function fixture unchanged. Why can an uncalled method break linking? Then remove no_mangle from 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 8
Relocation section '.rela.rodata._ZTV5Shape' at offset 0x4e8 contains 5 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000000008 0000000f00000001 R_X86_64_64 0000000000000000 _ZTI5Shape + 0
0000000000000010 0000000e00000001 R_X86_64_64 0000000000000000 _ZNK5Shape4areaEv + 0
0000000000000018 0000000800000001 R_X86_64_64 0000000000000000 _ZNK5Shape4nameEv + 0
0000000000000020 0000000b00000001 R_X86_64_64 0000000000000000 _ZN5ShapeD1Ev + 0
0000000000000028 0000000c00000001 R_X86_64_64 0000000000000000 _ZN5ShapeD0Ev + 0
0000000000000000 W _ZN5ShapeD0Ev
0000000000000000 W _ZN5ShapeD1Ev
0000000000000000 W _ZN5ShapeD2Ev
0000000000000000 n _ZN5ShapeD5Ev
COMDAT group section [ 2] `.group' [_ZN5ShapeD5Ev] contains 4 sections:
[Index] Name
[ 12] .text._ZN5ShapeD2Ev
[ 13] .rela.text._ZN5ShapeD2Ev
[ 14] .text._ZN5ShapeD0Ev
[ 15] .rela.text._ZN5ShapeD0Ev

The 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: 16
libcore: 17 members, of which .rcgu.o: 16
libcompiler_builtins: 265 members, of which .rcgu.o: 264
__udivti3: W

lib.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_d

Rust 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() const
0000000000000000 V vtable for Shape
U vtable for __cxxabiv1::__class_type_info
shape 0

Defining 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: 0x0
exit=139

U 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

  1. 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. ↩

  2. 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. ↩

  3. 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. ↩

  4. ODR — The One Definition Rule specifies when C++ entities may have definitions in several translation units and what consistency those definitions require. C++ draft. ↩

  5. 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. ↩

  6. 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. ↩

  7. 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. ↩

  8. 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. ↩