Series / STM32 Development with CMake and VSCode / Understanding the STM32 Linker Script

Understanding the STM32 Linker Script

Understand how an STM32 linker script describes flash, RAM, stack, heap, and firmware sections before startup code runs.

On this page

The previous article created a minimal CMake project that can compile C code for the STM32 Nucleo L433RC-P. It stopped before linking a real firmware image.

This article adds the missing memory map: the linker script.

A linker script tells the linker where code and data belong in the microcontroller's memory. Without it, the compiler can produce object files, but the linker does not know where the final firmware should live, where RAM begins, where the vector table goes, or which symbols startup code will need before calling main().

This article explains the linker script and how it fits into the project. The next article will add startup code that uses the symbols defined here.

Why a Linker Script Exists

On a desktop operating system, the executable loader handles many details for you. It decides where a program is mapped in memory, prepares runtime state, and starts the process.

On a bare-metal STM32, there is no operating system loader.

The firmware image has to be built for the memory layout of the chip. Code normally lives in flash. Variables live in RAM. Some variable initial values are stored in flash but copied to RAM during startup. Zero-initialized variables need RAM space but do not need bytes stored in the firmware image.

The linker script describes that layout.

It answers questions such as:

  • Where does flash start?
  • How large is flash?
  • Where does RAM start?
  • How large is RAM?
  • Where should the interrupt vector table be placed?
  • Where should code and constants be placed?
  • Which data must be copied from flash to RAM at startup?
  • Which RAM region must be cleared to zero before main()?
  • Where should the initial stack pointer point?

Those are not C language questions. They are target memory-layout questions, so they belong in the linker script.

Flash, RAM, and the STM32L433RC Memory Map

The STM32 Nucleo L433RC-P uses an STM32L433RC microcontroller. For this series, the important memory regions are the main flash and main SRAM.

Use the datasheet and reference manual for final values when creating production firmware. For the STM32L433RC target used here, the working layout is:

FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 256K
RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 64K

The flash origin 0x08000000 is where user firmware normally starts on STM32 devices. The RAM origin 0x20000000 is the start of SRAM in the Arm Cortex-M memory map.

Some STM32 parts have additional memory regions, aliases, option bytes, system memory, backup SRAM, or split SRAM banks. Ignore those until the project needs them. A first linker script should describe the memory the firmware actually uses.

Add a Linker Folder

Starting from the project created in the previous article, add a folder for linker scripts:

mkdir linker

The project now has this shape:

stm32-cmake-minimal/
├── CMakeLists.txt
├── cmake/
│   └── arm-none-eabi-gcc.cmake
├── linker/
│   └── STM32L433RCTx_FLASH.ld
└── src/
    └── main.c

The exact filename is a convention, not a requirement. The important part is that the filename identifies the target device and memory layout clearly.

A Minimal STM32 Linker Script

Create linker/STM32L433RCTx_FLASH.ld:

ENTRY(Reset_Handler)

_estack = ORIGIN(RAM) + LENGTH(RAM);

_Min_Heap_Size = 0x200;
_Min_Stack_Size = 0x400;

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

