Compilers · Linkers and Loaders
From execve to _start: How the Kernel Maps an ELF Executable
Program headers rather than section headers drive loading, PT_LOAD segments carry permissions, PT_INTERP names the dynamic linker, and the kernel hands over a stack with argc, argv, and auxv.
The section header table that a linker reads is irrelevant to the loader. A linked executable may have its section headers removed entirely by strip and still run, because the kernel reads a different table: the program header table, a much smaller array whose entries describe the file as the memory image it will become.
The distinction is the point where the object file stops being a container of named parts and becomes a plan for a process. Sections are for tools; segments are for the loader. Everything the kernel does between execve and the first instruction follows from a handful of program header types, and the same handful explains why an executable’s permissions, its interpreter, and its entry point are all properties of the file rather than of the compiler’s output.
Segments collapse sections by permission
A PT_LOAD program header describes a contiguous region of the file to be mapped into memory. Its fields are an offset into the file, a virtual address, the file size, the memory size, an alignment, and a set of permission flags. The linker’s job is to choose these regions so that everything mapped with execute permission contains no writable data, and everything writable contains no executable code.
That requirement forces the linker to merge sections. .text, .rodata, .plt, and .init may all be read-execute, so they are laid out contiguously in the file and described by a single PT_LOAD entry. .data and .got are read-write and get another. .bss has no file bytes, so it appears in a PT_LOAD with a memory size larger than the file size, and the kernel zero-fills the difference. A typical dynamic executable has four PT_LOAD segments: a read-only one for the ELF header and program headers, a read-execute one for code, a read-only one for constants, and a read-write one for data.
The file offset and the virtual address must be congruent modulo the page size. That constraint comes from mmap, which can only map at page granularity and therefore requires that the file offset be page-aligned relative to the address. A linker that places a segment at a virtual address not congruent to its file offset produces a file the kernel refuses to load. In practice linkers align segments to the page size (4096 bytes on most systems, 16384 on some 64-bit ARM configurations), which satisfies the condition trivially.
The permission flags are PF_R, PF_W, and PF_X. A segment with PF_X and PF_W both set is accepted by the kernel but defeats a security property: writable executable memory means code can be generated and run, which is what W^X enforcement is designed to prevent. Linkers separate the two because the alternative is a binary that cannot run under hardened configurations.
PT_INTERP and the two-stage load
A dynamically linked executable does not contain a complete program. It contains the application’s code plus a request: the PT_INTERP segment holds a NUL-terminated path, typically /lib64/ld-linux-x86-64.so.2, naming the program that must be loaded to finish the job.
The kernel’s handling is elegant and slightly surprising. It maps the executable’s PT_LOAD segments, then reads the interpreter path, opens that file, and maps its PT_LOAD segments as well, then transfers control to the interpreter’s entry point rather than the executable’s. The interpreter is itself an ELF shared object with an entry point, and it is the one that walks the executable’s dynamic section, loads the dependencies named in DT_NEEDED, applies relocations, and finally jumps to the executable’s e_entry.
This means the process at the moment of transfer has two mapped images and a stack, and no libc. The interpreter must therefore avoid libc entirely; it is a self-contained program that uses only raw system calls. That is why the dynamic linker is a single file with no dependencies, and why it duplicates small pieces of functionality such as string formatting for its diagnostics.
A statically linked executable has no PT_INTERP and is entered directly. A static PIE has no interpreter either, but its segments are position independent, so the kernel maps them at a randomized base and the code must relocate itself, which the startup code does before calling main.
The initial stack is the loader’s real interface
The kernel does not pass arguments in registers. It constructs a stack containing, from the top down, the strings of the arguments and environment, then an array of pointers to those strings, then a null terminator, then a similar array for the environment, then an auxiliary vector, and finally argc and the argv pointer. The stack pointer at entry points at argc.
The auxiliary vector is the interesting part. It is an array of type-value pairs terminated by an entry of type zero, and it carries facts the program cannot otherwise obtain: the page size, the address of the program’s own program header table in memory, the entry point, the base address the interpreter was mapped at, the user and group identifiers, and a random seed. Constants named AT_PAGESZ, AT_PHDR, AT_ENTRY, AT_BASE, AT_UID, AT_RANDOM identify the entries.
Two of these are load-bearing for the dynamic linker. AT_PHDR gives it the executable’s program headers without needing to know the executable’s base address, which is necessary because the interpreter cannot assume where the kernel placed it. AT_BASE gives the interpreter’s own load address, which it needs to relocate itself. A program that wants to walk its own program headers — for instance to find PT_NOTE for build identifiers, or to locate the dynamic section — reads AT_PHDR via getauxval.
The entry state is easiest to see in a program that does not use the C library at all. An assembly entry point receives the stack pointer with argc at its top, and passes it to a C function that walks the auxiliary vector:
# minimal entry point: hand the initial stack to a C function
.globl _start
_start:
movq %rsp, %rdi # rdi = pointer to argc
call show_auxv # never returns
/* Walk the auxiliary vector. The layout, from the stack pointer upward:
argc, argv[0..argc-1], NULL, envp[0..], NULL, then auxv pairs. */
#include <elf.h>
#include <stdint.h>
#include <unistd.h>
static void puthex(unsigned long v, char *out) {
static const char d[] = "0123456789abcdef";
for (int i = 15; i >= 0; i--) { out[i] = d[v & 15]; v >>= 4; }
out[16] = '\n';
}
void show_auxv(uint64_t *sp) {
long argc = (long)sp[0];
char **argv = (char **)&sp[1];
char **envp = argv + argc + 1;
while (*envp) envp++; /* skip environment strings */
Elf64_auxv_t *aux = (Elf64_auxv_t *)(envp + 1);
char line[24];
for (; aux->a_type != AT_NULL; aux++) {
if (aux->a_type == AT_PAGESZ) {
puthex(aux->a_un.a_val, line);
__asm__ volatile ("syscall" :: "a"(1L), "D"(1L),
"S"(line), "d"(17L) : "rcx", "r11", "memory");
}
}
__asm__ volatile ("syscall" :: "a"(60L), "D"(0L)); /* exit */
__builtin_unreachable();
}
Reading the stack this way only works before any function has disturbed it, which is why the pointer is captured at the entry point and passed along. A normal main receives the same information through getauxval, which reads a copy the C library saved during startup.
Address space layout and randomization
The kernel chooses where to map the segments unless the executable demands a fixed address. A non-PIE executable has ET_EXEC type and absolute virtual addresses in its program headers; the kernel maps it exactly there and fails if the range is unavailable. A PIE executable has ET_DYN type, the same type as a shared object, and its program headers describe offsets from zero; the kernel picks a base address and adds it to every segment address.
Randomizing the base is a few lines of arithmetic on the kernel side and a substantial obstacle for an attacker who wants to know where a particular function lives. The cost to the program is that nothing may assume a fixed address, which is exactly the constraint that position-independent code satisfies. The interpreter is mapped at a separately randomized address, so the two images do not have a fixed relative distance either.
The stack is randomized as well, and the auxiliary vector moves with it. Since AT_PHDR is an absolute address, a program that reads it learns the executable’s base — which is not a leak in itself, since the program already knows where it is, but which becomes one when the value is passed to something that should not know.
What the loader does not do
The kernel does not resolve symbols, does not process relocations, and does not call constructors. Those are the interpreter’s tasks. This split is deliberate: keeping the kernel’s loader small means it needs no knowledge of symbol tables, versioning, or lazy binding, and the same kernel can run binaries whose linking conventions change entirely, as long as the interpreter is updated alongside.
The kernel also does not verify that segments do not overlap; a malformed executable with two PT_LOAD segments claiming the same address is dealt with by the mapping machinery, not by any check of the file’s internal coherence. Tools that inspect binaries, such as the linker itself, check more than the kernel does.
The kernel also ignores PT_TLS. Thread-local storage is not mapped as a segment by execve; the interpreter reads the PT_TLS entries of every loaded object, lays out the static TLS area, and asks the kernel for a thread pointer block through an architecture-specific system call before transferring control onward. That is why a statically linked program still needs startup code for TLS even though it has no interpreter: the layout and the thread pointer are a runtime responsibility, not a kernel one. The arrangement is what makes the TLS relocations described in Thread-Local Storage Access Models resolvable before library code executes. Without it, a shared object’s thread-local variable would have no block to live in at the moment its relocations were applied.
The handover from kernel to interpreter to program is therefore three distinct contracts, each expressed in the file: program headers for the kernel, the dynamic section for the interpreter, and the initial stack for both. The middle contract, which names dependencies and versioned symbols, is the subject of SONAME, DT_NEEDED, and Symbol Versioning.
References
- elf(5) — Linux manual page — Linux man-pages project
- execve(2) — Linux manual page — Linux man-pages project
- getauxval(3) — Linux manual page — Linux man-pages project
- Linkers and Loaders — John R. Levine, Morgan Kaufmann, 1999