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

[The World of Linkers—Theory 07] Leave the Last Address to Runtime

The filenames in the commands below are placeholders for the inputs and outputs of this observation; choose any working directory.

Static PIE needs to discover where its own image landed. Shared libraries add another question: which image will supply a name this time? Even a function defined inside a library may be replaced by a definition elsewhere.

The kernel maps the interpreter named by PT_INTERP and starts it. That dynamic loader then locates dependencies, chooses definitions, and completes addresses. musl1 combines libc with its loader; glibc2 normally separates them. Their common unfinished task is runtime binding, not necessarily the same sequence of file mappings.

This chapter uses native x86-64 Linux throughout: Clang/LLD 21.1.8, GCC3 15.2, GNU4 binutils5 2.46, musl 1.2.5 through musl-gcc6, and glibc 2.43 through system GCC. glibc-specific features such as lazy binding and LD_DEBUG are tested with glibc programs. The examples below state the commands and observations directly.

Why not patch every instruction at startup?

We will follow this library:

// vec.c
int counter;
__attribute__((noinline)) int helper(int x) { return x * 2; }
int bump(int x) {
counter += helper(x);
return counter;
}

noinline keeps the internal call visible. Compile ordinary non-PIC code and try to make a shared object:

$ clang -fno-pic -O1 -c vec.c -o vec_nopic.o
$ llvm-objdump -dr --no-show-raw-insn vec_nopic.o
0000000000000010 <bump>:
10: pushq %rax
11: callq 0x16 <bump+0x6>
0000000000000012: R_X86_64_PLT32 helper-0x4
16: addl (%rip), %eax # 0x1c <bump+0xc>
0000000000000018: R_X86_64_PC32 counter-0x4
1c: movl %eax, (%rip) # 0x22 <bump+0x12>
000000000000001e: R_X86_64_PC32 counter-0x4
22: popq %rcx
23: retq
$ ld.lld -shared vec_nopic.o -o x.so
ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol 'counter'; recompile with -fPIC
>>> defined in vec_nopic.o
>>> referenced by vec.c
>>> vec_nopic.o:(bump)

PC32 needs a link-time distance to counter, yet runtime interposition may place the chosen definition in another module. A wider absolute address makes the problem representable, but moves the patch into executable code:

$ cat abs.c
int counter;
int *get(void){ return &counter; }
$ clang -fno-pic -mcmodel=large -O1 -c abs.c -o abs.o
$ llvm-objdump -dr --no-show-raw-insn abs.o
Disassembly of section .ltext:
0000000000000000 <get>:
0: movabsq $0x0, %rax
0000000000000002: R_X86_64_64 counter
a: retq
$ ld.lld -shared -z notext abs.o -o abs.so
$ readelf -d abs.so | grep -E 'TEXTREL|FLAGS'
0x000000000000001e (FLAGS) TEXTREL
0x0000000000000016 (TEXTREL) 0x0
$ readelf -r abs.so
Relocation section '.rela.dyn' at offset 0x310 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000001332 000200000001 R_X86_64_64 0000000000005440 counter + 0

-z notext permits the displayed text relocation. The loader must temporarily arrange write access, patch the address, and restore protection. With private file mappings, the write also creates process-private code pages. Different load addresses mean different patched contents, reducing physical sharing and adding startup work.

PIC7 instead keeps uncertain addresses in a compact writable structure while preserving immutable instructions. Relative references to a non-preemptible local target stay valid because source and target move together. Other references reach a nearby GOT8 slot, whose full-width address can be filled at runtime.

$ clang -fPIC -O1 -c vec.c -o vec.o
$ llvm-objdump -d -r --no-show-raw-insn vec.o
0000000000000000 <helper>:
0: leal (%rdi,%rdi), %eax
3: retq
4: nopw %cs:(%rax,%rax)
0000000000000010 <bump>:
10: pushq %rax
11: callq 0x16 <bump+0x6>
0000000000000012: R_X86_64_PLT32 helper-0x4
16: movq (%rip), %rcx # 0x1d <bump+0xd>
0000000000000019: R_X86_64_REX_GOTPCRELX counter-0x4
1d: addl (%rcx), %eax
1f: movl %eax, (%rcx)
21: popq %rcx
22: retq

The new sequence loads counter's address, then reads/writes the object through it. Why use indirection when the source itself defines counter and helper? ELF9 default-visible definitions in shared objects are normally eligible for interposition. Compilation must respect that contract unless stronger visibility or binding information says otherwise.

PIE10 makes the main executable movable too, but its own definitions occupy the leading role in normal global lookup. Its compiler can often use direct relative references where a shared library needs indirection.

The static linker writes a runtime instruction sheet

An input relocation can request a GOT slot without containing one. The linker creates the slot and describes its remaining work:

$ ld.lld -shared -soname libvec.so.1 vec.o -o libvec.so.1
$ readelf -d libvec.so.1
Dynamic section at offset 0x3b0 contains 15 entries:
Tag Type Name/Value
0x000000000000000e (SONAME) Library soname: [libvec.so.1]
0x0000000000000007 (RELA) 0x2d8
0x0000000000000008 (RELASZ) 24 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x0000000000000017 (JMPREL) 0x2f0
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000003 (PLTGOT) 0x34a8
0x0000000000000014 (PLTREL) RELA
0x0000000000000006 (SYMTAB) 0x200
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000005 (STRTAB) 0x2b0
0x000000000000000a (STRSZ) 33 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x260
0x0000000000000004 (HASH) 0x288
0x0000000000000000 (NULL) 0x0

PT_DYNAMIC locates a tag/value array. The loader does not need section headers to find the runtime structures.

A tag determines how to interpret its value

An Elf64_Dyn occupies 16 bytes: an eight-byte signed tag, d_tag, followed by an eight-byte union, d_un. The latter can represent an integer, d_val, or an address, d_ptr. The tag selects the interpretation; the numeric magnitude does not. DT_NULL terminates the array (gABI Dynamic Section).

Tag in this exampleValue categoryInterpretation with load bias B
DT_RELA = 0x2d8Linked virtual addressRelocation-table start is B + 0x2d8
DT_RELASZ = 24Byte countLength stays 24; do not add B
DT_RELAENT = 24Record width24 / 24 = 1 record
DT_STRTAB = 0x2b0Linked virtual addressString-table start is B + 0x2b0
Raw value of DT_SONAMEString-table-relative offsetAdd it to the string-table start and read a zero-terminated name
DT_PLTREL = DT_RELAFormat tagPLT relocations use RELA; this is not a pointer

readelf has already interpreted the SONAME offset as [libvec.so.1] and the PLTREL value as RELA. On disk, each still occupies the same eight-byte value field. Address entries use this module’s load bias; lengths, record widths, string offsets, and format tags retain their own meanings and origins.

