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's main function.
  • .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:

  1. Global Offset Table (GOT): An array of absolute addresses located in the writable data segment (.got and .got.plt). Code accesses variables and functions indirectly through pointers stored in the GOT.
  2. 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):

  1. The execution jumps to the function's entry in the PLT (puts@plt).
  2. The PLT entry performs an indirect jump using the address currently stored in its corresponding GOT entry (puts@got).
  3. Initially, the GOT entry does not contain the address of puts. Instead, it points to the very next instruction in the PLT stub.
  4. The remaining PLT instructions push a relocation identifier (an offset within the relocation table) and a library identification argument onto the stack.
  5. 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:

  1. The dynamic loader uses the symbol identifier to look up the function's string name (e.g., "puts") in .dynsym.
  2. It searches the loaded shared libraries' symbol tables in breadth-first order according to the global symbol scope.
  3. Once the matching exported function is found, the loader calculates its absolute virtual address in memory.
  4. The loader writes this absolute address directly into the function’s designated entry in the GOT (puts@got).
  5. Finally, the resolver transfers control directly to the target function.

4. Subsequent Calls

For all future calls to the same function:

  1. The program executes puts@plt.
  2. The PLT entry performs the indirect jump to the address in puts@got.
  3. Because the GOT now contains the real memory address of the target library function, execution branches directly to puts without 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.