The ELF Format & Binary Tools
ELF is the universal language your compiler, linker, loader, and kernel all speak — master it and binaries become transparent.
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.
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
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:
- Relocatable object file (
.o): the output of the assembler/compiler for a single translation unit. Not yet executable — symbol references may be unresolved. - Executable file: produced by the linker. All symbols resolved, ready to
execve(). - 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:
| Field | Size | Meaning |
|---|---|---|
e_ident[16] | 16 bytes | Magic 7f E L F, class (32/64), endianness, version, OS/ABI |
e_type | 2 bytes | ET_EXEC(2), ET_DYN(3), ET_REL(1), ET_CORE(4) |
e_machine | 2 bytes | EM_X86_64(62), EM_AARCH64(183), EM_RISCV(243)… |
e_entry | 8 bytes | Virtual address of entry point (0 for .o files) |
e_phoff | 8 bytes | Offset in file to program-header table |
e_shoff | 8 bytes | Offset in file to section-header table |
e_phnum | 2 bytes | Number of program headers |
e_shnum | 2 bytes | Number of section headers |
e_shstrndx | 2 bytes | Index 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 tableSHT_RELA/SHT_REL: relocation entriesSHT_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 runtimeSHF_EXECINSTR(X): contains executable instructionsSHF_WRITE(W): writableSHF_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:
| Section | Flags | Contains |
|---|---|---|
.text | AX | compiled machine code |
.rodata | A | read-only data (string literals, const globals) |
.data | AW | initialized read-write globals |
.bss | AW | zero-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_frame | A | unwind 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:
| Field | Meaning |
|---|---|
p_type | PT_LOAD, PT_DYNAMIC, PT_INTERP, PT_NOTE, PT_PHDR, PT_GNU_STACK… |
p_offset | Offset in the file where this segment starts |
p_vaddr | Virtual address where it should be mapped |
p_filesz | Number of bytes in the file |
p_memsz | Number of bytes in memory (≥ p_filesz; extra bytes are zeroed — this is how .bss works) |
p_flags | PF_R(read), PF_W(write), PF_X(execute) |
p_align | Alignment (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:
- RX segment (read+execute): contains
.text,.rodata. Mapped as read-only executable pages. - 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 toe_entrydirectly.PT_DYNAMIC: points to the.dynamicsection which the dynamic linker uses to find needed libraries.PT_GNU_STACK: itsp_flagsfield tells the kernel whether the stack should be executable. If absent or markedPF_X, the stack is executable — a security hazard. Modern toolchains setPF_R|PF_Wonly.PT_GNU_RELRO: tells the dynamic linker which region tomprotect(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. Cstaticfunctions 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 functionSTT_OBJECT: a data object (variable, array)STT_SECTION: refers to a section itselfSTT_FILE: source filenameSTT_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:
.symtab: the full symbol table. Contains every symbol the compiler generated, including locals. Used by debuggers and the linker. Often stripped from production binaries withstrip..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 (forRELA-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 forcallandjmpwithin 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_RELAin.ofiles) are resolved at link time byld. - Dynamic relocations (
SHT_RELAin.so/PIE) are left in the final binary and resolved at load time byld.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:
| Flag | Effect |
|---|---|
-d | Disassemble executable sections |
-D | Disassemble ALL sections |
-S | Intermix source code with disassembly (requires -g) |
-r | Show relocation entries |
-t | Print symbol table |
-x | Print all headers |
-j <section> | Operate on a specific section only |
--no-show-raw-insn | Cleaner output (no opcode bytes) |
-M intel | Use Intel syntax instead of AT&T (x86) |
-C | Demangle C++ names |
Reading the disassembly output — each line has:
- Virtual address
- Raw hex bytes (the machine code)
- 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.dynsymstrip --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+XPT_LOADsegment - Sections with
SHF_ALLOC | SHF_WRITE→ R+WPT_LOADsegment - Sections with
SHF_ALLOConly (no write, no exec) → R-onlyPT_LOADsegment
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 withCONFIG_DEBUG_INFO=y - The symbol table (
.symtab), which is used by/proc/kallsyms,perf,crash, andkgdb
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 afterdo_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 forCONFIG_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.,IBTfor Intel's Indirect Branch Tracking,SHSTKfor Shadow Stack — both are CET hardening features). The kernel checks these atexecve()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
- 01medium
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.
- 02easy
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.
- 03hard
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.
- 04hard
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.
- 05easy
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
The primary specification for ELF on x86-64: relocation types, calling conventions, and TLS. Essential reference, not light reading.
Surprisingly comprehensive overview; the table of section types and program header types is a useful quick reference.
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.
The definitive book on linkers, loaders, and object file formats. Chapter 3 covers ELF in detail. Old but accurate.
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.
A practical hands-on introduction to ELF with worked examples using readelf and objdump. Good companion to this module.
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.
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.
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.
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.