How the Dynamic Loader Resolves Symbols in C
This article explains the role of the dynamic loader in executing C programs, detailing how modern operating systems defer symbol binding from compile time to execution time. It covers the core components of the dynamic linking architecture—including Executable and Linkable Format (ELF) binary structures, the Global Offset Table (GOT), and the Procedure Linkage Table (PLT)—and walks step-by-step through the runtime symbol resolution process, from initial binary invocation to lazy binding resolution.
What is the Dynamic Loader?
When a C program is compiled as a dynamically linked executable, the
static linker (ld) does not embed the machine code of
external library functions (such as printf from
libc) directly into the final binary. Instead, it embeds
references indicating that these symbols must be provided by shared
libraries (.so files) at runtime.
The dynamic loader (often named ld.so or
ld-linux.so on Linux systems) is an operating system
utility responsible for preparing these dynamically linked executables
for execution. It locates required shared libraries, loads them into
memory, maps virtual address spaces, and resolves symbolic references
between the executable and the libraries.
The ELF Binary Structure for Dynamic Linking
Dynamically linked executables using the ELF format contain specialized sections that guide the dynamic loader:
.interp: Contains the file path of the dynamic loader itself. The operating system kernel reads this section upon execution and launches the loader before handing control to the program'smainfunction..dynamic: Acts as an array of tags (e.g.,DT_NEEDED) listing the shared libraries the binary depends on, along with pointers to symbol tables and relocation entries..dynsym: The dynamic symbol table, listing imported and exported symbols required during execution..rel.plt/.rela.plt: Relocation entries specifically for functions resolved through the Procedure Linkage Table..rel.dyn/.rela.dyn: Relocation entries for global and static data variables.
The Resolution Mechanism: GOT and PLT
Because modern operating systems use Address Space Layout Randomization (ASLR), shared libraries are mapped to arbitrary memory locations at runtime. Code cannot rely on fixed memory addresses. To solve this, C executables utilize Position-Independent Code (PIC) driven by two coordinating structures:
- Global Offset Table (GOT): An array of absolute
addresses located in the writable data segment (
.gotand.got.plt). Code accesses variables and functions indirectly through pointers stored in the GOT. - Procedure Linkage Table (PLT): An array of
executable code stubs located in the read-only text segment
(
.plt). Each imported dynamic function has a dedicated PLT entry that mediates calls to the actual library function via the GOT.
The Step-by-Step Resolution Process
Dynamic symbol resolution for function calls generally uses a technique called lazy binding, where symbols are not looked up until the function is invoked for the first time.
1. Invocation and Initialization
When the binary is executed, the kernel maps the binary into memory
and transfers control to the dynamic loader specified in the
.interp section. The loader parses the
.dynamic section, loads all dependent shared objects
specified by DT_NEEDED, recursively resolves library
dependencies, and applies data relocations.
2. The First Function Call
When the C code calls an unresolved library function (e.g.,
puts):
- The execution jumps to the function's entry in the PLT
(
puts@plt). - The PLT entry performs an indirect jump using the address currently
stored in its corresponding GOT entry (
puts@got). - Initially, the GOT entry does not contain the address of
puts. Instead, it points to the very next instruction in the PLT stub. - The remaining PLT instructions push a relocation identifier (an offset within the relocation table) and a library identification argument onto the stack.
- The PLT jumps to
PLT[0], the first entry in the table, which invokes the dynamic loader's internal symbol resolution routine (such as_dl_runtime_resolve).
3. Resolving the Symbol
Inside the resolver:
- The dynamic loader uses the symbol identifier to look up the
function's string name (e.g.,
"puts") in.dynsym. - It searches the loaded shared libraries' symbol tables in breadth-first order according to the global symbol scope.
- Once the matching exported function is found, the loader calculates its absolute virtual address in memory.
- The loader writes this absolute address directly into the function’s
designated entry in the GOT (
puts@got). - Finally, the resolver transfers control directly to the target function.
4. Subsequent Calls
For all future calls to the same function:
- The program executes
puts@plt. - The PLT entry performs the indirect jump to the address in
puts@got. - Because the GOT now contains the real memory address of the target
library function, execution branches directly to
putswithout involving the dynamic loader or invoking the resolver routine again.
Non-Lazy Binding
Lazy binding can be overridden. If the environment variable
LD_BIND_NOW=1 is set, or if an executable is compiled with
the -Wl,-z,now flag (often used in hardened binaries to
enable Full RELRO), the dynamic loader resolves all GOT entries
immediately during startup before transferring execution to
main. While this increases program startup time, it removes
runtime resolution latency and allows the entire GOT to be marked
read-only to prevent exploitation.