R0KernelPath
module 067 / 262

The ELF Format & Binary Tools

ELF is the universal language your compiler, linker, loader, and kernel all speak — master it and binaries become transparent.

~10h focused study11 concepts

Overview

Every C program you write, every kernel module you load, every shared library your process opens — they all share one common format on Linux: ELF, the Executable and Linkable Format. ELF is not a compiler detail you can safely ignore. It is the contract between your compiler, the linker, the dynamic loader, and the operating system kernel. Understanding it deeply transforms binaries from opaque blobs into readable, inspectable, manipulable artifacts.

This module builds a complete mental model of ELF from the ground up. We start with the file-level view — the ELF header, section headers, and program (segment) headers — and progressively add the concepts that make the format powerful: symbol tables, relocations, the distinction between link-time sections and load-time segments, and how the kernel reads ELF to bootstrap a process. We finish with vmlinux, the kernel's own ELF image, which is the ultimate example of ELF used at scale.

Along the way you will use the professional binary tools — readelf, objdump, nm, size, strip, objcopy — that every kernel and systems developer reaches for daily. These tools are not just for curiosity; they are essential debugging instruments. When a kernel module fails to load, when a function is not where the linker said it would be, when a security hardening feature is or is not active, the answer lives in the ELF structures these tools expose.

By the end of this module you will be able to open any Linux binary — including vmlinux itself — and explain what every major section does, how segments map to virtual memory, where symbols live and what their binding and visibility mean, and how relocations wire together separately compiled translation units into a working program.

why this matters for the kernel

Kernel development lives at the intersection of C, assembly, the linker, and the loader, and all of those meet in ELF. When you write a kernel module, the kernel's module loader parses ELF section headers, walks relocation tables, resolves symbols against the kernel's exported symbol table (/proc/kallsyms), and copies sections into executable memory — all before your module_init function is ever called. If you do not understand ELF, module loading is magic; if you do, it is a straightforward algorithm you can read and even patch.

Beyond modules, almost every kernel debugging technique depends on ELF literacy. vmlinux is a giant ELF file that tools like gdb, perf, crash, kgdb, bpftrace, and pahole parse to find symbol addresses, type layouts, and DWARF debug info. The kernel's own linker scripts (which you will study in a later module) are essentially a domain-specific language for sculpting ELF sections into the precise memory layout the boot loader expects. Reading and writing those scripts requires knowing what sections are, what segments are, and how the two relate. Understanding ELF now is the prerequisite for almost every other "how does the kernel actually work" question you will ask later.

ELF Dual-View: From .o File to Running Process

rendering diagram…

ELF is read in two completely different ways by two different consumers. The section-header table (blue) is the linker's and debugger's organized view of named logical units. The program-header table (red) is the kernel loader's physical map of what to mmap() and with what protection flags. The same file bytes are described by both tables simultaneously.

The concepts

01What is ELF and Why Does It Exist?

Before ELF (Executable and Linkable Format), Unix systems used several incompatible binary formats — a.out on early Unix/Linux, COFF on System V, PE on Windows. Each had different ideas about how to represent sections, symbols, and relocations, which made cross-tool portability painful.

ELF was introduced with System V Release 4 (SVR4) in the late 1980s and was adopted by Linux early on. It has three related but distinct roles in one format:

  1. Relocatable object file (.o): the output of the assembler/compiler for a single translation unit. Not yet executable — symbol references may be unresolved.
  2. Executable file: produced by the linker. All symbols resolved, ready to execve().
  3. Shared object (.so): a dynamic library. Partially resolved, completed at load time by the dynamic linker.

All three variants share the same basic structure. The key insight is that ELF is dual-view: the same bytes are interpreted through a section-header table (the linker's view, describing logical groupings) and a program-header table (the loader's view, describing what to map into memory). This duality is one of ELF's most important and most misunderstood aspects.

On a modern Linux system, ELF covers:

  • x86-64, aarch64, riscv64, arm, mips, powerpc, and dozens more architectures.
  • 32-bit (Elf32_*) and 64-bit (Elf64_*) variants.
  • Little-endian and big-endian byte orders.

All of this is described in a single, stable ABI documented in the System V ABI supplement for each architecture.

# Confirm an ELF file with the 'magic bytes'
$ xxd /bin/ls | head -2
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
#         ^--- 0x7f 'E' 'L' 'F'  = magic
#                   ^^ 02 = 64-bit, 01 = little-endian, 01 = ELF version

# Check the ELF type
$ readelf -h /bin/ls | grep Type
  Type:                              EXEC (Executable file)

02The ELF Header

Every ELF file begins with a fixed-size header. In 64-bit ELF (Elf64_Ehdr) it is 64 bytes; in 32-bit (Elf32_Ehdr) it is 52 bytes. This header is the root of everything — it tells you what kind of file this is, what architecture it targets, and where to find the section-header table and program-header table.

Key fields:

