Loading, Linking, Libraries and ABI

Large programs are assembled from pieces built separately — your own files, the C library, third-party libraries.

Learning Objectives

Explain object files, executable images, symbols, symbol tables and name mangling.

Build and use static and shared libraries; explain the dynamic loader, run-time linking, relocation types, position-independent code, the GOT and the PLT.

Explain ABIs, calling conventions, structure layout and binary compatibility; use symbol versioning and visibility.

Explain lazy versus eager binding, library search paths and their security, dependency errors, and plugin architectures.

Use link-time and whole-program optimization, link-time checks, debug symbols and stripping.

Cross-link for embedded targets and hand off from a bootloader to an application entry point.

Plain-Language Explanation

Large programs are assembled from pieces built separately — your own files, the C library, third-party libraries. The linker glues the pieces together by matching names: every function or variable one piece *uses* must be *defined* by exactly one other piece. Libraries can be copied into the program when it is built (static) or found and connected when it starts (shared). For the pieces to work together they must agree on binary details — how arguments are passed, how structures are laid out — called the ABI.

Precise Technical Explanation

Object files and executable images Object files (relocatable ELF, COFF .obj, Mach-O MH_OBJECT) hold sections, symbols and relocations. Executables and shared libraries add program headers (segments) that tell the loader what to map where, an entry point, and dynamic-linking information.

Symbols and symbol tables .symtab lists every symbol (for linking and debugging; removable with strip); .dynsym lists only those needed at run time (exports and imports of shared objects; never stripped). Each symbol has a name, value, size, type (function/object), binding (global/local/weak) and visibility.

C++ encodes namespaces, classes and parameter types in symbol names so overloads get distinct names: int area(int, int) becomes _Z4areaii (Itanium ABI used by GCC/Clang; MSVC uses a different scheme like ?area@@YAHHH@Z). extern "C" disables mangling so C and other languages can call the function.

An archive (ar) of object files: libmathx.a. The linker pulls in only the members that resolve currently undefined symbols, in command-line order — so libraries must come after the objects that use them.

Shared/dynamic libraries Position-independent code linked into an .so with a SONAME (e.g. libmathx.so.1); the executable records NEEDED libmathx.so.1. One copy of the code is shared by every process that uses it; updating the file updates all users.

Dynamic loader and runtime linking ld-linux.so (Module 10) maps dependencies, relocates and binds symbols. Programs can also load libraries explicitly at run time: dlopen/dlsym/dlclose (POSIX), LoadLibrary/GetProcAddress (Windows).

