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.

Quick reference
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

C source is preprocessed into expanded source, compiled into assembly, assembled into an object file, then linked against a linker script into the final firmware image main.c + other .c files Preprocessor #include, #define Compiler C → assembly Assembler assembly → object Linker .elf / .bin firmware .i .s .o Linker script The linker is the only stage that sees every object file and decides final memory addresses.
A "compile error" is almost always the compiler stage; a "linker error" (undefined reference, multiple definition) is a completely different failure mode, one stage later, with a different set of causes.

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

Gotchas

Field notes