FieldSizeMeaning
e_ident[16]16 bytesMagic 7f E L F, class (32/64), endianness, version, OS/ABI
e_type2 bytesET_EXEC(2), ET_DYN(3), ET_REL(1), ET_CORE(4)
e_machine2 bytesEM_X86_64(62), EM_AARCH64(183), EM_RISCV(243)…
e_entry8 bytesVirtual address of entry point (0 for .o files)
e_phoff8 bytesOffset in file to program-header table
e_shoff8 bytesOffset in file to section-header table
e_phnum2 bytesNumber of program headers
e_shnum2 bytesNumber of section headers
e_shstrndx2 bytesIndex of the section that holds section names

The e_entry field is critical for the kernel: when execve() finishes mapping an ELF executable, it jumps to this address. For dynamically linked programs, this points to the dynamic linker (ld.so), not main(). For statically linked programs it points to the C runtime start (_start), which eventually calls main().

The e_type field of ET_DYN is used for both shared libraries and position-independent executables (PIE). This is why readelf -h /bin/ls on a modern distro shows DYN even though ls is an executable — PIE is the default for security reasons (ASLR).

#include <elf.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(void) {
    Elf64_Ehdr hdr;
    int fd = open("/bin/ls", O_RDONLY);
    read(fd, &hdr, sizeof(hdr));
    close(fd);

    printf("Magic: %02x %c%c%c\n",
           hdr.e_ident[0],
           hdr.e_ident[1], hdr.e_ident[2], hdr.e_ident[3]);
    printf("Class: %s\n", hdr.e_ident[EI_CLASS] == ELFCLASS64 ? "64-bit" : "32-bit");
    printf("Entry point: 0x%lx\n", hdr.e_entry);
    printf("Section headers: %d at offset 0x%lx\n", hdr.e_shnum, hdr.e_shoff);
    printf("Program headers: %d at offset 0x%lx\n", hdr.e_phnum, hdr.e_phoff);
    return 0;
}

03Sections: The Linker's View

Sections are the unit of organization that the compiler and linker care about. A section is a contiguous chunk of bytes with a name, a type, a set of flags, and an alignment requirement. The section-header table (SHT) is an array of Elf64_Shdr structs, each describing one section.

Key section types (sh_type):

  • SHT_PROGBITS: arbitrary program data (code, data, debug info)
  • SHT_SYMTAB: symbol table (one per file, all symbols)
  • SHT_DYNSYM: dynamic symbol table (only the symbols needed at runtime)
  • SHT_STRTAB: a null-terminated string table
  • SHT_RELA / SHT_REL: relocation entries
  • SHT_NOBITS: like PROGBITS but takes no file space (.bss)
  • SHT_NOTE: vendor metadata, build IDs, ABI tags

Key section flags (sh_flags):

  • SHF_ALLOC (A): must be in memory at runtime
  • SHF_EXECINSTR (X): contains executable instructions
  • SHF_WRITE (W): writable
  • SHF_MERGE (M): entries can be merged (e.g., string literal deduplication)
  • SHF_STRINGS (S): section contains null-terminated strings

The canonical sections every C program produces:

SectionFlagsContains
.textAXcompiled machine code
.rodataAread-only data (string literals, const globals)
.dataAWinitialized read-write globals
.bssAWzero-initialized globals (no file bytes!)
.symtab—symbol table (stripped in release builds)
.strtab—string table for .symtab
.shstrtab—string table for section names themselves
.debug_*—DWARF debug information
.eh_frameAunwind tables for C++ exceptions and perf

Sections can be split by the compiler for function-level GC: -ffunction-sections makes each function its own .text.<funcname> section, and the linker's --gc-sections then dead-strips unreferenced ones. The Linux kernel uses this aggressively.

# Compile a small file and inspect its sections
$ cat hello.c
#include <stdio.h>
const char msg[] = "hello";
int counter = 0;
int main(void) { puts(msg); return counter; }

$ gcc -c -O1 hello.c -o hello.o
$ readelf -S hello.o
# (abbreviated)
  [Nr] Name       Type     Flags  Size
  [ 1] .text      PROGBITS AX     0x001a
  [ 2] .rodata    PROGBITS A      0x0006   # "hello\0"
  [ 3] .data      PROGBITS AW     0x0000   # counter is zero-init -> .bss
  [ 4] .bss       NOBITS   AW     0x0004   # counter: 4 bytes, no file space
  [ 5] .comment   PROGBITS          ...
  [ 6] .symtab    SYMTAB            ...
  [ 7] .strtab    STRTAB            ...
  [ 8] .shstrtab  STRTAB            ...

04Segments: The Loader's View (Program Headers)

While sections tell the linker how to lay things out, segments (described by program headers) tell the OS loader how to map the file into virtual memory. This is the view the kernel's execve() implementation reads — it ignores section headers entirely.

A program header (Elf64_Phdr) describes one segment:

