2026-06-15 · embedded · linker · memory map · firmware · startup

Linker scripts and the memory map: why firmware won't boot

A surprising amount of “my firmware is bricked” turns out to be a memory-layout problem, not a logic bug: it links without error, flashes fine, and then the chip faults before main() — or a global variable is mysteriously garbage. The linker script and the startup code are the least-understood part of an embedded project precisely because the toolchain hides them in a template that “just works” — until it doesn’t. Understanding the memory map they build is what turns those failures from mysteries into five-minute fixes.

Where everything lives

Your program isn’t one blob. The compiler splits it into sections by what the data is and where it must live, and the linker places each section into FLASH or RAM:

A light memory-map diagram with an address ruler. On the left, a FLASH ribbon (512 KB) holds, from its base address 0x08000000 upward, .isr_vector (the vector table), .text (code), .rodata (const data), then free flash, with hex addresses on a ruler beside it. On the right, a RAM ribbon (64 KB) holds, from base 0x20000000, .data (initialised globals), .bss (zero globals), the heap growing up, free RAM, and the stack at the top growing down from the initial stack pointer 0x20010000. A center legend lists the role of each section. A note explains heap and stack grow toward each other through free RAM, and when they meet you get silent stack-overflow corruption. Figure 1 — The classic Cortex-M map. Code and constants live in FLASH; mutable data lives in RAM, with the heap growing up and the stack growing down toward each other.

The five sections you meet constantly:

  • .isr_vector — the vector table: the initial stack pointer and the address of every interrupt handler. It must sit at the start of FLASH (where the CPU looks on reset).
  • .text — your code. Read-only, runs straight from FLASH.
  • .rodataconst data and string literals. Read-only, stays in FLASH.
  • .data — global/static variables with a non-zero initial value (int x = 5;). Here’s the catch: they have to be writable, so they run in RAM — but their initial values have to survive power-off, so they’re stored in FLASH.
  • .bss — global/static variables that start at zero (int y;). They take no space in the FLASH image at all; they just need to be zeroed in RAM at startup.

FLASH is non-volatile and keeps the program across power cycles; RAM is random at power-on. That difference is the whole reason startup code exists.

What runs before main()

Between reset and your first line of main(), the Reset_Handler (startup code) has to turn that random RAM into the state C promises you — globals initialised, zeros zeroed:

A light diagram titled at reset, startup runs before main. On the left a FLASH block contains .isr_vector, .text, .rodata and a highlighted ".data image" at its load address (LMA). On the right a RAM block contains a hatched stack/heap region set up later, a .bss region, and a .data region at its run address (VMA). An amber dashed arrow labeled ① copies the .data init values from FLASH to the RAM .data region; a second arrow labeled ② zero-fills .bss to 0. A step strip ③ shows MSP loaded from vector zero, then SystemInit(), then branch to main(). A red note warns that if a broken Reset_Handler skips steps ① or ②, globals hold garbage and .bss isn't zero, so the firmware faults before main ever runs. Figure 2 — The startup sequence. Copy .data’s initial values from FLASH to RAM, zero .bss, set the stack pointer, then call main(). Skip the copy or the zero and your globals are wrong before line one.

Three steps, in order:

  1. Copy .data from FLASH to RAM. The initial values were stored in the FLASH image (their load address); the startup loop copies them to where the variables actually live in RAM (their run address). This is why int x = 5; is 5 when main() starts.
  2. Zero .bss. Walk the .bss region in RAM and write zeros, so static int y; really is 0 as the C standard requires.
  3. Set up and go. The initial stack pointer is loaded from the vector table (the CPU does this in hardware on Cortex-M), SystemInit() configures clocks, and then control branches to main().

If a hand-rolled or mismatched Reset_Handler skips step 1, every initialised global is garbage. Skip step 2 and your “zero” variables hold whatever was in RAM at power-on — the kind of bug that changes with temperature and ambient noise. Both look like the firmware “doesn’t boot.”

VMA, LMA, and the linker script

That FLASH-stored-but-RAM-run trick has a name. Every section has two addresses, and for most they’re the same — but not for .data:

  • VMA (Virtual/run address) — where the section runs, what the code’s pointers expect.
  • LMA (Load address) — where the section is physically stored in the image.

For .text they’re equal: code runs straight from FLASH where it’s stored. For .data, VMA is in RAM (where it runs) and LMA is in FLASH (where the initial image lives) — and the gap between them is exactly what step 1 of startup bridges.

The linker script encodes all of this in two blocks:

A light diagram of a linker script, shown as two code panels. The first, MEMORY, declares FLASH with origin 0x08000000 and length 512K, and RAM with origin 0x20000000 and length 64K. The second, SECTIONS, maps each output section to a region: .isr_vector, .text and .rodata go to FLASH; .data is highlighted and reads "> RAM AT> FLASH"; .bss and the heap/stack go to RAM. A side card explains VMA versus LMA: VMA is the run address, LMA is the load address; .text has VMA equal to LMA so it runs from FLASH, while .data has VMA in RAM and LMA in FLASH so startup copies it. An amber callout breaks down "> RAM AT> FLASH": "> RAM" sets where it runs (VMA), "AT> FLASH" sets where it is stored (LMA). It also lists the boundary symbols _sdata, _edata, _sbss, _ebss that the startup loop uses. Figure 3 — MEMORY declares the regions; SECTIONS places each output section. > RAM AT> FLASH is what gives .data its split run/load addresses, and the _sdata/_ebss symbols are how startup knows what to copy and zero.

  • MEMORY declares the physical regions: name, attributes (rx, rwx), ORIGIN and LENGTH. Get these from the datasheet; a wrong LENGTH is how you overflow the part.
  • SECTIONS places each output section into a region with > REGION (its VMA). The special > RAM AT> FLASH on .data sets the VMA to RAM but the LMA to FLASH — the split that makes the startup copy possible.
  • The script also exports symbols_sdata, _edata, _sbss, _ebss, _estack — marking section boundaries. The startup code loops between them; that’s the contract between the linker script and the Reset_Handler.

How these actually bite

The failures sort into two kinds — loud and quiet:

  • Loud (link time): region FLASH overflowed by N bytes. Wrong LENGTH, or you genuinely don’t fit. The linker tells you exactly. Easy.
  • Quiet (runtime): firmware links and flashes but faults or misbehaves. Almost always one of: a vector table not at the start of FLASH (or VTOR not pointing at it), a startup that doesn’t match the script’s symbols (so .data/.bss aren’t set up), a .data placed without AT> FLASH (nothing to copy from), or a stack that grew into the heap because you sized RAM optimistically.

The map view in your build’s .map file (and the arm-none-eabi-size summary) is the ground truth: it lists every section’s address and size. When something boots wrong, read the map before you read the code.

Field notes

  • Open the .map file once per project. Seeing .data with a RAM VMA and a FLASH LMA, and .bss taking zero FLASH, makes Figures 1–3 concrete in a way no article can.
  • arm-none-eabi-size firmware.elf gives you text / data / bss at a glance — text+data must fit FLASH, data+bss must fit RAM. Check it in CI.
  • A global that’s wrong only sometimes is a .bss/.data startup bug, not a logic bug. Suspect the Reset_Handler and the linker symbols first.
  • Stack overflow is silent on most MCUs. Size the stack with the .map and a high-water-mark fill pattern; on M-class parts, set the MSPLIM register if you have it.
  • Don’t hand-edit the vendor linker script without understanding AT>. Moving .data to “> RAM” alone (dropping AT> FLASH) compiles, links, and bricks — the classic quiet failure. The same care applies when you place a bootloader and application at different origins.