The Linker
A real program is almost never just one object file — it's typically many object files plus external libraries, all needing to be combined, with every cross-file reference correctly resolved, into one final executable. That combining and resolving is the linker's job.
The linker combines multiple object files (and libraries) into a single executable (or a shared library), resolving every reference between them — matching each call to a function, or use of a variable, defined in one file with its actual definition, wherever that definition lives.
Splitting a large program across many source files is essential for organizing real software (and for not needing to recompile everything every time one small file changes), but each file is compiled and assembled somewhat independently — the linker is what stitches these independently produced pieces back into one coherent, runnable whole, and reports an error if something a file needs is never actually found anywhere.
Every time you build a multi-file program in a compiled language, the linker runs as the step immediately after compiling and assembling each individual source file — and it's the reason an "undefined reference" error can appear even when every individual file compiles perfectly on its own.
Think of assembling a jigsaw puzzle where different sections were cut and shaped by different workshops, each following its own numbered edge-matching scheme for the pieces meant to connect to other sections. The linker is the person who takes all the separately produced sections and actually fits them together, matching each numbered edge to its correct counterpart from another section — and who would immediately flag it as a problem if an edge has no matching counterpart anywhere among the sections provided.
Neither object file alone is a complete, runnable program — main.o has an unresolved reference to add, and math.o's add is never called from within math.o itself. The linker is what connects the call in main.o to the actual implementation in math.o, and only then can a final executable be produced.
Static linking copies all the needed code from libraries directly into the final executable at link time, producing a single, self-contained (but larger) file with no runtime dependency on those libraries being separately present. Dynamic linking instead leaves a reference to the needed library, which is loaded separately (often shared across multiple running programs) either when the program starts or, less commonly, later during execution — covered fully in the future Binary and Executable Formats and Loader sections' interaction.
Symbol resolution is the linker's core task — matching every undefined symbol (a function or variable a file uses but doesn't define) referenced in one object file against a matching defined symbol somewhere among the other object files and libraries being linked together, and reporting an "undefined reference" error for any that are never actually found.
Relocation is the process of patching each of the placeholder addresses the assembler left behind (as described in the previous section) with the actual final addresses now that the linker has decided exactly where every piece of code and data will sit in the combined output.
A static library (like a .a or .lib file) is essentially just a collection/archive of pre-assembled object files, bundled together for convenient distribution and linking. A shared/dynamic library (like a .so, .dll, or .dylib file) is a separately loadable unit that dynamic linking references rather than copies wholesale into every program that uses it.
Confusing a compile error with a link error — "undefined reference to `add'" is specifically a linker error (the symbol genuinely couldn't be found anywhere provided to the linker), not a compiler error, even though it can feel similar from the programmer's point of view since both stop the build.
Forgetting to actually link against a needed library (even when its header is correctly included, satisfying the compiler), causing every symbol that library defines to fail symbol resolution at link time despite the code otherwise compiling cleanly.
Assuming static and dynamic linking are purely interchangeable choices with no real trade-offs — static linking produces larger, self-contained binaries with no runtime dependency, while dynamic linking produces smaller binaries that share library code across programs but depend on the correct shared library actually being present (and version-compatible) on the machine that eventually runs the program.
Not understanding "multiple definition" linker errors — if the exact same symbol is fully defined (not just declared) in more than one object file being linked together, the linker has no way to know which definition should actually be used, and fails.
Reading an "undefined reference" linker error as a specific, actionable signal: either a needed source/object file or library simply wasn't included in the link step, or there's a genuine typo/mismatch between a declaration and its actual definition.
Choosing static linking for a standalone tool that must run reliably with zero external dependencies, and dynamic linking for a larger system where multiple programs sharing one copy of a common library's code (in memory and on disk) meaningfully saves resources.
Understanding why changing a shared library's version on a system can silently break other, already-installed programs that dynamically link against it, if that new version isn't fully compatible with what those programs actually expect.
Static linking, concretely During static linking, the linker doesn't just reference a static library — it actually copies the specific object-file members from that library's archive that are needed to resolve otherwise-undefined symbols directly into the final executable, discarding whatever parts of the library aren't actually referenced. This is precisely why statically linking against even a small library used minimally can still noticeably increase a binary's file size.
Dynamic linking's two big variants: load-time and runtime Load-time dynamic linking resolves and loads all of a program's needed shared libraries automatically when the program starts, before its own code begins executing (handled by the dynamic loader, covered in the following Loader section). Runtime dynamic linking lets a program explicitly load a specific shared library on demand, at a moment of its own choosing while already running (via mechanisms like dlopen on Unix-like systems, or LoadLibrary on Windows) — used for things like optional plugin systems, where the exact set of libraries needed isn't known until the program is actually running.
Position-independent code and shared libraries Because a shared library might be loaded at different memory addresses in different processes (or even at different addresses across separate runs of the same process, for security reasons — address space layout randomization), shared libraries are typically compiled as position-independent code, meaning the generated machine code doesn't hard-code absolute memory addresses for its own internal references, instead using addressing schemes that remain correct no matter where in memory the library actually ends up being loaded.
What is the linker's core job?
Combining multiple object files (and libraries) into one final executable or shared library, resolving every cross-file reference by matching each undefined symbol to its actual definition somewhere among the provided files, and patching (relocating) addresses accordingly.
What's the difference between static and dynamic linking?
Static linking copies the needed library code directly into the final executable at link time, producing a larger, self-contained binary. Dynamic linking instead leaves a reference to a separately loaded shared library, producing a smaller binary that depends on that library being present (and compatible) on the machine that runs it.
What causes an "undefined reference" linker error?
A symbol (typically a function or global variable) is used somewhere in the linked object files but is never actually defined anywhere among the object files and libraries provided to the linker — the linker's symbol resolution step fails to find a matching definition.
Why are shared libraries typically compiled as position-independent code?
Because a shared library can be loaded at different memory addresses in different processes (and, with address space layout randomization, even across different runs of the same process) — position-independent code avoids hard-coding absolute addresses, so the same compiled library works correctly no matter where it ends up in memory.
main.o defines main(), calls add(int, int) -- but add() isn't defined here
math.o defines add(int, int)
Linker combines main.o + math.o:
-> resolves main.o's call to add() using math.o's actual definition
-> produces one complete executable