FieldMeaning
p_typePT_LOAD, PT_DYNAMIC, PT_INTERP, PT_NOTE, PT_PHDR, PT_GNU_STACK…
p_offsetOffset in the file where this segment starts
p_vaddrVirtual address where it should be mapped
p_fileszNumber of bytes in the file
p_memszNumber of bytes in memory (≥ p_filesz; extra bytes are zeroed — this is how .bss works)
p_flagsPF_R(read), PF_W(write), PF_X(execute)
p_alignAlignment (typically 4096 = one page)

Critical insight: p_memsz > p_filesz. The gap is zero-filled by the kernel (or dynamic linker). This is exactly what .bss needs — zero-initialized data takes no file space but needs real virtual pages.

Typical PT_LOAD segments for a simple binary:

  1. RX segment (read+execute): contains .text, .rodata. Mapped as read-only executable pages.
  2. RW segment (read+write): contains .data, .bss. Mapped as read-write non-executable pages.

Key other segment types:

  • PT_INTERP: path to the dynamic linker (e.g., /lib64/ld-linux-x86-64.so.2). The kernel reads this and runs the interpreter instead of jumping to e_entry directly.
  • PT_DYNAMIC: points to the .dynamic section which the dynamic linker uses to find needed libraries.
  • PT_GNU_STACK: its p_flags field tells the kernel whether the stack should be executable. If absent or marked PF_X, the stack is executable — a security hazard. Modern toolchains set PF_R|PF_W only.
  • PT_GNU_RELRO: tells the dynamic linker which region to mprotect(PROT_READ) after relocation is complete (the RELRO hardening technique).

The section-to-segment relationship: Many sections are packed into few segments. The linker decides which sections go into which segment based on compatibility (flags, alignment). readelf -l shows this mapping.

$ gcc -O1 hello.c -o hello
$ readelf -l hello
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0002d8 0x0002d8 R   0x8
  INTERP         0x000318 0x0000000000000318 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x000628 0x000628 R   0x1000
  LOAD           0x001000 0x0000000000001000 0x000195 0x000195 R E 0x1000
  LOAD           0x002000 0x0000000000002000 0x000114 0x000114 R   0x1000
  LOAD           0x002df0 0x0000000000003df0 0x000228 0x000230 RW  0x1000
  DYNAMIC        0x002e00 0x0000000000003e00 0x0001c0 0x0001c0 RW  0x8
  GNU_STACK      0x000000 0x0000000000000000 0x000000 0x000000 RW  0x10
# Note: GNU_STACK has RW flags (no X) -- stack is NOT executable. Good.

 Section to Segment mapping:
  Segment Sections...
   02     .interp
   03     .interp .dynsym .dynstr ... (first LOAD: read-only metadata)
   04     .text ...                   (second LOAD: code, R+X)
   07     .data .bss ...              (fourth LOAD: RW, memsz > filesz for .bss)

05Symbol Tables: Names and Addresses

A symbol is a named entity — a function, a variable, or a label — with an address (or an offset within a section), a size, a type, and crucially, a binding and visibility.

Symbol binding (STB_*):

  • STB_LOCAL: visible only within the translation unit. C static functions and variables. Not exported.
  • STB_GLOBAL: visible to the entire link. Standard C functions and globals.
  • STB_WEAK: like global but can be overridden. Used for default implementations that libraries can replace (e.g., __attribute__((weak))).

Symbol type (STT_*):

  • STT_FUNC: a function
  • STT_OBJECT: a data object (variable, array)
  • STT_SECTION: refers to a section itself
  • STT_FILE: source filename
  • STT_NOTYPE: unknown type (often assembly labels)
  • STT_TLS: thread-local storage

Symbol visibility (STV_*):

  • STV_DEFAULT: default visibility; global/weak symbols are exported.
  • STV_HIDDEN: not visible outside the shared object even if global. Set by __attribute__((visibility("hidden"))) or -fvisibility=hidden. Critical for shared library ABIs.
  • STV_PROTECTED: exported but cannot be overridden by a preloaded .so.

Two symbol tables in one file:

  1. .symtab: the full symbol table. Contains every symbol the compiler generated, including locals. Used by debuggers and the linker. Often stripped from production binaries with strip.
  2. .dynsym: the minimal dynamic symbol table. Only the symbols that the dynamic linker needs to resolve at runtime. Cannot be stripped — the dynamic linker reads it.

The nm tool is the fastest way to inspect symbols. Its one-letter codes (lowercase = local, uppercase = global) are a shorthand for the above: T/t = text (function), D/d = data, B/b = BSS, U = undefined (to be resolved from another object), W/w = weak.

$ cat symbols.c
#include <stdio.h>
static int hidden_counter = 0;   // STB_LOCAL
int global_counter = 0;          // STB_GLOBAL, STT_OBJECT, .bss
extern void other_func(void);    // STB_GLOBAL, undefined

