The Dynamic Section
The dynamic section (
.dynamic, mapped at runtime as thePT_DYNAMICsegment) is the table of contents the dynamic linker reads to link a program. It is an array of fixed-sizeElf64_Dynrecords, each a tagged value: ad_tagselecting what the entry means, and a union holding either an integer (d_val) or a virtual address (d_ptr). When the kernel hands control told.so(The Dynamic Linker), the very first thing the linker does is walk this array —DT_NEEDEDtells it which shared libraries to load,DT_STRTAB/DT_SYMTAB/DT_GNU_HASHtell it where the symbol machinery lives,DT_RELA/DT_JMPRELtell it which relocations to apply, andDT_INIT_ARRAYtells it which constructors to run. Every other ELF structure the loader needs is reachable from.dynamic. You inspect it withreadelf -d.
What this note owns, and what it does not. .dynamic is pure indirection: a tag array whose entries point at other structures. This note owns the array — the Elf64_Dyn record, the complete tag table, the non-flat numbering of the tag space, the single pass glibc makes over it, and what shipped objects actually put in it. It does not own the things the tags point at. The Global Offset Table owns what DT_PLTGOT addresses, The Procedure Linkage Table and Lazy Binding own the stubs and the first-call path, Relocation Processing owns what DT_RELA/DT_JMPREL/DT_RELR contain and how they are applied, Symbol Versioning owns DT_VERNEED/DT_VERDEF/DT_VERSYM, Library Search Path and ldconfig owns how a DT_NEEDED name becomes a file, and The ELF Symbol Table owns .dynsym. Read this note for how the loader finds them all.
Mental Model
Think of .dynamic as a small, self-describing manifest sitting inside the binary. A statically linked executable does not have one — it is complete on disk and the kernel jumps straight to its entry point. A dynamically linked executable is deliberately incomplete: it leaves holes (“call printf, but I don’t know where that is”) and ships a manifest saying who can fill them and where the filling instructions are. ld.so is the agent that reads the manifest and completes the binary in memory before main runs.
The manifest is read by a trivial loop, not a parser: walk the Elf64_Dyn array from the start until you hit a DT_NULL tag, and for each entry, switch on d_tag and stash the value in a per-object record (glibc calls it the link_map, with a l_info[] array indexed by tag). After one pass the linker knows the address of every table it needs. The elegance is that .dynamic contains pointers to the symbol table, string table, hash table, and relocation tables rather than embedding them — so the loader bootstraps from a handful of d_ptr values.
flowchart TB DYN[".dynamic / PT_DYNAMIC<br/>array of Elf64_Dyn<br/>(d_tag + d_val|d_ptr)"] DYN -->|DT_NEEDED| NEED["libm.so.6, libc.so.6<br/>(string-table offsets)"] DYN -->|DT_STRTAB| STR[".dynstr<br/>string table"] DYN -->|DT_SYMTAB| SYM[".dynsym<br/>symbol table"] DYN -->|DT_GNU_HASH| HASH[".gnu.hash<br/>lookup accelerator"] DYN -->|DT_RELA / DT_RELASZ| RELA[".rela.dyn<br/>general relocations"] DYN -->|DT_JMPREL / DT_PLTRELSZ| JMP[".rela.plt<br/>PLT relocations"] DYN -->|DT_PLTGOT| GOT[".got.plt<br/>GOT for lazy binding"] DYN -->|DT_INIT_ARRAY| INIT["constructors<br/>(.init_array)"] DYN -->|DT_FLAGS_1| FLG["DF_1_NOW / DF_1_PIE …"] NEED -.resolved via.-> STR SYM -.indexed by.-> HASH RELA -.patch.-> GOT JMP -.patch.-> GOT
The dynamic section as the loader’s hub. What it shows: every table ld.so needs is reached through a d_ptr in .dynamic; DT_NEEDED entries are string-table offsets that name dependencies, and the relocation tables patch the GOT. The insight: .dynamic holds no data of its own — it is pure indirection, a directory of pointers the loader dereferences in a single startup pass.
The Elf64_Dyn Record
The man page (elf.5) gives the 64-bit structure verbatim (man7 elf.5):
typedef struct {
Elf64_Sxword d_tag; /* which kind of entry this is */
union {
Elf64_Xword d_val; /* an integer value (size, flags, …) */
Elf64_Addr d_ptr; /* a virtual address (table location) */
} d_un;
} Elf64_Dyn;packet-beta 0-63: "d_tag — Elf64_Sxword, 8 bytes (SIGNED, though every real tag is positive)" 64-127: "d_un — a union, 8 bytes: d_val (integer) OR d_ptr (virtual address)"
The 16-byte Elf64_Dyn record. What it shows: a fixed-size two-word cell — no length prefix, no type byte beyond the tag itself. The insight: because the record carries no discriminator for its union, the tag value alone must tell a parser whether the second word is a number or an address; that is the entire reason the tag-space numbering scheme described below exists, and why the array can be walked with a for loop rather than parsed.
d_tag is a signed 8-byte selector. Per the man page, d_val “represents integer values with various interpretations,” while d_ptr “represents program virtual addresses” — and crucially, “the actual address should be computed based on the original file value and memory base address.” That last clause matters for position-independent executables and shared objects: the d_ptr stored on disk is a link-time address, and ld.so adds the load bias (where the object actually landed in memory) to get the real runtime address. Whether a given tag uses d_val or d_ptr is fixed by the tag; the linker knows which union member to read.
The array is terminated by a single DT_NULL entry (d_tag == 0), which the man page describes as “Marks end of dynamic section.” Everything before it is live; the loop stops there.
The Complete Tag Table
Most of this note is commentary on one table, so here is the table. The d_un, Executable and Shared-Object columns are quoted from the System V gABI’s Figure 5-10, Dynamic Array Tags; the numeric values and the GNU extensions are from glibc 2.43’s elf/elf.h. “Required” means the gABI marks it mandatory for that file type; ignored means the standard says the loader must not act on it there.
| Tag | Value | d_un | Exec | Shared obj | What it means |
|---|---|---|---|---|---|
DT_NULL | 0 | ignored | mandatory | mandatory | end of array; the loop’s only terminator |
DT_NEEDED | 1 | d_val | optional | optional | .dynstr offset of a dependency’s name — one per direct dependency, order significant |
DT_PLTRELSZ | 2 | d_val | optional | optional | size in bytes of .rela.plt |
DT_PLTGOT | 3 | d_ptr | optional | optional | address of .got.plt (x86-64) |
DT_HASH | 4 | d_ptr | mandatory | mandatory | SysV symbol hash table — mandatory on paper, absent in practice (see below) |
DT_STRTAB | 5 | d_ptr | mandatory | mandatory | address of .dynstr |
DT_SYMTAB | 6 | d_ptr | mandatory | mandatory | address of .dynsym |
DT_RELA | 7 | d_ptr | mandatory | optional | address of .rela.dyn; may legally be 0 when DT_RELR is present |
DT_RELASZ | 8 | d_val | mandatory | optional | size of .rela.dyn in bytes |
DT_RELAENT | 9 | d_val | mandatory | optional | 24 on LP64; glibc asserts it equals sizeof(Elf64_Rela) |
DT_STRSZ | 10 | d_val | mandatory | mandatory | size of .dynstr |
DT_SYMENT | 11 | d_val | mandatory | mandatory | 24 on LP64 (sizeof(Elf64_Sym)) |
DT_INIT | 12 | d_ptr | optional | optional | legacy single init function (.init) |
DT_FINI | 13 | d_ptr | optional | optional | legacy single fini function (.fini) |
DT_SONAME | 14 | d_val | ignored | optional | this object’s canonical name |
DT_RPATH | 15 | d_val | optional | ignored | deprecated search path; forcibly discarded if DT_RUNPATH is present |
DT_SYMBOLIC | 16 | ignored | ignored | optional | start symbol search in this object (-Bsymbolic) |
DT_REL, DT_RELSZ, DT_RELENT | 17–19 | — | mandatory | optional | the implicit-addend form; never used on x86-64 |
DT_PLTREL | 20 | d_val | optional | optional | is .rela.plt DT_REL or DT_RELA? always DT_RELA (7) here |
DT_DEBUG | 21 | d_ptr | optional | ignored | 0 on disk; ld.so writes the r_debug address here at runtime |
DT_TEXTREL | 22 | ignored | optional | optional | this object relocates read-only segments |
DT_JMPREL | 23 | d_ptr | optional | optional | address of .rela.plt |
DT_BIND_NOW | 24 | ignored | optional | optional | legacy eager-binding request; superseded by DT_FLAGS (see below) |
DT_INIT_ARRAY | 25 | d_ptr | optional | optional | array of constructor pointers |
DT_FINI_ARRAY | 26 | d_ptr | optional | optional | array of destructor pointers |
DT_INIT_ARRAYSZ | 27 | d_val | optional | optional | size in bytes, not entries |
DT_FINI_ARRAYSZ | 28 | d_val | optional | optional | size in bytes |
DT_RUNPATH | 29 | d_val | optional | optional | search path consulted after LD_LIBRARY_PATH, direct deps only |
DT_FLAGS | 30 | d_val | optional | optional | DF_* bits: ORIGIN, SYMBOLIC, TEXTREL, BIND_NOW, STATIC_TLS |
DT_ENCODING | 32 | — | — | — | not a tag: the boundary above which the even/odd rule applies |
DT_PREINIT_ARRAY | 32 | d_ptr | optional | ignored | pre-constructors, executables only |
DT_PREINIT_ARRAYSZ | 33 | d_val | optional | ignored | size in bytes |
DT_SYMTAB_SHNDX | 34 | d_ptr | optional | optional | extended section indices for .dynsym |
DT_RELRSZ | 35 | d_val | optional | optional | size of .relr.dyn in bytes |
DT_RELR | 36 | d_ptr | optional | optional | address of .relr.dyn — packed relative relocations |
DT_RELRENT | 37 | d_val | optional | optional | 8 (sizeof(Elf64_Relr)); glibc asserts it |
DT_GNU_HASH | 0x6ffffef5 | d_ptr | GNU ext. | GNU ext. | Bloom-filtered hash table that displaced DT_HASH |
DT_TLSDESC_PLT / DT_TLSDESC_GOT | 0x6ffffef6/7 | d_ptr | GNU ext. | GNU ext. | entry points for R_X86_64_TLSDESC resolution |
DT_VERSYM | 0x6ffffff0 | d_ptr | GNU ext. | GNU ext. | per-symbol version index array (.gnu.version) |
DT_RELACOUNT | 0x6ffffff9 | d_val | GNU ext. | GNU ext. | count of leading R_X86_64_RELATIVE entries in .rela.dyn |
DT_FLAGS_1 | 0x6ffffffb | d_val | Sun ext. | Sun ext. | DF_1_* state flags: NOW, PIE, NODELETE, ORIGIN, … |
DT_VERDEF / DT_VERDEFNUM | 0x6ffffffc/d | d_ptr/d_val | GNU ext. | GNU ext. | versions this object defines (.gnu.version_d) |
DT_VERNEED / DT_VERNEEDNUM | 0x6ffffffe/f | d_ptr/d_val | GNU ext. | GNU ext. | versions this object requires (.gnu.version_r) |
DT_X86_64_PLT / PLTSZ / PLTENT | 0x70000000/1/3 | d_ptr/d_val | psABI | psABI | address, size and stride of the PLT, for loader-side PLT rewriting |
Three rows repay a second look, because in each case what the standard says and what the filesystem contains diverge.
DT_HASH is mandatory and almost never present. The gABI marks it mandatory for both file types. Of 1,200 shipped objects sampled on this machine, 43 carry it — 3.6 %. Every modern GNU toolchain emits DT_GNU_HASH instead (--hash-style=gnu, and GCC 16.2.1 here is configured --with-linker-hash-style=gnu), and glibc is happy with either. The mandatory marking is a 1990s artefact; treat the gABI’s requirement columns as historical, not as a validity check.
DT_BIND_NOW is not how eager binding is requested any more. Only 4 of those 1,200 objects carry tag 24, while 1,143 carry DT_FLAGS and 1,152 carry DT_FLAGS_1. The modern encoding is the DF_BIND_NOW bit (0x8) in DT_FLAGS and the DF_1_NOW bit (0x1) in DT_FLAGS_1. Grepping a binary for a BIND_NOW tag will therefore tell you almost nothing; grep the FLAGS lines instead.
DT_RELA can be zero. Not absent — present with the value 0. This became legal with DT_RELR: an object whose relative relocations are all packed and which has no symbolic relocations still emits the tag. glibc guards for it explicitly (&& (map)->l_info[DT_##RELOC]->d_un.d_ptr != 0), and so must anything else that parses .dynamic.
The Tag Space Is Not Flat
The tag table above looks like a simple enumeration until you notice that the values jump from 37 to 0x6ffffef5. That gap is not decoration; it is a layered allocation scheme, and both readelf and ld.so have to know its shape. Getting this wrong is the single most common misreading of .dynamic.
The even/odd rule and its two exceptions
The gABI states the rule precisely:
To make it simpler for tools to interpret the contents of dynamic section entries, the value of each tag, except for those in two special compatibility ranges, will determine the interpretation of the
d_ununion. A tag whose value is an even number indicates a dynamic section entry that usesd_ptr. A tag whose value is an odd number indicates a dynamic section entry that usesd_valor that uses neitherd_ptrnord_val. Tags whose values are less than the special valueDT_ENCODINGand tags whose values fall betweenDT_HIOSandDT_LOPROCdo not follow these rules.
So a tool that meets an unknown tag ≥ 32 can still tell whether the value is an address needing the load bias added, or a plain integer. Check the rule against the newest tags: DT_RELRSZ = 35 (odd → d_val, a size — correct), DT_RELR = 36 (even → d_ptr, an address — correct), DT_RELRENT = 37 (odd → d_val — correct). Below 32 the rule does not hold: DT_NEEDED = 1 is odd and is a d_val string offset, but DT_STRTAB = 5 is also odd and is a d_ptr.
Where the GNU tags actually live
DT_LOOS is 0x6000000d and DT_HIOS is 0x6ffff000. It is tempting — and the claim is common — to say that a tag like DT_GNU_HASH (0x6ffffef5) therefore “lives in the OS-specific range”. It does not. Compare the two: 0x6FFFFEF5 > 0x6FFFF000. Every GNU and Sun extension tag the toolchain actually emits — DT_GNU_HASH, DT_VERSYM, DT_RELACOUNT, DT_FLAGS_1, DT_VERNEED — sits in the region between DT_HIOS and DT_LOPROC, which is exactly the second of the two exception ranges the gABI carves out of its own even/odd rule. That region was allocated downward from 0x70000000 by Sun in three sub-blocks, and glibc’s elf.h names them:
#define DT_VALRNGLO 0x6ffffd00 /* ... d_val entries, allocated downward */
#define DT_VALRNGHI 0x6ffffdff
#define DT_VALTAGIDX(tag) (DT_VALRNGHI - (tag)) /* Reverse order! */
#define DT_VALNUM 12
#define DT_ADDRRNGLO 0x6ffffe00 /* ... d_ptr entries, allocated downward */
#define DT_GNU_HASH 0x6ffffef5
#define DT_ADDRRNGHI 0x6ffffeff
#define DT_ADDRTAGIDX(tag) (DT_ADDRRNGHI - (tag)) /* Reverse order! */
#define DT_ADDRNUM 11The comment /* Reverse order! */ is the whole design: instead of an even/odd bit, Sun split the space into a d_val block (DT_VALRNG*) and a d_ptr block (DT_ADDRRNG*), and allocated new tags downward from the top of each block. DT_GNU_HASH being a d_ptr is a consequence of which block it was allocated from, not of its parity. Above DT_ADDRRNGHI sits a third, older block — the versioning tags DT_VERSYM (0x6ffffff0) through DT_VERNEEDNUM (0x6fffffff), indexed by DT_VERSIONTAGIDX, also in reverse order.
flowchart TB subgraph LOW["Core tags — the gABI's own"] C1["0 … 31<br/>DT_NULL … DT_FLAGS<br/>no parity rule"] C2["32 … 37 (DT_ENCODING and up)<br/>DT_PREINIT_ARRAY … DT_RELRENT<br/>even = d_ptr, odd = d_val"] end subgraph OS["OS-specific — 0x6000000d … 0x6ffff000"] O1["DT_LOOS … DT_HIOS<br/>reserved, essentially unused<br/>by the GNU toolchain"] end subgraph SUN["Sun/GNU blocks — ABOVE DT_HIOS, an exception range"] S1["0x6ffffd00 … 0x6ffffdff<br/>DT_VALRNG* — d_val<br/>allocated downward"] S2["0x6ffffe00 … 0x6ffffeff<br/>DT_ADDRRNG* — d_ptr<br/>DT_GNU_HASH, DT_TLSDESC_*"] S3["0x6ffffff0 … 0x6fffffff<br/>versioning + DT_FLAGS_1<br/>DT_VERSYM, DT_RELACOUNT"] end subgraph PROC["Processor-specific — 0x70000000 … 0x7fffffff"] P1["DT_X86_64_PLT / PLTSZ / PLTENT<br/>DT_THISPROCNUM = DT_X86_64_NUM = 4"] end LOW --> OS --> SUN --> PROC
The d_tag number line. What it shows: four regions with four different allocation conventions, and the fact that the GNU extension tags sit above DT_HIOS, not inside the OS range. The insight: there is no single rule for decoding a d_tag; a parser must know which region a value falls in before it can say whether the union holds a pointer, and that is precisely why glibc needs five separate index mappings rather than one array lookup.
Why link_map.l_info[] needs five mappings
The practical consequence lands in glibc’s struct link_map. The loader wants l_info[DT_STRTAB] to be an O(1) array index, but the tag values span 0 to 0x7fffffff — an array of four billion pointers is not an option. So l_info[] is a dense array with five range-folding maps (glibc 2.43 include/link.h):
ElfW(Dyn) *l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM
+ DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM];| Block | Size macro | Value on x86-64 | Index formula |
|---|---|---|---|
core tags 0 … 37 | DT_NUM | 38 | the tag itself |
| processor-specific | DT_THISPROCNUM | 4 (DT_X86_64_NUM) | tag - DT_LOPROC + DT_NUM |
versioning + DT_FLAGS_1 | DT_VERSIONTAGNUM | 16 | VERSYMIDX(tag) = DT_VERNEEDNUM - tag |
DT_AUXILIARY/DT_FILTER | DT_EXTRANUM | 3 | DT_EXTRATAGIDX(tag) |
DT_VALRNG* block | DT_VALNUM | 12 | DT_VALTAGIDX(tag) = DT_VALRNGHI - tag |
DT_ADDRRNG* block | DT_ADDRNUM | 11 | DT_ADDRTAGIDX(tag) = DT_ADDRRNGHI - tag |
Sum: 84 pointers, about 672 bytes per loaded object, which is why the scheme is worth the complexity.
Uncertain — resolved by reading past the first definition
DT_THISPROCNUMis not zero on x86-64.sysdeps/generic/dl-dtprocnum.hdefines it as0with the comment “By default there are none”, and stopping there is the obvious mistake.sysdeps/x86_64/dl-dtprocnum.hoverrides it:#define DT_THISPROCNUM DT_X86_64_NUM, andelf.hsetsDT_X86_64_NUM 4. Both files were fetched from theglibc-2.43tag on 2026-09-12 and the override is what x86-64 builds compile against. (Separately:sysdeps/x86_64/dl-machine-rel.hdoes not exist — acurlfor it returns a ~2 KB gitweb “404 - Cannot find file” page whose length makes it look like a real file. x86-64 uses the generic one, which setsELF_MACHINE_NO_REL 1. Verify by content, never by response size.) uncertain
Walking the Tags — a Live readelf -d
Here is the dynamic section of a real dynamically linked C program (gcc -O2 hello.c -o hello -lm), captured on Fedora 43 with binutils 2.45.1:
Dynamic section at offset 0x1df8 contains 25 entries:
Tag Type Name/Value
0x01 (NEEDED) Shared library: [libm.so.6]
0x01 (NEEDED) Shared library: [libc.so.6]
0x0c (INIT) 0x40033c
0x0d (FINI) 0x400498
0x19 (INIT_ARRAY) 0x402de8
0x1b (INIT_ARRAYSZ) 8 (bytes)
0x1a (FINI_ARRAY) 0x402df0
0x1c (FINI_ARRAYSZ) 8 (bytes)
0x6ffffef5 (GNU_HASH) 0x401020
0x05 (STRTAB) 0x4010a0
0x06 (SYMTAB) 0x401040
0x0a (STRSZ) 84 (bytes)
0x0b (SYMENT) 24 (bytes)
0x15 (DEBUG) 0x0
0x03 (PLTGOT) 0x402fe8
0x02 (PLTRELSZ) 24 (bytes)
0x14 (PLTREL) RELA
0x17 (JMPREL) 0x401160
0x07 (RELA) 0x401130
0x08 (RELASZ) 48 (bytes)
0x09 (RELAENT) 24 (bytes)
0x6ffffffe (VERNEED) 0x401100
0x6fffffff (VERNEEDNUM) 1
0x6ffffff0 (VERSYM) 0x4010f4
0x00 (NULL) 0x0
Reading this top to bottom is exactly what ld.so does. Walk the essential tags:
Dependencies — DT_NEEDED. Each DT_NEEDED entry is a string-table offset (a d_val, not a pointer) into .dynstr; resolving it against DT_STRTAB yields a name like libm.so.6. The man page: “String table offset to name of a needed library.” There is one entry per direct dependency, and order matters — it defines the breadth-first load order and the symbol-lookup scope (Symbol Resolution and Lookup Scope). For each one, the loader runs the search algorithm (see Library Search Path and ldconfig) and mmaps the object. The name is typically a SONAME (libm.so.6), not a file path, so the search resolves it to an actual file.
This library’s name — DT_SONAME. Appears only in shared objects, not executables. The man page: “String table offset to name of shared object.” It is the canonical name the object advertises. When you link against libfoo.so, the linker copies that file’s DT_SONAME into your DT_NEEDED, so at runtime you depend on libfoo.so.1 (the SONAME) rather than the development symlink. This is the linchpin of ELF versioning: bumping the SONAME (.so.1 → .so.2) declares an ABI break and lets both versions coexist. You set it with ld -soname libgreet.so.1; per the binutils manual, “when an executable is linked with a shared object which has a DT_SONAME field, then when the executable is run the dynamic linker will attempt to load the shared object specified by the DT_SONAME field rather than using the file name given to the linker” (binutils ld Options). A live example — gcc -shared -Wl,-soname,libgreet.so.1 produces:
0x0e (SONAME) Library soname: [libgreet.so.1]
Search paths — DT_RPATH (deprecated) and DT_RUNPATH. Both are string-table offsets to a colon-separated search-path string baked into the object. The crucial difference is when they are consulted relative to LD_LIBRARY_PATH. Per ld.so(8), DT_RPATH is used “if present and DT_RUNPATH attribute does not exist” and is searched before LD_LIBRARY_PATH; DT_RUNPATH is searched after LD_LIBRARY_PATH (ld.so(8)). There is a second, subtler difference the man page spells out: DT_RPATH is “applied to searches for all children in the dependency tree,” whereas DT_RUNPATH directories “are searched only to find those objects required by DT_NEEDED (direct dependencies) entries and do not apply to those objects’ children.” Because DT_RPATH cannot be overridden by the user’s LD_LIBRARY_PATH and leaks into transitive dependencies, it is deprecated; modern toolchains emit DT_RUNPATH (via ld -z lazy… actually via --enable-new-dtags, default on GNU toolchains). Both support the $ORIGIN token, which ld.so(8) says “expands to the directory containing the program or shared object” — the standard trick for relocatable install trees (-Wl,-rpath,'$ORIGIN/../lib').
The symbol machinery — DT_STRTAB, DT_SYMTAB, DT_GNU_HASH. These three are the heart of symbol resolution. DT_STRTAB (“Address of string table”) points at .dynstr, the blob of null-terminated names; DT_STRSZ gives its size. DT_SYMTAB (“Address of symbol table”) points at .dynsym, an array of Elf64_Sym records (The ELF Symbol Table); DT_SYMENT gives the per-entry size (24 bytes). A symbol’s name is a st_name offset into .dynstr. To resolve a name to a symbol quickly, the loader uses a hash table: classic ELF used DT_HASH (“Address of symbol hash table”), but every modern binary ships DT_GNU_HASH (tag 0x6ffffef5) instead — a Bloom-filter-fronted, bucket-and-chain table that lets the loader reject most non-matches without touching .dynstr at all. The example above has only GNU_HASH; the legacy DT_HASH is omitted to save space. Note the tag value 0x6ffffef5: it is not in the gABI’s OS-specific range, despite that being the usual claim — DT_HIOS is 0x6ffff000, and 0x6ffffef5 is larger. It sits in Sun’s DT_ADDRRNG* block above DT_HIOS, which is why it is a GNU extension the base man page does not list. See The Tag Space Is Not Flat above for the full number line.
The relocation tables — DT_RELA, DT_RELASZ, DT_JMPREL. Relocations are the patch list: “at address X, write the resolved value of symbol Y.” x86-64 uses the RELA form (relocations with explicit addends; see ELF Relocation Entries). There are two relocation tables, and .dynamic describes them separately. DT_RELA (“Address of Rela relocation table”) plus DT_RELASZ (“Size in bytes”) plus DT_RELAENT (entry size, 24 bytes) describe .rela.dyn — the general relocations: data relocations, R_X86_64_RELATIVE self-relocations for PIE, GLOB_DAT entries. DT_JMPREL (“Address of relocation entries associated solely with the PLT”) plus DT_PLTRELSZ (“Size in bytes of PLT relocation entries”) describe .rela.plt — the JUMP_SLOT relocations that patch the GOT for function calls (The Procedure Linkage Table). DT_PLTREL (“Type of relocation entry to which the PLT refers”) says whether the PLT table is RELA or REL (always RELA on x86-64). The split exists precisely so the loader can apply .rela.dyn eagerly at startup but defer .rela.plt for Lazy Binding. The optional DT_RELACOUNT (0x6ffffff9) names how many leading entries are cheap R_X86_64_RELATIVE self-relocations, letting the loader fast-path the dominant case (MaskRay, relative relocations).
The GOT — DT_PLTGOT. The man page: “Address of PLT and/or GOT.” On x86-64 this points to .got.plt. The loader reserves its first three slots: per MaskRay, “ld.so puts a descriptor into .got.plt[1] and the address of the lazy PLT resolver into .got.plt[2]” (MaskRay GOT). When a PLT stub is hit for the first time, it jumps through GOT[2] to the resolver, passing GOT[1] (the per-object link_map descriptor) so the resolver knows which object’s symbols and relocations to consult — see The Global Offset Table.
Constructors — DT_INIT, DT_FINI, DT_INIT_ARRAY. DT_INIT (“Address of the initialization function”) and DT_FINI (“Address of the termination function”) are the legacy single-function .init/.fini hooks. The modern mechanism is DT_INIT_ARRAY — an array of function pointers (here at 0x402de8), sized by DT_INIT_ARRAYSZ (8 bytes = one pointer). After relocations are applied, ld.so calls each pointer in turn; this is how C++ static constructors and __attribute__((constructor)) functions run before main (Init Array Constructors and Destructors). DT_FINI_ARRAY/DT_FINI_ARRAYSZ are the teardown counterparts, run at unload.
Hardening flags — DT_FLAGS and DT_FLAGS_1. DT_FLAGS (tag 30) carries the base flags; DT_FLAGS_1 (0x6ffffffb) carries the GNU state flags. The bit values, from /usr/include/elf.h: DF_BIND_NOW = 0x8 (“No lazy binding for this object”) and DF_1_NOW = 0x1 (“Set RTLD_NOW for this object”). When you build with full RELRO (-Wl,-z,relro,-z,now), readelf -d shows both, captured live:
0x1e (FLAGS) BIND_NOW
0x6ffffffb (FLAGS_1) Flags: NOW
This is the on-disk signal for full RELRO: it tells ld.so to resolve all relocations eagerly (DF_1_NOW/DF_BIND_NOW) so the GOT can then be made read-only, defeating GOT-overwrite exploits. Other DF_1_* bits matter too: DF_1_PIE = 0x08000000 marks a position-independent executable (distinguishing it from a shared library so execve won’t run a .so directly), DF_1_NODELETE = 0x8 pins an object so dlclose won’t unmap it, and DF_1_ORIGIN = 0x80 tells the loader the object uses $ORIGIN and must compute its own path. The environment variable LD_BIND_NOW, per ld.so(8), achieves the same eager binding at runtime without rebuilding: it “causes the dynamic linker to resolve all symbols at program startup.”
Re-captured on Fedora 44 — Two Builds, Side by Side
The capture above is from Fedora 43. Re-running it on Fedora 44 (GCC 16.2.1 20260819, binutils 2.46.1-1.fc44, glibc 2.43-8.fc44) on 2026-09-12 with the same source under two different link lines makes the whole tag table concrete, because the differences are exactly the ones the distribution’s hardening flags cause.
$ gcc -O2 hello.c -o hello_plain -lm # bare gcc
$ gcc -O2 $(rpm --eval '%{build_ldflags}') -fPIE hello.c -o hello_hard -lm # as RPM builds it| Entry | hello_plain (25 tags) | hello_hard (29 tags) | Why |
|---|---|---|---|
NEEDED | libm.so.6, libc.so.6 | libc.so.6 only | -Wl,--as-needed dropped libm: GCC constant-folded sqrt(2.0), so no symbol was actually referenced |
STRTAB | 0x4010a0 | 0x10e8 | absolute vs. bias-relative — hello_plain is ET_EXEC, hello_hard is a PIE |
FLAGS | absent | BIND_NOW | from -Wl,-z,now |
FLAGS_1 | absent | Flags: NOW PIE | DF_1_NOW + DF_1_PIE (0x08000000) |
RELR/RELRSZ/RELRENT | absent | 0x1280 / 16 / 8 | from -Wl,-z,pack-relative-relocs |
DEBUG | 0x0 | 0x0 | zero on disk in both; written by ld.so at runtime |
PLTREL | RELA | RELA | invariant on x86-64 |
The NEEDED row is the one that catches people. A dependency listed in the link command does not guarantee a DT_NEEDED entry: with --as-needed — which Fedora passes and many distributions do — the linker records a dependency only if the object actually resolved a symbol from it. That is why readelf -d is the authority on what will be loaded and the gcc command line is not.
The Flag Bits, Since readelf Prints Names
DT_FLAGS and DT_FLAGS_1 are bitfields, and readelf renders them as names, so the numeric values only matter when you are parsing .dynamic yourself. From glibc 2.43’s elf/elf.h:
| Bit | DT_FLAGS (DF_*) | Meaning |
|---|---|---|
0x1 | DF_ORIGIN | object may use $ORIGIN |
0x2 | DF_SYMBOLIC | symbol search starts in this object |
0x4 | DF_TEXTREL | object contains text relocations |
0x8 | DF_BIND_NOW | no lazy binding for this object |
0x10 | DF_STATIC_TLS | object uses the static TLS model and so may not be dlopened after startup |
| Bit | DT_FLAGS_1 (DF_1_*) | Meaning |
|---|---|---|
0x1 | DF_1_NOW | set RTLD_NOW for this object |
0x2 | DF_1_GLOBAL | set RTLD_GLOBAL |
0x8 | DF_1_NODELETE | dlclose must not unmap it |
0x40 | DF_1_NOOPEN | dlopen must refuse it |
0x80 | DF_1_ORIGIN | $ORIGIN must be processed |
0x400 | DF_1_INTERPOSE | object is an interposer (Symbol Interposition and LD_PRELOAD) |
0x800 | DF_1_NODEFLIB | ignore the default library search path |
0x02000000 | DF_1_SINGLETON | singleton symbols are used |
0x08000000 | DF_1_PIE | this is a position-independent executable, not a library |
Note how DF_TEXTREL and DF_BIND_NOW duplicate the standalone DT_TEXTREL and DT_BIND_NOW tags. glibc reconciles them by pointing the old l_info[] slots at the DT_FLAGS entry, as shown earlier — the comment in the source is candid that “since these l_info entries are only tested for NULL pointers it is ok if they point to the DT_FLAGS entry.”
DF_1_PIE deserves a note of its own, because it is what makes ET_DYN unambiguous. A PIE and a shared library are both ET_DYN in the ELF header, so the flag is the only thing distinguishing them — and it is enforced, in elf/dl-load.c:
/* ... or a PIE object. */
if ((__glibc_unlikely (l->l_flags_1 & DF_1_NOOPEN) && (mode & __RTLD_DLOPEN))
|| (__glibc_unlikely (l->l_flags_1 & DF_1_PIE)
&& __glibc_unlikely ((mode & __RTLD_OPENEXEC) == 0)))
{
if (l->l_flags_1 & DF_1_PIE)
errstring = N_("cannot dynamically load position-independent executable");Measured against the two binaries above:
$ ./dlo ./hello_hard # a PIE — DT_FLAGS_1 has DF_1_PIE
./hello_hard: cannot dynamically load position-independent executable
$ ./dlo ./libdemo.so.1 # a real shared object
openedWithout the flag the loader would have no way to reject the first case, and dlopening an executable goes wrong in interesting ways — its DT_DEBUG, its _start, and its assumption of being at the head of the lookup scope are all invalid in a dlopened object. (Note that DF_1_PIE is a loader guard, not a kernel one: the kernel happily execves any ET_DYN with a PT_INTERP, which is why /lib64/libc.so.6 — which has no DF_1_PIE — can be run directly and prints its version banner.) glibc also warns about unrecognised bits, but only under debugging:
if (__builtin_expect (GLRO(dl_debug_mask) & DL_DEBUG_FILES, 0)
&& l->l_flags_1 & ~DT_1_SUPPORTED_MASK)
_dl_debug_printf ("\nWARNING: Unsupported flag value(s) of 0x%x "
"in DT_FLAGS_1.\n", ...);The comment above it explains why it is only a warning: “Users have been setting unsupported DF_1_* flags for a long time and glibc has ignored them. Therefore to avoid breaking existing applications the best we can do is add a warning.” Set LD_DEBUG=files to see it.
elf_get_dynamic_info — the Loader’s One Pass
The walk-through above reads .dynamic the way a human does. ld.so reads it once, mechanically, in a function short enough to quote almost in full. This is the single most important piece of code in the note, because it is where “instruction tape” stops being a metaphor: after this function returns, the loader never walks the array again — it indexes l_info[].
From glibc 2.43’s elf/get-dynamic-info.h:
ElfW(Dyn) **info = l->l_info;
for (ElfW(Dyn) *dyn = l->l_ld; dyn->d_tag != DT_NULL; dyn++)
{
d_tag_utype i;
if ((d_tag_utype) dyn->d_tag < DT_NUM) /* 0 … 37 */
i = dyn->d_tag;
else if (dyn->d_tag >= DT_LOPROC
&& dyn->d_tag < DT_LOPROC + DT_THISPROCNUM) /* DT_X86_64_* */
i = dyn->d_tag - DT_LOPROC + DT_NUM;
else if ((d_tag_utype) DT_VERSIONTAGIDX (dyn->d_tag) < DT_VERSIONTAGNUM)
i = VERSYMIDX (dyn->d_tag); /* versioning */
else if ((d_tag_utype) DT_EXTRATAGIDX (dyn->d_tag) < DT_EXTRANUM)
i = DT_EXTRATAGIDX (dyn->d_tag) + DT_NUM + DT_THISPROCNUM
+ DT_VERSIONTAGNUM;
else if ((d_tag_utype) DT_VALTAGIDX (dyn->d_tag) < DT_VALNUM)
i = DT_VALTAGIDX (dyn->d_tag) + DT_NUM + DT_THISPROCNUM
+ DT_VERSIONTAGNUM + DT_EXTRANUM;
else if ((d_tag_utype) DT_ADDRTAGIDX (dyn->d_tag) < DT_ADDRNUM)
i = DT_ADDRTAGIDX (dyn->d_tag) + DT_NUM + DT_THISPROCNUM
+ DT_VERSIONTAGNUM + DT_EXTRANUM + DT_VALNUM;
else
continue; /* unknown tag */
info[i] = dyn;
}Four properties of that loop are worth stating outright, because each has a practical consequence.
It stores pointers, not values. info[i] = dyn records where the entry is, not what it says. Everything downstream dereferences: l->l_info[DT_STRTAB]->d_un.d_ptr, or the D_PTR macro that adds the bias. Testing l->l_info[DT_TEXTREL] != NULL is therefore how the loader asks “does this object have the DT_TEXTREL tag at all”, which is the idiom you see all over elf/dl-reloc.c.
Unknown tags are silently ignored. The final else continue; means an object can carry a tag glibc has never heard of and load without complaint. That is deliberate forward compatibility, and it is also why DT_RELR needed the separate [[Symbol Versioning|GLIBC_ABI_DT_RELR]] interlock described in Relocation Processing: an old ld.so meeting DT_RELR would not error, it would skip the tag and run the program with unrelocated pointers.
Duplicate tags: last wins. Nothing checks whether info[i] was already set. Two DT_STRTAB entries and the second silently shadows the first. The one tag for which duplication is normal, DT_NEEDED, is not read from l_info[] for that reason. l_info[DT_NEEDED] is used only as a boolean “does this object have any dependencies”; the names are collected by a second, independent walk of the raw array in elf/dl-deps.c, which is what preserves their order:
if (l->l_info[DT_NEEDED] || l->l_info[AUXTAG] || l->l_info[FILTERTAG])
{
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
...
for (d = l->l_ld; d->d_tag != DT_NULL; ++d)
if (__builtin_expect (d->d_tag, DT_NEEDED) == DT_NEEDED)
{
/* Map in the needed object. */
name = expand_dst (l, strtab + d->d_un.d_val, 0);Note strtab + d->d_un.d_val: this is where the “DT_NEEDED is a string-table offset, not a pointer” convention is actually exercised, and expand_dst is where $ORIGIN, $LIB and $PLATFORM get substituted.
Only one entry is truly required. DT_NULL is the loop’s sole terminator. A .dynamic array missing it does not produce a diagnostic; it runs off the end of the section and interprets whatever follows as more tags.
The pass mutates .dynamic in place
The second half of the function is the part that surprises people. d_ptr values on disk are link-time addresses. Rather than add the load bias at every use site, glibc adds it once, into the file’s own mapped .dynamic array:
/* Don't adjust .dynamic unnecessarily. */
if (l->l_addr != 0 && dl_relocate_ld (l))
{
ElfW(Addr) l_addr = l->l_addr;
# define ADJUST_DYN_INFO(tag) \
do { if (info[tag] != NULL) info[tag]->d_un.d_ptr += l_addr; } while (0)
ADJUST_DYN_INFO (DT_HASH);
ADJUST_DYN_INFO (DT_PLTGOT);
ADJUST_DYN_INFO (DT_STRTAB);
ADJUST_DYN_INFO (DT_SYMTAB);
ADJUST_DYN_INFO (DT_RELR);
ADJUST_DYN_INFO (DT_JMPREL);
ADJUST_DYN_INFO (VERSYMIDX (DT_VERSYM));
ADJUST_DYN_INFO (ADDRIDX (DT_GNU_HASH));
# undef ADJUST_DYN_INFOThree things follow, and they connect .dynamic to the rest of the runtime.
.dynamicmust be writable while the loader runs, and read-only afterwards. That is the entire reason.dynamicis inside the [[RELRO Relocation Read-Only|PT_GNU_RELRO]] segment rather than in.rodata: the loader writes to it, then_dl_protect_relromprotects itPROT_READ. If it were never written, RELRO would not need to cover it; if it were never protected, an attacker who could reach it could repointDT_STRTABorDT_JMPREL.- The bias is added exactly once, gated on
l->l_addr != 0. A non-PIE executable loaded at its link address skips the whole block — nothing to add. This is whyreadelf -don a PIE shows small offsets (STRTAB 0x10e8) while a debugger attached to the running process sees full addresses at the same location. - The list is short and deliberate.
DT_INIT,DT_FINI,DT_INIT_ARRAYandDT_FINI_ARRAYare not in it. Those are biased at the point of use instead.elf/dl-init.cwrites the addition out longhand for exactly this reason:
DL_CALL_DT_INIT (l, l->l_addr + l->l_info[DT_INIT]->d_un.d_ptr, argc, argv, env);
...
jm = l->l_info[DT_INIT_ARRAYSZ]->d_un.d_val / sizeof (ElfW(Addr));
addrs = (ElfW(Addr) *) (init_array->d_un.d_ptr + l->l_addr);
for (j = 0; j < jm; ++j)
((dl_init_t) addrs[j]) (argc, argv, env);That / sizeof (ElfW(Addr)) is also the answer to a common confusion: DT_INIT_ARRAYSZ is a size in bytes, and the loader divides to get the count. DT_RELA/DT_REL get a separate, guarded macro that skips the adjustment when the value is zero — the DT_RELR accommodation described earlier.
Normalisation: the loop is also where policy is applied
The tail of the function is not bookkeeping — it is where several documented behaviours are actually implemented:
if (l->l_flags & DF_BIND_NOW)
info[DT_BIND_NOW] = info[DT_FLAGS];
...
if (l->l_flags_1 & DF_1_NOW)
info[DT_BIND_NOW] = info[VERSYMIDX (DT_FLAGS_1)];
...
if (info[DT_RUNPATH] != NULL)
/* If both RUNPATH and RPATH are given, the latter is ignored. */
info[DT_RPATH] = NULL;The first two lines explain why only 4 of 1,200 sampled objects carry a real DT_BIND_NOW tag yet nearly all bind eagerly: glibc synthesises the l_info[DT_BIND_NOW] slot from the DF_BIND_NOW or DF_1_NOW flag bit, pointing it at the flags entry. Everything downstream — including if (l->l_info[DT_BIND_NOW] != NULL) lazy = 0; in elf/dl-reloc.c — then tests one slot and does not care which of the three spellings produced it.
The last line is the code behind the man page’s “if both RUNPATH and RPATH are given, the latter is ignored”: DT_RPATH is not deprioritised, it is erased from l_info[] before any search runs. The tag is still in the file; the loader has simply made itself unable to see it.
flowchart TB LD["l->l_ld = PT_DYNAMIC address<br/>(set by the program-header scan)"] --> LOOP{"dyn->d_tag<br/>== DT_NULL?"} LOOP -- yes --> ADJ LOOP -- no --> CLASSIFY{"which range?"} CLASSIFY -- "< DT_NUM (0…37)" --> IDX["i = d_tag"] CLASSIFY -- "DT_LOPROC + 0…3" --> IDX2["i = d_tag - DT_LOPROC + 38"] CLASSIFY -- "versioning block" --> IDX3["i = VERSYMIDX(d_tag)"] CLASSIFY -- "VALRNG / ADDRRNG" --> IDX4["i = DT_VALTAGIDX / DT_ADDRTAGIDX"] CLASSIFY -- "none of these" --> SKIP["continue — tag ignored"] IDX --> STORE["l_info[i] = dyn<br/>(pointer, not value)"] IDX2 --> STORE IDX3 --> STORE IDX4 --> STORE STORE --> LOOP SKIP --> LOOP ADJ{"l_addr != 0?"} -- yes --> BIAS["ADJUST_DYN_INFO:<br/>d_ptr += l_addr, in place,<br/>for HASH PLTGOT STRTAB SYMTAB<br/>RELR JMPREL VERSYM GNU_HASH<br/>(+ RELA/REL if non-zero)"] ADJ -- "no (non-PIE EXEC)" --> NORM BIAS --> NORM["normalise:<br/>DF_BIND_NOW / DF_1_NOW -> l_info[DT_BIND_NOW]<br/>DT_RUNPATH present -> l_info[DT_RPATH] = NULL<br/>assert RELAENT == 24, RELRENT == 8"] NORM --> DONE["link_map ready<br/>every later question is an array index"]
elf_get_dynamic_info in glibc 2.43. What it shows: one linear pass that classifies each tag into one of five index spaces, stores a pointer, then biases a fixed short list of address-valued tags in place and applies three normalisation rules. The insight: the loader converts a variable-length, sparsely-numbered, order-dependent tape into a fixed-size random-access record exactly once per object — and the in-place bias is the reason .dynamic has to be writable during startup and read-only after it.
How ld.so Uses It, End to End
The loader’s startup, reduced to what touches .dynamic: (1) The kernel maps the executable and the interpreter named in PT_INTERP, then jumps to ld.so’s entry. (2) ld.so finds the executable’s .dynamic via the PT_DYNAMIC program header and walks it into a link_map, recording every d_ptr/d_val. (3) It reads each DT_NEEDED, runs the search algorithm, maps each dependency, and recursively processes its .dynamic — building the full dependency graph and the symbol-lookup scope. (4) It applies relocations: DT_RELA entries eagerly (data, RELATIVE, GLOB_DAT), and DT_JMPREL entries either eagerly (if DF_BIND_NOW) or lazily through the PLT/GOT. (5) If RELRO is set, it mprotects the relevant pages read-only. (6) It calls every object’s DT_INIT_ARRAY constructors in dependency order. (7) It jumps to the executable’s entry point, and _start → __libc_start_main → main runs (Process Startup Sequence). Nothing in this sequence is possible without .dynamic; it is the index the entire link is driven from.
What Shipped Objects Actually Carry
Specifications describe what is permitted. A filesystem tells you what is normal, and the two differ enough to be worth measuring. Every readelf -d tag was counted across a 1,200-object random sample of the ELF objects under /usr/bin, /usr/sbin, /usr/lib64 and /usr/libexec on Fedora 44, 2026-09-12 (the full population was 7,602 objects; 1,197 of the 1,200 sampled files had a dynamic section at all; the other three — /usr/bin/tailscale, /usr/bin/tailscaled and /usr/libexec/catatonit/catatonit — are statically linked and have none (Static vs Dynamic Linking)).
| Tag | Objects | % of 1,197 | Reading |
|---|---|---|---|
DT_NULL, DT_STRTAB, DT_SYMTAB, DT_STRSZ, DT_SYMENT | 1,197 | 100 % | the irreducible core: name the string and symbol tables, or you are not dynamically linked |
DT_PLTGOT, DT_JMPREL, DT_PLTRELSZ, DT_PLTREL | 1,197 | 100 % | every single object has a PLT |
DT_NEEDED | 6,293 entries across 1,197 objects | — | 5.3 dependencies per object on average |
DT_INIT, DT_FINI, DT_INIT_ARRAY, DT_FINI_ARRAY (+ sizes) | 1,196 | 99.9 % | the legacy DT_INIT hook has not gone away; both mechanisms ship together |
DT_GNU_HASH | 1,195 | 99.8 % | |
DT_RELA, DT_RELASZ, DT_RELAENT | 1,194 | 99.7 % | |
DT_VERSYM, DT_VERNEED, DT_VERNEEDNUM | 1,182 | 98.7 % | versioning is effectively universal, not optional |
DT_FLAGS_1 | 1,152 | 96.2 % | |
DT_FLAGS | 1,143 | 95.5 % | |
DT_RELR, DT_RELRSZ, DT_RELRENT | 1,103 | 92.1 % | packed relative relocations are the common case |
DT_DEBUG | 681 | 56.9 % | present in executables (incl. PIEs), ignored in shared objects per the gABI |
DT_SONAME | 304 | 25.4 % | |
DT_RUNPATH | 106 | 8.9 % | |
DT_RELACOUNT | 75 | 6.3 % | largely displaced by DT_RELR |
DT_HASH | 43 | 3.6 % | “mandatory” per the gABI |
DT_VERDEF, DT_VERDEFNUM | 42 | 3.5 % | only objects that define versions, i.e. real libraries |
DT_X86_64_PLT/PLTSZ/PLTENT | 42 | 3.5 % | processor-specific; enables loader-side PLT rewriting |
DT_SYMBOLIC | 9 | 0.8 % | |
DT_BIND_NOW | 4 | 0.3 % | the legacy spelling; see the synthesis in elf_get_dynamic_info above |
DT_REL, DT_RELSZ, DT_RELENT | 2 | 0.2 % | both ELF32 (/usr/libexec/getconf/XBS5_ILP32_OFFBIG, …/POSIX_V6_ILP32_OFF32) — the implicit-addend form is an i386 artefact |
DT_TLSDESC_PLT, DT_TLSDESC_GOT | 1 | 0.1 % | |
DT_RPATH | 0 | 0 % | |
DT_TEXTREL | 0 | 0 % |
Four readings worth extracting.
DT_RPATH is extinct, not merely deprecated. Zero occurrences in 1,197 objects. GNU ld has emitted DT_RUNPATH instead since --enable-new-dtags became the default, and glibc erases DT_RPATH from l_info[] whenever DT_RUNPATH is present anyway. If you are reasoning about search-path precedence on a modern distribution, the DT_RPATH-is-searched-before-LD_LIBRARY_PATH rule is a historical footnote — correct, and almost never reachable.
DT_TEXTREL is also extinct, which is the hardening story having worked. ld -z text rejects text relocations at link time and it is the default; the 1,197 objects contain none.
Only a quarter of shared objects have a DT_SONAME. That is not a packaging failure; the 304 are the actual libraries. The rest are dlopen-only plugins — gconv modules, PAM modules, rsyslog and GStreamer plugins — which nothing ever links against by name, so a SONAME would serve no purpose.
DT_DEBUG at 56.9 % is the executable/shared-object split, since the gABI marks it ignored in shared objects. That single tag is the subject of the next section, because what it does at runtime is the most surprising thing in the whole table.
DT_DEBUG — the Entry the Loader Writes Back Into
Every other tag is data the linker wrote for the loader to read. DT_DEBUG runs the other way: the linker emits it with the value zero, and ld.so fills it in at startup. It is the reverse channel by which a debugger, or the program itself, discovers the list of loaded objects.
On disk it is always zero, in both the plain and hardened builds:
$ readelf -dW hello_plain | grep DEBUG
0x0000000000000015 (DEBUG) 0x0At runtime it holds the address of a struct r_debug, whose r_map field is the head of the loader’s link_map chain. A program can walk it with nothing but the linker-defined _DYNAMIC symbol:
#define _GNU_SOURCE
#include <stdio.h>
#include <link.h>
#include <elf.h>
extern ElfW(Dyn) _DYNAMIC[]; /* the linker defines this in every dynamic object */
int main(void)
{
struct r_debug *dbg = NULL;
for (ElfW(Dyn) *d = _DYNAMIC; d->d_tag != DT_NULL; d++) /* the same loop ld.so runs */
if (d->d_tag == DT_DEBUG)
dbg = (struct r_debug *) d->d_un.d_ptr;
if (!dbg) { puts("no DT_DEBUG"); return 1; }
printf("r_debug at %p r_version=%d r_brk=%p r_state=%d\n",
(void *)dbg, dbg->r_version, (void *)dbg->r_brk, dbg->r_state);
for (struct link_map *m = dbg->r_map; m; m = m->l_next)
printf(" l_addr=%016lx l_ld=%p %s\n",
(unsigned long) m->l_addr, (void *) m->l_ld,
m->l_name[0] ? m->l_name : "(main executable)");
return 0;
}$ gcc -O2 -fPIE -pie dtdebug.c -o dtdebug && ./dtdebug
r_debug at 0x7fc2cd013b58 r_version=1 r_brk=0x7fc2ccfdca70 r_state=0
l_addr=0000557e3ee54000 l_ld=0x557e3ee56de0 (main executable)
l_addr=00007fc2ccfd8000 l_ld=0x7fc2ccfd84a8 linux-vdso.so.1
l_addr=00007fc2ccdb6000 l_ld=0x7fc2ccfa4920 /lib64/libc.so.6
l_addr=00007fc2ccfda000 l_ld=0x7fc2cd012e60 /lib64/ld-linux-x86-64.so.2Read the output carefully, because it closes the loop on this whole note.
l_addris the load biasB— the sameBthat everyR_X86_64_RELATIVEin Relocation Processing adds. The main executable is a PIE, so its bias is non-zero (0x557e3ee54000).l_ldis a pointer to that object’s own.dynamic. The loop is therefore literally an enumeration of every dynamic section in the process..dynamicis not one table; it is one table per object, andr_debugis the index of indexes.linux-vdso.so.1appears with no path, because it was never opened from the filesystem — it is the kernel-supplied virtual shared object mapped byexecve(The Auxiliary Vector carries its address inAT_SYSINFO_EHDR).r_brkis the address of a functionld.socalls — and deliberately does nothing useful in — immediately before and after modifying the list. A debugger sets a breakpoint on it;r_statethen says whether the list is mid-RT_ADD, mid-RT_DELETE, orRT_CONSISTENT(0, as above, since nothing is being loaded at that instant). That is howgdblearns about adlopenwithout polling.
flowchart LR DYN[".dynamic of the executable<br/>DT_DEBUG entry"] -->|"d_ptr, written by ld.so<br/>at startup (0 on disk)"| RD["struct r_debug<br/>r_version = 1<br/>r_brk (debugger breakpoint)<br/>r_state = RT_CONSISTENT"] RD -->|r_map| M1["link_map<br/>l_addr = 0x557e3ee54000<br/>l_ld -> its own .dynamic<br/>(main executable)"] M1 -->|l_next| M2["link_map<br/>linux-vdso.so.1<br/>l_ld -> vdso .dynamic"] M2 -->|l_next| M3["link_map<br/>/lib64/libc.so.6<br/>l_ld -> libc .dynamic"] M3 -->|l_next| M4["link_map<br/>/lib64/ld-linux-x86-64.so.2<br/>l_ld -> ld.so .dynamic"] M4 -->|l_next| NUL["NULL"] GDB["gdb / a profiler /<br/>the program itself"] -.->|"reads _DYNAMIC, finds DT_DEBUG"| RD
The DT_DEBUG reverse channel, drawn from the live output above. What it shows: one tag in the executable’s .dynamic points at r_debug, whose r_map heads a singly-linked chain of link_map records — and each record’s l_ld points back at that object’s own .dynamic. The insight: .dynamic is per-object, and r_debug is the only thing that makes the full set enumerable from inside the process; a debugger that breakpoints r_brk is notified every time that chain changes, which is how dlopen becomes visible to gdb without polling.
This is also why DT_DEBUG is ignored in shared objects: there is only one r_debug per process, anchored in the executable. And it is a good reminder that .dynamic is mapped writable during startup — the loader writes DT_DEBUG’s d_ptr before RELRO locks the segment, exactly as it writes the biased DT_STRTAB and DT_SYMTAB values.
Failure Modes
“error while loading shared libraries: libfoo.so.1: cannot open shared object file.” A DT_NEEDED entry could not be resolved by the search algorithm. Diagnose with ldd ./prog (shows which deps resolve to what) and LD_DEBUG=libs ./prog (traces the search). The fix is usually a missing ldconfig cache entry, a wrong DT_RUNPATH, or a 32/64-bit mismatch.
Wrong library version loaded. Two libraries with the same SONAME on the search path; whichever the cache/DT_RUNPATH/LD_LIBRARY_PATH finds first wins. Because DT_RPATH is searched before LD_LIBRARY_PATH and leaks to children, a stale DT_RPATH baked into a binary can silently override the user’s intent — a classic reason DT_RPATH is deprecated. Check with readelf -d | grep -E 'RPATH|RUNPATH'.
Symbol resolves but to the wrong definition. The lookup scope is breadth-first over the DT_NEEDED graph; a symbol of the same name in an earlier-loaded object shadows a later one (Symbol Interposition and LD_PRELOAD). LD_DEBUG=symbols shows the resolution order.
DT_TEXTREL present. The man page notes that the absence of DT_TEXTREL “indicates that no relocation entries should apply to a nonwritable segment.” Its presence means the object has text relocations — it patches its own code pages, forcing them writable and breaking PIC assumptions. Modern hardened builds reject it (ld -z text).
Alternatives and Boundaries
The dynamic section is ELF-specific. Other formats carry the same information elsewhere: PE/COFF (Windows) uses the import/export data directories in the optional header; Mach-O (macOS) uses load commands (LC_LOAD_DYLIB, LC_DYLD_INFO). A statically linked ELF binary has no .dynamic at all — see Static vs Dynamic Linking. The .dynamic array is the runtime view; the linker-time view of the same data is the .dynamic section in the section header table, but ld.so reaches it through the PT_DYNAMIC segment because section headers may be stripped (ELF Sections vs Segments).
See Also
- The Dynamic Linker — the program (
ld.so) that consumes this table - ELF Relocation Entries — the
DT_RELA/DT_JMPRELtables in detail - Library Search Path and ldconfig — how
DT_NEEDED/DT_RUNPATHnames become files - Symbol Versioning —
DT_VERNEED/DT_VERSYM, the version tags seen above - The Global Offset Table / The Procedure Linkage Table — what
DT_PLTGOT/DT_JMPRELset up - RELRO Relocation Read-Only — what
DF_BIND_NOW/DF_1_NOWenable - ELF Note Sections and Build IDs — the other metadata-bearing ELF structure
- Relocation Processing — what
DT_RELA/DT_JMPREL/DT_RELRcontain and the pass that applies them - GNU IFUNC and Indirect Functions — why
.rela.pltmay holdR_X86_64_IRELATIVEalongsideJUMP_SLOT - The Auxiliary Vector — the other tagged key/value array in process startup, supplied by the kernel rather than the linker;
AT_PHDRis howld.sofindsPT_DYNAMICin the first place - _start and __libc_start_main — what runs once
.dynamichas been fully processed - Position-Independent Code and PIE — why
d_ptrvalues need the load bias, and whatDF_1_PIEmarks - Thread-Local Storage · TLS Models and Access —
DF_STATIC_TLS,DT_TLSDESC_PLT/DT_TLSDESC_GOT - Symbol Interposition and LD_PRELOAD —
DF_1_INTERPOSEandDT_SYMBOLIC - Linux Userspace Runtime MOC
- Compilers and Toolchains MOC — parent map (linking section)