Lessons in this series · 4 of 6
Lesson 04

Link STM32 Firmware into Flash and RAM

STM32Bare metal
Code for this lessonstm32-cmake · lesson-04
View on GitHub
On this page

In the last lesson, we compiled C and startup assembly together. The nm output showed that Reset_Handler exists, but symbols such as _estack and _sdata still have no addresses. Compilation creates the pieces; the linker places them in memory and resolves their references. In this lesson, we’ll give it a memory layout and inspect the resulting ELF file.

Do not flash the ELF from this lesson. It has only the first two vector-table entries; a usable firmware image needs a complete exception and interrupt table. This is a linking exercise, and no board is required.

Continue the project from lesson 3

Stay in the stm32-cmake-lesson workspace from lessons 2 and 3. If you’re in its parent directory, enter it now:

cd stm32-cmake-lesson

Keep main.c, startup_stm32l433.s, and cmake/gcc-arm-none-eabi.cmake as they are. We’ll add a linker script and update CMakeLists.txt so the existing object files become an executable. The workspace will then have these files (along with the existing build/ directory):

stm32-cmake-lesson/
├── CMakeLists.txt
├── STM32L433xx_FLASH.ld
├── build/
├── main.c
├── startup_stm32l433.s
└── cmake/
    └── gcc-arm-none-eabi.cmake

The two variables in main.c still give us initialized and zero-filled data to place, while the startup file refers to the memory addresses we’ll define next. You don’t need to rewrite either file.

Describe Flash and RAM

The linker needs a linker script to describe the target’s memory regions and decide where each section belongs. For this STM32L433RCT6P example, Flash starts at 0x08000000 and has 256 KiB; main RAM starts at 0x20000000 and has 48 KiB; RAM2 starts at 0x10000000 and has 16 KiB. Other STM32 devices need a layout that matches their memory. Create STM32L433xx_FLASH.ld in the project directory:

ENTRY(Reset_Handler)

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 256K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 48K
    RAM2  (rwx) : ORIGIN = 0x10000000, LENGTH = 16K
}

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

