The object-file cabinet
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.
thread.c
queue.c
thread.o
queue.o
libtinythreads.a
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, printfmath.o
defines: double_itunused.o
defines: hidden_bonusThe 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
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:
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:
↑ 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.
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.