Linker Script Anatomy: Sections, Symbols, and the Mistakes That Cost Hours
A linker script is a program. Reading it as configuration leads to mistakes that are hard to diagnose without understanding the MEMORY, SECTIONS, and symbol resolution model.

A linker script is a program that runs before your program runs. Treating it as configuration — copy-paste MEMORY blocks without understanding symbol resolution — produces bugs that manifest as hard faults, mysteriously zeroed globals, or firmware that works in debug but not release. This note covers MEMORY, SECTIONS, and the mistakes that cost me hours on STM32 and nRF projects.
Related: C undefined behavior catalog, OTA update bootloader design.
Mental model
Compile pipeline:
.c/.s → .o (sections: .text, .data, .bss, .rodata, ...)
.o files + linker script → ELF (VMA/LMA assigned, symbols resolved)
The linker:
- Places input sections into output regions per
SECTIONS - Assigns virtual addresses (VMA) — runtime addresses
- Assigns load addresses (LMA) — where init data lives in flash
- Defines symbols (
_sidata,_sdata,_edata,_sbss,_ebss,_estack, ...)
Startup code (Reset_Handler) copies .data from LMA to VMA and zeroes .bss. If linker script lies, C runtime lies, you fault.
Minimal STM32-style script anatomy
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
.text :
{
*(.text*)
*(.rodata*)
_etext = .;
} > FLASH
.data : AT (_etext)
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM
_sidata = LOADADDR(.data);
.bss (NOLOAD) :
{
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
}
Every symbol name the startup file references must exist exactly once.
Mistake 1: .data LMA/VMA confusion
Symptom: Global uint32_t config = 0xDEADBEEF; reads as 0 at runtime.
Cause: .data VMA in RAM but LMA not placed in FLASH after .text. Missing AT (_etext) or wrong _sidata.
Debug: Compare map file:
arm-none-eabi-nm -S app.elf | grep config
VMA should be RAM, value in flash at LMA shown in .map file load address column.
Mistake 2: .bss in FLASH or loaded incorrectly
Symptom: BSS variables non-zero at boot; libc malloc corrupts early.
Cause: .bss not marked (NOLOAD) and accidentally assigned load region.
Fix: (NOLOAD) on .bss; startup must zero _sbss to _ebss.
Mistake 3: Stack/heap overlap
Symptom: Random faults under load; works with debugger attached (different timing).
Cause: _estack at RAM top but .heap section grows into stack from _end. No guard between heap and stack.
Check map file:
._user_heap_stack ends at 0x2001FF00
_estack at 0x20020000
If heap max + stack max > gap, you have latent overflow.
On Zephyr, different allocator — still verify CONFIG_MAIN_STACK_SIZE + worst-case ISR stack.
Mistake 4: KEEP() missing on vector table / .init_array
Symptom: Interrupts never fire; constructors do not run.
Cause: Link-time garbage collection (--gc-sections) removes "unreferenced" .isr_vector.
Fix:
KEEP(*(.isr_vector))
KEEP(*(.init_array*))
Match GCC flags: -ffunction-sections -fdata-sections -Wl,--gc-sections requires KEEP on critical sections.
Mistake 5: Dual-bank / bootloader offset wrong
Symptom: App runs from debugger load address 0x08000000 but not when flashed by bootloader at 0x08010000.
Cause: FLASH ORIGIN still 0x08000000; vectors point wrong.
Fix for MCUboot slot1:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K
}
Rebuild all objects — not just relink. Compile-time __FLASH_START macros may also need update.
Mistake 6: Alignment and MPU regions
Symptom: Hard fault on unaligned access when MPU enabled.
Cause: Section placed at odd boundary; DMA buffer not aligned to 32 bytes.
Fix in script:
.nocache (NOLOAD) :
{
. = ALIGN(32);
*(.nocache*)
} > RAM
Prefer __attribute__((aligned(32))) on buffers plus linker enforcement.
Reading the map file
Generate:
west build ... -- -DCMAKE_EXE_LINKER_FLAGS="-Wl,-Map=app.map"
Search for:
Memory Configuration— region sizes.dataload address vs VMAMaximum memory usage— if printed by toolchain- Symbol addresses near region boundaries
If .text + .rodata + .data init exceeds FLASH LENGTH, linker sometimes fails loudly — sometimes truncates if you override incorrectly (rare with GNU ld; do not rely on luck).
Custom sections (when you need them)
Place DMA descriptors in fixed RAM for debugger visibility:
.dma_desc (NOLOAD) :
{
_dma_desc_start = .;
KEEP(*(.dma_desc))
_dma_desc_end = .;
} > RAM
In C:
__attribute__((section(".dma_desc")))
static dma_descriptor_t desc;
Reference _dma_desc_start in debug code so GC keeps it.
Zephyr vs bare-metal
Zephyr generates linker scripts from devicetree (include/generated/linker.ld). Custom sections go through linker.ld fragments:
SECTION_PROLOGUE(.custom,,)
{
KEEP(*(.custom*))
} GROUP_LINK_IN(ROMABLE_REGION)
Editing generated linker.ld directly — changes lost on rebuild. Use snippets in app/snippets/.
Debug commands I use
# Section sizes
arm-none-eabi-size app.elf
# Disassemble vector table
arm-none-eabi-objdump -s -j .isr_vector app.elf
# All section headers with LMA
arm-none-eabi-objdump -h app.elf
What I would do differently
Start every new board port by diffing vendor linker script against reference manual memory map — before writing application code.
Add CI check: size thresholds and objdump -h script verifying _sidata, _sdata, _edata monotonicity.
Related
C undefined behavior catalog for what happens after bad init. OTA bootloader design for split FLASH layouts.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

