Embedded C
Compilation & Linking
"Compiling" is actually four distinct stages, and on embedded targets a fifth piece — the linker script — decides something a desktop program never has to think about: exactly which physical memory address every byte of code and data ends up at, because there's no operating system underneath handing out addresses dynamically.
- What it is
- The pipeline that turns C source into a binary the target can actually execute: preprocess, compile, assemble, link.
- Mental model
- Each stage only knows how to do one translation — the preprocessor rewrites text, the compiler turns text into assembly, the assembler turns assembly into machine code, and the linker is the only stage that sees the whole program at once and decides where everything actually lives.
- Key numbers
- 4 pipeline stages; 1 linker script deciding where FLASH and RAM regions begin and end.
- Reach for it when
- Debugging a "why won't this link," a mysterious startup crash, or a firmware image that doesn't fit — all three are pipeline problems, not application-logic problems.
The four stages
Preprocessing is pure text manipulation, before any C
syntax is actually understood: #include pastes in header
file contents, #define expands macros, and
#ifdef/#endif blocks include or exclude code
entirely based on compile-time conditions. Compiling
turns that expanded source into target-specific assembly — this is
where syntax errors, type errors, and most warnings are actually
detected. Assembling turns human-readable assembly
into a relocatable object file (.o) — machine code with
placeholders for addresses that aren't known yet. Linking
combines every object file and library into one program, resolving
every extern reference to an actual address and placing
each section according to the linker script.
The linker script
A linker script defines the target's actual memory map — where FLASH
starts and how big it is, where RAM starts and how big it is — and
which output section (.text, .rodata,
.data, .bss) goes into which region. This
is the concrete, per-chip version of the abstract memory-layout
diagram in Storage
Classes — on a desktop OS with virtual memory, the loader picks
addresses for you (see Paging
& Context Switching); on bare metal, the linker script is
the decision, made once, at build time, for real physical addresses.
/* simplified linker script excerpt */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : { *(.text*) } > FLASH /* code stays in flash */
.rodata : { *(.rodata*) } > FLASH /* const data too */
.data : { *(.data*) } > RAM AT> FLASH /* lives in RAM, initial values stored in flash */
.bss : { *(.bss*) } > RAM /* zeroed at startup, no flash storage needed */
}
Startup code, before main()
main() doesn't run first. Startup code (often called
crt0 — "C runtime, zero") runs before it and does exactly
the housekeeping the .data/.bss split above
implies: copy every initialized global's value out of flash into its
real RAM address, zero out the entire .bss region, set up
the initial stack pointer, and then jump to main().
Skipping or misconfiguring this step on a hand-written linker script
is exactly why a global can come up with garbage instead of its
written initial value.
The .map file
The linker can emit a .map file listing every symbol's
final address and the exact size of every section — the direct answer
to "will this firmware actually fit in flash and RAM," and the first
place to look when a build mysteriously stops fitting after an
innocuous-looking change (a large lookup table, an accidentally
non-static buffer that got duplicated across files, an
over-eager compiler inlining a function into every caller).
Cross-compiling
Firmware is built on a development machine but runs on a completely
different CPU architecture — a cross-compiler (e.g.
arm-none-eabi-gcc) targets the embedded CPU while running
on the host. This is the identical mechanism already covered for
cross-compiling the Linux kernel itself in
Kernel Source,
Versions & Patching — the same
ARCH/CROSS_COMPILE idea, just producing a
bare-metal binary instead of a kernel image.
Real-world applications
- Bootloader + application partitioning — a device with a separate bootloader and application firmware uses two different linker scripts, each targeting a different flash address range, so the bootloader can jump to the application at a known fixed address.
- OTA update sizing — the .map file's section sizes are exactly what determines whether a new firmware build fits in the flash partition an OTA update mechanism has reserved for it.
- Conditional compilation for hardware variants —
#ifdef BOARD_REV_Bblocks let one codebase's preprocessing stage produce different firmware for different board revisions from the same source.
Gotchas
- A missing function prototype lets the compiler assume a default (and often wrong) return type at the call site — a bug that only surfaces as strange runtime behavior, not a compile error, in older C standards.
- Mixing C and C++ without
extern "C"on the C-callable declarations produces "undefined reference" linker errors caused by C++ name mangling — a linker-stage symptom of a header-file problem. - Exceeding total RAM (
.data+.bss+ stack + heap) is invisible to the linker if the linker script doesn't explicitly check stack/heap sizes against what's left — it shows up at runtime as silent stack/heap collision instead of a build failure.