Linkers & Loaders Deep
From source file to running process: understand how the linker stitches object files together, how the loader maps them into memory, and how the kernel uses linker scripts to build itself.
Overview
When you press Enter to run a program, a remarkable chain of events unfolds before your main() even gets its first instruction. Source code becomes object files, object files get stitched into executables by a linker, and executables get mapped into a live process by a loader. Every single step involves decisions that directly affect security, performance, and correctness — yet most programmers treat the whole thing as a black box.
This module tears the black box open. You will learn why undefined reference to 'foo' happens and exactly how the linker resolves it. You will understand why shared libraries can be loaded at any address without being recompiled (position-independent code, the GOT, and the PLT). You will trace the dynamic linker ld.so as it wires up your program at startup. And you will read the kind of linker scripts that the Linux kernel uses to place its own sections at precise virtual addresses.
None of this is incidental trivia. Kernel developers need to understand linker scripts to add a new section to the kernel image, to implement __init (code that is discarded after boot), or to place per-CPU data correctly. Security researchers need to understand the PLT/GOT to grasp how return-oriented programming and GOT-overwrite attacks work. Systems programmers need to understand relocation to write position-independent shellcode or bootloaders. This is foundational knowledge for everyone who wants to work at the lowest levels of the stack.
The good news: the underlying ideas are elegant and surprisingly simple once you see them clearly. Linking is just symbol-table lookup. Loading is just mmap. Relocation is just arithmetic on addresses. The PLT is just a trampoline. Let us make each of those statements precise.
Linux kernel development requires direct, hands-on engagement with the linker. The kernel is not linked by a standard toolchain invocation — it uses a hand-written vmlinux.lds linker script that places every section at a specific virtual address, exports symbols that the C code then uses (like _end and __init_begin), and arranges __init sections so the boot code can be freed after startup. Without understanding how linker scripts work, you cannot add a new kernel section, implement a new per-CPU data structure, or understand how the kernel's own module loader (insmod) performs relocations at runtime.
Beyond the kernel itself, the linking and loading infrastructure is the security boundary that every modern exploit must cross. Return-oriented programming chains through code already linked into the process. GOT overwrites redirect function pointers. LD_PRELOAD hijacks the dynamic linker's symbol search order. ASLR and RELRO are direct responses to these attacks. Understanding the machinery at the level of GOT entries and PLT stubs is the only way to reason rigorously about whether a mitigation actually works — and that reasoning is exactly what kernel security reviewers do every day.
Dynamic Linking: From Source to First Instruction
The full lifecycle of a dynamically linked call: compile, link, kernel exec, ld.so bootstrap, lazy PLT resolution on first call, and direct GOT dispatch on subsequent calls.
The concepts
01Object Files and the ELF Format
Before anything can be linked, the compiler must produce an object file. On Linux, object files use the ELF (Executable and Linkable Format) container. ELF is used for object files (.o), shared libraries (.so), executables, and core dumps — the same container, slightly different contents.
An ELF file starts with a fixed-size ELF header that declares the file type (ET_REL for relocatable object, ET_EXEC for executable, ET_DYN for shared object), the target architecture, and the offsets of the two tables that matter most:
-
Section header table — used by the linker. Sections are named logical groupings:
.text(machine code),.data(initialized globals),.bss(uninitialized globals, takes no file space),.rodata(read-only data like string literals),.symtab(symbol table),.strtab(string table for symbol names),.rel.textor.rela.text(relocation entries). -
Program header table — used by the loader. Segments are contiguous ranges of the file that get
mmap'd into process memory. A segment has a type (PT_LOAD,PT_DYNAMIC,PT_INTERP), file offset, virtual address, and flags (read/write/execute).
Think of sections as the compiler's view (fine-grained, named) and segments as the OS's view (coarse-grained, permissions-aligned). The linker combines many input sections into a few output segments.
You can inspect any ELF with standard tools: readelf -a, objdump -d, nm. These are the primary debugging tools for linking problems.
# Compile to object file (not yet an executable)
gcc -c hello.c -o hello.o
# Inspect sections
readelf -S hello.o
# Inspect symbol table
nm hello.o
# U = undefined (needs linking), T = defined in .text
# Output might show:
# U printf
# T main
02Static Linking and Symbol Resolution
Static linking is the process of combining multiple .o files (and optionally static library archives .a) into a single self-contained executable. The linker does this in conceptually two passes:
Pass 1 — Symbol resolution: Every .o file has a symbol table listing defined symbols (functions and variables it provides) and undefined symbols (names it references but does not define). The linker walks each input file, adds defined symbols to a global symbol table, and checks that every undefined symbol is eventually satisfied by some input. If not: undefined reference error.
A static library (.a file) is just an ar archive of .o files. The linker is lazy about archives: it only pulls in a .o from the archive if that .o defines a symbol that is currently unresolved. This means library order on the command line matters — a classic beginner pitfall.
Pass 2 — Relocation: Once all symbols are resolved, the linker knows the final virtual address of every section. It then patches all the address placeholders left by the compiler. For example, a call to printf in .text was compiled as a call 0x00000000 instruction with a relocation record saying "fill in the address of printf here". After static linking with libc, the linker knows printf's address and writes it in.
The result is a single executable where every address is a concrete virtual address. No runtime help is needed — the OS just maps the segments and jumps to the entry point.
Trade-off: The entire libc is baked into the binary. If ten programs statically link libc, ten copies of libc are in memory simultaneously. That is why dynamic linking exists.
/* a.c */
extern int square(int x); /* defined elsewhere */
int main(void) {
return square(3);
}
/* b.c */
int square(int x) { return x * x; }
# Static link: combine both objects
gcc -c a.c -o a.o
gcc -c b.c -o b.o
gcc a.o b.o -o prog # linker resolves square
# Make a static library and link against it
ar rcs libmath.a b.o
gcc a.o -L. -lmath -o prog # -lmath means libmath.a
# Force fully static (links everything, including libc)
gcc a.o b.o -static -o prog_static
file prog_static # "statically linked"
03Relocation: Address Arithmetic at Link Time
Relocation is the heart of linking. When the compiler emits a .o file, it does not yet know where in virtual memory the code will land. It uses placeholder addresses (usually zero) and records a relocation entry for each placeholder. Each relocation entry says: "at this offset within this section, apply this formula using the value of this symbol."
The two most common x86-64 relocation types are:
R_X86_64_PC32: relative call/jump. Formula:S + A - Pwhere S = symbol value, A = addend, P = address of the relocation site. Used forcall foowithin the same binary.R_X86_64_PLT32: like PC32 but goes through the PLT (for calls to shared library functions).R_X86_64_64: absolute 64-bit address. Formula:S + A. Used when a global variable stores a pointer to something.
You can see all relocation entries in an object file with objdump -r or readelf -r. After linking, those entries are consumed — the final executable has concrete addresses.
For position-independent executables and shared libraries, absolute relocations are a problem: the code could be loaded at any address, so you cannot bake in absolute addresses. The solution is that PIC code accesses everything through the Global Offset Table (GOT), which is itself relocated at load time by the dynamic linker. More on that shortly.
/* tiny.c */
extern void puts(const char *s);
void greet(void) { puts("hi"); }
# Compile to object (no linking)
gcc -c tiny.c -o tiny.o
# Show relocation entries
objdump -r tiny.o
# Output:
# RELOCATION RECORDS FOR [.text]:
# OFFSET TYPE VALUE
# 0000000b R_X86_64_PLT32 puts-0x4
#
# The call instruction at offset 0xb has a placeholder.
# The linker (or dynamic linker) will fill it in.
# After linking into a shared library:
gcc -shared -fPIC tiny.c -o libtiny.so
objdump -d libtiny.so # call goes through PLT stub
04Position-Independent Code (PIC) and the Global Offset Table (GOT)
A shared library like libc.so can be loaded at any virtual address — different processes might map it at different base addresses. If the library contained absolute addresses baked in at link time, they would be wrong every time the base address changed, and every page would need to be patched, eliminating all sharing benefits.
The solution is position-independent code (PIC), enabled by -fPIC. PIC has one central invariant: no absolute addresses in code or read-only data. Instead, all references to global variables and external functions go through an indirection table called the Global Offset Table (GOT).
The GOT is a section (.got and .got.plt) in the data segment of the library — data can be written at load time even if the code is shared. Each entry is a pointer-sized slot. When the dynamic linker maps the library, it fills in the GOT entries with the actual addresses for this process. Code then reads the GOT to find addresses:
// Conceptually, accessing extern int errno becomes:
// mov rax, [rip + got_offset_of_errno] ; load GOT entry
// mov eax, [rax] ; dereference
For function calls, there is an additional optimization layer: the PLT. Reading a GOT entry for a data reference has no overhead, but for calls, the indirection must be hidden from the caller's perspective. That is what the PLT handles.
The key insight: the GOT is in writable memory, so its entries can be filled at load time (or lazily at first call). Because only the GOT entries are per-process, not the code pages, all processes share the same .text pages of libc.so — a massive win for memory.
/* Check if a shared library is PIC */
readelf -d /lib/x86_64-linux-gnu/libc.so.6 | grep FLAGS
# Should show: SYMBOLIC, or at minimum no absolute text relocs
/* Compile WITH and WITHOUT PIC, see the difference */
gcc -O2 -S -fPIC ex.c -o ex_pic.s
gcc -O2 -S -fno-PIC ex.c -o ex_nopic.s
diff ex_pic.s ex_nopic.s
# PIC version uses RIP-relative addressing (@GOTPCREL) for globals
# Non-PIC version uses absolute mov instructions
05The Procedure Linkage Table (PLT) and Lazy Binding
If every call to an external function had to look up the GOT, the call sequence would be: load GOT entry → call through register. That works but loses the direct-call optimization and, historically, the dynamic linker needed to resolve all symbols before the program could start — even symbols that might never be called.
The PLT (Procedure Linkage Table) solves both problems with a beautiful trampoline scheme that also enables lazy binding: symbols are resolved only on first call.
Each external function foo gets a PLT stub foo@plt. A call in your code to foo is compiled as call foo@plt. The stub looks like this (x86-64):
foo@plt:
jmp *foo@got.plt(%rip) ; jump through GOT entry
push $index ; push relocation index
jmp plt0 ; call the dynamic linker resolver
plt0 is a special entry that calls into the dynamic linker (_dl_runtime_resolve). The dynamic linker looks up foo's real address, writes it into foo@got.plt, and jumps to foo. On every subsequent call, the GOT entry already contains the real address, so the first jmp in the stub jumps directly to foo — the resolver is never called again.
This is an open-coded cache: GOT starts as a pointer back to the PLT resolver, and gets overwritten with the real address after first use.
You can disable lazy binding with LD_BIND_NOW=1 or by linking with -z now (which also enables RELRO). All symbols are resolved at startup. This is slower to start but eliminates a class of security attacks (GOT overwrite via use-after-free can redirect function calls) and is required for async-signal-safe code.
/* Trace PLT/GOT in action */
# Disassemble the PLT of any dynamically linked binary
objdump -d -j .plt /bin/ls | head -60
# You'll see the trampoline stubs for each imported function
# See the GOT entries
objdump -R /bin/ls # dynamic relocations
# or
readelf -r /bin/ls | grep JUMP_SLOT
# JUMP_SLOT entries are the PLT's GOT entries
# Run with lazy binding disabled
LD_BIND_NOW=1 /bin/ls
# Use LD_DEBUG to watch the resolver work
LD_DEBUG=bindings /bin/ls 2>&1 | head -30
# Shows each symbol being bound on first call
06Dynamic Linking and the Runtime Loader (ld.so)
Dynamic linking defers part of the linking job to load time. Instead of copying library code into the executable, the linker records a dependency: DT_NEEDED: libc.so.6. When the OS executes such a program, the kernel does not jump to main() directly. Instead:
- The kernel reads the
PT_INTERPsegment of the ELF, which names the program interpreter — usually/lib64/ld-linux-x86-64.so.2(this IS the dynamic linker,ld.so). - The kernel maps both the executable and
ld.sointo the process's address space. - Execution starts at the dynamic linker's entry point, not
main. ld.soreads the executable's.dynamicsection, finds allDT_NEEDEDlibraries, opens each.sofile, maps it into memory (withmmap), and processes theirDT_NEEDEDrecursively (transitive dependencies).ld.soprocesses all relocations: fills in GOT entries for data symbols, sets up PLT stubs for lazy binding.- If the library or executable has a
.initsection orDT_INIT_ARRAY,ld.socalls those constructors. - Finally,
ld.sotransfers control to the executable's entry point (usually_start, which callsmain).
The library search order is: DT_RPATH embedded in the binary → LD_LIBRARY_PATH environment variable → /etc/ld.so.cache (built by ldconfig) → default paths (/lib, /usr/lib). LD_PRELOAD injects libraries before everything else — a powerful hook used by valgrind, memory allocator replacements, and unfortunately also by attackers.
ldd is a convenience wrapper that shows what ld.so would load for a given binary. Never run ldd on untrusted binaries — it actually executes the binary with LD_TRACE_LOADED_OBJECTS=1.
# See what dynamic libraries a binary needs
ldd /bin/bash
# See the interpreter stored in the binary
readelf -l /bin/bash | grep interpreter
# [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
# See the .dynamic section (the map ld.so reads)
readelf -d /bin/bash | head -30
# DT_NEEDED, DT_RPATH, DT_RUNPATH, DT_INIT, DT_FINI ...
# Trace every library event during startup
LD_DEBUG=libs /bin/bash -c exit 2>&1 | head -40
# Inject a custom library before everything else
# (classic use: replace malloc with jemalloc)
LD_PRELOAD=/path/to/libjemalloc.so /bin/program
07Linker Scripts: Controlling the Memory Layout
Everything described so far assumes the toolchain's default layout. But what if you are writing a kernel, a bootloader, or an embedded firmware that must place code at a specific address, must export symbols to C code, or must arrange sections in a precise order?
That is what linker scripts are for. A linker script is a small domain-specific language processed by ld (or lld) that gives you complete control over:
- The output sections: which input sections go where, in what order
- The virtual memory address (VMA) and load address (LMA) of each section
- Symbols defined in terms of addresses (e.g.,
_text_start = .;) - The entry point and segment layout
The Linux kernel's linker script is arch/x86/kernel/vmlinux.lds.S (a C-preprocessed .S file because it uses #include and #ifdef). It defines sections like __init_begin / __init_end (code freed after boot), __percpu_start (per-CPU data), and _text / _etext (start/end of kernel text). The C code then refers to these as extern char __init_begin[]; — the linker fills in the address.
The three most important linker script concepts:
. = address— set the location counter (current output address).SECTIONS { .text : { *(.text) } }— define an output section and specify which input sections feed it.symbol = .— define a symbol at the current address (useful for_end,__bss_start, etc.).
Understanding linker scripts is a strict prerequisite for kernel work.
/* minimal.lds — bare-metal linker script for a toy kernel */
OUTPUT_FORMAT("elf64-x86-64")
ENTRY(_start) /* entry point symbol */
SECTIONS
{
. = 0xFFFFFFFF80000000; /* kernel virtual base */
_text = .;
.text : {
*(.text.boot) /* boot code first */
*(.text .text.*) /* then all other .text */
}
_etext = .;
.rodata : { *(.rodata .rodata.*) }
/* Page-align the data segment */
. = ALIGN(4096);
_data = .;
.data : { *(.data .data.*) }
__bss_start = .;
.bss : {
*(.bss .bss.*)
*(COMMON) /* uninitialized C globals */
}
__bss_end = .;
_end = .;
}
/* Link with: ld -T minimal.lds kernel.o -o kernel.elf */
08Load Address vs Virtual Address (LMA vs VMA)
In most user-space programs the where the file is stored and where code runs are the same virtual address. But in embedded and kernel contexts they frequently differ:
- VMA (Virtual Memory Address): the address the code uses at runtime — where pointers point, what
PCholds. - LMA (Load Memory Address): where the section is actually stored in the binary/flash — where the bootloader or ROM programmer places it.
Classic example: a microcontroller stores code in flash at address 0x08000000 (LMA) but the initialized .data section must live in RAM at 0xE0000000 (VMA). The flash contains a copy of the initial .data values (at LMA), and the startup code copies them to RAM before main() runs.
In a linker script you express this with the AT keyword:
.data : AT(ADDR(.text) + SIZEOF(.text)) {
_data_start = .;
*(.data)
_data_end = .;
}
The left-hand side sets the VMA; AT(...) sets the LMA.
For the Linux kernel: the compressed kernel image (vmlinuz) is loaded by the bootloader at one physical address, decompresses itself to another, and then the decompressed kernel sets up its own virtual address space (VMA ≠ physical). The linker script must account for all of this.
/* Startup code that copies .data from LMA to VMA */
/* (typical in bare-metal / RTOS startup files) */
extern char _data_start[]; /* VMA start */
extern char _data_end[]; /* VMA end */
extern char _data_load[]; /* LMA start (in flash) */
void startup_copy_data(void) {
char *src = _data_load;
char *dst = _data_start;
while (dst < _data_end)
*dst++ = *src++;
}
/* And zero BSS: */
extern char __bss_start[], __bss_end[];
void startup_zero_bss(void) {
char *p = __bss_start;
while (p < __bss_end)
*p++ = 0;
}
09The __init and __exit Mechanism in the Linux Kernel
One of the most elegant uses of linker scripts in the real world is the Linux kernel's __init / __exit mechanism.
Functions marked __init are placed by the linker script into a special .init.text section. Data marked __initdata goes into .init.data. The linker script knows the start and end of these sections (__init_begin / __init_end). After the kernel has finished booting, it calls free_initmem(), which iterates through these pages and returns them to the page allocator — saving megabytes of RAM that would otherwise be wasted on code that never runs again.
// From linux/include/linux/init.h (simplified)
#define __init __attribute__((__section__(".init.text")))
#define __initdata __attribute__((__section__(".init.data")))
The call chain: start_kernel() → ... → kernel_init() → calls all .initcall functions → free_initmem() → the init sections are freed.
You will see messages like "Freeing unused kernel image (initmem) memory: 2348K" in boot logs. That is the linker script payoff.
__exit is the symmetric annotation for module cleanup code: if the code is compiled into the kernel (not as a loadable module), __exit functions are discarded at link time because they can never be called (a built-in module cannot be unloaded). The linker script simply does not include .exit.text in the main image.
/* How __init is used in a real kernel driver */
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
static int __init mydriver_init(void)
{
pr_info("mydriver: initializing\n");
/* ... hardware setup ... */
return 0;
/* This function's memory is freed after boot */
}
static void __exit mydriver_exit(void)
{
pr_info("mydriver: exiting\n");
/* If compiled in (not module), linker discards this */
}
module_init(mydriver_init);
module_exit(mydriver_exit);
/* module_init expands to place a pointer in .initcall.text */
/* ld.so equivalent: the kernel's own module loader reads */
/* these pointers and calls them in order during boot */
10Runtime Dynamic Loading: dlopen and Friends
Everything so far has been about loading decided at program start. But what if you want to load a plugin at runtime, without knowing its name at compile time? That is what the dlopen family of functions provides — the programmatic interface to the dynamic linker.
dlopen(path, flags)— load a shared library, return a handle.RTLD_LAZYdefers PLT resolution;RTLD_NOWresolves everything immediately.RTLD_GLOBALmakes the loaded library's symbols available to subsequently loaded libraries.dlsym(handle, name)— look up a symbol by name, return a void pointer (you cast it to the right function pointer or data pointer type).dlclose(handle)— decrement the reference count; if it reaches zero, unload the library and call its destructors.dlerror()— return the last error message.
The Linux kernel uses a very similar mechanism for loadable modules (insmod / modprobe): init_module() syscall passes the .ko ELF image to the kernel, which performs relocations against the already-loaded kernel symbol table, then calls the module's init function. The kernel's module loader is essentially a mini ld.so running inside the kernel.
Plugin architectures, scripting language extensions (Python C extensions, Lua), and the kernel module system all build on this foundation. Understanding dlopen demystifies all of them.
#include <stdio.h>
#include <dlfcn.h> /* -ldl on Linux */
int main(void) {
/* Load libm at runtime */
void *handle = dlopen("libm.so.6", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
/* Look up the 'sin' function by name */
typedef double (*sin_fn)(double);
dlerror(); /* clear any old error */
sin_fn my_sin = (sin_fn) dlsym(handle, "sin");
char *err = dlerror();
if (err) { fprintf(stderr, "dlsym: %s\n", err); return 1; }
printf("sin(3.14159) = %f\n", my_sin(3.14159));
dlclose(handle);
return 0;
}
/* gcc dlopen_demo.c -ldl -o dlopen_demo */
11Security Implications: RELRO, ASLR, and the Stack of Mitigations
The GOT/PLT machinery has a well-known attack surface: if an attacker can write an arbitrary 8-byte value anywhere in memory (e.g., via a heap overflow), they can overwrite a GOT entry to redirect the next call to free() or printf() to attacker-controlled code. This is the classic GOT overwrite attack.
The mitigations stack up in layers:
ASLR (Address Space Layout Randomization) — the OS randomizes the base addresses of the stack, heap, and mapped libraries at each execution. The attacker cannot know where the GOT is, making it harder (but not impossible with a leak) to target. Enabled at the OS level: echo 2 > /proc/sys/kernel/randomize_va_space.
PIE (Position-Independent Executable) — extends ASLR to the main executable itself. Compiled with -fPIE -pie. Without this, the executable's own .text and .data are at fixed addresses, giving attackers a stable foothold.
RELRO (Relocation Read-Only) — after the dynamic linker finishes processing relocations, mark the GOT read-only. Two levels:
Partial RELRO(-z relro): the.gotsection (non-PLT GOT, used for data symbol addresses) is made read-only after startup. The.got.plt(PLT's GOT, needed for lazy binding) remains writable.Full RELRO(-z relro -z now): forces eager binding (all PLT symbols resolved at startup), then marks all of the GOT read-only. This completely eliminates GOT-overwrite attacks but adds startup cost.
Check the protection level of any binary with: checksec --file=/bin/ls (from the pwntools / checksec package).
Modern distros ship with Full RELRO + PIE + ASLR enabled by default for most binaries. The kernel itself enables KASLR (Kernel ASLR) for its own image.
# Check security properties of a binary
checksec --file=/bin/ls
# Or use readelf directly:
readelf -l /bin/ls | grep GNU_RELRO
readelf -d /bin/ls | grep BIND_NOW
# Build with full hardening:
gcc -O2 -fPIE -pie -fstack-protector-strong \
-z relro -z now -D_FORTIFY_SOURCE=2 \
myprog.c -o myprog
# Inspect the result:
readelf -l myprog | grep -E 'LOAD|GNU'
# PT_GNU_RELRO segment will be present
# All GOT entries will be in a read-only page after startup
Key insights
- ✦The ELF format serves double duty: sections are the linker's view (named, fine-grained), segments are the loader's view (coarse-grained, permission-aligned). Learning to read both is the first skill.
- ✦Static linking is just symbol-table lookup plus address arithmetic (relocation). Every 'undefined reference' error is a failed lookup. Library order on the command line matters because of lazy archive extraction.
- ✦PIC works by indirection: code never contains absolute addresses. All global references go through the GOT (a writable table filled by the dynamic linker). This lets the same code pages be shared across processes.
- ✦The PLT trampoline enables lazy binding: the first call to an external function pays the resolution cost; subsequent calls jump directly through the GOT entry. This is an open-coded cache.
- ✦ld.so is itself a program. It runs before main(), maps all dependencies, fills the GOT, calls constructors, then jumps to the entry point. LD_PRELOAD and LD_DEBUG expose its inner workings.
- ✦Linker scripts give you byte-level control over the output binary's memory layout. The Linux kernel uses them to define __init sections (freed after boot), per-CPU data, and the kernel's own symbol table.
- ✦LMA vs VMA is the embedded/kernel distinction that trips beginners: LMA is where the bytes sit in the file/flash, VMA is where the code expects them to be at runtime. Startup code copies LMA-to-VMA for initialized data.
- ✦Full RELRO + PIE + ASLR together close the classic GOT-overwrite attack surface by making the GOT read-only after all relocations are processed. These mitigations are direct consequences of understanding the linking machinery.
Common pitfalls
- ⚠Library order matters for static linking: if libA depends on libB, write '-lA -lB', not '-lB -lA'. The linker pulls archive members only when an undefined symbol matches — if libA appears after libB, libA's unresolved references have no resolver.
- ⚠Running 'ldd' on an untrusted binary executes it. ldd sets LD_TRACE_LOADED_OBJECTS=1 and runs the binary through the dynamic linker. Use 'readelf -d binary | grep NEEDED' instead for untrusted inputs.
- ⚠Forgetting -fPIC when compiling code that will go into a shared library. You get a linker error about 'relocation against a read-only section' or silent correctness bugs on some architectures.
- ⚠Treating 'undefined symbol at runtime' (from dlopen/dlsym) as the same as 'undefined reference' at link time. They have different causes: the former is a missing symbol in a loaded library, the latter is a missing object file at link time.
- ⚠Defining symbols in linker scripts (e.g., '_end = .') and then accessing them in C as 'extern int _end' — the symbol's VALUE is the address, not the word at that address. Always declare them as 'extern char _end[]' or use '&_end'.
- ⚠Assuming that because two shared libraries are both linked into a process, they share global state. If the library was compiled with visibility=hidden or loaded with RTLD_LOCAL, its globals are private. Use RTLD_GLOBAL explicitly if sharing is needed.
- ⚠Mixing PIE and non-PIE objects. Linking a non-PIE .o into a PIE executable produces text relocations, which modern kernels refuse to execute (W^X policy).
- ⚠Forgetting that LD_PRELOAD is inherited by child processes. A security-sensitive setuid binary will ignore LD_PRELOAD (the dynamic linker strips it), but your own spawned children will not.
Practice
- 01easy
Write a two-file C program (main.c calls add() from math.c). Compile each to a .o file. Use 'nm' to show the undefined/defined symbols in each object. Then link them together and confirm all symbols are resolved. Finally, deliberately break the link by misordering a static library and explain the error message.
- 02medium
Write a tiny shared library 'libgreeter.so' that exports one function. Write a main program that loads it with dlopen/dlsym at runtime (NOT at link time — do not pass -lgreeter to gcc). Verify it works, then add a second version of the library with a different implementation and load the correct one based on a command-line argument.
- 03hard
Write a minimal linker script for a bare-metal 'kernel' that runs under QEMU (use 'qemu-system-x86_64 -kernel'). Your script must: place .text at 0x100000, define symbols '_text_start' and '_end', and align .bss to a 16-byte boundary. Write startup C code that uses these symbols to zero the BSS. Boot it under QEMU and confirm it reaches your main() by writing a magic value to a known address and reading it with QEMU's monitor.
- 04medium
Investigate RELRO in practice. Compile a program without RELRO, then with full RELRO (-z relro -z now). For each, use 'readelf -l' to find the PT_GNU_RELRO segment, and use 'cat /proc/<pid>/maps' (pause the process with a sleep in main) to check whether the GOT pages are marked read-only (r-- vs rw-). Write a one-paragraph explanation of what you observe.
- 05hard
Read the Linux kernel's arch/x86/kernel/vmlinux.lds.S (in a kernel source tree). Find: (1) where __init_begin and __init_end are defined, (2) one per-CPU section, (3) how the kernel ensures .text starts at a specific address. Write a short annotated summary of the three findings.
Resources
The canonical deep-dive blog series on the GNU linker internals, from ld's own maintainer. Twenty parts, covers everything in this module.
Out of print but freely available as PDF from the author — the most complete single reference on linking across platforms.
Written by the glibc maintainer. Authoritative on PIC, GOT, PLT, versioning, and symbol visibility. Dense but worth every page.
The normative ELF spec. Read the section header and symbol table chapters alongside readelf output on real binaries.
Documents every environment variable (LD_PRELOAD, LD_DEBUG, LD_LIBRARY_PATH), security rules for setuid, and search order.
The official GNU ld manual. Chapter 3 on linker scripts is essential for kernel work.
Practical, example-driven guide aimed at OS developers. Pairs perfectly with the QEMU bare-metal exercise.
Paste C code, switch between -fPIC and -fno-PIC, and immediately see the difference in generated assembly (RIP-relative vs absolute addressing).
The macro definitions behind every arch's vmlinux.lds.S. Reading this next to an arch-specific script makes the kernel linker layout click.
The best undergraduate-level treatment of linking. Chapter 7 alone justifies access to this book.
Checkpoints — you can now…
- ✓You can run 'readelf -S', 'readelf -l', 'nm', and 'objdump -r' on any ELF binary and explain what each output section and relocation entry means.
- ✓You can explain why library order matters in a static link command line, and reproduce the 'undefined reference' error by deliberately misordering libraries.
- ✓You can describe the GOT/PLT lazy-binding sequence step by step — from the first call through a PLT stub to the dynamic linker, through GOT patching, to the direct dispatch on the second call.
- ✓You can write a linker script that places .text at a specific address, defines _start and _end symbols, and aligns .bss — and link a bare-metal object file against it.
- ✓You can use LD_DEBUG=bindings, LD_PRELOAD, and /proc/PID/maps to observe the dynamic linker in action and read the GOT permission flags with and without RELRO.
- ✓You can navigate the Linux kernel source tree to locate where __init_begin/__init_end are defined in a linker script, and explain why the memory they describe is freed after boot.
- ✓You can implement a dlopen-based plugin loader in C, handling dlsym type-casting correctly and checking dlerror after each call.