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

  1. Read the Theory Primer first. It is a mini-book that builds your mental model.
  2. Pick a project path based on your goals (see Recommended Learning Paths).
  3. Build incrementally: each project assumes you completed (or at least understood) the prior one.
  4. Use the checklists (Definition of Done) to validate that your implementation is correct.
  5. 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

  1. Can you compile a shared library with -fPIC -shared and link against it?
  2. Do you understand what a symbol table is and why unresolved symbols occur?
  3. Can you interpret ldd or readelf -d output?
  4. Have you used gdb or lldb to inspect a crashing program?
  5. 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)
  • gcc or clang
  • make, cmake
  • readelf, objdump, nm, ldd (or otool on macOS, dumpbin on Windows)

Recommended Tools:

  • gdb or lldb
  • strace / dtruss (macOS) for loader debugging
  • graphviz for 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:

  1. First pass: Get something working, even if you don’t fully understand it.
  2. Second pass: Trace execution paths and understand loader behavior.
  3. Third pass: Debug edge cases (ABI breaks, symbol collisions, search path issues).
  4. 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 .o files; 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)

  1. Preprocess: expand macros and include headers.
  2. Compile: generate assembly from high-level code.
  3. Assemble: create .o with symbols and relocations.
  4. Link: resolve symbols, produce executable or .so.
  5. 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

  1. What information does the linker embed to support dynamic linking?
  2. Why does link order matter with static libraries?
  3. What is the difference between .symtab and .dynsym?

Check-Your-Understanding Answers

  1. The linker writes dynamic section entries like DT_NEEDED, relocation tables, and dynamic symbol tables.
  2. The linker only pulls objects that satisfy unresolved symbols seen so far.
  3. .symtab is the full symbol table; .dynsym is 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

  1. Build a static library and link it into a program. Inspect the symbol table with nm.
  2. Build a shared library and compare .symtab and .dynsym with readelf -s.
  3. Use objdump -r to list relocations in a .o and a .so.

Solutions to the Homework/Exercises

  1. ar rcs libfoo.a foo.o then gcc main.c -L. -lfoo and nm a.out.
  2. readelf -s libfoo.so vs readelf --dyn-syms libfoo.so.
  3. objdump -r foo.o and readelf -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 requires
  • DT_SONAME: the “shared object name” used to enforce ABI compatibility
  • DT_RPATH / DT_RUNPATH: embedded library search paths
  • DT_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)

  1. Linker emits ELF file with headers and dynamic section.
  2. Loader reads ELF header to find program headers.
  3. Loader maps PT_LOAD segments into memory.
  4. Loader reads .dynamic to discover dependencies.
  5. 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_NEEDED is correct, the library will load.” (Search paths can still fail.)

Check-Your-Understanding Questions

  1. What does DT_NEEDED tell the loader?
  2. Why are program headers more important than sections at runtime?
  3. What is the role of DT_SONAME?

Check-Your-Understanding Answers

  1. The list of shared libraries that must be loaded.
  2. Program headers describe memory segments; sections are for link-time.
  3. 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

  1. Use readelf -d on /bin/ls and list all DT_NEEDED entries.
  2. Inspect .dynsym of libc.so and identify exported symbols.
  3. Compare readelf -l output for a static vs dynamically linked binary.

Solutions to the Homework/Exercises

  1. readelf -d /bin/ls | grep NEEDED.
  2. readelf --dyn-syms /lib/x86_64-linux-gnu/libc.so.6 | head.
  3. Compare readelf -l and observe missing .dynamic in 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)

  1. Linker emits PLT and GOT entries for external symbols.
  2. Loader maps library and applies non-PLT relocations.
  3. First call into PLT triggers resolver.
  4. Resolver updates GOT entry for that symbol.
  5. 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

  1. Why does PIC improve memory sharing?
  2. What does the first call through the PLT actually do?
  3. Why is RELRO a security improvement?

Check-Your-Understanding Answers

  1. Code pages remain read-only and identical across processes.
  2. It jumps into the resolver which patches the GOT with the real address.
  3. 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

  1. Compile a shared library with and without -fPIC and inspect relocation differences.
  2. Use objdump -d to find PLT entries.
  3. Run a program with LD_DEBUG=bindings and observe lazy binding.

Solutions to the Homework/Exercises

  1. Compare readelf -r output for both builds.
  2. objdump -d ./app | grep -A3 '<foo@plt>'.
  3. 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_PRELOAD changed 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)

  1. Loader maps the main executable.
  2. Loader loads LD_PRELOAD libraries (if any).
  3. Loader loads DT_NEEDED libraries.
  4. Loader resolves symbols based on search order.
  5. 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

  1. Why does LD_PRELOAD work for interposing symbols?
  2. What happens when two libraries define the same global symbol?
  3. Why must init functions run in dependency order?

Check-Your-Understanding Answers

  1. Preloaded libraries are searched first, so their symbols resolve earliest.
  2. The loader usually binds to the first definition it encounters in global scope.
  3. 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

  1. Use LD_PRELOAD to intercept puts() and add a prefix.
  2. Create two libraries exporting the same symbol and observe which one wins.
  3. Use LD_DEBUG=libs,bindings to inspect resolution order.

