On this page
Dynamic Linking and ld.so
Coverage: PLT/GOT → lazy binding → LD_PRELOAD → LD_LIBRARY_PATH → rpath/runpath → symbol versioning → dlopen/dlsym → IFUNC Applicable to: glibc ld.so, musl ldso
Overview
Dynamic linking allows multiple programs to share the same code page of a .so (only one copy in physical memory), while also allowing libraries to be updated independently (without relinking the programs). The cost is a slight delay on the first call to each external function (lazy binding) and the burden of maintaining ABI compatibility. Understanding the PLT/GOT mechanism is a prerequisite for troubleshooting issues such as "undefined symbol", "LD_PRELOAD not taking effect", and "libfoo.so.1 vs libfoo.so.2".
PLT/GOT: The Engine of Lazy Binding
The Problem with Static Linking
// main.c:
extern int ;
int
// Static linking: The linker hardcodes the address of foo in the call instruction
// call <address_of_foo>
// Problem: Every time libfoo.so is updated → the address of foo changes → all callers must be relinked
Dynamic Linking: PLT + GOT
flowchart TD
MAIN["Program (main)<br/>call foo@PLT<br/>Does not call foo directly, but calls the PLT stub"]
MAIN --> PLT{"PLT[foo]<br/>jmp *GOT[foo]"}
PLT -->|"First call<br/>GOT → next PLT instruction"| LAZY["push index<br/>Pushes the index of foo in .rela.plt"]
PLT -->|"Subsequent calls<br/>GOT → real address of foo"| DIRECT["jmp foo<br/>Direct jump ✅<br/><br/>Each call adds only one indirect jump"]
LAZY --> RESOLVER["jmp PLT[0]<br/>→ _dl_runtime_resolve()"]
RESOLVER --> FIND["🔍 Look up the address of function foo in libfoo.so"]
FIND --> UPDATE["📝 Update GOT[foo]<br/>= real address of foo"]
UPDATE --> EXEC["▶️ jmp foo<br/>Execute the real foo"]
classDef entry fill:#e3f2fd,stroke:#1565c0
classDef decision fill:#fff3e0,stroke:#ef6c00
classDef resolver fill:#f3e5f5,stroke:#7b1fa2
classDef done fill:#e8f5e9,stroke:#2e7d32
class MAIN entry
class PLT decision
class LAZY,RESOLVER,FIND,UPDATE resolver
class DIRECT,EXEC done
Three-Part Memory Layout
.got.plt (Global Offset Table — PLT specific):
GOT[0]: Address of the .dynamic section
GOT[1]: struct link_map * (link map of the current shared library)
GOT[2]: Address of dl_runtime_resolve (filled by ld.so)
GOT[3+]: Addresses of various external functions (initially pointing to the second instruction of PLT[func])
.plt (Procedure Linkage Table):
PLT[0]: Common entry — push link_map + jmp dl_runtime_resolve
PLT[1+]: Stubs for each function — jmp *GOT[n]; push index; jmp PLT[0]
.got (Global data for non-PLT):
Stores addresses of global variables (e.g., extern int errno;)
Practical Test
# Compile and view PLT/GOT:
|
|
# Confirm lazy binding at runtime:
LD_DEBUG=bindings |
# Output: binding file ./test [0] to ./test [0]: normal symbol `foo'
ld.so: The Dynamic Linker
Loading and Searching
1. Kernel loads ELF → sees PT_INTERP segment → /lib64/ld-linux-x86-64.so.2
2. Kernel loads ld.so into memory → jumps to ld.so's _start
3. ld.so bootstraps (relocates itself, because ld.so is also dynamically linked!)
4. ld.so reads the main program's .dynamic → DT_NEEDED → loads dependent .so files
5. For each .so: reads .dynamic → loads dependencies of dependencies
6. Symbol resolution: Iterates through all loaded .so files → resolves all undefined symbols
7. Relocation: Fills GOT, handles R_X86_64_GLOB_DAT / R_X86_64_JUMP_SLOT
8. Calls the main program's _start
Search Path Order
1. DT_RPATH (RPATH in ELF .dynamic, deprecated, replaced by DT_RUNPATH)
2. LD_LIBRARY_PATH (user override)
3. DT_RUNPATH (RUNPATH in ELF .dynamic)
4. /etc/ld.so.cache (generated by ldconfig)
5. /lib64, /usr/lib64 (default paths)
Checks:
readelf -d /bin/ls | grep -E 'RPATH|RUNPATH|NEEDED'
ldconfig -p | grep libfoo
LD_DEBUG=libs ./test 2>&1 # Trace all searches
LD_PRELOAD
# Force insertion of a shared library at the very beginning of symbol resolution → override any symbol
LD_PRELOAD=./override.so
# Typical uses:
# 1. Replace malloc → track memory allocations
# 2. Replace connect → network redirection
# 3. Replace open → filesystem sandbox
# Conditions: Cannot be used to override statically linked functions
# (Static linking has no PLT/GOT, call goes directly to the function address)
dlopen / dlsym
void *handle = ;
= ;
;
;
// RTLD_LAZY: Lazy binding (default)
// RTLD_NOW: Resolve all symbols immediately (dlopen fails earlier if issues exist)
// RTLD_GLOBAL: Symbols from this library are visible to subsequent dlopen calls
// RTLD_LOCAL: Symbols are visible only to this handle (default)
// RTLD_NODELETE: dlclose does not unload (prevents dangling pointers)
// RTLD_NOLOAD: Do not load, only check if already loaded
Symbol Versioning and ABI
// Symbol versioning (GNU extension):
// libfoo.so: foo@@VERS_2.0 (default), foo@VERS_1.0 (old)
// At link time: Take the default version
// At runtime: Check if the loaded .so has a matching version
// GCC's -fno-semantic-interposition (5.x+):
// Tells the compiler that function calls within the library can assume "this library's version"
// → Skips PLT → Direct call → Faster
// But disables the effect of LD_PRELOAD on this function
Debugging
# View all linked shared libraries
# View symbol resolution (extremely detailed)
LD_DEBUG=all |
# View .so files loaded by the current process
|
# Undefined symbols (at link time)
References
- Source code: glibc
elf/dl-runtime.c(_dl_runtime_resolve),elf/rtld.c(ld.so main) - man pages: ld.so(8), dlopen(3), dlsym(3), elf(5)
- LWN: "How to write shared libraries", "The cost of lazy binding"
Keywords: PLT, GOT, lazy binding, dl_runtime_resolve, LD_PRELOAD, dlopen, dlsym, DT_NEEDED, RPATH, RUNPATH, symbol versioning