Linkingtypical

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.

The question

What actually runs between exec and the first line of main?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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.
  • main is not the entry point: _start from the C runtime is, and it calls main after setup and passes its return value to exit.
  • 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 directory on 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.

From execve to maintypical
  1. execveload time
    A 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.
  2. Kernel maps the imageload time
    Loadable segments mapped at their addresses with their permissions; .bss as zero-filled anonymous memory.
    An address space containing the program.
  3. Stack setupload time
    Arguments, 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.
  4. Interpreter entryload time
    Control in the dynamic loader, not the program.
    A program whose job is to finish building this process.
  5. Library loadingload time
    The dependency graph mapped, breadth-first, with a search scope built.
    Every library the program needs, and a resolution order.
  6. Relocation and bindingload time
    GOT entries filled with real addresses; data symbols resolved.
    Working cross-module references. Function symbols may be deferred to first call.
  7. Initializersload time
    Each object's DT_INIT_ARRAY run in dependency order.
    Constructed globals and registered state — and the first opportunity for the program to crash.
  8. _startrun time
    The C runtime entry point.
    Stack alignment, argument marshalling, TLS setup, and the call to main.
  9. mainrun time
    Your 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

typicalThat the entry point is _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.

Three things that run before `main`, in order
1/* 1. A shared library's initializer, run by the dynamic loader
2 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 is
7 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.
  • .bss is 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 maingdb, break main, then bt from a crash — covers initializers.

Start-up failures by stageimplementation
StageTypical messageWhat to run
Kernel format checkimplementationexec format errorfile app, readelf -h app — wrong architecture or format
Interpreter lookupimplementationNo such file or directory on a file that existsreadelf -l app | grep interpreter — the named loader is absent
Library searchimplementationlibfoo.so.1: cannot open shared object fileldd app, LD_DEBUG=libs ./app
Symbol bindingimplementationundefined symbol: foo, version GLIBC_2.34 not foundLD_DEBUG=bindings, objdump -T, readelf -V
InitializersA crash with no output, before anything you wrotegdb, break before main, backtrace from the fault
Mappingcannot map, or an out-of-memory failure at startreadelf -l for segment sizes; check address-space limits

How it works

The steps, in the order the compiler takes them.

  • execve discards 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 .bss as 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_INIT and DT_INIT_ARRAY in dependency order, then jumps to the executable's entry point.
  • _start aligns the stack, initialises thread-local storage and the C library, runs the executable's own initializers, calls main, and calls exit with 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 directory even 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 main was 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 DllMain hazardous.

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 main buys 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 app shows the program headers, the interpreter path and the section-to-segment mapping.
  • The whole start-up trace: LD_DEBUG=all ./app 2>&1 | less is verbose and complete; LD_DEBUG=libs,bindings is the usable subset.
  • Time spent: LD_DEBUG=statistics ./app reports relocation processing time; strace -e trace=mmap,openat ./app shows every mapping and file the loader touched.
  • Initializers: objdump -s -j .init_array app lists the registered constructors; in gdb, break main then bt from an earlier crash shows what ran first.
  • On macOS: DYLD_PRINT_LIBRARIES=1, DYLD_PRINT_INITIALIZERS=1 and DYLD_PRINT_STATISTICS=1. On Windows: the loader snaps visible in Process Monitor, and gflags for 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. main is an ordinary function called by the runtime.
  • "No such file or directory means 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.

The operating system runs my program.
It maps your program and then runs the dynamic loader, which finishes assembling the process and eventually calls your entry point.
Nothing happens before main.
Library initializers, C++ global constructors, TLS setup and C library initialization all run first, and any of them can fail before your code exists.
A larger binary takes longer to start.
Segments are demand-paged, so start-up tracks the pages actually touched. Dependency count and relocation count matter far more than file size.

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.

typicalThe execve → kernel map → interpreter → loader → initializers → _startmain 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.
implementationThe 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.
specC++ guarantees that objects with static storage duration in one translation unit are initialised in declaration order, and guarantees nothing about the order across translation units. That is why the function-local static idiom — construction on first use — is the standard remedy rather than a stylistic preference.

If you were asked this in an interview

  • Walk through everything that happens between execve and the first instruction of main.
  • 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

Performancecpu-profiling
Domains that do not exist yet
  • Programming Languages & Runtime Internals — Runtime bootstrap: what a managed runtime does before user code, and class initialization on first use
    The 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.