JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Static vs Dynamic Libraries

You have been linking against the C standard library this whole time without thinking about it. This guide opens that black box: what a library actually is, and the two very different deals you can strike — bake the code into your program, or borrow it at launch.

What a library actually is

By now you can compile a `.c` file into an object file and let the linker stitch several of them into an executable. A library is nothing more exotic than a tidy bag of object files you did not write yourself, packaged so the linker can pull out exactly the pieces you call. When you write `printf("hi")` and the program just works, it is because the C standard library shipped with your compiler held a precompiled `printf` and the linker found it for you. You have been using libraries since hello world; the only new idea here is when and how that borrowed code joins yours.

There are exactly two answers to that when, and they give the two kinds of library. A static library is folded into your executable at link time — its code is physically copied into the final file, the way Make copied your own objects together in the last guide. A dynamic library (also called a shared library) is left outside your executable and joined to it later, at load time, each time the program is launched. Same source code, same `printf`; the difference is whether a copy lives inside your file or whether your file merely carries a note saying "I need printf from libc, please find it for me".

Static: baked in at link time

Picture a static library as that archive of `.o` files plus an index of which function lives in which object. When you link against it, the linker does not blindly copy the whole bag in. It looks at the symbols your program still needs — every name it has used but not yet defined — and pulls in only the objects that supply them, then repeats until nothing is left unresolved. Call one function out of a thousand and, in the simplest case, roughly one object's worth of code lands in your executable. The result is one self-contained file: everything it needs to run is already inside it.

# build a static library from two objects, then link a program against it
gcc -c rope.c knot.c              # -> rope.o  knot.o
ar rcs libclimb.a rope.o knot.o   # bundle into the archive libclimb.a
gcc -O2 -Wall main.c -L. -lclimb -o climber
#   -L.  : look for libraries in the current directory
#   -lclimb : pull in libclimb.a  (lib... .a added automatically)
# the bytes of the functions main() calls are now COPIED inside ./climber
./climber                         # runs with no .a file present anywhere
ar builds the archive; the -L flag adds a directory to the library search path and -lclimb pulls libclimb.a in. After linking, the executable needs nothing else to run.

That self-containment is the whole appeal. The executable runs on a machine that has never heard of `libclimb.a`; you can hand someone the single file and it works. The price is paid in three coins. The file is bigger, because the library's code is duplicated inside it — and inside every other program that links the same library statically. A security fix in the library reaches you only after you rebuild and reship; the old code is welded into the old binaries forever. And if ten programs on one machine each statically embed the same 200 KiB helper, that is 2 MiB on disk and, worse, ten separate copies in memory at run time with nothing shared between them.

Dynamic: borrowed at launch

Dynamic linking makes the opposite bet. When you link against a `.so`, the linker copies no function bodies into your executable. Instead it writes down a much smaller thing: a list of the libraries you depend on ("needs libclimb.so") and a table of the symbols you will call. Your executable ships as a file full of holes — placeholders where `printf` and friends ought to be. Those holes stay empty on disk and get filled only when the program is actually launched.

The filling-in is the job of a quiet helper called the dynamic loader — on Linux a small program like `ld.so` that the kernel runs before your `main()` ever gets control. It reads the "needs" list, hunts down each `.so` along the library search path, maps it into the process's address space, and patches your empty tables so each placeholder now points at the real function in the shared library. Only then does your code start. So a dynamically linked program is finished in two stages: the linker prepares the holes at build time, and the loader fills them at launch time, every single launch.

Now the savings appear. The library's code exists once, in the `.so` file, not duplicated inside every program. Better still, the operating system can map that one physical copy into many processes at the same time, so all of them genuinely share the bytes in memory — this is why a system can run a hundred programs that all use libc without a hundred copies of libc resident. And a fix to the shared library reaches every program the next time each one launches, with no rebuild of any of them. This is exactly why your distribution ships almost everything dynamically linked against a handful of shared `.so` files.

Why shared code must be position-independent

Sharing one copy of code among many programs raises a sharp question. A function compiled the old way assumes it lives at a fixed address — when it jumps to a helper or reads a global, those go to hardcoded locations baked in at link time. But a shared library cannot demand a fixed address: it has no idea which programs will load it, and two of them may already be using that exact slice of address space for something else. Each process may have to map the same `.so` at a different virtual address, so any address welded into the code would simply be wrong somewhere.

The cure is to compile the library as position-independent code (PIC), which you ask for with the `-fPIC` flag. PIC never hardcodes an absolute address; instead it reaches globals and other functions relative to where the code happens to be loaded, through small jump tables the loader fills in. The upshot is that the very same bytes work correctly no matter which address each process maps them to — which is precisely what makes one physical copy shareable across processes that disagree about where it sits. The cost is a hair of extra indirection on some accesses; in practice it is negligible, and on shared libraries it is mandatory.

Building a shared version of the same library is two small changes to the commands from before. You add `-fPIC` when compiling the objects — `gcc -O2 -fPIC -c rope.c knot.c` — and use `-shared` instead of `ar` to link them: `gcc -shared rope.o knot.o -o libclimb.so`. Linking your program against it looks identical to the static case, `gcc -O2 -Wall main.c -L. -lclimb -o climber`, but this time no function bodies are copied in. The catch arrives at launch: the loader must actually find `libclimb.so`, so a library in a non-standard place needs a nudge, such as `LD_LIBRARY_PATH=. ./climber` to tell it to look in the current directory — a path question the next guide takes up in full.

Choosing, and the version trap

Dynamic linking's great convenience hides its great hazard. Your executable carries only a name and a promise — "I will call a function named `climb` that takes these arguments and returns this". The loader honours that promise with whatever `libclimb.so` it finds at launch, which may not be the one you built against. If someone updates the library and changes how `climb` is called — a different argument, a struct that grew a field — your old binary will happily call the new code with the old expectations and crash or, worse, silently corrupt data. This is the contract known as the ABI (application binary interface), and keeping it stable across versions is delicate work.

Two real failures make this concrete, and both have folk names. "DLL hell" on Windows and the broken-symlink errors on Linux are the same wound: the program asks for a library that is missing, or present but the wrong version. To tame it, shared libraries carry a soname with a version number, like `libclimb.so.1`, and a program records the major version it was built against; a compatible `libclimb.so.1.4` can replace `libclimb.so.1.3` precisely because the major number — the promise — has not changed. When a change does break the ABI, the major number bumps to `.so.2`, and old and new can coexist side by side without poisoning each other.