The puzzle
You write gpio_put(LED_PIN, 1); in a file called led.c and press build. A second later a file of a few kilobytes appears, and somewhere inside it are the four bytes f000 f8e9, which the processor will execute as “call the function 466 bytes beyond the point the program counter reads as”. The name gpio_put, the macro LED_PIN, the idea of a function at all: none of it survives. Four stages of the toolchain touched the code on the way. What did each one do, and why can the number 466 only be computed at the very end?
STEP 1
Compiling for a different machine
A microcontroller cannot compile its own firmware; it has no room for a compiler and nowhere to run one. You build on a PC and run on the target, so the compiler must be a cross-compiler: it runs on one architecture and generates code for another. GNU cross toolchains are named by a target triple. In arm-none-eabi-gcc, arm is the architecture, none says there is no operating system underneath (the program owns the machine, unit 3), and eabi is the Arm Embedded ABI, the calling and object-file conventions from unit 3. Flags then narrow the target further: -mcpu=cortex-m0plus -mthumb selects the exact core and its Thumb instruction set, so the compiler never emits an instruction that core lacks.
STEP 2
One command, four stages
arm-none-eabi-gcc is a driver. For each source file it takes the code through three stages, and at the end it runs a fourth once for the whole program:
- Preprocessing. Handles every line beginning with
#: pastes the contents of#includefiles in, replaces macros such asLED_PINwith their text, and removes code excluded by#if. Output: a single, long C file. GCC normally does this inside the compiler proper as it reads the source, so the.ifile exists only if you ask for it. - Compiler proper (
cc1). Parses the C, optimises it and generates assembly language for the target (.s). This is where-O2or-Osdoes its work. - Assembler (
as). Encodes each assembly instruction into its binary form, producing an object file (.o). - Linker (
ld). Combines all object files and libraries into one executable image (.elf), deciding every address.
Three options stop the pipeline early (and -E writes out the preprocessed text), and they are the fastest way to see what a stage does: -E stops after preprocessing, -S after compilation, -c after assembly (GCC’s Overall Options). -save-temps runs the whole build but keeps all the intermediate files.
A single arm-none-eabi-gcc invocation takes each source file through preprocessing, compilation and assembly, then runs the linker once for the whole program. The stages are drawn separately; GCC normally preprocesses inside the compiler proper (cc1), so led.i exists only with -E or -save-temps. objcopy converts the ELF result into the raw binary or hex format a programmer or boot loader expects.
STEP 3
What each stage knows
Follow one function through the stages in the figure below. The preprocessed text has lost the macro: the compiler proper never sees LED_PIN, only 25, which is why a debugger cannot show you a macro’s name. The assembly has registers and instructions but still uses names for things outside this file. The object file has real machine code, with offsets counted from zero, because the assembler has no idea where in memory this code will end up or where gpio_put lives: gpio_put might be in another file, in a library, or not exist at all.
↑ This step uses the figure at the top of the page.
So the object file contains placeholders plus instructions for filling them in, called relocations (lesson 2). Only the linker sees every object file at once, so only the linker can choose addresses and fill the placeholders. That is why linking happens once, after everything else, and why “undefined reference” is a link error, not a compile error: each file compiled perfectly well on its own.
STEP 4
Worked example: computing a branch
A Thumb BL (branch with link) does not store the target address. It stores the distance from the branch to the target, measured from the branch’s own address plus 4 (the value the program counter reads as while the instruction executes):
where is the target’s address and the address of the BL itself. In the figure’s linked layout, led_on starts at 0x1000_0234, so the BL six bytes in is at = 0x1000_023A, and the linker has placed gpio_put at = 0x1000_0410:
The BL encoding splits that offset (halved, since instructions are 2-byte aligned) across two 16-bit halfwords: 466 / 2 = 233 = 0xE9 goes into the low 11 bits of the second halfword, the rest of the fields are zero or their “positive” values, and the instruction becomes f000 f8e9. In the object file the same instruction was the placeholder f7ff fffe, which the ELF for the Arm Architecture specification gives as the encoding of BL ., a branch to itself, representing an initial addend of −4. Move either function at all in the link and the four bytes change; that is why they cannot be known until the end.
STEP 5
From ELF to flash
The linker’s output is an ELF file: the machine code and data, plus section headers, symbol tables, debug information and each section’s load address. A debugger wants all of that. A chip’s flash wants only the bytes, at the right addresses. objcopy converts between the two: arm-none-eabi-objcopy -O binary app.elf app.bin writes a raw memory image, starting at the load address of the lowest section and discarding symbols and relocations; -O ihex writes Intel HEX, a text format in which every line carries its own address and a checksum. Programming tools and boot loaders accept one or more of these; some chips define their own container, such as the RP2040’s UF2 files, which carry the target address in every block.
The raw binary has one trap. It is a single contiguous dump from the lowest to the highest load address, so if any section is ever given a load address far away (say, in RAM at 0x2000_0000 while code sits at 0x1000_0000), the “binary” becomes hundreds of megabytes of padding. Lesson 4 shows how the linker script keeps every load address in flash.
MYTHS AND FACTS
Common misconceptions
The compiler makes the executable
The compiler makes assembly; the assembler makes object files; the linker makes the executable. gcc is a driver that runs all of them.
An undefined reference means the code did not compile
Each file compiled. The linker could not find a definition for a name one of them uses.
The object file contains the final addresses
It contains section-relative offsets and placeholders. Final addresses exist only after linking.
-O0 code is the ‘real’ code and optimised code is a distortion
Both implement the same C semantics. -O0 is merely easier to map back to source lines.
The .elf file is what goes into flash
The ELF describes the image; the programmer or objcopy extracts the loadable bytes from it.
Macros exist at run time
They are expanded by the preprocessor before compilation; the compiled code contains only their expansions.
Check yourself
Answer in your head, then open the card.
Which gcc option would you use to see what a macro-heavy header expands to, and which to see the instructions a loop compiles to?
-E stops after preprocessing and shows the expanded C. -S stops after compilation and writes the assembly.
A BL at 0x0800_1000 calls a function at 0x0800_0F00. What offset does the instruction encode?
bytes: a backward branch.
Why might the assembler leave the BL to gpio_put for the linker even when gpio_put is defined in the same source file?
With functions in separate sections (as -ffunction-sections arranges, lesson 3) the linker may place them anywhere, or discard one, so the distance is unknown until link time. And even within one section, gpio_put is a global symbol: a definition in another file could take its place (lesson 2), so the GNU assembler keeps a relocation for the call rather than fixing the distance itself.
A project’s .text is at 0x0000_0000 and a section wrongly given a load address of 0x2000_0000. How big is the raw binary objcopy produces?
At least 0x2000_0000 bytes, 512 MiB, almost all padding, because a raw binary is one contiguous dump from the lowest to the highest load address.
Sources (6)
- GCC manual, “Options Controlling the Kind of Output” (-E, -S, -c, -o) — -E: “Stop after the preprocessing stage”; -S: “Stop after the stage of compilation proper; do not assemble”; -c: “Compile or assemble the source files, but do not link”
- GCC manual, “Options That Control Optimization” (-O0, -Og, -O2, -Os) and “ARM Options” (-mcpu, -mthumb) — -O0 is the default and “make[s] debugging produce the expected results”; -Og is recommended for the edit-compile-debug cycle; -Os enables -O2 optimisations except those that often increase size; -mcpu accepts cortex-m0plus among others
- GCC manual, “GCC Developer Options”: -save-temps — keeps the intermediate .i, .s and .o files that a normal build deletes
- Arm, ELF for the Arm Architecture (AAELF32), “Relocation” and “Addends and PC-bias compensation” — an unrelocated Thumb BL to itself is encoded 0xf7ff, 0xfffe with R_ARM_THM_CALL and initial addend −4; R_ARM_THM_CALL computes ((S + A) | T) – P
- GNU Binutils manual, “objcopy” — “-O binary” produces “a memory dump of the contents of the input object file”; symbols and relocation information are discarded and the dump starts at the load address of the lowest section copied
- Microsoft, UF2 file format specification (README) — a UF2 file is a sequence of self-contained 512-byte blocks, each carrying the flash address its data should be written to (offset 12)