Your program does not start at main. By the time main runs, the kernel has
parsed an executable format, built an address space, and handed control to a
program you didn’t write — which then spends its time patching your program in
memory. Knowing this pipeline is what separates “it says No such file or directory
but the file exists??” confusion from a five-second diagnosis.
Step 1: The Kernel and execve
./a.out in a shell becomes fork() then execve("/path/a.out", argv, envp).
The kernel reads the first bytes and dispatches on magic: \x7fELF → ELF loader,
#! → script interpreter, and so on. For ELF, it reads the program headers —
the load-time view of the file (as opposed to section headers, which are for
linkers and debuggers and can be stripped):
$ readelf -lW ./a.out
Type Offset VirtAddr FileSiz MemSiz Flg
PHDR ...
INTERP 0x000318 ... /lib64/ld-linux-x86-64.so.2
LOAD 0x000000 0x400000 0x0650 0x0650 R (headers, rodata)
LOAD 0x001000 0x401000 0x02f5 0x02f5 R E (text)
LOAD 0x002df0 0x403df0 0x0230 0x0238 RW (data + bss)
...The kernel mmaps each LOAD segment with the requested permissions — note
nothing is actually read yet; pages fault in from the file on first touch
(demand paging). MemSiz > FileSiz on the RW segment is .bss: zero-initialized
data that occupies no file space. The old stack is replaced, argv/envp are copied
onto the new one along with the auxiliary vector (entropy for stack canaries,
page size, the VDSO address).
Then the plot twist: for dynamically linked binaries the kernel does not jump
to your entry point. The INTERP header names the real first program:
/lib64/ld-linux-x86-64.so.2, the dynamic linker. The kernel maps it and jumps
there. (This is also the answer to the classic riddle — running a binary built
against a different libc prints No such file or directory because the missing
file is the interpreter, not your executable. readelf -l | grep INTERP settles
it.)
Step 2: ld.so, the Program That Loads Programs
The dynamic linker reads your binary’s DT_NEEDED entries (ldd ./a.out shows
them), walks the dependency graph, and maps each shared library — searching
RPATH, LD_LIBRARY_PATH, the /etc/ld.so.cache, then default paths. Every
library lands at a randomized address (ASLR), which creates the central problem
of dynamic linking: your code calls printf, but nobody knew where printf
would be until now.
The solution is indirection through two tables:
- GOT (global offset table): slots that will hold real addresses of external symbols and data.
- PLT (procedure linkage table): tiny stubs your calls actually target; each jumps through its GOT slot.
Function resolution is lazy by default: the first call to printf goes
PLT → GOT → back into ld.so’s resolver, which does the symbol lookup, writes
printf’s real address into the GOT slot, and jumps there. Every later call is
PLT → GOT → printf, one extra jump. LD_DEBUG=bindings ./a.out shows every
resolution as it happens; LD_BIND_NOW=1 forces them all upfront (what hardened
“full RELRO” builds do, so the GOT can be remapped read-only — a writable
function-pointer table being an attacker favorite).
This is also where LD_PRELOAD lives: preloaded libraries win symbol lookup, so
you can interpose malloc on any dynamically linked binary without recompiling.
Step 3: The Runtime, and Finally You
ld.so runs initializers — C++ static constructors, __attribute__((constructor))
functions — in dependency order. Then it jumps to your executable’s entry point,
which is not main but _start (from crt0), which sets up argc/argv, calls
libc’s init, and then calls main. gdb with starti stops at the true first
instruction if you want to watch the whole thing; strace shows the syscall view
— the flurry of openat/mmap before your first line of output is ld.so doing
everything described above.
Static binaries skip steps 2 entirely — no INTERP, kernel jumps straight to
_start — which is why they start faster, survive libc mismatches, and are the
container world’s favorite trick.
Takeaways
- execve maps program-header LOAD segments lazily; nothing is “read into memory” upfront.
- Dynamically linked binaries start in ld.so, not your code; “No such file or directory” on an existing binary means the interpreter is missing.
- Calls to shared-library functions go through PLT/GOT stubs, resolved lazily on first call; full RELRO trades startup time for a read-only GOT.
mainis third-hand: kernel → ld.so →_start→ libc init →main.- The debugging toolkit:
readelf -l,ldd,LD_DEBUG=bindings,strace,gdb starti.