SECTIONS
{
    .isr_vector :
    {
        KEEP(*(.isr_vector))
    } > FLASH

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

    .ARM.extab : { *(.ARM.extab*) } > FLASH
    .ARM.exidx : { *(.ARM.exidx*) } > FLASH

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

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

/* Leave at least 1 KiB of RAM for the descending stack. */
ASSERT(_ebss + 0x400 <= _estack, "RAM overflow: no room for stack")

The script fills in the addresses that were undefined in lesson 3:

  • Memory regions: MEMORY names Flash and the two RAM regions. _estack is the top of main RAM (0x2000C000), where the downward-growing stack begins. RAM2 is listed but unused. ENTRY records the ELF entry point; at reset, the Cortex-M instead reads the stack pointer and reset address from the vector table.
  • Code and vectors: SECTIONS places the vector table first, followed by code and read-only data in Flash. KEEP prevents the vector table from being discarded. .ARM.extab and .ARM.exidx hold Arm exception metadata if any is present.
  • Initialized data: .data has a load address in Flash for its initial bytes and a run address in RAM for the writable variables. AT > FLASH selects the load region; _sidata = LOADADDR(.data) gives startup code the Flash source, while _sdata and _edata mark the RAM destination.
  • Zero-initialized data and stack: .bss (NOLOAD) occupies RAM without storing initial bytes in Flash; _sbss and _ebss mark the range to clear. The final ASSERT reserves at least 1 KiB of main RAM above the data for the stack.

The linker will now resolve the memory symbols that showed up as U in lesson 3’s nm output. It will also resolve main from the C object file.

Keep the Arm toolchain file from lesson 3. Replace CMakeLists.txt so firmware changes from an OBJECT library into an executable that CMake links:

cmake_minimum_required(VERSION 3.22)

project(stm32-cmake-lesson LANGUAGES C ASM)

set(LINKER_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/STM32L433xx_FLASH.ld")

add_executable(firmware main.c startup_stm32l433.s)
set_target_properties(firmware PROPERTIES
    SUFFIX ".elf"
    C_STANDARD 11
    C_STANDARD_REQUIRED ON
    LINK_DEPENDS "${LINKER_SCRIPT}"
)
target_compile_options(firmware PRIVATE $<$<COMPILE_LANGUAGE:C>:-Wall;-Wextra>)
target_link_options(firmware PRIVATE
    -nostdlib
    "-T${LINKER_SCRIPT}"
    "-Wl,-Map=$<TARGET_FILE_DIR:firmware>/firmware.map"
    -Wl,--gc-sections
)

These CMake settings turn the object-only build into a linked ELF:

  • add_executable invokes the linker to resolve references between the C and assembly objects.
  • -T selects our linker script.
  • -Wl,-Map=... writes a map showing where the linker placed each part of the program.
  • --gc-sections discards unused sections.
  • -nostdlib avoids pulling in a C runtime this example does not use.
  • LINK_DEPENDS triggers a relink when the script changes.

Build and inspect the ELF

From stm32-cmake-lesson, reconfigure the existing build/ directory and build the linked ELF. CMake keeps the toolchain and Ninja generator selected in lesson 2:

cmake -S . -B build
cmake --build build

The output is build/firmware.elf. Inspect its size, sections, and resolved symbols:

arm-none-eabi-size build/firmware.elf
arm-none-eabi-objdump -h build/firmware.elf
arm-none-eabi-nm -n build/firmware.elf

Here is the output from building the example with STM32CubeCLT; sizes and some addresses may vary with the toolchain. First, arm-none-eabi-size shows the totals for code, initialized data, and zero-initialized data:

   text    data     bss     dec     hex filename
     96       4       4     104      68 build/firmware.elf
  • text — 96 bytes: Code and read-only contents in Flash, including the vector table.
  • data — 4 bytes: Initialized data that runs in RAM; its initial value is also stored in Flash.
  • bss — 4 bytes: Zero-initialized RAM, with no initial bytes stored in Flash.
  • dec / hex — 104 / 0x68: The sum of text, data, and bss, shown in decimal and hexadecimal. It is not the size of the ELF file on disk.
  • filename: The ELF that was measured.

arm-none-eabi-objdump -h shows where the linked sections belong:

build/firmware.elf:     file format elf32-littlearm

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .isr_vector   00000008  08000000  08000000  00001000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .text         00000058  08000008  08000008  00001008  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .data         00000004  20000000  08000060  00002000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .bss          00000004  20000004  08000064  00002004  2**2
                  ALLOC
  4 .comment      00000043  00000000  00000000  00002004  2**0
                  CONTENTS, READONLY
  5 .ARM.attributes 00000032  00000000  00000000  00002047  2**0
                  CONTENTS, READONLY
  • Vector table and code: .isr_vector begins at 0x08000000, followed by .text in Flash.
  • Initialized data: .data has a VMA (run address) of 0x20000000 in RAM and an LMA (load address) of 0x08000060 in Flash. Startup copies its initial bytes from Flash to RAM.
  • Zero-initialized data: .bss occupies RAM but has ALLOC without CONTENTS or LOAD, so there are no stored initial bytes to copy.

Finally, arm-none-eabi-nm -n shows that the symbols left unresolved in lesson 3 now have addresses:

08000008 T main
08000020 T Reset_Handler
08000060 A _sidata
20000000 D _sdata
20000000 D initialized_counter
20000004 D _edata
20000004 B _sbss
20000004 B zero_counter
20000008 B _ebss
2000c000 R _estack

_sidata points into Flash, _sdata points into RAM, and _estack is at the top of main RAM. Open build/firmware.map in a text editor if you want to see which input sections contributed to these addresses.

What’s next

We now have a linked ELF and can see how startup connects Flash and RAM. Do not flash it: linking the file does not make its two-entry vector table complete or safe to run on a board. In the next lesson, we’ll replace this teaching example’s startup with ST’s complete device startup and add CMSIS and the HAL.