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:

bash
$ nm main.o
0000000000000000 T main        # T: defined, in .text
                 U crc32_init  # U: undefined — someone else's problem

The 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.c fails while gcc main.c -lcrc works: when -lcrc was 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-group for 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.