Dynamic tagsRuntime purpose
SYMTAB, STRTABDynamic symbols and names
GNU_HASH, HASHName-lookup acceleration
RELA, RELASZGeneral dynamic relocations
JMPREL, PLTRELSZPLT-related relocation table
PLTGOTGOT/PLT control area
SONAME, NEEDEDLibrary identity advertised to dependents, and dependencies
INIT, INIT_ARRAY, FINI, FINI_ARRAYInitialization/finalization hooks

Unlike .symtab, .dynsym must remain mapped because runtime lookup uses it. GNU hash adds a Bloom filter to cheaply reject many absent names. DT_DEBUG starts empty in the file and can receive a runtime debugger rendezvous structure.

Link a user of the library:

// main.c
extern int counter;
int bump(int);
int main(void) {
bump(1);
return counter;
}
$ ln -sf libvec.so.1 libvec.so
$ clang -fPIE -O1 -c main.c -o main_pie.o
$ musl-gcc -pie main_pie.o -L. -lvec -Wl,-rpath,'$ORIGIN' -o main_pie
$ readelf -d main_pie
Dynamic section at offset 0x2dd8 contains 26 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libvec.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x1176
0x0000000000000019 (INIT_ARRAY) 0x3dc8
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3dd0
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x258
0x0000000000000005 (STRTAB) 0x360
0x0000000000000006 (SYMTAB) 0x288
0x000000000000000a (STRSZ) 141 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fb8
0x0000000000000002 (PLTRELSZ) 48 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x498
0x0000000000000007 (RELA) 0x3f0
0x0000000000000008 (RELASZ) 168 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0

The command finds libvec.so; the library advertises libvec.so.1, so that SONAME becomes the executable's dependency. Incompatible library generations can use different SONAMEs and coexist.

$ORIGIN expands at runtime to the containing object's directory. Quote it when passing it through the shell:

$ musl-gcc -pie main_pie.o -L. -lvec -Wl,--enable-new-dtags,-rpath,'$ORIGIN' -Wl,-z,now -o main_pie2
$ readelf -d main_pie2 | grep -E 'RUNPATH|FLAGS'
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
$ ld.lld -shared -rpath '$ORIGIN' vec.o -o r_lld.so
$ readelf -d r_lld.so | grep PATH
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
$ ld -shared -rpath '$ORIGIN' vec.o -o r_bfd.so
$ readelf -d r_bfd.so | grep PATH
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]

Both tested linkers default to RUNPATH, but explicit --enable-new-dtags or --disable-new-dtags makes the choice reviewable. RPATH and RUNPATH differ in precedence and transitive behavior, discussed below.

A dependency entry has a startup cost. Compare an unused library with and without --as-needed:

$ musl-gcc -pie main_pie.o -L. -lvec -lother -o m1
$ readelf -d m1 | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libvec.so.1]
0x0000000000000001 (NEEDED) Shared library: [libother.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so]
$ musl-gcc -pie main_pie.o -L. -Wl,--as-needed -lvec -lother -o m2
$ readelf -d m2 | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libvec.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so]

The option is position-sensitive and affects subsequent library inputs under the linker's dependency rules. It can omit a library that contributes no required resolution, instead of retaining every named shared input.

Unresolved references have a different policy in a shared object:

$ cat lib2.c
int missing(int);
int use(int x) { return missing(x) + 1; }
$ clang -fPIC -O1 -c lib2.c -o lib2.o
$ ld.lld -shared lib2.o -o lib2.so
$ readelf --dyn-syms lib2.so | grep missing
1: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND missing
$ ld.lld -shared -z defs lib2.o -o lib2.so
ld.lld: error: undefined symbol: missing
>>> referenced by lib2.c
>>> lib2.o:(use)
$ ld -shared --no-undefined lib2.o -o lib2.so
ld: lib2.o: in function `use':
lib2.c:(.text+0x2): undefined reference to `missing'

A plugin may intentionally obtain a name from its eventual host, so the default shared link permits unresolved symbols. -z defs/--no-undefined asks for link-time resolution against supplied inputs and catches many accidental omissions earlier.

The executable also names the interpreter and dynamic-table location:

