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,
.datacopy addresses,.bssbounds, and stack check still serve the same purposes. - Runtime sections: The vendor startup calls
__libc_init_array()beforemain(). The script now includes.preinit_arrayand.init_arrayfor initialization, as well as.fini_array,.init, and.finifor runtime support. - Retained sections:
KEEPprevents 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:
STARTUPpoints to the complete vector table from the firmware package, replacing the educationalstartup_stm32l433.sremoved above. - CMSIS system source:
system_stm32l4xx.cprovidesSystemInit()and the clock value used by SysTick. CMSIS core and device definitions come from headers. - HAL sources:
stm32l4xx_hal.cprovidesHAL_Init()and tick handling;stm32l4xx_hal_cortex.cprovides Cortex-related helpers. - Shared build settings: The
stm32_halobject library groups vendor sources. ItsPUBLICinclude paths and device definitions also apply tomain.c. - C runtime: We remove lesson 4’s
-nostdlibbecause the vendor startup calls__libc_init_array().--specs=nano.specsselects a smaller newlib variant. - System-call stubs:
--specs=nosys.specssupplies 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_vectorstarts at0x08000000. At0x18cbytes, it is much larger than lesson 4’s two-entry (0x8-byte) table. - Flash contents:
.textand the runtime arrays are stored in Flash. - Initialized data:
.dataruns in RAM at0x20000000; its initial bytes load from Flash at0x0800105c. - Zero-initialized data:
.bssoccupies RAM but has no stored initial bytes. - Linker map:
build/firmware.mapshows 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
nosysstubs 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.