Solutions to the Homework/Exercises

  1. Build libhook.so, run LD_PRELOAD=./libhook.so ./app.
  2. Link both libraries and inspect symbol resolution with LD_DEBUG=bindings.
  3. 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)

  1. Loader reads dependency names from metadata.
  2. If dependency has no slash, apply platform search order.
  3. Resolve to a concrete path or fail with “not found”.
  4. 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

  1. Why can DT_RUNPATH break transitive dependencies?
  2. What does @loader_path resolve to in macOS?
  3. Why is Safe DLL Search Mode a security improvement?

Check-Your-Understanding Answers

  1. RUNPATH is only used for direct dependencies, not their children.
  2. The directory containing the Mach-O binary with the load command.
  3. 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

  1. Build an executable that depends on a local .so and experiment with RPATH and RUNPATH.
  2. Use patchelf or chrpath to change a RUNPATH and observe loader behavior.
  3. On macOS, build a dylib with an @rpath install name and test otool -L output.

Solutions to the Homework/Exercises

  1. gcc main.c -Wl,-rpath,'$ORIGIN' -L. -lfoo then move the binary.
  2. patchelf --set-rpath /tmp ./app and rerun.
  3. 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)

  1. Define exported symbols and ensure stable signatures.
  2. Encode ABI-major in SONAME.
  3. Hide internal symbols.
  4. 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

  1. Why does SONAME use major versions for ABI changes?
  2. What is the difference between API and ABI?
  3. Why hide symbols that are not part of the public API?

Check-Your-Understanding Answers

  1. Because ABI breaks require a loader-visible identity change.
  2. API is source-level; ABI is binary-level.
  3. 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

  1. Create a shared library with a SONAME and observe the symlink structure.
  2. Export only one symbol and hide the rest.
  3. Break ABI intentionally and see how the loader reacts.

Solutions to the Homework/Exercises

  1. gcc -shared -Wl,-soname,libfoo.so.1 -o libfoo.so.1.0 foo.o.
  2. Use __attribute__((visibility("hidden"))) on internal symbols.
  3. 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)

  1. Host scans plugin directory.
  2. For each .so, call dlopen.
  3. Use dlsym to find entry point.
  4. Validate ABI version and function table.
  5. 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

  1. What does dlopen return and why is it not a file descriptor?
  2. Why is a function table safer than many exported symbols?
  3. What is the risk of dlclose in a hot-reload system?

Check-Your-Understanding Answers

  1. It returns a loader handle representing a loaded object.
  2. It centralizes ABI into a versioned contract.
  3. 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.html
  • dlsym(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

  1. Write a plugin that exports a versioned API struct.
  2. Add a second plugin with the same interface and load both at runtime.
  3. Simulate a broken plugin and handle errors gracefully.

Solutions to the Homework/Exercises

  1. Export plugin_get_api and return a struct with function pointers.
  2. Load both plugins and call them in sequence.
  3. If dlopen or dlsym fails, print dlerror() 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)

  1. Loader maps code and data segments.
  2. Apply relocations (preferably data-only).
  3. Mark RELRO sections read-only.
  4. 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

  1. Why are text relocations bad for memory sharing?
  2. What does copy-on-write mean for shared libraries?
  3. Why does initialization order matter for memory safety?

Check-Your-Understanding Answers

  1. They modify code pages, which prevents sharing across processes.
  2. Shared pages stay shared until a process writes to them.
  3. 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

  1. Measure RSS of a program with and without a large shared library.
  2. Build a non-PIC shared library and observe loader warnings/errors.
  3. Inspect relocation entries for text relocations and explain their impact.

Solutions to the Homework/Exercises

  1. Use pmap or /proc/<pid>/smaps to compare memory.
  2. gcc -shared foo.o without -fPIC and observe loader warnings.
  3. 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):

  1. Read Chapter 1 (Compilation & Linking) and Chapter 3 (PIC/GOT/PLT).
  2. Compile a tiny shared library and run readelf -d on it.
  3. Start Project 3 (LD_PRELOAD Interceptor). Use Hint 1 and intercept puts().

Day 2 (4 hours):

  1. Extend your interceptor to log open() calls.
  2. Use LD_DEBUG=libs,bindings to see symbol resolution in action.
  3. 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.


Best for: Engineers who want immediate practical debugging power.

  1. Project 3 (LD_PRELOAD Interceptor) - fastest insight into loader behavior
  2. Project 2 (Dependency Visualizer) - debugging real-world failures
  3. Project 1 (Plugin Audio Processor) - stable ABI design
  4. Project 6 (Minimal Dynamic Linker) - full mastery

Path 2: The Plugin Engineer

Best for: People building extensible systems or tools.

  1. Project 1 (Plugin Audio Processor)
  2. Project 4 (Hot-Reload Server)
  3. Project 5 (Cross-Platform Library)