void my_func(void) {             // STB_GLOBAL, STT_FUNC
    hidden_counter++;
    global_counter++;
    other_func();
}

$ gcc -c symbols.c -o symbols.o
$ nm symbols.o
0000000000000000 b hidden_counter   # lowercase b = local BSS
0000000000000004 B global_counter   # uppercase B = global BSS
                 U other_func       # U = undefined (external reference)
0000000000000000 T my_func          # T = global function in .text

$ readelf -s symbols.o | grep -v 'FILE\|SECTION'
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     7: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 hidden_counter
     8: 0000000000000000    22 FUNC    GLOBAL DEFAULT    1 my_func
     9: 0000000000000004     4 OBJECT  GLOBAL DEFAULT    4 global_counter
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND other_func

06Relocations: Wiring It All Together

When the compiler generates a .o file, it does not know the final address of symbols defined in other files. It emits a placeholder and records a relocation entry saying "fix this up when you know the real address."

A relocation entry (Elf64_Rela) has three key fields:

  • r_offset: where in the section to apply the fix (byte offset, or a virtual address)
  • r_info: encodes the symbol index (what symbol) and the relocation type (how to compute the fix)
  • r_addend: a constant to add to the computed value (for RELA-style relocations)

Relocation types are architecture-specific. Common x86-64 types:

  • R_X86_64_64: absolute 64-bit address. Used for data references.
  • R_X86_64_PC32: PC-relative 32-bit. Used for call and jmp within 2 GB.
  • R_X86_64_PLT32: PC-relative call through the Procedure Linkage Table (for dynamic linking).
  • R_X86_64_GOTPCRELX: access the Global Offset Table. Used for PIC data references.

The formula (simplified): *(location) = symbol_address + addend - location_address

This is the linker's core job: walk every relocation entry, look up the symbol in the symbol table, compute the formula, and patch the bytes.

Static vs dynamic relocations:

  • Static relocations (SHT_RELA in .o files) are resolved at link time by ld.
  • Dynamic relocations (SHT_RELA in .so/PIE) are left in the final binary and resolved at load time by ld.so.

The PLT and GOT (for dynamic linking): instead of patching every call site at load time, the linker routes external function calls through the Procedure Linkage Table (PLT) and data accesses through the Global Offset Table (GOT). The GOT holds absolute addresses filled by the dynamic linker; PLT stubs jump through GOT entries. This indirection is what makes shared libraries work without modifying the .text segment.

# See relocations in a .o file
$ gcc -c -O1 symbols.c -o symbols.o
$ readelf -r symbols.o

Relocation section '.rela.text' at offset 0x... contains 2 entries:
  Offset          Info           Type            Sym. Value  Sym. Name + Addend
000000000000000b  000a00000004 R_X86_64_PLT32    0000000000000000 other_func - 4
# ^^^^ at byte 0x0b in .text, patch a PLT-relative 32-bit offset to other_func

# After linking, check the PLT stubs
$ gcc symbols.c other.c -o prog
$ objdump -d -j .plt prog | head -20
Disassembly of section .plt:
0000000000001020 <other_func@plt>:
    1020:  ff 25 e2 2f 00 00    jmpq *0x2fe2(%rip)   # GOT entry
    1026:  68 00 00 00 00       push $0x0
    102b:  e9 e0 ff ff ff       jmp  1010 <_init+0x10>  # resolver

07objdump: Disassembly and Section Inspection

objdump is the Swiss Army knife of binary inspection. It can disassemble code, dump raw section contents, show relocation entries, and print dynamic linking information.

Essential flags:

FlagEffect
-dDisassemble executable sections
-DDisassemble ALL sections
-SIntermix source code with disassembly (requires -g)
-rShow relocation entries
-tPrint symbol table
-xPrint all headers
-j <section>Operate on a specific section only
--no-show-raw-insnCleaner output (no opcode bytes)
-M intelUse Intel syntax instead of AT&T (x86)
-CDemangle C++ names

Reading the disassembly output — each line has:

  1. Virtual address
  2. Raw hex bytes (the machine code)
  3. Mnemonic and operands

Arrows in AT&T syntax point from source to destination, which is the opposite of Intel syntax. The suffixes b/w/l/q mean byte/word/long(32-bit)/quad(64-bit). You will read a lot of disassembly while debugging kernel code, so training your eye now on toy examples pays off enormously.

Combining -S with DWARF: When code is compiled with -g, objdump -S inserts the C source lines above each block of generated instructions. This is one of the most educational ways to understand what the compiler does to your code.

-j .rodata is useful for finding string literals embedded in a binary — sometimes you can identify what a stripped binary does just from its strings.

$ cat add.c
int add(int a, int b) { return a + b; }
int main(void) { return add(2, 3); }

$ gcc -O0 -g add.c -o add
$ objdump -d -M intel -j .text --no-show-raw-insn add

Disassembly of section .text:

