What Happens Before main() Runs
You type ./program, but the first executed instruction is not your main(). For a typical dynamically linked ELF program, the kernel maps segments and transfers control to the interpreter specified in the file. The dynamic linker loads dependencies and performs relocations before the runtime reaches application code.
Most developers treat ./program as a black box. An engineer who understands ELF and dynamic linking knows why the first call to a library function is slower, how LD_PRELOAD lets you interpose on many functions without recompilation, why ldd does not show the whole picture, and how symbol resolution can be an attack vector.
This article dissects the ELF format from the header through segments, the mechanics of ld.so, the GOT/PLT structures, lazy binding, and the practical applications of interposition. This is the knowledge that turns “linker errors” into a predictable, debuggable process.
Anatomy of an ELF File
ELF (Executable and Linkable Format) is the binary format used by Linux and many other Unix-like systems for executables, shared libraries (.so), object files (.o), and core dumps. The structure has two complementary perspectives: the linking view (sections, for the linker) and the execution view (segments, for the loader).
# ELF header — the first 64 bytes determine everything
$ readelf -h ./program
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 14688 (bytes into file)The magic number 7f 45 4c 46 identifies ELF. Both shared libraries and PIE executables use ET_DYN, so the type field alone does not distinguish them. A typical C executable’s entry point leads to startup code, usually _start, rather than directly to main().
Sections vs Segments — Two Views of the Same File
| Aspect | Sections (linking view) | Segments (execution view) |
|---|---|---|
| Consumer | Linker (ld) | Loader (kernel + ld.so) |
| Granularity | Fine (.text, .data, .bss, .rodata) | Coarse (LOAD, DYNAMIC, INTERP) |
| Purpose | Relocation, symbol resolution | Mapping into memory with permissions |
| Table | Section Header Table | Program Header Table |
# Segments — what the loader actually maps into memory
$ readelf -l ./program
Program Headers:
Type Offset VirtAddr Flags Align
INTERP 0x000318 0x0000000000000318 R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 R 0x1000 # headers
LOAD 0x001000 0x0000000000001000 R E 0x1000 # .text (code)
LOAD 0x002000 0x0000000000002000 R 0x1000 # .rodata
LOAD 0x002db8 0x0000000000003db8 RW 0x1000 # .data + .bss
DYNAMIC 0x002dc8 0x0000000000003dc8 RW 0x8The crucial one is the INTERP segment — it contains the path to the dynamic linker (/lib64/ld-linux-x86-64.so.2). The kernel reads this path, and it’s the linker, not the kernel, that finishes loading the program. Each LOAD segment has permission flags (R/W/E) mapped directly onto MMU page bits — the code segment is R E (read+execute, but not write), which blocks self-modifying code. Those same page flags decide whether a memory access is legal — the details are in the anatomy of a segfault.
The Role of the Dynamic Linker ld.so
When you run a dynamically linked program, the sequence is as follows:
- The kernel parses the ELF, maps the LOAD segments, reads the INTERP segment
- The kernel loads
ld.so(itself an ELF file) and transfers control to it — not to the program’s_start ld.soparses the.dynamicsection, finds the list of needed libraries (DT_NEEDED)- For each library: locate on disk, map, recursively resolve its dependencies
- Relocations and symbol resolution — filling the GOT
- Call constructors (
.init_array) - Jump to the program’s
_start, which finally callsmain()
# Trace the full library loading process via ld.so
$ LD_DEBUG=libs ./program 2>&1 | head -20
find library=libc.so.6 [0]; searching
search path=/lib/x86_64-linux-gnu/tls/haswell/x86_64
trying file=/lib/x86_64-linux-gnu/libc.so.6
calling init: /lib/x86_64-linux-gnu/libc.so.6
# All available ld.so debug categories
$ LD_DEBUG=help ./program
libs display library search paths
reloc display relocation processing
symbols display symbol table processing
bindings display information about symbol binding
statistics display relocation statisticsWhat ldd Shows and What It Misses
ldd normally runs the dynamic linker in dependency-tracing mode. It cannot show future dlopen() calls, and its result depends on the environment. Its documentation warns against using it on untrusted executables. objdump -p and readelf -d read DT_NEEDED entries without running the program, but show only direct dependencies.
# Safe dependency inspection — without running the binary
$ objdump -p ./program | grep NEEDED
NEEDED libssl.so.3
NEEDED libcrypto.so.3
NEEDED libc.so.6
# Alternatively via readelf
$ readelf -d ./program | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]GOT and PLT — The Mechanics of External Function Calls
Here’s the core. Since libraries are loaded at random addresses (ASLR), how can code call printf() when its address isn’t known at compile time? The answer is two structures: the GOT (Global Offset Table) and the PLT (Procedure Linkage Table).
- GOT — a table of pointers. An entry for each external symbol, filled by
ld.sowith the actual runtime address. Writable during relocation; RELRO can protect it later. - PLT — a table of small code stubs. A function using this call mechanism has a PLT entry, which performs an indirect jump through the GOT.
In a simplified PLT-based model, a printf() call compiles to call printf@plt — a jump to the PLT stub, not the function itself:
/* Your code */
printf("%s", message);
/* Compiles to (simplified): */
call printf@plt /* jump to the PLT stub */
/* printf@plt (simplified assembly): */
printf@plt:
jmp *printf@got /* indirect jump through the GOT */
/* if GOT not filled — fallback to the resolver */
push $printf_index
jmp _dl_runtime_resolveLazy Binding — Why the First Call Is Slower
With lazy binding enabled, a function address used through the PLT may be resolved on the first call. Linker options and LD_BIND_NOW can request earlier binding, so first-call cost depends on how the program was built.
The mechanics: initially the GOT entry for a function points back to the PLT code, which calls _dl_runtime_resolve. This locates the function’s actual address, writes it into the GOT, and jumps to the function. Every subsequent call goes directly through the filled GOT — hence the overhead only the first time.
# Force eager binding — all symbols resolved at startup
$ LD_BIND_NOW=1 ./program
# Slower start, but no overhead on the first call of each function
# Also: the entire GOT can be read-only (RELRO) — security hardening
# Check if a binary has Full RELRO (GOT read-only after startup)
$ readelf -l ./program | grep GNU_RELRO
GNU_RELRO 0x002db8 0x0000000000003db8 R 0x1
$ readelf -d ./program | grep BIND_NOW
0x000000000000001e (FLAGS) BIND_NOW # Full RELRO activeLazy binding requires updating the relevant GOT entries. Typical full RELRO is requested at link time with -Wl,-z,relro,-z,now: relocations happen before the region becomes read-only. Setting LD_BIND_NOW alone does not change an existing file’s segment layout.
LD_PRELOAD — Interposition as a Superpower
LD_PRELOAD loads extra libraries before ordinary dependencies and permits interposition on many symbols. It cannot replace every call: symbol visibility, static linking, compiler optimization and secure-execution mode impose limits.
/* puts_tracker.c — simple interposition example, Linux/glibc */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>
int puts(const char *text) {
int (*next_puts)(const char *) = dlsym(RTLD_NEXT, "puts");
if (!next_puts) return EOF;
const char notice[] = "[puts] intercepted call\n";
(void)write(STDERR_FILENO, notice, sizeof notice - 1);
return next_puts(text);
}gcc -shared -fPIC -o puts_tracker.so puts_tracker.c -ldl
# Your test program should call puts("hello").
LD_PRELOAD=./puts_tracker.so ./helloRTLD_NEXT searches for the next matching symbol, allowing a wrapper to forward the call. Intercepting malloc() is harder: dlsym() or stdio logging may allocate memory themselves and cause recursion. A simple wrapper using fprintf() is not a safe allocation profiler.
Static vs Dynamic Linking — The Trade-off
| Dimension | Static (-static) | Dynamic (default) |
|---|---|---|
| Binary size | Larger; required library code is included | Usually smaller; library dependencies remain |
| RAM usage (N processes) | Processes executing the same file can share clean code pages | Different programs can share clean library pages |
| Startup time | Fast (no resolution) | Slower (ld.so + relocations) |
| Library updates | Requires recompilation | Requires ABI compatibility; running processes may need restarting |
| Security patches | Rebuild every binary | Update the library and refresh processes using it |
| Portability | Fewer library dependencies; system and ABI requirements remain | Dependent on .so versions |
| Use case | Scratch containers, static CLIs | Standard system deployment |
The renaissance of static linking is visible in Go (static by default) and Rust (musl target) — mainly for scratch containers (zero-dependency images) and single-binary distribution. Dynamic remains the system standard due to memory sharing and central security patches (one CVE in OpenSSL = one libssl.so update, not a rebuild of a thousand applications). That sharing of code pages between processes rests on copy-on-write — the same mechanism as the fork() system call.
Practical Diagnostics — Tools
| Tool | Use |
|---|---|
readelf -h/-l/-d | Inspect header, segments, dynamic section |
objdump -d/-p | Disassembler + program headers (safe for dependencies) |
nm -D | Dynamic symbols exported/imported by a .so |
LD_DEBUG=all | Full trace of loading and resolution at runtime |
LD_TRACE_LOADED_OBJECTS=1 | Dependency list (what ldd does, but deliberately) |
patchelf | Modify RPATH, interpreter, SONAME of an existing binary |
strace -e trace=openat | Which .so files are actually opened at startup |
Conclusion: The Linker as Part of the Execution Contract
Dynamic linking isn’t magic that “just works” — it’s a precisely defined protocol between the compiler, linker, kernel, and ld.so. Understanding the ELF format, GOT/PLT mechanics, and lazy binding turns a whole class of problems (symbol not found, version mismatch, wrong library loaded, slow startup) from mysterious into deterministic and debuggable.
Distinguish a missing declared dependency from a missing search path. Adding RPATH does not create a DT_NEEDED entry. Inspect what the program needs and where the linker searches before changing configuration.
The mechanics of loading a program are the foundation everything else stands on. It’s worth knowing what happens before main() runs.
Related articles
- Anatomy of a Segfault — From MMU Through Kernel to gdb Core Dump — segment mapping and page permissions
- malloc internals — Why free() Doesn’t Return Memory to the System — LD_PRELOAD as the allocator swap mechanism
- What fork() in Linux Really Does Under the Hood — sharing libraries via copy-on-write
- strace as a Debugging Primitive — When the Stack Trace Lies — LD_DEBUG and strace as loading-analysis tools
Technical references: ELF, ld.so, ldd.


