Skip to main content

C++ Program Loading: From ELF to Running Process

How Linux loads C++ programs: captured ELF segments, runtime mappings, startup handoffs, lazy binding and practical loader debugging.

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.

Step 1 / 10

Control: Calling process

The executable is still a file

  1. program on disk
  2. execve
  3. 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
// 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

# Build
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie

Captured stdout
# Captured stdout
constructor
main
again
ELF fields and stdout come from a compiled example. Control flow is a source-backed teaching sequence, not a recorded kernel trace. Runtime bases and diagram geometry are authored; mappings are not drawn to scale.

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:

text
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
  1. file [0x2de0, 0x3034)
  2. 0x555555557de0 + 596 B
  3. 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
Captured readelf command
g++ -O0 -fcf-protection=none -Wl,-z,relro,-z,lazy -fPIE -pie example.cpp -o example-pie
readelf -Wl example-pie

Captured ELF metadata
ELF type: DYN
Entry: 0x1050
PT_INTERP: /lib64/ld-linux-x86-64.so.2
Program headers, sizes, section membership and link-time addresses are captured with readelf. Runtime load bias is an authored teaching input. Intervals show exact byte bounds, before page alignment; diagram lengths are not proportional.

Key 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, .plt and startup code
  • Read-only LOAD: .rodata and 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.

Executable PT_LOAD bounds and initial flags are captured; RELRO can protect part of a writable segment afterward. Load biases, heap, stack and libc ranges are authored presets, not /proc captures. Regions and gaps are not to scale. ld.so, vDSO and page rounding are omitted.

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:

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

bash
setarch $(uname -m) -R ./program

Or system-wide (not recommended for production):

bash
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.

Step 1 / 8

Control: Kernel

Kernel → ld.so

  1. kernel
  2. ld.so
  3. _start
  4. __libc_start_main
  5. initializers
  6. main
  7. exit

Current handoffKernel

Current handoff pseudocode
PT_INTERP → ld.so entry

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

Captured output order · program stdout
constructor
main
again
Source-backed Linux x86-64 / glibc teaching sequence. Registers and stack fields are selected ABI details; this is not a complete stack dump or an instruction trace. The example’s stdout is captured separately.

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:

  1. Receives the main pointer and arguments from _start.
  2. Arranges runtime cleanup and the normal exit path.
  3. Runs the main executable’s initialization functions, including .init_array entries.
  4. Calls main with its arguments.
  5. 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.

C++
#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

  1. First call: the PLT stub for printf jumps to the GOT entry, which initially points back to a PLT resolver stub
  2. Resolver invokes _dl_runtime_resolve: the dynamic linker searches loaded libraries for printf, finds its address in libc.so
  3. GOT is patched: the resolver writes the real address into the GOT entry
  4. 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 .got at load time and marks it read-only, but .got.plt stays 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

bash
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.

Step 1 / 6

A mapped library is not a resolved call

  1. call site
  2. puts@plt
  3. GOT slot
  4. 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.

Build and inspect the slot
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

Captured PLT layout
puts@plt: 0x1030
puts GOT: 0x4018
initial slot value: 0x1036
PLT0: 0x1020

Compare eager binding
# Compare eager binding
LD_BIND_NOW=1 ./example-pie

Captured stdout (lazy)
# Captured stdout (lazy)
constructor
main
again
PLT/GOT offsets and initial slot bytes are captured from the classic x86-64 PLT build. The libc target is symbolic; lookup steps and timing are an authored teaching model. Other architectures and CET-enabled PLTs can use different layouts.

Static vs Dynamic Linking

AspectStatic (-static)Dynamic (default)
Binary sizeLarge (libc included, ~1 MB+)Small (~16 KB)
Startup workNo separate dynamic interpreterMaps dependencies and relocates references
Code-page sharingSame executable can share clean file-backed pagesSame shared library can share clean file-backed pages
DeploymentBundled library code; platform constraints remainNeeds compatible runtime libraries
Security patchesMust recompile everythingUpdate .so, all programs benefit
Use caseContainers, Go binaries, embeddedDesktop, 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

bash
# 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):

text
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

bash
# Library search paths
LD_DEBUG=libs ./program

# Symbol resolution
LD_DEBUG=bindings ./program

# Everything
LD_DEBUG=all ./program

Check Dependencies and Segments

bash
# Check library dependencies
ldd ./program

# View segments
readelf -l program

Common Loading Errors

ErrorCauseFix
cannot open shared object fileLibrary not in search pathldconfig, or set LD_LIBRARY_PATH, or install the package
GLIBC_2.34 not foundBinary compiled with newer glibc than targetCompile on older system, use static linking, or update target
Segfault before main()Global constructor crashRun with gdb, break on __libc_start_main, step through constructors
version GLIBCXX_3.4.30 not foundC++ stdlib mismatchUpdate libstdc++ or use -static-libstdc++

Further Reading

If you found this explanation helpful, consider sharing it with others.

Mastodon