How a Program Runs: Source to Execution
Master the five-stage pipeline that turns C source into a running process, and gain the mental model that underlies every systems-programming tool and kernel debugging technique you will ever use.
Overview
Every time you type gcc hello.c -o hello && ./hello, a small miracle happens. A file of human-readable text is transformed — through five distinct mechanical stages — into a stream of electrons toggling transistors at billions of times per second. Understanding this pipeline is not optional background knowledge for a systems programmer: it is the mental model everything else builds on. When a segfault happens, when a symbol is "undefined", when your binary is mysteriously large, when two libraries conflict — all of these are symptoms of one specific stage in this pipeline doing something you didn't expect.
This module traces the complete journey of a C program from the characters you type to the instructions executing on silicon. We will visit the preprocessor (which manipulates text), the compiler (which understands your program's meaning and emits assembly), the assembler (which translates mnemonic instructions to machine code), the linker (which stitches multiple compiled objects into one coherent binary), and finally the loader (which is part of the operating system and sets up a live process in memory). Each stage has a well-defined input, a well-defined output, and solves a specific sub-problem. Together they embody decades of accumulated computer-science wisdom.
By the end of this module you will be able to stop gcc after any stage and inspect its output, read a basic ELF binary by hand, explain why printf lives in a shared library rather than your binary, and describe what the kernel does in the first microseconds of a new process's life. These are the skills that make the difference between a programmer who just uses tools and one who understands them — a prerequisite for kernel work.
Along the way we will pick up essential vocabulary: translation units, object files, relocations, symbol tables, the PLT, the GOT, virtual address spaces, and the ELF format. Do not worry if these words are unfamiliar now; each will be defined precisely when we need it.
The Linux kernel is itself a C program — one that must be compiled, linked, and loaded. Every patch you will eventually submit goes through this exact pipeline. When the kernel uses __attribute__((section(".init.text"))) to place initialization code in a special ELF section that is discarded after boot, or when it uses EXPORT_SYMBOL to expose a function to loadable modules, or when a kernel module is insmod-ed at runtime — all of these mechanisms are direct applications of the linker and loader concepts covered here. You cannot read vmlinux, understand kernel module loading, or make sense of a kernel oops backtrace without knowing what an ELF symbol table is and how virtual addresses are assigned.
More broadly, the compiler pipeline is where correctness is enforced and where performance is shaped. Kernel contributors regularly read compiler-generated assembly (via objdump or Compiler Explorer) to verify that an optimization barrier worked, that a likely()/unlikely() hint was honored, or that a struct layout is what they intended. The debugging tools you will use constantly — gdb, addr2line, nm, readelf, perf — are all consumers of information produced at specific pipeline stages. Knowing the pipeline means knowing what information exists and where, which transforms debugging from guesswork into systematic inquiry.
Source to Execution: The Five-Stage Pipeline
Each arrow is a file transformation or OS action. Stages above the dashed line are tools you invoke (directly or via GCC); below are OS/runtime mechanisms. Every tool in your career maps to exactly one box here.
The concepts
01The Big Picture: Five Stages, One Pipeline
The transformation from source to execution is not one monolithic step — it is a pipeline of five distinct tools, each with a precise contract.
Source (.c) → [Preprocessor] → Expanded Source (.i)
→ [Compiler] → Assembly (.s)
→ [Assembler] → Object file (.o)
→ [Linker] → Executable or library
→ [Loader/OS] → Live process in memory
When you invoke gcc hello.c -o hello, GCC is actually orchestrating all four toolchain stages (preprocessor through linker) for you. You can watch this happen:
gcc -v hello.c -o hello # prints every sub-command gcc runs
Or stop at each stage explicitly:
gcc -E hello.c -o hello.i # stop after preprocessor
gcc -S hello.i -o hello.s # stop after compiler
gcc -c hello.s -o hello.o # stop after assembler
gcc hello.o -o hello # run linker
./hello # loader runs when you execute
Each intermediate file is real and inspectable. The value of this pipeline model is that every error message, every symbol, every tool in your future career maps to exactly one stage. A #include file not found error is a preprocessor error. An undefined reference to 'foo' is a linker error. A segfault at address 0x0 at runtime is a loader/process issue. Knowing the stage tells you exactly where to look.
/* hello.c — the running example for this entire module */
#include <stdio.h>
#define GREETING "Hello, kernel world!"
int main(void) {
puts(GREETING);
return 0;
}
02Stage 1: The Preprocessor — Text Manipulation Before Compilation
The C preprocessor (cpp) is a text transformation engine that runs before the compiler sees your code. It is completely unaware of C syntax or types — it manipulates tokens and lines of text according to simple rules.
The three main jobs of the preprocessor are:
1. File inclusion (#include): When it sees #include <stdio.h>, it literally finds that header file on the include path and pastes its entire contents into the source stream, recursively. The output of gcc -E hello.c will be thousands of lines — mostly from system headers — followed by your few lines of code. This is called a translation unit: one .c file plus all its included headers, after preprocessing. The compiler works on translation units, not raw .c files.
2. Macro expansion (#define): #define GREETING "Hello, kernel world!" creates a text substitution rule. Every occurrence of the token GREETING is replaced with "Hello, kernel world!" before the compiler sees anything. Macros can take arguments and are widely (and dangerously) used in the Linux kernel for things like container_of, BUILD_BUG_ON, and every syscall wrapper.
3. Conditional compilation (#ifdef, #if, #endif): This is how the same source file compiles differently on different architectures or with different feature flags. The kernel uses this extensively — #ifdef CONFIG_SMP wraps code that only matters on multi-processor systems.
The output of the preprocessor is called a .i file (or .ii for C++). It is plain C — no macros, no #includes, just expanded source code with # line directives that tell the compiler where each piece of code originally came from (so error messages cite the right file and line).
Inspect it yourself:
gcc -E hello.c | grep -v '^#' | tail -20
This strips the line markers and shows you the last 20 lines — your main function, with GREETING expanded.
/* What the preprocessor produces from hello.c (simplified):
Thousands of lines from stdio.h declarations, then: */
extern int puts(const char *s); /* from stdio.h, simplified */
int main(void) {
puts("Hello, kernel world!"); /* macro expanded */
return 0;
}
03Stage 2: The Compiler — From C to Assembly
The compiler is the most intellectually complex stage. It takes the expanded C source (the translation unit) and produces assembly language — a human-readable, architecture-specific textual representation of machine instructions.
Inside the compiler, several sub-passes happen:
Parsing & semantic analysis: The source is parsed into an Abstract Syntax Tree (AST). Types are checked, scopes are resolved, function signatures are verified. This is where you get errors like error: implicit declaration of function 'foo'.
Intermediate Representation (IR): GCC converts the AST to an internal IR (called GIMPLE, then RTL). LLVM/Clang uses LLVM IR. The IR is architecture-independent — this is what enables the same compiler frontend to target x86, ARM, RISC-V, etc.
Optimization passes: The compiler runs dozens of optimization passes on the IR: dead code elimination, constant folding, inlining, loop unrolling, and more. The -O0 / -O2 / -Os flags control how aggressively this happens. These passes are why the assembly GCC produces often looks nothing like your C code.
Code generation: The optimized IR is translated into the target architecture's assembly language. For x86-64, the output is AT&T syntax assembly by default.
Run gcc -S -O1 hello.c -o hello.s and read hello.s. You will see:
- The function label
main:(or_main:on macOS) - The string
"Hello, kernel world!"in a.rodataor.section __TEXTsection - A call to
puts(orprintf) - A
retinstruction
The most powerful tool for this stage is Compiler Explorer at godbolt.org — paste any C and instantly see the assembly for any compiler/architecture/optimization level side by side. Kernel developers use this constantly to verify that the compiler understood what they wrote.
# x86-64 assembly gcc -S -O1 produces for hello.c
# (Linux, AT&T syntax)
.section .rodata
.LC0:
.string "Hello, kernel world!"
.text
.globl main
main:
subq $8, %rsp # align stack
leaq .LC0(%rip), %rdi # arg1 = address of string
call puts@PLT # call puts via PLT
xorl %eax, %eax # return 0
addq $8, %rsp
ret
04Stage 3: The Assembler — From Assembly to Object Files
The assembler (GNU as, part of binutils) translates the text assembly into a binary object file (.o). This is the first stage that produces actual machine code — the raw bytes the CPU will execute.
But an object file is not yet a complete program. It is a module — a collection of code and data with unresolved references. The key insight: when the assembler encodes call puts@PLT, it does not know the address of puts. It cannot — puts lives in the C library, which is a separate file entirely. So the assembler leaves a relocation entry: a placeholder in the binary that says "at offset X in this function, fill in the address of the symbol puts when you know it".
An object file is structured according to the ELF format (Executable and Linkable Format) on Linux. An ELF file is divided into sections:
.text— executable machine code.data— initialized global/static variables.bss— uninitialized global/static variables (takes no space in the file; the OS zeros this at load time).rodata— read-only data (string literals,constglobals).symtab— the symbol table: names and addresses/offsets of all defined and referenced symbols.rel.text(or.rela.text) — relocation table: list of places that need addresses filled in
You can inspect all of this:
gcc -c hello.c -o hello.o
readelf -a hello.o # dump everything
nm hello.o # show symbol table
objdump -d hello.o # disassemble .text
In the nm output, U puts means puts is Undefined (referenced but not defined here). T main means main is defined in the .Text section. Learning to read nm and readelf output is a fundamental kernel debugging skill.
# After: gcc -c hello.c -o hello.o
# nm hello.o output:
#
# U puts <- undefined: needs the linker
# 0000000000000000 T main <- defined in .text at offset 0
# readelf -S hello.o shows sections:
# [Nr] Name Type Address Off Size
# [ 1] .text PROGBITS 00000000 000040 00001b <- machine code
# [ 4] .rodata PROGBITS 00000000 00005b 000015 <- "Hello, kernel world!"
# [ 7] .symtab SYMTAB 00000000 ... <- symbol table
# [ 8] .rela.text RELA 00000000 ... <- relocation entries
05Stage 4: The Linker — Combining Object Files into an Executable
The linker (ld, invoked by GCC) takes one or more object files (and libraries) and produces a final executable (or a shared library). It does two fundamental things:
1. Symbol resolution: Every U (undefined) symbol in every input object file must be matched to a definition somewhere. The linker searches all input .o files and libraries until every undefined symbol is satisfied — or it fails with undefined reference to 'foo'. In our example, puts is satisfied by libc.
2. Relocation: Now that every symbol has a final address in the output file, the linker goes back and fills in all the placeholder relocation entries in the machine code. The call puts@PLT instruction, which had a zero or garbage target address in the .o file, now gets the correct address (or, for dynamic linking, the correct PLT stub address).
The linker also assigns virtual addresses to every section: it decides that .text starts at (say) 0x401000, .rodata at 0x402000, .data at 0x403000, etc. These virtual addresses are what you see in objdump -d ./hello.
Static vs. Dynamic Linking (overview — deserves its own module):
-
Static linking: The linker copies the machine code of every library function your program calls directly into your executable. The result is a self-contained binary with no runtime dependencies. Larger binary, but no version conflicts. Used by BusyBox, Go binaries by default, and the Linux kernel itself for built-in code.
gcc -static hello.c -o hello_static ls -lh hello_static # ~800KB — all of libc is inside -
Dynamic linking (default): The linker does not copy library code. Instead, it records a dependency on
libc.so.6(the shared library) and generates stub code (the PLT — Procedure Linkage Table). At runtime, the dynamic linker resolves addresses. The binary is small, and many processes can share one copy oflibc.so.6in memory.ldd ./hello # shows: libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
The linker is controlled by a linker script, a text file that describes the memory layout of the output. The Linux kernel has a highly customized linker script (arch/x86/kernel/vmlinux.lds.S) that places kernel sections at specific physical addresses and controls the order of init/exit sections. This is advanced territory, but understanding the linker is the prerequisite.
/* Two-file example showing why the linker exists */
/* greet.h */
void greet(const char *name);
/* greet.c */
#include <stdio.h>
#include "greet.h"
void greet(const char *name) { printf("Hello, %s!\n", name); }
/* main.c */
#include "greet.h"
int main(void) { greet("kernel"); return 0; }
/* Build step by step:
gcc -c greet.c -o greet.o # greet.o defines 'greet', needs 'printf'
gcc -c main.c -o main.o # main.o needs 'greet'
gcc greet.o main.o -o prog # linker: greet.o satisfies main.o's 'greet'
# libc satisfies greet.o's 'printf'
*/
06The ELF Format: The Universal Container
ELF (Executable and Linkable Format) is the binary file format used by Linux for executables, object files, shared libraries, and even core dumps. Understanding ELF is essential for kernel work because the kernel is an ELF binary (vmlinux), kernel modules are ELF .so files, and many kernel debugging tools operate directly on ELF data.
An ELF file has three levels of structure:
ELF Header (64 bytes for 64-bit ELF): Contains magic number (\x7fELF), architecture, pointer width (32/64-bit), endianness, the type of ELF (relocatable / executable / shared library / core), entry point address, and offsets to the section header table and program header table.
Sections (used by the linker and debugger): Logical divisions of the file content — .text, .data, .bss, .symtab, .debug_info, etc. Sections exist for the benefit of static tooling. The section header table is optional at runtime.
Segments / Program Headers (used by the loader): Groups of sections that must be loaded into memory. A LOAD segment says "map these bytes from file offset X to virtual address Y with permissions Z". A DYNAMIC segment carries information for the dynamic linker. Segments are the runtime view of the binary; the loader only needs program headers.
The analogy: sections are like chapters in a book (for the editor/linker), segments are like shipping boxes (for the loader/delivery).
Key commands:
readelf -h ./hello # ELF header
readelf -S ./hello # section headers
readelf -l ./hello # program headers (segments)
readelf -s ./hello # symbol table
objdump -d ./hello # disassemble .text
file ./hello # quick type identification
For kernel development, readelf -S vmlinux | grep init reveals the special initialization sections. nm vmlinux | grep ' T ' shows all kernel function symbols — this is how addr2line translates an instruction pointer from an oops to a function name.
# readelf -h ./hello (abbreviated output)
# ELF Header:
# Magic: 7f 45 4c 46 02 01 01 00 ... (\x7fELF, 64-bit, LE, Linux)
# Type: EXEC (Executable file) or DYN if PIE
# Machine: Advanced Micro Devices X86-64
# Entry point address: 0x401060 <- _start, NOT main!
# Start of program headers: 64 bytes into file
# Number of program headers: 13
#
# readelf -l ./hello (LOAD segments, abbreviated)
# LOAD 0x000000 0x400000 0x400000 0x000568 R <- read-only: ELF header+rodata
# LOAD 0x001000 0x401000 0x401000 0x0001e5 R E <- read+exec: .text
# LOAD 0x002000 0x402000 0x402000 0x000158 R <- read-only: .rodata etc
# LOAD 0x002e10 0x403e10 0x403e10 0x000220 RW <- read+write: .data .bss
07Static vs Dynamic Linking: A Deeper Look
This is one of the most practically important concepts in systems programming, affecting security, performance, deployment, and kernel module design.
Static Linking in detail: The linker resolves all symbols at link time by copying referenced code from archive libraries (.a files, which are just ar-format collections of .o files). The result is a completely self-contained binary. No shared library is needed at runtime. The Linux kernel, BusyBox, and many embedded programs use static linking for exactly this reason: no dependency hell, predictable deployment.
Cost: every statically-linked binary gets its own copy of libc code. If you have 100 processes each statically linked to libc, you have 100 copies of libc in RAM. Also, security patches to libc require relinking every binary.
Dynamic Linking in detail: The linker marks symbols as "to be resolved at runtime" and records which shared libraries are needed (in the .dynamic section). It generates:
- PLT (Procedure Linkage Table): A small stub for each external function. The first call to
putsjumps to a PLT stub that invokes the dynamic linker to resolve the real address, then patches itself so subsequent calls go directly toputs. This is called lazy binding and is the default. - GOT (Global Offset Table): A table of addresses, initially pointing back into the PLT stubs. After lazy resolution, GOT entries hold the real library function addresses.
The dynamic linker (ld-linux-x86-64.so.2, or ld.so) is itself a shared library that the kernel loads first, before your program runs, to set up this machinery.
Position-Independent Code (PIC/PIE): Shared libraries must work regardless of where in virtual memory they are loaded (ASLR randomizes this for security). Code compiled with -fPIC accesses global data via the GOT (using PC-relative addressing) rather than absolute addresses. Executables compiled with -fPIE (pie = position-independent executable) get ASLR protection too — the default on modern distros.
# See that dynamic linker is the true entry point:
readelf -l ./hello | grep 'interpreter'
# [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
# See PLT stubs in disassembly:
objdump -d ./hello | grep -A5 '<puts@plt>'
/* Demonstrate GOT/PLT indirection */
/* Compile: gcc -O1 -fno-pie -no-pie got_demo.c -o got_demo */
#include <stdio.h>
int main(void) {
/* First call: PLT stub -> dynamic linker -> resolves puts -> patches GOT */
puts("first"); /* slow path (lazy binding) */
/* Second call: PLT stub -> GOT -> puts directly */
puts("second"); /* fast path */
return 0;
}
/* Inspect with:
objdump -d got_demo | grep -A10 'plt'
objdump -R got_demo # show relocation entries (GOT slots)
*/
08Stage 5: The Loader — From File to Live Process
The loader is not a separate program you invoke — it is part of the kernel itself, plus the dynamic linker. When you run ./hello, the shell calls the execve() system call. This is where the kernel takes over.
Here is what happens inside execve(), step by step:
1. Permission and format check: The kernel reads the first few bytes of the file to identify its type. For ELF executables, it sees the magic bytes \x7fELF. For scripts, it sees #! (shebang). If neither, execve returns ENOEXEC.
2. Load program headers: The kernel reads the ELF program header table and maps each LOAD segment into the new process's virtual address space using mmap(). The read+exec segment (.text) is mapped with PROT_READ|PROT_EXEC. The read+write segment (.data+.bss) is mapped with PROT_READ|PROT_WRITE.
3. Set up the stack: The kernel allocates a stack (typically starting near the top of user virtual address space), places the argc, argv[], envp[] arrays there, and sets up the auxiliary vector (AT_* entries) — metadata like the program's own ELF header address, the hardware randomness seed, and the size of a page.
4. Hand off to the dynamic linker: For dynamically linked executables, the kernel does not jump to main. It jumps to the dynamic linker (ld.so). The dynamic linker reads the .dynamic section, finds all required shared libraries (NEEDED entries), maps them into memory, runs their initializers, resolves all GOT entries (either all at once with LD_BIND_NOW, or lazily), then transfers control to the ELF entry point of the actual program.
5. C runtime startup (_start → main): The entry point is not main — it is _start, provided by the C runtime (crt1.o, linked in by GCC automatically). _start calls __libc_start_main, which sets up the C environment (calls global constructors, sets up atexit handlers), then calls your main(). When main returns, __libc_start_main calls exit().
You can confirm the real entry point:
readelf -h ./hello | grep Entry
# Entry point address: 0x401060 <- _start, not main
objdump -d ./hello | grep -A20 '<_start>'
For statically linked binaries, steps 3-4 are skipped (no dynamic linker needed) and the kernel jumps directly to _start.
/* What _start looks like conceptually (simplified libc internals) */
/* You never write this — it's in crt1.o, linked automatically */
void _start(void) {
/* rdi = argc, rsi = argv, rdx = envp (passed by kernel on stack) */
int argc = /* from stack */;
char **argv = /* from stack */;
char **envp = /* from stack */;
/* libc_start_main sets up C runtime, calls main, handles exit */
__libc_start_main(main, argc, argv,
__libc_csu_init, /* constructor runner */
__libc_csu_fini, /* destructor runner */
0);
/* NOT REACHED */
__builtin_unreachable();
}
/* After main() returns:
__libc_start_main -> exit(main_return_value)
exit() -> calls atexit handlers -> _exit() -> sys_exit_group syscall */
09Virtual Address Space: The Process's View of Memory
After the loader finishes, your process lives in a virtual address space — a private illusion that it owns all of memory from address 0 to (on x86-64) address 0x7fffffffffff. This illusion is maintained by the CPU's Memory Management Unit (MMU) and the kernel's page tables, but that is the topic of a later module.
For now, understand the conventional layout of a Linux process's virtual address space (lower addresses at bottom):
High addresses (~0x7fffffffffff)
┌──────────────────────┐
│ stack │ ← grows downward; argc/argv/envp here
│ (grows down ↓) │
├──────────────────────┤
│ [random gap, ASLR] │
├──────────────────────┤
│ shared libraries │ ← libc.so, ld.so, etc. mmap'd here
├──────────────────────┤
│ heap │ ← grows upward via brk()/mmap()
├──────────────────────┤
│ .bss │ ← zeroed by OS
│ .data │ ← initialized from ELF LOAD segment
│ .text / .rodata │ ← mapped from ELF LOAD segment
└──────────────────────┘
Low addresses (~0x400000 for non-PIE, random for PIE)
You can see the live memory map of any running process:
./hello & # run in background
cat /proc/$!/maps # show virtual memory areas
Each line in /proc/PID/maps corresponds to one mmap() call made by the kernel or dynamic linker: the address range, permissions (r-xp, rw-p), the file or [heap]/[stack]/[vdso] label. Reading this output is a critical debugging skill — it tells you exactly what is mapped where and with what permissions, which is the first thing to check when debugging a crash or a security vulnerability.
/* /proc/PID/maps output for ./hello (PIE build, abbreviated)
Fields: address range | perms | offset | dev | inode | path
55a3d2c00000-55a3d2c01000 r--p 00000000 fd:01 1234567 /home/user/hello
55a3d2c01000-55a3d2c02000 r-xp 00001000 fd:01 1234567 /home/user/hello <- .text
55a3d2c02000-55a3d2c03000 r--p 00002000 fd:01 1234567 /home/user/hello <- .rodata
55a3d2c03000-55a3d2c04000 rw-p 00003000 fd:01 1234567 /home/user/hello <- .data
7f8a4b200000-7f8a4b400000 r--p 00000000 fd:01 9999999 /lib/x86_64-linux-gnu/libc.so.6
7f8a4b400000-7f8a4b598000 r-xp 00200000 fd:01 9999999 /lib/x86_64-linux-gnu/libc.so.6
7fff9e200000-7fff9e221000 rw-p 00000000 00:00 0 [stack]
7fff9e3f4000-7fff9e3f8000 r--p 00000000 00:00 0 [vvar]
7fff9e3f8000-7fff9e3fa000 r-xp 00000000 00:00 0 [vdso]
*/
10Translation Units, Compilation Units, and Why They Matter
A translation unit (TU) is the unit of work for the compiler: one .c file plus all the headers it #includes, after preprocessing. The compiler sees only one translation unit at a time. This has profound consequences:
Each TU is compiled independently: The compiler does not see other .c files. When main.c calls greet() (defined in greet.c), the compiler trusts the declaration in greet.h and generates a relocation entry. It does not check whether greet.c actually defines greet with a compatible signature. That check is the linker's job — and the linker does it only at the binary level (matching symbol names), not at the type level. This is why mismatched function signatures between TUs can cause silent bugs that gcc won't catch without Link-Time Optimization (LTO) or careful header discipline.
Incremental builds: Because TUs are independent, if you change only greet.c, you only need to recompile greet.c and re-link. Build systems (Make, CMake, Ninja) exploit this by tracking which .c files changed. The kernel's Makefile system does exactly this.
static keyword and TU scope: A function or variable declared static at file scope has internal linkage — it is invisible outside its translation unit. The linker will never see it as a symbol to be resolved. This is how you hide implementation details. The Linux kernel uses static functions extensively to prevent unintended coupling between subsystems. Only functions marked EXPORT_SYMBOL() (which adds them to the kernel's own symbol table) are visible to loadable modules.
extern and inline: extern declares that a symbol is defined in another TU. The compiler emits a relocation; the linker resolves it. inline functions (in C99+) are a hint to the compiler to expand the function body at the call site, potentially eliminating the call overhead and enabling better optimization — but the rules around inline linkage are subtle and trip up many beginners.
/* Demonstrating internal vs external linkage */
/* In module_a.c */
static int secret = 42; /* internal linkage: only module_a.c sees this */
static void helper(void) { } /* internal linkage */
int public_value = 100; /* external linkage: linker exports this symbol */
void public_api(void) { /* external linkage */
helper(); /* fine: same TU */
secret++; /* fine: same TU */
}
/* In module_b.c */
extern int public_value; /* ok: linker will find it in module_a.o */
/* extern int secret; */ /* would compile but LINK ERROR: not exported */
/* extern void helper(void); */ /* same: link error */
11Debug Information: The Bridge Between Binary and Source
When you compile with gcc -g, the compiler adds DWARF debug information to the ELF binary — extra sections (.debug_info, .debug_line, .debug_frame, etc.) that map machine code addresses back to source file names, line numbers, variable names, and types.
This information is what makes tools like gdb, addr2line, perf report, and the kernel's own oops decoder work. Without it, a crash at address 0x401123 is opaque. With DWARF, addr2line -e ./hello 0x401123 might tell you hello.c:6 — the exact source line.
For the Linux kernel, debug info matters enormously:
CONFIG_DEBUG_INFOadds DWARF tovmlinux- When the kernel oopses and prints a backtrace of instruction pointers,
scripts/decode_stacktrace.shusesaddr2lineonvmlinuxto turn those addresses intofile:line:functionentries gdb vmlinux(with a live kernel via KGDB, or a crash dump) uses DWARF to show variables, call stacks, and data structures- The pahole tool uses DWARF to analyze kernel data structure layout and generate BTF (BPF Type Format), which is how modern eBPF programs do safe type-aware kernel introspection
Key insight: debug info is metadata about the binary, not part of the program. Stripping it (strip ./hello) removes the DWARF sections and makes the binary smaller without changing its behavior. Production kernel images are often stripped; separate vmlinux-dbg packages contain the debug info.
gcc -g hello.c -o hello_dbg
ls -lh hello hello_dbg # hello_dbg is much larger
readelf -S hello_dbg | grep debug # see all .debug_* sections
addr2line -e hello_dbg -f 0x$(nm hello_dbg | grep ' T main' | cut -d' ' -f1)
# outputs: main\nhello.c:7
# Practical: find which kernel line caused an oops address
# (Hypothetical kernel oops shows: [<ffffffff81234567>] some_function+0x42/0x80)
# On a system with kernel debug symbols:
addr2line -e /usr/lib/debug/boot/vmlinux-$(uname -r) \
-f ffffffff81234567
# Output:
# some_function
# kernel/some_subsystem.c:142
Key insights
- ✦GCC is a pipeline orchestrator, not a monolithic translator — understanding each stage gives you a precise mental map for every error message and every debugging tool you will ever encounter.
- ✦The compiler only sees one translation unit at a time, which is why type mismatches across .c files can silently compile and only blow up at runtime — header discipline and LTO exist precisely to close this gap.
- ✦An object file's machine code is incomplete: relocation entries are IOUs that the linker must cash in. 'Undefined reference' means an IOU could not be paid.
- ✦The ELF format has two views — sections (for linker/debugger tooling) and segments/program headers (for the loader) — and confusing them is a common source of confusion when reading documentation.
- ✦Your program's entry point is _start, not main. The kernel jumps to _start; the C runtime sets up the environment and then calls main. This explains why global constructors run before main and atexit handlers run after it returns.
- ✦Dynamic linking trades binary size and memory efficiency for runtime complexity (PLT/GOT indirection, ASLR, LD_PRELOAD tricks, and potential version conflicts) — knowing when to use each is a real engineering decision.
- ✦Debug information (DWARF) is the bridge that makes every high-level debugging tool work — it is metadata, not code, and understanding it demystifies how gdb, perf, addr2line, and the kernel oops decoder actually function.
- ✦The Linux kernel is itself an ELF binary with a custom linker script — every concept in this module (sections, symbols, relocations, entry points) applies directly to the kernel source tree.
Common pitfalls
- ⚠Confusing 'compilation errors' (from the compiler stage) with 'linker errors' (from the linker stage). 'implicit declaration of function' is a compiler warning about a missing header; 'undefined reference' is a linker error about a missing .o or -l flag. They require completely different fixes.
- ⚠Forgetting that -g and -O2 can be used together, but optimized code has inlined functions, reordered instructions, and eliminated variables — the debugger output will be confusing. Use -O0 -g for debugging, -O2 -g for profiling.
- ⚠Thinking that #include 'includes the library'. It only includes the header (declarations). The actual library code is linked via -l flags or found automatically by GCC. Many beginners add the header and wonder why 'undefined reference to sqrt' still appears — the answer is -lm.
- ⚠Treating static and dynamic libraries interchangeably. Static libraries (.a) are archives of .o files — the linker copies only what it needs. Shared libraries (.so) are mapped at runtime — the entire library is loaded even if you use one function. Order of -l flags matters for static libraries (right-to-left dependency resolution).
- ⚠Assuming the binary layout matches the source layout. With optimizations, the compiler reorders code, inlines functions, and eliminates branches. The assembly you see in objdump may look nothing like your C. This is expected and correct — use Compiler Explorer at -O0 to match intuition, then -O2 to see real production code.
- ⚠Not understanding ASLR and PIE. On modern systems, executables are compiled as position-independent executables (PIE) by default, so their load address changes every run. Hardcoded addresses from a previous run will be wrong. Always use /proc/PID/maps or gdb to find the current load address.
Practice
- 01easy
Stop GCC after each stage and inspect the output. Write a simple C program with one #define macro, one #include, and one function call to a standard library function. Run: gcc -E, gcc -S, gcc -c, then link. For each output file, answer: What format is it? What is its size? What tool do you use to read it? Can you find the macro expansion in the .i file? Can you find the relocation entry for the standard library call in the .o file using readelf?
- 02easy
Build a two-file C project: one file defines three functions, one file calls them. Compile them separately to .o files, then link. Now deliberately introduce a symbol mismatch: change the function signature in one file only (not the header). What happens? Does it fail at compile time or link time? Why? Then try removing the -c flag and compiling both files together in one gcc invocation — does the error change?
- 03medium
Compare static and dynamic linking. Take any C program that uses math functions (e.g., sqrt, sin). Build it both ways: gcc prog.c -lm -o prog_dynamic and gcc -static prog.c -lm -o prog_static. Compare sizes with ls -lh. Run ldd on both. Use readelf -d prog_dynamic | grep NEEDED to find library dependencies. Use /proc/PID/maps (run in background with &) to see the memory layout of both. Write a one-paragraph explanation of the trade-offs you observe.
- 04medium
Examine the PLT and GOT in a dynamically linked binary. Compile hello.c with gcc -O1 -fno-pie -no-pie hello.c -o hello_nopie. Run: objdump -d hello_nopie | grep -A15 '@plt'. Then run: objdump -R hello_nopie to see the GOT relocation entries. Describe in your own words: what does the PLT stub do? What is in the GOT before the program runs? What is in the GOT after the first call to puts? Use GDB to verify: set a breakpoint at main, then examine the GOT entry for puts before and after calling it.
- 05hard
Write a custom linker script that places a special named section in the output binary. Create a C program with a function annotated __attribute__((section(".myfuncs"))) and a variable annotated __attribute__((section(".mydata"))). Write a minimal linker script (using OUTPUT_FORMAT, SECTIONS, and explicit placement) or just use the -T flag. Verify with readelf -S that your section appears at the expected address. This is the basis for how the Linux kernel places __init functions in .init.text.
- 06hard
Trace the execve system call for a simple program using strace. Run: strace -e trace=execve,mmap,openat,read ./hello 2>&1 | head -60. Identify: which files are opened by the dynamic linker (ld.so), which mmap calls correspond to loading the ELF segments (cross-reference with readelf -l ./hello), and where the stack is set up. Write a step-by-step narrative of what happens between pressing Enter and your main() running.
Resources
Indispensable: paste C and instantly see assembly for any compiler, architecture, and optimization level side by side. Used daily by kernel developers.
The definitive reference on exactly this topic: object file formats, symbol resolution, relocation, static and dynamic linking, and the loader. Dense but authoritative.
The gold standard undergraduate systems textbook. Chapter 7 on linking is among the clearest explanations of ELF, symbol tables, and relocations ever written.
The primary spec for ELF. Read sections 1-4 to understand the header, sections, and program headers at the binary level.
A masterful LWN deep-dive into exactly what the Linux kernel does inside execve() for ELF binaries, with kernel source references.
Friendly, thorough walkthrough of everything confusing about linking in C — static vs dynamic, library order, symbol visibility. A classic reference.
The official ABI spec defining calling conventions, register usage, ELF section names, and the PLT/GOT layout for x86-64 Linux. Chapters 3 and 5 are essential for understanding what the linker and loader produce.
Documents LD_PRELOAD, LD_BIND_NOW, lazy binding, rpath, and all the environment variables that control dynamic linking behavior at runtime.
Free textbook that bridges the loader's work (mapping segments) to the OS virtual memory system — the conceptual follow-on to this module.
Once you understand userspace linking, this book shows how the same ideas (symbol tables, relocations, loading) apply inside the kernel itself.
Checkpoints — you can now…
- ✓You can stop GCC after any of the four tool stages (-E, -S, -c, link) and identify the output file format and its contents using file, readelf, nm, and objdump.
- ✓You can explain, without notes, why 'undefined reference to foo' is a linker error and not a compiler error, and what information in the .o file triggers it.
- ✓You can read the output of 'readelf -S' on an ELF binary and identify at least five sections by name, explain their purpose, and know whether each is present in a .o file, an executable, or both.
- ✓You can explain the difference between ELF sections and ELF segments, and state which view is used by the linker vs which is used by the OS loader.
- ✓You can describe the PLT/GOT mechanism in dynamic linking: what each table contains before and after the first call to a shared library function, and what 'lazy binding' means.
- ✓You can use 'cat /proc/PID/maps' on a running process, identify each mapped region, and match it to a segment in the ELF program header table.
- ✓You can explain why the entry point in an ELF executable is _start rather than main, and describe the sequence of events that takes place between _start and the first line of your main() function.
- ✓You can use addr2line on a binary compiled with -g to convert a raw instruction pointer address to a source file and line number, and explain why this works (i.e., what DWARF information enables it).