0000000000001129 <add>:
    1129:  push   rbp
    112a:  mov    rbp,rsp
    112d:  mov    DWORD PTR [rbp-0x4],edi   ; a
    1130:  mov    DWORD PTR [rbp-0x8],esi   ; b
    1133:  mov    edx,DWORD PTR [rbp-0x4]
    1136:  mov    eax,DWORD PTR [rbp-0x8]
    1139:  add    eax,edx
    113b:  pop    rbp
    113c:  ret

0000000000001140 <main>:
    1140:  push   rbp
    1141:  mov    rbp,rsp
    1144:  mov    esi,0x3                   ; b=3
    1149:  mov    edi,0x2                   ; a=2
    114e:  call   1129 <add>
    1153:  pop    rbp
    1154:  ret

08nm, size, strip, and objcopy

Beyond readelf and objdump, a few focused tools are indispensable:

nm — symbol lister Shows the symbol table in a compact format. Key use: quickly see what a .o or library exports, find undefined references, or check that an optimization (like dead-code stripping) actually removed a symbol. nm -u shows only undefined symbols; nm -D shows only dynamic symbols (from .dynsym); nm -S shows size next to each symbol.

size — section size summary Prints the size of .text, .data, and .bss (and total) for each object or binary. The output is deceptively simple but immediately useful: you can see how much your binary will consume in memory, whether your changes grew the code, and whether constants are going where you expect. The Berkeley format (size --format=Berkeley) is the default; SysV format (size --format=SysV) shows every section individually.

strip — symbol removal Production and embedded binaries are stripped to save space and reduce information leakage. strip by default removes .symtab and .strtab but preserves .dynsym (which cannot be stripped without breaking dynamic linking). Options:

  • strip --strip-debug: remove only debug sections (.debug_*, .symtab), keep .dynsym
  • strip --strip-unneeded: remove everything not needed at runtime (same as above, usually)
  • strip -s: the most aggressive; removes everything removable

The Linux kernel itself ships vmlinux with full symbols (for perf, crash, kgdb), but the compressed vmlinuz that goes to the bootloader is stripped.

objcopy — binary manipulation The most powerful of the bunch. It can:

  • Convert ELF to raw binary: objcopy -O binary vmlinux vmlinux.bin (used in kernel build for some architectures)
  • Remove or rename sections: objcopy --remove-section .comment
  • Add a section from a file: objcopy --add-section .firmware=fw.bin
  • Change section flags
  • Extract DWARF separately: objcopy --only-keep-debug vmlinux vmlinux.debug && strip --strip-debug vmlinux

This debug-split workflow (keep debug in a .debug file, strip the binary, link them via .gnu_debuglink) is exactly how distribution packages like linux-image-dbg work.

# nm examples
$ nm -S --size-sort /bin/ls | tail -5   # 5 largest symbols by size

# size examples
$ size add.o add
   text    data     bss     dec     hex filename
     22       0       0      22      16 add.o
   4208     600      16    4824    12d8 add

# strip workflow: keep debug, strip binary, link them
$ gcc -g -O1 add.c -o add
$ objcopy --only-keep-debug add add.debug
$ strip --strip-debug add
$ objcopy --add-gnu-debuglink=add.debug add
$ ls -lh add add.debug
-rwxr-xr-x add       (small, no debug)
-rwxr-xr-x add.debug (just debug info)
# gdb will automatically find add.debug via the .gnu_debuglink note

# objcopy: raw binary extraction (used in kernel Makefile for ARM/RISC-V)
$ objcopy -O binary -j .text add text.bin
$ xxd text.bin | head -3

09Sections vs Segments: The Crucial Distinction

This is the single most commonly confused concept in ELF, so let us be very precise.

Sections exist for the linker and debugger. They are the unit of layout control in linker scripts, the unit of relocation (.rela.text), and the thing tools like gdb and perf use to find code and data. An ELF .o file only has sections — no segments. The section-header table is optional in the final binary (the kernel ignores it during execve()), but present for tooling.

Segments (program headers) exist for the OS loader. The kernel's load_elf_binary() in fs/binfmt_elf.c loops over PT_LOAD program headers, calls elf_map() (ultimately mmap()), and builds the process's virtual address space. It never looks at section headers.

Analogy: Think of a warehouse. Sections are the labeled bins ("power tools", "hand tools", "hardware") — useful for finding things. Segments are the fire compartments — what matters for safety (execution protection). Many bins may live in one compartment; a compartment's properties (fire resistance = W/X flags) apply to everything inside it.

The mapping is many-to-one: A PT_LOAD segment typically contains multiple sections. The linker packs compatible sections together:

  • Sections with SHF_ALLOC | SHF_EXECINSTR → R+X PT_LOAD segment
  • Sections with SHF_ALLOC | SHF_WRITE → R+W PT_LOAD segment
  • Sections with SHF_ALLOC only (no write, no exec) → R-only PT_LOAD segment