$ readelf -lW main_pie
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1050
There are 9 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0001f8 0x0001f8 R 0x8
INTERP 0x000238 0x0000000000000238 0x0000000000000238 0x000019 0x000019 R 0x1
[Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0004c8 0x0004c8 R 0x1000
LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x000179 0x000179 R E 0x1000
LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000074 0x000074 R 0x1000
LOAD 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x000240 0x000248 RW 0x1000
DYNAMIC 0x002dd8 0x0000000000003dd8 0x0000000000003dd8 0x0001e0 0x0001e0 RW 0x8
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x000238 0x000238 R 0x1

Those program headers, dynamic tags, and dynamic symbols are the contract handed to the runtime loader.

Six relocation jobs, not one

Input relocations are normally consumed by the static link. Remaining work is expressed as dynamic relocations; their offsets identify image virtual addresses rather than offsets within separate input sections. TLS11 relocations are deferred to Theory 09.

Compute the destination separately from the value

The name r_offset does not imply one coordinate system for every ELF type. In an ET_REL object it is relative to the target input section; in an executable or shared object it is the relocated field’s linked virtual address (gABI Relocation). A shared object loaded with bias B therefore writes at B + r_offset. This B belongs to the module containing the field, which need not supply the symbol definition.

For the RELATIVE record shown below, r_offset=0x3338 and A=0x3348. With hypothetical B=0x70000000, the loader writes the eight-byte pointer 0x70003348 at 0x70003338. Its little-endian encoding is 48 33 00 70 00 00 00 00. The destination answers “where”; the computed pointer answers “what”. RELATIVE uses its own module’s B for both and performs no name lookup.

For GLOB_DAT, the destination is still the referring module’s B + r_offset, but the value is the selected symbol’s runtime address S. A definition in another shared object uses that object’s bias. Interposition can select a different definition altogether, so this module’s same-named symbol value cannot substitute for lookup.

$ readelf -r libvec.so.1
Relocation section '.rela.dyn' at offset 0x2d8 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
0000000024a0 000300000006 R_X86_64_GLOB_DAT 00000000000034c8 counter + 0
Relocation section '.rela.plt' at offset 0x2f0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
0000000034c0 000100000007 R_X86_64_JUMP_SLO 0000000000001360 helper + 0

GLOB_DAT looks up counter and places its runtime address in a GOT slot. JUMP_SLOT similarly supplies a function target but can participate in lazy binding. RELATIVE avoids name lookup entirely:

$ cat rel.c
static int table[4];
int *cursor = &table[2];
$ clang -fPIC -O1 -c rel.c -o rel.o
$ llvm-objdump -r rel.o
RELOCATION RECORDS FOR [.data]:
OFFSET TYPE VALUE
0000000000000000 R_X86_64_64 .bss+0x8
$ ld.lld -shared rel.o -o librel.so
$ readelf -r librel.so
Relocation section '.rela.dyn' at offset 0x270 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000003338 000000000008 R_X86_64_RELATIVE 3348

The local table cannot be preempted. The linker already knows that &table[2] lies at image offset 0x3348, so the loader writes B+0x3348 at B+0x3338. A RELACOUNT prefix can identify a batch of cheap relative relocations.

Packing repeated relative adjustments

RELR records only where to add the base; the original field holds the addend. A word with low bit zero gives an address and advances the cursor by one word. A word with low bit one is a bitmap for the next 63 ELF64 words, then advances past that window.

$ gcc -fPIE -O1 -c relr.c -o relr.o
$ gcc -pie relr.o -o relr_plain
$ gcc -pie -Wl,-z,pack-relative-relocs relr.o -o relr_packed
$ readelf -SW relr_plain | grep "rel.\.dyn"
$ readelf -SW relr_packed | grep "rel.\.dyn"
[ 8] .rela.dyn RELA 0000000000000510 000510 0030c0 18 A 4 0 8
[ 8] .rela.dyn RELA 0000000000000530 000530 000078 18 A 4 0 8
[ 9] .relr.dyn RELR 00000000000005a8 0005a8 000058 08 A 0 0 8
$ readelf -rW relr_packed | grep -A5 relr.dyn
Relocation section '.relr.dyn' at offset 0x5a8 contains 11 entries which relocate 515 locations:
Index: Entry Address Symbolic Address
0000: 0000000000003dc0 0000000000003dc0 __frame_dummy_init_array_entry
0001: 0000000000000003 0000000000003dc8 __do_global_dtors_aux_fini_array_entry
0002: ffffffffffffe401 0000000000004008 __dso_handle
0000000000004020 ptrs

This example has 515 relative locations: 512 pointers plus startup-array entries and __dso_handle. The ordinary relocation table occupies 12480 bytes including five GLOB_DAT entries. Packing leaves 120 bytes of .rela.dyn and 88 bytes—eleven words—of .relr.dyn. The first address 0x3dc0 and bitmap 0x3 cover two adjacent locations; later bitmaps span the pointer array.

Both tested runtime libraries execute the packed version successfully. The glibc binary records a GLIBC_ABI_DT_RELR version requirement. Format support must exist on both the producer and consumer side; a smaller file alone proves neither compatibility nor correct loading.

Local pointers, public pointers, and resolvers

Which target a pointer denotes determines whether lookup can be skipped:

$ cat ptr.c
static int table[4];
static int quiet(int x) { return x; }
__attribute__((visibility("hidden"))) int hid(int x) { return x + 1; }
int loud(int x) { return x + 2; }
int *cursor = &table[2];
const char *msg = "hi";
int (*ops[3])(int) = { quiet, hid, loud };
$ clang -fPIC -O1 -c ptr.c -o ptr.o
$ ld.lld -shared ptr.o -o libptr.so
$ readelf -r libptr.so
Relocation section '.rela.dyn' at offset 0x2f0 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000000034b0 000000000008 R_X86_64_RELATIVE 34e8
0000000034b8 000000000008 R_X86_64_RELATIVE 368
0000000034c0 000000000008 R_X86_64_RELATIVE 13f0
0000000034c8 000000000008 R_X86_64_RELATIVE 13d0
0000000034d0 000100000001 R_X86_64_64 00000000000013e0 loud + 0

Static objects/functions, hidden definitions, and string literals use RELATIVE here. A pointer to default-visible loud needs a symbol-bearing R_X86_64_64, applying S+A after runtime lookup. Large C++ tables can contain many such entries when their function targets remain preemptible.

IFUNC adds a computation to resolution:

$ cat ifn.c
extern int has_avx2;
static int add_sse(int a, int b) { return a + b; }
static int add_avx(int a, int b) { return b + a; }
static void *pick(void) { return has_avx2 ? (void *)add_avx : (void *)add_sse; }
__attribute__((visibility("hidden"))) int add(int, int) __attribute__((ifunc("pick")));
int call(int a, int b) { return add(a, b); }
$ clang -fPIC -O1 -c ifn.c -o ifn.o
$ ld.lld -shared ifn.o -o libifn.so
$ readelf -r libifn.so | grep IREL
000000003448 000000000025 R_X86_64_IRELATIV 1350
$ readelf -s libifn.so | grep -E ' (pick|add)$'
2: 0000000000001350 29 FUNC LOCAL DEFAULT 7 pick
5: 0000000000001350 29 IFUNC LOCAL HIDDEN 7 add

For hidden add, IRELATIVE identifies a resolver at B+A, calls it, and stores its returned implementation address. A preemptible IFUNC may instead be reached through normal symbol lookup and JUMP_SLOT handling. The displayed resolver is a mechanism demonstration; a production CPU-dispatch resolver must obey the early-runtime restrictions of its implementation.

Copy relocations create storage in the executable

Non-PIC code may directly address an external variable even when its definition eventually comes from a shared library:

$ clang -fno-pic -O1 -c main.c -o main_nopic.o
$ llvm-objdump -dr --no-show-raw-insn main_nopic.o
0000000000000000 <main>:
0: pushq %rax
1: movl $0x1, %edi
6: callq 0xb <main+0xb>
0000000000000007: R_X86_64_PLT32 bump-0x4
b: movl (%rip), %eax # 0x11 <main+0x11>
000000000000000d: R_X86_64_PC32 counter-0x4
11: popq %rcx
12: retq

The linker creates nearby executable-owned storage and asks the loader to copy the library's initial bytes into it:

$ musl-gcc -no-pie main_nopic.o -L. -lvec -Wl,-rpath,'$ORIGIN' -o main_nopie_musl
$ readelf -rW main_nopie_musl
Relocation section '.rela.dyn' at offset 0x3f8 contains 4 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000403fd0 0000000500000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0
0000000000403fd8 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTable + 0
0000000000403fe0 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMCloneTable + 0
0000000000404018 0000000700000005 R_X86_64_COPY 0000000000404018 counter + 0
Relocation section '.rela.plt' at offset 0x458 contains 2 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000404000 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 __libc_start_main + 0
0000000000404008 0000000400000007 R_X86_64_JUMP_SLOT 0000000000000000 bump + 0
$ readelf -sW main_nopie_musl | grep counter
7: 0000000000404018 4 OBJECT GLOBAL DEFAULT 19 counter
23: 0000000000404018 4 OBJECT GLOBAL DEFAULT 19 counter

The output now defines a four-byte counter at 0x404018. The library's own GOT reference binds to that executable copy, so all participating references see one object. This is why library-local source syntax did not guarantee library-local storage.

The copy fixes the object's size in the executable's binary interface. Changing the library definition from int counter to long counter grows it from four to eight bytes under this x86-64 ABI. The old executable still reserves four bytes; replacing a library cannot enlarge that allocation.

Loaders can differ in how they diagnose a size mismatch. The following example explicitly builds main_nopie_glibc with the glibc toolchain, links against the original four-byte definition, and then replaces the library. The earlier main_nopie_musl remains a separate musl executable.

$ gcc -fPIC -O1 -shared -Wl,-soname,libvec.so.1 vec.c -o libvec.so.1
$ gcc -no-pie main_nopic.o -L. -lvec -Wl,-rpath,'$ORIGIN' -o main_nopie_glibc
$ ./main_nopie_glibc; echo exit=$?
exit=2
$ sed 's/int counter;/long counter;/' vec.c > vec8.c
$ gcc -fPIC -O1 -shared -Wl,-soname,libvec.so.1 vec8.c -o libvec.so.1
$ ./main_nopie_glibc; echo exit=$?
./main_nopie_glibc: Symbol `counter' has different size in shared object, consider re-linking
exit=2
$ ld.lld -shared -soname libvec.so.1 vec.o -o libvec.so.1

The final command restores the original four-byte library definition from the earlier vec.o. The warning identifies an ABI mismatch. An exit status of two does not make the replacement safe: the new library can access eight bytes through storage for which the old executable reserved only four. This is the central cost of a copy relocation: the executable becomes dependent on an object layout that originally belonged to the library.

PIE does not guarantee the absence of copy relocations. Clang normally uses a GOT access here:

$ clang -fPIE -O1 -c main.c -o main_pie.o
$ llvm-objdump -dr main_pie.o
b: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 12 <main+0x12>
e: R_X86_64_REX_GOTPCRELX counter-0x4
12: 8b 00 mov (%rax),%eax
$ readelf -r main_pie | grep counter
000000003ff0 000200000006 R_X86_64_GLOB_DAT 0000000000000000 counter + 0

The tested GCC instead emits direct PC-relative access and relies on a copy relocation if needed:

$ musl-gcc -fPIE -O1 -c main.c -o main_gcc_pie.o
$ llvm-objdump -dr main_gcc_pie.o
12: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 18 <main+0x18>
14: R_X86_64_PC32 counter-0x4
$ musl-gcc -pie main_gcc_pie.o -L. -lvec -o main_gccpie
$ readelf -r main_gccpie | grep counter
000000004008 000700000005 R_X86_64_COPY 0000000000004008 counter + 0

The shorter access saves an indirection while retaining the storage-size and binding constraints. Inspect the actual relocation table rather than inferring the behavior from ELF type alone.

A PLT entry can resolve itself on first use

The library's call reaches a generated PLT12 stub:

$ llvm-objdump -d --no-show-raw-insn libvec.so.1
0000000000001370 <bump>:
1370: pushq %rax
1371: callq 0x13a0 <helper@plt>
1376: movq 0x1123(%rip), %rcx # 0x24a0
...
Disassembly of section .plt:
0000000000001390 <.plt>:
1390: pushq 0x211a(%rip) # 0x34b0
1396: jmpq *0x211c(%rip) # 0x34b8
139c: nopl (%rax)
00000000000013a0 <helper@plt>:
13a0: jmpq *0x211a(%rip) # 0x34c0
13a6: pushq $0x0
13ab: jmp 0x1390 <.plt>
$ llvm-objdump -s -j .got.plt libvec.so.1
Contents of section .got.plt:
34a8 b0230000 00000000 00000000 00000000 .#..............
34b8 00000000 00000000 a6130000 00000000 ................

The initial GOT/PLT area contains a dynamic-table reference, two loader-populated control words, and a function slot that initially routes back into the stub. In the shown traditional glibc protocol:

  1. bump calls helper@plt.
  2. The stub's indirect jump follows its unresolved slot to its own next instruction.
  3. That instruction pushes the relocation index and jumps to PLT0.
  4. PLT0 pushes the module's link_map and enters the runtime resolver.
  5. The resolver preserves call state, finds the named definition, updates the slot, and transfers directly to the function.

Later calls follow the updated slot without repeating lookup. This defers work for unused functions, but leaves writable function slots, delays some missing-symbol errors, and adds first-call latency.

Immediate binding requests all targets before execution proceeds:

$ ld.lld -shared -soname libvec.so.1 -z now vec.o -o libvec_now.so
$ readelf -d libvec_now.so | grep FLAGS
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW

-z now sets dynamic flags; LD_BIND_NOW=1 can request the same behavior at runtime. The PLT need not disappear. musl 1.2.5 performs eager binding and does not implement glibc's lazy protocol. -fno-plt goes further by generating a direct GOT-indirect call sequence, trading away lazy binding.

Function addresses need one identity

A non-PIC executable may require a fixed address for a library function before runtime knows its body address. A canonical PLT supplies that identity:

$ cat fp.c
int bump(int);
int (*fp)(int) = bump;
int main(void) {
return fp(1) + (fp == bump);
}
$ clang -fno-pic -O1 -c fp.c -o fp.o
$ musl-gcc -no-pie fp.o -L. -lvec -o fp_nopie
$ readelf --dyn-syms fp_nopie | grep bump
2: 0000000000401030 0 FUNC GLOBAL DEFAULT UND bump
$ llvm-objdump -d fp_nopie | grep -A1 'bump@plt>:'
0000000000401030 <bump@plt>:
401030: ff 25 d2 2f 00 00 jmp *0x2fd2(%rip) # 404008 <bump>

The executable's dynamic symbol is undefined but has nonzero value 0x401030, its PLT entry. Address-taking lookup can use that value consistently; JUMP_SLOT resolution must still find the actual implementation. This identity contract is separate from when first-call binding happens.

Binding a library's references directly to its body with -Bsymbolic can undermine this arrangement: an address taken inside the library may differ from the executable's canonical address. As with copy relocations, a local optimization interacts with a process-wide identity rule.

Relocate, then remove write access

RELRO13 groups data needed writable during relocation but immutable afterward. The static linker describes the range with PT_GNU_RELRO; the loader applies mprotect after completing the required writes.

$ readelf -lW libvec.so.1 # default
LOAD 0x0003b0 0x00000000000023b0 ... 0x0000f8 0x000c50 RW 0x1000
LOAD 0x0004a8 0x00000000000034a8 ... 0x000020 0x000024 RW 0x1000
GNU_RELRO 0x0003b0 0x00000000000023b0 ... 0x0000f8 0x000c50 R 0x1
03 .dynamic .got .relro_padding
04 .got.plt .bss
$ readelf -lW libvec_now.so # -z now
LOAD 0x0003b0 0x00000000000023b0 ... 0x000138 0x000c50 RW 0x1000
LOAD 0x0004e8 0x00000000000034e8 ... 0x000000 0x000004 RW 0x1000
GNU_RELRO 0x0003b0 0x00000000000023b0 ... 0x000138 0x000c50 R 0x1
03 .dynamic .got .got.plt .relro_padding
04 .bss

With lazy binding, function slots that may still change remain writable: partial RELRO. With immediate binding, they can join the protected range: full RELRO. Padding or layout must make the boundary compatible with page-granular protection. The linker only writes the range description; a runtime startup path must actually enforce it.

Interposition is useful—and it has a cost

For ordinary startup lookup of preemptible globals, the main executable leads, followed by preload/dependency scopes. The first eligible definition wins. Visibility, versions, protected binding, and dlopen scopes constrain this picture; it is not an unconditional rule for every reference.

This allows an application's allocator to serve library callers too, preserving a useful aspect of static-library replacement. Preloading makes the mechanism observable:

// fake.c
int helper(int x) { return 100; }
$ musl-gcc -shared -fPIC fake.c -o libfake.so
$ ./main_pie; echo $?; LD_PRELOAD=./libfake.so ./main_pie; echo $?
2
100

helper is replaced, so bump(1) changes counter to 100 rather than 2. The internal call was deliberately preserved with noinline.

Supporting replacement costs GOT/PLT indirections and constrains optimization. GCC and Clang also differ in how aggressively they optimize same-translation-unit calls under their semantic-interposition policies. Four controls act at different stages:

Hidden visibility narrows the interface before code generation:

$ cat vec2.c
int counter;
__attribute__((noinline)) int helper(int x) { return x * 2; }
__attribute__((visibility("default"))) int bump(int x) {
counter += helper(x);
return counter;
}
$ clang -fPIC -O1 -fvisibility=hidden -c vec2.c -o vec2.o
$ llvm-objdump -d -r --no-show-raw-insn vec2.o
0000000000000010 <bump>:
10: pushq %rax
11: callq 0x16 <bump+0x6>
0000000000000012: R_X86_64_PLT32 helper-0x4
16: addl (%rip), %eax # 0x1c <bump+0xc>
0000000000000018: R_X86_64_PC32 counter-0x4
1c: movl %eax, (%rip) # 0x22 <bump+0x12>
000000000000001e: R_X86_64_PC32 counter-0x4
22: popq %rcx
23: retq
$ ld.lld -shared -soname libvec.so.1 vec2.o -o libvec2.so
$ readelf -r libvec2.so
There are no relocations in this file.
$ readelf --dyn-syms libvec2.so
Symbol table '.dynsym' contains 2 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000000012e0 20 FUNC GLOBAL DEFAULT 6 bump
$ llvm-objdump -d --no-show-raw-insn libvec2.so
00000000000012e0 <bump>:
12e0: pushq %rax
12e1: callq 0x12d0 <helper>
12e6: addl 0x208c(%rip), %eax # 0x3378 <counter>
12ec: movl %eax, 0x2086(%rip) # 0x3378 <counter>

Only bump remains exported. Internal accesses become direct, and this tiny library needs no dynamic relocations.

-fno-semantic-interposition permits stronger compiler assumptions while retaining an external interface:

$ clang -fPIC -O1 -fno-semantic-interposition -c vec.c -o vec_n.o
$ llvm-objdump -d -r --no-show-raw-insn vec_n.o
0000000000000010 <bump>:
10: pushq %rax
11: callq 0x0 <helper>
16: movq (%rip), %rcx # 0x1d <bump+0xd>
0000000000000019: R_X86_64_REX_GOTPCRELX counter-0x4

The call can use a local alias; the variable still uses the GOT to preserve applicable copy-relocation behavior.

-Bsymbolic acts during linking:

$ ld.lld -shared -Bsymbolic vec.o -o libvec_bs.so
$ readelf -r libvec_bs.so
There are no relocations in this file.
$ llvm-objdump -d --no-show-raw-insn libvec_bs.so
0000000000001330 <bump>:
1330: pushq %rax
1331: callq 0x1320 <helper>
1336: leaq 0x208b(%rip), %rcx # 0x33c8 <counter>
133d: addl (%rcx), %eax

The existing GOT load relaxes to lea, and the PLT disappears. However, binding data to the library's own copy can disagree with an executable's COPY allocation. -Bsymbolic-functions limits the policy to functions, though function-address identity still deserves scrutiny.

A version script can make implementation names local:

$ cat vec.map
VEC_1.0 {
global: bump; counter;
local: *;
};
$ ld.lld -shared -soname libvec.so.1 --version-script=vec.map vec.o -o libvec.so.1
$ readelf -r libvec.so.1
Relocation section '.rela.dyn' at offset 0x2f0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000002468 000200000006 R_X86_64_GLOB_DAT 0000000000003470 counter@@VEC_1.0 + 0
$ llvm-objdump -d --no-show-raw-insn libvec.so.1
0000000000001370 <bump>:
1370: pushq %rax
1371: callq 0x1360 <helper>

Here helper loses its PLT/relocation while exported counter retains GLOB_DAT. Explicitly declaring the intended interface is often clearer than relying on every global declaration becoming public.

Which calls does an interposer actually intercept?

Compare three ways of counting allocation calls:

// hello.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <malloc.h>
char *greet(void); /* defined in static archive libgreet.a */
int main(void) {
char *p = malloc(10); /* the application source */
char *s = strdup("hi"); /* malloc called inside libc */
char *g = greet(); /* malloc called inside the static library */
printf("%s %s\n", s, g);
free(p); free(s); free(g);
return 0;
}

The direct call is in the main source, greet comes from a static archive, and strdup is in libc. Compile without optimization so deliberately unused allocation/free pairs are not removed.

A macro header changes only source that includes it. --wrap changes undefined references admitted to the current link, including extracted archive members. LD_PRELOAD changes eligible runtime lookup; the wrapper can find the next definition with RTLD_NEXT:

// mymalloc_r.c (free follows the same pattern)
#define _GNU_SOURCE
#include <dlfcn.h>
#include "count.h"
static void *(*real_malloc)(size_t);
void *malloc(size_t n) {
if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc");
n_malloc++;
return real_malloc(n);
}

This is an educational wrapper, not a production allocator bootstrap. dlsym may itself allocate or reenter under other conditions. The accompanying reentry probe happened not to trigger on these two library versions; that does not establish a general guarantee.

The native glibc results are:

$ ./hello_c >/dev/null # compile-time interception
malloc=1 free=3
$ ./hello_l >/dev/null # link-time interception, dynamic executable
malloc=2 free=3
$ ./hello_ls >/dev/null # link-time interception, static executable
malloc=10 free=5
$ LD_PRELOAD=./mymalloc.so ./hello_r >/dev/null # load-time interception
malloc=4 free=3

The macro sees one allocation and three frees. A dynamically linked --wrap build sees the main call and greet, but not an already-linked libc internal reference. Static wrapping admits libc members too: musl records three allocations/three frees; glibc records ten/five because startup and buffering add activity. The extra allocations were attributed to stdout buffering and the static runtime's loader-support paths. Preloading glibc sees the three requested allocations plus stdout's buffer; musl's fixed stdout buffer leaves three/three.

Scope, not the wrapper's spelling, explains the counts. Statically linked programs do not acquire this preload behavior, locally bound calls bypass ordinary interposition, and secure-execution policy restricts preload use.

A name can have several interface generations

The version script also creates runtime version metadata:

$ readelf --dyn-syms libvec.so.1
1: 0000000000001370 19 FUNC GLOBAL DEFAULT 9 bump@@VEC_1.0
2: 0000000000003470 4 OBJECT GLOBAL DEFAULT 13 counter@@VEC_1.0
$ readelf -V libvec.so.1
Version symbols section '.gnu.version' contains 3 entries:
Addr: 0x0000000000000248 Offset: 0x00000248 Link: 1 (.dynsym)
000: 0 (*local*) 2 (VEC_1.0) 2 (VEC_1.0)
Version definition section '.gnu.version_d' contains 2 entries:
Addr: 0x0000000000000250 Offset: 0x00000250 Link: 6 (.dynstr)
000000: Rev: 1 Flags: BASE Index: 1 Cnt: 1 Name: libvec.so.1
0x001c: Rev: 1 Flags: none Index: 2 Cnt: 1 Name: VEC_1.0
$ musl-gcc -pie main_pie.o -L. -lvec -o main_ver
$ readelf -V main_ver
Version needs section '.gnu.version_r' contains 1 entry:
Addr: 0x0000000000000400 Offset: 0x00000400 Link: 4 (.dynstr)
000000: Version: 1 File: libvec.so.1 Cnt: 1
0x0010: Name: VEC_1.0 Flags: none Version: 2
$ readelf --dyn-syms main_ver | grep -E 'bump|counter'
1: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND counter@VEC_1.0 (2)
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND bump@VEC_1.0 (2)

.gnu.version assigns an index to each dynamic symbol; definition records describe provided versions; need records describe a consumer's requirements. A default definition has @@VERSION; a non-default compatibility definition has @VERSION.

$ cat sv.c
int bump_v1(int x) { return x; }
int bump_v2(int x) { return x * 2; }
__asm__(".symver bump_v1, bump@VEC_1.0");
__asm__(".symver bump_v2, bump@@VEC_2.0");
$ cat sv.map
VEC_1.0 { global: bump; local: *; };
VEC_2.0 { global: bump; } VEC_1.0;
$ clang -fPIC -O1 -c sv.c -o sv.o
$ ld.lld -shared --version-script=sv.map sv.o -o libsv.so
$ readelf --dyn-syms libsv.so | grep bump
1: 0000000000001320 3 FUNC GLOBAL DEFAULT 8 bump@VEC_1.0
2: 0000000000001330 4 FUNC GLOBAL DEFAULT 8 bump@@VEC_2.0

Old clients requesting bump@VEC_1.0 can keep one implementation while newly linked clients select bump@@VEC_2.0. The library need not change SONAME merely to retain both compatible entry points.

glibc's versioned memcpy is a useful historical example: newer default behavior could coexist with an old symbol version mapped to overlap-tolerant compatibility behavior. That preserves a particular old binary contract; it does not make overlapping memcpy valid source code.

The reverse direction fails when a consumer requests a version the installed library never provided:

./main: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./main)

This line is illustrative; the locally reproduced version failure appears below. Minimum-runtime support must account for the versions recorded at link time, not just the source APIs used. musl does not implement the same version-selection contract as glibc, so these examples keep the two runtimes distinct.

One loader startup, in order

After receiving AT_PHDR, AT_ENTRY, and AT_BASE, the loader first makes its own early code usable. It then reads the executable's dynamic table, loads preload objects and dependencies, and builds the relevant scopes. Dependency traversal is generally breadth-first for ordinary startup discovery; file identity and aliases also matter, so SONAME is not a universal unique object identifier.

For a dependency name without a slash, glibc's ordinary search order is: applicable RPATH when no RUNPATH supersedes it; LD_LIBRARY_PATH; applicable RUNPATH; loader cache; default directories. RPATH can affect descendants; RUNPATH applies to the requesting object's direct dependencies. Secure execution changes environment handling.

Relocation ordering must make dependencies usable before dependent initialization and copying. Relative relocations need a base; named relocations need scope/version lookup; IFUNC needs its resolver; lazy JUMP_SLOT entries may remain deferred. RELRO protection follows the writes. Shared-library initializers run in dependency order, then startup reaches the executable's entry and libc startup completes the main program's initialization before main. Finalizers unwind the relevant order at shutdown.

Runtime loading repeats much of that work:

#include <dlfcn.h>
#include <stdio.h>
int main(void) {
void *h = dlopen("libvec.so.1", RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
int (*bump)(int) = (int (*)(int))dlsym(h, "bump");
printf("%d\n", bump(21));
dlclose(h);
return 0;
}

RTLD_NOW requests immediate binding. RTLD_LOCAL keeps the newly loaded group out of the ordinary global export scope; RTLD_GLOBAL exposes it to relevant later lookup. dlsym searches through a handle; GNU dlvsym can request a particular symbol version. dlclose decrements a reference count but does not guarantee immediate unloading when dependencies, NODELETE, or other lifetime rules retain the object.

Stack permissions also cross module boundaries

Handwritten assembly should explicitly state its stack requirements:

$ cat fast.s
.text
.globl fast_add
.type fast_add,@function
fast_add:
leal (%rdi,%rsi),%eax
ret
$ as fast.s -o fast.o
$ ld -shared fast.o vec.o -o libmix.so
$ readelf -lW libmix.so | grep GNU_STACK
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
$ ld.lld -shared fast.o vec.o -o libmix_lld.so
$ readelf -lW libmix_lld.so | grep GNU_STACK
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0

The tested mixed inputs produce a non-executable stack with both GNU ld and LLD. Missing-note compatibility defaults depend on target and build configuration; absence is not a portable request for a particular policy.

glibc's loading policy changed in 2.41: a late dlopen cannot simply make all existing thread stacks executable to satisfy a newly loaded object. A request conflicting with the existing stack policy can fail. Startup policy and the documented glibc.rtld.execstack tunable are separate controls; enabling an executable stack at process start is not restoration of the old late-transition behavior. Consult the version-appropriate tunable documentation when investigating such a failure.

Read the binary's expectations before guessing

Use file-inspection tools first:

$ llvm-objdump -p main_pie | grep -E 'NEEDED|RPATH|RUNPATH'
NEEDED libvec.so.1
NEEDED libc.so
RUNPATH $ORIGIN
$ llvm-objdump -R main_pie | grep RELATIVE | head -2
0000000000003dc8 R_X86_64_RELATIVE *ABS*+0x1150
0000000000003dd0 R_X86_64_RELATIVE *ABS*+0x1110

readelf -d, -r, -V, and --dyn-syms reveal dependencies, relocation jobs, versions, and interfaces. Relocations do not describe every loader action; search paths, initialization, and permission changes have their own metadata.

For a trusted local program, resolved dependencies can be listed with ldd:

$ ldd ./main
linux-vdso.so.1 (0x00007d997b25c000)
libvec.so.1 => <work>/ex1/libvec.so.1 (0x00007d997b24a000)
libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6 (0x00007d997b000000)
/lib64/ld-linux-x86-64.so.2 (0x00007d997b25e000)

ldd may involve the runtime loader. Use static inspection such as objdump -p for untrusted files; it reveals direct dependencies without promising a full resolved dependency tree.

glibc's loader explains its own decisions through LD_DEBUG:

$ LD_DEBUG=libs ./main
1592030: find library=libvec.so.1 [0]; searching
1592030: search path=<work>/ex1/glibc-hwcaps/x86-64-v3:<work>/ex1/glibc-hwcaps/x86-64-v2:<work>/ex1 (RUNPATH from file ./main)
1592030: trying file=<work>/ex1/glibc-hwcaps/x86-64-v3/libvec.so.1
1592030: (no such file)
1592030: trying file=<work>/ex1/glibc-hwcaps/x86-64-v2/libvec.so.1
1592030: (no such file)
1592030: trying file=<work>/ex1/libvec.so.1
1592030:
1592030: find library=libc.so.6 [0]; searching
1592030: search path=<work>/ex1 (RUNPATH from file ./main)
1592030: trying file=<work>/ex1/libc.so.6
1592030: (no such file)
1592030: search cache=/etc/ld.so.cache
1592030: trying file=/usr/lib/x86_64-linux-gnu/libc.so.6
1592030:
1592030:
1592030: calling init: /lib64/ld-linux-x86-64.so.2
1592030:
1592030:
1592030: calling init: /usr/lib/x86_64-linux-gnu/libc.so.6
1592030:
1592030:
1592030: calling init: <work>/ex1/libvec.so.1
1592030:
1592030:
1592030: initialize program: ./main
1592030:
1592030:
1592030: transferring control: ./main
1592030:
1592030:
1592030: calling fini: [0]
1592030:
1592030:
1592030: calling fini: <work>/ex1/libvec.so.1 [0]

libs traces search paths, including hardware-capability subdirectories; bindings identifies chosen definitions; versions checks interface requirements. musl does not supply this glibc debugging facility.

Three locally induced failures distinguish missing file, missing symbol, and missing version:

./main: error while loading shared libraries: libvec.so.1: cannot open shared object file: No such file or directory
./main: symbol lookup error: ./main: undefined symbol: bump
./main_ver: <work>/ex1/libvec.so.1: version `VEC_2.0' not found (required by ./main_ver)

The first two recorded failures exit 127; the version mismatch exits 1. Lazy binding can delay the missing-function failure until its first call. Diagnose the layer that failed rather than treating every case as “the library is missing.”

A call can now cross a module boundary. The next chapter asks how an exception can recover machine state in the opposite direction, back across that same boundary.

Exercises

  1. In a fresh native directory, explicitly select lazy binding—the distribution driver may otherwise request NOW:
gcc -O1 -fPIC -shared -Wl,-soname,libvec.so.1 vec.c -o libvec.so.1
ln -sf libvec.so.1 libvec.so
gcc -O1 main.c -L. -lvec -Wl,-z,lazy -Wl,-rpath,'$ORIGIN' -o main
readelf -d main
readelf -rW main
LD_DEBUG=bindings ./main
LD_BIND_NOW=1 LD_DEBUG=bindings ./main

Record dependencies, RUNPATH, counter's relocation, and whether bump/helper bind before or after transferring control. Repeat with LD_BIND_NOW=1.

  1. Compile the following with native GCC -fPIC -O1, then link with ld.lld -shared. Predict counts and targets for RELATIVE, GLOB_DAT, JUMP_SLOT, and symbol-bearing R_X86_64_64. Repeat with { global: use; local: *; };, then with Clang:
extern int ext_var; /* defined in another module */
extern int ext_fn(int);
static int priv[8];
int pub_var = 1;
static int sfn(int x) { return x; }
__attribute__((visibility("hidden"))) int hid_fn(int x) { return x + 2; }
int pub_fn(int x) { return x + 1; }
int *p_priv = &priv[3];
int *p_pub = &pub_var;
int *p_ext = &ext_var;
const char *names[] = { "red", "green", "blue" };
int (*tbl[])(int) = { sfn, hid_fn, pub_fn, ext_fn };
int use(int x) { return ext_fn(x) + pub_fn(x) + ext_var + pub_var; }
  1. Preload this deliberately incomplete allocator into a program using strdup, puts, and free. Predict which function fails and why:
// fastmalloc.c
#include <stddef.h>
static char arena[1 << 20];
static size_t used;
void *malloc(size_t n) {
void *p = arena + used;
used += (n + 15) & ~(size_t)15;
return p;
}
  1. Build this using native musl static-PIE startup and libc with musl-gcc -static-pie -fPIE -O1 sp.c -o sp, as in Theory 06. Predict cursor's relocation, inspect all runtime relocation locations, and explain why dynamic relocation tags exist without an interpreter or dependency list:
static int table[4] = { 10, 20, 30, 40 };
int *cursor = &table[2];
int main(void) { return *cursor; }

Answers

1. PIE can still contain COPY

0x0000000000000001 (NEEDED) Shared library: [libvec.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
[24] .got PROGBITS 0000000000003fc0 002fc0 000028 08 WA 0 0 8
[25] .got.plt PROGBITS 0000000000003fe8 002fe8 000020 08 WA 0 0 8
[26] .data PROGBITS 0000000000004008 003008 000010 00 WA 0 0 8
Relocation section '.rela.dyn' at offset 0x568 contains 9 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000003db0 000000000008 R_X86_64_RELATIVE 1140
000000003db8 000000000008 R_X86_64_RELATIVE 1100
000000004010 000000000008 R_X86_64_RELATIVE 4010
000000003fc0 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
000000003fc8 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTM[...] + 0
000000003fd0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003fd8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCl[...] + 0
000000003fe0 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0
000000004018 000700000005 R_X86_64_COPY 0000000000004018 counter + 0
Relocation section '.rela.plt' at offset 0x640 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000004000 000500000007 R_X86_64_JUMP_SLO 0000000000000000 bump + 0
1591939: binding file <work>/ex1/libvec.so.1 [0] to ./main [0]: normal symbol `counter'
1591939: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `counter'
1591939: transferring control: ./main
1591939: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `bump'
1591939: binding file <work>/ex1/libvec.so.1 [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `helper'
1591941: binding file <work>/ex1/libvec.so.1 [0] to ./main [0]: normal symbol `counter'
1591941: binding file <work>/ex1/libvec.so.1 [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `helper'
1591941: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `counter'
1591941: binding file ./main [0] to <work>/ex1/libvec.so.1 [0]: normal symbol `bump'
1591941: transferring control: ./main

NEEDED lists libvec.so.1 and libc.so.6; RUNPATH is $ORIGIN. GCC's direct variable access produces a COPY allocation at 0x4018, and the library binds to that copy. With lazy binding, bump and helper bind after control transfers; NOW moves both earlier. The JUMP_SLOT at 0x4000 and the RELATIVE self-pointer at 0x4010 are different jobs.

2. Classify each use, not merely each name

$ musl-gcc -fPIC -O1 -c cnt.c -o cnt_gcc.o
$ ld.lld -shared cnt_gcc.o -o libcnt_gcc.so
$ readelf -r libcnt_gcc.so | awk '/R_X86/{print $3, $5}' | sort | uniq -c
1 R_X86_64_64 ext_fn
1 R_X86_64_64 ext_var
1 R_X86_64_64 pub_fn
1 R_X86_64_64 pub_var
1 R_X86_64_GLOB_DAT ext_var
1 R_X86_64_GLOB_DAT pub_var
1 R_X86_64_JUMP_SLO ext_fn
1 R_X86_64_JUMP_SLO pub_fn
6 R_X86_64_RELATIVE
$ printf '{ global: use; local: *; };\n' > use.map
$ ld.lld -shared --version-script=use.map cnt_gcc.o -o libcnt_v.so
$ readelf -r libcnt_v.so | awk '/R_X86/{print $3, $5}' | sort | uniq -c
1 R_X86_64_64 ext_fn
1 R_X86_64_64 ext_var
1 R_X86_64_GLOB_DAT ext_var
1 R_X86_64_JUMP_SLO ext_fn
8 R_X86_64_RELATIVE

Six relative pointers cover private data, three strings, a static function, and a hidden function. Four symbol-bearing pointers name public/external data and functions. Code adds two GLOB_DAT entries and two JUMP_SLOT entries. A name used both in data and through a GOT slot can legitimately contribute two relocations.

Localizing pub_var and pub_fn converts their stored pointers to RELATIVE, raising that count to eight. Their code references resolve directly, leaving only external GOT/PLT work. Clang inlines pub_fn into use in this example, so its original JUMP_SLOT count is one rather than GCC's two. Both retain the other classifications.

3. Allocation and deallocation must agree

$ LD_PRELOAD=./ex3-gcc/libfast.so LD_DEBUG=bindings ./ex3-gcc/app 2>&1 | grep -E "symbol .(malloc|free|strdup)."
1593718: binding file /usr/lib/x86_64-linux-gnu/libc.so.6 [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `free' [GLIBC_2.2.5]
1593718: binding file /usr/lib/x86_64-linux-gnu/libc.so.6 [0] to ./ex3-gcc/libfast.so [0]: normal symbol `malloc' [GLIBC_2.2.5]
1593718: binding file ./ex3-gcc/app [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `free' [GLIBC_2.2.5]
1593718: binding file ./ex3-gcc/app [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `strdup' [GLIBC_2.2.5]
1593718: binding file ./ex3-gcc/app [0] to /usr/lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `free' [GLIBC_2.2.5]
1593718: binding file ./ex3-gcc/app [0] to ./ex3-gcc/libfast.so [0]: normal symbol `malloc' [GLIBC_2.2.5]

malloc comes from the interposer, but free still comes from libc and interprets the returned pointer using an unrelated allocator's metadata. glibc aborts with status 134; musl faults with status 139 in these runs. Buffered output is not a reliable indicator of where failure occurred.

The demonstration repair supplies compatible free, calloc, and realloc and then prints hello with status zero on both runtimes. A general replacement also needs correct alignment, bounds/overflow handling, allocation-size tracking, concurrency, and the platform's wider allocator interface. The unbounded bump-pointer snippet is intentionally not a production allocator; see glibc's replacement requirements.

4. Dynamic relocation does not imply shared-library lookup

$ musl-gcc -static-pie -fPIE -O1 sp.c -o sp
$ readelf -rW sp
Relocation section '.rela.dyn' at offset 0x208 contains 5 entries:
0000000000003e48 0000000000000008 R_X86_64_RELATIVE 14c0
0000000000003e50 0000000000000008 R_X86_64_RELATIVE 1480
0000000000003ff0 0000000000000008 R_X86_64_RELATIVE 3e58
0000000000004000 0000000000000008 R_X86_64_RELATIVE 4000
0000000000004020 0000000000000008 R_X86_64_RELATIVE 4018
$ nm sp | grep -wE 'cursor|table'
0000000000004020 D cursor
0000000000004010 d table
$ ./sp; echo exit=$?
exit=30

The five sites belong to initializer/finalizer arrays, GOT, __dso_handle, and cursor. table=0x4010; adding eight gives the cursor addend 0x4018. Native execution returns 30 after startup adds the chosen base. The executable needs self-relocation metadata, not an external interpreter or dependency.

With load bias B, startup writes B+0x4018 at B+0x4020, allowing main to read 30 through cursor. Linking determines the target's position within the image; startup supplies B without looking up a definition in another library.

Here .dynamic describes relocation metadata rather than external dependencies. rcrt1.o uses _DYNAMIC to find that metadata and relocates the image before entering ordinary C startup. The kernel establishes mappings; it does not apply this table. Function pointers in initialization and finalization arrays have the same requirement, so one cursor in the source does not imply one runtime relocation in the executable. The count depends on the startup and library contents; explaining each destination is more useful than memorizing five entries.

References and terminology

Appendix: terms and tools

  1. musl is a C-library implementation for Linux, providing standard functions and runtime support. We use it when inspecting or linking a compact static runtime; an ordinary Linux server need not have it installed. Project. ↩

  2. glibc, the GNU C Library, is the default C library in many Linux distributions. Startup files, shared libraries, and the dynamic linker all participate in building and running programs. Project. ↩

  3. GCC, the GNU Compiler Collection, provides compilers for several languages. The gcc command is a driver that coordinates compilation, assembly, and linking; it need not perform all those operations in one process. Overall options. ↩

  4. GNU is the recursive acronym “GNU's Not Unix,” the name of the free-software operating-system project. GCC, binutils, and glibc are distinct GNU projects with different responsibilities. GNU's introduction. ↩

  5. GNU binutils includes the assembler as, linker ld, and inspection or archive utilities such as readelf, nm, objdump, and ar. Documentation. ↩

  6. musl-gcc wraps GCC with musl-specific header and linking settings. It is not inherently a cross-compiler; options such as -static determine the requested linkage. Getting started with musl. ↩

  7. PIC, position-independent code, uses addressing suited to placement at varying load addresses. It is common in shared libraries; the exact use of PC-relative access or indirection depends on the architecture and symbol binding. GCC code-generation options. ↩

  8. GOT, the Global Offset Table, stores addresses or related offsets used through indirection. It lets some address-dependent updates happen in data rather than instruction bytes; relocation types and the ABI define each entry's role. Dynamic linking. ↩

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

  10. PIE, a position-independent executable, can run at different load bases. Compiler and linker choices must cooperate; static PIE also needs a startup path that performs its required relocations. GCC link options. ↩

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

  12. PLT, the Procedure Linkage Table, contains instruction sequences used as call stubs, often together with the GOT and dynamic symbol binding. It is not merely another table of addresses. Dynamic linking. ↩

  13. RELRO, RELocation Read-Only, protects memory that needs relocation-time writes but should become read-only afterward. PT_GNU_RELRO describes the range; the startup path must enforce the protection. GNU ld options. ↩