# What the Linker Actually Does

> Half of all confusing C build errors — undefined reference, multiple definition, static initialization order — are linker concepts wearing compiler costumes. Symbols, relocations, and the archive rule that surprises everyone.

URL: https://harshith.in/blog/what-the-linker-does/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: systems, compilers, fundamentals

`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.
