Skip to content
C++
Domain Deep-Dive
Advanced

Linker Scripts for Embedded C++

Writing and understanding linker scripts for bare-metal C++: memory regions, section placement, startup initialization, and common pitfalls.

Why linker scripts matter

On a hosted system (PC/server), the OS provides a flat virtual address space and the loader handles placement. On bare-metal, you must tell the linker exactly where every section goes. Flash, RAM, and I/O peripherals have fixed physical addresses burned into silicon.


Memory layout of a typical Cortex-M

cpp
Physical address map (STM32F4 as example):
0x0800_0000 - 0x080F_FFFF  FLASH (1 MB) — read-only after power-on
0x2000_0000 - 0x2001_FFFF  SRAM  (128 KB) — read/write

Typical section placement:
FLASH:                         SRAM:
┌──────────────┐ 0x0800_0000  ┌──────────────┐ 0x2000_0000
│ .vectors     │              │ .data        │ (copied from FLASH)
│ .text        │              │ .bss         │ (zeroed by startup)
│ .rodata      │              │ .heap        │ ↑ grows up
│ .data (load) │              │              │
└──────────────┘              │ .stack       │ ↓ grows down
                              └──────────────┘

Minimal linker script

/* STM32F407 example */
MEMORY {
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1M
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* Stack top = end of RAM */
_stack_top = ORIGIN(RAM) + LENGTH(RAM);

SECTIONS {
    /* Vector table MUST be at offset 0 in FLASH */
    .vectors : {
        KEEP(*(.vectors))
    } > FLASH

    /* Executable code and read-only data */
    .text : {
        *(.text*)
        *(.rodata*)
    } > FLASH

    /* .data section: stored in FLASH, copied to RAM at startup */
    .data : {
        _data_start = .;
        *(.data*)
        _data_end = .;
    } > RAM AT > FLASH   /* VMA in RAM, LMA in FLASH */

    /* Load address of .data in FLASH */
    _data_load = LOADADDR(.data);

    /* .bss section: zero-initialized; only in RAM */
    .bss : {
        _bss_start = .;
        *(.bss*)
        *(COMMON)
        _bss_end = .;
    } > RAM

    /* Heap starts after .bss */
    _heap_start = _bss_end;
    _heap_end   = _stack_top - 1024;  /* leave 1KB for minimum stack */
}

Startup: copying .data and zeroing .bss

The linker provides the _data_* / _bss_* symbols but your startup code must use them:

cpp
// startup.cpp — runs before main()
extern uint32_t _data_start;
extern uint32_t _data_end;
extern uint32_t _data_load;   // source address in FLASH
extern uint32_t _bss_start;
extern uint32_t _bss_end;
extern uint32_t _stack_top;

extern "C" void Reset_Handler() {
    // 1. Copy .data from FLASH to RAM
    uint32_t* src = &_data_load;
    uint32_t* dst = &_data_start;
    while (dst < &_data_end) *dst++ = *src++;

    // 2. Zero .bss
    dst = &_bss_start;
    while (dst < &_bss_end) *dst++ = 0;

    // 3. Call static constructors (C++ global objects)
    extern void (*__preinit_array_start[])();
    extern void (*__preinit_array_end[])();
    for (auto** fn = __preinit_array_start; fn < __preinit_array_end; ++fn)
        (*fn)();

    extern void (*__init_array_start[])();
    extern void (*__init_array_end[])();
    for (auto** fn = __init_array_start; fn < __init_array_end; ++fn)
        (*fn)();

    // 4. Jump to main
    main();

    // Should never return from main on bare-metal
    while (true) { __WFI(); }
}

Placing specific code in specific sections

cpp
// Force a function into FLASH (default for most compilers)
__attribute__((section(".text.fast"))) void CriticalISR() { /* ... */ }

// Place time-critical code in RAM for faster execution
__attribute__((section(".ramfunc"))) void UltraFastFunc() { /* ... */ }
// Then in linker script:
// .ramfunc : { *(.ramfunc) } > RAM AT > FLASH

// Place a variable in a specific address (e.g., at SRAM2)
__attribute__((section(".sram2"), used)) uint8_t dma_buffer[4096];
// In linker script:
// MEMORY { SRAM2 (rw): ORIGIN = 0x10000000, LENGTH = 64K }
// .sram2 : { *(.sram2) } > SRAM2

// Non-initialized section (survives reset — used for watchdog crash logs)
__attribute__((section(".noinit"))) char crash_log[256];
// .noinit (NOLOAD) : { *(.noinit) } > RAM  ← NOLOAD = don't init/zero

Checking your memory map

bash
# Generate a map file
arm-none-eabi-gcc ... -Wl,-Map=output.map

# Check section sizes
arm-none-eabi-size firmware.elf

# Typical output:
#    text    data     bss     dec     hex filename
#   45320    1432    8192   54944    d6a0 firmware.elf

# text = FLASH used by .text + .rodata + .data (load copy)
# data = RAM used by .data
# bss  = RAM used by .bss

Common rules:

  • data + bss + stack < RAM_SIZE — or you'll corrupt the stack
  • text + data_load < FLASH_SIZE — or you won't fit
  • Stack grows down from top of RAM — verify with _stack_top = ORIGIN(RAM) + LENGTH(RAM)

Common linker script mistakes

cpp
1. Forgetting KEEP() on the vector table
   → linker optimizes it away → hard fault on first interrupt

2. No AT > FLASH for .data
   → data section has no ROM image → garbage on first access

3. Missing __init_array calls in startup
   → global C++ objects never constructed → undefined behavior

4. Overlapping RAM sections
   → stack corrupts .bss or vice versa
   → use -Wl,--check-sections to catch this

5. NOLOAD missing from .noinit
   → startup zeros the crash log → defeats the purpose
Edit on GitHubUpdated 2026-05-24T00:00:00.000Z