Series / STM32 Development with CMake and VSCode / Adding STM32CubeMX-Generated Drivers to a CMake Project

Adding STM32CubeMX-Generated Drivers to a CMake Project

Use STM32CubeMX-generated startup, HAL, CMSIS, and device support files from a CMake project without making STM32CubeIDE the owner of the workflow.

On this page

The previous article connected VSCode to the STM32 target through OpenOCD and debugged the CMake-built firmware.elf file.

At this point, the workflow can build, flash, and debug a minimal bare-metal project. That is enough to understand the toolchain, but real STM32 projects usually need more than a hand-written main.c and startup file. They often need CMSIS headers, HAL drivers, interrupt names, clock setup, and peripheral initialization code.

STM32CubeMX can generate those pieces. The important constraint for this series is that CubeMX should provide code, not take ownership of the whole project workflow.

The Role of CubeMX

CubeMX is useful for:

  • Selecting the exact STM32 part or board.
  • Configuring pins, clocks, and peripherals.
  • Generating CMSIS device support files.
  • Generating STM32 HAL source files and configuration headers.
  • Producing startup code and interrupt stubs.

CubeMX does not need to own:

  • The editor.
  • The build command.
  • The debug configuration.
  • The repository structure.
  • The decision to use CMake and VSCode.

That separation keeps the workflow explicit. CubeMX can help with vendor-specific setup while CMake remains the build system and VSCode remains the editor/debugger front end.

Starting Point

This article assumes the project from the previous lessons already builds and debugs:

stm32-cmake-minimal/
├── CMakeLists.txt
├── cmake/
│   └── arm-none-eabi-gcc.cmake
├── linker/
│   └── STM32L433RCTx_FLASH.ld
├── src/
│   ├── main.c
│   └── startup_stm32l433xx.c
└── .vscode/
    └── launch.json

The goal is not to abandon that structure. The goal is to add vendor-generated files deliberately and teach CMake where they are.

Generate Code with CubeMX

Open STM32CubeMX and create a new project for the STM32 Nucleo L433RC-P board or the STM32L433RCTx device.

For the first integration pass, keep the configuration small:

  • Use the default board or device clock setup.
  • Leave most peripherals disabled.
  • Keep the generated project simple enough to inspect.

In Project Manager, choose a toolchain or IDE output that produces normal source files. The exact option depends on the CubeMX version, but the output should include folders such as Core, Drivers, and a startup file.

The generated project is a source input, not the final project. You can generate it into a temporary folder, inspect the output, and then copy the useful parts into the CMake project.

Generated Files to Keep

A typical CubeMX output for this target includes a structure similar to:

Core/
├── Inc/
│   ├── main.h
│   ├── stm32l4xx_hal_conf.h
│   └── stm32l4xx_it.h
└── Src/
    ├── main.c
    ├── stm32l4xx_hal_msp.c
    ├── stm32l4xx_it.c
    └── system_stm32l4xx.c

Drivers/
├── CMSIS/
│   ├── Device/ST/STM32L4xx/Include/
│   └── Include/
└── STM32L4xx_HAL_Driver/
    ├── Inc/
    └── Src/

The most useful pieces are:

  • Drivers/CMSIS/Include for Cortex-M CMSIS headers.
  • Drivers/CMSIS/Device/ST/STM32L4xx/Include for STM32L4 device headers.
  • Drivers/STM32L4xx_HAL_Driver/Inc for HAL headers.
  • Drivers/STM32L4xx_HAL_Driver/Src for HAL driver implementations.
  • Core/Inc/stm32l4xx_hal_conf.h for HAL feature selection.
  • Core/Src/system_stm32l4xx.c for system clock and core support.
  • Core/Src/stm32l4xx_hal_msp.c for low-level HAL peripheral hooks.
  • Core/Src/stm32l4xx_it.c if you want CubeMX interrupt handlers.

You may also get a generated startup file and linker script. Do not add them blindly if the project already has hand-written versions from earlier lessons.

Place Generated Code in the Project

One simple layout is:

stm32-cmake-minimal/
├── CMakeLists.txt
├── cmake/
├── generated/
│   ├── Core/
│   │   ├── Inc/
│   │   └── Src/
│   └── Drivers/
│       ├── CMSIS/
│       └── STM32L4xx_HAL_Driver/
├── linker/
├── src/
└── .vscode/

Using a generated folder makes ownership clear. Files under generated came from CubeMX. Files under src, cmake, and linker belong to the hand-maintained project.

You can choose a different folder name, but keep the boundary obvious. Mixing generated vendor code directly into hand-written application folders makes later updates harder to review.

Decide Which Startup File Owns Reset

The earlier articles built a hand-written startup file so the reset path was visible.

CubeMX can also generate startup code. For one firmware image, only one startup file should provide the vector table and reset handler.

Choose one of these approaches:

  • Keep the hand-written src/startup_stm32l433xx.c and do not compile the generated startup file.
  • Replace the hand-written startup file with the CubeMX-generated startup file.

For this article, keep the hand-written startup file. That preserves the learning path from the linker script and startup code articles while still allowing the project to use CMSIS and HAL support.

The same rule applies to interrupt handlers. If two source files define the same handler, the linker will fail with duplicate symbol errors. Keep exactly one definition for each interrupt handler.

Update Include Directories

Add the generated include paths to the firmware target in CMakeLists.txt:

