ELF Header and Program Headers
Every Executable and Linkable Format (ELF) file on Linux opens with a fixed-size ELF header (
Elf64_Ehdr) — the 64-byte preamble the kernel reads first to decide whether the file is even loadable, and to find everything else. The single most important thing the header points to is the program header table (an array ofElf64_Phdrentries): the run-time description of the file, telling the loader which byte-ranges tommapinto the new process’s address space and with what permissions. When youexecve(2)a binary, the kernel’sfs/binfmt_elf.c(load_elf_binary()) validates the ELF header, reads the program headers, maps eachPT_LOADsegment, optionally hands off to the dynamic linker named inPT_INTERP, and finally jumps to the entry addresse_entry(elf.5; gABI ch.5; Linux 6.12binfmt_elf.c). This note walks both structures field-by-field and traces how the kernel consumes them.
Mental Model
Think of the ELF header as a table of contents printed on the cover and the program header table as the loading instructions. The cover (the Elf64_Ehdr) is always at file offset 0, always the same size, and is self-describing: it carries a magic number so a loader can recognize the format, a word telling whether the file is 32- or 64-bit and big- or little-endian (so the loader knows how to interpret every subsequent field), the machine architecture (so an ARM kernel rejects an x86-64 binary), and — crucially — two file offsets: e_phoff (where the program header table starts) and e_shoff (where the section header table starts). The kernel reads the program header table; the section header table is for the link editor ld and is irrelevant at run time (see ELF Sections vs Segments).
The program header table is an array of fixed-size records, each describing one segment — a contiguous chunk of the file that should become a contiguous run of memory with uniform permissions. The loader does not care about the dozens of named sections (.text, .rodata, .data, …); it cares only about the handful of PT_LOAD segments that say “map file bytes [p_offset, p_offset+p_filesz) to virtual address p_vaddr, reserve p_memsz bytes there, and protect them with these read/write/execute flags.”
flowchart TB subgraph FILE["ELF file on disk"] EHDR["Elf64_Ehdr @ offset 0<br/>magic, class, e_entry,<br/>e_phoff, e_phnum"] PHT["Program header table @ e_phoff<br/>(e_phnum × Elf64_Phdr)"] SEG1["PT_LOAD #1 (R-X): .text .rodata"] SEG2["PT_LOAD #2 (RW-): .data .bss"] INTERP["PT_INTERP bytes:<br/>/lib64/ld-linux-x86-64.so.2"] end EHDR -->|e_phoff| PHT PHT -->|p_offset,p_vaddr| SEG1 PHT -->|p_offset,p_vaddr| SEG2 PHT -->|p_offset| INTERP EXEC["execve → load_elf_binary()"] -->|reads| EHDR EXEC -->|load_elf_phdrs| PHT EXEC -->|elf_map/vm_mmap| MEM["process address space"] SEG1 -.mapped to.-> MEM SEG2 -.mapped to.-> MEM
Diagram: the ELF header is the entry point into the file; e_phoff locates the program header table, whose PT_LOAD entries drive the mmap calls that build the process image, while PT_INTERP names the dynamic linker. The insight: the kernel touches a tiny, fixed set of structures — header, program headers, two-or-so load segments — and everything else in the file is for tools, not the loader.
The ELF Header: Elf64_Ehdr Field by Field
The 64-bit header is defined in <elf.h> and documented in elf.5:
typedef struct {
unsigned char e_ident[EI_NIDENT]; /* 16-byte identification block */
uint16_t e_type; /* object file type */
uint16_t e_machine; /* required architecture */
uint32_t e_version; /* file version (EV_CURRENT) */
Elf64_Addr e_entry; /* entry point virtual address */
Elf64_Off e_phoff; /* program header table offset */
Elf64_Off e_shoff; /* section header table offset */
uint32_t e_flags; /* processor-specific flags */
uint16_t e_ehsize; /* this header's size */
uint16_t e_phentsize; /* size of one Phdr entry */
uint16_t e_phnum; /* number of Phdr entries */
uint16_t e_shentsize; /* size of one Shdr entry */
uint16_t e_shnum; /* number of Shdr entries */
uint16_t e_shstrndx; /* Shdr index of name strtab */
} Elf64_Ehdr;e_ident[16] — the identification block. This is the first thing every loader inspects, because until you read it you don’t know how to read anything else. Its bytes have fixed meanings (elf.5):
- Bytes 0–3, the magic number.
EI_MAG0 = 0x7f,EI_MAG1 = 'E',EI_MAG2 = 'L',EI_MAG3 = 'F'. The four-byte sequence\x7fELFis whatfile(1)and the kernel use to recognize an ELF file. The kernel’s check is literallymemcmp(elf_ex->e_ident, ELFMAG, SELFMAG)whereELFMAGis"\177ELF"andSELFMAGis 4 (Linux 6.12binfmt_elf.c). - Byte 4,
EI_CLASS.ELFCLASS32(1) orELFCLASS64(2). This determines the width ofElf32_*vsElf64_*structures — a 64-bit kernel reading a 32-bit binary uses the narrower layouts.ELFCLASSNONE(0) is invalid. - Byte 5,
EI_DATA. Endianness of the data:ELFDATA2LSB(little-endian, two’s complement) orELFDATA2MSB(big-endian). On x86-64 it isELFDATA2LSB. The loader must byte-swap multi-byte header fields if the file’s encoding differs from the host — in practice Linux rejects non-native binaries early. - Byte 6,
EI_VERSION. ELF spec version, alwaysEV_CURRENT(1) in practice. - Byte 7,
EI_OSABI. Identifies the operating-system/ABI conventions:ELFOSABI_NONE/ELFOSABI_SYSV(0) is the generic System V ABI,ELFOSABI_LINUX(3) marks Linux-specific extensions (e.g.STB_GNU_UNIQUEsymbols). Most Linux executables actually carry0here, not3— theELFOSABI_LINUXmarking is set lazily only when a Linux-only feature is used. - Byte 8,
EI_ABIVERSION. ABI sub-version, usually 0. - Bytes 9–15,
EI_PAD. Reserved, zero-filled.
e_type — what kind of object this is. Five values matter (elf.5): ET_NONE (0, unknown), ET_REL (1, a relocatable .o object file from the compiler), ET_EXEC (2, a fixed-address executable), ET_DYN (3, a shared object — .so libraries and position-independent executables / PIE), and ET_CORE (4, a core dump). The kernel only loads ET_EXEC or ET_DYN: if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN) goto out; (Linux 6.12 binfmt_elf.c). The ET_EXEC vs ET_DYN distinction is central: an ET_EXEC binary demands to be loaded at the exact p_vaddr addresses baked into it, while an ET_DYN binary is position-independent and the kernel can slide it by an arbitrary load bias (the basis of Address Space Layout Randomization for the main executable — see Position-Independent Code and PIE).
e_machine — the required CPU. A numeric architecture code: EM_X86_64 (62), EM_386 (3), EM_AARCH64 (183), EM_ARM (40), and so on. The kernel checks this through the architecture-supplied elf_check_arch(elf_ex) macro and refuses a binary built for the wrong CPU.
e_version mirrors EI_VERSION (always EV_CURRENT). e_entry is the virtual address to which the kernel transfers control once loading finishes — the entry point. For a dynamically linked program this is not used directly; control instead goes to the interpreter’s entry point, and the program’s own e_entry is passed to the interpreter via the auxiliary vector entry AT_ENTRY (see The Auxiliary Vector).
e_phoff and e_shoff are byte offsets from the start of the file to the program header table and section header table respectively (0 means “not present”). e_flags holds processor-specific flags (used on architectures like ARM and MIPS; zero on x86-64). e_ehsize is the size of the ELF header itself (64 on 64-bit). e_phentsize is the size of one program-header entry, and e_phnum the number of them — the kernel multiplies these to size its read. Symmetrically, e_shentsize and e_shnum describe the section header table, and e_shstrndx is the index of the section that holds section names. The section trio is unused by the kernel loader.
The Program Header: Elf64_Phdr Field by Field
Each program header entry is (gABI ch.5):
typedef struct {
uint32_t p_type; /* segment type (PT_*) */
uint32_t p_flags; /* permission flags (PF_R/PF_W/PF_X) */
Elf64_Off p_offset; /* file offset of segment's first byte */
Elf64_Addr p_vaddr; /* virtual address to map it at */
Elf64_Addr p_paddr; /* physical address (unused on Linux) */
uint64_t p_filesz; /* bytes of this segment present in file*/
uint64_t p_memsz; /* bytes this segment occupies in memory*/
uint64_t p_align; /* alignment of p_vaddr/p_offset */
} Elf64_Phdr;Note the field order differs between Elf32_Phdr and Elf64_Phdr: in the 64-bit form p_flags moves up next to p_type for better alignment. p_paddr (physical address) is meaningful only on systems where physical addressing matters (firmware, some embedded ROM images); on a virtual-memory OS like Linux it is ignored.
The interesting field is p_type, which selects the segment’s meaning. The generic values and their numbers (gABI ch.5):
PT_LOAD (1) — a mappable segment. This is the only type that contributes to the process image directly, and the only one the loader strictly must honor. The loader maps p_filesz bytes from file offset p_offset to virtual address p_vaddr, then reserves up to p_memsz bytes there. The defining rule: p_memsz >= p_filesz, and any bytes in [p_filesz, p_memsz) are defined to be zero (gABI ch.5: “If the segment’s memory size (p_memsz) is larger than the file size (p_filesz), the ‘extra’ bytes are defined to hold the value 0… The file size may not be larger than the memory size.”). That zero-fill gap is the .bss — uninitialized global data that costs no file space because it is all zeros (see ELF Sections vs Segments for why .bss is “in memory but not in the file”). The p_flags give the segment its permissions; p_align constrains placement with the rule that p_vaddr mod p_align == p_offset mod p_align (so the file mapping lands page-aligned), and loadable segments appear in the table sorted in ascending p_vaddr order (gABI ch.5). On x86-64 p_align is typically the 4 KiB page size (modern binutils reduced the default max-page-size for x86-64 from 2 MiB to 4 KiB to keep binaries small — see Production Notes).
PT_INTERP (3) — the program interpreter path. Points at a NUL-terminated pathname (e.g. /lib64/ld-linux-x86-64.so.2) naming the dynamic linker that should be loaded to finish setting up a dynamically linked program. It is meaningful only in executables and may appear at most once (gABI ch.5). A statically linked binary has no PT_INTERP. See The Dynamic Linker.
PT_DYNAMIC (2) — the dynamic section. Locates the .dynamic array of tagged values (DT_NEEDED library names, DT_SYMTAB, DT_RELA, …) that the dynamic linker reads to do its work. See The Dynamic Section.
PT_PHDR (6) describes the location of the program header table itself within the memory image — so a running program (and the dynamic linker) can find its own headers. PT_NOTE (4) locates note sections (ElfN_Nhdr records) carrying vendor metadata — ABI tags, the GNU build-ID (see ELF Note Sections and Build IDs). PT_TLS (7) describes the thread-local storage template: the initialization image for __thread variables that gets copied per-thread; its p_flags must be PF_R (gABI ch.5). See Thread-Local Storage. PT_SHLIB (5) is reserved with unspecified semantics and unused.
Three GNU extensions live in the OS-specific range (PT_LOOS = 0x60000000 … PT_HIOS = 0x6fffffff), and the loader treats them specially (Ghidra ELF constants):
PT_GNU_EH_FRAME(0x6474e550) points at.eh_frame_hdr, the sorted index used for C++ exception unwinding andbacktrace().PT_GNU_STACK(0x6474e551) carries no data — itsp_flagsencode whether the thread stack should be executable. The kernel inspects it:case PT_GNU_STACK: if (elf_ppnt->p_flags & PF_X) executable_stack = EXSTACK_ENABLE_X; else executable_stack = EXSTACK_DISABLE_X;(Linux 6.12binfmt_elf.c). Modern toolchains emit it withPF_R | PF_W(noPF_X), giving a non-executable (NX) stack — a core defense against stack-injected shellcode.PT_GNU_RELRO(0x6474e552) marks a region the loader should make read-only after relocation (RELRO) — typically the GOT and.dynamic, hardened so an attacker who gains a write primitive cannot overwrite function pointers the dynamic linker resolved. See RELRO Relocation Read-Only.
The permission flags p_flags are a bitmask: PF_X (1, execute), PF_W (2, write), PF_R (4, read).
How execve Reads These: the Kernel Walk-through
When a process calls execve(2), the kernel selects a binary-format handler; for ELF that is load_elf_binary() in fs/binfmt_elf.c. The sequence, grounded in Linux 6.12 binfmt_elf.c:
-
Validate the header. The first 128 bytes of the file are already in
bprm->buf. The loader checks the magic (memcmp(..., ELFMAG, SELFMAG)), thate_typeisET_EXECorET_DYN, and that the CPU matches (elf_check_arch). A mismatch returns-ENOEXEC, letting another handler try. -
Read the program headers.
load_elf_phdrs()allocatese_phnum * e_phentsizebytes andelf_read()s the table from offsete_phoff. It rejects a boguse_phentsize != sizeof(struct elf_phdr)or an implausibly large count. -
Find the interpreter. The loader scans for a
PT_INTERPentry; if present it reads thep_filesz-byte pathname,open_exec()s that file (the dynamic linker), reads its ELF header and program headers too. Both the program and its interpreter will be mapped. -
Map the
PT_LOADsegments. Looping over the program headers, for eachPT_LOADthe loader builds the protection from the flags — conceptuallyif (p_flags & PF_R) prot |= PROT_READ; if (PF_W) PROT_WRITE; if (PF_X) PROT_EXEC;(the helpermake_prot()) — and callself_load()→vm_mmap()to mapp_fileszbytes of the file atload_bias + p_vaddr. For anET_DYN(PIE) image,load_biasis the randomized slide; forET_EXECit is zero. -
Zero the
.bssgap. Wherep_memsz > p_filesz, the trailing partial page is cleared withpadzero()and any further whole pages are reserved withvm_brk_flags()— this is what realizes the zero-filled.bsspromised by the spec (Linux 6.12binfmt_elf.c). -
Apply stack and property settings.
PT_GNU_STACKsetsexecutable_stack;parse_elf_properties()consumes thePT_GNU_PROPERTYnote (used for control-flow-integrity markings like Intel CET/IBT and ARM BTI), recording state instruct arch_elf_state. -
Transfer control. After building the initial stack (argv/envp/auxv — see The Initial Process Stack), the loader computes
elf_entry: for a static binary that is the program’se_entry + load_bias; for a dynamic one it is the interpreter’s entry point (ld.soruns first). It finishes withSTART_THREAD(elf_ex, regs, elf_entry, bprm->p), which sets the instruction pointer and stack pointer and returns into user space.
Reading the Headers Yourself
readelf -h dumps the ELF header; readelf -l (alias --program-headers/--segments) dumps the program header table plus the section-to-segment mapping (readelf.1). A representative readelf -l /bin/ls shows program headers such as:
Type Offset VirtAddr FileSiz MemSiz Flg Align
PHDR 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0035c8 0x0035c8 R 0x1000
LOAD 0x0000000000004000 0x0000000000004000 0x00d8d1 0x00d8d1 R E 0x1000
LOAD 0x0000000000012000 0x0000000000012000 0x007f00 0x007f00 R 0x1000
LOAD 0x000000000001 a3b0 0x000000000001b3b0 0x000a18 0x001a18 RW 0x1000
DYNAMIC ... ... RW
NOTE ... ... R
GNU_EH_FRAME ... ... R
GNU_STACK 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO ... ... R
Read this top to bottom. INTERP names ld.so. There are four LOAD segments here (read-only, read-execute, read-only, read-write) — modern binutils splits permissions finely (see Production Notes). Look at the last LOAD: FileSiz 0x000a18 but MemSiz 0x001a18 — the 4 KiB difference is the .bss, present in memory but not in the file, exactly the p_memsz > p_filesz rule. GNU_STACK has flags RW (no E): a non-executable stack.
Failure Modes and Common Misunderstandings
e_entryis not where a dynamic program starts. Beginners assume the kernel jumps toe_entry. For a dynamic executable, the kernel jumps to the interpreter’s entry first; the program’se_entryis delivered told.soviaAT_ENTRYin the auxiliary vector, andld.sojumps there only after relocations finish.- Confusing
p_fileszandp_memszcorrupts globals. A handcrafted ELF (malware, a teaching toy) that setsp_memsz == p_fileszfor the data segment leaves no room for.bss; the program runs but its zero-initialized globals overlap whatever follows, a classic source of mysterious corruption. The kernel trusts these fields; nothing checks that they are “sensible.” - “
bad ELF interpreter: No such file or directory.” ThePT_INTERPpath is hardcoded into the binary. Run a glibc binary on a musl-only system (Alpine) and the namedld-linux-x86-64.so.2does not exist;execvefails. This is why “static” containers exist. See glibc vs musl. - An executable RWX
LOADsegment is a smell. Older or hand-rolled linker scripts produced a singleLOADwithRWXflags; modern linkers warn (“LOAD segment with RWX permissions”) because writable-and-executable memory defeats W^X. The fine-grained multi-segment split exists to avoid it. e_phnumoverflow. With more thanPN_XNUM(0xffff) program headers,e_phnumis set to the escape value and the real count lives insh_infoof the first section header — a corner the kernel andreadelfhandle but which surprises naive parsers.
See Also
- ELF Format — the parent overview of the whole container format
- ELF Sections vs Segments — the link-time (sections) vs run-time (segments) duality these headers embody; why
.bssisp_memsz > p_filesz - The Dynamic Section — what
PT_DYNAMICpoints at, read by the dynamic linker - The Initial Process Stack — what the kernel builds after mapping the segments, before jumping to the entry point
- The Dynamic Linker — the
PT_INTERPinterpreter that runs first for a dynamic binary - Position-Independent Code and PIE — why
ET_DYNexecutables can be slid by a load bias - RELRO Relocation Read-Only — the hardening
PT_GNU_RELROrequests - The Auxiliary Vector — how
e_entryand the program headers’ address reachld.so - Go Executable Layout — a concrete worked example of these headers in a real binary
- Linux Userspace Runtime MOC — the parent map of content