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.so must 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 what ld.so does 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 in ld.so(8), elf(5), the x86-64 psABI relocation definitions, and glibc’s elf/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.

  1. The static linker (ld), at link time. Most relocations never survive to runtime at all. When gcc compiles printf("%d\n", g) it emits R_X86_64_PLT32 for the call and R_X86_64_REX_GOTPCRELX for the global — but those are link-time relocations living in the .o file’s .rela.text. ld consumes them, computes the displacement, patches the instruction bytes, and the relocation is gone from the output. Nothing of it reaches ld.so.
  2. 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.plt and .relr.dyn contain.
  3. 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.so calls 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 + 8

Three 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 by ELF64_R_SYM, found by symbol lookup.
  • A — the addend, taken directly from r_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_offset itself (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:

TypeValueFormulaMeaning
R_X86_64_RELATIVE8B + Abase fix-up; no symbol
R_X86_64_GLOB_DAT6Sfill a GOT data slot with a symbol address
R_X86_64_JUMP_SLOT7Sfill a GOT function slot (the PLT target)
R_X86_64_COPY5(special — copy bytes)pull a data object’s bytes into the executable
R_X86_64_641S + Aa 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).

SympsABI definitionKnown 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 cites master. To resolve: cite the commit hash, as done above, rather than a version number, and re-check master if 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.

TypeValFieldCalculationEmitted byResolved by, whenSeen in
R_X86_64_641word64S + Aassembler, for an absolute 64-bit pointer in datald if the target is local; otherwise survives as a dynamic reloc.rela.text/.rela.data in a .o
R_X86_64_PC322word32S + A - Passembler, for %rip-relative data access and non-PLT callsld, always — never dynamic.rela.text in a .o
R_X86_64_PLT324word32L + A - Passembler, for every call/jmp to a possibly-external functionld, always — it becomes call <stub> or is relaxed to a direct call.rela.text in a .o
R_X86_64_COPY5nonenone (byte copy)ld, when an executable references a data object defined in a .sold.so, eagerly, by memcpying st_size bytes out of the library.rela.dyn of an executable
R_X86_64_GLOB_DAT6wordclassSld, for a GOT slot holding a preemptible symbol’s addressld.so, eagerly, after a full scope lookup.rela.dyn
R_X86_64_JUMP_SLOT7wordclassSld, one per imported function reached through the PLTld.so — eagerly under BIND_NOW, else on first call.rela.plt
R_X86_64_RELATIVE8wordclassB + Ald, for every absolute pointer inside a PIC/PIE imageld.so, eagerly, with no symbol lookup at all.rela.dyn, or packed into .relr.dyn
R_X86_64_3210word32S + Aassembler under -fno-pic, for a 32-bit absolute addressld; link fails (“relocation truncated”) if the value exceeds 32 bits.rela.text in a non-PIC .o
R_X86_64_DTPMOD6416word64(TLS — module id)ld, for general-dynamic TLSld.so, eagerly, writing the module’s l_tls_modid.rela.dyn
R_X86_64_DTPOFF6417word64(TLS — offset in block)ld, alongside the DTPMOD64 of the same pairld.so, eagerly.rela.dyn
R_X86_64_TPOFF6418word64(TLS — offset from %fs:0)ld, for the initial-exec TLS modelld.so, eagerly, once the static TLS block is laid out.rela.dyn
R_X86_64_TLSDESC36word64 ×2(TLS descriptor pair)ld under -mtls-dialect=gnu2ld.so, eagerly, filling a {resolver, argument} pair.rela.dyn
R_X86_64_IRELATIVE37wordclassindirect (B + A)ld, for every __attribute__((ifunc)) targetld.so, eagerly — by calling the function at B + A and storing its return value.rela.plt
R_X86_64_REX_GOTPCRELX42word32G + GOT + A - Passembler, for a GOT-indirect load with a REX prefixld, 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 + 0

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

SectionSHT_ typeEntryTags in .dynamicHoldsCan be lazy?
.relr.dynSHT_RELR (19)Elf64_Relr — one bare 8-byte wordDT_RELR (36), DT_RELRSZ (35), DT_RELRENT (37)only R_X86_64_RELATIVE, bit-packedno
.rela.dynSHT_RELA (4)Elf64_Rela — 24 bytesDT_RELA (7), DT_RELASZ (8), DT_RELAENT (9)RELATIVE, GLOB_DAT, COPY, all TLS typesno
.rela.pltSHT_RELA (4)Elf64_Rela — 24 bytesDT_JMPREL (23), DT_PLTRELSZ (2), DT_PLTREL (20)JUMP_SLOT, IRELATIVEyes, 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):

  1. elf_machine_runtime_setup installs the lazy-binding plumbing if lazy binding is in play (on x86-64 it writes the link_map pointer into .got.plt[1] and _dl_runtime_resolve into .got.plt[2]) and returns the effective laziness — it can downgrade a caller’s lazy=1 to 0. See Lazy Binding and The Global Offset Table for those two slots.
  2. ELF_DYNAMIC_DO_RELR applies the packed relative relocations. It runs first, before any symbolic work, because it needs nothing except l_addr.
  3. ELF_DYNAMIC_DO_REL is a no-op on x86-64 (ELF_MACHINE_NO_REL is set — the architecture is RELA-only).
  4. ELF_DYNAMIC_DO_RELA walks .rela.dyn and .rela.plt.
  5. ELF_DYNAMIC_AFTER_RELOC is 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                   2dc0

