The Loader
From `exec` to the first instruction of `main`: the kernel maps the image, hands control to the dynamic loader, which maps libraries, applies relocations and runs initializers. `main` is not the first code to run, and a program can fail before it.
What actually runs between exec and the first line of main?
The program as a mapping plan rather than a file: a set of segments, each with a file offset, a virtual address, a size and a set of permissions, plus an interpreter to run first and an entry point to jump to last. That representation exists because the OS does not read programs — it maps them, and a process is an address space with the right things visible at the right addresses.
The loader may map a segment only at an address and alignment the program headers permit and the address space allows, and it may grant execute permission only to segments the image declares executable — a page may not be both writable and executable where the platform forbids it. It may bind a symbol only to a definition present in the current search scope, and it must run each object's initializers before any of that object's code is called from elsewhere. What it is not entitled to assume is anything about the correctness of what it maps: it verifies formats and permissions, not semantics.
Key points
- The kernel maps the image and then transfers control to the interpreter named in the executable, not to the program.
- The dynamic loader maps libraries, builds the search scope, applies relocations and runs initializers before the program's entry point is reached.
mainis not the entry point:_startfrom the C runtime is, and it callsmainafter setup and passes its return value toexit.- Every shared object's initializers — C++ global constructors included — run before
main, in an order that is only partly specified. - Loading is
mmap, so pages arrive on demand and read-only code pages are physically shared between processes. - Each start-up stage has a characteristic failure, and
No such file or directoryon an existing file means the *interpreter* was not found.
The sequence, in order
The path from execve to main is a specific sequence with several places to fail, and knowing it turns a class of opaque startup failures into ordinary diagnosis.
The kernel reads the executable's headers, checks the format, and creates a fresh address space. It maps the loadable segments at their specified addresses with their specified permissions — typically a read-execute segment for code, a read-only one for constants, and a read-write one for data, with .bss mapped as zero-filled anonymous memory beyond the file-backed part. It sets up the stack with the arguments, the environment and the auxiliary vector.
Then, crucially, it does not jump to the program. If the image names an interpreter — which every dynamically linked executable does, in its PT_INTERP header — the kernel maps that too and transfers control *there*. The dynamic loader is itself a program, statically linked and self-relocating, and it now runs in the address space of the process it is preparing.
The loader reads the needed libraries, finds and maps each one recursively, builds the search scope, applies relocations, resolves data symbols, and runs the initializers of each object in dependency order — deepest first, so a library's dependencies are initialised before it is. Only then does it jump to the executable's entry point, which is _start in the C runtime, which sets up a little more state and calls main.
execve to maintypical- execveload timeA path and an argument vector; the old address space is about to be discarded.The decision to replace this process's image entirely.The previous program — file descriptors survive, memory does not.
- Kernel maps the imageload timeLoadable segments mapped at their addresses with their permissions;
.bssas zero-filled anonymous memory.An address space containing the program. - Stack setupload timeArguments, environment and the auxiliary vector laid out on the new stack.Everything the program and the loader need to know about how they were started.
- Interpreter entryload timeControl in the dynamic loader, not the program.A program whose job is to finish building this process.
- Library loadingload timeThe dependency graph mapped, breadth-first, with a search scope built.Every library the program needs, and a resolution order.
- Relocation and bindingload timeGOT entries filled with real addresses; data symbols resolved.Working cross-module references. Function symbols may be deferred to first call.
- Initializersload timeEach object's
DT_INIT_ARRAYrun in dependency order.Constructed globals and registered state — and the first opportunity for the program to crash. - _startrun timeThe C runtime entry point.Stack alignment, argument marshalling, TLS setup, and the call to
main. - mainrun timeYour code, finally.Nothing structural. It is an ordinary function called by the runtime.
Read it asCount the rows before main: eight. Every one of them can fail, and several fail in ways that produce no message pointing at your code — a missing library, an unresolvable symbol, a constructor that throws, a mapping that will not fit. "The program does nothing and exits" is usually one of these, and knowing the sequence tells you which tool to reach for at each step.
`main` is not the first thing to run
_start from the C runtime, and that it calls main after running initializers, is typical of C and C++ toolchains on Unix-likes. Go supplies its own runtime entry and does substantial work before any user code; Rust's runtime setup is thin but non-empty; a freestanding build with -nostartfiles has whatever entry you define and none of this happens. The ordering guarantees within one translation unit are specified; across translation units and across objects they are not.The entry point recorded in a typical C or C++ executable is _start, supplied by the C runtime startup object (crt1.o and friends), and it is doing real work before main. It aligns the stack as the ABI requires, extracts argc, argv and envp from where the kernel left them, initialises thread-local storage, calls the C library's initialisation, runs any remaining constructors, registers the exit handling, and then calls main — and calls exit with whatever main returns.
Before even that, every shared object's initializers have run. In C++ this includes the constructors of every global and static object with dynamic initialization, across the executable and every library. In C it includes anything marked with the constructor attribute. These run in an order determined by dependency relationships between objects and, within one object, by link order — which is under-specified enough that depending on it is a well-known way to produce a bug that appears when someone adds an unrelated library.
This is why a program can fail before main in ways that look impossible. A global object's constructor throws and the process terminates with no stack trace anyone recognises. Two globals in different translation units depend on each other and one is used before construction — the static initialization order fiasco, whose standard remedy is the function-local static, constructed on first use rather than at load. And on some platforms an initializer that calls into a library whose own initializers have not run yet will do something arbitrary.
1/* 1. A shared library's initializer, run by the dynamic loader2 after the library is mapped and relocated. */3__attribute__((constructor))4static void lib_init(void) { /* runs before anything calls into us */ }5 6/* 2. A C++ global with dynamic initialization: its constructor is7 registered in .init_array and run by the same mechanism. */8static Registry g_registry; /* constructed before main */9 10/* 3. _start, from crt1.o, which the entry point actually points at:11 aligns the stack, sets up TLS, runs .init_array, calls main,12 and passes main's return value to exit(). */13 14int main(void) { return 0; } /* an ordinary function, called by _start */The second is where the trouble usually is. g_registry's constructor runs at an unspecified point relative to every other global in the program, so if it uses another global it may be reading an object that has not been constructed. The standard fix is to make it a function-local static — Registry& registry() { static Registry r; return r; } — which defers construction to first use and makes the ordering a data dependency rather than a hope.
What the loader is actually doing to memory
Loading is mapping, not reading. The loader calls mmap on the executable file and each library, so the pages are file-backed and demand-paged: nothing is read from disk until an instruction touches a page and takes a fault. That is why a large binary starts quickly and why start-up cost is dominated by page faults on the pages actually touched rather than by file size.
Because the mappings are file-backed and read-only for code, the same physical pages back every process running the same binary or library. This is the sharing that [[dynamic-linking]] is bought for, and it is a property of the mapping rather than of anything the loader does explicitly. Writable data is mapped copy-on-write: shared until written, private after.
.bss has no file content, so it is mapped as anonymous zero-filled memory. And relocations complicate the picture slightly: applying a relocation writes to a page, so any page containing relocations becomes private to the process. This is why the number of load-time relocations is a real memory cost as well as a time cost, and why compressed relocation formats and prelinking were both attempts to reduce it.
- Segments are
mmaped, not read: pages arrive on demand via page faults, so start-up cost tracks pages touched rather than file size. - Read-only, file-backed code pages are shared physically between every process using the same binary or library.
- Writable data is copy-on-write: shared until first write, private thereafter.
.bssis anonymous zero-filled memory, contributing address space and no file content.- Applying a relocation dirties a page, so heavily-relocated data becomes private — one reason relocation count matters for memory as well as time.
When start-up fails
Each stage of the sequence has a characteristic failure with a characteristic message, and the messages are unusually informative once you know which stage produced them.
The kernel refuses the image: exec format error, meaning the architecture or the format is wrong — a cross-compiled binary on the wrong machine, or a script without a valid shebang. The interpreter is missing: No such file or directory on a file that visibly exists, which is the kernel reporting that the *interpreter* named in PT_INTERP was not found — the single most confusing message in this whole area, and it is why a glibc binary run on a musl-only system says the file does not exist. A library is missing: cannot open shared object file. A symbol is missing or version-mismatched: undefined symbol or version GLIBC_2.34 not found. And finally an initializer crashes, which produces an ordinary fault with a stack that bottoms out in the loader.
The tooling maps onto the stages directly. file and readelf -h answer the format question. readelf -l | grep interpreter shows which interpreter is demanded. ldd and LD_DEBUG=libs cover library finding. LD_DEBUG=bindings covers symbol resolution. And a debugger with a breakpoint before main — gdb, break main, then bt from a crash — covers initializers.
| Stage | Typical message | What to run |
|---|---|---|
| Kernel format checkimplementation | exec format error | file app, readelf -h app — wrong architecture or format |
| Interpreter lookupimplementation | No such file or directory on a file that exists | readelf -l app | grep interpreter — the named loader is absent |
| Library searchimplementation | libfoo.so.1: cannot open shared object file | ldd app, LD_DEBUG=libs ./app |
| Symbol bindingimplementation | undefined symbol: foo, version GLIBC_2.34 not found | LD_DEBUG=bindings, objdump -T, readelf -V |
| Initializers | A crash with no output, before anything you wrote | gdb, break before main, backtrace from the fault |
| Mapping | cannot map, or an out-of-memory failure at start | readelf -l for segment sizes; check address-space limits |
How it works
The steps, in the order the compiler takes them.
execvediscards the old address space and the kernel validates the new image's headers and architecture.- The kernel maps each loadable segment at its virtual address with its permissions, mapping
.bssas anonymous zero-filled memory. - It builds the initial stack containing
argc,argv, the environment and the auxiliary vector. - If the image names an interpreter, the kernel maps it too and transfers control there; the dynamic loader relocates itself first.
- The loader reads
DT_NEEDED, finds and maps each library recursively, and builds the ordered search scope. - It applies relative relocations, resolves data symbols eagerly and function symbols eagerly or lazily depending on binding mode.
- It runs each object's
DT_INITandDT_INIT_ARRAYin dependency order, then jumps to the executable's entry point. _startaligns the stack, initialises thread-local storage and the C library, runs the executable's own initializers, callsmain, and callsexitwith its result.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A binary fails with
No such file or directoryeven though it is plainly present, because the dynamic loader named in its headers is not on the system — the usual symptom of a glibc binary on a musl system. - A program exits silently with a non-zero status and no output, because a global constructor threw or aborted before
mainwas reached. - Start-up is slow and profiling shows the time in the dynamic loader, applying tens of thousands of relocations across a deep dependency graph.
- A program works when run directly and fails under a service manager, because the environment differs and a library resolves elsewhere.
- Two globals in different translation units depend on each other and one is used before construction, producing behavior that changes when link order changes.
- A binary built for another architecture produces
exec format error, which names the stage precisely and is nonetheless routinely misread as a permissions problem. - A statically linked binary starts and immediately misbehaves on a different machine because it still loads NSS modules for name resolution — the partial-static caveat from
[[static-linking]].
When it helps
- Diagnosing any failure that happens before the program prints anything, which is otherwise one of the least tractable classes of bug.
- Understanding start-up latency, which for short-lived processes and serverless workloads is a real fraction of total runtime.
- Reasoning about memory: which pages are shared, which are private, and why the resident size of a process is not the size of its binaries.
When it hurts
- Doing meaningful work in initializers. Anything that can fail, block, or depend on another object's state is in the wrong place, because the ordering guarantees do not exist.
- Assuming the sequence is the same everywhere. Go, Rust and any freestanding build differ substantially, and Windows' loader has its own rules including a lock that makes work in
DllMainhazardous.
What it costs
Every one of these is paid by something.
- Demand-paged mapping buys fast start-up regardless of binary size and shared physical pages across processes, and costs page faults distributed through early execution rather than one predictable read.
- Running initializers before
mainbuys the ability for libraries to self-register and set up state without the program knowing they exist, and costs an under-specified ordering plus failures that occur before any of your code can report them. - A separate dynamic loader buys the entire dynamic-linking model — sharing, upgrades, plugins — and costs a program that must run before your program on every single start.
- Lazy binding buys start-up proportional to what is called and costs first-call latency at unpredictable moments plus a writable GOT for the process lifetime.
What else you could do
What a different compiler or language does instead, and when that is better.
- A static binary with no interpreter, which the kernel maps and enters directly. Fewest moving parts, fastest start-up, and everything
[[static-linking]]gives up. - Explicit initialization instead of constructors: an
init()the program calls in a known order. Verbose, and it eliminates the entire class of ordering bugs. - Prelinking or a snapshot of a warmed address space, which trades away address-space randomisation or portability for start-up time. CRIU-style checkpoint-restore and language runtime snapshots are the modern versions.
- Runtime-managed loading, as the JVM and .NET do: classes are found, verified and initialised on first use by the runtime, with initialization order defined by the language rather than by link structure.
See it for yourself
The flag, dump or tool that shows you this directly.
- The interpreter and segments:
readelf -lW appshows the program headers, the interpreter path and the section-to-segment mapping. - The whole start-up trace:
LD_DEBUG=all ./app 2>&1 | lessis verbose and complete;LD_DEBUG=libs,bindingsis the usable subset. - Time spent:
LD_DEBUG=statistics ./appreports relocation processing time;strace -e trace=mmap,openat ./appshows every mapping and file the loader touched. - Initializers:
objdump -s -j .init_array applists the registered constructors; ingdb,break mainthenbtfrom an earlier crash shows what ran first. - On macOS:
DYLD_PRINT_LIBRARIES=1,DYLD_PRINT_INITIALIZERS=1andDYLD_PRINT_STATISTICS=1. On Windows: the loader snaps visible in Process Monitor, andgflagsfor loader snap tracing.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The program starts at
main." It starts at_start, after every loaded object's initializers have already run.mainis an ordinary function called by the runtime. - "
No such file or directorymeans the binary is missing." It usually means the *interpreter* named inside the binary is missing. The binary is right there. - "Loading reads the program into memory." It maps it. Pages arrive on demand, which is why start-up does not scale with binary size.
- "Static initializers run in the order I wrote them." Within one translation unit, yes. Across translation units and across shared objects, the order is not specified and changes with link order.
Misconceptions
The claim, and what is actually true.
main.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
When you run a program, the kernel sets up an address space and maps the file into it — then hands control not to your program but to the dynamic loader, a separate program whose job is to find the libraries, connect everything up and run any setup code. Only after all of that does your main get called, which means a program can fail before a single line you wrote has executed.
practical
For any failure before your first line of output, walk the stages. file for the format, readelf -l | grep interpreter for the loader, ldd and LD_DEBUG=libs for the libraries, LD_DEBUG=bindings for the symbols, and a debugger breaking before main for constructors. And keep initializers trivial: registering something in a list is fine, opening a file or touching another object's globals is a bug waiting for someone to change the link order.
advanced
The structurally interesting thing about the loader is that it is a program that runs in the address space of a process it is constructing, which forces it to be self-contained and self-relocating — it cannot call anything it has not yet loaded, including itself. That constraint is why the dynamic loader is statically linked, why it performs its own bootstrap relocation before doing anything else, and why bugs in it are so severe: there is nothing underneath to catch them. It also explains the initialization-order problem in a way the usual C++ framing does not. The loader has a partial order — the dependency graph — and initialization requires a total order, so it linearises the partial order arbitrarily wherever the graph does not constrain it. Every static-initialization-order bug is a program depending on part of that arbitrary linearisation, and the reliable fix is always the same: convert the assumed order into an actual dependency, which is exactly what construction-on-first-use does.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
execve → kernel map → interpreter → loader → initializers → _start → main sequence is typical of dynamically linked C and C++ programs on Linux. Go supplies its own runtime entry and does substantial setup before user code; a freestanding -nostartfiles build skips all of it; a statically linked binary has no interpreter and is entered directly by the kernel.LD_DEBUG variable, the message wordings and the auxiliary-vector layout are glibc and Linux specifics. macOS uses dyld with DYLD_PRINT_* variables and different messages; Windows has a loader with a serialising lock that makes almost any work in DllMain hazardous, a constraint with no Unix equivalent.If you were asked this in an interview
- Walk through everything that happens between
execveand the first instruction ofmain. - A binary exists and running it says
No such file or directory. What is actually missing? - Why is depending on static initialization order across translation units a bug, and what is the standard fix?
Connections
- Programming Languages & Runtime Internals — Runtime bootstrap: what a managed runtime does before user code, and class initialization on first useThe JVM, CPython and the Go runtime all have their own start-up sequence layered on top of this one, with their own initialization ordering rules that are specified rather than emergent. Comparing the two is the clearest way to see what the native model leaves undefined.