In this lesson:
- the four stages between source text and an executable
- what each stage consumes and produces (
.cβ.iβ.sβ.oβ ELF) - translation units, symbols, and the declaration-vs-definition distinction
- why "implicit declaration" and "undefined reference" point at different stages
- where the embedded build diverges: ELF β
.bin/.hexand the linker script
One command, four stages
When you run gcc main.c -o main, it looks like one step. It's actually four tools run back to back, each transforming the previous one's output:
flowchart LR
A[main.c<br/>source] -->|preprocessor<br/>cpp| B[main.i<br/>expanded source]
B -->|compiler<br/>cc1| C[main.s<br/>assembly]
C -->|assembler<br/>as| D[main.o<br/>object file]
D -->|linker<br/>ld| E[main<br/>executable / ELF]You can stop after any stage and inspect the intermediate output:
gcc -E main.c -o main.i # stop after preprocessing
gcc -S main.c -o main.s # stop after compiling (emit assembly)
gcc -c main.c -o main.o # stop after assembling (object file, not linked)
gcc main.c -o main # all the way through to an executableStage 1, the preprocessor
The preprocessor is a text tool. It doesn't understand C; it manipulates characters. It handles every line starting with #:
#include <stdio.h>, literally pastes the contents ofstdio.hin place.#define MAX 256, text substitution: every laterMAXbecomes256.#ifdef DEBUG ... #endif, conditionally keeps or drops a block of text.
The output (main.i) is pure C with no # directives left, typically thousands of lines, because a single #include <stdio.h> drags in all its declarations.
#include <stdint.h>
#define LED_PIN 5
int main(void) {
uint32_t mask = 1u << LED_PIN; // after preprocessing: 1u << 5
return mask;
}A bug at this stage looks like fatal error: stdio.h: No such file or directory (missing include path) or a macro expanding wrong.
Stage 2, the compiler
The compiler proper takes the expanded source and translates it into assembly for your target architecture, x86-64, ARM Thumb, RISC-V, whatever you're building for. This is the stage that:
- parses C syntax and type-checks,
- runs optimizations (
-O0β¦-O3,-Osfor size), - emits human-readable assembly (
main.s).
This is where most language errors surface: type mismatches, syntax errors, and the warning implicit declaration of function 'foo', meaning you called foo but the compiler never saw a declaration (you forgot to #include its header). The compiler guesses a signature and marches on, which is a bug waiting to bite.
Stage 3, the assembler
The assembler is a near-mechanical translation from assembly mnemonics to binary machine-code object files (.o). An object file contains:
- machine code for each function (the
.textsection), - initialized globals (
.data), zero-initialized globals (.bss), constants (.rodata), - a symbol table: names this file defines (e.g.
main) and names it references but doesn't define (e.g.printf).
That last point is the key to understanding the linker.
Stage 4, the linker
Each .c file is compiled independently into its own .o, this is a translation unit. The linker's job is to combine all the object files (plus libraries) into one executable, and to resolve symbols: match every "I need printf" reference to the one place printf is defined.
main.o uart.o libc.a
βββββ ββββββ ββββββ
defines: main defines: uart_tx defines: printf, malloc, ...
needs: uart_tx needs: printf
printf
β β β
βββββββ linker βββββββββββββββ
β
resolves every "needs" to a "defines"
β
main (ELF executable)If a needed symbol has no definition anywhere, you get the classic undefined reference to 'uart_tx', a linker error, not a compiler error. The code compiled fine; there was just nothing to point the call at.
Declaration vs definition, the distinction the linker cares about
- A declaration promises a symbol exists somewhere:
int uart_tx(char c);orextern int g_count;. It produces no code or storage. - A definition actually creates it: the function body
int uart_tx(char c) { ... }, orint g_count = 0;.
The compiler is happy with just a declaration, it trusts the promise. The linker is the one that demands a definition exist. This split is exactly why the same missing function gives you a compiler warning (implicit declaration, if you also forgot to declare it) versus a linker error (undefined reference, if you declared it but never defined it).
| Symptom | Stage | Cause |
|---|---|---|
No such file or directory |
preprocessor | bad #include / missing -I path |
implicit declaration of function |
compiler | called a function with no declaration in scope |
conflicting types for |
compiler | declaration and use/definition disagree |
undefined reference to |
linker | declared & called, but never defined (or not linked) |
multiple definition of |
linker | the same symbol defined in two translation units |
The embedded twist
On a desktop, the linker produces an executable the OS loads. On a microcontroller there is no OS loader, so two extra things matter:
A linker script tells the linker the chip's memory map: put .text and .rodata in flash (e.g. 0x08000000 on STM32), put .data/.bss in RAM (0x20000000). On a PC the linker uses a default script; on bare metal you supply your own.
An objcopy step converts the ELF into a raw image the flashing tool understands:
arm-none-eabi-gcc ... -T stm32f4.ld -o firmware.elf # link with the chip's script
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin # strip ELF metadata β raw flash imageThe ELF still carries debug info and section headers for your debugger; the .bin/.hex is the stripped-down bytes that actually get written to flash.
Gotchas
- "Undefined reference" is never a compiler problem. The file compiled. You either forgot to link a
.o/library, or never wrote the definition. Check your link line, not your syntax. - Implicit declaration is silent disaster pre-C99. Calling an undeclared function makes the compiler assume
intreturn and unchecked args. With a 64-bit pointer return truncated toint, you corrupt data. Always#includethe header; compile with-Werror=implicit-function-declaration. - Header guards prevent "multiple definition" / re-include explosions. Every header needs
#ifndef X_H / #define X_H / ... / #endifor#pragma once. Without them, including a header twice re-pastes its contents. - Editing a header rebuilds every
.cthat includes it. Headers are pasted at preprocess time, so a one-line header change invalidates every translation unit that pulls it in, the reason big C projects have slow incremental builds.
TL;DR
gccruns four stages: preprocess (text expansion) β compile (C β assembly) β assemble (assembly β object file) β link (objects β executable).- Each
.cis a translation unit compiled in isolation; the linker stitches them together by resolving symbols. - A declaration promises a symbol exists (compiler trusts it); a definition creates it (linker requires it). That split explains why some errors are compiler-stage and others linker-stage.
- "implicit declaration" = compiler stage (missing header); "undefined reference" = linker stage (missing definition or unlinked object).
- Embedded adds a linker script (chip memory map) and an objcopy to turn the ELF into a raw
.bin/.hexfor flashing.