target_include_directories(firmware PRIVATE
    generated/Core/Inc
    generated/Drivers/CMSIS/Include
    generated/Drivers/CMSIS/Device/ST/STM32L4xx/Include
    generated/Drivers/STM32L4xx_HAL_Driver/Inc
)

If your project still uses an object library such as firmware_objects, add the include directories to the target that compiles the C source files. The important point is that every source file including STM32 or HAL headers must see these paths.

Add Required Definitions

The project already used the target device definition:

STM32L433xx

HAL code also expects:

USE_HAL_DRIVER

Add both definitions to the compiling target:

target_compile_definitions(firmware PRIVATE
    STM32L433xx
    USE_HAL_DRIVER
)

STM32L433xx selects the correct device header behavior. USE_HAL_DRIVER enables HAL-related declarations in the STM32 device headers.

Add Generated Source Files

Start with a small source list rather than adding every generated .c file at once:

target_sources(firmware PRIVATE
    generated/Core/Src/system_stm32l4xx.c
    generated/Core/Src/stm32l4xx_hal_msp.c
    generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal.c
    generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_cortex.c
    generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_rcc.c
    generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_rcc_ex.c
    generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_gpio.c
)

This list is not universal. CubeMX enables HAL modules based on selected peripherals and the contents of stm32l4xx_hal_conf.h.

If you enable UART, I2C, SPI, timers, DMA, or other peripherals, you will need the corresponding HAL source files. Add only the modules the project actually uses.

Keep One main

CubeMX usually generates Core/Src/main.c. The existing project also has src/main.c.

Do not compile both if they both define main.

For this article, keep the hand-written src/main.c and use CubeMX output for support code. That keeps the application entry point simple and avoids hiding the project flow inside generated code.

Later, once you are comfortable with the generated structure, you can either:

  • Move useful initialization functions from generated main.c into hand-maintained source files.
  • Adopt CubeMX main.c and treat user-code sections carefully.

The first option is easier to reason about in a small CMake project.

Call HAL Initialization Carefully

If the project starts using HAL drivers, main usually needs HAL initialization:

#include "stm32l4xx_hal.h"

int main(void)
{
    HAL_Init();

    volatile uint32_t counter = 0;

    while (1)
    {
        counter++;
    }
}

HAL_Init() configures HAL internal state and sets up the SysTick time base by default. That is useful for many HAL examples, but it also means the project is no longer doing absolutely nothing at startup.

If your startup file does not install the SysTick handler expected by HAL, or if interrupt handlers are duplicated or missing, HAL-based code may fail in ways that look unrelated to the build system. This is why the previous debugger article matters: stop the target and inspect what is actually happening.

Build the Project

Reconfigure if the CMake file changed significantly:

cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-gcc.cmake -DCMAKE_BUILD_TYPE=Debug

Then build:

cmake --build build

The first build after adding generated code often fails. Treat that as normal integration feedback, not as a reason to abandon the workflow.

Debug the Integrated Firmware

After the build succeeds, use the same VSCode debug configuration from the previous article.

The debugger still points at:

build/firmware.elf

That is the main benefit of keeping CMake as the owner of the build. The project can gain generated vendor drivers without changing how VSCode launches the debug session.

Set a breakpoint in main, step over HAL_Init(), and inspect whether execution reaches the loop. If it does, the generated code is integrated enough for the current project state.

Common Mistakes

If the compiler cannot find stm32l4xx_hal.h, check the HAL include path.

If the compiler cannot find stm32l433xx.h, check the CMSIS device include path and confirm STM32L433xx is defined.

If HAL declarations are missing or disabled, confirm USE_HAL_DRIVER is defined and stm32l4xx_hal_conf.h is on the include path.

If the linker reports duplicate Reset_Handler or vector table symbols, two startup files are being compiled. Remove one from the build.

If the linker reports duplicate interrupt handlers, both generated and hand-written files define the same handler. Keep one owner for each interrupt.

If the linker reports undefined HAL functions, a needed HAL .c file is missing from target_sources.

If the firmware builds but faults before main, debug from Reset_Handler and check the startup path, vector table, and system initialization code.

If regenerated CubeMX files overwrite local edits, stop editing generated files directly or make sure the changes live inside CubeMX user-code sections. Better yet, keep application behavior in hand-maintained source files and use generated files as support code.

What This Proves

This integration proves that:

  • CubeMX-generated CMSIS and HAL files can live inside a CMake project.
  • CMake can compile selected generated sources explicitly.
  • The existing VSCode debugger workflow still works with generated vendor code.
  • CubeMX can help with STM32-specific setup without owning the project environment.

That is the practical middle ground. You do not have to choose between a fully hand-written bare-metal project and an IDE-owned generated project.

What This Does Not Prove

This article does not prove that every CubeMX peripheral configuration is correct.

Clock trees, GPIO alternate functions, DMA channels, interrupt priorities, and peripheral timing still need careful review. CubeMX can generate plausible code, but firmware correctness still depends on the board, datasheet, reference manual, and measurements.

The important workflow result is that generated code is now visible to CMake, visible to version control, and debuggable through the same firmware.elf path used by the rest of the series.

Next Steps

The next article organizes the project into a more reusable STM32 CMake structure.

Now that the project has hand-written code, generated vendor code, linker scripts, startup code, build files, and debug configuration, folder boundaries start to matter. A clearer structure will make future peripheral drivers and display projects easier to maintain.