Path 3: The ABI Steward

Best for: Library maintainers shipping stable APIs.

  1. Project 5 (Cross-Platform Library)
  2. Project 2 (Dependency Visualizer)
  3. 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, and LD_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 exported
  • nm -D: dynamic symbols in a shared library
  • ldd: loader-resolved dependencies
  • LD_DEBUG=libs,bindings: loader decision tracing
  • otool -L (macOS): library dependencies
  • dumpbin /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:

  1. A host CLI that lists discovered plugins and their metadata.
  2. Audio processing output with a clear effect chain.
  3. 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

  1. Dynamic loading APIs
    • How does dlopen manage lifetime?
    • How does dlsym resolve symbol names?
    • Book Reference: TLPI Ch. 42
  2. ABI stability
    • What breaks ABI when you change a struct?
    • How do you version an interface?
    • Book Reference: Drepper, “How To Write Shared Libraries”
  3. PIC and GOT/PLT
    • Why must plugins be built with -fPIC?
    • Book Reference: CSAPP Ch. 7

Questions to Guide Your Design

  1. Plugin API
    • How will the host verify plugin version compatibility?
    • Will you use a function table or many exported symbols?
  2. Error Handling
    • How will you report missing symbols without crashing?
    • How do you handle partially loaded plugins?
  3. 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

  1. “Why is -fPIC required for shared libraries?”
  2. “What is the difference between dlopen and static linking?”
  3. “How do you prevent ABI breakage in plugins?”
  4. “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_api is 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 dlopen and 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

  1. ELF dynamic section
    • Where is DT_NEEDED stored?
    • Book Reference: TLPI Ch. 41
  2. Loader search order
    • What is the precedence of RPATH/RUNPATH?
    • Book Reference: TLPI Ch. 41
  3. Graph traversal
    • How do you avoid cycles in dependency graphs?
    • Book Reference: Algorithms, Fourth Edition - Ch. 4 (Graph traversal)

Questions to Guide Your Design

  1. How will you resolve libfoo.so.1 to a real path?
  2. How will you detect circular dependencies?
  3. 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

  1. “Where does the loader find DT_NEEDED entries?”
  2. “What is the difference between RUNPATH and RPATH?”
  3. “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 ldconfig cache 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 ldd output 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

  1. Symbol resolution order
    • Why does preloading take precedence?
    • Book Reference: TLPI Ch. 42
  2. RTLD_NEXT
    • How do you call the real function?
    • Book Reference: TLPI Ch. 42
  3. Thread safety
    • How do you avoid recursion in hooks?
    • Book Reference: APUE Ch. 11-12 (Threads, Thread Control)

Questions to Guide Your Design

  1. How will you avoid infinite recursion when intercepting malloc?
  2. What data will you log without creating a performance bottleneck?
  3. 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

  1. “Why does LD_PRELOAD override libc functions?”
  2. “What is RTLD_NEXT and why do you need it?”
  3. “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 printf inside malloc hook
  • Fix: use write or low-level logging

Problem 2: “Symbol not found”

  • Why: using wrong symbol name (mangled)
  • Fix: ensure C linkage or use nm -D to 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

  1. Runtime loading APIs
    • How do you load/unload safely?
    • Book Reference: TLPI Ch. 42
  2. State isolation
    • How do you keep state outside the plugin?
    • Book Reference: C Interfaces and Implementations - Ch. 2 (Interfaces and Implementations)
  3. Memory mapping & relocation
    • What happens to code addresses after reload?
    • Book Reference: Drepper

Questions to Guide Your Design

  1. What state must persist across reloads?
  2. How will you prevent calls into stale function pointers?
  3. 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

  1. “Why is hot reload hard in C?”
  2. “How do you preserve state across reloads?”
  3. “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 dlclose and 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

  1. Symbol visibility
    • How do you export only the public API?
    • Book Reference: TLPI Ch. 42
  2. Calling conventions
    • What differs between Windows and System V ABI?
    • Book Reference: System V ABI specs
  3. Versioning
    • How do you manage SONAME and DLL versions?
    • Book Reference: Drepper

Questions to Guide Your Design

  1. What is your minimal C API? (avoid structs in public ABI)
  2. How will you handle allocation ownership across FFI boundaries?
  3. 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

  1. “Why is C ABI the lingua franca for shared libraries?”
  2. “How do you export symbols on Windows vs Linux?”
  3. “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 .def file 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

  1. ELF program headers
    • How do you map PT_LOAD segments?
    • Book Reference: Linkers and Loaders Ch. 10
  2. Relocations
    • Which relocations are required to start a program?
    • Book Reference: CSAPP Ch. 7
  3. Symbol resolution
    • How do you find symbols in .dynsym?
    • Book Reference: TLPI Ch. 41

Questions to Guide Your Design

  1. Which subset of relocations will you support first?
  2. How will you locate and map libc?
  3. 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

  1. “What is the role of the dynamic linker?”
  2. “Why do ELF binaries contain a dynamic section?”
  3. “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 .dynsym and .dynstr correctly

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.