Relocation Processing
A relocation is a deferred instruction left in an ELF (Executable and Linkable Format) object that says “once you know the final address of X, write it here.” When the kernel maps an executable and its shared libraries at addresses that are not known until load time — the normal case for Position-Independent Executables (PIE) and shared objects — the dynamic linker
ld.somust walk these patch lists and fix up every affected location before the program can run correctly. The patches live in two relocation tables:.rela.dyn(data fix-ups, applied eagerly) and.rela.plt(function fix-ups, applied eagerly or lazily on first call). This note traces exactly whatld.sodoes with those tables, the per-type relocation formula it applies, and the cost and security tensions that drove the modern shift to full RELRO (RELocation Read-Only). It is grounded inld.so(8),elf(5), the x86-64 psABI relocation definitions, and glibc’self/dl-runtime.c.
What this note owns, and what it does not. The boundary in this cluster is the patch record and the pass that applies it. This note owns the Elf64_Rela and Elf64_Relr entry formats, the per-type formulas, the three relocation sections, the order glibc walks them in, DT_RELR compression, and the IRELATIVE second pass. It does not re-explain the tables those patches land in — The Global Offset Table owns .got versus .got.plt, The Procedure Linkage Table owns the stub instruction sequence, and Lazy Binding owns the _dl_runtime_resolve → _dl_fixup first-call path. Nor does it own the tag array that points at the relocation tables: that is The Dynamic Section. Where those notes are needed here they are cited, not repeated.
Mental Model
Think of a freshly mapped program as a half-finished circuit board: the chips (code and data) are placed, but a list of solder joints is left open because the wires’ destinations depend on where neighbouring boards (shared libraries) landed in memory. Each relocation entry is one solder joint with a note attached: here is the slot to fill (r_offset), here is the kind of joint (the relocation type), and here is which symbol’s address goes in it (the symbol index). ld.so is the technician who, before powering the board, reads every note and completes every joint. Two stacks of notes sit on the bench: the data joints (.rela.dyn) must all be done before power-on; the function joints (.rela.plt) can either all be done up front (eager / -z now) or completed one at a time the first time current actually flows through each (lazy binding via the Procedure Linkage Table (PLT)).
flowchart TB KERNEL["execve maps PIE + .so deps<br/>at ASLR-randomized base B"] KERNEL --> LDSO["ld.so begins relocation"] LDSO --> DYN[".rela.dyn (eager, always)<br/>R_X86_64_RELATIVE = B + A<br/>R_X86_64_GLOB_DAT = S<br/>R_X86_64_COPY = copy bytes"] LDSO --> PLT[".rela.plt (function slots)<br/>R_X86_64_JUMP_SLOT = S"] PLT --> NOW{"BIND_NOW set?<br/>(-z now / LD_BIND_NOW /<br/>DF_BIND_NOW)"} NOW -- "yes (full RELRO)" --> EAGER["resolve ALL JUMP_SLOTs now,<br/>then mprotect GOT read-only"] NOW -- "no (lazy, historical default)" --> LAZY["leave GOT pointing at PLT stub;<br/>resolve on first call via _dl_fixup"] DYN --> RELRO["mprotect .rela.dyn-covered<br/>GOT/.dynamic read-only (RELRO segment)"] EAGER --> RUN["jump to entry point"] LAZY --> RUN RELRO --> RUN
What it shows: the two relocation tables and the eager-vs-lazy fork for .rela.plt. The insight to take: .rela.dyn is non-negotiably eager and its targets get locked read-only by RELRO; .rela.plt is the only part that can be deferred, and deferring it is exactly what keeps the Global Offset Table writable — the security hole that full RELRO closes by forcing BIND_NOW.
Who Fills It In, and When
The single organising question for this whole topic is who writes the value, and at what moment. A relocation is a record of something the compiler could not know, escalated to whoever can know it. There are exactly three fillers, and every relocation type on x86-64 belongs to one of them.
- The static linker (
ld), at link time. Most relocations never survive to runtime at all. Whengcccompilesprintf("%d\n", g)it emitsR_X86_64_PLT32for the call andR_X86_64_REX_GOTPCRELXfor the global — but those are link-time relocations living in the.ofile’s.rela.text.ldconsumes them, computes the displacement, patches the instruction bytes, and the relocation is gone from the output. Nothing of it reachesld.so. - The dynamic linker (
ld.so), at load time or first call. A residue survives linking because it depends on the load address (B) or on a symbol that another object owns (S). These are the dynamic relocations —R_X86_64_RELATIVE,GLOB_DAT,JUMP_SLOT,COPY, the TLS family — and they are what.rela.dyn,.rela.pltand.relr.dyncontain. - The program itself, at load time, via
R_X86_64_IRELATIVE. A single type hands the decision back to code shipped inside the object:ld.socalls a function in the binary and stores whatever pointer it returns. This is GNU IFUNC, and it is the only relocation type whose value is computed by running user code. See IRELATIVE — The Program Resolves Its Own Relocation below.
The practical consequence is that readelf -r shows you two completely different populations depending on what you point it at. On a .o you see the link-time set; on the linked executable or .so you see only the residue. Compiled on Fedora 44, GCC 16.2.1 20260819 (Red Hat 16.2.1-2), binutils 2.46.1-1.fc44, glibc 2.43 (all versions in this note verified on 2026-09-12 with gcc --version, ld --version, ldd --version, rpm -q):
/* obj.c */
#include <stdio.h>
extern int g;
int arr[4];
long *p = (long *)&arr[2]; /* R_X86_64_64 with addend 8 */
void f(void){ printf("%d\n", g); }$ gcc -O2 -c -fPIC obj.c -o obj_pic.o && readelf -rW obj_pic.o
Relocation section '.rela.text' at offset 0x220 contains 3 entries:
Offset Info Type Symbol's Name + Addend
0000000000000003 000000060000002a R_X86_64_REX_GOTPCRELX g - 4
000000000000000a 0000000300000002 R_X86_64_PC32 .LC0 - 4
0000000000000013 0000000700000004 R_X86_64_PLT32 printf - 4
Relocation section '.rela.data.rel' at offset 0x268 contains 1 entry:
0000000000000000 0000000900000001 R_X86_64_64 arr + 8Three of those four die at link time. Only the R_X86_64_64 on p has any chance of surviving, and only because &arr[2] is an absolute address that moves with the load bias — in the linked shared object it is rewritten as a base-relative fix-up. Note the - 4 addends on the PC-relative types: P is the address of the four-byte displacement field, but the CPU computes %rip-relative addresses from the end of the instruction, so the linker folds a -4 correction into A. That is not a special case in the formula; it is the assembler pre-loading the addend so that plain S + A - P comes out right.
flowchart LR SRC["C source<br/>printf(g)<br/>long *p = &arr[2]"] --> CC["gcc -c"] CC --> OBJ[".o file<br/>.rela.text + .rela.data<br/>PLT32, GOTPCRELX,<br/>PC32, 64"] OBJ --> LD["ld — static linker<br/>knows every section's<br/>final offset"] LD -->|"resolved and discarded"| GONE["displacements patched<br/>into instruction bytes"] LD -->|"cannot know B or S"| DYNREL["dynamic relocations<br/>.rela.dyn / .rela.plt / .relr.dyn"] DYNREL --> LDSO["ld.so — dynamic linker<br/>knows the load bias B<br/>and the lookup scope"] LDSO -->|"B + A, S, S + A"| PATCHED["GOT slots and data<br/>words patched in memory"] LDSO -->|"R_X86_64_IRELATIVE:<br/>calls back into the binary"| SELF["the program's own<br/>ifunc resolver decides"] SELF --> PATCHED
The three fillers of a relocation. What it shows: the same mechanism — “leave a hole and a note” — is applied twice in sequence, once by the compiler for the linker and once by the linker for the loader, plus a third path where the loader delegates back to the program. The insight: whether a relocation type is “static” or “dynamic” is not a property of the type’s arithmetic but of when the inputs become knowable; PLT32 needs only section offsets, RELATIVE needs B, JUMP_SLOT needs the lookup scope, and IRELATIVE needs the CPU you happen to be running on.
The Relocation Entry and Its Formula
On 64-bit Linux every dynamic relocation is an Elf64_Rela record — Linux/x86-64 uses the explicit-addend (“RELA”) form, not the implicit-addend Elf64_Rel form (per elf(5)):
typedef struct {
Elf64_Addr r_offset; // where to write: virtual address of the slot to patch
uint64_t r_info; // packed: symbol-table index (high 32b) + reloc type (low 32b)
int64_t r_addend; // constant A folded into the computed value
} Elf64_Rela;packet-beta 0-63: "r_offset — Elf64_Addr, 8 bytes: link-time address of the slot to patch" 64-95: "ELF64_R_TYPE(r_info) — relocation type, low 32 bits" 96-127: "ELF64_R_SYM(r_info) — .dynsym index, high 32 bits" 128-191: "r_addend — int64_t, 8 bytes: the constant A (SIGNED)"
The 24-byte Elf64_Rela record, drawn at bit accuracy. What it shows: r_info is one 64-bit field carrying two independent 32-bit quantities, little-endian, so the type occupies the low-addressed four bytes and the symbol index the high four — which is why the real Info column 0000000700000006 from libdemo.so.1 below reads as “symbol 7, type 6” — .dynsym index 7 (libvar), R_X86_64_GLOB_DAT. The insight: the symbol index and the type are packed together because they are always used together; a relocation that needs no symbol simply stores index 0, which is what makes R_X86_64_RELATIVE’s r_info the memorable constant 0x0000000000000008.
Per elf(5), r_offset for an executable or shared object is “the virtual address of the storage unit affected by the relocation”; r_info “gives both the symbol table index … and the type of relocation to apply”; and r_addend “specifies a constant addend used to compute the value to be stored.” Two macros split r_info: ELF64_R_SYM(r_info) yields the index into .dynsym, and ELF64_R_TYPE(r_info) yields the processor-specific relocation type. Each type defines a formula over a small alphabet of symbols, defined in the x86-64 psABI (mirrored in the Oracle amd64 relocation table):
S— the runtime address (value) of the symbol named byELF64_R_SYM, found by symbol lookup.A— the addend, taken directly fromr_addend.B— the base address at which this object was loaded (the ASLR-randomized load bias; for a PIE this is non-zero).P— the place being relocated, i.e.r_offsetitself (used only by PC-relative types).
ld.so’s job for each entry is mechanical: compute the formula, then write the result into the eight bytes (word64) at r_offset. The four types that dominate dynamic loading are:
| Type | Value | Formula | Meaning |
|---|---|---|---|
R_X86_64_RELATIVE | 8 | B + A | base fix-up; no symbol |
R_X86_64_GLOB_DAT | 6 | S | fill a GOT data slot with a symbol address |
R_X86_64_JUMP_SLOT | 7 | S | fill a GOT function slot (the PLT target) |
R_X86_64_COPY | 5 | (special — copy bytes) | pull a data object’s bytes into the executable |
R_X86_64_64 | 1 | S + A | a plain 64-bit absolute pointer in data |
A subtlety worth pinning: although a tool like maskray’s PLT write-up describes the result of resolving a JUMP_SLOT informally as “S + A”, the psABI field calculation for GLOB_DAT and JUMP_SLOT is plain S — the linker pre-stores any constant offset elsewhere; these GOT slots receive the bare symbol address. The + A worded in lazy-binding walk-throughs refers to the symbol-lookup result, not an addend folded by the formula. R_X86_64_64 is the type that genuinely carries S + A (a pointer-into-the-middle-of-an-object, e.g. &array[3]).
The Full Symbol Alphabet
The four symbols above (S, A, B, P) are the ones dynamic relocations need, but the psABI defines eight. All eight are quoted verbatim from the x86-64 psABI’s object-files.tex at commit e1ce0983 (12 March 2025, the tip of master as of 2026-09-12).
| Sym | psABI definition | Known to |
|---|---|---|
A | “the addend used to compute the value of the relocatable field” | assembler (written into r_addend) |
B | “the base address at which a shared object has been loaded into memory during execution” | ld.so only |
G | “the offset into the global offset table at which the relocation entry’s symbol will reside during execution” | ld |
GOT | “the address of the global offset table” | ld (as an offset), ld.so (as an address) |
L | “the place … of the Procedure Linkage Table entry for a symbol” | ld |
P | “the place … of the storage unit being relocated (computed using r_offset)” | ld for link-time types, ld.so for load-time ones |
S | “the value of the symbol whose index resides in the relocation entry” | ld if the definition is local, otherwise ld.so |
Z | “the size of the symbol whose index resides in the relocation entry” | ld |
The column on the right is the whole point: a relocation is dynamic exactly when its formula contains a term that only ld.so can supply. B is unknowable to ld under ASLR, and S is unknowable whenever the defining object is a different shared object, or when the definition is preemptible — interposable by [[Symbol Interposition and LD_PRELOAD|LD_PRELOAD]] or by an earlier object in the lookup scope. Everything else the static linker finishes itself.
Uncertain
Verify: the exact revision of the x86-64 psABI. Reason: the document has no current numbered release. Its GitLab repository does carry tags, but the newest,
abi_0_96, dates from 2005 (confirmed 2026-09-12 via/api/v4/.../repository/tags); everyone in practice citesmaster. To resolve: cite the commit hash, as done above, rather than a version number, and re-checkmasterif a formula looks unfamiliar. uncertain
Every Relocation Type That Appears in Shipped Binaries
The psABI table lists over forty types. Only a dozen or so actually show up in objects a distribution ships, and knowing which is which is most of the practical skill. The table below merges the psABI’s Calculation column with what was measured on this machine on 2026-09-12 — every row was reproduced with a small build, and the “Seen in” column names the section the type actually lands in.
| Type | Val | Field | Calculation | Emitted by | Resolved by, when | Seen in |
|---|---|---|---|---|---|---|
R_X86_64_64 | 1 | word64 | S + A | assembler, for an absolute 64-bit pointer in data | ld if the target is local; otherwise survives as a dynamic reloc | .rela.text/.rela.data in a .o |
R_X86_64_PC32 | 2 | word32 | S + A - P | assembler, for %rip-relative data access and non-PLT calls | ld, always — never dynamic | .rela.text in a .o |
R_X86_64_PLT32 | 4 | word32 | L + A - P | assembler, for every call/jmp to a possibly-external function | ld, always — it becomes call <stub> or is relaxed to a direct call | .rela.text in a .o |
R_X86_64_COPY | 5 | none | none (byte copy) | ld, when an executable references a data object defined in a .so | ld.so, eagerly, by memcpying st_size bytes out of the library | .rela.dyn of an executable |
R_X86_64_GLOB_DAT | 6 | wordclass | S | ld, for a GOT slot holding a preemptible symbol’s address | ld.so, eagerly, after a full scope lookup | .rela.dyn |
R_X86_64_JUMP_SLOT | 7 | wordclass | S | ld, one per imported function reached through the PLT | ld.so — eagerly under BIND_NOW, else on first call | .rela.plt |
R_X86_64_RELATIVE | 8 | wordclass | B + A | ld, for every absolute pointer inside a PIC/PIE image | ld.so, eagerly, with no symbol lookup at all | .rela.dyn, or packed into .relr.dyn |
R_X86_64_32 | 10 | word32 | S + A | assembler under -fno-pic, for a 32-bit absolute address | ld; link fails (“relocation truncated”) if the value exceeds 32 bits | .rela.text in a non-PIC .o |
R_X86_64_DTPMOD64 | 16 | word64 | (TLS — module id) | ld, for general-dynamic TLS | ld.so, eagerly, writing the module’s l_tls_modid | .rela.dyn |
R_X86_64_DTPOFF64 | 17 | word64 | (TLS — offset in block) | ld, alongside the DTPMOD64 of the same pair | ld.so, eagerly | .rela.dyn |
R_X86_64_TPOFF64 | 18 | word64 | (TLS — offset from %fs:0) | ld, for the initial-exec TLS model | ld.so, eagerly, once the static TLS block is laid out | .rela.dyn |
R_X86_64_TLSDESC | 36 | word64 ×2 | (TLS descriptor pair) | ld under -mtls-dialect=gnu2 | ld.so, eagerly, filling a {resolver, argument} pair | .rela.dyn |
R_X86_64_IRELATIVE | 37 | wordclass | indirect (B + A) | ld, for every __attribute__((ifunc)) target | ld.so, eagerly — by calling the function at B + A and storing its return value | .rela.plt |
R_X86_64_REX_GOTPCRELX | 42 | word32 | G + GOT + A - P | assembler, for a GOT-indirect load with a REX prefix | ld, which may relax it into a plain lea and delete the GOT slot | .rela.text in a .o |
Two rows deserve a second look because they contradict things that are widely repeated.
GLOB_DAT and JUMP_SLOT compute plain S, not S + A. The psABI’s Calculation column for both is the bare letter S. Informal walk-throughs of lazy binding often write “S + A” because _dl_fixup’s local variable holds a looked-up value that has an addend folded in elsewhere; the field calculation has no addend. R_X86_64_64 is the type that genuinely carries S + A, which is why the p = &arr[2] example above shows arr + 8 and not arr.
R_X86_64_COPY is not eliminated by PIE. The standard story is that copy relocations are a non-PIE artefact. Measured here, that is false: the same source, compiled -fPIE -pie, still produces one.
$ gcc -O2 -fPIE -pie usecopy.c -o usecopy_pie -L. -ldemo -Wl,-rpath,'$ORIGIN'
$ readelf -rW usecopy_pie | grep COPY
000000000000301c 0000000800000005 R_X86_64_COPY 000000000000301c libvar + 0What PIE changes is addressability, not preemptibility. An executable — position-independent or not — is at the head of the lookup scope and its definitions can never be preempted, so the linker is still free to allocate storage for libvar in the executable’s own .bss and have ld.so copy the library’s initialiser into it. Suppressing the copy relocation means forcing the reference through the GOT. Two routes were measured here, and the obvious guess is not one of them — GCC 16.2.1 has no -fno-direct-access-external-data (gcc: error: unrecognized command-line option); that switch is Clang’s. What works:
$ clang -O2 -fPIE -fno-direct-access-external-data usecopy.c -o usecopy_clang -L. -ldemo
$ readelf -rW usecopy_clang | grep -c COPY
0
$ gcc -O2 -fPIC -pie usecopy.c -o usecopy_fpic -L. -ldemo # note -fPIC, not -fPIE
$ readelf -rW usecopy_fpic | grep libvar
0000000000002fd0 0000000600000006 R_X86_64_GLOB_DAT 0000000000000000 libvar + 0-fPIC differs from -fPIE in exactly this respect: it tells GCC to assume the symbol may be defined elsewhere and to route the access through a GOT slot, which turns the COPY into a GLOB_DAT. The failure mode a copy relocation creates is genuine and silent: st_size is baked into the executable, so growing an exported variable in a later library release corrupts memory in binaries built against the old one, and ldd reports nothing wrong.
What ld.so Does With .rela.dyn (Eager, Always)
The .rela.dyn table is reached through the dynamic-section tag DT_RELA (with DT_RELASZ giving its size). ld.so processes it in full before the program runs — there is no lazy option for data relocations, because data can be read at any instant and there is no per-access trampoline to intercept the first touch.
The overwhelming majority of entries are R_X86_64_RELATIVE. For a PIE or shared object, every absolute pointer baked into the data segment at link time (a vtable pointer, a static const char *msg = "hi";, a function-pointer table) was written assuming a load address of 0, so each needs B added. Drepper notes these dominate by count and are cheap precisely because they carry no symbol — “processing a relative relocation is much less expensive than a normal relocation” (per How To Write Shared Libraries) since no string comparison or scope search is involved; the formula is just “add the load bias.” ld.so reads r_offset, computes B + A where A is the link-time value, and stores it. glibc special-cases these in a tight elf_dynamic_do_Rela loop, and a dedicated DT_RELR/R_X86_64_RELATIVE-compaction scheme (relative-relocation packing) exists to shrink the table further on modern toolchains.
R_X86_64_GLOB_DAT entries fill GOT data slots for symbols that are preemptible (could be interposed) — for instance a reference to stdout or environ defined in libc. ld.so performs a full symbol lookup to obtain S, then writes S into the GOT slot at r_offset. R_X86_64_COPY is the oddball: it appears in executables (not PIEs built -fno-pic, classically) that reference a global variable defined in a shared library. Because the executable’s code took the variable’s address at link time as a fixed location in its own BSS, ld.so must copy the variable’s bytes from the library into the executable’s storage, and the library’s own references are then redirected (by GLOB_DAT) to that copy. Copy relocations are a known design wart — they freeze the variable’s size into the executable’s ABI — and PIE plus -fno-plt-style codegen has been steadily reducing their use.
Once .rela.dyn’s targets are patched, the RELRO segment (PT_GNU_RELRO program header) is mprotect’d read-only — .dynamic, the non-PLT .got, .init_array/.fini_array — so nothing can rewrite those resolved pointers afterward (per the OpenSSF hardening guide).
What ld.so Does With .rela.plt (Eager or Lazy)
The .rela.plt table, reached via DT_JMPREL (DT_PLTRELSZ for its size, DT_PLTREL declaring it is RELA), holds one R_X86_64_JUMP_SLOT per imported function. This is the only table whose processing can be deferred.
Lazy binding (historical default). Before relocation, each .got.plt function slot points back into its own PLT stub — specifically to the pushq $index; jmp PLT[0] tail. On the first call, control flows: call func@plt → the stub does jmpq *GOT_slot(%rip), which (still unresolved) lands on the very next instruction pushq $reloc_index → jmp PLT[0], the resolver trampoline. PLT[0] pushes the link-map descriptor from .got.plt[1] and jumps through .got.plt[2] into glibc’s _dl_runtime_resolve, which calls _dl_fixup (per glibc dl-runtime.c). _dl_fixup reads the JUMP_SLOT reloc at D_PTR(l, l_info[DT_JMPREL]) + reloc_offset, asserts ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT, calls _dl_lookup_symbol_x to find S, computes the value, and writes it into the GOT slot at l->l_addr + reloc->r_offset via elf_machine_fixup_plt. Every subsequent call jumps straight through the now-patched GOT slot — the resolver runs exactly once per function.
Eager binding (-z now / LD_BIND_NOW / DF_BIND_NOW). When the binary carries the DF_BIND_NOW flag (DT_FLAGS) or DF_1_NOW (DT_FLAGS_1), or when the environment variable LD_BIND_NOW is set, ld.so processes the entire .rela.plt table up front — resolving every JUMP_SLOT and writing the address into the GOT before the program runs. ld.so(8) states LD_BIND_NOW “causes the dynamic linker to resolve all symbols at program startup instead of deferring function call resolution to the point when they are first referenced” (per ld.so(8)). With nothing left to patch lazily, the .got.plt can then be made read-only — this is full RELRO.
A debugging lever worth knowing: LD_BIND_NOT, which tells ld.so to resolve symbols normally but not update the GOT/PLT afterward, so every call re-enters the resolver — useful for observing bindings under LD_DEBUG without the one-shot patch hiding repeats.
The Three Relocation Sections and the Order They Are Processed In
A modern x86-64 object carries up to three relocation sections. They are not interchangeable: each has a different entry format, a different dynamic-section tag pair, and a fixed position in the loader’s sequence.
| Section | SHT_ type | Entry | Tags in .dynamic | Holds | Can be lazy? |
|---|---|---|---|---|---|
.relr.dyn | SHT_RELR (19) | Elf64_Relr — one bare 8-byte word | DT_RELR (36), DT_RELRSZ (35), DT_RELRENT (37) | only R_X86_64_RELATIVE, bit-packed | no |
.rela.dyn | SHT_RELA (4) | Elf64_Rela — 24 bytes | DT_RELA (7), DT_RELASZ (8), DT_RELAENT (9) | RELATIVE, GLOB_DAT, COPY, all TLS types | no |
.rela.plt | SHT_RELA (4) | Elf64_Rela — 24 bytes | DT_JMPREL (23), DT_PLTRELSZ (2), DT_PLTREL (20) | JUMP_SLOT, IRELATIVE | yes, the JUMP_SLOTs only |
The order is not folklore; it is one macro. glibc 2.43’s elf/dynamic-link.h defines the entire relocation pass for an object as:
# define ELF_DYNAMIC_RELOCATE(map, scope, lazy, consider_profile, skip_ifunc) \
do { \
int edr_lazy = elf_machine_runtime_setup ((map), (scope), (lazy), \
(consider_profile)); \
if (!is_rtld_link_map (map) || DO_RTLD_BOOTSTRAP) \
ELF_DYNAMIC_DO_RELR (map); \
ELF_DYNAMIC_DO_REL ((map), (scope), edr_lazy, skip_ifunc); \
ELF_DYNAMIC_DO_RELA ((map), (scope), edr_lazy, skip_ifunc); \
ELF_DYNAMIC_AFTER_RELOC ((map), (edr_lazy)); \
} while (0)Read line by line, that is the whole loader-side story for one object (glibc 2.43 elf/dynamic-link.h):
elf_machine_runtime_setupinstalls the lazy-binding plumbing if lazy binding is in play (on x86-64 it writes thelink_mappointer into.got.plt[1]and_dl_runtime_resolveinto.got.plt[2]) and returns the effective laziness — it can downgrade a caller’slazy=1to 0. See Lazy Binding and The Global Offset Table for those two slots.ELF_DYNAMIC_DO_RELRapplies the packed relative relocations. It runs first, before any symbolic work, because it needs nothing exceptl_addr.ELF_DYNAMIC_DO_RELis a no-op on x86-64 (ELF_MACHINE_NO_RELis set — the architecture is RELA-only).ELF_DYNAMIC_DO_RELAwalks.rela.dynand.rela.plt.ELF_DYNAMIC_AFTER_RELOCis a per-architecture hook. It is empty on most targets; on x86-64 it is the PLT-rewriting pass described at the end of this note.
The ranges[2] Trick
Step 4 is more interesting than it looks. _ELF_DYNAMIC_DO_RELOC does not process .rela.dyn and .rela.plt as two separate walks by default. It builds a two-element ranges[] array and then — the comment in the source is explicit that the ELF specification permits DT_JMPREL to fall inside the DT_RELA range — checks whether the PLT table begins exactly where the general table ends:
if (!(do_lazy)
&& (ranges[0].start + ranges[0].size) == start)
{
/* Combine processing the sections. */
ranges[0].size += size;
}
else
{
ranges[1].start = start;
ranges[1].size = size;
ranges[1].lazy = (do_lazy);
}Under BIND_NOW with the two sections adjacent — which is exactly what ld emits — the loader makes one contiguous pass over both, and the second range stays empty. Laziness is what forces them apart: ranges[1].lazy is the flag that sends .rela.plt down the elf_machine_lazy_rel path instead. This is a small but real reason eager binding is not simply “lazy plus more work”: it also removes a loop boundary and a second set of bounds checks.
Note also the guard that opens the macro, which only makes sense once DT_RELR exists:
/* With DT_RELR, DT_RELA/DT_REL can have zero value. */
if ((map)->l_info[DT_##RELOC] != NULL
&& (map)->l_info[DT_##RELOC]->d_un.d_ptr != 0)An object whose relative relocations are all packed and which has no symbolic relocations at all legitimately ships DT_RELA with a zero address, and the loader must not dereference it.
flowchart TB START["_dl_relocate_object(map)"] --> SETUP["elf_machine_runtime_setup<br/>write .got.plt[1] = link_map<br/>write .got.plt[2] = _dl_runtime_resolve<br/>returns edr_lazy"] SETUP --> RELR["ELF_DYNAMIC_DO_RELR<br/>walk DT_RELR bitmap<br/>*addr += l_addr<br/>no symbol lookup"] RELR --> SKIPREL["ELF_DYNAMIC_DO_REL<br/>no-op on x86-64<br/>(ELF_MACHINE_NO_REL)"] SKIPREL --> RANGES{"BIND_NOW and<br/>.rela.plt adjacent to<br/>.rela.dyn?"} RANGES -- yes --> ONE["one contiguous pass<br/>ranges[0] covers both"] RANGES -- no --> TWO["ranges[0] = .rela.dyn (eager)<br/>ranges[1] = .rela.plt (lazy)"] ONE --> FAST["first DT_RELACOUNT entries:<br/>elf_machine_rela_relative<br/>(no symbol lookup)"] TWO --> FAST FAST --> SYMB["remaining entries:<br/>elf_machine_rela<br/>RESOLVE_MAP -> scope search"] SYMB --> DEFER["IRELATIVE entries skipped<br/>and remembered in r2..end2"] DEFER --> SECOND["second pass: run each<br/>ifunc resolver, store result"] SECOND --> AFTER["ELF_DYNAMIC_AFTER_RELOC<br/>x86-64: optional PLT rewrite"] AFTER --> RELRO["_dl_protect_relro:<br/>mprotect PT_GNU_RELRO PROT_READ"]
The relocation pass for one object in glibc 2.43, as coded. What it shows: the fixed order — RELR, then REL, then RELA, then the architecture hook — and the two places the loop branches: the eager/lazy range split, and the deliberate deferral of IRELATIVE. The insight: everything cheap happens before anything that needs a symbol lookup, and the one thing that needs to run arbitrary program code (IRELATIVE) is pushed to the very end, after every other relocation in the same table is already in place.
One R_X86_64_RELATIVE, Applied, With the Arithmetic
Everything above is easier to hold once you have watched a single entry go through. R_X86_64_RELATIVE is the right one to watch because it is both the simplest and, by count, the overwhelming majority.
The relocation says: the eight bytes at r_offset contain a link-time address; add the load bias. Written as the psABI writes it, the field gets B + A; written as memory, the loader does:
*(uint64_t *)(B + r_offset) = B + r_addend
Take a real one. From libdemo.so.1, built here with gcc -O2 -shared -fPIC:
$ readelf -rW libdemo.so.1 | head -5
Relocation section '.rela.dyn' at offset 0x1240 contains 9 entries:
Offset Info Type Symbol's Value Name + Addend
0000000000002db0 0000000000000008 R_X86_64_RELATIVE 3e0
0000000000002db8 0000000000000008 R_X86_64_RELATIVE 3a0
0000000000002dc0 0000000000000008 R_X86_64_RELATIVE 2dc0r_info is 0x0000000000000008: ELF64_R_SYM (the high 32 bits) is 0 — no symbol, which is the entire reason this type is cheap — and ELF64_R_TYPE (the low 32 bits) is 8, R_X86_64_RELATIVE. Suppose the kernel maps this object at B = 0x7f2c4a600000. Then the first entry resolves as:
flowchart LR subgraph ENTRY["Elf64_Rela entry (24 bytes in .rela.dyn)"] RO["r_offset<br/>0x0000_0000_0000_2db0"] RI["r_info<br/>0x0000_0000_0000_0008<br/>sym = 0, type = 8"] RA["r_addend<br/>0x0000_0000_0000_03e0"] end B["load bias B<br/>0x7f2c_4a60_0000<br/>(chosen by the kernel, ASLR)"] RO --> DST["destination address<br/>B + r_offset<br/>= 0x7f2c_4a60_2db0"] B --> DST RA --> VAL["value to store<br/>B + r_addend<br/>= 0x7f2c_4a60_03e0"] B --> VAL DST --> W["store: *(u64 *)0x7f2c_4a60_2db0<br/>= 0x7f2c_4a60_03e0"] VAL --> W W --> NOTE["no .dynsym read<br/>no .dynstr read<br/>no strcmp<br/>no scope walk"]
One R_X86_64_RELATIVE applied. What it shows: both operands of the store are B plus a constant taken straight from the entry; the two constants play different roles — r_offset locates the slot, r_addend is the link-time value of what goes in it. The insight: the destination and the value are computed from the same bias, which is why a relative relocation is a pure add with no memory reads other than the entry itself, and why glibc gives it a dedicated tight loop (elf_machine_rela_relative) that never touches the symbol table.
The addend is the link-time address of whatever the pointer pointed at: 0x3e0 and 0x3a0 are offsets of string literals in .rodata, and 0x2dc0 is a self-reference (a pointer whose target is the slot itself — the __dso_handle pattern). Because the whole computation is B + constant, the linker can afford to emit one of these for every absolute pointer in the data segment, and that is exactly what it does. It is also why the count explodes on C++ code: every vtable is an array of absolute function pointers.
DT_RELR — the Common Case, Not an Exotic One
R_X86_64_RELATIVE entries cost 24 bytes each on disk to express what is, semantically, a single bit: this word needs the bias added. DT_RELR (SHT_RELR, section .relr.dyn) is the fix — a run-length bitmap that stores only the positions. It is easy to file it under “new and rare”. On this machine it is neither.
Measured: how common
Scanning every ELF object under /usr/bin, /usr/sbin, /usr/lib64 and /usr/libexec on Fedora 44, 2026-09-12 — 7,602 distinct files, identified by magic rather than by extension:
| Population | Count | Carries .relr.dyn |
|---|---|---|
| All ELF objects scanned | 7,602 | 7,336 — 96.5 % |
ET_DYN (PIE executables + shared objects) | 7,506 | 7,336 — 97.7 % |
ET_EXEC (position-dependent executables) | 96 | 0 — 0 % |
The ET_EXEC row is not a gap in coverage; it is the definition. -z pack-relative-relocs “is ignored when building position-dependent executable and relocatable output” (binutils 2.46 ld manual) because a non-PIE executable is loaded at its link address, B is zero, and glibc skips the relative loop entirely (if (l_addr != 0) in elf/do-rel.h). The 170 ET_DYN objects that lack it are the ones that did not go through Fedora’s RPM link flags. Classified by scanning each file for a toolchain marker: 50 contain an rustc string, 9 contain a Go build ID (/usr/bin/docker, /usr/bin/containerd, /usr/bin/ctr), and the remaining 111 are C objects built with bespoke link lines — 37 rsyslog plugins, 8 traceevent plugins, and assorted libexec helpers. That distribution is itself the lesson: whether a shipped object gets DT_RELR is a packaging property, not a language or toolchain one.
Which is the second half of the story: .relr.dyn is not a toolchain default. ld --help on binutils 2.46.1 prints -z nopack-relative-relocs Do not pack relative relocations (default). It reaches 96.5 % of the filesystem because Fedora puts it in the RPM link flags:
$ rpm --eval '%{build_ldflags}'
-Wl,-z,relro -Wl,--as-needed -Wl,-z,pack-relative-relocs -Wl,-z,now
-specs=/usr/lib/rpm/redhat/redhat-hardened-ld ... -Wl,--build-id=sha1Measured: how much it saves
Forty pointers into the object’s own data — the shape that generates relative relocations in bulk:
$ gcc -O2 -fPIE -pie many.c -o many_norelr
$ readelf -SW many_norelr | grep rela.dyn
[12] .rela.dyn RELA 00000000000011b8 0011b8 000480 18 A 8 0 8
$ readelf -dW many_norelr | grep -E 'RELASZ|RELACOUNT'
0x0000000000000008 (RELASZ) 1152 (bytes)
0x000000006ffffff9 (RELACOUNT) 43
$ gcc -O2 -fPIE -pie -Wl,-z,pack-relative-relocs many.c -o many_relr
$ readelf -dW many_relr | grep -E 'RELASZ|RELR'
0x0000000000000008 (RELASZ) 120 (bytes)
0x0000000000000024 (RELR) 0x1280
0x0000000000000023 (RELRSZ) 24 (bytes)
0x0000000000000025 (RELRENT) 8 (bytes)43 relative relocations occupying 1,032 bytes of Elf64_Rela became 24 bytes of Elf64_Relr — a 43× reduction on that subset, and the file’s total relocation payload fell from 1,152 to 144 bytes. Note the second effect: DT_RELACOUNT (43) disappeared from the packed build, because there are no leading relative entries left in .rela.dyn for it to count.
Decoding the bitmap by hand
The encoding is the part that is genuinely hard to picture, so here it is decoded from raw bytes against readelf’s own answer. ld puts .relr.dyn at file offset 0x1280, 24 bytes:
$ xxd -s 0x1280 -l 24 many_relr
00001280: 982d 0000 0000 0000 0700 0000 0000 0000 .-..............
00001290: 0100 c0ff ffff ff3fLittle-endian, that is three 8-byte words: 0x0000000000002d98, 0x0000000000000007, 0x3fffffffffc00001. The rule, from glibc 2.43’s ELF_DYNAMIC_DO_RELR in elf/dynamic-link.h, is twenty lines long and worth reading in full:
for (; r < end; r++)
{
ElfW(Relr) entry = *r;
if ((entry & 1) == 0) /* EVEN: an address */
{
where = (ElfW(Addr) *)(l_addr + entry);
*where++ += l_addr; /* relocate, then step */
}
else /* ODD: a bitmap */
{
for (long int i = 0; (entry >>= 1) != 0; i++)
if ((entry & 1) != 0)
where[i] += l_addr;
where += CHAR_BIT * sizeof(ElfW(Relr)) - 1; /* += 63 words */
}
}Walking the three words with that code, where being the cursor:
0x2d98— bit 0 is clear, so this is an address. Relocate the word at0x2d98, then advance the cursor to0x2da0.0x7— bit 0 is set, so this is a bitmap over the 63 words starting at the cursor.entry >>= 1first, so the loop’si = 0corresponds to original bit 1.0x7is0b111: bit 0 is the tag, bits 1 and 2 are payload → relocatewhere[0] = 0x2da0andwhere[1] = 0x2da8. Then the cursor advances by the full 63 words regardless of how many bits were set:0x2da0 + 63×8 = 0x2f98.0x3fffffffffc00001— a bitmap again. Set bits are 0 (the tag) and 22 through 61 inclusive: 40 payload bits. Bit k maps towhere[k-1], so bit 22 →0x2f98 + 21×8 = 0x3040and bit 61 →0x2f98 + 60×8 = 0x3178.
Total: 1 + 2 + 40 = 43 locations, from 0x3040 to 0x3178 in steps of 8 — precisely the 320-byte, 40-entry tab[] array plus three init/fini-array words. readelf agrees exactly:
$ readelf -rW many_relr | sed -n '/relr.dyn/,+6p'
Relocation section '.relr.dyn' at offset 0x1280 contains 3 entries which relocate 43 locations:
Index: Entry Address Symbolic Address
0000: 0000000000002d98 0000000000002d98 __frame_dummy_init_array_entry
0001: 0000000000000007 0000000000002da0 __do_global_dtors_aux_fini_array_entry
0000000000002da8 __dso_handle
0002: 3fffffffffc00001 0000000000003040 tab
0000000000003048 tab + 0x8flowchart TB E0["word 0: 0x0000_0000_0000_2d98<br/>bit0 = 0 → ADDRESS"] E0 --> A0["relocate 0x2d98<br/>cursor := 0x2da0"] A0 --> E1["word 1: 0x0000_0000_0000_0007<br/>bit0 = 1 → BITMAP"] E1 --> B1["bits set: 1, 2<br/>→ 0x2da0, 0x2da8"] B1 --> C1["cursor += 63 words<br/>0x2da0 + 0x1f8 = 0x2f98"] C1 --> E2["word 2: 0x3fff_ffff_ffc0_0001<br/>bit0 = 1 → BITMAP"] E2 --> B2["bits set: 22 … 61 (40 bits)<br/>bit k → cursor + (k-1)*8<br/>→ 0x3040 … 0x3178"] B2 --> C2["cursor += 63 words"] C2 --> DONE["3 words = 24 bytes<br/>encoded 43 relocations<br/>(1,032 bytes as Elf64_Rela)"]
The RELR bitmap decoded step by step. What it shows: the alternation between an address word, which repositions the cursor absolutely, and bitmap words, each of which covers the next 63 machine words and then advances the cursor by 63 whether or not their bits were used. The insight: the encoding is a sparse-bitset-with-restarts, and the reason it works so well on real binaries is that relative relocations come in dense runs — a pointer array, a vtable, a jump table — so one bitmap word usually catches dozens of them, while an address word lets the encoder skip a gap of any size at a cost of one word.
Two subtleties the code makes explicit and prose usually elides. First, the operation is +=, a read-modify-write: RELR has no addend field, so the addend lives in the target word itself, exactly as in the implicit-addend Elf64_Rel form. That is why .relr.dyn can only ever express R_X86_64_RELATIVE — it is the only type whose addend is already sitting in the destination. Second, where is advanced by 63, not 64, on every bitmap word, because bit 0 is spent on the tag.
The GLIBC_ABI_DT_RELR interlock
A binary with unapplied relative relocations does not fail cleanly — every pointer in its data segment is off by the load bias, and it crashes somewhere unhelpful. ELF’s designed mechanism for “this object needs a newer loader” is the EI_ABIVERSION byte in the ELF header, and H.J. Lu’s patch series explains why that mechanism was not used:
The
EI_ABIVERSIONfield of the ELF header in executables and shared libraries can be bumped to indicate the minimum ABI requirement on the dynamic linker. However,EI_ABIVERSIONin executables isn’t checked by the Linux kernel ELF loader nor the existing dynamic linker. Executables will crash mysteriously if the dynamic linker doesn’t support the ABI features required by theEI_ABIVERSIONfield.Add a glibc version,
GLIBC_ABI_DT_RELR, to indicateDT_RELRsupport so that the existing dynamic linkers will issue an error on executables withGLIBC_ABI_DT_RELRdependency.—
[PATCH v11 2/7] Add GLIBC_ABI_DT_RELR for DT_RELR support, libc-alpha, 22 April 2022
The workaround is to reuse a mechanism old loaders do already enforce: symbol versioning. ld adds a version dependency on a deliberately empty version node, and an old ld.so refuses to start the program because it cannot find that version in libc.so.6. The effect is visible with readelf -V:
$ readelf -VW many_relr | sed -n '/Version needs/,$p'
Version needs section '.gnu.version_r' contains 1 entry:
000000: Version: 1 File: libc.so.6 Cnt: 3
0x0010: Name: GLIBC_ABI_DT_RELR Flags: none Version: 4
0x0020: Name: GLIBC_2.2.5 Flags: none Version: 3
0x0030: Name: GLIBC_2.34 Flags: none Version: 2
$ readelf -VW many_norelr | grep -c GLIBC_ABI_DT_RELR
0The dependency appears only in the packed build. The interlock runs both ways: glibc also errors on an object that has a DT_RELR entry and a libc.so.6 version dependency but no GLIBC_ABI_DT_RELR, catching objects assembled by hand or edited after linking. GLIBC_ABI_DT_RELR defines no symbols at all — it exists purely as an assertion, which is why glibc needed a __placeholder_only_for_empty_version_map symbol to make the linker emit an empty version node in the first place.
IRELATIVE — the Program Resolves Its Own Relocation
Every other relocation type is data the loader computes. R_X86_64_IRELATIVE is different in kind: its psABI calculation is indirect (B + A), and the word “indirect” is doing enormous work. B + A is not the value to store — it is the address of a function, which ld.so calls, and whose return value is stored. The program has been handed the pen.
The mechanism is GNU IFUNC, and it exists so that memcpy can pick an AVX-512 implementation on hardware that has one without paying a branch on every call. This note is not the place for IFUNC’s full semantics; what belongs here is what it does to relocation processing, which is unusual enough to be worth showing.
A minimal one, built and run here:
/* ifunc2.c — write(2) rather than printf: the resolver runs before libc is ready */
#include <stdio.h>
#include <unistd.h>
static int impl_a(int x){ return x + 1; }
static void *resolve_bump(void)
{
write(2, "[resolver ran]\n", 15);
return (void *)impl_a;
}
int bump(int) __attribute__((ifunc("resolve_bump")));
__attribute__((constructor)) static void ctor(void){ write(2, "[ctor ran]\n", 11); }
int main(void){ write(2, "[main]\n", 7); printf("bump(1)=%d\n", bump(1)); return 0; }$ gcc -O2 ifunc2.c -o ifunc2 && ./ifunc2
[resolver ran]
[ctor ran]
[main]
bump(1)=2The ordering is the point, and it is not obvious: the resolver runs before __attribute__((constructor)) functions, which themselves run before main (Init Array Constructors and Destructors). It is not “early in the program”; it is inside relocation processing, which finishes before DT_INIT_ARRAY is walked at all. That is why an ifunc resolver may not call into libc — printf may not yet have a resolved GOT slot, and if it did, calling it would be reentering an object mid-relocation.
The relocation itself lands in .rela.plt, not .rela.dyn, alongside the JUMP_SLOTs:
$ gcc -O2 $(rpm --eval '%{build_ldflags}') -fPIE ifunc.c -o ifunc_pie
$ readelf -rW ifunc_pie | sed -n '/rela.plt/,$p'
Relocation section '.rela.plt' at offset 0x1290 contains 4 entries:
Offset Info Type Symbol's Name + Addend
0000000000002fb8 0000000300000007 R_X86_64_JUMP_SLOT printf@GLIBC_2.2.5 + 0
0000000000002fc0 0000000500000007 R_X86_64_JUMP_SLOT getauxval@GLIBC_2.16 + 0
0000000000002fc8 0000000700000007 R_X86_64_JUMP_SLOT __cxa_finalize@GLIBC_2.2.5 + 0
0000000000002fd0 0000000000000025 R_X86_64_IRELATIVE 4f0Read the last line the way the loader does. Type 0x25 is 37, IRELATIVE. The symbol index is 0 — like RELATIVE, it names no symbol. The addend 0x4f0 is the link-time address of resolve_bump. So ld.so computes B + 0x4f0, calls through that pointer, and stores whatever comes back into the GOT slot at B + 0x2fd0.
Why the Loader Defers Them to a Second Pass
Running arbitrary program code in the middle of a relocation loop is dangerous: the resolver may read globals, and those globals may themselves be the targets of relocations the loop has not reached yet. glibc’s answer, visible in elf/do-rel.h, is to make IRELATIVE a strictly second pass over the same table. The main loop does not process them — it records the first and last one seen and skips on:
if (ELFW(R_TYPE) (r->r_info) == ELF_MACHINE_IRELATIVE)
{
if (r2 == NULL)
r2 = r;
end2 = r;
continue;
}
elf_machine_rel (map, scope, r, sym, rversion, r_addr_arg, skip_ifunc);
}
if (r2 != NULL)
for (; r2 <= end2; ++r2)
if (ELFW(R_TYPE) (r2->r_info) == ELF_MACHINE_IRELATIVE)
elf_machine_rel (map, scope, r2, ..., skip_ifunc);The r2/end2 pair brackets the sub-range containing ifuncs, and the second loop re-scans just that bracket, re-testing the type because the range may contain non-ifunc entries in between. The same pattern appears verbatim in the lazy path a few lines above, so it holds whether or not BIND_NOW is set (glibc 2.43 elf/do-rel.h). The contract this buys the resolver author is narrow but real: every other relocation in this object’s table is already applied when your resolver runs. It does not extend across objects — a resolver in libfoo.so cannot assume libbar.so is relocated — which is the usual source of IFUNC ordering bugs.
Note also the skip_ifunc parameter threaded through every one of these calls. It is set when the loader is in a context where running program code would be unsafe or wrong, and it is why elf_machine_rel takes it at all.
Seeing the Resolver’s Choice Take Effect
Because the resolver is real code, its decision is observable. The ifunc.c variant used above dispatches on getauxval(AT_HWCAP2) and returns one of two implementations, x + 1 or x + 100:
$ ./ifunc_pie
bump(1) = 101The 101 is the load-time decision made visible — impl_fast was selected on this CPU. Change the machine and the same binary, byte for byte, stores a different pointer into the same GOT slot. Nothing in the file says which; the file says ask the program.
The Cost of Relocation — Why -z now and Prelink Exist
Relocation is not free, and at scale it is the dominant startup cost. Drepper’s measurement of a trivial /bin/echo under LD_DEBUG=statistics shows relocation eating 50.4% of dynamic-loader startup time (“time needed for relocation: 378004 clock cycles (50.4%)” of 748696 total), with the loader concluding “relocation processing dominates the startup costs with more than 50%” (per dsohowto). For a small program that is 133 relocations; for a large C++ application like the OpenOffice.org case he studies, about 20,000 relocations are performed at startup, each GLOB_DAT/JUMP_SLOT carrying a symbol lookup that walks the lookup scope doing string comparisons. This is the reason large C++ binaries have slow cold starts.
Two historical mitigations attacked this. Prelink (prelink(8), now largely retired) pre-computed library load addresses and patched relocations on disk so ld.so could skip most of the work — Drepper’s text repeatedly footnotes that pre-linking “can reduce significantly or even eliminate the relocation costs.” Prelink fell out of favour because it fundamentally conflicts with ASLR (a fixed prelinked address is a known address). The surviving lever is the eager-vs-lazy trade: -z now front-loads all the PLT work into startup. Counterintuitively this can increase total startup latency — the OpenSSF guide warns “enabling full RELRO can increase the startup time for applications with large numbers of dynamic dependencies” because functions that would never have been called still get resolved. Lazy binding’s whole point was to pay only for functions actually used. The modern judgement is that the security win of a read-only GOT outweighs the startup cost for almost all software.
Re-measured in 2026, and the Trap in the Counter
Drepper’s 50.4 % figure is from 2011-era hardware and a 2006-era toolchain. Re-running the same experiment on Fedora 44 / glibc 2.43, 2026-09-12, the shape of the result survives and the magnitude has if anything got worse for large binaries:
| Binary | Dynamic deps | Total loader cycles | Relocation cycles | Relocations | From cache |
|---|---|---|---|---|---|
ifunc_pie (this note’s toy, PIE + BIND_NOW + RELR) | 1 | 62,865 | 1,752 — 2.7 % | 101 | 7 |
/usr/bin/ls | 3 | 87,123 | 24,003 — 27.5 % | 564 | 7 |
/usr/bin/clang-22 --version | 15 | 1,915,257 | 1,776,045 — 92.7 % | 23,065 | 23,551 |
clang-22 performs 23,065 relocations with a further 23,551 satisfied from the lookup cache, and spends over nine tenths of its dynamic-loader time on them — a direct modern echo of Drepper’s ~20,000-relocation OpenOffice.org case, on a binary that is not unusual. This is what “C++ binaries start slowly” means concretely: it is not code size and it is not page faults, it is strcmp inside the scope walk, 23,000 times.
There is a trap in that output, and it is worth spelling out because it can send you chasing the wrong thing. LD_DEBUG=statistics cannot see DT_RELR relocations at all. It reported number of relative relocations: 4 for /usr/bin/ls, but:
$ readelf -rW /usr/bin/ls | grep 'relr.dyn.*contains'
Relocation section '.relr.dyn' at offset 0x1b028 contains 10 entries which relocate 282 locations:282, not 4. The counter is not sampling or estimating; it structurally cannot count them. glibc 2.43’s print_statistics in elf/rtld.c builds the number by summing two dynamic-section tags across every link_map:
if (l->l_addr != 0 && l->l_info[VERSYMIDX (DT_RELCOUNT)])
num_relative_relocations
+= l->l_info[VERSYMIDX (DT_RELCOUNT)]->d_un.d_val;
...
if (l->l_info[VERSYMIDX (DT_RELACOUNT)])
num_relative_relocations
+= l->l_info[VERSYMIDX (DT_RELACOUNT)]->d_un.d_val;DT_RELCOUNT and DT_RELACOUNT only ever describe leading runs inside .rel.dyn/.rela.dyn. DT_RELR is never consulted, so on a distribution where 96.5 % of objects pack their relative relocations, this line under-reports by roughly the packing ratio. Use readelf -r for counts and reserve LD_DEBUG=statistics for the timings — those are measured, not derived from tags. (Incidentally, this also means /usr/bin/ls gets 80 bytes of .relr.dyn where 282 Elf64_Rela entries would have cost 6,768 — an 84× saving on that section.)
LD_DEBUG=reloc shows the per-object pass rather than the totals, and it is the right tool for “which object is doing all this”:
$ gcc -O2 hello.c -o hello_plain -lm # plain gcc: no -pie, no -z now
$ LD_DEBUG=reloc ./hello_plain 2>&1 | grep 'relocation processing'
2214986: relocation processing: /lib64/libc.so.6
2214986: relocation processing: /lib64/libm.so.6
2214986: relocation processing: ./hello_plain (lazy)
2214986: relocation processing: /lib64/ld-linux-x86-64.so.2
$ LD_DEBUG=reloc ./ifunc_pie 2>&1 | grep 'relocation processing'
2214988: relocation processing: /lib64/libc.so.6
2214988: relocation processing: ./ifunc_pie
2214988: relocation processing: /lib64/ld-linux-x86-64.so.2Two things fall out of those four lines. The order is reverse dependency order — dependencies are relocated before their dependents, and ld.so relocates itself last, because it has been running on a hand-bootstrapped subset of its own relocations since _dl_start. And the (lazy) suffix is printed straight from _dl_relocate_object_no_relro and is the fastest way to confirm whether a given object actually got BIND_NOW, because it reflects the loader’s effective decision — l->l_info[DT_BIND_NOW] != NULL forces lazy = 0 regardless of what the command line asked for. Pair it with LD_DEBUG=bindings to see which definition each symbol bound to, and LD_BIND_NOT=1 to keep the resolver running on every call instead of patching the slot once.
The Security Tension and Full RELRO
Lazy binding has a structural cost beyond startup philosophy: it requires the .got.plt to stay writable at runtime, because _dl_fixup patches it on the fly. A writable, executable-reachable table of function pointers is a classic exploitation target — overwrite a GOT entry and the next call to that function hijacks control flow. Red Hat’s RELRO write-up frames this directly: the defence is to mark relocation targets read-only after ld.so finishes.
- Partial RELRO (
-Wl,-z,relroalone): the linker emits aPT_GNU_RELROsegment covering.dynamic,.init_array,.fini_array, and the non-PLT.got.ld.soresolves.rela.dynand thenmprotects that region read-only. But.got.pltis left writable so lazy binding can keep patching it. - Full RELRO (
-Wl,-z,relro -Wl,-z,now): addsBIND_NOW, forcing the entire.rela.pltto be resolved eagerly so that.got.plttoo can be made read-only. The OpenSSF guide describes this as disabling “lazy binding entirely, allowing the linker to resolve the complete GOT at startup and mark all GOT sections as read-only.” There is then no writable function-pointer table for an attacker to overwrite.
See RELRO Relocation Read-Only for the segment mechanics and Lazy Binding for the PLT trampoline in full.
The Modern Default — Verified on a 2026 Binary
Uncertain — resolved 2026-09-12, see the subsection immediately below
Verify: that PIE + full RELRO (
BIND_NOW) is the distro default in 2026 and not a property of upstream GCC/binutils. Reason: the hardening flags are injected by distro build configs (Fedora RPM specs, Debiandpkg-buildflags, Ubuntu toolchain defaults), and upstream GCC/binutils “do not enable optimization or security hardening options by default” (per the OpenSSF guide). A binary compiled with a bare upstreamgcc foo.cmay be only partial-RELRO or even no-RELRO. To resolve: runreadelf -dlW <binary> | grep -E 'BIND_NOW|RELRO|PIE'(orscanelf -e,hardening-check,checksec) on the specific binary in question rather than assuming. uncertain
Checked on Fedora 43 (glibc 2.42, GCC 15.2.1, GNU ld 2.45.1) on 2026-06-27, the system /bin/ls reports FLAGS BIND_NOW, FLAGS_1 NOW PIE, and carries a GNU_RELRO program header — i.e. PIE + full RELRO + immediate binding is the shipped default. This matches Fedora’s long-standing Harden All Packages policy (full RELRO since Fedora 23) and Ubuntu’s PIE-by-default since 16.10. The historical “lazy binding is the default” statement in ld.so(8) remains true of the dynamic linker’s behaviour when a binary does not request BIND_NOW — but the binaries distros actually ship in 2026 overwhelmingly do request it, so in practice you rarely see lazy binding on a distro-built executable. Date this: it is a 2026 distro-policy fact, not an ELF-format guarantee.
Resolved on Fedora 44 — Where the Hardening Actually Comes From
The callout above was checked directly on Fedora 44 (GCC 16.2.1 20260819, binutils 2.46.1-1.fc44, glibc 2.43-8.fc44) on 2026-09-12, and it is correct: the hardening is a packaging property, not a toolchain one. It is worth having the exact provenance, because this is stated backwards often enough to be worth pinning.
A plain gcc on Fedora 44 produces a non-PIE, lazily-bound executable:
$ gcc -O2 hello.c -o hello_plain -lm
$ readelf -hW hello_plain | grep Type
Type: EXEC (Executable file)
$ readelf -dW hello_plain | grep -cE 'BIND_NOW|FLAGS_1'
0
$ gcc -v 2>&1 | tr ' ' '\n' | grep -c enable-default-pie
0Type: EXEC, no DT_FLAGS, no DT_FLAGS_1 — and GCC 16.2.1 is not configured with --enable-default-pie. Anything that says Fedora gets PIE from a GCC configure switch is wrong. What actually happens is that RPM injects two -specs= files:
$ cat /usr/lib/rpm/redhat/redhat-hardened-cc1
*cc1_options:
+ %{!r:%{!fpie:%{!fPIE:%{!fpic:%{!fPIC:%{!fno-pic:-fPIE}}}}}}
$ cat /usr/lib/rpm/redhat/redhat-hardened-ld
*self_spec:
+ %{!static:%{!shared:%{!r:-pie}}}Read the spec conditionals: -fPIE is appended only if none of -fpie/-fPIE/-fpic/-fPIC/-fno-pic was already given, and -pie only if the link is neither -static nor -shared nor a partial link. BIND_NOW, full RELRO and relative-relocation packing come from %{build_ldflags}, quoted in full earlier in this note.
Three details that matter when reproducing any of this:
-zmust be passed through-Wl,.gcc -z nowis a GCC option meaning something else entirely; the linker option isgcc -Wl,-z,now. Fedora’s macro writes-Wl,-z,nowfor exactly this reason.-z relrois already the binutils default.ld --helpon 2.46.1 prints-z relro Create RELRO program header (default). So even the plaingccbuild above gets partial RELRO —PT_GNU_RELROis present. What the RPM flags add on top is-z now, which is what upgrades partial to full.-z pack-relative-relocsis not the default (-z nopack-relative-relocs ... (default)), which is why the 96.5 % figure measured earlier is a statement about Fedora’s packaging, not about binutils.
A fourth, easy to trip over: on Fedora 44 -mtls-dialect=gnu2 is GCC’s default, not something the RPM flags add.
$ gcc -Q --help=target 2>/dev/null | grep -i tls-dialect
-mtls-dialect= gnu2That is why the demonstration library in this note produced R_X86_64_TLSDESC rather than the DTPMOD64/DTPOFF64 pair without anyone asking for it; forcing -mtls-dialect=gnu brings the classic pair back. See TLS Models and Access.
To settle the question for any specific binary rather than any specific distribution, the one-liner is unchanged: readelf -dlW <binary> | grep -E 'BIND_NOW|NOW|RELRO|PIE'.
ELF_DYNAMIC_AFTER_RELOC — When the Loader Rewrites the PLT Itself
One loose end from the processing-order macro: ELF_DYNAMIC_AFTER_RELOC is a no-op on most architectures, but on x86-64 in glibc 2.43 it is a genuine extra pass that goes beyond patching data — it patches code.
Under BIND_NOW every JUMP_SLOT is already resolved before the program starts, which means each PLT stub’s jmp *GOT(%rip) is an indirect branch whose target will never change again. x86_64_rewrite_plt can therefore overwrite the stub with a direct jump, removing an indirect branch and a data load from every call. To do that safely the loader needs to know where the PLT is and how big its entries are, which is what the three processor-specific dynamic tags are for:
$ readelf -dW /usr/lib64/gconv/CP1255.so | grep -E 'X86_64|FLAGS'
0x0000000070000000 (DT_X86_64_PLT) 0x360
0x0000000070000001 (DT_X86_64_PLTSZ) 0x60
0x0000000070000003 (DT_X86_64_PLTENT) 0x10
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOWThese live in the DT_LOPROC range (0x70000000), and they are the reason the earlier claim that DT_THISPROCNUM is zero needs qualifying — see The Dynamic Section, which owns the tag-space arithmetic. glibc’s sysdeps/x86_64/dl-machine.h is explicit about the preconditions: it ignores DT_X86_64_PLT if lazy binding is enabled, if there is no R_X86_64_JUMP_SLOT, or if DT_X86_64_PLTENT is missing or implausible.
The pass is off by default, gated behind a tunable:
$ /lib64/ld-linux-x86-64.so.2 --list-tunables | grep plt_rewrite
glibc.cpu.plt_rewrite: 0 (min: 0, max: 2)Zero means plt_rewrite_none. The two non-zero settings select how the stub is rewritten (the JMPABS form needs APX). The reason this belongs in a note about relocation processing is that it completes the argument for eager binding: once nothing will ever be patched again, the loader is free not only to mprotect the tables read-only but to specialise the code that reads them. Lazy binding forecloses both.
Uncertain
Verify: whether any shipping distribution enables
glibc.cpu.plt_rewriteby default, and what the measured win is. Reason: the tunable reads0on this Fedora 44 box and glibc’s manual documents the mechanism without publishing numbers; no benchmark was found during this research. To resolve: setGLIBC_TUNABLES=glibc.cpu.plt_rewrite=1on a call-heavy workload and compare, and check other distributions’glibcbuilds. uncertain
Failure Modes and Diagnosis
- Unresolved symbol surfaces late under lazy binding. With lazy binding, a missing symbol in a rarely-called code path does not fail at startup — the program runs until that function is first called, then dies with
symbol lookup error: undefined symbol.-z now(orLD_BIND_NOW=1for a one-off) surfaces it immediately at load. This is exactly why Drepper notes eager binding “can detect underlinking problems.” R_X86_64_COPYsize mismatch. If a shared library changes the size of an exported variable but the executable was built against the old size, the copy relocation copies the wrong number of bytes — a subtle data-corruption ABI break invisible toldd.- Text relocations (
DT_TEXTREL). A non-PIC shared object forcesld.soto write into the (normally read-only, executable) text segment, requiring it be made temporarily writable — a performance and security regression flagged byreadelf -dshowing aTEXTRELtag. Modern toolchains reject this by default (-z text). - GOT corruption symptom. A crash where
%riplands at an attacker-controlled or garbage address right after a librarycalloften means a GOT/.got.pltoverwrite — which full RELRO would have prevented by making the table read-only.
Diagnose all of these with readelf -r <file> (dump relocations), readelf -dW (dynamic flags), and LD_DEBUG=reloc,bindings,symbols ./prog to watch ld.so apply each fix-up and report which definition each symbol bound to (per ld.so(8)).
See Also
- ELF Relocation Entries — the on-disk
Elf64_Relastructure and the relocation-table layout this note consumes - RELRO Relocation Read-Only — the
PT_GNU_RELROsegment and themprotectthat locks resolved tables - Lazy Binding — the PLT trampoline and
_dl_runtime_resolvefirst-call mechanism in full - The Global Offset Table — the table whose slots these relocations fill;
.gotvs.got.plt - Symbol Resolution and Lookup Scope — how
ld.sofindsSfor symbolic relocations - The Dynamic Linker — the loader that drives the whole relocation pass
- The Dynamic Section —
DT_RELA/DT_JMPREL/DT_RELRand the rest of the tag space that points at these tables - GNU IFUNC and Indirect Functions — the semantics behind
R_X86_64_IRELATIVE, whose processing this note covers - Position-Independent Code and PIE — why
Bis unknown at link time in the first place - Symbol Versioning — the mechanism
GLIBC_ABI_DT_RELRreuses as a loader-capability interlock - Thread-Local Storage · TLS Models and Access — what
DTPMOD64/DTPOFF64/TPOFF64/TLSDESCmean once applied - Symbol Interposition and LD_PRELOAD — why a symbol is preemptible, and therefore why
Sis deferred told.so - Init Array Constructors and Destructors — what runs after relocation finishes, and why an ifunc resolver runs before it
- Static vs Dynamic Linking — the case where none of this happens at all
- Linux Userspace Runtime MOC — parent map (§3 dynamic linker, §4 PLT/GOT)
- Compilers and Toolchains MOC — parent map (linking section)