ELF and Dynamic Linking — What Happens Before main() Runs

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

AspectSections (linking view)Segments (execution view)
ConsumerLinker (ld)Loader (kernel + ld.so)
GranularityFine (.text, .data, .bss, .rodata)Coarse (LOAD, DYNAMIC, INTERP)
PurposeRelocation, symbol resolutionMapping into memory with permissions
TableSection Header TableProgram 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     0x8

The 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:

  1. The kernel parses the ELF, maps the LOAD segments, reads the INTERP segment
  2. The kernel loads ld.so (itself an ELF file) and transfers control to it — not to the program’s _start
  3. ld.so parses the .dynamic section, finds the list of needed libraries (DT_NEEDED)
  4. For each library: locate on disk, map, recursively resolve its dependencies
  5. Relocations and symbol resolution — filling the GOT
  6. Call constructors (.init_array)
  7. Jump to the program’s _start, which finally calls main()
# 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 statistics

What 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.so with 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_resolve

Lazy 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 active

Lazy 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 ./hello

RTLD_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

DimensionStatic (-static)Dynamic (default)
Binary sizeLarger; required library code is includedUsually smaller; library dependencies remain
RAM usage (N processes)Processes executing the same file can share clean code pagesDifferent programs can share clean library pages
Startup timeFast (no resolution)Slower (ld.so + relocations)
Library updatesRequires recompilationRequires ABI compatibility; running processes may need restarting
Security patchesRebuild every binaryUpdate the library and refresh processes using it
PortabilityFewer library dependencies; system and ABI requirements remainDependent on .so versions
Use caseScratch containers, static CLIsStandard 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

ToolUse
readelf -h/-l/-dInspect header, segments, dynamic section
objdump -d/-pDisassembler + program headers (safe for dependencies)
nm -DDynamic symbols exported/imported by a .so
LD_DEBUG=allFull trace of loading and resolution at runtime
LD_TRACE_LOADED_OBJECTS=1Dependency list (what ldd does, but deliberately)
patchelfModify RPATH, interpreter, SONAME of an existing binary
strace -e trace=openatWhich .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

Technical references: ELF, ld.so, ldd.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top