Before you start#
Read C pointers and lifetime. This chapter uses a C compiler and binutils (readelf, nm, objdump) for a safe host-only experiment. The kernel excerpts are upstream v6.12; the program you compile is synthetic. Examples assume a Linux host that uses ELF, while exact output depends on architecture, compiler and distribution defaults.
Learning goals#
Separate compilation, static linking, program loading and dynamic linking. Read an ELF header, program headers, symbol table and relocation without mistaking a section for a running mapping. Explain an ABI as a target-specific contract and trace the ELF loader's acceptance and failure paths.
From translation unit to running image#
A compiler translates a C translation unit into an object file containing machine instructions, data and metadata. Declarations describe names and types; definitions supply storage or code. The compiler need not know the final address of every external symbol. A relocation records a place requiring adjustment or resolution. A static linker combines input objects and libraries, resolves references where possible and writes an executable or shared object. An undefined symbol at this stage can be a deliberate dynamic dependency or a link error, depending on the command and output type.
ELF is a container format, not an instruction set. Its header identifies properties such as class, byte order, machine and object type. An ET_REL object is generally a relocatable linker input; ET_EXEC and ET_DYN have different intended roles, and position-independent executables commonly use ET_DYN. Do not classify a file solely by its extension. A section table organizes information useful to linkers and debugging tools, such as .text, .data, symbol tables and relocation sections. A loader instead uses program headers, including PT_LOAD, to determine loadable segments. A stripped executable can run without the ordinary static symbol table; a section is not necessarily a separately mapped region.
The 64-bit header layout is defined in v6.12 include/uapi/linux/elf.h (SPDX GPL-2.0 WITH Linux-syscall-note; the struct is shown in full):
typedef struct elf64_hdr {
unsigned char e_ident[EI_NIDENT]; /* ELF "magic number" */
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
Each member is naturally aligned for its size here, so the byte offsets follow from the member sizes without padding. The typedefs make Elf64_Half 16 bits, Elf64_Word 32 bits, and Elf64_Addr and Elf64_Off 64 bits. The struct does not record byte order, which e_ident must state, and it says nothing about 32-bit files; it describes a file format, not a kernel-internal structure. The following teaching model lays out an invented header with these offsets, and its values are illustrative only:
Synthetic teaching data, not a device capture.
Select a field to connect its position, bytes, and meaning.
44 bytes in file order. Offsets start at zero.
Selected: e_ident. magic, 64-bit class, little-endian, version, padding.
Read the byte table
| Offset | Field | Hex bytes | Interpretation |
|---|---|---|---|
| 0 | e_ident | 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 | magic, 64-bit class, little-endian, version, padding |
| 16 | e_type | 03 00 | ET_DYN (3): position-independent executable or shared object |
| 18 | e_machine | 3e 00 | EM_X86_64 (62) |
| 20 | e_version | 01 00 00 00 | ELF version 1 |
| 24 | e_entry | 40 10 00 00 00 00 00 00 | entry address 0x1040, relative to the load bias for ET_DYN |
| 32 | e_phoff | 40 00 00 00 00 00 00 00 | program header table at file offset 0x40 |
| 54 | e_phentsize | 38 00 | one program header is 56 bytes |
| 56 | e_phnum | 0d 00 | thirteen program headers in this invented file |
A dynamic executable can contain a PT_INTERP program header naming its dynamic loader. The kernel opens that interpreter and establishes the new process image; the interpreter then resolves dynamic dependencies and relocations before transferring control to the program. The actual library search, symbol interposition and security behavior depend on that userspace loader and its configuration, not just Linux. A static executable need not have PT_INTERP, although static linking does not imply that every possible runtime dependency vanishes.
Source walkthrough#
- ELF header
Check magic, type and architecture; reject invalid input.
- Program headers
Load segment descriptions and find an optional PT_INTERP.
- Interpreter and mappings
Open the named loader and prepare loadable segments as required.
- Userspace entry
Successful execution transfers to the appropriate entry; failures follow cleanup paths.
The following excerpt comes from load_elf_binary in v6.12 fs/binfmt_elf.c (GPL-2.0-only; surrounding code omitted):
retval = -ENOEXEC;
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
goto out;
The function starts with an executable-format error and checks the ELF magic bytes in the prepared binary header. On mismatch it follows the failure path instead of interpreting arbitrary data as a valid image. Subsequent checks in the same function reject unsupported object types or architectures and require suitable file operations. The checks are necessary, not a complete proof that a program is safe or runnable.
After the header checks the loader requests the program-header table (same file, omissions explicit):
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
if (!elf_phdata)
goto out;
Here bprm describes the proposed program and bprm->file is the opened executable; elf_phdata points to kernel-allocated program-header data on success. If loading fails, the function takes its error path. The subsequent loop examines headers including PT_INTERP and loadable segments. Its cleanup labels release temporary allocations and opened interpreter references on relevant paths. A failed execve before the old image is replaced can return an error to the caller; failures after the point of no return have different consequences. Do not assume every error leaves the original program able to continue. The generic execution framework is in v6.12 fs/exec.c.
Imagine a dynamically linked x86-64 executable with a valid header but PT_INTERP naming a loader missing from the target root filesystem. The executable file can exist and have execute permission, yet execution fails because its interpreter cannot be opened. A shell message such as “No such file or directory” is therefore not conclusive evidence that the named executable itself is absent. Inspect the interpreter path with readelf -l and check it in the same filesystem namespace. Another architecture's binary may instead fail the architecture check unless a configured interpreter or emulation mechanism handles it.
The source-side view of the interpreter is in the PT_INTERP branch of load_elf_binary in v6.12 fs/binfmt_elf.c (GPL-2.0-only; the loop that finds the header and the lines before the excerpt are omitted):
interpreter = open_exec(elf_interpreter);
kfree(elf_interpreter);
retval = PTR_ERR(interpreter);
if (IS_ERR(interpreter))
goto out_free_ph;
This branch runs for a PT_INTERP header. It opens the interpreter path stored in that segment and turns an open failure into the function's error through PTR_ERR. It is the source-side counterpart of the missing-interpreter case above: a bad interpreter path can fail the whole exec even though the program file exists. The excerpt does not show the later validation of the interpreter's header, nor whether the executable's other segments are valid. The following teaching model orders the steps for a dynamically linked executable with a valid interpreter:
- Caller before execsearch_binary_handler
execve(path, argv, envp)
- search_binary_handlerload_elf_binary
load_binary for ELF; ENOEXEC tries the next format
- load_elf_binaryDynamic loader
PT_INTERP: open and map the interpreter
- Dynamic loaderProgram image
map libraries, relocate, jump to program entry
Time runs downward. Dashed arrows show returns.
Read the text version
- Caller before exec → search_binary_handler: execve(path, argv, envp)
- search_binary_handler → load_elf_binary: load_binary for ELF; ENOEXEC tries the next format
- load_elf_binary → Dynamic loader: PT_INTERP: open and map the interpreter
- Dynamic loader → Program image: map libraries, relocate, jump to program entry
ABI and portability#
An application binary interface (ABI) is the set of binary-level rules that let separately built components work together. It can include the function calling convention, register use, stack alignment, data layout, system-call interface and object format. These rules are target-specific: the System V AMD64 ABI defines the x86-64 Linux function convention, while AArch64 has its own procedure-call standard. The x86-64 and arm64 ABIs differ; even on one CPU, different operating systems or ABIs can impose different rules. The Linux kernel's internal C interfaces and module interfaces are separate from the userspace ABI and are not promised stable across arbitrary upstream releases. A program's source compiling on two machines does not guarantee that one machine can run the other's binary.
A system-call ABI is also distinct from the C function-call ABI. A C library wrapper may translate arguments and errors; do not infer the system call number or register assignment from a generic C prototype. For x86-64 Linux, the System V AMD64 ABI specifies the userspace function ABI, while the Linux x86 entry documentation describes the kernel entry path. On this syscall ABI, argument four is in r10 (the syscall instruction uses rcx for the return address); the ordinary function ABI uses rcx for its fourth integer argument. The System V ELF gABI specifies generic ELF structures and loader concepts; the kernel ELF definitions define Linux constants and structures but are not a substitute for a processor-specific ABI specification.
Following one external call#
For the practice program, a compiler can emit a call whose target is not yet known and a relocation describing what the linker or loader must resolve. The exact relocation depends on the architecture and compiler options. The linker records the dependencies and constructs loadable segments. At execution, the kernel validates the image and starts the named interpreter when one is present. The interpreter maps needed libraries and resolves relocations according to its own policy; resolution may be eager or lazy. Finally control reaches the executable's entry and the C runtime arranges a call to main. A successful compilation establishes none of the later stages: a missing library, incompatible architecture or absent interpreter can stop this chain. Conversely, inspecting nm -u hello.o only shows unresolved names in the object; it does not prove that the final program cannot run.
Symbols, relocations, and visibility#
An ELF symbol table associates names with attributes such as binding, type, section and value. A local symbol is scoped differently from a global or weak symbol; visibility attributes influence whether and how a symbol is exposed for dynamic linking. An undefined symbol in a relocatable object is a request for a definition, not an instruction that the kernel must search every library. The static linker uses its input order, symbol rules and selected options to resolve or retain that reference. The dynamic symbol table is separate from the larger ordinary symbol table that a stripped executable may omit. nm -u on the object and readelf -Ws on the final image answer related but different questions.
A relocation identifies a place to be adjusted, the kind of adjustment and usually a symbol and/or addend. Its encoding is architecture-specific. For a dynamically linked executable, the Global Offset Table (GOT) can hold addresses or related entries used by position-independent code, and Procedure Linkage Table (PLT) stubs can mediate calls to dynamically resolved functions. Exact sequences depend on compiler, linker, architecture and options such as eager binding. A relocation or PLT entry is not by itself proof that lazy binding will be used in one run. Shared libraries, interposition, security mode and loader policy affect resolution.
A static link resolves more dependencies into the executable at build time, but static linking does not prove the program never reads configuration, plugins, data files or external services. A dynamically linked image needs a compatible interpreter and the libraries it requests in the process's filesystem view. readelf -d inspects the dynamic section without executing the program; running an untrusted executable to discover its dependencies is a different and potentially unsafe action. For this chapter, inspect only the small program you created yourself.
One segment, with actual arithmetic#
In v6.12 fs/binfmt_elf.c, elf_map computes values for a loadable segment using these statements (SPDX GPL-2.0-only; other mapping and error handling omitted):
unsigned long size = eppnt->p_filesz + ELF_PAGEOFFSET(eppnt->p_vaddr);
unsigned long off = eppnt->p_offset - ELF_PAGEOFFSET(eppnt->p_vaddr);
p_filesz is the file-backed byte count in this program header; the virtual-address page offset is added so that the file mapping includes the leading part of the containing page. The file offset is moved back by that same amount. The following source aligns the mapping address and size using the ELF alignment macros. These calculations depend on valid ELF headers and architecture alignment rules; they do not say that an arbitrary mismatched offset and virtual address is valid.
For a synthetic x86-64 example, assume a 4 KiB ELF minimum alignment, a fixed executable with no added load bias, p_vaddr = 0x401234, p_offset = 0x1234 and p_filesz = 0x500. Both addresses have offset 0x234 within their respective 4 KiB pages. The adjusted file offset is 0x1234 - 0x234 = 0x1000; the mapped virtual page begins at 0x401000. The unrounded mapping length is 0x500 + 0x234 = 0x734, rounded up to 0x1000. These numbers illustrate alignment; they are not output from the host program. If p_memsz exceeds p_filesz, the loader must also handle the memory-only portion and zeroing under its rules. A malformed header, failed mapping or unavailable interpreter can take an error path instead of reaching the program entry.
load_elf_binary reads and validates the header and program headers, finds an optional PT_INTERP, and sets up loadable segments. Its multiple cleanup labels release temporary program-header buffers and interpreter references according to the stage reached. A failure before the image transition can return an error; a later failure after exec's point of no return need not leave the old program able to resume. A successful elf_map for one segment is not proof that every later segment or interpreter mapping will succeed.
The first userspace instructions#
The kernel constructs an initial stack with argument strings, environment strings and an auxiliary vector. The auxiliary vector provides ABI-defined information to the runtime, such as values needed to locate program headers or determine platform properties; entries vary by architecture and environment. The dynamic loader may set up thread-local storage, resolve relocations and apply protections such as RELRO before application startup. Runtime startup code can invoke constructors before main. These steps belong to the actual loader and runtime implementation, not to a universal sequence that the kernel performs for every ELF file.
The ABI also specifies register and stack calling conventions for compiled functions, data sizes and alignment. Linux syscall entry has its own convention: on x86-64 its fourth argument uses a different register from the ordinary C function-call convention. The corresponding arm64 conventions differ. A stable userspace ABI is a commitment distinct from internal kernel structures, which can change with configuration or release; a module built against one internal layout should not be assumed compatible with another kernel just because both use ELF.
A missing symbol versus a missing interpreter#
The practice's -nostdlib link failure happens while constructing an image: the linker cannot satisfy references or construct the usual startup environment under the selected command. It is not the same event as an execve failure. A separate runtime counterexample is an executable script whose first line names an absent interpreter. The script can exist and be executable while the kernel returns an error for the interpreter lookup. In a disposable directory you can create such a script, attempt to run it as your ordinary user, and inspect the diagnostic; never create or replace files in system loader directories. A shell's wording does not by itself reveal whether the executable or its interpreter was absent.
Practice#
On a Linux host with cc and binutils, make a private directory. This example references an external library function so object-file relocation and a dynamic dependency may be visible; compiler and distribution settings change exact names and output.
lab_dir=$(mktemp -d) || exit 1
cd "$lab_dir" || exit 1
cat > hello.c <<'C'
#include <stdio.h>
int main(void) { return puts("hello ELF") < 0; }
C
cc -Wall -Wextra -c hello.c -o hello.o
cc -o hello hello.o
readelf -h hello.o
readelf -r hello.o
nm -u hello.o
readelf -h hello
readelf -l hello
readelf -d hello
objdump -d hello | sed -n '1,80p'
./hello
The object header normally reports REL, while the linked file commonly reports DYN for a PIE or EXEC for a non-PIE. nm -u normally lists an unresolved puts in the object; readelf -r shows relocation entries whose names and encodings are architecture-dependent. The linked executable's program headers commonly include INTERP and LOAD; a statically configured toolchain may differ. ./hello should print hello ELF and return zero if puts succeeds. readelf -d is inspection, not an execution of untrusted code.
For an error example, run cc -o broken hello.o -nostdlib and record the linker's diagnostics and status. An unresolved puts or missing entry-point warning demonstrates that a compiled object is not necessarily a self-contained executable; exact diagnostics and whether an output file is produced vary. Do not run the broken output. Record cc --version, readelf --version, architecture, observed header type, the interpreter if present, one relocation and the failure. Clean up only created files, then the empty directory:
rm -f hello.c hello.o hello broken
cd /tmp
rmdir "$lab_dir"
Knowledge check#
-
Why can an object contain an unresolved symbol and still compile successfully?
-
How do program headers differ from sections when an executable is loaded?
-
Why can an existing executable report a missing file when launched?
-
What does the initial
-ENOEXECcheck inload_elf_binaryestablish, and what does it not establish? -
In the worked segment example, why is the file mapping offset
0x1000rather than0x1234?
Answers#
-
Compilation can emit a relocation for a reference that a later link or dynamic loader resolves. Whether that is acceptable depends on the selected output and link options.
-
Sections organize link/debug information; program headers describe segments and other loader information. A loader uses the latter to establish mappings, not one mapping per section.
-
A
PT_INTERPloader can be missing in the process's filesystem view even when the executable exists. Inspect the interpreter and target namespace rather than guessing. -
A wrong ELF magic takes the failure path with an executable-format error. Passing that check does not prove correct architecture, valid headers, available interpreter or safe behavior.
-
The mapping starts at the file page boundary: subtracting the virtual page offset
0x234from0x1234gives0x1000. The mapped length includes that leading offset before rounding.
Checkpoint#
Keep the command output and a diagram from hello.c through object, link, ELF loader and interpreter. Pass when the diagram distinguishes sections from segments and identifies a concrete failure before program entry. If not, repeat readelf -h -l -r and compare each output to its stage. Progress is not certification.
Sources#
- v6.12 fs/binfmt_elf.c: ELF validation and program headers.
- v6.12 fs/exec.c: execution framework.
- v6.12 ELF definitions.
- System V AMD64 ABI, draft 0.99.6: function calling convention and platform ABI details.
- System V ELF gABI: generic ELF structures and object semantics.
- execve(2): documented interpreter-related execution failures.
- x86-64 entry documentation.