logo

Contents

STM32 Linker Script (From Scratch)

Foreword

When you hit "build" on something like STM32CubeIDE, something called a linker runs at some point in that process.

Its role is to take your code (more specifically, a generated object file, such as an .elf file) and map it onto the target device in a way that fits the specific hardware.

A Raspberry Pi Example

The clearest way I can think to explain its use is through an experience I had flashing the RP2040 with my firmware (bare-metal project, no SDK).

The way that MCU works is, it expects the first 256 bytes of code it reads from flash to be structured like this:

Generally, there's no way to really guarantee that your compiled code will have the first 256 bytes set up like that. Luckily, as you can already guess, a linker script fixes that and it's exactly what it's used for.

Memory Layout (MEMORY())

Generally, on ARM Cortex-M chips, the memory is structured like this:

Because these are hardware-specific, and SRAM/peripherals aren't always at those addresses on every type of CPU architecture, you can specify where each begins, as well as its size, in the linker script:

MEMORY (
    flash(rw) : ORIGIN = 0x08000000, LENGTH = 128K
    sram(rwx) : ORIGIN = 0x20000000, LENGTH = 624K
);

Note that LENGTH doesn't have to be accurate - you can claim to have less than you actually do, the rest will just remain unutilised. Just don't claim to have more than you actually do, that'll lead to undefined behaviour.

You might also notice the (rw) and (rwx) parts of the code - that lets the linker know what it can do with that section of memory:

And they don't actually enforce any kind of memory protection, they're just there for the linker to warn you if you try to, for example, place a writeable section in a memory region marked as only readable.

Entry Point (ENTRY())

This is largely ceremonial on microcontrollers, or at least those powered by the Cortex-M like the STM32 family. It specifies where to start code execution from, and it looks like this:

ENTRY(_reset_handler);

The reason I say it's ceremonial is because Cortex-M chips always boot like this under normal conditions:

  1. Read 0x00000000 for initial stack pointer value
  2. Read 0x00000004 for reset handler function's address
  3. Jump to reset handler and begin code execution

So ENTRY() doesn't influence where the CPU starts code execution from.

What does get influenced, however, is the debugger. So for debugging purposes, the entry point is necessary.

Symbols

The part which reads _reset_handler inside of ENTRY(_reset_handler) is called a symbol. The linker script doesn't actually have a variable called _reset_handler - instead it is user-defined and ends up in the symbol table. More on this later

Symbols

This was the most confusing part of linker scripts for me because they look and behave like variables, but they are NOT variables.

Take this as an example:

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

This creates a symbol (not variable) called _estack (meaning end-of-stack, it's a user-defined name so you can call it anything), and the value inside it becomes the origin of RAM (0x20000000) plus the length of RAM (624k), which is 0x20098580.

_estack can then be used inside of your source code:

// declare it first
extern uint32_t _estack;

// then use it like this
(uint32_t)&_estack,

And here's the confusing part: addressing &_estack will give you its address 0x20098580 which is what you want, NOT addressing it like _estack will give you the value stored at its address, which may or may not have something useful depending on your use case, in this case it will not.

This is exactly the same as a normal variale: uint32_t x = 5. Here _estack corresponds to x - and addressing &x will give you its address (e.g. 0x20001000), NOT addressing it will give you its value (in this case 5).

It's so similar to a variable, that it even gets added to the symbol table and looked up whenever it's needed.

The one distinction here is that a linker symbol is NOT a variable, so it reserves NO memory in order to exist. It's closer to a C macro #define in that sense, so it has no runtime overhead as it's resolved during the build process.

I'm not sure why it confused me so much... But hey, making things easy-to-understand is the whole point of this website!

Data Sections (SECTIONS{})

After making the linker aware of the hardware memory layout with MEMORY{}, we can now put data inside it. That's achieved with the SECTIONS{} keyword.

The way it works at a high level is as follows:

SECTIONS {
    <section 1>
    <section 2>
    <section 3>
    …
}

The general structure of each individual section is: output_name : { input_pattern } > memory_region

You can define as many sections as you like.

Example

Here is a piece of example code (from STM32World - see sources). I'll go through it line-by-line.

SECTIONS {
  .vectors  : { KEEP(*(.vectors)) }   > flash
  .text     : { *(.text*) }           > flash
  .rodata   : { *(.rodata*) }         > flash

  .data : {
    _sdata = .;                                  /* .data section start */
    *(.first_data)
    *(.data SORT(.data.*))
    _edata = .;                                  /* .data section end */
  } > sram AT > flash
  _sidata = LOADADDR(.data);

  .bss : {
    _sbss = .;                                   /* .bss section start */
    *(.bss SORT(.bss.*) *(COMMON))
    _ebss = .;                                   /* .bss section end */
  } > sram

  . = ALIGN(8);
  _end = .;     /* for cmsis_gcc.h  */
}

Breakdown

.vectors : { KEEP(*(.vectors)) } > flash:

* Garbage-collection is ran when the linker is invoked with the --gc-sections flag. It silently removes sections which appear to have no references to them. For optimisation that's great, but i the context of a startup file on Cortex-M chips, the vector table is never explicitly referenced in code; the CPU uses it automatically, so removing it will be catastrophic.

.text : { *(.text*) } > flash: This is largely the same as the previous like, except:

I will split the line starting with .data : { from here.

_sdata = .;:

*(.first_data):

*(.data SORT(.data.*)):

_edata = .;:

> sram AT > flash: Places the section in flash AND sram, however in different ways:

_sidata = LOADADDR(.data);:

* LMA persists between power-offs as it's stored into persistent flash memory (in this case). VMA is placed in RAM (in this case yet again), which is non-persistent, so upon a power-off it will be lost. That's why I said VMA is 'placed' - there has to be code to copy it from its LMA to its VMA at startup, otherwise the CPU will try to access the VMA at runtime and (inevitably) access garbage data.

The code starting at .bss : { only has 2 noteworthy things to mention:

And the final noteworthy piece - . = ALIGN(8);:

GNU-Style Attributes

While not part of the linker script, these work together with it to make everything function properly.

GNU-style attributes are a way you can:

By doing this: __attribute__((section(“.my_section”))) void my_function(void);

The compiler and assembler generate an additional section inside the object file, and call it “.my_section”.

Your linker can then access that section and whatever is inside it - that way you can place a function or variable anywhere in memory that you wish.

Final Words

This article barely scratches the surface of what's possible with the linker script. I mostly covered the basics that would be enough for the average basic project (as that's more-or-less what I know as of now), so I'd recommend looking into more linker script concepts. Check out the sources below - some of them (mostly the written sources) have a lot of extra information on more advanced concepts.

Sources

STM32World - STM32 Bare Metal Tutorial MCU on Eclipse - Accessing GNU Linker Script Symbols from C/C++ Memfault - Demistifying Firmware Linker Scripts