Series / STM32 Development with CMake and VSCode / Organizing Reusable STM32 CMake Projects

Organizing Reusable STM32 CMake Projects

Structure an STM32 CMake project so application code, board configuration, startup files, linker scripts, generated vendor code, and reusable drivers have clear ownership.

On this page

The previous article added STM32CubeMX-generated CMSIS and HAL files to the CMake project without making STM32CubeIDE the owner of the workflow.

That leaves the project in a realistic state. It now has hand-written application code, generated vendor code, startup code, a linker script, CMake files, and VSCode debug configuration. That is enough moving material that folder structure starts to matter.

This article organizes those pieces into a project layout that can grow without hiding how the firmware is built.

What the Project Contains Now

The project has several kinds of files with different ownership:

  • Application code written for this firmware.
  • Startup code that defines the reset path and vector table.
  • A linker script that describes target memory.
  • CubeMX-generated CMSIS and HAL support files.
  • CMake files that describe the build.
  • A toolchain file that selects arm-none-eabi-gcc.
  • VSCode launch configuration for debugging through OpenOCD.

Those files should not all live in one source folder. They change for different reasons, and they should be reviewed with different expectations.

The Ownership Rule

Use folder structure to show ownership.

Good project organization should make these questions easy to answer:

  • Which files are generated?
  • Which files are hand-written application code?
  • Which files are specific to this board?
  • Which files are specific to this STM32 part family?
  • Which files could become reusable drivers?
  • Which files control the build and debug workflow?

The goal is not to create a complex framework. The goal is to avoid mixing unrelated responsibilities until the project becomes difficult to change.

A Practical Folder Layout

A useful next layout is:

firmware/
├── app/
│   ├── Inc/
│   └── Src/
├── boards/
│   └── nucleo-l433rcp/
│       ├── Inc/
│       └── Src/
├── cmake/
│   └── arm-none-eabi-gcc.cmake
├── drivers/
│   ├── Inc/
│   └── Src/
├── generated/
│   ├── Core/
│   └── Drivers/
├── linker/
│   └── STM32L433RCTx_FLASH.ld
├── startup/
│   └── startup_stm32l433xx.c
├── .vscode/
│   └── launch.json
└── CMakeLists.txt

This is still a small project. It only separates the categories that already exist.

Application Code

The app folder contains firmware behavior for this application:

app/
├── Inc/
└── Src/
    └── main.c

This is where the application loop, application state, and project-specific behavior belong.

Application code can call board support functions and reusable drivers, but it should not need to know every generated HAL source file or linker detail.

For a small project, keeping main.c here is enough. Do not split the application into many modules just to make the tree look professional.

Board Support Code

The boards folder contains details that belong to a physical board:

boards/
└── nucleo-l433rcp/
    ├── Inc/
    │   └── board.h
    └── Src/
        └── board.c

Board support code is a good place for names such as:

  • User LED port and pin.
  • Button port and pin.
  • UART instance used for logs.
  • I2C or SPI bus selected for a display module.

Reusable drivers should not hard-code Nucleo pin names. A display driver should not know that a chip select line happens to be on a specific GPIO pin on this board. The board layer can connect those details.

Reusable Drivers

The drivers folder is for code that could plausibly be reused by more than one application:

drivers/
├── Inc/
└── Src/

Do not move code here too early. A driver boundary is useful when the code has a clear job and a stable interface.

Examples that may belong here later include:

  • A small GPIO wrapper.
  • A debounced button module.
  • A seven-segment display driver.
  • An OLED display driver.
  • A MAX7219 driver.

Reusable does not mean hardware-independent. It means the code has a clean boundary and avoids application-specific assumptions.

Generated Vendor Code

Keep CubeMX output isolated:

generated/
├── Core/
│   ├── Inc/
│   └── Src/
└── Drivers/
    ├── CMSIS/
    └── STM32L4xx_HAL_Driver/

This makes generated-code updates easier to review. If CubeMX changes a file under generated, that change should be understood as vendor configuration output.

Avoid editing generated files directly unless the file has explicit user-code sections and you accept CubeMX's regeneration rules. Prefer keeping application logic, board support, and reusable drivers outside the generated tree.

Startup and Linker Files

Keep startup code and linker scripts visible:

startup/
└── startup_stm32l433xx.c

linker/
└── STM32L433RCTx_FLASH.ld

These files are target-specific. They define how the MCU starts and where firmware sections live in memory.

Do not hide them in a generic helper folder. When startup fails, or when memory placement changes, these are some of the first files you need to inspect.

If a future board uses the same MCU and memory layout, it may reuse these files. If a future target uses a different STM32 family or memory size, it probably needs different startup and linker inputs.

CMake Files

Keep the top-level CMakeLists.txt readable. It should show the firmware target, major source groups, include directories, compile definitions, linker script, and post-build outputs.

The toolchain file remains separate:

cmake/
└── arm-none-eabi-gcc.cmake

That file selects the cross compiler and related tools. It should not contain application source lists or board-specific choices.

If the top-level CMake file grows too large, introduce small helper files only when there is a real boundary. For example, a later project might add:

