Shared Libraries & Dynamic Linking: Project-Based Mastery
Goal: Build a deep, working mental model of how shared libraries are built, discovered, loaded, and bound at runtime across Linux, macOS, and Windows. You will be able to design stable C APIs, debug loader failures, and reason about symbol resolution, search paths, and ABI compatibility under real production constraints. By the end, you will have built a plugin system, a dependency visualizer, a function interceptor, a hot-reload server, a cross-platform library, and a minimal dynamic linker. The outcome is practical: you can ship and maintain shared libraries that load reliably, perform well, and remain compatible across releases.
Introduction
Shared libraries (also called dynamic libraries or DSOs) are compiled code modules that can be loaded at runtime and shared across multiple processes. Dynamic linking is the runtime process of resolving symbols from those libraries and binding them into a running program.
What you will build (by the end of this guide):
- A plugin-based audio effects processor with hot-loaded effect modules
- A dependency visualizer that explains exactly why a binary fails to start
- A function interceptor using
LD_PRELOAD-style interposition - A hot-reload development server that swaps code without restarting
- A cross-platform shared library with a stable C ABI
- A minimal dynamic linker that loads an ELF executable and runs
main()
Scope (what is included):
- How the compilation and linking pipeline creates DSOs
- ELF dynamic linking internals (and how Mach-O/PE map the same concepts)
- Position-independent code (PIC), relocations, GOT/PLT, and lazy binding
- Loader search paths, rpath/runpath, and platform differences
- ABI stability, symbol visibility, and versioning strategies
- Runtime loading APIs (
dlopen,dlsym,LoadLibrary) and plugin design
Out of scope (for this guide):
- High-level package managers (apt, brew, conda) and language-specific build systems
- Full OS loader implementation (beyond the minimal loader project)
- Compiler theory or full linker implementation (beyond linker interactions)
The Big Picture (Mental Model)
Build Time Run Time
┌──────────────┐ ┌──────────────┐ ┌─────────┐ ┌───────────────┐
│ Source .c │→→ │ Object .o │→→ │ Linker │→→│ Executable │
└──────────────┘ └──────────────┘ └─────────┘ └──────┬────────┘
| | | |
| | | v
| | ┌──────────────┐ ┌────────────┐
| | │ libfoo.so │ │ Loader │
| | └──────┬───────┘ │ (ld.so) │
| | | └────┬───────┘
v v v v
Preprocess Assemble DT_NEEDED Map segments
Compile Relocations SONAME Relocate symbols
Jump into main()
Key Terms You Will See Everywhere
- DSO / Shared Object: A compiled library meant to be loaded at runtime (e.g.,
.so,.dylib,.dll). - Dynamic Linker / Loader: The runtime component that loads DSOs, relocates them, and resolves symbols.
- PIC: Position-Independent Code; required for DSOs to load at arbitrary addresses.
- GOT / PLT: Indirection tables used to resolve data/function addresses at runtime.
How to Use This Guide
- Read the Theory Primer first. It is a mini-book that builds your mental model.
- Pick a project path based on your goals (see Recommended Learning Paths).
- Build incrementally: each project assumes you completed (or at least understood) the prior one.
- Use the checklists (Definition of Done) to validate that your implementation is correct.
- Treat failures as learning: a loader error is a concept you now need to understand.
Prerequisites & Background
Essential Prerequisites (Must Have)
Programming Skills:
- Proficiency in C (pointers, structs, function pointers, file I/O)
- Comfort with the command line (compiling, debugging, inspection tools)
- Understanding of processes, virtual memory, and basic OS concepts
Systems Fundamentals:
- Object files, symbols, and basic linking
- Basic ELF awareness (
readelf,objdump,nm,ldd) - Basic build tools (
make,gcc/clang,cmake)
Recommended Reading:
- Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron - Ch. 7 (Linking)
- The Linux Programming Interface by Kerrisk - Ch. 41-42 (Shared Libraries)
Helpful But Not Required
Advanced Topics (learn during projects):
- ABI stability, symbol versioning
- Loader security and search path hardening
- Cross-platform loader differences (Mach-O/PE)
Self-Assessment Questions
- Can you compile a shared library with
-fPIC -sharedand link against it? - Do you understand what a symbol table is and why unresolved symbols occur?
- Can you interpret
lddorreadelf -doutput? - Have you used
gdborlldbto inspect a crashing program? - Can you explain the difference between static and dynamic linking?
Development Environment Setup
Required Tools:
- Linux (Ubuntu 22.04+ recommended) or macOS 13+ or Windows 10+ (WSL2 recommended)
gccorclangmake,cmakereadelf,objdump,nm,ldd(orotoolon macOS,dumpbinon Windows)
Recommended Tools:
gdborlldbstrace/dtruss(macOS) for loader debugginggraphvizfor dependency graphs
Testing Your Setup:
$ gcc --version
$ readelf --version
$ ldd --version
Time Investment
- Simple projects (Project 3): Weekend (4-8 hours)
- Moderate projects (Projects 1-2, 5): 1-2 weeks each
- Complex projects (Projects 4, 6): 2-4+ weeks each
- Total sprint: 8-12 weeks if doing everything thoroughly
Important Reality Check
Shared libraries are deceptively deep. Expect to revisit the primer multiple times. The learning curve looks like:
- First pass: Get something working, even if you don’t fully understand it.
- Second pass: Trace execution paths and understand loader behavior.
- Third pass: Debug edge cases (ABI breaks, symbol collisions, search path issues).
- Fourth pass: Optimize and harden for real-world reliability.
Big Picture / Mental Model
The system is a pipeline that splits into two phases: build time and run time. The build phase prepares metadata so the loader can finish the job at runtime.
BUILD PHASE RUN PHASE
┌────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ source.c │→→│ object.o │→→│ executable │ │ loader │
└────────────┘ └──────────────┘ │ + .so deps │ │ (ld.so) │
└──────┬─────────┘ └──────┬───────┘
│ │
v v
DT_NEEDED, SONAME Map segments, relocate
RPATH/RUNPATH Resolve symbols, call init
Theory Primer (Read This Before Coding)
This section is the mini-book. Each chapter builds a core mental model that you will apply directly in the projects.
Chapter 1: Compilation & Linking Pipeline
Fundamentals
Compilation translates source code into machine code. The compiler produces object files (.o) that contain machine instructions plus metadata: symbol tables, relocation entries, and section headers. The linker then combines object files and libraries to produce an executable, resolving symbol references and performing relocations. In static linking, this resolution happens completely at build time. In dynamic linking, the linker emits metadata (like DT_NEEDED entries) that tells the runtime loader which shared libraries to load later. Understanding this pipeline is the foundation for all shared library work.
Deep Dive into the Concept
The compilation pipeline is a series of transformations with carefully defined boundaries. The preprocessor expands macros and includes headers, producing a translation unit. The compiler emits assembly, the assembler emits a relocatable object file, and the linker consumes many object files to produce an executable or shared library. Object files are not just code; they are structured containers with sections like .text (code), .data (initialized data), .bss (uninitialized data), .rodata (constants), and tables such as .symtab and .strtab that map symbol names to addresses. A symbol is any named entity the linker must resolve: a function, global variable, or undefined reference.
Relocations are entries that say, in effect, “the code/data at offset X needs the address of symbol Y.” In a static binary, the linker resolves all such relocations and writes final addresses into the binary. In a dynamically linked binary, many relocations are left for the runtime loader. The linker emits the relocation entries in the .rela.* or .rel.* sections, and the loader will patch them once the library is mapped into memory.
Static libraries (.a) are archives of object files. The linker pulls in only the required objects, which is why link order matters: the linker only resolves undefined symbols that it already knows about. Shared libraries (.so) are different: the linker does not copy their code into the final binary; instead, it records the dependency. The executable contains metadata telling the loader which libraries to load and which symbols remain unresolved.
Linking is also where visibility rules are applied. A symbol can be local (not exported), global (exported), or weak (a fallback if no strong symbol exists). The linker uses these rules to decide which definition to bind. Incorrect visibility or duplicate global definitions can produce subtle bugs that only show up at runtime.
Finally, the dynamic linker itself is just another program. When a dynamically linked executable starts, the kernel loads the dynamic linker, which then maps the program and its libraries, resolves symbols, and transfers control to main(). The boundary between link-time and run-time is intentional: link-time establishes the dependency graph and symbol tables; run-time resolves actual addresses based on where libraries load in memory.
How This Fits in Projects
- Project 2 depends on reading link-time metadata to build the dependency graph.
- Project 6 requires you to replicate parts of the loader’s relocation logic.
Definitions & Key Terms
- Relocation: A link-time or run-time patch that writes a resolved address into code/data.
- Symbol Table: Metadata mapping symbol names to addresses/sections.
- Static Library: An archive of
.ofiles; code copied into the executable at link time. - Shared Library: A runtime-loaded library; code is not copied into the executable.
Mental Model Diagram
source.c -> compiler -> object.o -> linker -> executable
| |
| +-> DT_NEEDED list
+-> symbols/relocations
How It Works (Step-by-Step)
- Preprocess: expand macros and include headers.
- Compile: generate assembly from high-level code.
- Assemble: create
.owith symbols and relocations. - Link: resolve symbols, produce executable or
.so. - Runtime: loader maps libraries, resolves remaining relocations.
Minimal Concrete Example
# Build object file
$ gcc -c -fPIC hello.c -o hello.o
# Build shared library
$ gcc -shared -o libhello.so hello.o
# Link executable (dynamic)
$ gcc -o app main.c -L. -lhello
Common Misconceptions
- “Static linking means no relocations.” (Relocations still occur, just at link time.)
- “The linker loads libraries at runtime.” (The loader does.)
Check-Your-Understanding Questions
- What information does the linker embed to support dynamic linking?
- Why does link order matter with static libraries?
- What is the difference between
.symtaband.dynsym?
Check-Your-Understanding Answers
- The linker writes dynamic section entries like
DT_NEEDED, relocation tables, and dynamic symbol tables. - The linker only pulls objects that satisfy unresolved symbols seen so far.
.symtabis the full symbol table;.dynsymis the subset needed at runtime.
Real-World Applications
- Large C/C++ applications with plugin ecosystems
- System libraries like libc, libpthread, libssl
Where You’ll Apply It
- Projects 1-6
References
- “How To Write Shared Libraries” (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
- “System V ABI (gABI)” - https://www.sco.com/developers/gabi/
- “Computer Systems: A Programmer’s Perspective” - Ch. 7 (Linking) (TOC: https://thomasmore.ecampus.com/computer-systems-programmers-perspective/bk/9780134092669)
Key Insight
Linking is a contract: build time records what must be resolved; run time determines where it actually lands in memory.
Summary
The compilation pipeline produces object files with symbols and relocations. The linker resolves what it can and leaves the rest for the runtime loader. If you can read this metadata, you can predict loader behavior.
Homework/Exercises to Practice the Concept
- Build a static library and link it into a program. Inspect the symbol table with
nm. - Build a shared library and compare
.symtaband.dynsymwithreadelf -s. - Use
objdump -rto list relocations in a.oand a.so.
Solutions to the Homework/Exercises
ar rcs libfoo.a foo.othengcc main.c -L. -lfooandnm a.out.readelf -s libfoo.sovsreadelf --dyn-syms libfoo.so.objdump -r foo.oandreadelf -r libfoo.so.
Chapter 2: Executable Formats & Shared Object Metadata (ELF Focus)
Fundamentals
Shared libraries are not just raw machine code. They are structured binary formats that encode where code and data live, what symbols exist, and what dependencies are required. On Linux, the format is ELF (Executable and Linkable Format). ELF defines headers, program segments, and dynamic sections that the runtime loader uses to map libraries into memory. The ELF specification is defined by the System V ABI (gABI), which provides the canonical meaning of fields like DT_NEEDED, DT_SONAME, and relocation tables. This structure is what makes dynamic linking possible at all.
Deep Dive into the Concept
An ELF file is split into sections (used mainly by the linker) and segments (used by the loader). Sections like .text, .data, .bss, .rodata, .dynsym, and .rela.dyn describe the compiled output and metadata. Segments are contiguous regions that the loader maps into memory, usually with PT_LOAD entries describing how to map them with appropriate permissions (read, write, execute).
The dynamic loader does not read every section. Instead, it follows the program header table to find loadable segments and the dynamic section (.dynamic) to learn which other libraries are required. The .dynamic section contains tagged entries such as:
DT_NEEDED: names of libraries the executable or DSO requiresDT_SONAME: the “shared object name” used to enforce ABI compatibilityDT_RPATH/DT_RUNPATH: embedded library search pathsDT_RELA,DT_JMPREL: relocation tables
This metadata is the contract between the build-time linker and the runtime loader. If DT_NEEDED is missing, the loader will never know it needs to load a dependency. If relocation tables are missing or wrong, runtime patching fails. If DT_SONAME is wrong, you can silently break ABI compatibility.
Other operating systems use different formats (Mach-O on macOS, PE on Windows), but the conceptual fields exist everywhere: a list of dependencies, a place to store import/export symbols, and relocation info. Mach-O uses load commands like LC_LOAD_DYLIB and install names, while PE uses import tables and loader metadata. The reason this matters is that cross-platform shared library design is always a mapping between these concepts, even if the structure differs.
Understanding ELF at the metadata level makes many “mysterious” loader errors obvious. For example, if an executable cannot find libfoo.so.1, you can inspect its DT_NEEDED entries. If a program loads the wrong library, you can inspect DT_RUNPATH and DT_RPATH. If a symbol is missing, you can inspect .dynsym for whether it was exported at all.
How This Fits in Projects
- Project 2 parses ELF metadata to build dependency graphs.
- Project 6 reads ELF program headers to map segments correctly.
Definitions & Key Terms
- ELF Header: Identifies file type, architecture, and offsets to other tables.
- Program Header: Describes loadable segments and permissions.
- Dynamic Section: Tagged metadata for runtime linking.
- DT_NEEDED: Dependency list used by the loader.
Mental Model Diagram
ELF File
+-----------------------+
| ELF Header |
+-----------------------+
| Program Header Table | --> loader uses this
+-----------------------+
| .text .data .bss ... |
+-----------------------+
| .dynamic (DT_NEEDED) | --> loader uses this
+-----------------------+
How It Works (Step-by-Step)
- Linker emits ELF file with headers and dynamic section.
- Loader reads ELF header to find program headers.
- Loader maps
PT_LOADsegments into memory. - Loader reads
.dynamicto discover dependencies. - Loader performs relocations using
DT_RELA/DT_JMPREL.
Minimal Concrete Example
# Inspect dynamic section
$ readelf -d ./app
# Inspect program headers
$ readelf -l ./app
Common Misconceptions
- “Sections are mapped directly into memory.” (Segments, not sections, are mapped.)
- “If
DT_NEEDEDis correct, the library will load.” (Search paths can still fail.)
Check-Your-Understanding Questions
- What does
DT_NEEDEDtell the loader? - Why are program headers more important than sections at runtime?
- What is the role of
DT_SONAME?
Check-Your-Understanding Answers
- The list of shared libraries that must be loaded.
- Program headers describe memory segments; sections are for link-time.
- It defines the ABI identity of a shared library used by the loader.
Real-World Applications
- Packaging shared libraries correctly in Linux distributions
- Investigating why a binary fails to start
Where You’ll Apply It
- Projects 2 and 6
References
- “System V ABI (gABI)” - https://www.sco.com/developers/gabi/
- “ELF and ABI Standards” (Linux Foundation) - https://refspecs.linuxfoundation.org/elf/index.html
- “How To Write Shared Libraries” (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
Key Insight
Dynamic linking only works because the binary format encodes everything the loader needs to finish the job.
Summary
ELF is a structured metadata format, not just machine code. If you can read .dynamic, you can understand loader behavior.
Homework/Exercises to Practice the Concept
- Use
readelf -don/bin/lsand list allDT_NEEDEDentries. - Inspect
.dynsymoflibc.soand identify exported symbols. - Compare
readelf -loutput for a static vs dynamically linked binary.
Solutions to the Homework/Exercises
readelf -d /bin/ls | grep NEEDED.readelf --dyn-syms /lib/x86_64-linux-gnu/libc.so.6 | head.- Compare
readelf -land observe missing.dynamicin static binary.
Chapter 3: Position-Independent Code, Relocations, GOT/PLT
Fundamentals
Shared libraries must be loadable at any address, so their code cannot rely on absolute addresses. Position-Independent Code (PIC) solves this by using relative addressing and indirection tables. Data addresses are accessed through the Global Offset Table (GOT), and function calls go through the Procedure Linkage Table (PLT). These mechanisms allow the loader to relocate addresses without rewriting executable code pages, enabling efficient sharing between processes.
Deep Dive into the Concept
PIC is the reason multiple processes can map the same shared library pages and still run correctly. If code contained absolute addresses, each process would require a different copy of the code once relocations were applied. PIC uses relative addressing (like RIP-relative addressing on x86-64) so that code can be shared even when loaded at different base addresses. Only data pointers stored in the GOT need to be fixed up, which keeps code pages read-only and shareable.
The GOT is a table of addresses used to access global variables and functions whose addresses are not known at link time. When a shared library is loaded, the loader applies relocations to fill in GOT entries with the correct runtime addresses. This makes data access efficient after the first relocation.
The PLT is used for function calls to external symbols. Initially, PLT entries point to a resolver stub that invokes the dynamic linker. When the function is called the first time, the linker resolves the symbol and patches the GOT entry so subsequent calls jump directly to the function. This is called lazy binding. Lazy binding reduces startup time but can hide errors until first use; many systems allow you to disable it for more predictable startup behavior.
Relocations are of different types: some adjust absolute addresses, others adjust relative offsets. The loader uses relocation tables (DT_RELA, DT_JMPREL) to know what to patch. Some relocations are processed eagerly at startup; others are deferred until the first call (PLT relocations). Modern systems can enable RELRO (Relocation Read-Only), which relocates GOT entries early and then makes the GOT read-only for security.
Performance is a real trade-off: PIC can be slightly slower due to extra indirection, and dynamic linking adds overhead at startup and sometimes during first calls. But the memory savings from shared code pages and the ability to update libraries without relinking often outweigh these costs.
How This Fits in Projects
- Project 3 relies on PLT/GOT behavior for interposition.
- Project 6 requires understanding relocations to load an ELF binary.
Definitions & Key Terms
- PIC: Code that runs correctly regardless of where it is loaded in memory.
- GOT: Table of addresses used for global data and functions.
- PLT: Stub table used for lazy resolution of external functions.
- Relocation: A loader patch to insert actual addresses.
Mental Model Diagram
Caller --> PLT entry --> resolver (first call)
| |
v v
GOT slot <------ dynamic linker patches
|
v
actual function
How It Works (Step-by-Step)
- Linker emits PLT and GOT entries for external symbols.
- Loader maps library and applies non-PLT relocations.
- First call into PLT triggers resolver.
- Resolver updates GOT entry for that symbol.
- Later calls jump directly to function.
Minimal Concrete Example
// External call through PLT
extern int foo(int);
int bar(int x) {
return foo(x + 1);
}
Common Misconceptions
- “PLT/GOT is only for dynamic libraries.” (Executables can have them too.)
- “Lazy binding is always enabled.” (It can be disabled via loader flags.)
Check-Your-Understanding Questions
- Why does PIC improve memory sharing?
- What does the first call through the PLT actually do?
- Why is RELRO a security improvement?
Check-Your-Understanding Answers
- Code pages remain read-only and identical across processes.
- It jumps into the resolver which patches the GOT with the real address.
- It prevents GOT overwrites after relocation.
Real-World Applications
- libc and libstdc++ (massively shared across processes)
- hot-reloading engines and plugin systems
Where You’ll Apply It
- Projects 3, 4, and 6
References
- “How To Write Shared Libraries” (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
- “Computer Systems: A Programmer’s Perspective” - Ch. 7 (Linking) (TOC: https://thomasmore.ecampus.com/computer-systems-programmers-perspective/bk/9780134092669)
Key Insight
PIC keeps code pages shareable, which is the real economic reason shared libraries exist at scale.
Summary
PIC + GOT/PLT enable runtime relocation without rewriting code pages. Lazy binding trades startup time for first-call cost.
Homework/Exercises to Practice the Concept
- Compile a shared library with and without
-fPICand inspect relocation differences. - Use
objdump -dto find PLT entries. - Run a program with
LD_DEBUG=bindingsand observe lazy binding.
Solutions to the Homework/Exercises
- Compare
readelf -routput for both builds. objdump -d ./app | grep -A3 '<foo@plt>'.LD_DEBUG=bindings ./app 2>&1 | head.
Chapter 4: Dynamic Loader & Symbol Resolution
Fundamentals
The dynamic loader (e.g., ld.so on Linux, dyld on macOS) is responsible for mapping DSOs into memory, resolving symbols, and running initialization routines. Symbol resolution follows a defined order based on scope, load order, and preloading rules. Features like LD_PRELOAD and RTLD_NEXT allow you to interpose functions and change runtime behavior without recompiling code.
Deep Dive into the Concept
When you run a dynamically linked program, the kernel loads the loader first. The loader then reads the executable’s DT_NEEDED entries, maps required libraries, and resolves symbols. Resolution follows a search order: first symbols in the main executable, then in shared libraries in load order. Preloaded libraries (LD_PRELOAD) are injected early so their symbols resolve first, enabling interposition. This is why LD_PRELOAD works for function interception.
The loader must handle multiple namespaces, symbol visibility, and weak/strong definitions. If multiple libraries define the same global symbol, the loader applies rules (often “first definition wins” in the global scope). This can cause subtle bugs if two libraries export the same symbol name unintentionally. Symbol versioning mitigates this by allowing multiple versions of the same symbol to coexist.
The loader also runs initialization functions: constructors in .init_array, and finalizers in .fini_array. The order matters: dependencies must initialize before dependents. The loader typically topologically sorts the dependency graph to ensure this order.
In practice, this is the source of many loader errors:
- Missing symbol: the loader found the library but the symbol wasn’t exported.
- Wrong symbol: another library defined a symbol with the same name, causing incorrect binding.
- Interposition surprise:
LD_PRELOADchanged behavior and introduced recursion or deadlocks.
Understanding symbol resolution is the key to debugging these problems.
How This Fits in Projects
- Project 3 depends on interposition and
RTLD_NEXT. - Project 4 depends on loader lifecycle and unloading.
Definitions & Key Terms
- Symbol Resolution: The process of binding symbol references to definitions.
- Interposition: Overriding a symbol by loading another definition first.
- RTLD_NEXT: A handle used to find the next definition after the current one.
- Initialization Order: Dependency-first construction of shared libraries.
Mental Model Diagram
Executable + Preload libs + Needed libs
| | |
v v v
Symbol lookup in global scope
(first match wins)
How It Works (Step-by-Step)
- Loader maps the main executable.
- Loader loads
LD_PRELOADlibraries (if any). - Loader loads
DT_NEEDEDlibraries. - Loader resolves symbols based on search order.
- Loader runs constructors in dependency order.
Minimal Concrete Example
// Interpose malloc
void* malloc(size_t n) {
void* (*real_malloc)(size_t) = dlsym(RTLD_NEXT, "malloc");
return real_malloc(n);
}
Common Misconceptions
- “If it links, it will run.” (Runtime resolution can still fail.)
- “Symbol conflicts will be compile-time errors.” (Often resolved silently at runtime.)
Check-Your-Understanding Questions
- Why does
LD_PRELOADwork for interposing symbols? - What happens when two libraries define the same global symbol?
- Why must init functions run in dependency order?
Check-Your-Understanding Answers
- Preloaded libraries are searched first, so their symbols resolve earliest.
- The loader usually binds to the first definition it encounters in global scope.
- Dependencies must be initialized before dependents to avoid undefined state.
Real-World Applications
- Profilers and tracers that intercept libc calls
- Hotfixing production binaries without rebuilding
Where You’ll Apply It
- Projects 3 and 4
References
- “ld.so(8)” man page (search order & loader behavior) - https://man7.org/linux/man-pages/man8/ld-linux.8.html
- “How To Write Shared Libraries” (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
- TLPI Ch. 42 (Dynamic loading, visibility, preload) (TOC: https://www.oreilly.com/library/view/the-linux-programming/9781593272203/xhtml/ch42.xhtml)
Key Insight
Dynamic linking is not just “loading a library”; it’s a global symbol resolution algorithm with subtle rules.
Summary
The loader determines not only which libraries load, but which symbol definitions win. Interposition is powerful but dangerous.
Homework/Exercises to Practice the Concept
- Use
LD_PRELOADto interceptputs()and add a prefix. - Create two libraries exporting the same symbol and observe which one wins.
- Use
LD_DEBUG=libs,bindingsto inspect resolution order.
Solutions to the Homework/Exercises
- Build
libhook.so, runLD_PRELOAD=./libhook.so ./app. - Link both libraries and inspect symbol resolution with
LD_DEBUG=bindings. LD_DEBUG=libs,bindings ./app 2>&1 | less.
Chapter 5: Library Search Paths & Install Names
Fundamentals
The loader must locate shared libraries on disk. On Linux, this search order is defined by ld.so rules involving DT_RPATH, DT_RUNPATH, LD_LIBRARY_PATH, the loader cache (ldconfig), and default directories. On macOS, dyld uses install names and @rpath, @loader_path, @executable_path. On Windows, the loader searches directories based on Safe DLL Search Mode and process-specific search paths. These rules directly determine whether your program starts or fails with “library not found” errors.
Deep Dive into the Concept
On Linux, the loader uses a defined search order. If a dependency name has no slash, the loader searches DT_RPATH (if DT_RUNPATH is absent), then LD_LIBRARY_PATH (unless running in secure mode), then DT_RUNPATH, then the ldconfig cache, and finally default system directories such as /lib and /usr/lib. DT_RUNPATH is not transitive: it only applies to direct dependencies, unlike DT_RPATH which applies to the full dependency tree. This subtle difference is one of the most common causes of “works on my machine” loader errors.
On macOS, library identities are stored as install names. When you compile a shared library, you embed an install name like @rpath/libfoo.dylib. At runtime, dyld maintains a run-path list built from LC_RPATH entries across the dependency chain. @loader_path resolves to the directory containing the Mach-O binary currently being loaded; @executable_path resolves to the main executable’s directory. This indirection allows applications to bundle libraries inside app bundles or frameworks without hardcoding absolute paths.
On Windows, the loader search order is affected by Safe DLL Search Mode (enabled by default). The standard order typically searches the application directory, system directories, the current directory, then PATH. Functions like SetDefaultDllDirectories and AddDllDirectory let you lock down and control the search order to prevent DLL preloading attacks. Unlike ELF, Windows does not have an rpath embedded in the binary. You must set the search order programmatically or rely on installation paths.
These platform differences matter when you ship cross-platform libraries. A correct Linux rpath setup can still fail on macOS if install names are wrong, or on Windows if the DLL search path is not configured. The key is to treat search paths as a deployment concern, not a development convenience.
How This Fits in Projects
- Project 2 must reimplement the search logic to explain dependency resolution.
- Project 5 must build correct install names and export rules across platforms.
Definitions & Key Terms
- RPATH/RUNPATH: Embedded library search paths in ELF.
- ldconfig cache: System cache of library locations.
- Install Name: Mach-O embedded identity of a dylib.
- Safe DLL Search Mode: Windows security feature changing search order.
Mental Model Diagram
Linux search order (simplified):
DT_RPATH -> LD_LIBRARY_PATH -> DT_RUNPATH -> ld.so.cache -> /lib,/usr/lib
macOS: @rpath -> @loader_path -> @executable_path (via LC_RPATH)
Windows: AppDir -> System -> Windows -> CurrentDir -> PATH
How It Works (Step-by-Step)
- Loader reads dependency names from metadata.
- If dependency has no slash, apply platform search order.
- Resolve to a concrete path or fail with “not found”.
- Load library and repeat for its dependencies.
Minimal Concrete Example
# Inspect RPATH/RUNPATH
$ readelf -d ./app | grep -E 'RPATH|RUNPATH'
# Observe loader search
$ LD_DEBUG=libs ./app 2>&1 | head
Common Misconceptions
- “Setting LD_LIBRARY_PATH fixes everything.” (It can mask real deployment issues.)
- “RPATH applies to all dependencies.” (RUNPATH does not.)
Check-Your-Understanding Questions
- Why can
DT_RUNPATHbreak transitive dependencies? - What does
@loader_pathresolve to in macOS? - Why is Safe DLL Search Mode a security improvement?
Check-Your-Understanding Answers
- RUNPATH is only used for direct dependencies, not their children.
- The directory containing the Mach-O binary with the load command.
- It prevents the current directory from being searched too early.
Real-World Applications
- Shipping Linux binaries with private shared libraries
- Packaging macOS app bundles correctly
- Preventing DLL hijacking on Windows
Where You’ll Apply It
- Projects 2 and 5
References
- “ld.so(8)” man page (search order) - https://man7.org/linux/man-pages/man8/ld-linux.8.html
- Apple Run-Path Dependent Libraries - https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/DynamicLibraries/100-Articles/RunpathDependentLibraries.html
- “dyld(1)” man page (
@rpath,@loader_path) - https://keith.github.io/xcode-man-pages/dyld.1.html - Microsoft DLL Search Order - https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-search-order
- Microsoft DLL Security Guidance - https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-security
Key Insight
Search paths are part of your ABI contract. If you do not control them, your users will.
Summary
The loader’s search order differs by platform. RPATH/RUNPATH, install names, and Safe DLL search mode decide whether your binary starts at all.
Homework/Exercises to Practice the Concept
- Build an executable that depends on a local
.soand experiment with RPATH and RUNPATH. - Use
patchelforchrpathto change a RUNPATH and observe loader behavior. - On macOS, build a dylib with an
@rpathinstall name and testotool -Loutput.
Solutions to the Homework/Exercises
gcc main.c -Wl,-rpath,'$ORIGIN' -L. -lfoothen move the binary.patchelf --set-rpath /tmp ./appand rerun.install_name_tool -id @rpath/libfoo.dylib libfoo.dylib.
Chapter 6: ABI Stability, Versioning, and Symbol Visibility
Fundamentals
An ABI (Application Binary Interface) defines how compiled code interacts: calling conventions, data layout, symbol names, and binary compatibility rules. For shared libraries, ABI stability is critical: if you break it, every dependent program may crash or refuse to load. Tools like SONAME, symbol versioning, and visibility controls are used to manage compatibility.
Deep Dive into the Concept
APIs are source-level contracts, while ABIs are binary-level contracts. A small change in a struct layout, function signature, or calling convention can break ABI even if the API “looks” compatible. Shared libraries therefore use explicit versioning mechanisms.
On ELF systems, the SONAME encodes the ABI-major version. For example, libfoo.so.1 indicates ABI version 1. If you break ABI, you increment the SONAME major version so that old programs continue to load the old library instead of crashing with subtle corruption. This is why libfoo.so.1 and libfoo.so.2 can coexist. Symbol versioning is a finer-grained mechanism: you can have multiple versions of the same symbol in one library, allowing old binaries to bind to the old symbol implementation while new binaries use the new one.
Symbol visibility controls which symbols are exported. Exporting fewer symbols reduces symbol collisions and speeds up dynamic symbol lookup. On ELF, visibility can be controlled via compiler attributes or linker version scripts. On Windows, __declspec(dllexport) and .def files provide a similar mechanism. On macOS, export lists and -exported_symbols_list control visibility.
ABI stability also requires discipline: avoid exposing structs directly, prefer opaque pointers, use factory functions for object creation, and never change the size or layout of exported structs without versioning. If you must evolve an API, use new symbols rather than modifying old ones.
Finally, ABI stability is a security and operational concern. Many production outages are caused by an ABI-breaking update that was accidentally deployed. A proper ABI strategy protects you from this class of failure.
How This Fits in Projects
- Project 1 forces you to design a stable plugin ABI.
- Project 5 requires cross-platform ABI discipline.
Definitions & Key Terms
- ABI: Binary-level contract (calling conventions, layout, symbol names).
- SONAME: ABI version identity used by the loader.
- Symbol Versioning: Multiple versions of a symbol in one library.
- Visibility: Whether a symbol is exported or hidden.
Mental Model Diagram
API (source) -> ABI (binary)
struct change -> ABI break
SONAME bump -> old apps keep working
How It Works (Step-by-Step)
- Define exported symbols and ensure stable signatures.
- Encode ABI-major in SONAME.
- Hide internal symbols.
- Use symbol versioning for compatibility shims if needed.
Minimal Concrete Example
// Opaque handle pattern
typedef struct audio_plugin audio_plugin_t;
audio_plugin_t* plugin_create(void);
void plugin_destroy(audio_plugin_t* p);
Common Misconceptions
- “Adding a field to a struct is safe.” (It can break ABI if size/layout changes.)
- “If it compiles, ABI is preserved.” (Binary compatibility is separate.)
Check-Your-Understanding Questions
- Why does SONAME use major versions for ABI changes?
- What is the difference between API and ABI?
- Why hide symbols that are not part of the public API?
Check-Your-Understanding Answers
- Because ABI breaks require a loader-visible identity change.
- API is source-level; ABI is binary-level.
- To avoid symbol conflicts and speed up resolution.
Real-World Applications
- libc and libstdc++ ABI compatibility across distro updates
- Plugin systems that must load third-party modules safely
Where You’ll Apply It
- Projects 1 and 5
References
- “How To Write Shared Libraries” (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
- TLPI Ch. 41-42 (SONAME, versioning, visibility) (TOC: https://www.oreilly.com/library/view/the-linux-programming/9781593272203/xhtml/ch41.xhtml)
Key Insight
ABI stability is not optional. If you ship libraries, you are a compatibility steward.
Summary
ABI breaks require explicit versioning. Symbol visibility and opaque handles keep your ABI stable and maintainable.
Homework/Exercises to Practice the Concept
- Create a shared library with a SONAME and observe the symlink structure.
- Export only one symbol and hide the rest.
- Break ABI intentionally and see how the loader reacts.
Solutions to the Homework/Exercises
gcc -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0 foo.o.- Use
__attribute__((visibility("hidden")))on internal symbols. - Change struct layout and run an old binary to see failure modes.
Chapter 7: Runtime Loading APIs & Plugin Architecture
Fundamentals
Runtime loading lets a program load libraries after startup. On POSIX systems, this is done with dlopen, dlsym, and dlclose. On Windows, the equivalents are LoadLibrary and GetProcAddress. Runtime loading enables plugins, optional features, and hot reload. But it also introduces ABI risks and lifetime management issues.
Deep Dive into the Concept
dlopen() loads a shared library file and returns a handle. You can then call dlsym() to look up a symbol by name, typically a function pointer. dlerror() reports loader errors, and dlclose() unloads a library when no more references remain. These APIs operate on runtime loader state, not just file handles.
Plugin architecture emerges from these APIs. A plugin library exports a known entry point (for example, plugin_init), which returns a struct of function pointers or a versioned interface. The host program loads each plugin, validates its version, and calls into it via the function table. This avoids direct symbol dependencies and allows multiple plugin versions to coexist.
However, unloading is tricky. If any code or data references the plugin after dlclose(), your program can crash. Safe hot reload requires isolating plugin state, versioning the interface, and managing lifetimes carefully. A common pattern is to keep state in the host and only reload pure functions from the plugin.
On Windows, LoadLibrary searches for DLLs using the process search path (which can be hardened with SetDefaultDllDirectories). The same plugin design principles apply, but you must manage calling conventions and name decoration carefully.
How This Fits in Projects
- Project 1 builds a plugin processor.
- Project 4 builds a hot-reload system.
Definitions & Key Terms
- dlopen/dlsym: POSIX runtime loader APIs.
- LoadLibrary/GetProcAddress: Windows runtime loader APIs.
- Plugin ABI: A stable interface boundary between host and plugin.
Mental Model Diagram
Host app
|
+-- dlopen("plugin.so")
+-- dlsym("plugin_init") -> function table
+-- call plugin functions
How It Works (Step-by-Step)
- Host scans plugin directory.
- For each
.so, calldlopen. - Use
dlsymto find entry point. - Validate ABI version and function table.
- Call plugin functions.
Minimal Concrete Example
typedef struct {
int api_version;
float (*process)(float sample);
} plugin_api_t;
plugin_api_t* (*get_api)(void);
void* h = dlopen("./plugins/reverb.so", RTLD_NOW);
get_api = dlsym(h, "plugin_get_api");
plugin_api_t* api = get_api();
Common Misconceptions
- “dlclose always unloads the library immediately.” (It only decrements refcount.)
- “You can reload a plugin with a changed struct layout safely.” (Not without versioning.)
Check-Your-Understanding Questions
- What does
dlopenreturn and why is it not a file descriptor? - Why is a function table safer than many exported symbols?
- What is the risk of
dlclosein a hot-reload system?
Check-Your-Understanding Answers
- It returns a loader handle representing a loaded object.
- It centralizes ABI into a versioned contract.
- Code can still reference unloaded memory, causing crashes.
Real-World Applications
- Audio plugins, graphics extensions, database adapters
- Feature toggles loaded only when needed
Where You’ll Apply It
- Projects 1 and 4
References
dlopen(3)man page - https://man7.org/linux/man-pages/man3/dlopen.3p.htmldlsym(3)documentation (Oracle) - https://docs.oracle.com/cd/E86824_01/html/E54766/dlsym-3c.html- Microsoft DLL search order & LoadLibrary docs - https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibraryexw
Key Insight
Runtime loading is a power tool: it gives flexibility, but only if your ABI boundary is disciplined.
Summary
dlopen/dlsym enable plugins and hot reload, but you must design for ABI stability and safe lifetimes.
Homework/Exercises to Practice the Concept
- Write a plugin that exports a versioned API struct.
- Add a second plugin with the same interface and load both at runtime.
- Simulate a broken plugin and handle errors gracefully.
Solutions to the Homework/Exercises
- Export
plugin_get_apiand return a struct with function pointers. - Load both plugins and call them in sequence.
- If
dlopenordlsymfails, printdlerror()and continue.
Chapter 8: Memory Mapping, Sharing, and Initialization
Fundamentals
Shared libraries save memory because their code pages are mapped once and shared across processes. The loader maps segments with mmap, applies relocations, and then runs initialization routines. Read-only code pages can be shared, while writable data pages use copy-on-write. This is why PIC and correct segment permissions matter for performance and security.
Deep Dive into the Concept
When a program starts, the loader maps each shared library’s loadable segments into the process address space. The code segment is typically mapped read-execute (RX) and is identical across processes. Data segments are mapped read-write (RW) and are usually private per process. Because of copy-on-write, multiple processes can initially share the same physical pages until one writes to them.
This memory model is fundamental to shared library efficiency. If a library is not PIC, the loader must apply text relocations that modify code pages, forcing them to become private and unshareable. This is why text relocations are discouraged and often disallowed on hardened systems.
Initialization functions complicate memory sharing. Constructors can allocate memory, register callbacks, or modify global state, which can force page faults and copy-on-write. The loader therefore runs constructors only after relocation is complete, and in a dependency-respecting order. Destructors run when libraries are unloaded or at process exit.
Thread-local storage (TLS) adds complexity: the loader must allocate per-thread blocks and ensure TLS variables resolve correctly. This is why TLS models (initial-exec, local-exec, etc.) exist. You do not need to implement TLS in the projects, but you should know that it is another layer of runtime relocation logic.
Understanding memory mapping helps you reason about performance. Large libraries with many relocations slow startup. Libraries with many writable global variables reduce page sharing. Symbol interposition can prevent the linker from doing certain optimizations, increasing the number of relocations.
How This Fits in Projects
- Project 6 requires mapping segments correctly with permissions.
- Project 4 requires understanding what state can survive reloads.
Definitions & Key Terms
- mmap: System call to map files into memory.
- Copy-on-Write (COW): Shared pages become private when written.
- Text Relocation: Relocation that modifies executable code pages.
- TLS: Thread-local storage requiring loader support.
Mental Model Diagram
Disk .so -> mmap -> RX code (shared)
RW data (COW per process)
How It Works (Step-by-Step)
- Loader maps code and data segments.
- Apply relocations (preferably data-only).
- Mark RELRO sections read-only.
- Run constructors.
Minimal Concrete Example
# Inspect program headers for permissions
$ readelf -l ./libfoo.so | grep LOAD
Common Misconceptions
- “Shared libraries always save memory.” (Not if text relocations force private copies.)
- “Global variables are free.” (They reduce sharing and increase RSS.)
Check-Your-Understanding Questions
- Why are text relocations bad for memory sharing?
- What does copy-on-write mean for shared libraries?
- Why does initialization order matter for memory safety?
Check-Your-Understanding Answers
- They modify code pages, which prevents sharing across processes.
- Shared pages stay shared until a process writes to them.
- Dependencies must be initialized before dependents use their state.
Real-World Applications
- Minimizing memory footprint in system-wide shared libraries
- Diagnosing startup performance problems
Where You’ll Apply It
- Projects 4 and 6
References
- “How To Write Shared Libraries” (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
- TLPI Ch. 49 (Memory Mappings) (TOC: https://nostarch.com/tlpi)
Key Insight
Memory sharing is the main economic reason for shared libraries; PIC and correct permissions make it possible.
Summary
Shared libraries rely on mmap, COW, and careful relocation to stay shareable and efficient.
Homework/Exercises to Practice the Concept
- Measure RSS of a program with and without a large shared library.
- Build a non-PIC shared library and observe loader warnings/errors.
- Inspect relocation entries for text relocations and explain their impact.
Solutions to the Homework/Exercises
- Use
pmapor/proc/<pid>/smapsto compare memory. gcc -shared foo.owithout-fPICand observe loader warnings.readelf -r libfoo.so | grep TEXTREL.
Glossary
- ABI: Binary compatibility contract (calling conventions, layout, symbol names).
- DSO: Dynamic Shared Object, a shared library (
.so). - GOT: Global Offset Table; holds resolved addresses for data/functions.
- PLT: Procedure Linkage Table; indirection for external function calls.
- SONAME: ABI identity of a shared library used at runtime.
- RPATH/RUNPATH: Embedded library search paths in ELF.
- Interposition: Overriding symbols by preloading another library.
- Lazy Binding: Deferring symbol resolution until first call.
Why Shared Libraries & Dynamic Linking Matters
The Modern Problem It Solves
Modern software depends on hundreds of shared components. Dynamic linking allows updates without rebuilding everything and saves memory by sharing code pages. But it also creates a dependency graph whose failure modes are subtle and costly.
Real-world impact (statistics):
- Indirect dependency risk: In the Snyk State of Open Source Security 2023 report, 31% of respondents said they ignore indirect (transitive) dependencies, even though these are critical to runtime behavior. (2023) - https://snyk.io/de/reports/open-source-security/
- Vulnerabilities in transitive dependencies: Snyk’s 2020 report found that most vulnerabilities in major ecosystems were in indirect dependencies (e.g., npm 86%, Ruby 81%, Java 74%). (2020) - https://snyk.io/es/articles/open-source-security/
Dynamic linking is the practical boundary between your code and every dependency your program loads. If you don’t understand it, you cannot reliably ship or debug production software.
Static Linking Dynamic Linking
┌──────────────┐ ┌──────────────┐
│ app + libs │ │ app │
│ (one binary) │ │ + shared libs│
└──────┬───────┘ └──────┬───────┘
| |
rebuild all update libs
Context & Evolution
Dynamic linking evolved to solve memory sharing and update problems in multi-process systems. ELF replaced older formats (a.out, COFF) because it offered better relocation and sharing models, making large-scale shared libraries practical. (Drepper, 2011) - https://www.akkadia.org/drepper/dsohowto.pdf
Concept Summary Table
| Concept Cluster | What You Need to Internalize |
|---|---|
| Compilation & Linking Pipeline | How symbols and relocations move from source to runtime. |
| ELF & Shared Object Metadata | How .dynamic, DT_NEEDED, and program headers drive loader behavior. |
| PIC, GOT, PLT, Relocations | Why PIC exists and how the loader patches addresses. |
| Dynamic Loader & Symbol Resolution | How symbol lookup order and interposition work. |
| Search Paths & Install Names | How libraries are located on Linux/macOS/Windows. |
| ABI Stability & Versioning | How SONAME and symbol visibility keep binaries compatible. |
| Runtime Loading APIs | How dlopen/dlsym enable plugins and hot reload. |
| Memory Mapping & Sharing | How mmap, COW, and init order impact performance. |
Project-to-Concept Map
| Project | What It Builds | Primer Chapters It Uses |
|---|---|---|
| Project 1: Plugin Audio Processor | Runtime plugin ABI + dynamic loading | 1, 3, 6, 7 |
| Project 2: Dependency Visualizer | ELF metadata parsing and search logic | 1, 2, 5 |
| Project 3: LD_PRELOAD Interceptor | Interposition + symbol resolution | 3, 4, 7 |
| Project 4: Hot-Reload Server | Loader lifecycle + state preservation | 3, 4, 7, 8 |
| Project 5: Cross-Platform C API | ABI stability + platform differences | 5, 6, 7 |
| Project 6: Minimal Dynamic Linker | Mapping, relocations, symbol resolution | 1, 2, 3, 4, 8 |
Deep Dive Reading by Concept
Fundamentals & Linking
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Linking pipeline | Computer Systems: A Programmer’s Perspective - Ch. 7 (Linking) | Best end-to-end explanation of symbols, relocations, and dynamic linking. |
| Shared library basics | The Linux Programming Interface - Ch. 41 | Practical shared library creation, soname, and search paths. |
Dynamic Linking & Loader Behavior
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Dynamic loading APIs | The Linux Programming Interface - Ch. 42 | dlopen/dlsym, visibility, version scripts, preload. |
| Dynamic linker theory | Linkers and Loaders by John Levine - Ch. 10 | Conceptual model of loader behavior and GOT/PLT. |
Memory & Performance
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Memory mapping | The Linux Programming Interface - Ch. 49 | Understanding mmap and shared pages. |
Quick Start
Your First 48 Hours
Day 1 (4 hours):
- Read Chapter 1 (Compilation & Linking) and Chapter 3 (PIC/GOT/PLT).
- Compile a tiny shared library and run
readelf -don it. - Start Project 3 (LD_PRELOAD Interceptor). Use Hint 1 and intercept
puts().
Day 2 (4 hours):
- Extend your interceptor to log
open()calls. - Use
LD_DEBUG=libs,bindingsto see symbol resolution in action. - Read Project 3’s “Core Question” and “Pitfalls”.
End of Day 2:
You can explain what a PLT entry is and why LD_PRELOAD works.
Recommended Learning Paths
Path 1: The Systems Debugger (Recommended Start)
Best for: Engineers who want immediate practical debugging power.
- Project 3 (LD_PRELOAD Interceptor) - fastest insight into loader behavior
- Project 2 (Dependency Visualizer) - debugging real-world failures
- Project 1 (Plugin Audio Processor) - stable ABI design
- Project 6 (Minimal Dynamic Linker) - full mastery
Path 2: The Plugin Engineer
Best for: People building extensible systems or tools.
- Project 1 (Plugin Audio Processor)
- Project 4 (Hot-Reload Server)
- Project 5 (Cross-Platform Library)
Path 3: The ABI Steward
Best for: Library maintainers shipping stable APIs.
- Project 5 (Cross-Platform Library)
- Project 2 (Dependency Visualizer)
- Project 6 (Minimal Dynamic Linker)
Path 4: The Completionist
Phase 1: Foundation (Weeks 1-2)
- Project 3, Project 2
Phase 2: Core Systems (Weeks 3-4)
- Project 1, Project 5
Phase 3: Advanced (Weeks 5-7)
- Project 4, Project 6
Success Metrics
By the end of this guide, you should be able to:
- Explain how dynamic linking works without looking anything up.
- Diagnose loader errors using
readelf,ldd, andLD_DEBUG. - Design a stable C ABI and explain why it will remain compatible.
- Build a plugin architecture with safe runtime loading.
- Implement a minimal loader that runs a dynamically linked ELF binary.
Tooling & Debugging Appendix
Core tools and what they reveal:
readelf -d: dynamic section entries (DT_NEEDED,RUNPATH)objdump -T: dynamic symbols exportednm -D: dynamic symbols in a shared libraryldd: loader-resolved dependenciesLD_DEBUG=libs,bindings: loader decision tracingotool -L(macOS): library dependenciesdumpbin /dependents(Windows): DLL dependencies
Project Overview Table
| Project | Difficulty | Time | Primary Outcome |
|---|---|---|---|
| Plugin Audio Processor | Advanced | 1-2 weeks | Runtime plugin ABI and loader integration |
| Dependency Visualizer | Advanced | 1-2 weeks | ELF dependency graph + search path simulation |
| LD_PRELOAD Interceptor | Intermediate | 3-7 days | Function interposition + symbol tracing |
| Hot-Reload Server | Expert | 2-4 weeks | Safe reload with state preservation |
| Cross-Platform C API | Advanced | 2-3 weeks | Stable ABI across Linux/macOS/Windows |
| Minimal Dynamic Linker | Expert | 4+ weeks | ELF loader that runs a dynamic binary |
Project List
Project 1: Plugin-Based Audio Effects Processor
- Main Programming Language: C
- Alternative Programming Languages: C++, Rust (FFI), Zig
- Coolness Level: Level 3: Genuinely Clever
- Business Potential: 2. The “Micro-SaaS / Pro Tool”
- Difficulty: Level 3: Advanced
- Knowledge Area: Systems Programming / Dynamic Loading
- Software or Tool:
dlopen,dlsym - Main Book: “The Linux Programming Interface” by Michael Kerrisk
What you’ll build: A command-line audio processor where each effect (reverb, distortion, echo) is a shared library plugin that loads at runtime. The host scans a plugins directory, loads each .so, and applies a chain of effects to an input file.
Why it teaches shared libraries: You must design a stable plugin ABI, compile PIC shared libraries, load symbols dynamically, and handle incompatible plugins gracefully.
Core challenges you’ll face:
- Designing a versioned plugin API
- Managing symbol lookup and error handling (
dlerror) - Ensuring plugin ABI stability across builds
Real World Outcome
What you will see:
- A host CLI that lists discovered plugins and their metadata.
- Audio processing output with a clear effect chain.
- Clear error messages for missing or incompatible plugins.
Command Line Outcome Example:
$ ./audioproc input.wav output.wav --plugins ./plugins --chain reverb,echo
[loader] found plugin: libecho.so (api=1)
[loader] found plugin: libreverb.so (api=1)
[chain] reverb -> echo
[render] 00:00:00.000 processing...
[render] 00:00:04.182 done
[output] wrote output.wav (44100 Hz, stereo)
The Core Question You’re Answering
“How do I design and load a stable binary plugin API that works without recompiling the host?”
If you can answer this, you understand ABI boundaries, symbol resolution, and loader errors in practice.
Concepts You Must Understand First
- Dynamic loading APIs
- How does
dlopenmanage lifetime? - How does
dlsymresolve symbol names? - Book Reference: TLPI Ch. 42
- How does
- ABI stability
- What breaks ABI when you change a struct?
- How do you version an interface?
- Book Reference: Drepper, “How To Write Shared Libraries”
- PIC and GOT/PLT
- Why must plugins be built with
-fPIC? - Book Reference: CSAPP Ch. 7
- Why must plugins be built with
Questions to Guide Your Design
- Plugin API
- How will the host verify plugin version compatibility?
- Will you use a function table or many exported symbols?
- Error Handling
- How will you report missing symbols without crashing?
- How do you handle partially loaded plugins?
- Performance
- Will you process audio in-place or with buffers?
- How will you chain effects efficiently?
Thinking Exercise
The “Version 2” Problem
Imagine you add a field to your plugin struct. Old plugins still exist. What happens when the host reads the struct? Sketch the memory layout and explain the crash scenario.
The Interview Questions They’ll Ask
- “Why is
-fPICrequired for shared libraries?” - “What is the difference between
dlopenand static linking?” - “How do you prevent ABI breakage in plugins?”
- “What happens when a symbol is missing?”
Hints in Layers
Hint 1: Start with a minimal API
typedef struct {
int api_version;
const char* name;
float (*process)(float);
} plugin_api_t;
Hint 2: Expose one entry point
plugin_api_t* plugin_get_api(void);
Hint 3: Use RTLD_NOW to fail fast
void* h = dlopen(path, RTLD_NOW);
Hint 4: Verify progress
$ LD_DEBUG=libs ./audioproc input.wav out.wav --plugins ./plugins
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Shared libraries basics | The Linux Programming Interface | Ch. 41 |
| Dynamic loading APIs | The Linux Programming Interface | Ch. 42 |
| Linking & PIC | Computer Systems: A Programmer’s Perspective | Ch. 7 |
Common Pitfalls & Debugging
Problem 1: “undefined symbol: plugin_get_api”
- Why: symbol not exported or name mangled
- Fix: ensure
plugin_get_apiis non-static and compiled as C - Quick test:
nm -D libplugin.so | grep plugin_get_api
Problem 2: “dlopen failed: wrong ELF class”
- Why: architecture mismatch (32-bit vs 64-bit)
- Fix: rebuild plugin with correct target
- Quick test:
file libplugin.so
Definition of Done
- Plugins load with
dlopenand are validated - ABI version mismatch is detected and reported
- Chain of multiple plugins produces correct output
- Host survives missing or broken plugins
Project 2: Library Dependency Visualizer
- Main Programming Language: C
- Alternative Programming Languages: Python (parsing), Rust
- Coolness Level: Level 3: Genuinely Clever
- Business Potential: 3. The “Service & Support” Model
- Difficulty: Level 3: Advanced
- Knowledge Area: Systems Programming / ELF Parsing
- Software or Tool: ELF reader + Graphviz
- Main Book: “Linkers and Loaders” by John Levine
What you’ll build: A CLI tool that takes an executable and generates a dependency graph (SVG/DOT/ASCII) that shows every shared library dependency recursively.
Why it teaches shared libraries: You must parse ELF metadata, follow DT_NEEDED entries, and reimplement loader search logic to resolve real paths.
Real World Outcome
$ ./libviz /usr/bin/ssh --output deps.svg
[scan] /usr/bin/ssh
[deps] libcrypto.so.3
[deps] libz.so.1
[deps] libc.so.6
[graph] wrote deps.svg (27 nodes, 42 edges)
The Core Question You’re Answering
“Can I replicate the loader’s dependency resolution and explain it visually?”
Concepts You Must Understand First
- ELF dynamic section
- Where is
DT_NEEDEDstored? - Book Reference: TLPI Ch. 41
- Where is
- Loader search order
- What is the precedence of RPATH/RUNPATH?
- Book Reference: TLPI Ch. 41
- Graph traversal
- How do you avoid cycles in dependency graphs?
- Book Reference: Algorithms, Fourth Edition - Ch. 4 (Graph traversal)
Questions to Guide Your Design
- How will you resolve
libfoo.so.1to a real path? - How will you detect circular dependencies?
- How will you cache resolved paths to avoid repetition?
Thinking Exercise
Draw the dependency tree for a binary that depends on libA and libB, where libA depends on libC and libB also depends on libC. How many nodes should the graph show?
The Interview Questions They’ll Ask
- “Where does the loader find
DT_NEEDEDentries?” - “What is the difference between RUNPATH and RPATH?”
- “How do you handle cycles in dependency graphs?”
Hints in Layers
Hint 1: Use readelf output as your oracle
readelf -d /usr/bin/ssh | grep NEEDED
Hint 2: Build a resolver that mimics ld.so
- RPATH (if no RUNPATH)
- LD_LIBRARY_PATH
- RUNPATH
- ldconfig cache
- /lib, /usr/lib
Hint 3: Emit DOT format
ssh -> libcrypto
ssh -> libc
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| ELF/dynamic linking | The Linux Programming Interface | Ch. 41 |
| Dynamic linking concepts | Linkers and Loaders | Ch. 10 |
Common Pitfalls & Debugging
Problem 1: “Dependency not found”
- Why: your search order is incomplete
- Fix: implement
ldconfigcache lookup - Quick test:
ldconfig -p | grep libfoo
Problem 2: “Infinite recursion”
- Why: cycle in dependency graph
- Fix: track visited nodes by SONAME
Definition of Done
- Graph matches
lddoutput for at least 3 binaries - RUNPATH vs RPATH differences are reflected
- Cycles are handled without recursion errors
Project 3: LD_PRELOAD Function Interceptor
- Main Programming Language: C
- Alternative Programming Languages: C++, Rust
- Coolness Level: Level 4: Hardcore Tech Flex
- Business Potential: 3. The “Service & Support” Model
- Difficulty: Level 3: Advanced
- Knowledge Area: Systems Programming / Interposition
- Software or Tool: LD_PRELOAD
- Main Book: “The Linux Programming Interface” by Michael Kerrisk
What you’ll build: A shared library that intercepts malloc, open, and connect and logs usage statistics without modifying the target program.
Real World Outcome
$ LD_PRELOAD=./libintercept.so /usr/bin/curl https://example.com
[hook] malloc(4096) = 0x7f8a...
[hook] open("/etc/resolv.conf", O_RDONLY) = 3
[hook] connect(fd=5, 93.184.216.34:443)
[hook] total_malloc_calls=842 total_bytes=2.1MB
The Core Question You’re Answering
“How does symbol resolution order allow runtime interception?”
Concepts You Must Understand First
- Symbol resolution order
- Why does preloading take precedence?
- Book Reference: TLPI Ch. 42
- RTLD_NEXT
- How do you call the real function?
- Book Reference: TLPI Ch. 42
- Thread safety
- How do you avoid recursion in hooks?
- Book Reference: APUE Ch. 11-12 (Threads, Thread Control)
Questions to Guide Your Design
- How will you avoid infinite recursion when intercepting
malloc? - What data will you log without creating a performance bottleneck?
- How will you handle multi-threaded access to counters?
Thinking Exercise
If you intercept printf, what internal functions might it call that could re-enter your hook? Draw the call chain.
The Interview Questions They’ll Ask
- “Why does LD_PRELOAD override libc functions?”
- “What is RTLD_NEXT and why do you need it?”
- “What are the dangers of intercepting malloc?”
Hints in Layers
Hint 1: Start with puts()
int puts(const char* s) {
int (*real_puts)(const char*) = dlsym(RTLD_NEXT, "puts");
return real_puts(s);
}
Hint 2: Use dlerror after dlsym
Hint 3: Use thread-local recursion guards
static __thread int in_hook;
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Dynamic loading | The Linux Programming Interface | Ch. 42 |
| Linking & symbol resolution | CSAPP | Ch. 7 |
Common Pitfalls & Debugging
Problem 1: “Segmentation fault at startup”
- Why: using
printfinsidemallochook - Fix: use
writeor low-level logging
Problem 2: “Symbol not found”
- Why: using wrong symbol name (mangled)
- Fix: ensure C linkage or use
nm -Dto inspect
Definition of Done
- Intercepts at least 3 libc functions
- Logs without recursion crashes
- Works on a real binary (
curl,ls, etc.)
Project 4: Hot-Reload Development Server
- Main Programming Language: C
- Alternative Programming Languages: C++, Rust
- Coolness Level: Level 5: Pure Magic
- Business Potential: 4. The “Open Core” Infrastructure
- Difficulty: Level 4: Expert
- Knowledge Area: Systems Programming / Live Reload
- Software or Tool:
dlopen,inotify - Main Book: “Game Programming Patterns” by Robert Nystrom
What you’ll build: A runtime server that watches .so files for changes, recompiles them, and hot-swaps them without restarting.
Real World Outcome
$ ./hotreload
[server] running
[state] score=0
[watch] change detected in logic.c
[build] gcc -shared -fPIC -o logic.so logic.c
[reload] swapped logic.so (api=1)
[state] score=0 (preserved)
The Core Question You’re Answering
“How do you reload code without losing state or crashing the process?”
Concepts You Must Understand First
- Runtime loading APIs
- How do you load/unload safely?
- Book Reference: TLPI Ch. 42
- State isolation
- How do you keep state outside the plugin?
- Book Reference: C Interfaces and Implementations - Ch. 2 (Interfaces and Implementations)
- Memory mapping & relocation
- What happens to code addresses after reload?
- Book Reference: Drepper
Questions to Guide Your Design
- What state must persist across reloads?
- How will you prevent calls into stale function pointers?
- How will you handle ABI changes in the reloaded library?
Thinking Exercise
Draw two memory maps: before and after reload. Which pointers become invalid? How will you prevent use-after-free?
The Interview Questions They’ll Ask
- “Why is hot reload hard in C?”
- “How do you preserve state across reloads?”
- “What happens if the ABI changes during reload?”
Hints in Layers
Hint 1: Separate state and logic
typedef struct { int score; } game_state_t;
Hint 2: Keep function pointers in a table
typedef struct { void (*tick)(game_state_t*); } logic_api_t;
Hint 3: On reload, swap only the table
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Dynamic loading | The Linux Programming Interface | Ch. 42 |
| Design boundaries | C Interfaces and Implementations | Ch. 2 (Interfaces and Implementations) |
Common Pitfalls & Debugging
Problem 1: “Crash after reload”
- Why: stale function pointer
- Fix: always refresh API table after reload
Problem 2: “Memory leak after reload”
- Why: old library not closed
- Fix: ensure
dlcloseand no dangling references
Definition of Done
- Code reload works without restarting
- State is preserved across reloads
- ABI mismatches are detected and rejected
Project 5: Cross-Platform Shared Library with C API
- Main Programming Language: C
- Alternative Programming Languages: C++, Rust, Zig
- Coolness Level: Level 3: Genuinely Clever
- Business Potential: 3. The “Service & Support” Model
- Difficulty: Level 3: Advanced
- Knowledge Area: Systems Programming / Portability
- Software or Tool: CMake + FFI
- Main Book: “C Interfaces and Implementations” by David Hanson
What you’ll build: A shared library with a stable C API that builds on Linux, macOS, and Windows and is callable from Python.
Real World Outcome
import ctypes
lib = ctypes.CDLL("./libmystats.so")
lib.mean.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
print(lib.mean((ctypes.c_double*3)(1.0, 2.0, 3.0), 3))
# Output: 2.0
The Core Question You’re Answering
“How do I ship a binary library that remains ABI-stable across platforms?”
Concepts You Must Understand First
- Symbol visibility
- How do you export only the public API?
- Book Reference: TLPI Ch. 42
- Calling conventions
- What differs between Windows and System V ABI?
- Book Reference: System V ABI specs
- Versioning
- How do you manage SONAME and DLL versions?
- Book Reference: Drepper
Questions to Guide Your Design
- What is your minimal C API? (avoid structs in public ABI)
- How will you handle allocation ownership across FFI boundaries?
- How will you version the library across releases?
Thinking Exercise
Design a small C API for JSON parsing without exposing any struct layout. Explain how you would version it.
The Interview Questions They’ll Ask
- “Why is C ABI the lingua franca for shared libraries?”
- “How do you export symbols on Windows vs Linux?”
- “How do you handle memory ownership across languages?”
Hints in Layers
Hint 1: Use extern "C" for C++
Hint 2: Define export macros
#if defined(_WIN32)
#define API __declspec(dllexport)
#else
#define API __attribute__((visibility("default")))
#endif
Hint 3: Keep API opaque
typedef struct parser parser_t;
API parser_t* parser_create(void);
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| C API design | C Interfaces and Implementations | Ch. 2 (Interfaces and Implementations) |
| Shared library versioning | TLPI | Ch. 41 |
Common Pitfalls & Debugging
Problem 1: “Unresolved symbol on Windows”
- Why: missing
__declspec(dllexport) - Fix: use export macros and a
.deffile if needed
Problem 2: “ABI mismatch between builds”
- Why: struct layout changes
- Fix: use opaque handles and versioned API
Definition of Done
- Builds
.so,.dylib, and.dll - API is callable from Python via ctypes
- ABI versioning strategy is documented
Project 6: Build a Minimal Dynamic Linker
- Main Programming Language: C
- Alternative Programming Languages: Rust, Zig
- Coolness Level: Level 5: Pure Magic
- Business Potential: 4. The “Infrastructure” Model
- Difficulty: Level 5: Expert
- Knowledge Area: Systems Programming / Loader Internals
- Software or Tool: ELF parsing + mmap + relocations
- Main Book: “Linkers and Loaders” by John Levine
What you’ll build: A simplified dynamic linker that loads a dynamically linked ELF executable, maps its segments, resolves symbols for a subset of relocations, and transfers control to main().
Real World Outcome
$ ./myld ./hello_dynamic
[loader] mapped PT_LOAD segments
[loader] resolved 37 relocations
[loader] loaded libc.so.6
[loader] transferring control...
Hello, world!
The Core Question You’re Answering
“What actually happens between execve and main()?”
Concepts You Must Understand First
- ELF program headers
- How do you map PT_LOAD segments?
- Book Reference: Linkers and Loaders Ch. 10
- Relocations
- Which relocations are required to start a program?
- Book Reference: CSAPP Ch. 7
- Symbol resolution
- How do you find symbols in
.dynsym? - Book Reference: TLPI Ch. 41
- How do you find symbols in
Questions to Guide Your Design
- Which subset of relocations will you support first?
- How will you locate and map libc?
- How will you handle PLT/GOT relocations?
Thinking Exercise
Sketch the minimal data structures your loader must parse: ELF header, program headers, dynamic section, symbol table. Which are mandatory?
The Interview Questions They’ll Ask
- “What is the role of the dynamic linker?”
- “Why do ELF binaries contain a dynamic section?”
- “How do you resolve external symbols?”
Hints in Layers
Hint 1: Start with static relocations
Hint 2: Use mmap with correct permissions
Hint 3: Resolve only a small set of symbols
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| ELF dynamic linking | Linkers and Loaders | Ch. 10 |
| Linking & relocations | CSAPP | Ch. 7 |
Common Pitfalls & Debugging
Problem 1: “Segmentation fault on jump to entry”
- Why: incorrect mapping permissions or wrong entry point
- Fix: verify program headers and entry address
Problem 2: “Unresolved relocation”
- Why: missing symbol in lookup table
- Fix: parse
.dynsymand.dynstrcorrectly
Definition of Done
- Can load a simple dynamically linked binary
- Resolves at least one external symbol
- Executes
main()successfully
After completing these projects, you will be able to reason about shared libraries at the level of loader internals and ABI design, and you will have built the tools to debug real production issues with confidence.