How the Linux loader starts your program
From execve to main, a guided tour of what the kernel and the dynamic linker do before your code ever runs. Trace it yourself with strace and ltrace.
Take the simplest C program you can think of.
#include <stdio.h>
int main(void) { puts("hello"); return 0;}Compile with gcc hello.c -o hello, run ./hello, and a string lands on your terminal. By the time main runs, several thousand instructions have already executed inside your process. Most of them belong to code you never linked against and probably never read. This post walks the chain from the shell's execve call to the first instruction of main, with enough detail that you can follow along on your own machine.
The shell does almost nothing
When you press enter, your shell calls fork() and the child immediately invokes execve("./hello", argv, envp). That's the entire shell-side story. After execve, the child's existing address space is gone. The kernel takes over.
strace -f -e execve ./helloYou'll see exactly one execve line, and the rest of the output comes from a process that, from the shell's point of view, no longer has anything to do with bash.
Inside the kernel
execve is one of the few syscalls that doesn't return on success. The kernel reads the first few bytes of the file (the magic number \x7fELF), figures out what kind of binary it is, and dispatches to a binfmt handler. For ELF, that's binfmt_elf.c in the kernel source.
The handler does a lot, but the parts you care about are:
- It parses the ELF header and the program headers (the load view of the file, separate from the section headers).
- For each
PT_LOADsegment, it sets up anmmapof the file into a fresh address space at the right virtual address, with the right permissions. - If the binary is dynamically linked (almost everything is), the program header includes a
PT_INTERPentry naming an interpreter, usually/lib64/ld-linux-x86-64.so.2. The kernel loads that as well, at some address chosen by ASLR. - It builds the auxiliary vector on the new stack: a list of key/value pairs telling userspace things only the kernel knows. Page size, the address of the program's entry point, the address where the interpreter was mapped, a pointer to a fresh 16-byte random seed, and so on.
- It zeroes the registers (mostly) and jumps to the interpreter's entry point with the stack laid out:
argc,argv[], a NUL,envp[], a NUL, then the auxv.
The kernel never directly transfers control to your main. It transfers control to ld-linux.so, the dynamic linker, which is a separate ELF program living in /lib.
The dynamic linker takes over
ld.so is itself an ELF binary, but it has the unusual property that it cannot rely on a loader to set it up — it is the loader. Its early prologue does its own self-relocation using the auxv to find where the kernel mapped it. From there it has hands and feet.
Its job is to make your binary runnable. That means:
- Read the
PT_DYNAMICsegment of your executable to find the dynamic table — the list of needed shared libraries, the address of the relocation table, the symbol table, and so on. - For each
DT_NEEDEDentry (libc.so.6, perhapslibm.so.6),mmapthe library into the address space, recursively resolving its own dependencies. - Apply relocations. Each shared object has a list of "fix this address up once you know where everything ended up." The runtime linker walks these lists and patches them in place.
- Run any constructors — anything in the
.init_arrayof any loaded object, including the C++ static initialisers of objects defined at file scope. - Hand control to the
e_entryof the original executable. That's_start.
Lazy binding is worth a brief detour. By default, function calls into shared libraries don't get resolved at startup. The first call jumps through the PLT (procedure linkage table) into a stub that asks the dynamic linker to resolve the symbol, patches the GOT (global offset table) with the answer, and forwards the call. Subsequent calls hit the GOT directly. You can disable this with LD_BIND_NOW=1, which is sometimes wanted for security: lazy binding requires a writable GOT, and a writable GOT is something attackers like.
The handoff to libc
_start is a tiny piece of assembly that the C runtime ships with every binary. On x86-64 with glibc it does roughly this: clear the frame pointer (no caller above us), pop the kernel-supplied arguments off the stack into registers in the right order for the System V ABI, and call __libc_start_main.
__libc_start_main is the real driver. It's where the C runtime gets initialised. It sets up thread-local storage, registers the destructors that should run on exit, calls the _init function and walks .init_array for the executable itself (the dynamic linker only handled shared objects), then calls main with argc, argv, and envp.
When main returns, control comes back to __libc_start_main, which calls exit(status), which runs atexit handlers and .fini_array, flushes stdio buffers, and finally invokes the _exit syscall. The kernel reaps the process.
The whole chain
Watching it happen
Two tracers will show you the chain in action. strace shows the syscalls — the execve, the mmap calls for ld-linux.so and libc.so, the read of /etc/ld.so.cache, and so on.
strace ./helloYou'll notice that even a trivial hello world makes dozens of syscalls before write ever appears. That noise is the loader.
ltrace traces library calls, so you can watch puts get resolved through the PLT on its first invocation.
ltrace ./helloFor the really juicy bits — the relocations, the constructor walks — use LD_DEBUG=all ./hello and pipe through less. The output is verbose to the point of being difficult, but it's the closest thing to a printout of the loader's internal state.
Where this fits
The loader chain is the meat of Module 103: Linking, Loading & Binary Formats, which builds the picture from the ELF header up: section headers, program headers, the dynamic table, relocations, the PLT and GOT, lazy binding, and the runtime linker's data structures. Once you've sat with this material, LD_DEBUG=all becomes readable, and a stripped binary stops looking like noise.