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

text
$ 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.
  • main is third-hand: kernel → ld.so → _start → libc init → main.
  • The debugging toolkit: readelf -l, ldd, LD_DEBUG=bindings, strace, gdb starti.