The object-file cabinet

SYSTEMS TOOLCHAIN · 13 SEPTEMBER 2026 · ABOUT 20 MINUTES WITH THE LINKER LAB

What does ar actually build?

The compiler has produced several .o files. Then one quiet command—ar rcs libtiny.a …—turns them into a library. It did not compile, link, or make anything runnable. Its real job becomes visible only when another program asks the linker for one symbol.

Find your place in this lesson

The build stops one step before a program

Consider this ordinary build fragment:

$ gcc -g -c thread.c -o thread.o
$ gcc -g -c queue.c  -o queue.o
$ ar rcs libtinythreads.a thread.o queue.o

The -c tells GCC to compile and assemble each source file but stop before linking. The outputs are relocatable object files. They already contain machine instructions, but their final addresses are not fixed and some names may still point outside the file.

The last line invokes the archiver, conventionally called ar. It stores the object files as named members inside one archive and writes an index of the symbols they define. The result is convenient linker input, not an executable.

This distinction matters because the archive has deferred a decision. It has not decided where the object code will live or whether every member is needed. The final linker makes those decisions separately for each program that uses the library.

Build a library small enough to see through

We will carry three files through the real GNU toolchain. The application calls double_it. The library contains that function and a second function that nobody calls.

/* main.c */
#include <stdio.h>
int double_it(int x);

int main(void) {
    printf("%d\n", double_it(21));
    return 0;
}

/* math.c */
int double_it(int x) { return x * 2; }

/* unused.c */
int hidden_bonus(void) { return 99; }

The commands below were run on an x86-64 Ubuntu 24.04 environment with GCC 13.3 and GNU Binutils 2.42. First, compile each translation unit independently:

$ gcc -Wall -Wextra -O0 -c main.c math.c unused.c
$ file main.o math.o unused.o
main.o:   ELF 64-bit LSB relocatable, x86-64, ...
math.o:   ELF 64-bit LSB relocatable, x86-64, ...
unused.o: ELF 64-bit LSB relocatable, x86-64, ...

$ readelf -h math.o | grep -E 'Type:|Machine:'
  Type:    REL (Relocatable file)
  Machine: Advanced Micro Devices X86-64

REL is the ELF file type for relocatable code. “Relocatable” does not mean portable across instruction sets. These files contain x86-64 instructions and relocation records for an x86-64 linker. Putting them in a .a archive will not translate them to Arm.

An object file is a promise plus a list of debts

Run nm -g to inspect globally visible symbols:

$ nm -g main.o math.o unused.o

main.o:
                 U double_it
0000000000000000 T main
                 U printf

math.o:
0000000000000000 T double_it

unused.o:
0000000000000000 T hidden_bonus

T means the symbol is defined in the object’s text—the machine-code section. U means undefined here: main.o contains a call site for double_it, but not its implementation. It also owes the linker a definition of printf.

main.o

defines: mainneeds: double_it, printf

math.o

defines: double_it

unused.o

defines: hidden_bonus

The address shown as zero does not say every function will execute at address zero. At this stage, the symbol’s value is relative to its section. The final linker lays sections into an address space, resolves cross-file references, and patches relocation sites.

If ar is not the linker, what useful work can it do with these unresolved names?

Read ar rcs one letter at a time

$ ar rcs libtiny.a math.o unused.o
$ file libtiny.a
libtiny.a: current ar archive

$ ar t libtiny.a
math.o
unused.o
rInsert the named members, replacing same-named members already in the archive.
cCreate the archive if absent, while suppressing the warning that creation was implicit.
sWrite or refresh an index mapping defined symbols to the members that contain them.

The letters are compact because ar predates modern long-option conventions. r is the operation; c and s modify it. Rebuilding after changing math.o replaces that member. It does not inspect your C source or understand functions as C declarations; it handles archive members and the symbols found in relocatable object formats.

The index is the part that makes the archive useful as a library. GNU ar documents nm -s as a way to print it:

$ nm -s libtiny.a

Archive index:
double_it in math.o
hidden_bonus in unused.o

math.o:
0000000000000000 T double_it

unused.o:
0000000000000000 T hidden_bonus

The index is a directory: “if the linker needs double_it, inspect math.o.” It avoids searching every member’s full contents. Running ranlib libtiny.a would build the same kind of index; with GNU tools, the s modifier already did it.

The linker treats the archive as a demand-loaded shelf