Relocation types overview Each relocation tells the linker or loader how to patch a location: R_X86_64_PC32 (32-bit PC-relative), R_X86_64_PLT32 (call through the PLT), R_X86_64_GOTPCREL (PC-relative reference to a GOT slot), R_X86_64_RELATIVE (add the load base — for PIE/PIC data), R_X86_64_JUMP_SLOT (a PLT's GOT entry), R_X86_64_GLOB_DAT (a GOT entry for data).

Position-independent code (PIC) Code that works at any load address: it reaches its own code and data PC-relatively and other modules' symbols through the GOT, so the code pages need no patching and can be shared. PIE is the same for executables, enabling ASLR.

Global Offset Table (GOT) and Procedure Linkage Table (PLT) overview

GOT — a table of addresses in writable data, filled in by the loader; code loads an external variable's or function's address from its GOT slot.

PLT — small stubs in code; a call to puts@plt jumps through puts's GOT slot. With lazy binding, the slot initially points back into the resolver, which looks up puts on the first call and patches the slot.

ABI and binary compatibility Two binaries are compatible if they agree on calling convention, type sizes and alignment, structure layout, name mangling, exception handling, and the set of symbols and their meanings. A library that adds a field in the middle of a public struct, changes a function's parameters, or removes a symbol breaks the ABI: old programs crash or fail to load until rebuilt.

Module 5. Mixing conventions (e.g. __stdcall vs __cdecl on 32-bit Windows) corrupts the stack.

Structure layout and ABI compatibility Public structs freeze their layout. Techniques that preserve the ABI: opaque handles (typedef struct Ctx Ctx; plus accessor functions), a size field first so the library knows which version the caller compiled against, reserved fields, and adding new functions instead of changing old ones.

Versioning and symbol visibility SONAME major versions (libfoo.so.1 → .2) signal incompatible changes.

Symbol versioning (GNU) keeps several implementations of one symbol in one library (memcpy@GLIBC_2.2.5 and memcpy@@GLIBC_2.14) so old binaries keep their old behavior.

Visibility — compile with -fvisibility=hidden and mark only the public API __attribute__((visibility("default"))) (Windows: __declspec(dllexport)): smaller export tables, faster loading, fewer accidental ABI commitments.

Lazy versus eager binding (platform-dependent) Lazy binding resolves functions on first call (faster start-up); eager binding (LD_BIND_NOW=1, -Wl,-z,now) resolves everything at load time and allows the GOT to be made read-only afterwards (full RELRO), blocking GOT-overwrite attacks. Windows resolves imports at load time; delay-loading is opt-in.

DLL/shared-library search paths and security Linux search order: DT_RPATH (deprecated), LD_LIBRARY_PATH, DT_RUNPATH, /etc/ld.so.cache, default directories. $ORIGIN in a run path means "the directory of this binary". Windows searches the application directory, system directories and PATH — DLL search-order hijacking places a malicious DLL earlier in that order. Use absolute or $ORIGIN-relative run paths, SetDefaultDllDirectories, signed binaries, and never load libraries from writable or current directories in privileged programs. The loader ignores LD_LIBRARY_PATH/LD_PRELOAD for setuid programs.

Dependency errors and missing libraries error while loading shared libraries: libmathx.so.1: cannot open shared object file (not found), version 'GLIBC_2.34' not found (built against a newer library), undefined symbol: foo (ABI mismatch or missing export). ldd, readelf -d, LD_DEBUG=libs,bindings and Windows' Dependencies tool diagnose them.

A host loads plugins at run time (dlopen), looks up a well-known entry point (dlsym("plugin_init")) that returns a table of function pointers, and checks an ABI version number before using it (demonstration below). Plugins must not outlive dlclose of their code.

Link-time optimization (LTO) -flto stores IR in object files and optimises across translation units at link time — inlining a function from util.c into main.c, removing unused code program-wide.

With -fwhole-program (GCC) or -flto plus hidden visibility, the compiler can assume it sees every caller and specialise or delete functions; MSVC calls it /GL + /LTCG.

Static analysis and link-time checks The linker detects duplicate and missing definitions; -Wl,--no-undefined makes shared libraries fail to link if they have unresolved symbols; -Wl,--warn-common, ODR checking with LTO (-Wodr) and tools such as abi-compliance-checker/libabigail (abidiff) detect ABI changes between releases.

Debug symbols and stripped binaries Ship stripped binaries (strip, -s), keep the debug information separately (objcopy --only-keep-debug, .debug files, debuginfod servers, Windows PDBs) and match them by build ID to symbolise crash reports.

Cross-linking for embedded targets Embedded links use the target's linker (arm-none-eabi-ld, LLD) with a linker script, link the target's C library (newlib, picolibc) and start-up files, usually statically, and produce ELF plus .bin/.hex images (Module 16 built one).

Bootloader handoff and application entry point The application is linked at its slot's address; the bootloader validates it, sets VTOR and MSP from the application's vector table and jumps to its reset handler (Module 16). On hosted systems the "entry point" is _start, recorded in the ELF header.

Why It Matters in Real Systems

Distribution packaging, plugin systems, SDKs, game modding, browser extensions in native code, language bindings (Python C extensions, JNI) and security hardening all depend on linking and ABI discipline. "It compiles but crashes on the customer's machine" is often an ABI or library-version problem.

Key Terms

Static / shared library Archive copied in at link time / object loaded at run time

Name (with major version) a shared library is known by at run time

Encoding of C++ names and types in symbols Position-independent code / executable

Table of addresses / call stubs for external symbols Lazy / eager binding Resolve on first call / at load time

Making relocated data read-only after start-up Whether a symbol is exported from a shared object

Change that makes existing binaries incompatible Optimization across translation units at link time

Memory Layout and Execution Flow

flowchart LR
  C[main calls area] --> P[area@plt stub]
  P --> G[GOT slot for area]
  G -- first call, lazy --> R[dynamic loader resolver] --> U[patch GOT slot] --> F[area in libmathx.so]
  G -- later calls --> F

Syntax

gcc -c -fPIC mathx.c -o mathx.o                          # position-independent object
ar rcs libmathx.a mathx.o                                # static library
gcc -shared -Wl,-soname,libmathx.so.1 mathx.o -o libmathx.so.1   # shared library
gcc app.c -L. -lmathx -Wl,-rpath,'$ORIGIN' -o app        # link + run path
nm -D --defined-only libmathx.so.1                       # exported symbols
readelf -d app | grep -E 'NEEDED|RUNPATH'                # dependencies and search path

Complete Runnable Example

A small library built three ways — static, shared and as a dlopen plugin — with mangling, visibility, PLT/GOT and binding inspected. Everything below ran in WSL with GCC 11.2 and binutils 2.38.

/* m17_mathx.c — the library */
#define API __attribute__((visibility("default")))

static int helper(int x) { return x * x; }            /* internal: never exported */
int internal_but_global(int x) { return x + 1; }      /* global, but hidden by -fvisibility=hidden */
API int area(int w, int h) { return w * h; }
API int square(int x) { return helper(x); }
API const char *mathx_version(void) { return "mathx 1.2"; }
/* m17_app.c — a program using the library */
#include <stdio.h>
int area(int w, int h);
int square(int x);
const char *mathx_version(void);

int main(void)
{
    printf("%s: area(6, 7) = %d, square(9) = %d\n", mathx_version(), area(6, 7), square(9));
    return 0;
}
/* m17_plugin.c — a plugin exposing a versioned function table */
#include <string.h>
#define PLUGIN_ABI 2
struct PluginApi { int abi; const char *name; int (*transform)(int); };

static int triple(int x) { return 3 * x; }
static const struct PluginApi api = { PLUGIN_ABI, "tripler", triple };

__attribute__((visibility("default"))) const struct PluginApi *plugin_init(void) { return &api; }
/* m17_host.c — loads the plugin at run time */
#include <dlfcn.h>
#include <stdio.h>
#define PLUGIN_ABI 2
struct PluginApi { int abi; const char *name; int (*transform)(int); };
typedef const struct PluginApi *(*init_fn)(void);

int main(void)
{
    void *lib = dlopen("./libtripler.so", RTLD_NOW | RTLD_LOCAL);
    if (lib == NULL) { printf("dlopen failed: %s\n", dlerror()); return 1; }
    init_fn init;
    *(void **)&init = dlsym(lib, "plugin_init");      /* POSIX-sanctioned way to convert */
    if (init == NULL) { printf("dlsym failed: %s\n", dlerror()); dlclose(lib); return 1; }
    const struct PluginApi *api = init();
    if (api->abi != PLUGIN_ABI) { printf("incompatible plugin ABI %d\n", api->abi); dlclose(lib); return 1; }
    printf("loaded plugin '%s' (ABI %d): transform(14) = %d\n", api->name, api->abi, api->transform(14));
    dlclose(lib);
    return 0;
}
// m17_mangle.cpp — name mangling
namespace geo {
int area(int w, int h) { return w * h; }
double area(double r) { return 3.14159 * r * r; }
}
extern "C" int c_area(int w, int h) { return w * h; }
gcc -std=c17 -O2 -fPIC -fvisibility=hidden -c m17_mathx.c -o mathx.o
echo "=== static library ==="
ar rcs libmathx.a mathx.o; ar t libmathx.a
gcc -std=c17 m17_app.c libmathx.a -o app_static && ./app_static
echo "=== shared library ==="
gcc -shared -Wl,-soname,libmathx.so.1 mathx.o -o libmathx.so.1
ln -sf libmathx.so.1 libmathx.so
echo "exported symbols:"; nm -D --defined-only libmathx.so.1 | awk '{print "  " $2, $3}'
gcc -std=c17 m17_app.c -L. -lmathx -o app_noruntimepath
./app_noruntimepath 2>&1 | sed 's/^/  without a run path: /' || true
gcc -std=c17 m17_app.c -L. -lmathx -Wl,-rpath,'$ORIGIN' -o app_shared && ./app_shared
readelf -d app_shared | grep -E 'NEEDED|RUNPATH' | sed 's/^ *0x[0-9a-f]* *//'
echo "static binary $(stat -c %s app_static) bytes vs shared-linked $(stat -c %s app_shared) bytes (both still use libc.so.6)"
echo "=== PLT and GOT: how app_shared calls area() ==="
objdump -d --no-show-raw-insn app_shared | grep -E 'call.*<(area|square|mathx_version)@plt>' | sed 's/^ *[0-9a-f]*:\s*/  /'
readelf -r app_shared | grep -E 'JUMP_SLO' | awk '{print "  GOT slot", $1, $3, $5}'
echo "dynamic flags of the default Ubuntu build:"
readelf -d app_shared | grep -E 'FLAGS' | sed 's/
…

Line-by-Line Explanation

-fvisibility=hidden plus the API attribute exports exactly the three public functions; helper (static) and internal_but_global (hidden) do not appear in nm -D.

ar t lists the archive's single member; linking it copies mathx.o into app_static.

The shared library gets SONAME libmathx.so.1; a program linked without a run path fails at start-up because the loader does not search the current directory.

-Wl,-rpath,'$ORIGIN' records RUNPATH [$ORIGIN], so the program finds the library next to itself wherever it is installed.

objdump shows calls to area@plt and friends; readelf -r shows their R_X86_64_JUMP_SLOT relocations — the GOT slots the loader fills.

Ubuntu's GCC already links with -z now and -z relro by default (full RELRO: BIND_NOW plus a GNU_RELRO segment that becomes read-only after relocation), so its executables bind eagerly; linking with -z lazy restores classic lazy binding, with no BIND_NOW flag. Other distributions and toolchains choose differently — check with readelf -d.

The host dlopens the plugin, looks up plugin_init, checks the ABI number, and calls through the returned function table.

nm shows mangled names (_ZN3geo4areaEii, _ZN3geo4areaEd); nm -C demangles them; the extern "C" function keeps its plain name.

glibc exports two versions of memcpy; old binaries keep binding to the version they were linked against.

Expected Output

=== static library ===
mathx.o
mathx 1.2: area(6, 7) = 42, square(9) = 81
=== shared library ===
exported symbols:
  T area
  T mathx_version
  T square
  without a run path: ./app_noruntimepath: error while loading shared libraries: libmathx.so.1: cannot open shared object file: No such file or directory
mathx 1.2: area(6, 7) = 42, square(9) = 81
(NEEDED)             Shared library: [libmathx.so.1]
(NEEDED)             Shared library: [libc.so.6]
(RUNPATH)            Library runpath: [$ORIGIN]
static binary 16136 bytes vs shared-linked 16056 bytes (both still use libc.so.6)
=== PLT and GOT: how app_shared calls area() ===
  call   1080 <square@plt>
  call   10a0 <area@plt>
  call   10b0 <mathx_version@plt>
  GOT slot 000000003fb8 R_X86_64_JUMP_SLO square
  GOT slot 000000003fc0 R_X86_64_JUMP_SLO printf@GLIBC_2.2.5
  GOT slot 000000003fc8 R_X86_64_JUMP_SLO area
  GOT slot 000000003fd0 R_X86_64_JUMP_SLO mathx_version
dynamic flags of the default Ubuntu build:
  (FLAGS)              BIND_NOW
  (FLAGS_1)            Flags: NOW PIE
explicitly lazy build (-z lazy):
  (FLAGS_1)            Flags: PIE
relocations made read-only after start-up (RELRO segment): 1
=== plugin loaded with dlopen
…

Step-by-Step Execution Trace

Start-up of app_shared:

The kernel maps app_shared and ld-linux.

The loader reads NEEDED libmathx.so.1 and RUNPATH $ORIGIN, finds the library in the executable's directory, and maps it.

It maps libc.so.6, applies relocations, and leaves the area/square/mathx_version GOT slots pointing at the lazy resolver.

main calls mathx_version@plt → resolver → looks up mathx_version in libmathx.so.1 → patches the slot → jumps there. Later calls go straight through the slot.

Interactive Visualization

The pipeline explorer's *linker* and *loader* stages summarise static versus dynamic linking, relocation and binding.

Common Mistakes and Failure Modes

Wrong library order on the command line (gcc -lmathx app.c with static libraries).

Relying on LD_LIBRARY_PATH in production instead of proper run paths or install locations.

Exporting every symbol (default visibility) and accidentally making internals part of the ABI.

Changing a public struct or function signature without bumping the SONAME.

Calling C++ functions from C without extern "C".

Allocating memory in one library/runtime and freeing it in another (different heaps on Windows CRTs).

Unloading a plugin while its callbacks are still registered.

Corrected / Safe Pattern

/* public header: opaque handle + versioned API, stable across releases */
#ifdef __cplusplus
extern "C" {
#endif
typedef struct mathx_ctx mathx_ctx;                     /* layout hidden from users */
#define MATHX_API __attribute__((visibility("default")))
MATHX_API mathx_ctx *mathx_create(void);
MATHX_API void       mathx_destroy(mathx_ctx *ctx);     /* the library frees what it allocated */
MATHX_API int        mathx_area(const mathx_ctx *ctx, int w, int h);
#ifdef __cplusplus
}
#endif

Debugging and Diagnostic Techniques

ldd, readelf -d, objdump -p, nm -D, LD_DEBUG=libs,bindings,symbols ./app.

abidiff old.so new.so (libabigail) before every release.

Windows: dumpbin /dependents /exports, Dependencies, Process Monitor (to watch DLL search order).

macOS: otool -L, DYLD_PRINT_LIBRARIES=1.

Performance Implications

Shared libraries save RAM and disk across processes and allow updates without relinking; they cost relocation time at start-up, an indirect call per external function, and limit cross-module inlining. Static linking plus LTO gives the fastest code and simplest deployment at the cost of size and update flexibility. Hidden visibility speeds up loading and lets the compiler optimise internal calls.

Security and Safety Implications

Full RELRO + BIND_NOW, PIE and hidden visibility reduce attack surface. Control library search paths; never load plugins from untrusted locations; verify plugin signatures; prefer static linking or vendored, pinned dependencies for embedded and safety-critical products; keep debug symbols private but available for crash analysis.

Platform and Language Caveats

ELF/GNU specifics: SONAME, symbol versioning, $ORIGIN, lazy binding by default. Windows PE uses import tables, .lib import libraries and load-time binding; macOS uses two-level namespaces, @rpath, and dyld shared caches.

C++ ABI differs between MSVC and Itanium; libstdc++ had a one-time std::string ABI change (GCC 5's dual ABI).

Java, .NET and Python load code by name at run time with their own versioning (JAR/class loaders, assembly binding, import system); their native extensions still follow the platform's C ABI.

Real-World Application

Linux distributions rebuild packages when a library's SONAME changes; glibc's symbol versioning lets 20-year-old binaries keep running; browsers and game engines load plugins through versioned C interfaces; Python wheels pin a manylinux ABI baseline; embedded projects link statically with LTO and verify the map file.

Best Practices Checklist

[ ] Export only the public API (-fvisibility=hidden + explicit exports).

[ ] Keep public structs opaque or versioned; bump the SONAME on ABI breaks; check with abidiff.

[ ] Use $ORIGIN run paths or standard install locations, not LD_LIBRARY_PATH.

[ ] Build with PIE, -z now, -z relro.

[ ] Use extern "C" for cross-language interfaces.

[ ] Version plugin interfaces and check the version on load.

[ ] Keep separate debug symbols for every release.

Exercises

E1 · Beginner. gcc main.o -lm works but gcc -lm main.o fails with "undefined reference to sqrt" when linking statically. Why?

Answer: The linker scans archives once, in command-line order, extracting only members that satisfy *currently* undefined symbols. When -lm comes first nothing needs sqrt yet, so nothing is extracted.

E2 · Intermediate. A library adds a new field at the start of a public struct that applications allocate on their stack. Which existing binaries break, and how could the API have avoided it?

Answer: Every application compiled against the old header: it allocates the old (smaller) size and accesses fields at old offsets, while the new library uses new offsets — memory corruption. An opaque handle allocated by the library (or a size/version field plus append-only growth) avoids it.

E3 · Advanced challenge. Explain how LD_PRELOAD can intercept malloc in an unmodified program, why this is useful for debugging, and why the loader ignores it for setuid programs.

Answer: Libraries in LD_PRELOAD are loaded first, so their definition of malloc wins symbol resolution for every lookup (including libc's internal PLT calls); the wrapper can log and forward to the real malloc via dlsym(RTLD_NEXT, "malloc"). It powers heap profilers and leak checkers without recompilation. For setuid programs an unprivileged user could otherwise inject code into a privileged process, so secure-execution mode ignores it.

Quiz

? Why did app_noruntimepath fail to start?
- The library was compiled without -fPIC. :: It was compiled with -fPIC.
+ The loader does not search the current directory, and the executable had no RUNPATH. :: $ORIGIN fixes this.
- area was not exported. :: It was exported with default visibility.
- Static and shared libraries cannot coexist. :: They can.

? What does the PLT do?
+ Provides call stubs that jump through GOT slots, enabling lazy binding of external functions. :: area@plt jumps through area's GOT entry.
- Stores the program's local variables. :: That is the stack.
- Contains the mangled names of C++ functions. :: Names live in the symbol tables.
- Lists the shared libraries a program needs. :: That is the NEEDED entries of the dynamic section.

? Which changes break a shared library's ABI? (select all that apply)
+ Reordering fields of a public struct :: Offsets compiled into applications change.
+ Removing an exported function :: Existing binaries fail with undefined symbol.
- Changing the body of an exported function without changing its signature or semantics :: Binaries keep working.
+ Changing an exported function's parameter types :: Callers pass arguments the old w
…

Summary

Linkers resolve symbols and apply relocations; static libraries are copied in, shared libraries are mapped and bound by the dynamic loader through the GOT and PLT, lazily or eagerly. PIC and PIE make code shareable and randomisable. ABIs — calling conventions, layouts, mangling, symbol sets and versions — must stay compatible across releases; visibility, opaque types, SONAMEs and version checks keep them so. Control search paths for security, use LTO for speed, and keep debug symbols for diagnosis.

References and Further Reading

Ulrich Drepper, "How To Write Shared Libraries".

John R. Levine, *Linkers and Loaders*.

Itanium C++ ABI specification; Microsoft Docs, "Decorated names", "DLL search order".

man 8 ld.so, man 3 dlopen; libabigail documentation.