undefined reference to 'crc32_init' — and the function is right there in
the library you passed. Every C programmer has lost an afternoon to this, and
the afternoon is refundable: the error comes from the linker, the linker’s
rules are few and mechanical, and one of them (the archive ordering rule) is
responsible for most of the pain. The linker deserves an hour of study; it
repays in never being confused by builds again.
Object Files: Compiled, But Full of Holes
The compiler processes one translation unit at a time and emits an object file: machine code and data, plus two tables that make linking possible. The symbol table lists what this file defines and what it needs:
$ nm main.o
0000000000000000 T main # T: defined, in .text
U crc32_init # U: undefined — someone else's problemThe relocations are the holes: every call or reference to an undefined
(or address-unknown) symbol is emitted as a placeholder, with an entry saying
“patch a 32-bit PC-relative address here once you know where crc32_init
lives” (objdump -r shows them). The linker’s whole job is the obvious
two-step: merge sections from all inputs into one layout, then resolve every
U against exactly one T and patch every relocation. Errors are just the two
failure cases — no T found (undefined reference), two strong Ts found
(multiple definition).
The Archive Rule That Causes Everything
A static library (.a) is not linked wholesale — it’s a bag of object
files consulted like a lending library: the linker walks the command line
left to right, and pulls an object from an archive only if it satisfies
a currently-unresolved symbol. Then that object’s own needs join the list.
Consequences, all famous:
gcc -lcrc main.cfails whilegcc main.c -lcrcworks: when-lcrcwas scanned, nothing was undefined yet, so nothing was pulled; main.o’s needs arrived too late. Libraries go after the code that uses them, in dependency order (--start-group/--end-groupfor genuine cycles).- Object files with only constructors/registrations (plugin self-registration
via
__attribute__((constructor))) get silently dropped — nobody referenced them.--whole-archive(or-u symbol, or-Wl,--undefined=) is the escape hatch, and half the “my plugin doesn’t register in the static build” bugs in existence.
Shared libraries behave differently (recorded as a DT_NEEDED dependency,
resolved at load time by ld.so — a separate story), which is why the same
link line can work with .so and fail with .a.
The Rules Worth Knowing Beyond That
Strong vs weak. Two strong definitions = error; a weak one (__attribute__((weak)))
yields to a strong one — the mechanism under overridable defaults and libc’s
interposable functions. Historical wart in the same family: pre-C17 “common”
symbols let multiple tentative int x; definitions silently merge across
files — -fno-common (now the default) rightly made that an error.
Sections and garbage collection. Code lands in sections; with
-ffunction-sections -fdata-sections each function gets its own, and
--gc-sections drops every one unreachable from the entry point — the
standard embedded-firmware diet. The linker script controls where sections
land in memory, which on bare metal (my bootloader taught me this one) is not
a formality: .text at the load address, .bss bounds exported as symbols
so startup code can zero it.
LTO moves the wall. Classic linking optimizes nothing across files —
inlining stops at the TU boundary. -flto stores compiler IR in the object
files and runs whole-program optimization inside the link, buying cross-file
inlining and dead-code elimination at the price of link time and memory
(mold/lld exist because link time became the bottleneck it is).
The diagnostic toolkit, in the order reached for: nm (what does this
object/library define and need), c++filt for mangled names (a C++
undefined reference with a mangled symbol usually means an extern "C"
mismatch or a missing template instantiation), -Wl,--trace-symbol=foo (who
defines/references foo during the actual link), and --print-map when you
need the full where-did-everything-go story.
Takeaways
- Objects = code + symbol table + relocation holes; linking = merge, resolve U↔T, patch. Both error messages are the two resolution failures.
- Archives are scanned left-to-right, lazily: libraries after users, and —whole-archive for self-registering objects.
- Weak symbols implement overridable defaults; -fno-common ended silent tentative-definition merging.
- gc-sections + function-sections for size; linker scripts are load-bearing on bare metal; LTO relocates optimization into the link.
- Debug with nm, c++filt, and —trace-symbol before rearranging flags at random.