All Courses
C Advanced

Static & Dynamic Libraries

A library is a collection of pre-compiled object code that you can link into your programs. Instead of copy-pasting the same utility functions into every project, you compile them once into a library and reuse them everywhere. Libraries are the foundation of code reuse in C—the standard library itself (libc) is the most obvious example: you call printf() in every program without ever recompiling its implementation.

C supports two fundamentally different ways to ship libraries: static (the code is baked into your executable at compile time) and dynamic/shared (the code lives in a separate file and is loaded at runtime). This lecture covers both, from creation to deployment, so you understand exactly what happens when you type -lm on the command line.

1. What Libraries Solve

Imagine writing a math-heavy application. You need vector_add(), matrix_multiply(), quaternion_rotate(), and a dozen other functions. Without libraries, you have these choices—and none of them are good:

  • Copy-paste the functions into every source file. Violates DRY (Don't Repeat Yourself). A bug fix means hunting down every copy.
  • Include the .c files directly. Works, but recompiles everything from source every time. Slow. Also leaky—everyone sees your implementation.
  • Compile a single monolithic executable. No modularity. Can't update one piece independently.

Libraries solve all three problems:

  • Code reuse: Write once, link many times.
  • Modularity: Each library is a self-contained module with a clean interface (header file) and a hidden implementation.
  • Distribution: Ship a library + header to users without exposing source code.
  • Compilation speed: Recompile only what changed. Libraries are already compiled.

2. Static Libraries (.a on Linux, .lib on Windows)

A static library is simply an archive of object files (.o). At link time, the linker copies the needed object code from the archive into your final executable. After linking, the library file is no longer needed—everything is self-contained inside the binary.

Creating a Static Library with ar

The ar (archiver) tool bundles .o files into a .a archive. The workflow:

# Step 1: Compile each source file to an object file (no linking)
gcc -c math_ops.c -o math_ops.o
gcc -c trig_ops.c -o trig_ops.o
gcc -c stats_ops.c -o stats_ops.o

# Step 2: Bundle the object files into a static library
ar rcs libmymath.a math_ops.o trig_ops.o stats_ops.o

# ar flags:
#   r - insert files into archive (replace if they already exist)
#   c - create the archive if it doesn't exist
#   s - write an object-file index (makes linking faster)

The lib prefix and .a suffix are convention, not magic—but compilers and linkers expect this naming pattern (see the -l flag below).

Linking Against a Static Library

# Compile main.c and link against libmymath.a
gcc main.c -L. -lmymath -o program

# -L.       : add the current directory to the library search path
# -lmymath  : link against libmymath.a (or libmymath.so)
#             gcc strips "lib" prefix and ".a"/".so" suffix automatically

You can also specify the library file directly:

gcc main.c ./libmymath.a -o program

What Happens Under the Hood

When you link statically, the linker:

  1. Reads the archive index to find which .o file contains each unresolved symbol.
  2. Extracts only the needed .o files from the archive (not the whole library).
  3. Copies their machine code and data into the final executable.
  4. Resolves all addresses so function calls go directly to the inlined code.

The result: a single, self-contained ELF binary. No external dependencies at runtime.

Advantages of Static Libraries

AdvantageWhy It Matters
Self-containedThe executable has no runtime library dependencies. Ship one file.
No version conflictsYour binary carries the exact library version it was built with. The system can't break it by upgrading libc.
Slightly fasterNo runtime linking overhead. No PLT/GOT indirection for function calls (though this is truly micro-optimisation territory).
Simpler deploymentCopy the binary to any compatible system and it runs. No LD_LIBRARY_PATH shenanigans.

Disadvantages

DisadvantageWhy It Matters
Large binariesEvery executable carries its own copy of the library code. Ten programs using libmymath.a = ten copies of the same code on disk and in memory.
No runtime updatesTo patch a security bug in the library, you must recompile and redistribute every program that links it.
Longer compile timesThe linker must process the archive each time. For large projects, this adds up.
LGPL complicationsThe LGPL requires that users be able to relink against modified versions of the library. Static linking technically satisfies this only if you also ship the object files—something most projects forget.

3. Dynamic (Shared) Libraries (.so on Linux, .dll on Windows)

A dynamic library (also called a shared object or shared library) is a separate file that is loaded and linked into a process at runtime. Unlike a static library, the code is not copied into the executable—the executable contains only references to the library's symbols, resolved when the program starts (or even later, on demand).

Position-Independent Code (-fPIC)

For a shared library to work, its code must be position-independent. This means the machine code does not contain absolute memory addresses. Instead, it uses relative addressing and indirection through the Global Offset Table (GOT), so the same code can be mapped at different virtual addresses in different processes.

# Compile with -fPIC (Position-Independent Code)
gcc -c -fPIC math_ops.c -o math_ops.o
gcc -c -fPIC trig_ops.c -o trig_ops.o

Without -fPIC, the linker will refuse to create a shared library on most 64-bit systems, or produce a library that can only be loaded at a fixed address (defeating the purpose of sharing).

Creating a Shared Library with -shared

# Create a shared library from position-independent object files
gcc -shared -o libmymath.so math_ops.o trig_ops.o

# Or do it all in one step:
gcc -fPIC -shared math_ops.c trig_ops.c -o libmymath.so

# Check what you built:
file libmymath.so
# libmymath.so: ELF 64-bit LSB shared object, x86-64, ...

Linking Against a Shared Library at Compile Time

The compile-time link step for a shared library is nearly identical to static linking:

gcc main.c -L. -lmymath -o program

# The linker finds libmymath.so, records a NEEDED entry in the ELF binary,
# and resolves symbols against it—but does NOT copy the library code.

The critical difference: at this point, the executable contains only stubs—entries in the Procedure Linkage Table (PLT) that redirect through the GOT. The actual library code is loaded later by the dynamic linker (ld.so).

Runtime Loading with dlopen, dlsym, and dlclose

Sometimes you do not want the library loaded when the program starts. Maybe it is an optional plugin, or the library path is only known at runtime. The dlfcn.h API lets you load and unload shared libraries programmatically:

#include <stdio.h>
#include <dlfcn.h>

int main() {
    /* Open the shared library. RTLD_LAZY resolves symbols on first use;
       RTLD_NOW resolves everything immediately (fails early if something
       is missing). */
    void *handle = dlopen("./libmymath.so", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "dlopen error: %s\n", dlerror());
        return 1;
    }

    /* Clear any existing error state */
    dlerror();

    /* Look up a function by name. The cast is messy but necessary. */
    double (*vector_magnitude)(double, double, double) = dlsym(handle, "vector_magnitude");
    char *error = dlerror();
    if (error != NULL) {
        fprintf(stderr, "dlsym error: %s\n", error);
        dlclose(handle);
        return 1;
    }

    /* Use the function like any other function pointer */
    double mag = vector_magnitude(3.0, 4.0, 0.0);
    printf("|(3, 4, 0)| = %f\n", mag);  /* prints 5.000000 */

    /* Unload the library (decrements reference count; unmaps if zero) */
    dlclose(handle);
    return 0;
}