Why does this matter? When you write a kernel linker script, you are explicitly choosing which sections go into which output section and what address they land at. The script also controls how sections become segments. Understanding this mapping is what separates someone who can read a linker script from someone who can write one.

# Visualize the section-to-segment mapping
$ readelf -l /bin/ls

# At the bottom:
 Section to Segment mapping:
  Segment Sections...
   00      (PHDR)
   01      .interp
   02      .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag
           .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r
           .rela.dyn .rela.plt
   03      .init .plt .plt.got .plt.sec .text .fini     # R+X segment
   04      .rodata .eh_frame_hdr .eh_frame              # R-only segment
   05      .init_array .fini_array .dynamic .got .data .bss  # R+W segment

# .text and .rodata are in DIFFERENT segments!
# .text is R+X, .rodata is R-only. The linker keeps them separate
# so .rodata pages are not accidentally made executable.

10vmlinux: The Kernel's ELF Image

vmlinux is the fully-linked, uncompressed ELF image of the Linux kernel. It is the direct output of ld invoked by the kernel's top-level Makefile, linked according to arch/<arch>/kernel/vmlinux.lds (a kernel linker script).

Why is it huge? vmlinux for a typical x86_64 defconfig build is around 30–60 MB. It contains:

  • All compiled kernel code (.text)
  • All kernel data (.data, .bss, .rodata)
  • Extensive DWARF debug information (.debug_*) if compiled with CONFIG_DEBUG_INFO=y
  • The symbol table (.symtab), which is used by /proc/kallsyms, perf, crash, and kgdb

What the kernel does with its own ELF: The compressed vmlinuz or bzImage that actually boots is not ELF — it is a self-decompressing executable with the compressed vmlinux payload inside. The decompressor unpacks vmlinux into memory at the address the linker script specified, then jumps to the kernel entry point. This address is encoded in e_entry.

Special kernel sections you will encounter in kernel development:

  • .init.text, .init.data: code and data only needed during boot. Freed after do_initcalls() completes — you have probably seen the message "Freeing unused kernel memory: XXXX K".
  • __ksymtab, __ksymtab_strings, __kcrctab: the kernel's exported symbol table (EXPORT_SYMBOL). Kernel modules are linked against this, not .symtab.
  • .altinstructions, .altinstr_replacement: alternative instructions patched at boot (e.g., for SMP vs UP, or for CPU feature detection like Spectre mitigations).
  • __param: module parameter descriptors (also used for CONFIG_ boot parameters).
  • .fixup, __ex_table: the exception table — maps addresses in kernel code to fixup handlers for when user-space pointer accesses fault.

/proc/kallsyms is generated from vmlinux's symbol table by the build system (specifically, scripts/kallsyms.c). It is embedded into the kernel image itself so tools can do symbolic stack traces without needing the vmlinux file at runtime.

# If you have a kernel build tree:
$ ls -lh vmlinux
-rwxr-xr-x 1 user user 54M vmlinux

$ readelf -h vmlinux | head -20
  Type:   EXEC (Executable file)   # or ET_DYN on some arm64 configs
  Machine: Advanced Micro Devices X86-64
  Entry point address: 0xffffffff81000000   # kernel virtual address!

# List kernel sections and sizes
$ size --format=SysV vmlinux | grep -E 'text|data|bss|init'
.text        : 14524416
.rodata      :  5423104
.data        :  2097152
.bss         :  3145728
.init.text   :   786432    # freed after boot!
.init.data   :   524288    # freed after boot!

# The exported symbol table (what modules link against)
$ readelf -S vmlinux | grep ksymtab
  [ ] __ksymtab          PROGBITS  ...
  [ ] __ksymtab_gpl      PROGBITS  ...

# Cross-check with /proc/kallsyms at runtime
$ sudo grep ' T do_sys_open' /proc/kallsyms
ffffffff811b6e10 T do_sys_open

11Build IDs and the .note Sections

A Build ID is a 160-bit SHA1 hash (or 128-bit MD5, or a random UUID) computed over the binary's code and data sections and embedded as an ELF note in .note.gnu.build-id. It is added by the linker with --build-id (GCC/clang enable this by default on most distributions).

Build IDs solve a critical problem: how do you match a stripped binary running in production with the correct .debug file? The answer: compare build IDs. Tools like gdb, crash, and eu-unstrip all use build IDs to automatically locate debug files under /usr/lib/debug/.build-id/<xx>/<rest>.debug.

In the kernel world, the build ID is used by perf to identify which vmlinux corresponds to a recorded profile. If you upgrade your kernel and try to analyze an old perf.data, perf checks the build ID embedded in the data file against vmlinux and warns you if they do not match.

Reading a build ID:

$ readelf -n /bin/ls | grep -A4 'GNU'
Displaying notes found in: .note.gnu.build-id
  Owner        Data size  Description
  GNU          0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring)
    Build ID: a8d2b4c1f3...<40 hex chars>

