C Static and Dynamic Libraries: Build Reusable Code Like a Pro (2026)
Table of Contents
- What Are Libraries and Why Do We Need Them?
- Static Libraries (.a Files)
- Creating a Static Library Step by Step
- Dynamic/Shared Libraries (.so Files)
- Creating a Shared Library Step by Step
- Header Files and Library Interfaces
- Static vs Dynamic Linking: The Real Tradeoffs
- Runtime Loading with dlopen, dlsym, and dlclose
- Library Search Paths: LD_LIBRARY_PATH, ldconfig, rpath
- Name Conventions and Versioning
- Windows DLLs vs Unix Shared Objects
- Common Linking Errors and How to Fix Them
- Real-World Example: Building a Reusable Math Library
- Conclusion
What Are Libraries and Why Do We Need Them?
Imagine you’ve written a brilliant sorting function. It works perfectly. Now you need it in three different projects. Do you copy-paste it into each one? That’s what beginners do — and it’s a maintenance nightmare. Change one bug fix, and you’ve got to remember to update it everywhere. Libraries exist to solve exactly this problem.
A library in C is a collection of pre-compiled object files bundled together so other programs can reuse them. When you call printf(), you’re not writing that function yourself — you’re linking against the C standard library (libc). Every function you’ve ever called from a standard header was pulled from a library.
Libraries give you three things that matter:
- Code reuse — Write once, link everywhere. No duplication.
- Modularity — Break massive projects into logical components that teams can develop independently.
- Distribution — Ship compiled code without exposing source. Every commercial SDK does this.
In the Unix/Linux world, there are two flavors: static libraries (.a files) and dynamic/shared libraries (.so files). They solve the same fundamental problem but with very different tradeoffs. Let’s dig into both.
Static Libraries (.a Files)
A static library is literally an archive of object files glued together with a tool called ar (the archiver). When you link against a static library, the linker copies the relevant object code directly into your executable. The result is a standalone binary — it doesn’t need the library at runtime because the code is baked in.
The naming convention is straightforward: lib + name + .a. So a math library becomes libmath.a. The lib prefix isn’t decoration — it’s how the linker finds the library when you use the -l flag.
Key insight: Static linking is like photocopying pages from a reference book into your notebook. You’ve got everything you need, but your notebook gets thicker — and if the book gets updated, your copies are outdated.
Creating a Static Library Step by Step
Let’s build one from scratch. We’ll create a tiny utility library with two functions.
Step 1: Write the source files.
/* string_utils.c */
#include <ctype.h>
void to_uppercase(char *str) {
while (*str) {
*str = toupper((unsigned char)*str);
str++;
}
}
int count_chars(const char *str, char target) {
int count = 0;
while (*str) {
if (*str == target) count++;
str++;
}
return count;
}
/* math_utils.c */
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int power(int base, int exp) {
int result = 1;
for (int i = 0; i < exp; i++)
result *= base;
return result;
}
Step 2: Compile each source file to an object file.
gcc -c string_utils.c -o string_utils.o
gcc -c math_utils.c -o math_utils.o
The -c flag tells gcc to compile but not link. You end up with .o files — raw machine code without a main() entry point.
Step 3: Bundle them into a static library with ar.
ar rcs libmyutils.a string_utils.o math_utils.o
The flags: r inserts files (replacing if they exist), c creates the archive if it doesn’t exist, s writes an index for faster symbol lookup. You can inspect the contents with:
ar -t libmyutils.a # Lists files in the archive
nm libmyutils.a # Shows symbols (function names)
Step 4: Link against the library in your program.
/* main.c */
#include <stdio.h>
/* Declare the functions we're using */
extern void to_uppercase(char *str);
extern int factorial(int n);
int main(void) {
char greeting[] = "hello libraries";
to_uppercase(greeting);
printf("%s\n", greeting);
printf("5! = %d\n", factorial(5));
return 0;
}
gcc main.c -L. -lmyutils -o myprogram
./myprogram
The -L. tells the linker to search the current directory for libraries. The -lmyutils tells it to look for libmyutils.a (it prepends lib and appends .a automatically). Order matters here — the file that needs symbols must come before the library that provides them.
Dynamic/Shared Libraries (.so Files)
Dynamic libraries take the opposite approach. Instead of copying code into your executable, the linker leaves a reference — a bookmark. At runtime, the dynamic linker (ld-linux.so) loads the shared library into memory and resolves those references on the fly.
The naming convention: lib + name + .so. So libmyutils.so. The critical difference from static libraries is the -fPIC flag — Position Independent Code. Since the library can be loaded at any memory address, every instruction must work regardless of where it lives in the process’s address space.
Creating a Shared Library Step by Step
Using the same source files from above:
Step 1: Compile with position-independent code.
gcc -c -fPIC string_utils.c -o string_utils.o
gcc -c -fPIC math_utils.c -o math_utils.o
Step 2: Create the shared library.
gcc -shared -o libmyutils.so string_utils.o math_utils.o
That’s it. No ar needed — gcc handles the shared library format directly.
Step 3: Link your program against it.
gcc main.c -L. -lmyutils -o myprogram
Same linking command as static! The linker prefers .so over .a by default. If you want to force static linking, use -static or specify the full path to the .a file.
Step 4: Run it — but there’s a catch.
./myprogram
# Error: libmyutils.so: cannot open shared object file
The runtime linker doesn’t know where your library lives. You need to tell it:
# Quick fix for testing:
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./myprogram
# Or specify the path at compile time:
gcc main.c -L. -lmyutils -Wl,-rpath,. -o myprogram
You can verify which shared libraries your binary depends on with ldd:
ldd myprogram
Header Files and Library Interfaces
Those extern declarations we wrote in main.c are ugly and error-prone. In real projects, you create a header file that serves as the library’s public API — the contract between your library and its users.
/* myutils.h */
#ifndef MYUTILS_H
#define MYUTILS_H
/* String utilities */
void to_uppercase(char *str);
int count_chars(const char *str, char target);
/* Math utilities */
int factorial(int n);
int power(int base, int exp);
#endif /* MYUTILS_H */
The preprocessor guards (#ifndef) prevent double-inclusion. Now your main file becomes clean:
/* main.c */
#include <stdio.h>
#include "myutils.h"
int main(void) {
char msg[] = "clean code";
to_uppercase(msg);
printf("%s\n", msg);
printf("2^10 = %d\n", power(2, 10));
return 0;
}
When distributing a library, you ship two things: the compiled library file (.a or .so) and the header file(s). The header tells users what functions exist; the library provides the implementation. This separation is fundamental to every C library you’ve ever used.
Static vs Dynamic Linking: The Real Tradeoffs
This isn’t just an academic question — it’s a decision that affects deployment, security, and performance. Here’s an honest comparison:
| Factor | Static Linking | Dynamic Linking |
|---|---|---|
| Binary size | Larger — library code is copied in | Smaller — only references stored |
| Memory usage | Each process has its own copy | Shared across all processes using the library |
| Startup speed | Faster — no runtime resolution | Slightly slower — symbols resolved at load time |
| Updates/patches | Must recompile every program | Update the .so file, all programs get the fix |
| Portability | Highly portable — no dependencies | Requires correct library version on target system |
| Security patches | Dangerous — old vulnerable code stays baked in | Patch the library once, everything is fixed |
| Deployment | Simple — single binary, no dependency hell | Complex — must ensure .so files are available |
My take: Use dynamic linking for system libraries and anything security-critical (like OpenSSL — you want patches to propagate). Use static linking for standalone tools you need to deploy to unknown environments. Projects like musl libc exist specifically to make static linking practical for production deployments.
Runtime Loading with dlopen, dlsym, and dlclose
Here’s where things get powerful. Instead of linking at compile time, you can load libraries at runtime using the dl API. This is how plugin systems work — think of browser extensions, game mods, or any application that loads modules dynamically.
This technique relies heavily on function pointers to call the loaded functions.
/* plugin_loader.c */
#include <stdio.h>
#include <dlfcn.h> /* dlopen, dlsym, dlclose, dlerror */
int main(void) {
/* Load the shared library at runtime */
void *handle = dlopen("./libmyutils.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
return 1;
}
/* Clear any existing error */
dlerror();
/* Look up the 'factorial' symbol */
int (*fact)(int) = (int (*)(int))dlsym(handle, "factorial");
char *error = dlerror();
if (error) {
fprintf(stderr, "dlsym failed: %s\n", error);
dlclose(handle);
return 1;
}
/* Call the function through the pointer */
printf("7! = %d\n", fact(7));
/* Look up another symbol */
int (*pow_func)(int, int) = (int (*)(int, int))dlsym(handle, "power");
if ((error = dlerror())) {
fprintf(stderr, "dlsym failed: %s\n", error);
dlclose(handle);
return 1;
}
printf("3^4 = %d\n", pow_func(3, 4));
/* Unload the library */
dlclose(handle);
return 0;
}
Compile with the -ldl flag to link against the dynamic loading library:
gcc plugin_loader.c -ldl -o plugin_loader
./plugin_loader
The flags for dlopen control when symbols are resolved:
RTLD_LAZY— Resolve symbols only when they’re first called (faster startup)RTLD_NOW— Resolve all symbols immediately (fails fast if something’s missing)RTLD_GLOBAL— Make symbols available to subsequently loaded libraries
This mechanism is documented in detail in the Linux man page for dlopen.
Library Search Paths: LD_LIBRARY_PATH, ldconfig, rpath
When your program starts and needs libfoo.so, the dynamic linker searches in this order:
- RPATH — Embedded in the binary at compile time (set with
-Wl,-rpath,/path) - LD_LIBRARY_PATH — Environment variable, checked next
- RUNPATH — Similar to RPATH but checked after LD_LIBRARY_PATH
- /etc/ld.so.cache — A cache built by
ldconfig - Default paths —
/lib,/usr/lib, and paths in/etc/ld.so.conf
For development, LD_LIBRARY_PATH is the quick fix. For production, use ldconfig:
# Install your library to a system path
sudo cp libmyutils.so /usr/local/lib/
# Update the linker cache
sudo ldconfig
# Verify it's found
ldconfig -p | grep myutils
For self-contained binaries, embed the search path with rpath:
# Use $ORIGIN to reference the binary's own directory
gcc main.c -L. -lmyutils -Wl,-rpath,'$ORIGIN/lib' -o myprogram
The $ORIGIN trick is incredibly useful for shipping applications with bundled libraries — the binary looks for .so files relative to its own location. The GNU linker documentation at sourceware.org covers these options exhaustively.
Name Conventions and Versioning
Shared libraries on Linux follow a strict naming scheme that handles API compatibility:
libname.so.MAJOR.MINOR.PATCH
For example: libssl.so.3.0.12
- MAJOR — Incremented when the ABI breaks (incompatible changes)
- MINOR — New features added, backward compatible
- PATCH — Bug fixes only
The system uses symlinks to manage versions:
libmyutils.so → libmyutils.so.1.2.0 # Linker name (for -lmyutils)
libmyutils.so.1 → libmyutils.so.1.2.0 # SONAME (for runtime)
libmyutils.so.1.2.0 # Real file
To set the SONAME when building:
gcc -shared -Wl,-soname,libmyutils.so.1 -o libmyutils.so.1.2.0 *.o
# Create the symlinks
ln -sf libmyutils.so.1.2.0 libmyutils.so.1
ln -sf libmyutils.so.1 libmyutils.so
The SONAME is what gets recorded in the executable. So when you update from 1.2.0 to 1.3.0, programs still find libmyutils.so.1 through the updated symlink. But when you release 2.0.0 with breaking changes, the SONAME becomes libmyutils.so.2 and old programs aren’t silently broken. This convention is part of the Linux Program Library HOWTO.
Windows DLLs vs Unix Shared Objects
If you’ve ever worked on Windows, you’ve seen .dll files. They serve the same purpose as .so files but with some key differences:
| Aspect | Unix (.so) | Windows (.dll) |
|---|---|---|
| Export symbols | All symbols exported by default | Must explicitly mark with __declspec(dllexport) |
| Import library | Not needed — link directly against .so | Requires a .lib import library for linking |
| Runtime loading | dlopen() / dlsym() |
LoadLibrary() / GetProcAddress() |
| Search path | LD_LIBRARY_PATH, ldconfig, rpath | Application directory, PATH, system directories |
| Versioning | SONAME with symlinks | Side-by-side assemblies (WinSxS) or manual naming |
| PIC requirement | Required (-fPIC) |
Not required (relocation handled differently) |
Windows DLLs require more ceremony — you explicitly control what’s visible. Unix shared objects export everything unless you use visibility attributes. For cross-platform libraries, projects often use macros like this in their headers as described in the GCC visibility documentation:
#ifdef _WIN32
#ifdef MYLIB_EXPORTS
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif
#else
#define MYLIB_API __attribute__((visibility("default")))
#endif
MYLIB_API int factorial(int n);
Common Linking Errors and How to Fix Them
Every C programmer hits these. Here’s your troubleshooting cheat sheet:
1. “undefined reference to…”
# Problem: Linker can't find the function definition
# Fix: Make sure you're linking the library, and ORDER matters
gcc main.c -lmyutils -L. # WRONG order if main.c calls myutils
gcc main.c -L. -lmyutils # Correct — source before library
2. “cannot find -lmyutils”
# Problem: Linker can't find the library file
# Fix: Check the path and filename
ls libmyutils.a libmyutils.so # Does the file exist?
gcc main.c -L/correct/path -lmyutils
3. “cannot open shared object file”
# Problem: Runtime linker can't find the .so
# Fix: Update the library path
export LD_LIBRARY_PATH=/path/to/lib:$LD_LIBRARY_PATH
# Or install properly:
sudo cp libmyutils.so /usr/local/lib && sudo ldconfig
4. “symbol lookup error” / “undefined symbol”
# Problem: The .so file exists but doesn't contain the expected function
# Debug: Check what symbols the library provides
nm -D libmyutils.so | grep factorial
# If missing, rebuild the library with the correct source files
5. “relocation … can not be used when making a shared object”
# Problem: Object files weren't compiled with -fPIC
# Fix: Recompile with position-independent code
gcc -c -fPIC source.c -o source.o
Real-World Example: Building a Reusable Math Library
Let’s put it all together with a proper project structure — the kind you’d actually use in production.
mathlib/
├── include/
│ └── mathlib.h
├── src/
│ ├── arithmetic.c
│ ├── geometry.c
│ └── stats.c
├── test/
│ └── test_mathlib.c
└── Makefile
The header file (include/mathlib.h):
/* mathlib.h - A reusable math library */
#ifndef MATHLIB_H
#define MATHLIB_H
/* Arithmetic operations */
double ml_add(double a, double b);
double ml_divide(double a, double b, int *error);
/* Geometry */
double ml_circle_area(double radius);
double ml_distance(double x1, double y1, double x2, double y2);
/* Statistics */
double ml_mean(const double *data, int n);
double ml_std_dev(const double *data, int n);
#endif /* MATHLIB_H */
One of the source files (src/stats.c):
#include "mathlib.h"
#include <math.h>
double ml_mean(const double *data, int n) {
if (n <= 0) return 0.0;
double sum = 0.0;
for (int i = 0; i < n; i++)
sum += data[i];
return sum / n;
}
double ml_std_dev(const double *data, int n) {
if (n <= 1) return 0.0;
double avg = ml_mean(data, n);
double sum_sq = 0.0;
for (int i = 0; i < n; i++) {
double diff = data[i] - avg;
sum_sq += diff * diff;
}
return sqrt(sum_sq / (n - 1));
}
The Makefile (builds both static and shared):
CC = gcc
CFLAGS = -Wall -Wextra -Iinclude
SRC = src/arithmetic.c src/geometry.c src/stats.c
OBJ = $(SRC:.c=.o)
OBJ_PIC = $(SRC:.c=.pic.o)
all: libmathlib.a libmathlib.so
# Static library
libmathlib.a: $(OBJ)
ar rcs $@ $^
# Shared library
libmathlib.so: $(OBJ_PIC)
$(CC) -shared -Wl,-soname,libmathlib.so.1 -o $@ $^ -lm
# Regular object files (for static lib)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# PIC object files (for shared lib)
%.pic.o: %.c
$(CC) $(CFLAGS) -fPIC -c $< -o $@
# Build and run tests
test: libmathlib.a test/test_mathlib.c
$(CC) $(CFLAGS) test/test_mathlib.c -L. -lmathlib -lm -o test_runner
./test_runner
clean:
rm -f src/*.o libmathlib.a libmathlib.so test_runner
.PHONY: all test clean
The test file (test/test_mathlib.c):
#include <stdio.h>
#include <math.h>
#include "mathlib.h"
#define ASSERT_NEAR(a, b, eps) do { \
if (fabs((a) - (b)) > (eps)) { \
printf("FAIL: %s line %d: %.6f != %.6f\n", \
__FILE__, __LINE__, (a), (b)); \
failures++; \
} else { passed++; } \
} while(0)
int main(void) {
int passed = 0, failures = 0;
/* Test arithmetic */
ASSERT_NEAR(ml_add(2.5, 3.5), 6.0, 0.0001);
int err = 0;
ASSERT_NEAR(ml_divide(10.0, 3.0, &err), 3.3333, 0.001);
ml_divide(1.0, 0.0, &err);
if (err) passed++; else { printf("FAIL: division by zero not caught\n"); failures++; }
/* Test geometry */
ASSERT_NEAR(ml_circle_area(1.0), M_PI, 0.0001);
ASSERT_NEAR(ml_distance(0, 0, 3, 4), 5.0, 0.0001);
/* Test statistics */
double data[] = {2, 4, 4, 4, 5, 5, 7, 9};
ASSERT_NEAR(ml_mean(data, 8), 5.0, 0.0001);
ASSERT_NEAR(ml_std_dev(data, 8), 2.0, 0.01);
printf("\nResults: %d passed, %d failed\n", passed, failures);
return failures > 0 ? 1 : 0;
}
Build and test everything:
make all
make test
This is a real project pattern. The separation between include/, src/, and test/ is what you’ll see in projects like OpenSSL and curl. Understanding this structure is essential before contributing to any open-source C project.
Conclusion
Libraries are how professional C code is organized. Every time you type #include <stdio.h> and call printf, you’re using a shared library. Now you know how to build your own — both static archives with ar and shared objects with gcc -shared.
Here’s the practical takeaway: start with shared libraries for most projects. They save memory, make updates painless, and are how Linux distributions manage their entire software ecosystem. Use static linking when you need a portable, dependency-free binary — embedded systems, single-file tools, or containers where minimalism matters.
The dlopen/dlsym pattern is your gateway to plugin architectures. Master it, and you can build systems that load new functionality without recompilation — something that’s at the heart of software extensibility.
If you’re building on the concepts from this lesson, make sure your header files are clean, your functions have clear interfaces, and you understand how the preprocessor ties them together. From here, dive into the memory layout of your running programs to understand exactly where library code ends up at runtime.
The GNU C Library documentation is worth bookmarking — it’s the definitive reference for the library your programs almost certainly link against.