Lessons in this series · 5 of 6
Lesson 05

Add CMSIS and the STM32 HAL to a CMake Project

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

In the last lesson, we linked our C code and reset handler into an ELF. That taught us where code and data belong, but the two-entry vector table was only a teaching aid. Now we’ll keep the build we understand and replace that incomplete startup with ST’s complete device startup. We’ll also add CMSIS definitions and the STM32 HAL (Hardware Abstraction Layer), then build an application that calls HAL_Init().

This lesson still does not ask you to flash the ELF. The application has no GPIO setup or visible output; we’ll check the build without a board and wait until there is something to observe on hardware.

CMSIS and the STM32 HAL

CMSIS (Cortex Microcontroller Software Interface Standard) provides common definitions for Arm Cortex-M processors. ST’s device-specific CMSIS files add register names, interrupt definitions, startup code, and SystemInit() for the STM32 we’re building for. These are the low-level pieces that connect the processor and device to our C program.

The STM32 HAL (Hardware Abstraction Layer) sits above those definitions. It offers C functions for common setup and peripheral operations, so application code doesn’t have to manipulate every register directly. In this lesson, we’ll add only the HAL sources needed for HAL_Init() and its tick support; later, we’ll add GPIO support for the LED.

We’re using these files so we can focus on how firmware is built and connected, not on rewriting device support. CMSIS gives us the processor and device definitions plus a complete startup path; the HAL gives us established initialization and, in the next lesson, a straightforward GPIO API. That makes this build closer to a practical STM32 project while keeping the source list small enough to understand.

CMSIS and HAL serve different roles. HAL uses the device definitions supplied through CMSIS, but using CMSIS does not require using HAL for every peripheral.

Get the vendor support

The project already uses the command-line tools from STM32CubeCLT. To add ST’s device support, you also need the STM32CubeL4 firmware package, which is separate from CubeCLT. Download and extract it (this example was checked with STM32Cube_FW_L4_V1.18.2), then find the directory containing Drivers/. You don’t need STM32CubeMX to generate a project.

Set an environment variable to the absolute path of that directory. Adjust this example to where you extracted the package:

export STM32CUBE_L4_ROOT="$HOME/STM32Cube/Repository/STM32Cube_FW_L4_V1.18.2"

Check that it contains the GCC startup file:

test -f "$STM32CUBE_L4_ROOT/Drivers/CMSIS/Device/ST/STM32L4xx/Source/Templates/gcc/startup_stm32l433xx.s"

If that command fails, check the path and make sure you have the STM32L4 firmware package, not just the command-line tools. Its Drivers/CMSIS directory provides Arm core and STM32L433 device definitions; Drivers/STM32L4xx_HAL_Driver provides HAL headers and implementations. The package also provides the complete vector table and SystemInit() source. CMake will read those files from the package; we won’t copy them into the project.

Continue the project from lesson 4

Return to the stm32-cmake-lesson workspace from lessons 2–4. If you’re in its parent directory, enter it now:

cd stm32-cmake-lesson

We’ll replace main.c, update the linker script and CMakeLists.txt, add one HAL configuration header, and remove the educational startup file. The project will then look like this (the existing build/ directory is still in use):

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

Keep the Arm toolchain file unchanged. Remove the educational startup_stm32l433.s from the project; we’ll use the complete startup file from the firmware package instead.

rm startup_stm32l433.s

Make an application with a HAL tick

Replace the contents of main.c with:

#include "stm32l4xx_hal.h"

void SysTick_Handler(void)
{
    HAL_IncTick();
}

int main(void)
{
    if (HAL_Init() != HAL_OK) {
        while (1) {
        }
    }

    while (1) {
    }
}

HAL_Init() configures the HAL’s 1 ms SysTick time base using the default clock after reset. Our SysTick_Handler increments the HAL tick counter; without it, time-based HAL functions such as HAL_Delay() would not progress. Nothing configures a GPIO pin here, so an LED will not blink. We also haven’t switched the system clock to a faster setting.

The HAL needs a configuration header to select its modules. Add stm32l4xx_hal_conf.h to the project:

#ifndef STM32L4XX_HAL_CONF_H
#define STM32L4XX_HAL_CONF_H

