The puzzle
A project links in a vendor’s peripheral library of 300 functions and uses six of them. Will the other 294 end up in flash? It depends on two compiler flags and one linker flag. On the same project, swapping the order of two libraries on the link line changes a clean build into “undefined reference to gpio_init”, although nothing in the code changed. Both behaviours follow from how object files and libraries are organised. What is inside a .o, and how does the linker choose what to take from a .a?
STEP 1
Inside an ELF object file
Every object file on an Arm toolchain is in ELF (Executable and Linkable Format). A relocatable .o contains:
- an ELF header: the file type, target architecture and where the other tables are;
- sections:
.text,.rodata,.data,.bss(which occupies no space in the file, only a size), plus debug sections; - a symbol table (
.symtab) with its string table: one 16-byte entry per symbol, giving the name, the value (offset in its section), the size, the section index, and an info byte that packs the binding (local, global or weak) and the type (function, object); - relocation sections (
.rel.text,.rel.data): the placeholders from lesson 2.
Three binutils tools read these: objdump -h lists the sections and their sizes, nm prints the symbol table one line per symbol, and objdump -r prints the relocations. readelf -a shows everything at once.
nm condenses each symbol into a type letter. The letter comes from the section the symbol is in; its case usually comes from the binding, upper case for global (visible to other files) and lower case for local (static), with weak symbols as the exception:
| Letter | Meaning |
|---|---|
| T / t | code (.text) |
| D / d | initialised data (.data) |
| B / b | zero-initialised data (.bss) |
| R / r | read-only data (.rodata) |
| U | undefined here: needed from elsewhere |
| W / w | weak: W a weak definition, w a weak undefined reference |
The compiler turns every definition into bytes in a named section and an entry in the symbol table; nm prints each symbol with a type letter chosen by its section, upper case usually global and lower case local (W/w mark weak symbols). With -ffunction-sections and -fdata-sections each function or object gets its own section, which lets the linker discard unused ones. An ELF32 symbol-table entry is 16 bytes.
STEP 2
One section per function
By default the compiler puts all of a file’s functions into one .text section. The linker works in whole input sections, so if any function in that section is used, all of them are kept. -ffunction-sections and -fdata-sections tell GCC to place each function and each data object in its own section, named after it (.text.led_on, .bss.calls). The linker option --gc-sections then performs garbage collection: starting from the entry symbol, it keeps each section that something kept refers to, following relocations recursively, and discards the rest. That is how a program that calls six functions of a 300-function library pays only for the six (and whatever they call).
Garbage collection has one hazard. Some sections are needed but referenced by nothing: the vector table is read by the hardware at reset, not called by code. A linker script must mark such sections KEEP(), as the RP2040 SDK’s script does for its vectors; otherwise --gc-sections would discard the table and the chip would not boot.
STEP 3
Static libraries are archives
A static library (libname.a) is an archive: object files bundled into one file by ar, with an index of the symbols they define. It is not linked as a whole. GNU ld treats it like this (its manual, under -l):
- The archive is searched once, at the position where it appears on the command line.
- A member is extracted only if it defines a symbol that is currently undefined: needed by something that appeared earlier. Extracting it may add new undefined symbols, which other members of the same archive can resolve: ld keeps scanning the archive’s index until nothing new is extracted.
- An undefined symbol that appears later on the command line does not cause the archive to be searched again.
↑ This step uses the figure at the top of the page.
The consequence is a rule: list each library after everything that uses it. In the figure, main.o needs board_init from libboard.a, which needs gpio_init from libgpio.a, so the order must be main.o -lboard -lgpio. The reverse order searches libgpio.a before anyone needs gpio_init, extracts nothing, and fails. When two libraries need each other, wrap them in --start-group … --end-group, which searches them repeatedly until nothing new is needed; the manual warns that this costs link time, so use it only for real circular dependencies. And notice that uart.o is never extracted: unused members of an archive cost nothing, even without garbage collection.
STEP 4
Worked example: what nm tells you before and after linking
Run nm led.o on the object from lesson 1 (nm sorts by name):
U gpio_put
00000000 B led_count
00000000 T led_on
led_on is code at offset 0 of this file’s .text; led_count is at offset 0 of .bss; gpio_put is needed from elsewhere and has no value yet. Now nm app.elf after linking with the lesson 1 layout:
10000410 T gpio_put
20000010 B led_count
10000234 T led_on
Every value is now an absolute address. There is one subtlety: the ELF for the Arm Architecture says a Thumb function’s symbol value has bit 0 set, and readelf -s app.elf, which prints the raw table, shows led_on as 0x1000_0235. nm and objdump read symbols through the BFD library, which clears that bit for Thumb functions, so they show the instruction address, 0x1000_0234. The symbol table itself never reaches the chip: at 16 bytes per entry,
so a program with 2000 symbols carries 32 000 bytes of symbol table in its ELF file, plus names, none of which is loaded into flash. objcopy -O binary drops it entirely (lesson 1).
MYTHS AND FACTS
Common misconceptions
Linking a library copies all of it into the program
Only the archive members that resolve an undefined symbol are extracted; with --gc-sections, only the used sections of those members are kept.
Library order does not matter
With GNU ld it does: each archive is searched once, in order, so a library must come after its users, or be grouped. (LLVM’s lld is an exception: it resolves against archives regardless of their position, so an order bug may hide under lld and appear under GNU ld.)
static functions are in the symbol table as globals
They are local symbols (lower-case letter), invisible to other files; two files may each have their own static helper.
The symbol table is in the firmware
It lives in the ELF file for tools and debuggers; the flash image contains only the loadable sections.
--gc-sections works on its own
Without -ffunction-sections -fdata-sections each file has one big .text, and one used function keeps the whole file’s code.
Unreferenced means unused
Hardware reads the vector table without any code referring to it. Such sections need KEEP().
Check yourself
Answer in your head, then open the card.
What nm letter does each get: static const char banner[] = "v1";, uint8_t rx_buf[64]; (file scope), void uart_init(void) { … }?
banner: r (read-only, local). rx_buf: B (global, zero-initialised, in .bss under GCC’s default -fno-common). uart_init: T.
A link line reads main.o -lgpio -lboard, and libboard’s board.o calls gpio_init from libgpio. What happens and how do you fix it?
libgpio.a is searched first, when nothing needs gpio_init yet, so nothing is extracted. libboard’s board.o is then extracted for board_init and introduces an undefined gpio_init that no later input resolves: undefined reference. Put -lgpio after -lboard, or group them.
With --gc-sections enabled, the firmware no longer boots and the debugger shows the vector table missing from the image. Why?
Nothing in the code refers to the vector-table section; only the hardware reads it. The garbage collector discarded it. The linker script must wrap it in KEEP().
How many bytes of symbol-table entries does an ELF32 file with 750 symbols contain, and how many of those are written to flash?
bytes, plus the string table for the names. None is written to flash; the symbol table is not a loadable section.
Sources (6)
- GNU Binutils manual, “nm”, “objdump”, “readelf” and “ar” — nm’s symbol-type letters (T/t text, D/d initialised data, B/b BSS, R/r read-only data, U undefined, W/w weak; “If lowercase, the symbol is usually local; if uppercase, the symbol is global”); ar’s symbol index created with the s modifier
- GNU ld manual, “Command-line Options”: -l (archive search), -( archives -), --gc-sections; “Input Section and Garbage Collection” (KEEP) — “The linker will search an archive only once, at the location where it is specified on the command line”; a group’s archives “are searched repeatedly until no new undefined references are created”; --gc-sections keeps the entry symbol’s section and recursively marks sections referenced by relocations
- GCC manual, “Options That Control Optimization”: -ffunction-sections, -fdata-sections — “Place each function or data item into its own section in the output file if the target supports arbitrary sections”
- Linux kernel, include/uapi/linux/elf.h, struct elf32_sym (Elf32_Sym) — the ELF32 symbol-table entry: st_name, st_value, st_size (4 bytes each), st_info, st_other (1 byte each), st_shndx (2 bytes): 16 bytes
- Arm, ELF for the Arm Architecture (AAELF32), “Symbol Values” — a Thumb function symbol’s value is its address with bit zero set
- Raspberry Pi Ltd, pico-sdk 1.5.1, src/rp2_common/pico_standard_link/memmap_default.ld — a production linker script that wraps the vector table and init arrays in KEEP() so that garbage collection cannot remove them