# From ./a.out to main(): What exec Actually Does

> Between pressing enter and your first line of code, the kernel parses an ELF file, maps segments, loads an interpreter you didn't know existed, and that interpreter relocates half your program. A tour of the machinery under ./a.out.

URL: https://harshith.in/blog/what-happens-when-you-run-a-program/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: linux, systems, fundamentals

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 `mmap`s 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`.