/* Only the modules and settings needed for HAL_Init in this example. */
#define HAL_MODULE_ENABLED
#define HAL_CORTEX_MODULE_ENABLED
#define HAL_RCC_MODULE_ENABLED

#define TICK_INT_PRIORITY 0U
#define PREFETCH_ENABLE 1U
#define INSTRUCTION_CACHE_ENABLE 1U
#define DATA_CACHE_ENABLE 1U

#include "stm32l4xx_hal_cortex.h"
#include "stm32l4xx_hal_flash.h"
#include "stm32l4xx_hal_rcc.h"

#define assert_param(expr) ((void)0U)

#endif

Enabling a module makes its declarations available; including a header does not mean we must compile every driver source. HAL_Init() uses Cortex and RCC declarations and Flash cache/prefetch definitions. When we start using another peripheral, we’ll revisit both this header and the source list.

Match the startup code with a linker script

ST’s GCC startup file for the STM32L433 includes the full vector table. On reset it calls SystemInit(), initializes .data and .bss, calls the C library’s initialization routine, then enters main(). It expects the linker to define _estack, _sidata, _sdata, _edata, _sbss, and _ebss. Our script must also retain the library’s initialization-array sections.

Replace STM32L433xx_FLASH.ld with this expanded version:

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*)
        KEEP(*(.init))
        KEEP(*(.fini))
        . = ALIGN(4);
    } > FLASH

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

    .preinit_array :
    {
        PROVIDE_HIDDEN(__preinit_array_start = .);
        KEEP(*(.preinit_array*))
        PROVIDE_HIDDEN(__preinit_array_end = .);
    } > FLASH
    .init_array :
    {
        PROVIDE_HIDDEN(__init_array_start = .);
        KEEP(*(SORT(.init_array.*)))
        KEEP(*(.init_array*))
        PROVIDE_HIDDEN(__init_array_end = .);
    } > FLASH
    .fini_array :
    {
        PROVIDE_HIDDEN(__fini_array_start = .);
        KEEP(*(SORT(.fini_array.*)))
        KEEP(*(.fini_array*))
        PROVIDE_HIDDEN(__fini_array_end = .);
    } > 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
}

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

Compared with lesson 4, this script keeps the existing memory layout and adds the sections needed by the vendor startup and C runtime:

  • Unchanged layout: The Flash and RAM regions, .data copy addresses, .bss bounds, and stack check still serve the same purposes.
  • Runtime sections: The vendor startup calls __libc_init_array() before main(). The script now includes .preinit_array and .init_array for initialization, as well as .fini_array, .init, and .fini for runtime support.
  • Retained sections: KEEP prevents the vector table and runtime sections from being removed when the linker discards unused sections.

This remains a small script for the exercise, not a general linker script for every STM32 application.

Compile only the support we use

The existing toolchain file already selects Arm GCC for C and assembly. Replace CMakeLists.txt to use the vendor sources instead of our two-entry startup file:

cmake_minimum_required(VERSION 3.22)

project(stm32-cmake-lesson LANGUAGES C ASM)

set(STM32CUBE_L4_ROOT "" CACHE PATH "STM32CubeL4 firmware package directory")
set(CMSIS "${STM32CUBE_L4_ROOT}/Drivers/CMSIS")
set(DEVICE "${CMSIS}/Device/ST/STM32L4xx")
set(DRIVERS "${STM32CUBE_L4_ROOT}/Drivers/STM32L4xx_HAL_Driver")
set(STARTUP "${DEVICE}/Source/Templates/gcc/startup_stm32l433xx.s")
set(LINKER_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/STM32L433xx_FLASH.ld")

if(NOT EXISTS "${STARTUP}")
    message(FATAL_ERROR "Set STM32CUBE_L4_ROOT to the extracted STM32CubeL4 package")
endif()

add_library(stm32_hal OBJECT
    "${DEVICE}/Source/Templates/system_stm32l4xx.c"
    "${DRIVERS}/Src/stm32l4xx_hal.c"
    "${DRIVERS}/Src/stm32l4xx_hal_cortex.c"
)
target_include_directories(stm32_hal PUBLIC
    "${CMAKE_CURRENT_SOURCE_DIR}"
    "${DRIVERS}/Inc"
    "${DEVICE}/Include"
    "${CMSIS}/Include"
)
target_compile_definitions(stm32_hal PUBLIC STM32L433xx USE_HAL_DRIVER)

add_executable(firmware main.c "${STARTUP}")
target_link_libraries(firmware PRIVATE stm32_hal)
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
    "-T${LINKER_SCRIPT}"
    --specs=nano.specs
    --specs=nosys.specs
    "-Wl,-Map=$<TARGET_FILE_DIR:firmware>/firmware.map"
    -Wl,--gc-sections
)

