From File to Process
Before main() runs, who maps your code, fixes its addresses and calls the constructors? Follow one executable through those handoffs.
Linux x86-64 · ELF
Inside a program launch
Follow one executable as control moves from the kernel to the loader, runtime and your code.
Control: Calling process
The executable is still a file
- program on disk
- execve
- ELF headers
Executable contractElf64_Ehdr / Phdr
- e_type
- DYN (PIE)
- e_entry
- 0x1050 → _start
- PT_LOAD
- 4 segments
- PT_INTERP
- /lib64/ld-linux-x86-64.so.2
- DT_NEEDED
- libc.so.6
Process imageCalling process
- Image
- replacement requested
- PID
- preserved by execve
- PC / work
- execve
- Mapped
- new mappings not installed yet
- main
- not entered
Old image → replacement image
execve replaces the current process image. It does not create a second process or assign a new PID.
Runnable example and captured evidence
g++ (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0
ldd (Ubuntu GLIBC 2.35-0ubuntu3.15) 2.35
The browser illustrates the launch; it does not execute this C++ program. The constructor prints before main. Disabling CET code generation selects the classic PLT layout used later in this page.
// example.cpp
#include <cstdio>
int answer = 42;
char scratch[8192];
struct BeforeMain {
BeforeMain() { std::puts("constructor"); }
};
BeforeMain before_main;
int main() {
std::puts("main");
std::puts("again");
return 0;
}
# Build
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie
# Captured stdout
constructor
main
again
The kernel replaces the process image and maps the executable and its interpreter. The dynamic linker maps dependencies and processes relocations. The C runtime reaches the executable’s initializers and calls main(). Loading, resolving a symbol and executing an initializer are separate operations.
The figures follow one compiled example on Linux x86-64 with glibc. ELF fields, symbol offsets and stdout are captured; runtime bases, memory placement and event timing are labeled teaching presets. Expand the first figure for the runnable source. The same puts call and global storage appear throughout.
The ELF Binary
Every Linux executable (and shared library) uses ELF — the Executable and Linkable Format. ELF has two parallel views of the same file:
- Linking view (sections): used by the linker at build time —
.text,.data,.bss,.symtab,.rela.dyn, etc. - Execution view (segments): used by the kernel at load time —
LOAD,INTERP,DYNAMIC,GNU_STACK, etc.
Sections are fine-grained (one per purpose). A loadable segment groups file regions with compatible memory permissions. The kernel uses program headers to create the executable mappings; section names are not required for that operation.
Selected readelf -h fields from the compiled PIE example:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 ...
Class: ELF64
Type: DYN (Position-Independent Executable)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x1050
Start of program headers: 64 (bytes into file)
Number of program headers: 13
For this executable, Type: DYN and the PIE build flags identify a position-independent executable. DYN is also used for shared objects, so the type alone does not prove PIE. The Entry point address: 0x1050 identifies the executable’s _start; at runtime its load bias is added. With PT_INTERP, the kernel first enters the dynamic linker instead of jumping directly to that executable entry.
Linux x86-64 · ELF
From file bytes to virtual memory
Select a program header. A PT_LOAD connects a file interval to a memory interval; other headers describe metadata.
LOAD headerElf64_Phdr
- p_offset
- 0x2de0
- p_vaddr
- 0x3de0
- p_filesz
- 596 B
- p_memsz
- 8,840 B
- p_flags
- RW
- p_align
- 0x1000
Resulting memory intervalDYN
- Load bias
- 0x555555554000 (preset)
- Start
- 0x555555557de0
- End (exclusive)
- 0x55555555a068
- File-backed bytes
- 596 B
- Zero-filled tail
- 8,244 B
- file [0x2de0, 0x3034)
- 0x555555557de0 + 596 B
- zero 8,244 B
File bytes → mapped memory
0x555555557de0 → 0x555555558034
Zero-fill: memory exists without file payload
0x555555558034 → 0x55555555a068
memory start = load bias + 0x3de0
The extra 8,244 B needs memory, but no corresponding payload bytes in the executable. The zero-filled tail covers storage such as .bss and can include alignment space. Section names explain purpose; program headers drive the mapping.
Sections covered: .init_array, .fini_array, .dynamic, .got, .got.plt, .data, .bss
Captured readelf command
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie
readelf -Wl example-pie
ELF type: DYN
Entry: 0x1050
PT_INTERP: /lib64/ld-linux-x86-64.so.2Key Segments
LOAD segments are the segments the kernel actually maps into memory. The captured example has four:
- Read-only LOAD: ELF headers and dynamic-linking metadata
- Executable LOAD:
.text,.pltand startup code - Read-only LOAD:
.rodataand unwind information - Writable LOAD:
.data,.bss, GOT and initialization arrays
Why can p_memsz exceed p_filesz? The memory interval has a zero-filled tail with no corresponding file payload. This covers storage such as .bss and can also include alignment space. In our example, scratch[8192] contributes zero-initialized storage; its bytes do not need to be stored in the executable. The exact zero-filled tail is p_memsz - p_filesz, not necessarily the exact .bss section size.
INTERP segment contains a single string: /lib64/ld-linux-x86-64.so.2. This tells the kernel which dynamic linker to invoke before transferring control to the program.
Memory Layout
Once the kernel and dynamic linker finish their work, the process has a well-defined virtual address space:
Linux x86-64 · ELF
One address space, many mappings
Select a region, then change the launch. PIE moves the executable as a unit; its internal offsets stay the same.
Higher virtual addresses ↓ lower virtual addresses
··· unmapped gap ···
··· unmapped gap ···
··· unmapped gap ···
program · LOAD 1RE
- Start
- 0x555555555000
- End (exclusive)
- 0x5555555551d1
- Range length
- 465 B
- Evidence
- captured interval + preset bias
.init, .plt, .plt.got, .text, .fini
Where does main land?DYN
- Load bias
- 0x555555554000
- main in ELF
- 0x1139
- main at runtime
- 0x555555555139
- main − _start
- 0xe9
Changing base + unchanged symbol offset
In the non-PIE build, executable addresses stay fixed across presets. The library and stack can still move. Mapping permissions apply to ranges; they do not say how many pages are physically resident.
You can inspect a running process with cat /proc/PID/maps. A shortened illustrative listing looks like this; these addresses are not captured from the example above:
55a3f2400000-55a3f2401000 r--p program (ELF headers)
55a3f2401000-55a3f2402000 r-xp program (.text)
55a3f2402000-55a3f2403000 r--p program (.rodata)
55a3f2403000-55a3f2405000 rw-p program (.data, .bss)
7f8c12000000-7f8c12200000 r-xp /lib/x86_64-linux-gnu/libc.so.6
7ffca1200000-7ffca1221000 rw-p [stack]
7ffca1304000-7ffca1306000 r-xp [vdso]
Each line shows the virtual address range, permissions (r = read, w = write, x = execute, p = private), and what occupies that region. Notice the program occupies four small mappings with different permissions, libc is mapped separately, and the stack is at the top of the address space. The [vdso] is a kernel-provided shared object that accelerates system calls like gettimeofday without a full context switch.
ASLR: Address Space Layout Randomization
Run cat /proc/self/maps twice and you’ll get different addresses each time. That’s ASLR — the kernel randomizes the base addresses of the executable, shared libraries, stack, and heap on every execution.
ASLR makes return-oriented programming (ROP) and ret2libc attacks less predictable. It frustrates hardcoded jump targets, but an address disclosure or other bypass can undermine that protection. Combined with PIE binaries (which randomize the code segment too, not just the stack and libraries), ASLR makes exploitation significantly harder.
PIE vs non-PIE: with ASLR enabled, a PIE executable can receive a randomized load bias. PIE defaults depend on the compiler configuration and distribution. A conventional non-PIE ELF uses its linked executable addresses; 0x400000 is a common x86-64 base, not a universal requirement. Its libraries and stack can still be randomized.
For debugging, you can disable ASLR temporarily:
setarch $(uname -m) -R ./program
Or system-wide (not recommended for production):
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
The Startup Sequence
The path from execve to your main() function involves several handoffs. Understanding this chain explains why programs can crash “before main” and how the C runtime sets up the environment your code depends on.
Linux x86-64 · ELF
Who calls main?
Follow the control handoffs and the structures each stage consumes. The kernel does not call main.
Control: Kernel
Kernel → ld.so
- kernel
- ld.so
- _start
- __libc_start_main
- initializers
- main
- exit
Current handoffKernel
PT_INTERP → ld.so entryThe initial stack is data prepared by the kernel, not a call frame for main. The auxiliary vector lets the interpreter find the already-mapped executable.
Inputs and stateinterpreter entry
- argc
- 1
- argv[0]
- "./program"
- AT_ENTRY
- load bias + e_entry
- AT_PHDR
- mapped program headers
- AT_BASE
- interpreter load bias
PT_INTERP → ld.so entry
Initialization can execute user code before main. A failure here belongs to the startup path, even if main itself is correct.
constructor
main
again
Why _start Exists
For a dynamically linked ELF, the kernel enters the interpreter named by PT_INTERP. After the loader’s startup work, control reaches the executable’s _start. This assembly stub comes from a C runtime startup object, such as Scrt1.o for the captured PIE build. It extracts arguments from the initial stack and calls __libc_start_main, passing main as a function pointer. The environment is derived from the initial argument layout.
What __libc_start_main Does
For this glibc startup path, libc coordinates the transition from runtime setup to application code:
- Receives the
mainpointer and arguments from_start. - Arranges runtime cleanup and the normal exit path.
- Runs the main executable’s initialization functions, including
.init_arrayentries. - Calls
mainwith its arguments. - Passes the return value into normal exit processing.
The loader has already handled dynamic-loader work such as initial TLS setup and dependency initialization. Older diagrams often show __libc_csu_init; modern glibc startup can perform executable initialization without that wrapper. The exact division of work depends on libc and whether the executable is static or dynamic.
The Static Initialization Order Fiasco
For ordinary non-inline globals with ordered dynamic initialization in one translation unit, declaration order determines initialization order. Across translation units, a dependency on another object’s dynamic initialization can fail. There is no defined “50/50” probability: the behavior depends on the program and its initialization rules.
#include <iostream>
class Logger {
public:
Logger() { std::cout << "Logger init\n"; } // runs BEFORE main
} g_logger;
int main() { std::cout << "main\n"; }
// Output: Logger init, then main
Cross-File Initialization Dependencies
This works fine in isolation, but if Logger’s constructor tries to use another global from a different translation unit that hasn’t been constructed yet, you get undefined behavior. The fix is the Construct on First Use idiom: wrap the global in a function that returns a reference to a local static.
Dynamic Linking: PLT and GOT
When your program calls a shared library function like printf, the compiler doesn’t know the final address at compile time. Instead, it generates a call through two indirection tables: the PLT (Procedure Linkage Table) and the GOT (Global Offset Table).
How Lazy Binding Works
- First call: the PLT stub for
printfjumps to the GOT entry, which initially points back to a PLT resolver stub - Resolver invokes
_dl_runtime_resolve: the dynamic linker searches loaded libraries forprintf, finds its address inlibc.so - GOT is patched: the resolver writes the real address into the GOT entry
- Subsequent calls: the PLT jumps to the GOT, which now contains the real address — no resolver overhead
For a lazily bound PLT slot, the first call pays symbol-lookup work; later calls use the patched target. This is per eligible slot, and a constructor can make that first call before main. Other references may already be resolved during loading.
RELRO: Hardening the GOT
The GOT is writable (so the resolver can patch it), which makes it an attractive attack target. RELRO (Relocation Read-Only) protections exist in two forms:
- Partial RELRO (default): resolves
.gotat load time and marks it read-only, but.got.pltstays writable for lazy binding - Full RELRO (
-Wl,-z,relro,-z,now): resolves all symbols at load time and marks the entire GOT read-only
Full RELRO moves eligible binding work to startup and protects the covered GOT pages from writes afterward. It narrows the attack surface; it does not make the whole process immune to memory corruption.
Forcing Eager Resolution
LD_BIND_NOW=1 ./program
This forces the dynamic linker to resolve all PLT entries at load time instead of lazily. Equivalent to compiling with -Wl,-z,now. Useful for catching missing symbols early and for security hardening.
Linux x86-64 · ELF
One call, one mutable GOT slot
libc is mapped first. Binding decides when the puts slot learns its final target.
A mapped library is not a resolved call
- call site
- puts@plt
- GOT slot
- PLT fallback
Loaded objectlibc.so.6
- DT_NEEDED
- libc.so.6
- Mapping
- present in both policies
- Function
- puts
- Resolved target
- libc load bias + puts offset
Mapping a library and resolving one function reference are separate operations.
puts GOT slotR_X86_64_JUMP_SLOT
- Slot in ELF
- 0x4018
- Slot at runtime
- 0x555555558018
- Stored target
- 0x555555555036
- Binding state
- unresolved
- Lookup now?
- no
Before bindingpointer value
GOT[puts] → 0x555555555036
Back into puts@plt + 6
After bindingnot reached yet
GOT[puts] → libc:puts
The PLT code stays unchanged
Unresolved slot · no lookup at this event
libc is already mapped, but this puts slot still targets the executable’s PLT fallback. Lazy binding defers the symbol lookup.
Captured PLT layout and reproduction
The example calls puts in a global constructor, then twice in main. The first lazy binding therefore happens before main. The browser does not inspect the running GOT.
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie
# Inspect the slot and stub
objdump -d -j .plt example-pie
readelf -Wr example-pie
puts@plt: 0x1030
puts GOT: 0x4018
initial slot value: 0x1036
PLT0: 0x1020
# Compare eager binding
LD_BIND_NOW=1 ./example-pie
# Captured stdout (lazy)
constructor
main
again
Static vs Dynamic Linking
| Aspect | Static (-static) | Dynamic (default) |
|---|---|---|
| Binary size | Large (libc included, ~1 MB+) | Small (~16 KB) |
| Startup work | No separate dynamic interpreter | Maps dependencies and relocates references |
| Code-page sharing | Same executable can share clean file-backed pages | Same shared library can share clean file-backed pages |
| Deployment | Bundled library code; platform constraints remain | Needs compatible runtime libraries |
| Security patches | Must recompile everything | Update .so, all programs benefit |
| Use case | Containers, Go binaries, embedded | Desktop, servers, system packages |
Static linking bundles library code into the executable, while compatibility still depends on the architecture, ABI and runtime assumptions. Dynamic linking lets different executables share clean library code pages and receive library fixes without rebuilding every executable, subject to ABI compatibility. Static executables can also share their own clean file-backed pages across processes. Neither model shares every writable page.
Debugging Loading
Trace System Calls
# Trace syscalls during loading
strace -f -e trace=execve,brk,openat,mmap,mprotect ./program 2>&1 | head -20
A shortened illustrative strace excerpt (addresses and sizes vary):
execve("./program", ["./program"], 0x7ffd...) = 0
brk(NULL) = 0x55a3f4a00000
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY) = 3
mmap(NULL, 2136936, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f8c12000000
mprotect(0x7f8c12028000, 1990656, PROT_NONE) = 0
This shows the kernel executing the binary (execve), then the dynamic linker opening libc.so.6, mapping it into memory with mmap, and setting up page protections with mprotect.
Debug the Dynamic Linker
# Library search paths
LD_DEBUG=libs ./program
# Symbol resolution
LD_DEBUG=bindings ./program
# Everything
LD_DEBUG=all ./program
Check Dependencies and Segments
# Check library dependencies
ldd ./program
# View segments
readelf -l program
Common Loading Errors
| Error | Cause | Fix |
|---|---|---|
cannot open shared object file | Library not in search path | ldconfig, or set LD_LIBRARY_PATH, or install the package |
GLIBC_2.34 not found | Binary compiled with newer glibc than target | Compile on older system, use static linking, or update target |
| Segfault before main() | Global constructor crash | Run with gdb, break on __libc_start_main, step through constructors |
version GLIBCXX_3.4.30 not found | C++ stdlib mismatch | Update libstdc++ or use -static-libstdc++ |
Further Reading
-
How Programs Get Run: ELF Binaries — LWN deep dive into ELF loading
-
A Whirlwind Tutorial on Creating Really Teensy ELF Executables — Classic article on minimal ELF
-
Drepper: How to Write Shared Libraries — Ulrich Drepper’s definitive guide to ELF and dynamic linking
-
The Linux Programming Interface, Ch. 41-42 — Michael Kerrisk’s comprehensive coverage of shared libraries and loading
Related concepts
Follow Linux shared libraries from dependency lookup to PLT/GOT binding, then build a versioned library, load a plugin and debug runtime linking failures.
How C++ object files are linked into executables. Learn symbol resolution, static vs dynamic linking, and linker optimization.
Deep dive into C++ memory allocation — stack frame internals, heap allocator mechanics, fragmentation, performance benchmarks, custom allocators, RAII, and debugging with AddressSanitizer and Valgrind.
Complete guide to C++ symbol resolution — how linkers match references to definitions, name mangling, strong vs weak symbols, ODR, template instantiation, linking order, and debugging undefined reference errors.
See how tail calls reuse stack frames, when compilers can eliminate them, and how CPython 3.14 applies tail calls to C opcode dispatch.
Deep dive into Python's asyncio library, understanding event loops, coroutines, tasks, and async/await patterns with interactive visualizations.