Other .note sections:

  • .note.ABI-tag: minimum Linux kernel version required to run the binary.
  • .note.gnu.property: encodes CPU feature requirements and security properties (e.g., IBT for Intel's Indirect Branch Tracking, SHSTK for Shadow Stack — both are CET hardening features). The kernel checks these at execve() to decide whether to enable CET for the process.

Notes are a general-purpose metadata mechanism: SHT_NOTE sections hold a vendor name, a type, and arbitrary data. They are small, ignored by the loader, but invaluable for tooling.

# Check build ID
$ readelf -n /bin/ls | grep 'Build ID'
    Build ID: e8e46bdc2e06f4b42cba0e3c7a3ab1d30bfbef81

# Find the corresponding debug file on a Debian/Ubuntu system
$ B=$(readelf -n /bin/ls | awk '/Build ID/{print $NF}')
$ ls /usr/lib/debug/.build-id/${B:0:2}/${B:2}.debug 2>/dev/null
/usr/lib/debug/.build-id/e8/e46bdc2e06f4b42cba0e3c7a3ab1d30bfbef81.debug

# Check GNU property notes (security features)
$ readelf -n /bin/ls | grep -A2 'GNU_PROPERTY'
  Displaying notes found in: .note.gnu.property
  Owner    Data size    Description
  GNU      0x00000020   NT_GNU_PROPERTY_TYPE_0
      Properties: x86 feature used: x86, x87, MMX, XMM, YMM
                  x86 ISA needed: x86-64-baseline
                  x86 feature needed: IBT, SHSTK

Key insights

  • ✦ELF is a dual-view format: the section-header table serves the linker and debugger; the program-header table serves the OS loader. They describe the same bytes from different perspectives, and both views are essential for different tasks.
  • ✦The kernel's execve() implementation reads only program headers — it maps PT_LOAD segments into virtual memory via mmap() and completely ignores section headers. Section headers survive in the binary purely for tooling.
  • ✦A relocation entry is a recipe: 'at this location, write the address of this symbol, computed by this formula.' The linker follows these recipes to wire together separately compiled object files.
  • ✦p_memsz > p_filesz in a PT_LOAD segment is how .bss works: zero-initialized data takes no file space, but the kernel zeroes the extra virtual pages. This is both elegant and efficient.
  • ✦vmlinux is a regular ELF file — everything you learn about ELF applies directly to it. The kernel's .init sections, exported symbol tables, exception tables, and alternative instructions are all just ELF sections with specific names the linker script and kernel startup code agree on.
  • ✦strip removes .symtab but cannot remove .dynsym (the dynamic linker needs it). This is why stripped shared libraries still work at runtime but cannot be inspected with nm.
  • ✦Build IDs are the thread connecting stripped production binaries to their debug files. Internalize the ~/.build-id/ lookup path — it will save you hours when debugging crashes.
  • ✦The PLT/GOT indirection is not a compiler quirk — it is the fundamental mechanism that allows shared libraries to exist: the .text segment is mapped read-only and shared between all processes using a library, while GOT entries (in the writable RW segment) hold the per-process resolved addresses.

Common pitfalls

  • ⚠Confusing sections and segments: saying 'the .text segment' when you mean the .text section (which lives inside an R+X PT_LOAD segment). They are different things. Sections have names; segments have protection flags.
  • ⚠Assuming a stripped binary has no symbols at all: it has .dynsym (dynamic symbols). `nm` on a stripped shared library still shows exported symbols; use `nm -D` to target .dynsym explicitly.
  • ⚠Forgetting that .bss takes no space in the file: if you look at a file offset for a .bss symbol, you will get garbage — the bytes are zero-filled in memory by the loader, not stored on disk. Always check sh_type == SHT_NOBITS.
  • ⚠Using objdump -d and expecting ALL code: objdump -d only disassembles sections flagged as executable. Use -D to disassemble everything (including data interpreted as instructions, which is intentionally confusing).
  • ⚠Reading ET_DYN as 'this must be a shared library': modern PIE executables are also ET_DYN. Check for a PT_INTERP segment (executables have it; pure shared libraries do not, or they have a different path).
  • ⚠Forgetting that relocation types are architecture-specific: R_X86_64_PC32 and R_AARCH64_CALL26 do completely different arithmetic. Never memorize specific type numbers — always look them up in the architecture's ABI supplement.
  • ⚠Expecting readelf -S on vmlinux to give manageable output: vmlinux has hundreds of sections. Pipe through grep or use -j to target a specific section.
  • ⚠Assuming the entry point (e_entry) leads to main(): it always points to _start (the C runtime startup) or the dynamic linker. main() is called several frames deep after the C runtime initializes .bss, calls constructors, and sets up argc/argv.

Practice

  1. 01
    medium

    Write a C program that reads an ELF file path from argv[1] and prints: the ELF class (32/64-bit), the machine type as a human-readable string, the entry point address, and the name and size of every section with the SHF_ALLOC flag set. Use only <elf.h>, <fcntl.h>, and <unistd.h> — no libelf. Map the file with mmap() and walk the struct arrays by hand.

  2. 02
    easy

    Compile a small C file with -ffunction-sections -fdata-sections, then link it twice: once normally, and once with --gc-sections. Use 'size' and 'nm' to show that --gc-sections removed the dead code. Write a Makefile that automates this comparison and prints a summary of bytes saved.

  3. 03
    hard

    Build a shared library (.so) from two object files. Use objdump -d to find a PLT stub in the final executable that calls a function from the .so. Then use readelf -r to find the corresponding GLOB_DAT or JUMP_SLOT relocation in the .so, and trace it to the GOT entry. Write a short explanation (a few paragraphs) connecting all three: PLT stub -> GOT entry -> relocation entry.

  4. 04
    hard

    If you have a kernel build, run 'readelf -S vmlinux | grep init' and list every .init.* section you find. For each one, look up what it contains in the kernel source (search for the section name in include/linux/init.h or arch/x86/include/asm/sections.h). Then calculate: how many bytes does the kernel free after boot by discarding these sections? Compare with the 'Freeing unused kernel memory' message in dmesg.

  5. 05
    easy

    Use objcopy to extract the raw bytes of the .text section from any compiled binary into a flat binary file. Then write a Python script that reads this file and prints each byte as a hex value, 16 bytes per line (like xxd). Verify your output matches 'xxd -l 64 text.bin'.

Resources

📄System V Application Binary Interface — AMD64 Architecture Supplement
docsfree

The primary specification for ELF on x86-64: relocation types, calling conventions, and TLS. Essential reference, not light reading.

✎Executable and Linkable Format (ELF) — Wikipedia
articlefree

Surprisingly comprehensive overview; the table of section types and program header types is a useful quick reference.

📄readelf(1), objdump(1), nm(1), strip(1) man pages
docsfree

man7.org hosts the binutils man pages. Read the full flags list for readelf and objdump — there are many useful options not covered in typical tutorials.

📕Linkers and Loaders
bookpaidJohn R. Levine, Morgan Kaufmann (1999). Out of print but PDFs circulate legally — the author made it freely available.

The definitive book on linkers, loaders, and object file formats. Chapter 3 covers ELF in detail. Old but accurate.

✎How To Write Shared Libraries — Ulrich Drepper
articlefree

Deep treatment of PIC, GOT, PLT, visibility attributes, and .so ABI design by a former glibc maintainer. Dense and technical — worth re-reading as your understanding grows.

✎The ELF Object File Format by Dissection — LWN
articlefree

A practical hands-on introduction to ELF with worked examples using readelf and objdump. Good companion to this module.

⚡Compiler Explorer (godbolt.org)
interactivefree

Indispensable for understanding what the compiler generates. Write C, see assembly. Use the 'Binary' mode to see actual object file output. Try -O0 vs -O2 side by side.

📄Linux kernel source: fs/binfmt_elf.c
docsfree

The kernel's actual ELF loader. Reading load_elf_binary() is the ground truth for how the kernel maps PT_LOAD segments and sets up the initial process stack.

📄Oracle Linker and Libraries Guide — ELF chapters
docsfree

Detailed, well-organized reference for ELF internals including relocation types, symbol lookup algorithm, and versioning. Written for Solaris but applies to Linux ELF with minor differences.

📕Learning Linux Binary Analysis
bookpaidRyan O'Neill (elfmaster), Packt Publishing (2016).

Hands-on book covering ELF from a security and reverse-engineering angle. Teaches you to read and modify ELF programmatically with real C code.

Checkpoints — you can now…

  • ✓You can run 'readelf -h', '-S', '-l', and '-s' on any ELF file and explain every field in the output without looking anything up.
  • ✓You can explain the exact difference between a section and a segment, why both exist in the same file, and which one the kernel reads during execve().
  • ✓You can trace a function call from a C source file through: compiler output (relocation entry in .o), linker output (PLT stub in executable), and dynamic linker resolution (GOT entry filled at runtime).
  • ✓You can use nm -u to find all undefined symbols in an object file, and explain what each of the single-letter nm codes means.
  • ✓You can strip a binary, split its debug info into a separate .debug file, and verify that gdb can still produce a symbolic stack trace using the .gnu_debuglink mechanism.
  • ✓You can open vmlinux with readelf and identify at least five kernel-specific sections (.init.text, __ksymtab, .altinstructions, __ex_table, .debug_info) and explain what each one is for.
  • ✓You can explain why p_memsz > p_filesz in a PT_LOAD segment is not an error — and what the kernel does with the gap.
  • ✓You can write a C program that parses an ELF file's section headers by hand using the structs in <elf.h>, without using libelf.