The source list and link options each have a role:

  • Vendor startup: STARTUP points to the complete vector table from the firmware package, replacing the educational startup_stm32l433.s removed above.
  • CMSIS system source: system_stm32l4xx.c provides SystemInit() and the clock value used by SysTick. CMSIS core and device definitions come from headers.
  • HAL sources: stm32l4xx_hal.c provides HAL_Init() and tick handling; stm32l4xx_hal_cortex.c provides Cortex-related helpers.
  • Shared build settings: The stm32_hal object library groups vendor sources. Its PUBLIC include paths and device definitions also apply to main.c.
  • C runtime: We remove lesson 4’s -nostdlib because the vendor startup calls __libc_init_array(). --specs=nano.specs selects a smaller newlib variant.
  • System-call stubs: --specs=nosys.specs supplies minimal stubs for calls we haven’t implemented. They do not provide working console I/O.

Build and check the ELF

From stm32-cmake-lesson, reconfigure the same build/ directory and pass in the firmware package path. CMake keeps the Arm toolchain and Ninja generator from the previous lessons:

cmake -S . -B build -DSTM32CUBE_L4_ROOT="$STM32CUBE_L4_ROOT"
cmake --build build
arm-none-eabi-size build/firmware.elf
arm-none-eabi-objdump -h build/firmware.elf

Here is the output from building this example with STM32CubeCLT and STM32Cube_FW_L4_V1.18.2; sizes and some addresses may differ with other toolchain or package versions. arm-none-eabi-size reports:

   text    data     bss     dec     hex filename
   4180      20      32    4232    1088 build/firmware.elf

The code is larger than lesson 4’s 96-byte text total: we now link vendor startup, selected HAL sources, and the C runtime. arm-none-eabi-objdump -h shows their sections:

build/firmware.elf:     file format elf32-littlearm

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .isr_vector   0000018c  08000000  08000000  00001000  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .text         00000ec8  0800018c  0800018c  0000118c  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .init_array   00000004  08001054  08001054  00002054  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .fini_array   00000004  08001058  08001058  00002058  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  4 .data         0000000c  20000000  0800105c  00003000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  5 .bss          00000020  2000000c  08001068  0000300c  2**2
                  ALLOC
  6 .ARM.attributes 00000030  00000000  00000000  0000300c  2**0
                  CONTENTS, READONLY
  7 .comment      00000043  00000000  00000000  0000303c  2**0
                  CONTENTS, READONLY
  8 .debug_frame  0000002c  00000000  00000000  00003080  2**2
                  CONTENTS, READONLY, DEBUGGING, OCTETS

Read the output like this:

  • Vector table: .isr_vector starts at 0x08000000. At 0x18c bytes, it is much larger than lesson 4’s two-entry (0x8-byte) table.
  • Flash contents: .text and the runtime arrays are stored in Flash.
  • Initialized data: .data runs in RAM at 0x20000000; its initial bytes load from Flash at 0x0800105c.
  • Zero-initialized data: .bss occupies RAM but has no stored initial bytes.
  • Linker map: build/firmware.map shows which vendor objects and library sections contributed to the ELF. Paths depend on where the firmware package is installed.
  • Possible warnings: The small linker script and nosys stubs may produce unimplemented-I/O or RWX-segment warnings. They do not mean the build failed, but the stubs do not provide working I/O.

What’s next

We now have a complete vector table and a program that can enter HAL_Init(), but a successful build does not prove what happens on a board. There is still no GPIO setup or visible output. In the next lesson, we’ll add a blinking LED and flash a program we can observe.