Now link the application:

$ gcc main.o -L. -ltiny -o demo
$ ./demo
42

-L. adds the current directory to the library search path. -ltiny asks for a library named tiny; the conventional static filename is libtiny.a. GCC drives the linker and also supplies startup objects and standard libraries, which later satisfy printf.

Walk the important part as a symbol transaction:

1. read main.o
  definitions seen: main
  unresolved: double_it, printf

2. search libtiny.a
  index says math.o defines double_it
  extract math.o; resolve double_it
  hidden_bonus is not needed → leave unused.o on the shelf

3. search compiler-supplied libraries
  resolve printf
  unresolved set is empty → link succeeds

The GNU link map records the reason a member was selected:

$ gcc main.o libtiny.a -Wl,-Map=demo.map -o demo-map
$ head -3 demo.map
Archive member included to satisfy reference by file (symbol)
libtiny.a(math.o)             main.o (double_it)

And the executable’s symbols confirm the result:

$ nm -g demo | grep -E 'double_it|hidden_bonus'
0000000000001178 T double_it

double_it arrived in the executable. hidden_bonus did not. A normal static-library link does not blindly paste every archive member into every program; it extracts members that satisfy references encountered during the link.

Link order is the moment the mechanism becomes undeniable

GNU ld processes file inputs where they appear on the command line. An ordinary archive is searched to satisfy symbols that are unresolved at that point. Put the archive before the object that creates the need:

$ gcc -L. -ltiny main.o -o wrong-order
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0xe): undefined reference to `double_it'
collect2: error: ld returned 1 exit status

When libtiny.a was examined, the unresolved set did not yet contain double_it, so no member was selected. Only afterward did main.o create the unresolved reference. The ordinary link did not travel backward and search that archive again.

Step through both orders below. Watch the unresolved-symbol set at the exact moment the archive is visited.

This is also why libraries usually appear after the object files that use them:

gcc main.o worker.o -L. -ltinythreads -o app
    ↑ needs first        ↑ supplier afterward

Circular dependencies among multiple static libraries complicate the one-pass story. GNU ld provides archive groups that repeatedly search a set until no new references are created. That is an escape hatch, not a reason to forget the default mechanism.

What “static” means—and what it does not

Static library: .a

An archive of relocatable objects used during the link. Needed members contribute code to the resulting executable. The program does not need libtiny.a present at runtime.

Shared library: .so

A loadable shared object. The executable can retain a dynamic dependency, and the runtime loader maps a compatible library when the program starts.

Our executable still uses shared system libraries:

$ ldd ./demo
    linux-vdso.so.1 (...)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (...)
    /lib64/ld-linux-x86-64.so.2 (...)

libtiny.a does not appear because it is not a runtime dependency. Its selected member was incorporated at link time. This does not make the whole executable “fully static”; libc is still dynamically linked. Static versus dynamic can be decided per library.

Nor does a .a guarantee that every byte is copied. The ordinary archive rule selects needed members. Finer removal can depend on how functions were placed into sections and which linker garbage-collection options were enabled. The useful default model is member-level demand loading, then refine it when build flags make section-level behavior relevant.

Return to the quiet build line

We can now read the command without treating it as punctuation:

ar rcs libtinythreads.a thread.o queue.o

It says: create or update an archive named libtinythreads.a; replace its thread.o and queue.o members with these versions; and write an index from their defined symbols to those members. Nothing runs. No unresolved reference is resolved. No final address is chosen.

Later, a program’s link command places its own object files before the archive. Those objects create unresolved function names. The linker consults the archive index, extracts only the object members that pay those debts, lays their sections into the final image, and patches the call sites. That later demand is the reason ar preserved separate object members instead of flattening them into one executable.

Change one assumption: replace ar with gcc -r math.o unused.o -o combined.o. That performs a partial link and produces one larger relocatable object. When a later program needs double_it, it now selects combined.o as a whole; hidden_bonus travels in the same input member unless section garbage collection removes it. The archive’s preserved member boundary was doing real selection work.

Retrieval check: diagnose the failed command

gcc -L. -ltiny main.o -o demo finds the correct archive and the archive contains the correct definition, yet the link fails. The failure is not compilation or architecture. At the instant the linker searched libtiny.a, it had not read main.o, so double_it was not unresolved and math.o was not extracted. Put main.o before -ltiny.