SECTIONS
{
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector))
    . = ALIGN(4);
  } > FLASH

  .text :
  {
    . = ALIGN(4);
    *(.text)
    *(.text*)
    *(.glue_7)
    *(.glue_7t)
    *(.eh_frame)

    KEEP(*(.init))
    KEEP(*(.fini))

    . = ALIGN(4);
    _etext = .;
  } > FLASH

  .rodata :
  {
    . = ALIGN(4);
    *(.rodata)
    *(.rodata*)
    . = ALIGN(4);
  } > FLASH

  _sidata = LOADADDR(.data);

  .data :
  {
    . = ALIGN(4);
    _sdata = .;
    *(.data)
    *(.data*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH

  .bss :
  {
    . = ALIGN(4);
    _sbss = .;
    *(.bss)
    *(.bss*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

  ._user_heap_stack :
  {
    . = ALIGN(8);
    PROVIDE(end = .);
    PROVIDE(_end = .);
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
  } > RAM

  /DISCARD/ :
  {
    libc.a (*);
    libm.a (*);
    libgcc.a (*);
  }
}

This is enough to explain the memory layout and prepare for startup code. It is not the final linker script every STM32 project will ever need, but it has the core sections that matter for a small bare-metal firmware image.

The MEMORY Block

The MEMORY block names physical memory regions:

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

FLASH is marked rx, meaning readable and executable. Firmware code and constants usually go there.

RAM is marked xrw, meaning executable, readable, and writable. Many projects do not intentionally execute code from RAM, but this permission set is common in embedded linker scripts. The important point is that variables requiring runtime modification belong in RAM.

The names FLASH and RAM are used later by section placement rules such as > FLASH and > RAM.

The Initial Stack Pointer

This line defines the initial stack pointer value:

_estack = ORIGIN(RAM) + LENGTH(RAM);

On Arm Cortex-M, the first word of the vector table is the initial stack pointer. The stack normally grows downward, so the initial value should point to the top of RAM.

The linker script does not put the stack there by itself. It defines the symbol. The startup code will place _estack into the vector table in the next article.

Vector Table Placement

The vector table section is placed first in flash:

.isr_vector :
{
  . = ALIGN(4);
  KEEP(*(.isr_vector))
  . = ALIGN(4);
} > FLASH

For a normal STM32 boot from flash, the vector table must be located at the beginning of the firmware image. It contains the initial stack pointer, reset handler address, and exception/interrupt handler addresses.

KEEP(*(.isr_vector)) matters because linker garbage collection can remove sections that do not appear to be referenced. The vector table is not called like a normal C function, but the CPU still needs it. KEEP tells the linker not to discard it.

The startup article will define an object in the .isr_vector section.

Code and Read-Only Data

The .text section places executable code in flash:

.text :
{
  . = ALIGN(4);
  *(.text)
  *(.text*)
  . = ALIGN(4);
  _etext = .;
} > FLASH

The patterns *(.text) and *(.text*) collect input sections from object files. The wildcard version catches compiler-generated section names, especially when using -ffunction-sections.

The _etext symbol marks the end of the code region in flash. Startup code often uses this area indirectly when copying initialized data into RAM.

Read-only data goes into flash too:

.rodata :
{
  . = ALIGN(4);
  *(.rodata)
  *(.rodata*)
  . = ALIGN(4);
} > FLASH

Constants such as string literals and const lookup tables commonly end up in .rodata.

Initialized Data

Initialized global or static variables are more complicated.

Consider this C variable:

uint32_t counter = 10;

At runtime, counter must live in RAM because the program can modify it. But the initial value 10 must be stored somewhere in the firmware image, which means it starts in flash.

That is why .data has two addresses:

_sidata = LOADADDR(.data);

.data :
{
  . = ALIGN(4);
  _sdata = .;
  *(.data)
  *(.data*)
  . = ALIGN(4);
  _edata = .;
} > RAM AT > FLASH

> RAM says the section's runtime address is in RAM.

AT > FLASH says the initial contents are loaded from flash.

_sidata, _sdata, and _edata are symbols startup code will use to copy initialized data from flash to RAM before main() runs.

Zero-Initialized Data

Zero-initialized globals and statics go into .bss:

.bss :
{
  . = ALIGN(4);
  _sbss = .;
  *(.bss)
  *(.bss*)
  *(COMMON)
  . = ALIGN(4);
  _ebss = .;
} > RAM

For example:

uint32_t samples_collected;

This variable must be zero before main() runs. The firmware image does not need to store a byte-for-byte block of zeros in flash. Instead, the linker script marks the .bss range with _sbss and _ebss, and startup code clears that RAM range at boot.

This is one of the main reasons startup code and linker scripts are linked concepts. The linker script defines the memory ranges. Startup code performs the runtime initialization.

Heap and Stack Reservation

This section reserves a small amount of RAM for heap and stack:

._user_heap_stack :
{
  . = ALIGN(8);
  PROVIDE(end = .);
  PROVIDE(_end = .);
  . = . + _Min_Heap_Size;
  . = . + _Min_Stack_Size;
  . = ALIGN(8);
} > RAM

The reservation helps the linker catch obvious RAM overflow. It does not measure real worst-case stack usage. Embedded projects still need discipline around stack depth, interrupt usage, library calls, and dynamic allocation.

For many small bare-metal projects, avoiding heap allocation entirely is a good default. The heap symbol is still present because standard library stubs and future code may expect it.

ENTRY and Reset_Handler

The script begins with:

ENTRY(Reset_Handler)

This tells the linker that Reset_Handler is the firmware entry symbol.

That symbol does not exist yet. The next article will define it in startup code.

It may feel strange to reference a symbol before creating it, but this is normal when building embedded projects incrementally. The linker script describes what the final firmware needs. Startup code will provide the missing entry point and vector table.

How CMake Will Use the Linker Script

The previous article used an object library to avoid linking too early. Once startup code exists, the project can switch to an executable target and pass the linker script to the linker with an option like:

target_link_options(firmware PRIVATE
    -T${CMAKE_SOURCE_DIR}/linker/STM32L433RCTx_FLASH.ld
    -Wl,--gc-sections
)

-T selects the linker script.

-Wl,--gc-sections asks the linker to remove unused sections. This pairs with the compile flags -ffunction-sections and -fdata-sections from the previous article.

Do not add this to the project yet unless you also add startup code and switch from the object-library approach to a firmware executable. The next article will make that transition deliberately.

What Still Will Not Work Yet

After adding the linker script, the project still cannot boot on the board by itself.

It still needs:

  • A vector table placed in .isr_vector.
  • A Reset_Handler symbol.
  • Code to copy .data from flash to RAM.
  • Code to clear .bss.
  • A call to main().
  • Default handlers for exceptions and interrupts.

Those belong in startup code, not in the linker script.

The linker script defines memory placement. Startup code uses that placement to initialize the C runtime.

Common Mistakes

One common mistake is using the wrong flash or RAM size. Always check the exact part number and package. STM32 families often contain similar parts with different memory sizes.

Another mistake is forgetting KEEP(*(.isr_vector)). If section garbage collection removes the vector table, the firmware image may build but fail immediately at boot.

It is also easy to confuse load addresses and runtime addresses for .data. Initialized variables run from RAM, but their initial values are stored in flash. That is why .data uses > RAM AT > FLASH.

Finally, do not treat heap and stack reservations as proof that stack usage is safe. They are linker-time reservations, not runtime analysis.

Next Steps

The next article adds startup code to the project.

That startup code will define the vector table, provide Reset_Handler, initialize .data and .bss, and finally call main(). At that point, the CMake project can move from compiling object files toward linking a real STM32 firmware image.