Embedded

Linker scripts in embedded

By CourseTron Team ·  8 min read ·  Updated Oct 2026 💬 𝕏 in f
Table of Contents
    ? Print / Save as PDF

    In short: A linker script is the blueprint that tells the GNU linker which physical memory regions exist on your microcontroller and exactly where each output section — code, constants, initialised data, zero-init data, stack and heap — must land, and the .map file is the receipt that proves it did.

    If you have ever flashed firmware that ran perfectly in the debugger but hung the instant you power-cycled the board, there is a good chance the linker script was quietly to blame. On a desktop the operating system and a mature C runtime hide all of this from you. On a bare-metal Cortex-M, RISC-V or AVR target, you own the memory map, and the linker script is where you declare it.

    Why embedded engineers must care about the linker

    Compilation turns each .c file into an object file full of named input sections: machine code goes into .text, read-only constants into .rodata, initialised globals into .data, and zero-initialised globals into .bss. The linker's job is to collect every input section of the same kind from every object file and archive, concatenate them into output sections, and assign each output section a run-time address. The script that drives those decisions — conventionally *.ld — is passed to the GNU linker with -T. Get one address wrong and the chip fetches its reset vector from garbage.

    Key fact: The compiler decides what goes in each section; the linker script decides where each section lives. These are two separate stages, which is exactly why a correct build can still produce broken firmware.

    Declaring memory regions with MEMORY

    Every linker script starts by describing the silicon. An STM32F103 "Blue Pill", for example, has 64 KB of flash at 0x08000000 and 20 KB of SRAM at 0x20000000. That becomes:

    MEMORY
    {
      FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
      RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
    }

    The attribute letters (read, write, execute) are documentation plus a sanity check — the linker warns if a section's permissions clash with the region it is placed in. The real power of MEMORY is that the linker tracks how full each region is and will emit region `FLASH' overflowed by 412 bytes rather than silently corrupting your image. That single diagnostic has saved more 2 a.m. debugging sessions than any JTAG probe.

    Where each section lives FLASH @ 0x08000000 .isr_vector .text (code) .rodata .data (LMA) RAM @ 0x20000000 .data (VMA) .bss (zeroed) heap ↓ / stack ↑ startup copy .data is stored in flash (LMA) but addressed in RAM (VMA) CourseTron · coursetron.com
    The two-address trick: .data is stored in flash (its load address, LMA) and copied into RAM (its virtual/run address, VMA) by startup code.

    Placing sections with SECTIONS

    The SECTIONS command maps input sections into output sections and regions. The location counter . tracks the current address as it walks down. A minimal but realistic Cortex-M body looks like this:

    SECTIONS
    {
      .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
      .text : { *(.text*) *(.rodata*) } > FLASH
      _sidata = LOADADDR(.data);
      .data : {
        _sdata = .;
        *(.data*)
        _edata = .;
      } > RAM AT> FLASH
      .bss : {
        _sbss = .;
        *(.bss*) *(COMMON)
        _ebss = .;
      } > RAM
    }

    Two constructs do the heavy lifting. KEEP() stops the linker's garbage collector (--gc-sections) from discarding the interrupt vector table, which nothing appears to "call". And > RAM AT> FLASH expresses the split between VMA (where the code expects .data at run time) and LMA (where the bytes are actually stored in non-volatile flash). The symbols _sidata, _sdata, _edata, _sbss and _ebss are exported so your Reset_Handler can copy initialised data from flash to RAM and zero the .bss before main() runs.

    Key fact: Forgetting the .data copy loop is the classic "works in the debugger, dies on reset" bug — the debugger's load command writes RAM for you, but a cold boot does not.

    VMA versus LMA: the idea that trips everyone up

    A global like int counter = 42; must read as 42 on first access, yet RAM is volatile and loses its contents at power-off. The only non-volatile storage is flash, so the initial value lives in flash (the LMA) while the variable is addressed in RAM (the VMA). The startup code bridges the two. For a variable you never initialise, there is no value to store, so it goes in .bss, occupies zero bytes of flash, and is simply zeroed at boot. This is why oversized initialised arrays bloat your flash image while equally large uninitialised arrays do not — a distinction that matters enormously on a part with 32 KB of flash.

    Linker symbols that bracket .data and .bss FLASH .text / .rodata .data image _sidata RAM .data .bss free: heap / stack _sdata _edata = _sbss _ebss Copy flash → [_sdata,_edata), then zero [_sbss,_ebss) before main() CourseTron · coursetron.com
    Symbols exported by the SECTIONS example: startup code copies from _sidata to _sdata._edata, then zeroes _sbss._ebss.

    Reading the .map file

    Pass -Wl,-Map=firmware.map and the linker writes a complete ledger. It is plain text, and learning to read it is a genuine interview differentiator. The sections you will actually use:

    Map sectionWhat it answers
    Memory ConfigurationDid my MEMORY regions parse as intended?
    Archive member included…Which library objects got pulled in, and why (who referenced them)?
    Discarded input sectionsWhat did --gc-sections remove? Useful when a symbol vanished unexpectedly.
    Linker script and memory mapThe address and size of every symbol — your ground truth for "why is my image 54 KB?"

    To find the single fattest contributor, sort the symbol table by size, or use arm-none-eabi-nm --size-sort --radix=d firmware.elf | tail alongside size firmware.elf, which prints the headline text / data / bss totals. When a 2 KB function appears that you never wrote, the map's "referenced by" column tells you which call dragged in the whole of printf's floating-point formatter.

    Where this fits in the toolchain

    Linker scripts are a GNU ld feature, but the discipline is universal. ARM's armlink uses scatter-loading files that express the same VMA/LMA idea in different syntax, and IAR uses ICF files. On the verification side, EDA houses such as Synopsys, Cadence and Siemens EDA care about memory maps too — a post-silicon bring-up team cross-checks the linker's placement against the chip's memory-controller configuration before the first bootloader ever runs. The habit of reasoning precisely about address space is the same one you will use in SoC verification. If you are moving toward chip-design roles, the Avecas VLSI design-services team works these boundaries between firmware and silicon daily.

    Reset_Handler copies .data FLASH (LMA) 0x2A RAM (VMA) 00 0x2A Reset_Handler: memcpy(_sdata, _sidata, _edata-_sdata) byte 0x2A travels flash → RAM at every boot CourseTron · coursetron.com
    Animated: the initial value 0x2A is copied from its flash load address to its RAM run address during reset, before main().

    This startup sequence is exactly what a bootloader in embedded systems must also manage before it hands control to the application, and it interacts directly with the startup code and vector table you write in assembly or C.

    Want to write a linker script from a blank file, map every byte, and shrink a bloated image live on real hardware? Our Embedded Systems track on CourseTron courses walks through it board-in-hand, and the VLSI-adjacent firmware modules at ChipXpert training go deeper on memory architecture.

    Frequently asked questions

    Why does my global variable keep the wrong value after a reset?

    Almost always a missing or broken .data copy loop in your startup code, or a script that placed .data in RAM without an AT> FLASH load region. The initial value never made it into non-volatile memory, or was never copied back.

    What is the difference between .data and .bss?

    .data holds globals with a non-zero initial value and therefore costs both flash (to store the value) and RAM (to run). .bss holds zero-initialised globals, costs only RAM, and is cleared in bulk at boot — so prefer leaving large buffers uninitialised when you can.

    Do I need a linker script if I use an RTOS or vendor HAL?

    Yes — the vendor supplies one with their startup project, but you still own it. Adding a bootloader, moving the vector table, reserving a no-init RAM region for crash logs, or placing fast code in CCM/TCM all require editing it.

    How do I find what is eating my flash?

    Generate the map with -Wl,-Map=, check size firmware.elf for the headline totals, then sort symbols with nm --size-sort. The map's "referenced by" lines reveal which call pulled in a heavyweight library routine.

    Advertisement