Static Vs Dynamic Linking Explained
Same source code, two very different binaries. Learn when each linking strategy actually earns its keep.
free embedded linux course
free linux device drivers course
What You Will Learn
Static linking: when it helps and what it costs
Dynamic linking, shared objects, and soname versioning
Why PIE executables changed the default in recent years
Prerequisites
Basic familiarity with readelf from the previous lecture
How the Linker Finds -l
Every program you write links against the C library automatically, without you ever typing -lc, because it is so fundamental that GCC assumes it. Any other library needs to be named explicitly with -l, dropping the lib prefix and any extension: -lm links libm, -lsqlite3 links libsqlite3. You can always verify what actually got linked into a binary after the fact:
$ aarch64-linux-gnu-gcc ep_math.c -o ep_math -lm
$ aarch64-linux-gnu-readelf -d ep_math | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libm.so.6]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
Static Linking: Everything, Bundled In
Static linking copies the actual machine code for every function your program calls, plus its dependencies, directly into the final executable at build time. There’s no separate library file needed on the target at all, because everything is self-contained.
$ aarch64-linux-gnu-gcc -static ep_hello.c -o ep_hello_static
$ ls -l ep_hello ep_hello_static
-rwxr-xr-x 1 dev dev 16840 ep_hello
-rwxr-xr-x 1 dev dev 865432 ep_hello_static
That size jump, roughly 50x in this example, is typical of statically linking against glibc, because glibc was never designed to be split finely for static use. This is exactly where musl earns its reputation: musl was built from day one to produce small, efficient static binaries, and static musl executables are commonly a fraction of the size of the equivalent static glibc build.
Static linking still earns its place in specific situations: minimal init systems that must run before any filesystem holding shared libraries is even mounted, rescue or recovery tools that need to work no matter how broken the rest of the system is, and extremely small single-purpose containers. For general application development, though, dynamic linking is almost always the better default.
Building Your Own Static Library
A static library is just an archive of object files, created with ar:
$ aarch64-linux-gnu-gcc -c ep_math_utils.c
$ aarch64-linux-gnu-gcc -c ep_string_utils.c
$ aarch64-linux-gnu-ar rc libep_utils.a ep_math_utils.o ep_string_utils.o
Link an application against it with the same familiar -l flag, pointing the linker at the archive’s directory with -L:
$ aarch64-linux-gnu-gcc ep_hello.c -lep_utils -L. -o ep_hello
Dynamic Linking: Loaded at Runtime
Dynamic linking is how most real-world software actually ships. Instead of copying library code into every binary that uses it, the executable stores a reference to the library, and the kernel’s runtime linker resolves and loads it when the program actually starts. Only one copy of the library’s code needs to sit in memory even if a dozen programs are using it simultaneously, and you can patch a security bug in the library without recompiling a single application that depends on it.
Building a shared library requires position-independent code, so the runtime linker is free to place it anywhere in memory:
$ aarch64-linux-gnu-gcc -fPIC -c ep_math_utils.c
$ aarch64-linux-gnu-gcc -fPIC -c ep_string_utils.c
$ aarch64-linux-gnu-gcc -shared -o libep_utils.so ep_math_utils.o ep_string_utils.o
Linking against it looks identical to the static case from the command line, but the result is very different: the executable now carries a reference to libep_utils.so that must be resolved at runtime.
$ aarch64-linux-gnu-gcc ep_hello.c -lep_utils -L. -o ep_hello
$ aarch64-linux-gnu-readelf -d ep_hello | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libep_utils.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
At runtime, the dynamic linker searches the standard system paths, typically /lib and /usr/lib, plus any directories listed in the LD_LIBRARY_PATH environment variable:
$ export LD_LIBRARY_PATH=/opt/lib:/opt/usr/lib
LD_LIBRARY_PATH is a handy debugging tool, but avoid relying on it in production images; bake correct library paths into your root filesystem or use rpath/ldconfig instead, since environment variables are easy to forget or misconfigure on deployed devices.
| Static | Dynamic | |
|---|---|---|
| Library code location | Copied into the executable | Separate .so file, loaded at runtime |
| Binary size | Large | Small |
| Security patch process | Recompile every dependent binary | Replace one shared library file |
| Startup dependency | None | Runtime linker must find the .so |
| Best for | Rescue tools, early-boot init, tiny containers | Nearly everything else |
Shared Library Versioning: the Soname System
Shared libraries can be updated independently of the programs that use them, which is one of dynamic linking’s biggest advantages, but it requires a versioning scheme to avoid breaking old binaries when a library changes. GNU/Linux handles this with a release version and a soname. Take a library like libjpeg, currently released as libjpeg.so.8.0.2. A symbolic link named libjpeg.so points at that file so that compiling with -ljpeg always grabs the current version:
$ readelf -d libjpeg.so.8.0.2 | grep SONAME
0x000000000000000e (SONAME) Library soname: [libjpeg.so.8]
The soname, libjpeg.so.8, encodes the interface version at the time the library was built, and it’s what gets baked into any binary linked against it, not the exact patch version. If libjpeg jumps to a new major version, 9.0.0, that breaks compatibility, the libjpeg.so symlink moves to point at the new file, but old binaries still requesting libjpeg.so.8 keep working against the old one, because both can coexist on the same system.
| libjpeg.a | static archive, host build-time only |
| libjpeg.so → libjpeg.so.8.0.2 | dev symlink, host build-time only |
| libjpeg.so.8 → libjpeg.so.8.0.2 | soname symlink, needed on target at runtime |
| libjpeg.so.8.0.2 | actual shared library, needed on target at runtime |
One Thing the Original Material Predates: PIE by Default
Modern GCC and glibc toolchains build executables as position-independent executables (PIE) by default, not just shared libraries. This is a security hardening measure that enables address space layout randomization (ASLR) for the main executable too, not just its shared libraries, making memory-corruption exploits significantly harder to pull off reliably. That’s why the file command output you’ve seen throughout this course reads “ELF … pie executable” rather than the plain “executable” you’d have seen in older toolchain output. You can opt out with -no-pie if you have a specific reason to, but leave PIE enabled unless you understand exactly why you need to disable it.
Common Mistakes
| Mistake | Why it hurts |
|---|---|
| Statically linking glibc-based apps by default for “simplicity” | Inflates image size dramatically and can silently break NSS-based name/user lookups |
| Forgetting -fPIC when building a shared library | Linking fails, or the shared library only works when loaded at one fixed address |
| Baking LD_LIBRARY_PATH into production init scripts | Fragile; a missing or wrong path silently breaks every dynamically linked program |
| Assuming the .so symlink is enough on target | Only the versioned file (libjpeg.so.8.0.2) and the soname symlink are needed at runtime; the plain .so symlink is a build-time convenience |
Best Practices
If you need small static binaries, evaluate musl before fighting glibc’s static-link limitations
Use ldconfig and correct rpath settings instead of LD_LIBRARY_PATH in shipped images
Always confirm NEEDED entries with readelf -d after changing how something links
Leave PIE enabled unless you have a measured, specific reason to disable it
Summary and Key Takeaways
Static linking bundles everything into one self-contained binary at the cost of size and harder security patching; dynamic linking keeps binaries small and patchable at the cost of a runtime dependency the linker has to resolve. The soname system is what lets multiple incompatible versions of the same shared library coexist safely on one target. And modern toolchains now build position-independent executables by default, a security improvement worth understanding rather than silently disabling.
Next, we move from linking individual libraries to cross-compiling entire third-party packages with Autotools.
FAQ
Why is my statically linked glibc binary so much bigger than expected?
glibc was not designed for fine-grained static linking, so static builds pull in far more code than a dynamically linked equivalent needs. musl is specifically designed to avoid this problem if small static binaries are a hard requirement.
What does -fPIC actually do?
It tells the compiler to generate position-independent code, meaning the code doesn’t assume it will be loaded at a fixed memory address, which is required for building shared libraries.
Is LD_LIBRARY_PATH safe to use in production?
It’s fine for debugging and development, but it’s fragile for production images. Prefer baking correct library search paths into the root filesystem with ldconfig or rpath settings instead.
What is a soname, and why isn’t the plain .so symlink enough?
The soname is the interface version encoded into a shared library at build time. The plain .so symlink is only needed on the development host for linking; the target needs the soname symlink and the actual versioned library file to run programs at all.
Why does the file command now say “pie executable” instead of just “executable”?
Modern GCC and glibc build executables as position-independent by default for security hardening (ASLR), which is a change from older toolchains that produced fixed-address executables unless explicitly told to build PIE.
Next: cross-compile real third-party packages with Autotools
