Linking Deep Dive
Symbol resolution semantics and link failure diagnosis: why archive order matters, what SONAME and RUNPATH actually control at load time, and how visibility and linker choice change both ABI surface and link speed.
Overview
From translation units to one address space
Linking is where independently compiled translation units become one address space. Each TU emitted a symbol table (what it defines, what it references) plus relocation records for every address still to be filled in. The linker binds each undefined reference to exactly one definition, lays out sections, applies relocations and records what must still happen at load time.
Left to right, and archives are lazy
The linker processes inputs strictly left to right, and it treats object files and archives by different rules.
- Object file — Unconditional: every one you name is fully linked in
- Archive — Lazy: pulls in only members that resolve symbols undefined at that moment, then never looks at the archive again
> [!IMPORTANT] The GNU ld manual says it directly: an archive is searched only once, in the order it appears on the command line. That sentence explains most wrong-library-order undefined references, why --start-group exists, and why the manual warns it has a significant performance cost.
Load time: where production incidents come from
A dynamically linked binary records DT_NEEDED entries holding each library's SONAME, not paths. At exec time, ld.so resolves each name against a defined search order.
- RUNPATH suppresses RPATH — If both are present, RPATH is ignored entirely
- RUNPATH is not transitive — It covers only the object's own direct dependencies
- LD_LIBRARY_PATH sits between them — It beats RUNPATH but loses to RPATH
Symbol visibility
By default every non-static symbol in a shared object is exported. For a C++ library that means every inline function, template instantiation and implementation-detail class lands in the dynamic symbol table and in an ABI surface you never meant to promise.
- Faster load — Fewer symbols for the loader to search and relocate
- Smaller binary — Smaller dynamic symbol table
- No clashes between libraries — Internal names stay local
- Better optimization — A hidden symbol cannot be interposed
> [!TIP] Drepper's How To Write Shared Libraries is the canonical treatment. Its central argument: export control is the single highest-leverage thing a shared library author does.
Link time is a performance problem
On a large C++ codebase with debug info, the link can take longer than compiling the file you edited, and the traditional bfd linker is single-threaded.
- -fuse-ld=lld or -fuse-ld=mold — Faster
- --gc-sections with -ffunction-sections -fdata-sections — Drops unreferenced code
- -gsplit-dwarf — Linker no longer merges gigabytes of DWARF
- LTO — Much slower link, in exchange for cross-TU optimization
> [!NOTE] Inspecting binaries (readelf, nm, objdump, ldd, c++filt) has its own topic. This one is about semantics: what the linker decided, why, and what changes the decision.
Key concepts
- Strong, weak, and common symbols
- A strong definition is a normal function body or initialized global; two of them for the same name is an error. A weak symbol (explicit attribute((weak)), or the COMDAT groups emitted for inline functions and template instantiations) may appear many times and the linker keeps one silently. Common symbols were the old C tentative-definition merge behavior; GCC 10 switched the default to -fno-common, so duplicate uninitialized globals across C files now fail to link.
- Archive extraction semantics
- A .a is a container plus a symbol index. The linker extracts only those members needed to resolve symbols that are undefined at the moment it reaches the archive, then never revisits it. This is why link order matters for -l but not for .o files, and why the fix for a circular archive dependency is --start-group / --end-group rather than reordering.
- SONAME and DT_NEEDED
- The SONAME, set with -Wl,-soname,libfoo.so.1, is the name recorded in the library and copied into every consumer as a DT_NEEDED entry. Consumers therefore depend on an ABI generation, not a filename, which is what allows multiple major versions to coexist on one system. A SONAME bump is the declaration that the old ABI is gone.
- PIC and PIE
- -fPIC makes code position independent by routing global data access through the GOT and cross-object calls through the PLT; it is mandatory for shared objects. -fpic is a cheaper variant with a target-specific GOT size limit (28k on AArch64, 32k on m68k and RS/6000, unbounded on x86). -fPIE with -pie applies the same treatment to executables so the kernel can load them at a randomized base, which is the default on modern distributions.
- RPATH, RUNPATH, LD_LIBRARY_PATH
- Search order at load: DT_RPATH (only when DT_RUNPATH is absent), then LD_LIBRARY_PATH (ignored under setuid), then DT_RUNPATH (direct DT_NEEDED entries only, never transitively), then /etc/ld.so.cache, then the default library directories. --enable-new-dtags emits RUNPATH; --disable-new-dtags emits RPATH. Embedding $ORIGIN makes the path relative to the binary, which is how relocatable install trees work.
- Symbol visibility
- -fvisibility=hidden makes every symbol local by default; you then mark the public API with attribute((visibility("default"))). -fvisibility-inlines-hidden additionally hides out-of-line copies of inline C++ member functions. The GCC manual states this substantially improves link and load times, produces more optimized code, and gives near-perfect API export control. A --version-script achieves the same at link time without touching sources.
- Link-time optimization (LTO)
- -flto emits GIMPLE bytecode into ELF sections instead of final machine code; at link time all function bodies are read back and optimized as if they were one translation unit. Enables cross-TU inlining and dead code elimination, and surfaces cross-TU declaration mismatches that per-TU compilation cannot see. Costs a much longer, much more memory-hungry link. -flto=auto parallelizes; -flto-partition controls how work is split.
- Linker choice
- ld.bfd is the GNU default, single-threaded, most compatible with linker scripts. gold was the ELF-only rewrite and is now deprecated upstream. lld ships with LLVM and is a large step faster. mold is the fastest through aggressive parallelism and is the usual pick for link-bound C++ builds. Select via -fuse-ld=bfd|gold|lld|mold, which GCC documents as an explicit option.
When to use it
- Diagnosing undefined reference or multiple definition errors where the symbol visibly exists in the source tree
- Deciding whether a component ships as a static archive or a shared object, and what that commits you to for ABI stability
- Designing the public export surface of a shared library so that internal symbols do not become an accidental ABI promise
- Debugging a binary that runs on the build machine and fails at load on the target with a missing shared object
- Attacking link time on a large C++ build where the link step dominates the edit-compile-test loop
- Reviewing whether enabling LTO is worth it, and what class of latent bugs it will surface on the way in
Interview questions
- You get "undefined reference to symbol X" but X is clearly defined in the codebase. Walk through your diagnosis.
- Explain why link order matters for static archives but not for object files, and what --start-group actually does.
- A binary runs on the build machine and dies at startup on the target with a missing shared library. Walk through the search order and the fix.
- Why does symbol visibility matter for a C++ shared library, and how would you control the export surface?
- Static library or shared library? Argue both sides for a component you ship to other teams.
- Link time dominates our edit-test loop. What do you do, and where does LTO fit?
Sign up free to read the full topic
More DevOps topics
- Kubernetes the Hard Way
- Kubescape and Kubernetes Runtime Security
- Kustomization and Reconciliation
- Lease and Reservation Control Planes
- libstdc++, libc++, glibc and Symbol Versioning
- Lifecycle Hooks and Tests
- Linux Cron & Task Scheduling
- Local Kubernetes Cluster Setup (minikube / kind / k3d)
- Log Aggregation
- Microservices Design
- Migration and Production Patterns
- Monitoring and Troubleshooting
Start free on Cariara · Coding problems · System design studio