Compile with -ldl to link against the dynamic linking library:

gcc plugin_loader.c -ldl -o plugin_loader

Advantages of Shared Libraries

AdvantageWhy It Matters
Smaller binariesThe library code lives in one .so file, shared across all executables. Ten programs using libmymath.so add negligible disk footprint per program.
Shared memory pagesThe OS loads a shared library's read-only code segment once into physical RAM and maps it into every process's virtual address space. Huge memory savings on multi-process servers.
Runtime updatesFix a bug in libmymath.so and every program using it picks up the fix on next launch—no recompilation needed.
Plugin architecturesdlopen() enables loading code that did not even exist when the main program was compiled. This is how web browsers load codecs, how Python loads .so C extensions, and how game engines load mods.

Disadvantages

DisadvantageWhy It Matters
Dependency hellThe runtime environment must have the exact .so version your program was linked against. A system upgrade can break your binary. This is the infamous "works on my machine" problem.
Startup overheadThe dynamic linker (ld.so) must load and resolve all shared libraries before main() runs. For programs with many dependencies (think a complex GUI app), this can add hundreds of milliseconds.
PLT indirection costEvery call into a shared library goes through the PLT to GOT indirection. Modern CPUs handle this well, but it is a measurable cost in tight loops.
ABI fragilityChanging a function signature or struct layout in the library breaks all consumers without recompilation. More on this in the ABI section below.

4. Step-by-Step: Building a Math Library Both Ways

Let us build the same library as both static and shared, then compare. Create three files:

/* mymath.h - the public interface */
#ifndef MYMATH_H
#define MYMATH_H

double vector_magnitude(double x, double y, double z);
double factorial(unsigned int n);
double power(double base, int exp);

#endif
/* mymath.c - the implementation */
#include <math.h>
#include "mymath.h"

double vector_magnitude(double x, double y, double z) {
    return sqrt(x*x + y*y + z*z);
}