cmake/
├── arm-none-eabi-gcc.cmake
└── stm32-common.cmake

But do not split CMake just to split it. Too many helper files can make the build harder to understand than one clear top-level file.

A Clear Source List

A straightforward CMakeLists.txt might keep source ownership visible like this:

add_executable(firmware
    app/Src/main.c
    boards/nucleo-l433rcp/Src/board.c
    startup/startup_stm32l433xx.c
    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 is more verbose than a glob, but it is also more explicit. In embedded projects, explicit source ownership is usually worth the extra lines.

When a file is added, the build file changes. That makes source additions visible in code review and avoids accidentally compiling generated files that were not meant to be part of the firmware.

Include Directories Without Sprawl

Include paths should be broad enough to compile the project, but not so broad that every folder can include everything accidentally.

For a small firmware target, this is reasonable:

target_include_directories(firmware PRIVATE
    app/Inc
    boards/nucleo-l433rcp/Inc
    drivers/Inc
    generated/Core/Inc
    generated/Drivers/CMSIS/Include
    generated/Drivers/CMSIS/Device/ST/STM32L4xx/Include
    generated/Drivers/STM32L4xx_HAL_Driver/Inc
)

Avoid adding the project root as a global include directory. It makes includes less meaningful and can hide naming conflicts.

Definitions and Linker Options

Target-specific compile definitions remain attached to the firmware target:

target_compile_definitions(firmware PRIVATE
    STM32L433xx
    USE_HAL_DRIVER
)

Linker options should also stay visible:

target_link_options(firmware PRIVATE
    -T${CMAKE_SOURCE_DIR}/linker/STM32L433RCTx_FLASH.ld
    -Wl,-Map=${CMAKE_BINARY_DIR}/firmware.map
    -Wl,--gc-sections
)

These settings are not incidental. They define the MCU target and memory layout. Hiding them too deeply makes debugging harder.

Keep VSCode Thin

The VSCode debug configuration should still point at the CMake output:

"executable": "${workspaceFolder}/build/firmware.elf"

Do not duplicate build knowledge in VSCode if CMake already owns it. VSCode can launch debugging, and it may eventually run a pre-launch build task, but the target sources, compiler flags, linker script, and output names should remain in CMake.

That keeps command-line builds and editor builds aligned.

What Not to Generalize Yet

Reusable structure is useful. Premature abstraction is not.

Avoid these until there is a concrete need:

  • A multi-board framework before adding a second board.
  • A generic STM32 family abstraction before using a second STM32 family.
  • A driver interface hierarchy before writing a second driver.
  • CMake functions that hide every source list and compiler flag.
  • A complicated generated-code sync process before CubeMX output changes frequently.

The first reusable version should still be easy to read by opening CMakeLists.txt and following the folder names.

How This Helps Future Articles

This organization supports the next STM32 content paths.

The Working with STM32 Peripherals series can add board-level examples such as blinking an LED, reading a button, using timers, and logging over UART without rebuilding the tooling foundation each time.

The display articles can put reusable display code under drivers while keeping board pin choices under boards/nucleo-l433rcp.

The bare-metal articles can revisit startup code, memory-mapped registers, interrupts, and timers while still using the same build/debug workflow.

The important result is that the project has places for new code to go.

Common Mistakes

If generated files are mixed with application files, CubeMX updates become hard to review and application behavior becomes harder to find.

If board pin names leak into reusable drivers, those drivers become difficult to reuse on another board.

If multiple startup files are compiled, the linker will report duplicate reset handlers or vector tables.

If include directories are too broad, files may compile for accidental reasons and break later when names collide.

If CMake is split into too many helper files, the build becomes less transparent even though it looks more organized.

If source files are discovered with broad globs, generated files may be added to the build accidentally.

If every folder is made reusable from the start, the project accumulates abstractions before there is enough code to justify them.

What This Proves

This article proves that the STM32 CMake workflow can grow beyond the minimal tutorial project without becoming opaque.

The project can now separate:

  • Application behavior.
  • Board-specific support.
  • Reusable driver candidates.
  • Generated vendor support.
  • Startup and linker configuration.
  • Build and debug tooling.

That separation is enough for the next stage of development. It keeps the workflow practical while preserving the visibility that motivated CMake and VSCode in the first place.

Series Wrap-Up

This series started with a question: how do you build, flash, and debug STM32 firmware without making STM32CubeIDE the center of the project?

The answer is not to reject STM32's tools. The answer is to give each tool a clear job.

CMake owns the build. The Arm GNU toolchain compiles and links the firmware. OpenOCD talks to ST-LINK. VSCode edits code and launches the debugger. CubeMX can generate vendor support files when useful. The repository owns the project structure.

That foundation is now ready for actual STM32 firmware work: GPIO, clocks, timers, UART, I2C, SPI, display modules, and eventually reusable peripheral drivers.

Next Steps

The next path is Working with STM32 Peripherals.

That series can assume the build, flash, debug, and project-organization basics from this tooling series. Instead of explaining how to produce firmware.elf again, it can focus on making the STM32 Nucleo L433RC-P do useful work.