r_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:

PopulationCountCarries .relr.dyn
All ELF objects scanned7,6027,336 — 96.5 %
ET_DYN (PIE executables + shared objects)7,5067,336 — 97.7 %
ET_EXEC (position-dependent executables)960 — 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=sha1

Measured: 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 ff3f

Little-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:

  1. 0x2d98 — bit 0 is clear, so this is an address. Relocate the word at 0x2d98, then advance the cursor to 0x2da0.
  2. 0x7 — bit 0 is set, so this is a bitmap over the 63 words starting at the cursor. entry >>= 1 first, so the loop’s i = 0 corresponds to original bit 1. 0x7 is 0b111: bit 0 is the tag, bits 1 and 2 are payload → relocate where[0] = 0x2da0 and where[1] = 0x2da8. Then the cursor advances by the full 63 words regardless of how many bits were set: 0x2da0 + 63×8 = 0x2f98.
  3. 0x3fffffffffc00001 — a bitmap again. Set bits are 0 (the tag) and 22 through 61 inclusive: 40 payload bits. Bit k maps to where[k-1], so bit 22 → 0x2f98 + 21×8 = 0x3040 and 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 + 0x8
flowchart 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_ABIVERSION field of the ELF header in executables and shared libraries can be bumped to indicate the minimum ABI requirement on the dynamic linker. However, EI_ABIVERSION in 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 the EI_ABIVERSION field.

Add a glibc version, GLIBC_ABI_DT_RELR, to indicate DT_RELR support so that the existing dynamic linkers will issue an error on executables with GLIBC_ABI_DT_RELR dependency.

— [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
0

The 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)=2

The 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                       4f0

Read 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) = 101

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

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:

BinaryDynamic depsTotal loader cyclesRelocation cyclesRelocationsFrom cache
ifunc_pie (this note’s toy, PIE + BIND_NOW + RELR)162,8651,752 — 2.7 %1017
/usr/bin/ls387,12324,003 — 27.5 %5647
/usr/bin/clang-22 --version151,915,2571,776,045 — 92.7 %23,06523,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.2

Two 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,relro alone): the linker emits a PT_GNU_RELRO segment covering .dynamic, .init_array, .fini_array, and the non-PLT .got. ld.so resolves .rela.dyn and then mprotects that region read-only. But .got.plt is left writable so lazy binding can keep patching it.
  • Full RELRO (-Wl,-z,relro -Wl,-z,now): adds BIND_NOW, forcing the entire .rela.plt to be resolved eagerly so that .got.plt too 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, Debian dpkg-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 upstream gcc foo.c may be only partial-RELRO or even no-RELRO. To resolve: run readelf -dlW <binary> | grep -E 'BIND_NOW|RELRO|PIE' (or scanelf -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
0

Type: 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:

  • -z must be passed through -Wl,. gcc -z now is a GCC option meaning something else entirely; the linker option is gcc -Wl,-z,now. Fedora’s macro writes -Wl,-z,now for exactly this reason.
  • -z relro is already the binutils default. ld --help on 2.46.1 prints -z relro Create RELRO program header (default). So even the plain gcc build above gets partial RELRO — PT_GNU_RELRO is present. What the RPM flags add on top is -z now, which is what upgrades partial to full.
  • -z pack-relative-relocs is 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=              		gnu2

That 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: NOW

These 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_rewrite by default, and what the measured win is. Reason: the tunable reads 0 on this Fedora 44 box and glibc’s manual documents the mechanism without publishing numbers; no benchmark was found during this research. To resolve: set GLIBC_TUNABLES=glibc.cpu.plt_rewrite=1 on a call-heavy workload and compare, and check other distributions’ glibc builds. 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 (or LD_BIND_NOW=1 for a one-off) surfaces it immediately at load. This is exactly why Drepper notes eager binding “can detect underlinking problems.”
  • R_X86_64_COPY size 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 to ldd.
  • Text relocations (DT_TEXTREL). A non-PIC shared object forces ld.so to write into the (normally read-only, executable) text segment, requiring it be made temporarily writable — a performance and security regression flagged by readelf -d showing a TEXTREL tag. Modern toolchains reject this by default (-z text).
  • GOT corruption symptom. A crash where %rip lands at an attacker-controlled or garbage address right after a library call often means a GOT/.got.plt overwrite — 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