double factorial(unsigned int n) {
    if (n <= 1) return 1.0;
    double result = 1.0;
    for (unsigned int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

double power(double base, int exp) {
    if (exp == 0) return 1.0;
    double result = 1.0;
    int e = (exp < 0) ? -exp : exp;
    for (int i = 0; i < e; i++) result *= base;
    if (exp < 0) result = 1.0 / result;
    return result;
}
/* main.c - program that uses the library */
#include <stdio.h>
#include "mymath.h"

int main() {
    printf("|(3, 4, 0)| = %.2f\n", vector_magnitude(3.0, 4.0, 0.0));
    printf("5! = %.0f\n", factorial(5));
    printf("2^10 = %.0f\n", power(2.0, 10));
    printf("2^-3 = %.4f\n", power(2.0, -3));
    return 0;
}

Static Build

# 1. Compile the library object
gcc -c mymath.c -o mymath.o

# 2. Create the static archive
ar rcs libmymath.a mymath.o

# 3. Compile and link main.c (statically)
gcc main.c -L. -lmymath -o program_static

# 4. Run it
./program_static

# 5. Check: the library is NOT needed at runtime
rm libmymath.a
./program_static    # Still works!

Shared Build

# 1. Compile the library object with position-independent code
gcc -c -fPIC mymath.c -o mymath.o

# 2. Create the shared library
gcc -shared -o libmymath.so mymath.o

# 3. Compile and link main.c (dynamically)
gcc main.c -L. -lmymath -o program_shared

# 4. Try to run it (this WILL FAIL without extra setup)
./program_shared
# ./program_shared: error while loading shared libraries:
#   libmymath.so: cannot open shared object file: No such file or directory

# 5. Tell the dynamic linker where to find our library
LD_LIBRARY_PATH=. ./program_shared
# Works!

Comparing File Sizes

ls -lh program_static program_shared libmymath.a libmymath.so
# Typical output on x86-64:
# -rw-r--r--  1 user user  3.8K  libmymath.a     (archive of objects)
# -rwxr-xr-x  1 user user  8.2K  libmymath.so    (shared library, PIC overhead)
# -rwxr-xr-x  1 user user   17K  program_static  (contains mymath code)
# -rwxr-xr-x  1 user user   16K  program_shared  (only stubs, smaller)

# Check what symbols are in the static binary:
nm program_static | grep vector_magnitude
# 0000000000401186 T vector_magnitude   (T = in text section, directly embedded)

# Check what symbols are in the shared binary:
nm program_shared | grep vector_magnitude
#                  U vector_magnitude   (U = undefined, resolved at runtime)

# See what shared libraries the dynamic binary needs:
ldd program_shared
#   linux-vdso.so.1
#   libmymath.so => not found      <-- our library (won't find it without LD_LIBRARY_PATH)
#   libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
#   /lib64/ld-linux-x86-64.so.2

Notice that program_static runs without libmymath.a, but program_shared needs libmymath.so present at runtime. This is the core trade-off: static = self-contained but larger; shared = leaner but dependent.

5. Library Search Paths

When you write -lmymath, the linker (at compile time) and the dynamic linker (at runtime) need to find the library file. Understanding the search order prevents "cannot find -lX" and "cannot open shared object file" errors.

Compile-Time Search (-L and -l)

The compile-time linker (ld, invoked by gcc) searches for libraries in this order:

  1. Directories specified with -L flags (in order, left to right).
  2. Directories listed in the LIBRARY_PATH environment variable.
  3. System default directories: /usr/lib, /usr/local/lib, and GCC's internal library paths.
# Specify a non-standard library path
gcc main.c -L/home/user/mylibs -L./thirdparty -lmymath -o program

# The -l flag strips "lib" and the suffix:
# -lmymath  →  looks for libmymath.so or libmymath.a
# If both exist, the linker prefers the shared library (.so) by default.
# Override with -static to force static linking:
gcc main.c -L. -static -lmymath -o program_static

Runtime Search (LD_LIBRARY_PATH, rpath, ldconfig)

The dynamic linker (/lib64/ld-linux-x86-64.so.2 on 64-bit Linux) resolves shared libraries when a program starts. It searches in this order:

  1. RPATH baked into the ELF binary (set with -rpath at link time).
  2. LD_LIBRARY_PATH environment variable (colon-separated directories).
  3. RUNPATH baked into the ELF (newer alternative to RPATH; -rpath with --enable-new-dtags).
  4. /etc/ld.so.cache (populated by ldconfig).
  5. System defaults: /lib, /usr/lib.

RPATH: Baking the Search Path into the Binary

RPATH embeds library search directories directly in the ELF executable. This is the most robust way to ship a binary with its private libraries:

# Set RPATH to a relative path (executable-relative)
gcc main.c -L./libs -lmymath -Wl,-rpath,'$ORIGIN/libs' -o program

# $ORIGIN expands at runtime to the directory containing the executable.
# So if program is in /opt/myapp/bin/program, it looks for libraries
# in /opt/myapp/bin/libs/.

# Check what RPATH is set:
readelf -d program | grep -i rpath
# 0x000000000000000f (RPATH)    Library rpath: [$ORIGIN/libs]

This is how most commercial Linux applications ship. Drop the folder anywhere and it runs—no LD_LIBRARY_PATH, no root permissions, no system-wide installation.

LD_LIBRARY_PATH: Quick Override

Useful for development and testing, but never for production deployment:

# Temporary override for one command
LD_LIBRARY_PATH=/home/user/mylibs:/opt/custom/lib ./program

# Set for the session
export LD_LIBRARY_PATH=/home/user/mylibs:$LD_LIBRARY_PATH
./program

ldconfig: System-Wide Installation

To make a library available to all programs on the system:

# 1. Copy the library to a standard location
sudo cp libmymath.so /usr/local/lib/

# 2. Update the dynamic linker cache
sudo ldconfig

# 3. Verify it is registered
ldconfig -p | grep mymath
#   libmymath.so (libc6,x86-64) => /usr/local/lib/libmymath.so

You can also add a custom directory by creating a file in /etc/ld.so.conf.d/ and running ldconfig.

6. Versioning and ABI Compatibility

Shared Library Versioning (soname)

Shared libraries on Linux follow a versioning convention using the soname (shared object name):

# Build with a versioned soname
gcc -shared -fPIC -Wl,-soname,libmymath.so.1 -o libmymath.so.1.0.0 mymath.c

# Create symlinks (convention)
ln -s libmymath.so.1.0.0 libmymath.so.1    # soname link (ABI version)
ln -s libmymath.so.1 libmymath.so          # linker name (used with -lmymath at compile time)

# The version number convention: MAJOR.MINOR.PATCH
#   MAJOR bump = ABI-breaking change (consumers must recompile)
#   MINOR bump = new features, ABI-compatible
#   PATCH bump = bug fixes only

When a program is linked against -lmymath, the linker reads the soname from libmymath.so and records libmymath.so.1 as the NEEDED entry. At runtime, the dynamic linker resolves libmymath.so.1—which can point to libmymath.so.1.0.0, libmymath.so.1.2.0, or any ABI-compatible version. This is how you can update from 1.0.0 to 1.2.3 without recompiling consumers.

What Is ABI Compatibility?

The Application Binary Interface (ABI) is the runtime contract between a library and its consumers. It includes:

  • Function signatures (name, argument types, return type—this is name mangling territory in C++).
  • Struct layouts (field order, offsets, sizes, alignment).
  • Global variable addresses and sizes.
  • Calling conventions.

In C, the ABI is simpler than in C++ (no name mangling), but it can still break. Here is what breaks ABI compatibility:

ChangeABI-Safe?Notes
Add a new function✅ YesExisting consumers do not reference it, so no conflict.
Change function implementation✅ YesAs long as the visible behaviour matches the contract.
Add a field at the end of a struct⚠️ RiskySafe only if consumers never allocate the struct themselves (always use a factory function). Otherwise the consumer's sizeof is wrong.
Change function parameter types❌ NoCaller passes arguments for the old signature; callee interprets them differently. Undefined behaviour.
Change function return type❌ NoCaller's stack layout assumes the old return size.
Reorder struct fields❌ NoConsumer code compiled against the old layout accesses wrong offsets.
Change struct field types❌ NoOffsets and sizes shift.
Remove a function❌ NoThe dynamic linker cannot resolve the symbol at startup. The program won't even reach main().
Change an enum value❌ NoIf the enum values are baked into consumer binaries (they usually are), the consumer is sending the old numeric value.

Strategies for Maintaining ABI Compatibility

  • Opaque pointers: Instead of exposing struct definitions in the header, forward-declare the struct and only provide a pointer (typedef struct mylib_ctx mylib_ctx;). Allocate and free through factory/destroy functions. The struct layout becomes completely private and can change freely.
  • Reserved padding: Add char reserved[64] fields to public structs so you can repurpose them later for new fields without growing the struct.
  • Never remove or reorder: Once a function or struct field is in a public header, it is permanent. Add new ones alongside; mark old ones as deprecated but keep them compiling.
  • Bump the soname major version: When you must break ABI, change the soname to libmymath.so.2 so old consumers continue using libmymath.so.1 and new ones link against .so.2. Both versions can coexist on the same system.

7. When to Use Which: Decision Guide

Use Static Libraries When

  • You are shipping a single, self-contained tool that should run on any compatible system with zero setup. Think busybox or a Go binary (Go statically links by default).
  • The library is small and tightly coupled to the application. The overhead of a separate .so file is not worth the deployment complexity.
  • You need reproducible, auditable builds. A statically linked binary is a time capsule: it embeds the exact code it was built with. There is no ambiguity about which version of libssl it used.
  • The target environment is minimal (embedded Linux, containers with no package manager). No dynamic linker, no problem.
  • You are writing the library yourself and the consumers are all internal. Static linking eliminates a whole class of deployment issues.

Use Shared Libraries When

  • Multiple programs on the same system use the library. Every major desktop environment works this way: one copy of GTK/Qt in RAM, shared by dozens of apps.
  • You are implementing a plugin system. dlopen() enables runtime extensibility that static linking simply cannot provide.
  • The library is large (e.g., libllvm.so, libQt5Core.so). Linking it statically into every consumer would balloon disk and memory usage.
  • System integrators need to update the library independently of the applications. A security patch to libssl.so should not require recompiling every program that uses TLS.
  • You are writing a system library intended to be the canonical implementation on a platform. Follow the soname versioning scheme.

Hybrid Approach

You can ship both and let the linker decide. Provide libmymath.a for static linking and libmymath.so for dynamic linking in the same package. The consumer controls the choice with -static or the default dynamic preference. This is what the C standard library itself does: libc.a and libc.so both exist on most systems.

8. Exercises

Exercise 1: Build and Compare a String Utility Library

Create a library (libstrutil) with three functions:

  • int str_count_char(const char *s, char c) — returns the number of times c appears in s.
  • char *str_reverse(char *s) — reverses s in place and returns s.
  • int str_is_palindrome(const char *s) — returns 1 if s reads the same forwards and backwards, 0 otherwise (use str_reverse on a copy).

Steps:

  1. Write strutil.h and strutil.c.
  2. Build libstrutil.a (static) and libstrutil.so (shared).
  3. Write a test_strutil.c that exercises all three functions.
  4. Compile and link test_strutil against both library types, producing test_static and test_shared.
  5. Run both. Verify identical output. Compare binary sizes with ls -l.
  6. Delete libstrutil.so and confirm test_static still works but test_shared fails.

Exercise 2: Plugin Loader with dlopen

Create a minimal plugin system:

  1. Define a plugin interface in plugin.h: each plugin must expose a function const char *plugin_name(void) and int plugin_run(int a, int b).
  2. Write two plugins as shared libraries: adder.so (plugin_run returns a + b) and multiplier.so (plugin_run returns a * b).
  3. Write a loader.c that takes a plugin path and two integers as command-line arguments, uses dlopen / dlsym to load the plugin, prints its name, calls plugin_run, prints the result, and calls dlclose.
  4. Handle errors: what if the .so file does not exist? What if it is not a valid shared library? What if it does not export the expected symbol? Print meaningful messages using dlerror().

Exercise 3: ABI Break Experiment

Simulate an ABI break to understand why it matters:

  1. Write a library libcounter that exposes a struct and two functions:
/* counter.h - original version */
typedef struct {
    int value;
    int step;
} Counter;
void counter_init(Counter *c, int step);
void counter_inc(Counter *c);
  1. Build libcounter.so from this version. Write use_counter.c that creates a Counter, initialises it with step 3, and increments it 5 times, printing the value after each increment. Compile and link against libcounter.so.
  2. Now modify counter.h to add a new field in the middle of the struct:
/* counter.h - modified version (ABI BREAK!) */
typedef struct {
    int value;
    int max_value;  /* new field inserted in the middle! */
    int step;
} Counter;
  1. Rebuild only libcounter.so (do NOT recompile use_counter.c). Run the program and observe what happens. The step field in use_counter's compiled code will now access the bytes that libcounter.so thinks belong to max_value. The increment will not work as expected—this is ABI breakage in action.
  2. Fix it by adding the new field at the end of the struct instead. Rebuild libcounter.so, run without recompiling use_counter, and confirm it still works. This demonstrates ABI-compatible extension.