An executable file is not a memory image that the operating system copies wholesale into RAM. It is a structured plan. Some ranges should become executable code, some should become read-only constants, some should become writable data, and some occupy memory without taking space in the file. A dynamically linked program also names an interpreter and shared libraries whose final virtual addresses are unknown until launch.
Loading turns that plan into a process. The kernel validates the format, creates virtual-memory mappings and an initial stack, and transfers control either to the program or to a dynamic loader. The loader maps dependencies, resolves addresses, applies relocations, establishes protection boundaries, runs initialization, and finally calls the program’s entry path. Demand paging means many mapped bytes are not read from storage until execution touches them.
The useful thesis is: process startup is a contract among the executable format, virtual-memory manager, dynamic loader, and application binary interface. Understanding that contract makes missing-library errors, symbol mismatches, startup cost, address randomization, and hardening settings explainable rather than magical. This article uses ELF terminology for a concrete path while noting where PE and Mach-O express comparable ideas; it begins after compilation and does not cover language parsing or compiler front ends.
An Executable Format Describes a Load Plan
Linux and many Unix-like systems commonly use ELF, the Executable and Linkable Format. Windows uses PE/COFF, and Apple platforms use Mach-O. Their exact records differ, but all must identify an architecture, entry information, mapped regions, permissions, and metadata needed by a loader. Loading a file built for the wrong instruction set or application binary interface fails before its main function can run.
An ELF file starts with a header containing a magic value, word size, byte order, object type, target machine, entry address, and offsets to other tables. Two table families are easy to confuse. Sections organize material for linking and analysis: code, constants, symbol tables, relocation records, and debug data. Program headers describe segments that matter when creating a process. The kernel primarily follows program headers. A stripped executable can omit much section-oriented metadata and still run because its loadable segments remain intact.
Each PT_LOAD program header gives a file offset, virtual address relationship, bytes present in the file, bytes required in memory, alignment, and read/write/execute flags. A typical image has an executable mapping for code, a read-only mapping for constants, and a writable mapping for initialized and zero-initialized data. If the in-memory size exceeds the file size, the remainder is filled with zeroes. This is how a large uninitialized array can occupy virtual memory without adding equivalent bytes to the executable.
Other headers carry supporting contracts. PT_INTERP names the dynamic loader for a dynamically linked executable. PT_DYNAMIC locates tags describing required libraries, relocation tables, string and symbol tables, and initialization functions. PT_TLS describes the initial template for thread-local storage. A GNU stack marker can request a non-executable stack, while a RELRO region identifies data that should become read-only after relocation.
The executable is therefore more like a manifest of ranges and fixups than a serialized process. It does not contain live stacks, heap objects, open file descriptors, scheduler state, or the final addresses of all shared libraries.
The Kernel Creates the Initial Address Space
On an exec operation, the calling process asks the kernel to replace its current program image. The process identity may persist in selected ways, but its ordinary user-space mappings and threads are replaced according to operating-system rules. The kernel opens the executable, checks permissions and format, validates headers and ranges, and rejects malformed or incompatible input.
For each loadable segment, the kernel creates virtual mappings backed by the executable file, with page-aligned boundaries and requested protections. Because memory protection works at page granularity, file and virtual offsets must obey alignment constraints. Bytes at a segment boundary may require careful sharing of a page, but tools and linkers arrange layouts so code is not accidentally writable merely because adjacent data is.
Mapping is not the same as eagerly reading. Page-table entries can initially describe file-backed regions without resident physical pages. The first instruction fetch or data access faults in the required page, after which the kernel obtains it from the page cache or storage. Clean read-only code pages can be shared physically among processes loading the same file. Writable private mappings use copy-on-write: processes begin from the same backing content but receive private pages when modifications occur.
The kernel also constructs a user stack containing argument strings, environment strings, and metadata in an ABI-defined arrangement. An auxiliary vector supplies values such as page size, program-header location, security state, and a source of random bytes used by runtime components. Exact contents are platform-specific, so application code should use supported runtime interfaces rather than parse an assumed stack layout.
Address-space layout randomization changes mapping positions to make stable exploit addresses harder to predict. A position-independent executable can be placed at a randomized base. Shared libraries, stack, and other regions can be randomized independently. A traditional fixed-address executable limits that flexibility even if its libraries move.
If no interpreter is required, the kernel can transfer control to the executable entry address. For a dynamic ELF executable, it maps the interpreter named by PT_INTERP and transfers control to the interpreter’s entry instead. The application entry has not run yet; the dynamic loader must finish the image.
The Dynamic Loader Completes the Dependency Graph
The dynamic loader is itself executable code mapped into the new process. It first discovers its own location and the main executable’s metadata using the initial stack and auxiliary vector. It must bootstrap carefully because the machinery normally used to call shared-library functions is not fully resolved yet.
The executable’s dynamic tags contain DT_NEEDED entries naming direct dependencies. The loader locates each library according to a platform-defined search policy, maps its loadable segments, reads its own dependencies, and builds a graph of loaded objects. Search can involve embedded run paths, configured caches, trusted system directories, and environment overrides. The exact precedence varies across systems and secure-execution modes; relying on an assumed current working directory is both fragile and dangerous.
A library name is usually a logical ABI name, not necessarily the complete versioned filename on disk. A package manager can point that name to a compatible implementation while retaining side-by-side major versions. Compatibility is a contract: replacing a library with one that has the same filename but removes a required symbol or changes data layout still breaks consumers.
Libraries are mapped into one process address space. They are not remote services and do not get isolation from the caller. A library function runs with the process’s credentials and can access its memory. Read-only code pages may be shared across processes, but writable globals are normally private to each process. Thread-local storage gives each thread a distinct instance of declared data and requires the loader and runtime to assign offsets or access machinery.
Dependencies can contain cycles, and constructors can observe partially initialized global state. The loader can map a cyclic graph, but application initialization order across independent libraries is a brittle foundation. Keep cross-library constructors minimal and move fallible work into explicit application-controlled initialization where errors and dependencies can be handled.
Relocations Turn Symbolic References into Addresses
Object code often contains references whose final values were unknown when the file was produced. A relocation says, in effect, “compute a value from this symbol, addend, load address, and relocation rule, then place it here.” Static linking resolves what it can while producing the executable. Dynamic relocation handles references that depend on where the executable and shared objects land at runtime.
The cheapest common relocation is relative to an object’s load base. If a data slot should point 4,096 bytes into a library, the loader can add the randomized base without performing a name lookup. Toolchains group and encode relative relocations efficiently because large applications can contain many of them.
External data and function references require symbol resolution. Position-independent code avoids embedding absolute addresses directly in instruction streams. It commonly accesses data through a Global Offset Table, whose entries the loader fills with runtime addresses. Function calls can use a Procedure Linkage Table or an equivalent indirection. These mechanisms keep code pages shareable and avoid rewriting executable pages for every process.
Function binding may be eager or lazy. Eager binding resolves applicable calls during startup, making missing symbols fail before application work and allowing more relocation data to become read-only. Lazy binding initially points a call stub toward a resolver; the first call finds the target and patches a table entry. Lazy binding can reduce startup work for never-called functions, but it moves latency and failure into first use and expands the period during which writable linkage state exists. Modern hardening policies often prefer immediate binding for predictable behavior and stronger protection.
Relocation work has security implications. Text relocations require modifying executable pages and undermine write-xor-execute policy and page sharing. Position-independent code avoids most such cases. After the loader completes selected relocations, full RELRO can mark linkage tables read-only, preventing later overwrites. These protections compose with ASLR; random addresses help less if an attacker can simply replace a function pointer in writable loader data.
Symbol Resolution Is a Scope and Compatibility Rule
A dynamic symbol record includes a name, value, size, kind, visibility, and binding strength. The loader searches an ordered scope of loaded objects to choose a definition for an unresolved reference. That order is observable behavior. On ELF systems, a definition in the main executable or an earlier global object can sometimes interpose on a later library’s definition, depending on visibility and linking options.
Interposition enables diagnostics and compatibility techniques, such as preloading a wrapper around allocation calls. It also limits optimization and can produce surprising behavior when two libraries export generic names. Hidden visibility and version scripts reduce the public surface: internal functions need not enter global lookup, and callers cannot accidentally depend on them. A library should export an intentional ABI rather than every externally linked implementation detail.
Weak symbols provide fallback definitions that a stronger definition can replace. They are useful for optional hooks but are not a general plugin system: lookup order, static linking, and platform differences can alter results. Explicit registration and versioned interfaces are easier to reason about when extension is a product requirement.
Symbol versioning can let one shared object provide multiple historical implementations under the same base name. A consumer records the required version, and the loader selects a matching definition. This supports gradual ABI evolution, but only within what the platform and library maintainers promise. Changing a C structure’s size, calling convention, exception boundary, allocator ownership, or semantic precondition can break clients even if every symbol name still exists.
Not every function name appears in the dynamic symbol table. Local or hidden symbols, stripped debug names, inlined code, and statically linked functions follow different paths. Conversely, finding a name with a symbol-inspection tool does not prove it will be chosen in a particular process. Resolution depends on architecture, object version, visibility, dependency graph, and runtime scope.
Worked Example: Resolve One Shared Function
Suppose app has a direct dependency on libgreet.so.1 and calls an exported function greet. The executable is position independent, and the call goes through an indirection slot. At launch, the kernel maps app and the dynamic loader at randomized bases, builds the initial stack, and enters the loader.
The loader reads app’s dynamic entries and finds libgreet.so.1. It locates a compatible file, maps its code read-execute and its data read-write, then processes both objects’ relocations. Conceptually, the important state looks like this:
| Object | Runtime base | Relevant metadata |
|---|---|---|
app |
Randomized base A | Undefined symbol greet; function slot R |
libgreet.so.1 |
Randomized base B | Defined symbol greet at offset G |
Resolution searches app’s symbol scope and chooses the library definition. The runtime address is approximately B + G, adjusted according to the architecture’s relocation rule. With eager binding, the loader writes that address into slot R before startup continues. With lazy binding, R initially reaches a resolver that performs this search on the first call, then replaces R with the result.
Before handing control onward, the loader applies remaining relative and data relocations, prepares thread-local storage, tightens RELRO protections, and runs required initializers in defined dependency order. It eventually transfers to app’s entry symbol, which is generally runtime startup code rather than main. That startup code establishes language-runtime state, arranges cleanup, and invokes main with the prepared arguments and environment.
Now consider three failures. If the search policy cannot locate libgreet.so.1, loading stops with a missing-object error. If the file is found but exports only an incompatible greet@GREET_2 while the executable requests greet@GREET_1, symbol resolution fails. If an environment preload supplies an earlier global greet, the call may reach the interposed definition instead, unless secure mode or symbol visibility prevents it. These are distinct stages and require distinct repairs.
Startup Tradeoffs, Failure Modes, and Security
Dynamic linking reduces duplicated library storage and allows shared clean pages, while enabling a library implementation to be updated independently within its ABI. Its costs include dependency management, relocation and lookup work, a larger runtime compatibility surface, and the possibility that an environmental change breaks a previously working binary. Static linking simplifies dependency discovery and can improve deployment reproducibility, but increases executable size, may duplicate code in memory, and requires relinking to incorporate library fixes. Some system facilities and licenses impose additional constraints, so the choice is not purely technical.
Startup cost is not just file size. It includes opening dependency metadata, mapping objects, faulting touched pages, processing relocations, resolving symbols, initializing thread-local storage, and running constructors. A dependency that is mapped but never touched may cost little physical I/O; a constructor that scans configuration or opens a network connection can dominate startup despite living in a small library. Measure stage behavior rather than counting libraries as a proxy.
Unsafe search paths are a common security failure. Loading a library by an untrusted relative path can execute attacker-controlled code before main. Privileged execution modes restrict environment overrides for this reason. Applications should use trusted installation locations, constrained plugin directories, authenticated update mechanisms, and explicit APIs for optional modules.
Writable-executable memory, text relocations, disabled ASLR, exported internal symbols, and partial RELRO weaken defense in depth. Conversely, hardening can expose invalid assumptions: immediate binding reveals missing optional symbols at startup, and hidden visibility breaks accidental interposition. Treat such failures as ABI design feedback rather than automatically disabling the protection.
Operational failures also arise from architecture mismatch, truncated files, unsupported relocation types, absent interpreter paths, incompatible C runtimes, and mixing libraries built around conflicting allocator or exception conventions. A container image does not eliminate these dependencies; it packages a particular userspace while still relying on a compatible host kernel and architecture.
Inspect and Verify the Actual Load Path
Verification begins with static inspection. On ELF systems, tools such as readelf can show the ELF header, program headers, dynamic tags, relocations, and dynamic symbols. objdump and nm provide complementary disassembly and symbol views. A dependency-listing tool can be useful, but do not run an untrusted executable merely to discover dependencies; some tools invoke or emulate loader behavior, and constructors or environment rules can make inspection unsafe.
Check that loadable segments have intended permissions, the stack is non-executable, the executable is position independent where required, RELRO is enabled, and no unexpected runtime search path is embedded. Enumerate DT_NEEDED entries and exported symbols as release artifacts. Compare them against an allowlist so an accidental dependency or ABI export fails review before deployment.
For runtime diagnosis, loader tracing can report searched paths, mapped objects, relocation decisions, and symbol bindings. System-call tracing shows file opens and mapping failures. Process mapping interfaces show the actual address ranges and permissions after launch. Sample startup page faults and elapsed phases when latency matters; tracing every binding changes timing and should remain a diagnostic mode.
Build integration fixtures that launch a minimal consumer against every supported library version. Assert successful startup, required symbol versions, and behavior across both eager and configured default binding. Negative fixtures should cover a missing library, wrong architecture, missing symbol, corrupt header, and untrusted search-path candidate. Run them in a constrained environment because loading is code execution.
Reproducible deployment records should include executable digest, interpreter path, dependency identities, architecture, ABI baseline, and hardening flags. When an incident reports “works on one host,” compare those facts plus environment-controlled search settings and actual process maps. The executable filename alone is insufficient evidence that two processes loaded the same code.
From format headers to the first application instruction, loading is a sequence of testable decisions. Program headers define mappings, the kernel establishes the initial virtual address space, the dynamic loader maps the dependency graph, relocations materialize addresses, symbol scope selects definitions, and startup code establishes the runtime. Once those boundaries are visible, dynamic linking becomes an inspectable ABI mechanism rather than invisible work performed before main.