Lessons in this series · 6 of 6
Lesson 06

Blink the LED and Flash the STM32 Board

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

In the last lesson, we built an ELF with ST’s complete startup, CMSIS, and the HAL. It has everything needed to begin running, but nothing visible to do. Now we’ll give it a job: configure PB13 as an output, change its state every 500 ms, and flash the program to the STM32L433RCT6P Nucleo board. Its green LD4 LED is connected to PB13.

This is our first hardware exercise. Unlike the earlier build-only lessons, you’ll need the board to check the result.

Continue the project from lesson 5

Use the stm32-cmake-lesson workspace from lessons 2–5 and the STM32CubeL4 firmware package configured in lesson 5. Keep its toolchain file, linker script, and vendor startup selection unchanged. For the hardware step, you’ll need the Nucleo board, a USB cable for its ST-LINK port, and STM32_Programmer_CLI on your PATH.

If you’re in the project’s parent directory, enter the existing workspace:

cd stm32-cmake-lesson

We’ll replace main.c, update stm32l4xx_hal_conf.h, and add one GPIO driver source to CMakeLists.txt. The existing build/ directory will be reused:

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

CMake still reads the complete vendor startup and HAL sources from the firmware package; the educational startup file was removed in lesson 5.

Replace main.c with the blink application:

#include "stm32l4xx_hal.h"

void SysTick_Handler(void)
{
    HAL_IncTick();
}

int main(void)
{
    GPIO_InitTypeDef gpio = {0};

    if (HAL_Init() != HAL_OK) {
        while (1) {
        }
    }

    __HAL_RCC_GPIOB_CLK_ENABLE();

    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_RESET);
    gpio.Pin = GPIO_PIN_13;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOB, &gpio);

    while (1) {
        HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_13);
        HAL_Delay(500);
    }
}

As in lesson 5, HAL_Init() establishes the default 1 ms SysTick time base. Each SysTick interrupt calls HAL_IncTick(), advancing the counter that HAL_Delay() reads. Without that handler, the delay could wait forever. We leave the chip on its default post-reset clock; no PLL configuration is needed for a blink.

Before touching PB13, we enable GPIOB’s peripheral clock. We set the output level before configuring the pin to avoid an unintended transition, then select a push-pull output with no pull resistor and low speed. In the loop, HAL_GPIO_TogglePin() flips PB13 and HAL_Delay(500) waits roughly half a second. A full on/off cycle takes about one second.

Enable the GPIO HAL driver

Update stm32l4xx_hal_conf.h to enable the GPIO module alongside the modules from lesson 5:

#ifndef STM32L4XX_HAL_CONF_H
#define STM32L4XX_HAL_CONF_H

#define HAL_MODULE_ENABLED
#define HAL_CORTEX_MODULE_ENABLED
#define HAL_RCC_MODULE_ENABLED
#define HAL_GPIO_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_gpio.h"
#include "stm32l4xx_hal_rcc.h"

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

#endif

Compared with lesson 5, the new HAL_GPIO_MODULE_ENABLED definition and GPIO header provide the declarations for pin setup and toggling. A header is not the implementation: we’ll also add stm32l4xx_hal_gpio.c to the CMake target. That is the only new HAL driver source needed for this change.

Add the GPIO source to CMake

Keep the linker script and Arm toolchain from lesson 5. Update CMakeLists.txt so the stm32_hal object library also compiles the GPIO driver:

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"
    "${DRIVERS}/Src/stm32l4xx_hal_gpio.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 only new source since lesson 5 is stm32l4xx_hal_gpio.c. The vendor startup, other HAL sources, device definitions, and runtime link options stay the same. Adding the GPIO module in the header lets main.c use its declarations; compiling the GPIO driver supplies their implementations.

From stm32-cmake-lesson, reconfigure and build in the existing build/ directory. CMake retains the Arm toolchain, Ninja generator, and firmware package path from the earlier lessons:

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

A successful build updates build/firmware.elf. If CMake cannot find the startup file, check the firmware package path cached in STM32CUBE_L4_ROOT; if you get undefined HAL_GPIO_* references, check that the GPIO driver source appears in add_library. The nosys stubs and small linker script may produce warnings about unimplemented I/O calls or an RWX load segment; these warnings do not prevent the ELF from being built, but the stubs do not provide working console I/O.

Flash and watch LD4

Connect the Nucleo board through its ST-LINK USB port. From the same workspace, run:

STM32_Programmer_CLI -c port=SWD -w build/firmware.elf -v -rst

STM32CubeProgrammer uses ST-LINK over SWD to program the ELF, verify what was written, and reset the board. LD4 should change state roughly every 500 ms: about half a second on and half a second off. A successful CMake build alone never transfers firmware to the chip.

If the programmer cannot connect, check the USB cable and ST-LINK port, that the board is powered, and that no other tool has the ST-LINK connection open. If programming succeeds but LD4 does not blink, confirm that you’re using the STM32L433RCT6P Nucleo board, that LD4 is on PB13 for your board, and that the SysTick handler calls HAL_IncTick(). A different board may require different pin or power setup. The build, flashing step, and LD4 blink have been tested on the STM32L433RCT6P Nucleo board.

What you’ve built

You started with C source, selected a cross-compiler, supplied startup and memory layout, added CMSIS and the HAL, then used the resulting ELF to program a real microcontroller. The blink is small, but it exercises the whole chain: compile → link → flash → run. You now have a build you can read and change yourself instead of relying on an IDE to assemble it invisibly. Those same pieces will be easier to recognize when you encounter generated projects in the future.