Systems Programming · Binary Formats
ELF Program Headers and the Mapping a Loader Performs Before _start
How PT_LOAD entries describe the bytes the kernel maps, why file offsets and virtual addresses must be congruent modulo the page size, and where the process stack layout comes from.
An executable file on disk and a running process share almost no structure. The file is a sequence of bytes with headers at fixed offsets; the process is a set of virtual memory regions with permissions, an initial stack, and a register state. Everything between those two states is described by a small table near the start of the file: the program header table. The kernel reads that table, not the symbol table and not the debug information, and it reads nothing else before transferring control to the entry point.
That narrowness is the design. A loader must decide in microseconds which pages to map, with which permissions, and at which addresses, and it must do so without parsing formats that were designed for linkers. The program header table is the minimum information needed to make those decisions, and its layout has stayed compatible for decades because changing it would invalidate every existing binary.
The header fields the kernel actually reads
Every ELF file begins with a 64-byte identification and header block on 64-bit targets, or 52 bytes on 32-bit ones. The first 16 bytes are e_ident: the magic 0x7f followed by E, L, F, a class byte distinguishing 32-bit from 64-bit objects, a data byte selecting little- or big-endian encoding, a version byte, and nine bytes of ABI and padding information. The loader checks the magic and the class before interpreting any multi-byte field, because the class byte determines both the size and the layout of everything that follows.
After e_ident come the fields that locate the rest of the file: e_type (executable, shared object, or core), e_machine, e_version, e_entry, e_phoff, e_shoff, e_flags, e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, and e_shstrndx. For loading, four of these matter: e_entry is the virtual address of the first instruction, e_phoff is the file offset of the program header table, e_phentsize is the size of one entry, and e_phnum is how many there are.
The indirection through e_phentsize rather than a fixed constant is deliberate. It allows a future ELF revision to grow the entry structure while old loaders still walk the table correctly by stepping e_phentsize bytes at a time. In practice ELF64 entries are 56 bytes and ELF32 entries are 32 bytes, but a reader that hardcodes those values is making an assumption the format does not require. The same pattern appears in the section table, described in Section Headers, String Tables, and the Bookkeeping a Linker Needs, where the section entry size serves the identical purpose.
Segments are page-shaped, sections are not
A program header describes a segment: a contiguous region of the file that becomes a contiguous region of memory. A section header describes a section: a named unit that a linker or debugger cares about, such as .text, .rodata, .data, or .symtab. The two views overlap but are not parallel. Several sections can be folded into a single segment, and padding is inserted between them so that the segment’s boundaries fall on page boundaries.
This is the first place where a naive reader goes wrong. The number of PT_LOAD entries is typically four on a modern dynamically linked executable: a read-execute segment holding headers and code, a read-only segment holding constants, a read-write segment holding initialised data, and often a separate read-write segment for thread-local storage. There may be 25 or 30 sections. Counting sections and expecting a matching count of segments produces nonsense.
A PT_LOAD entry carries p_offset (where the bytes begin in the file), p_vaddr (the virtual address at which they should appear), p_paddr (meaningful only on systems without virtual memory, ignored on Linux), p_filesz (how many bytes to take from the file), p_memsz (how large the region should be in memory), p_flags (read, write, execute bits), and p_align.
Two invariants are worth committing to memory. First, p_filesz may be smaller than p_memsz, and the difference is zero-filled at load time. That is how .bss exists without occupying file space: a large uninitialised array costs a few bytes of header rather than its full size on disk. Second, p_memsz is never smaller than p_filesz, and a file where it is has been corrupted or crafted.
The congruence rule and why page granularity leaks into the format
The kernel maps memory in page units. On the common 4 KiB page configuration, and on architectures that support 16 KiB or 64 KiB pages, the loader cannot map an arbitrary byte range: it must map whole pages. Since it also cannot shift the file contents relative to each other, the file offset and the virtual address of a segment must agree modulo the page size:
p_vaddr % p_align == p_offset % p_align
The ELF specification states this requirement for PT_LOAD entries. In practice p_align is set to the page size the target uses, or to a larger power of two when the linker wants the segment aligned for huge pages, and a loader that maps with smaller pages must honour the stronger constraint rather than the weaker one.
The practical consequence is visible in any hex dump of an executable. The .text segment usually starts at file offset 0x1000 and virtual address 0x1000 plus the load bias, so the first page of the file, which holds the ELF header and program headers, is mapped as part of the read-only or read-execute segment rather than separately. Making the header page readable at run time is a side effect of that alignment, and it is why AT_PHDR in the auxiliary vector can point at the program headers inside the process.
Mapping, permissions, and the page-splitting problem
A loader that mapped each segment with exactly its p_flags would occasionally be unable to honour both segments’ permissions, because two segments can share a page: the tail of a read-execute segment and the head of a following read-write segment may fall inside the same page frame. Mapping the page twice is not possible without aliasing, and mapping it read-execute denies writes to the data that lives there.
Linux resolves this by mapping the whole span from the lowest to the highest segment address with permissive protections, then applying mprotect to tighten permissions over the ranges that need it. A side effect is that the boundary page containing both the end of one segment and the start of the next ends up with the union of both permissions, so a few bytes of code may remain writable. The PT_GNU_RELRO segment exists to close that hole for the specific pages holding the global offset table and other relocation targets: after the dynamic linker finishes applying relocations, it marks the RELRO range read-only, and a later write faults.
/* Walk PT_LOAD entries and report each mapped span. The stride comes from
e_phentsize, since the entry may be larger than this struct. */
#include <elf.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
FILE *f = fopen(argv[1], "rb");
if (!f) return 1;
Elf64_Ehdr eh;
if (fread(&eh, sizeof eh, 1, f) != 1) return 1;
if (memcmp(eh.e_ident, ELFMAG, SELFMAG) != 0) return 1;
if (eh.e_ident[EI_CLASS] != ELFCLASS64) return 1;
if (eh.e_phentsize < sizeof(Elf64_Phdr)) return 1;
for (unsigned i = 0; i < eh.e_phnum; i++) {
Elf64_Phdr ph;
long off = (long)eh.e_phoff + (long)i * eh.e_phentsize;
if (fseek(f, off, SEEK_SET) != 0) return 1;
if (fread(&ph, sizeof ph, 1, f) != 1) return 1;
if (ph.p_type != PT_LOAD) continue;
printf("vaddr=%#lx off=%#lx filesz=%lu memsz=%lu flags=%c%c%c\n",
(unsigned long)ph.p_vaddr, (unsigned long)ph.p_offset,
(unsigned long)ph.p_filesz, (unsigned long)ph.p_memsz,
ph.p_flags & PF_R ? 'r' : '-',
ph.p_flags & PF_W ? 'w' : '-',
ph.p_flags & PF_X ? 'x' : '-');
}
return 0;
}
The example is intentionally strict about e_phentsize: it advances by the value in the header rather than by sizeof(Elf64_Phdr), and it trusts the file only after checking the class byte. Both habits matter when the input is not a binary the reader produced.
Beyond PT_LOAD: the entries that configure the runtime
Only PT_LOAD entries produce mappings of file contents, but several other entry types are load-bearing. PT_INTERP names the dynamic linker as a NUL-terminated path, and its presence is what makes a binary dynamically linked; the kernel opens that file, maps it, and passes control to the interpreter instead of to e_entry. PT_DYNAMIC locates the dynamic section that the interpreter reads for relocation tables, dependencies, and symbol hashes. PT_NOTE carries build identifiers and ABI metadata. PT_GNU_STACK records whether the process stack should be executable, which is how a binary requests a non-executable stack on systems where that is not the default. PT_TLS describes the initialisation image for thread-local storage, which the runtime copies into each new thread’s block.
The presence of PT_INTERP also changes the entry contract. For a dynamically linked program the kernel maps the executable and the interpreter, then jumps to the interpreter’s entry point, which performs relocations and only then calls the executable’s e_entry. The symbol _start in that executable is not main: it is a small stub that reads the initial stack, aligns it, and calls __libc_start_main with the address of main. main is a convention of the C runtime, not a fact the kernel knows about.
What arrives on the stack
When control reaches the entry point, the stack is not empty. The kernel has pushed argc, then argv[0] through argv[argc-1], then a null pointer, then envp, then a null pointer, then the auxiliary vector: an array of (type, value) pairs terminated by AT_NULL. Among those pairs, AT_PHDR gives the address of the program headers in memory, AT_PHENT and AT_PHNUM give their entry size and count, AT_ENTRY gives the executable’s entry address, AT_BASE gives the interpreter’s load address, AT_PAGESZ gives the page size, and AT_RANDOM points at bytes usable for stack canaries and pointer guards.
This is why a static position-independent executable can relocate itself: it has no interpreter to do the work, but AT_PHDR and AT_BASE tell it where it landed, so a startup routine can walk its own relocation table and apply the offsets. The same mechanism underpins Position-Independent Executables and the Cost of PC-relative Addressing.
Failure modes worth recognising
The loader rejects a file for a small set of reasons, and each maps to a distinct diagnostic. A truncated file whose e_phoff plus e_phnum * e_phentsize extends past the end of the file is malformed. A PT_LOAD entry with p_filesz > p_memsz is malformed. A segment whose virtual address range wraps around the end of the address space, or overlaps another segment in a way that cannot be reconciled, is rejected. A missing interpreter named by PT_INTERP produces a distinct error before any user code runs, which is why “no such file or directory” can appear when executing a binary that plainly exists.
Alignment violations are subtler. A file whose p_align is smaller than the page size, or whose p_offset is not congruent to p_vaddr modulo the page size, cannot be mapped directly on a system whose pages are larger than the file’s alignment allows. Loaders differ in how strictly they enforce the congruence rule: some reject the file, some map it with an extra copy. A binary that depends on a lenient loader is not portable to a stricter one. Tools that rewrite executables sometimes produce exactly this defect by appending a segment at an unaligned offset.
The format’s age shows in its reliance on the loader to validate. There is no checksum over the header table, no length field for the table as a whole, and no redundancy. A single corrupted byte in e_phnum turns a valid file into one whose table extends into arbitrary file content, and the loader will interpret whatever bytes it finds there as segment descriptors. Readers that consume untrusted executables, as described in Parsing Untrusted Binaries Without Trusting Their Length Fields, must re-derive every bound from the actual file length rather than trusting the header’s own claims.
References
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification — TIS Committee
- elf(5) — Linux man-pages — Linux man-pages project
- System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee
- Linkers and Loaders — John R. Levine, Morgan Kaufmann, 1999