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
.cfiles 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:
- Reads the archive index to find which
.ofile contains each unresolved symbol. - Extracts only the needed
.ofiles from the archive (not the whole library). - Copies their machine code and data into the final executable.
- 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
| Advantage | Why It Matters |
|---|---|
| Self-contained | The executable has no runtime library dependencies. Ship one file. |
| No version conflicts | Your binary carries the exact library version it was built with. The system can't break it by upgrading libc. |
| Slightly faster | No runtime linking overhead. No PLT/GOT indirection for function calls (though this is truly micro-optimisation territory). |
| Simpler deployment | Copy the binary to any compatible system and it runs. No LD_LIBRARY_PATH shenanigans. |
Disadvantages
| Disadvantage | Why It Matters |
|---|---|
| Large binaries | Every 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 updates | To patch a security bug in the library, you must recompile and redistribute every program that links it. |
| Longer compile times | The linker must process the archive each time. For large projects, this adds up. |
| LGPL complications | The 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
| Advantage | Why It Matters |
|---|---|
| Smaller binaries | The library code lives in one .so file, shared across all executables. Ten programs using libmymath.so add negligible disk footprint per program. |
| Shared memory pages | The 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 updates | Fix a bug in libmymath.so and every program using it picks up the fix on next launch—no recompilation needed. |
| Plugin architectures | dlopen() 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
| Disadvantage | Why It Matters |
|---|---|
| Dependency hell | The 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 overhead | The 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 cost | Every 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 fragility | Changing 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:
- Directories specified with
-Lflags (in order, left to right). - Directories listed in the
LIBRARY_PATHenvironment variable. - 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:
- RPATH baked into the ELF binary (set with
-rpathat link time). - LD_LIBRARY_PATH environment variable (colon-separated directories).
- RUNPATH baked into the ELF (newer alternative to RPATH;
-rpathwith--enable-new-dtags). - /etc/ld.so.cache (populated by
ldconfig). - 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:
| Change | ABI-Safe? | Notes |
|---|---|---|
| Add a new function | ✅ Yes | Existing consumers do not reference it, so no conflict. |
| Change function implementation | ✅ Yes | As long as the visible behaviour matches the contract. |
| Add a field at the end of a struct | ⚠️ Risky | Safe only if consumers never allocate the struct themselves (always use a factory function). Otherwise the consumer's sizeof is wrong. |
| Change function parameter types | ❌ No | Caller passes arguments for the old signature; callee interprets them differently. Undefined behaviour. |
| Change function return type | ❌ No | Caller's stack layout assumes the old return size. |
| Reorder struct fields | ❌ No | Consumer code compiled against the old layout accesses wrong offsets. |
| Change struct field types | ❌ No | Offsets and sizes shift. |
| Remove a function | ❌ No | The dynamic linker cannot resolve the symbol at startup. The program won't even reach main(). |
Change an enum value | ❌ No | If 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.2so old consumers continue usinglibmymath.so.1and 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
busyboxor a Go binary (Go statically links by default). - The library is small and tightly coupled to the application. The overhead of a separate
.sofile 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
libsslit 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.soshould 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 timescappears ins.char *str_reverse(char *s)— reversessin place and returnss.int str_is_palindrome(const char *s)— returns 1 ifsreads the same forwards and backwards, 0 otherwise (usestr_reverseon a copy).
Steps:
- Write
strutil.handstrutil.c. - Build
libstrutil.a(static) andlibstrutil.so(shared). - Write a
test_strutil.cthat exercises all three functions. - Compile and link
test_strutilagainst both library types, producingtest_staticandtest_shared. - Run both. Verify identical output. Compare binary sizes with
ls -l. - Delete
libstrutil.soand confirmtest_staticstill works buttest_sharedfails.
Exercise 2: Plugin Loader with dlopen
Create a minimal plugin system:
- Define a plugin interface in
plugin.h: each plugin must expose a functionconst char *plugin_name(void)andint plugin_run(int a, int b). - Write two plugins as shared libraries:
adder.so(plugin_run returnsa + b) andmultiplier.so(plugin_run returnsa * b). - Write a
loader.cthat takes a plugin path and two integers as command-line arguments, usesdlopen/dlsymto load the plugin, prints its name, callsplugin_run, prints the result, and callsdlclose. - Handle errors: what if the
.sofile does not exist? What if it is not a valid shared library? What if it does not export the expected symbol? Print meaningful messages usingdlerror().
Exercise 3: ABI Break Experiment
Simulate an ABI break to understand why it matters:
- Write a library
libcounterthat 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);
- Build
libcounter.sofrom this version. Writeuse_counter.cthat creates aCounter, initialises it with step 3, and increments it 5 times, printing the value after each increment. Compile and link againstlibcounter.so. - Now modify
counter.hto 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;
- Rebuild only
libcounter.so(do NOT recompileuse_counter.c). Run the program and observe what happens. Thestepfield inuse_counter's compiled code will now access the bytes thatlibcounter.sothinks belong tomax_value. The increment will not work as expected—this is ABI breakage in action. - Fix it by adding the new field at the end of the struct instead. Rebuild
libcounter.so, run without recompilinguse_counter, and confirm it still works. This demonstrates ABI-compatible extension.