The generation of an executable binary from C source code involves multiple stages: preprocessing, compilation, assembly, and linking. Each phase transforms the program representation closer to machine code while resolving references and organizing memory layout.
Preprocessing handles directives such as macro expansion, conditional compilation blocks (#if/#endif), file inclusion (#include), and comment removal. The output is typically a .i translation unit.
Compilation proper performs lexical, syntactic, and semantic analysis, applies optimizations, and emits an assembly file (.s). The assembly step converts these human-readable instructions into machine code and creates a relocatable object file (.o). An object file contains an ELF header, various sections (text, data, rodata, bss), and a symbol table, but all addresses are still relative offsets.
Linking resolves symbols across object files and libraries. It can be static—where all referenced code is copied into the final executable at build time—or dynamic, where linking is deferred until load time or runtime.
Object File Anatomy
An ELF object file is structured into sections. The .text section stores executable instructions. .rodata holds read-only constants like string literals. Initialized global and static variables with non-zero values live in .data. Zero-initialized or uninitialized globals and statics belong to .bss, which occupies no actual space in the object file—only a size record in the section header.
Sections are aligned according to type-specific requirements. For instance, a 4-byte int demands alignment on a 4-byte boundary. Alignment constraints improve memory access efficiency since the CPU can fetch aligned data in fewer cycles. The ELF header itself occupies 64 bytes, with subsequent sections placed at aligned offsets.
Symbols and Linkage
Symbols represent variible or functon names. They carry a binding attribute: global symbols are visible across translation units, while local symbols (those declared static) are confined to a single file. A symbol definition allocates storage; a symbol reference merely uses an address that must be resolved. The symbol resolution process maps references to their defining location.
The linker classifies symbols into strong and weak. Initialized globals and function names are strong; uninitialized globals are weak. The rule is that multiple strong definitions are forbidden, a strong definition overrides a weak one, and among competing weak definitions one is chosen arbitrarily.
A symbol table in an object file records each symbol's name, type (function or object), binding, section index, and a value representing an offset relative to the section start. Before linking, these values are not absolute addresses.
Linker Operation
The linker merges sections with compatible permissions (e.g., .text and .rodata share read/execute) into segments, conserving page-level memory. It then performs symbol resolusion, combining symbol tables from all input objects and computing final virtual addresses. Symbol references in the code are patched (relocated) to match these definitive addresses.
Static Linking
When building programs split across multiple source files, each .c file compiles into a separate object file. Static linking collects all required .o files and static libraries (.a archives), resolving every symbol before producing a self-contained executable. The advantage is fast startup and portability; the drawback is duplication—multiple executables embedding the same library code inflate disk and memory usage.
Dynamic Linking
Dynamic linking defers binding until execution. Shared libraries (.so or .dll) can have their code segments mapped into multiple processes simultaneously, while each process maintains private copies of data segments that may be modified. To reconcile code sharing with relocation, Position-Independent Code (PIC) techniques rely on data structures like the Global Offset Table (GOT) and Procedure Linkage Table (PLT).
The GOT is an array of pointers residing in a writable data segment. At compile time its entries are placeholder values; the dynamic linker fills them with actual symbol addresses. Because patching the GOT modifies per-process data rather than shared code, relocation remains safe. The PLT implements lazy binding: initially the PLT stub calls the dynamic resolver, which fills the corresponding GOT slot and jumps to the target. Subsequent invocations use the cached GOT address directly.
// Example: lazy function resolution via PLT/GOT interaction
// Compiler emits a call to func@plt
call func@plt
...
func@plt:
jmp *func@GOTPCREL(%rip)
push relocation_index
jmp resolver
This deferred mechanism avoids the startup cost of relocating unused library functions while enabling runtime flexibility.