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 of Elf64_Phdr entries): the run-time description of the file, telling the loader which byte-ranges to mmap into the new process’s address space and with what permissions. When you execve(2) a binary, the kernel’s fs/binfmt_elf.c (load_elf_binary()) validates the ELF header, reads the program headers, maps each PT_LOAD segment, optionally hands off to the dynamic linker named in PT_INTERP, and finally jumps to the entry address e_entry (elf.5; gABI ch.5; Linux 6.12 binfmt_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 \x7fELF is what file(1) and the kernel use to recognize an ELF file. The kernel’s check is literally memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) where ELFMAG is "\177ELF" and SELFMAG is 4 (Linux 6.12 binfmt_elf.c).
  • Byte 4, EI_CLASS. ELFCLASS32 (1) or ELFCLASS64 (2). This determines the width of Elf32_* vs Elf64_* 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) or ELFDATA2MSB (big-endian). On x86-64 it is ELFDATA2LSB. 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, always EV_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_UNIQUE symbols). Most Linux executables actually carry 0 here, not 3 — the ELFOSABI_LINUX marking 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 = 0x60000000PT_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 and backtrace().
  • PT_GNU_STACK (0x6474e551) carries no data — its p_flags encode 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.12 binfmt_elf.c). Modern toolchains emit it with PF_R | PF_W (no PF_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:

  1. Validate the header. The first 128 bytes of the file are already in bprm->buf. The loader checks the magic (memcmp(..., ELFMAG, SELFMAG)), that e_type is ET_EXEC or ET_DYN, and that the CPU matches (elf_check_arch). A mismatch returns -ENOEXEC, letting another handler try.

  2. Read the program headers. load_elf_phdrs() allocates e_phnum * e_phentsize bytes and elf_read()s the table from offset e_phoff. It rejects a bogus e_phentsize != sizeof(struct elf_phdr) or an implausibly large count.

  3. Find the interpreter. The loader scans for a PT_INTERP entry; if present it reads the p_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.

  4. Map the PT_LOAD segments. Looping over the program headers, for each PT_LOAD the loader builds the protection from the flags — conceptually if (p_flags & PF_R) prot |= PROT_READ; if (PF_W) PROT_WRITE; if (PF_X) PROT_EXEC; (the helper make_prot()) — and calls elf_load()vm_mmap() to map p_filesz bytes of the file at load_bias + p_vaddr. For an ET_DYN (PIE) image, load_bias is the randomized slide; for ET_EXEC it is zero.

  5. Zero the .bss gap. Where p_memsz > p_filesz, the trailing partial page is cleared with padzero() and any further whole pages are reserved with vm_brk_flags() — this is what realizes the zero-filled .bss promised by the spec (Linux 6.12 binfmt_elf.c).

  6. Apply stack and property settings. PT_GNU_STACK sets executable_stack; parse_elf_properties() consumes the PT_GNU_PROPERTY note (used for control-flow-integrity markings like Intel CET/IBT and ARM BTI), recording state in struct arch_elf_state.

  7. 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’s e_entry + load_bias; for a dynamic one it is the interpreter’s entry point (ld.so runs first). It finishes with START_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_entry is not where a dynamic program starts. Beginners assume the kernel jumps to e_entry. For a dynamic executable, the kernel jumps to the interpreter’s entry first; the program’s e_entry is delivered to ld.so via AT_ENTRY in the auxiliary vector, and ld.so jumps there only after relocations finish.
  • Confusing p_filesz and p_memsz corrupts globals. A handcrafted ELF (malware, a teaching toy) that sets p_memsz == p_filesz for 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.” The PT_INTERP path is hardcoded into the binary. Run a glibc binary on a musl-only system (Alpine) and the named ld-linux-x86-64.so.2 does not exist; execve fails. This is why “static” containers exist. See glibc vs musl.
  • An executable RWX LOAD segment is a smell. Older or hand-rolled linker scripts produced a single LOAD with RWX flags; 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_phnum overflow. With more than PN_XNUM (0xffff) program headers, e_phnum is set to the escape value and the real count lives in sh_info of the first section header — a corner the kernel and readelf handle but which surprises naive parsers.

See Also