Series / Working with STM32 Peripherals / Organizing a Small STM32 Firmware Project

Organizing a Small STM32 Firmware Project

Organize a small STM32 firmware project after adding GPIO, timers, UART, I2C, and SPI by separating board support, peripheral setup, drivers, and application code.

On this page

The previous articles brought up the core peripherals used by many small STM32 projects: GPIO, clocks, timers, UART, I2C, and SPI.

That is enough functionality to build useful firmware. It is also enough functionality to turn main.c into a junk drawer if every pin definition, callback, log helper, bus transaction, and application decision stays in one file.

This article organizes a small STM32 firmware project without turning it into a framework.

What This Article Covers

This article covers:

  • Separating application behavior from board support.
  • Keeping generated HAL/CubeMX code isolated.
  • Giving peripheral setup a clear home.
  • Deciding when small drivers are useful.
  • Keeping interrupt callbacks short.
  • Avoiding premature abstraction.

It does not introduce an RTOS, dependency injection, dynamic driver registration, generic bus frameworks, or a large hardware abstraction layer. Those tools can be useful in larger systems, but they are not needed to keep a small STM32 project maintainable.

What the Project Has Now

At this point in the series, the firmware may include:

  • LED GPIO output.
  • Button GPIO input and debounce.
  • Clock configuration.
  • Timer update callbacks.
  • UART logging.
  • I2C scanning and basic transactions.
  • SPI transmit or transmit/receive transactions.
  • CubeMX-generated HAL support.
  • CMake source lists and VSCode debug configuration.

All of that code has different ownership. Some belongs to the board. Some belongs to the application. Some belongs to generated vendor support. Some is reusable enough to become a small driver.

Folder structure should make those boundaries visible.

A Practical Small-Project Layout

A useful layout for this stage is:

firmware/
├── app/
│   ├── app.c
│   └── app.h
├── board/
│   ├── board.c
│   └── board.h
├── peripherals/
│   ├── gpio.c
│   ├── gpio.h
│   ├── uart.c
│   ├── uart.h
│   ├── i2c.c
│   ├── i2c.h
│   ├── spi.c
│   └── spi.h
├── drivers/
│   ├── i2c_devices/
│   └── spi_devices/
├── generated/
├── startup/
├── linker/
├── cmake/
└── CMakeLists.txt

This is not the only valid structure. The important point is that each folder has a job.

Application Code

The app folder owns firmware behavior.

It decides what the device does:

  • When to blink status LEDs.
  • What to do when a button is pressed.
  • Which module to query.
  • What to log.
  • How to combine timer events, bus transactions, and state changes.

A small application entry shape can be simple:

int main(void)
{
    HAL_Init();
    SystemClock_Config();

    board_init();
    app_init();

    while (1)
    {
        app_run();
    }
}

This keeps main boring. That is a good thing. Startup should initialize the platform and then hand control to the application.

Board Support

The board folder owns facts about this physical board.

Examples include:

  • Which GPIO pin drives the status LED.
  • Which GPIO pin reads the user button.
  • Which UART is connected to the debug serial path.
  • Which I2C pins reach the module header.
  • Which SPI chip-select pin controls a module.

Board names should describe physical meaning:

#define BOARD_LED_STATUS_PORT GPIOA
#define BOARD_LED_STATUS_PIN GPIO_PIN_5

#define BOARD_BUTTON_USER_PORT GPIOC
#define BOARD_BUTTON_USER_PIN GPIO_PIN_13

A driver should not need to know that a display chip select is on a specific Nucleo header pin. The board layer can connect that physical detail to the driver.

Peripheral Setup

The peripherals folder owns MCU peripheral initialization and thin wrappers.

Examples:

  • uart.c initializes the logging UART and provides log_write().
  • i2c.c initializes the selected I2C instance and may expose i2c_scan() for bring-up.
  • spi.c initializes the selected SPI instance and provides basic transmit helpers.
  • gpio.c initializes board GPIO used by the application.

This layer is still close to HAL. It does not need to hide the fact that the project uses STM32 HAL. Its job is to stop every source file from repeating handle names, clock enables, and setup details.

Drivers

The drivers folder is for code that knows a device protocol.

Examples:

  • An OLED controller driver.
  • A MAX7219 LED matrix driver.
  • A temperature sensor driver.
  • A shift-register output driver.

Drivers should know the device protocol, not the board layout.

For example, an SPI display driver may need a way to write bytes and toggle data/command state, but it should not hard-code the Nucleo pin used for chip select. Board or peripheral code should provide that connection.

Do not move code into drivers just because the folder exists. A driver boundary is useful when there is a device behavior worth naming.

Generated Code

Keep generated vendor code under generated or an equally obvious folder.

Generated code may include:

  • HAL configuration headers.
  • CMSIS device headers.
  • CubeMX initialization files.
  • MSP setup files.
  • Interrupt handler templates.

Avoid mixing generated files with hand-written application files. When CubeMX output changes, you want to review those changes as generated configuration changes, not hunt through application logic to see what moved.

If you edit generated files, keep those edits inside supported user-code sections or accept that regeneration may overwrite them.

Interrupt and Callback Boundaries

Timer callbacks and interrupt handlers should stay short.

Good callback behavior:

  • Set a flag.
  • Increment a counter.
  • Toggle a debug pin.
  • Record a small event.

Risky callback behavior:

  • Blocking UART logs.
  • Long I2C transactions.
  • Long SPI transfers.
  • HAL_Delay().
  • Complex application decisions.

For example, a timer callback can set a flag:

static volatile bool tick_1hz;

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2)
    {
        tick_1hz = true;
    }
}

Then the main application loop can do the heavier work:

void app_run(void)
{
    if (tick_1hz)
    {
        tick_1hz = false;
        board_led_toggle();
        log_write("tick\r\n");
    }
}

That pattern keeps interrupt timing predictable and application behavior easier to debug.

Bring-Up Utilities Are Not Always Permanent

The I2C scanner from the previous article is a bring-up utility.

It is valuable while wiring and addresses are uncertain. It may not belong in the final application loop forever.

The same applies to repeated SPI test transfers and one-second UART tick logs. They prove the bus works. Once a real module driver exists, keep the useful diagnostic code but remove noisy test loops that no longer serve the application.

Good project organization makes that cleanup easier because bring-up helpers are not tangled with core application behavior.

CMake Source Lists

Keep source lists explicit.

For a small project, this is readable:

target_sources(firmware PRIVATE
    app/app.c
    board/board.c
    peripherals/gpio.c
    peripherals/uart.c
    peripherals/i2c.c
    peripherals/spi.c
    startup/startup_stm32l433xx.c
    generated/Core/Src/system_stm32l4xx.c
    generated/Core/Src/stm32l4xx_hal_msp.c
)

Avoid broad source globs that silently compile every .c file under generated or drivers. Embedded builds are easier to review when adding a source file requires an intentional build-file change.

Include directories should also be deliberate:

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

Do not add the whole project root as a shortcut include path unless there is a concrete reason.

When Not to Abstract

Do not create a generic driver framework because one LED blinks.

Do not create a board abstraction layer for five boards when the project only supports one board.

Do not hide every HAL call behind a wrapper before seeing repeated pain.

Do not create a generic I2C device registry before the second I2C device exists.

Good abstractions usually appear after duplication or awkward boundaries become real. Until then, clear names and small files are enough.

A Reasonable First Boundary

A reasonable small-project boundary is:

  • main.c handles startup sequence only.
  • board names physical pins and board-level helpers.
  • peripherals initializes MCU peripherals and exposes thin project helpers.
  • drivers owns module-specific protocols.
  • app owns behavior and state.
  • generated stays isolated.

That is enough structure for many small STM32 projects.

Common Mistakes

The first common mistake is putting every example directly into main.c and never cleaning it up.

The second is putting board pin names inside reusable drivers.

The third is mixing CubeMX-generated code with hand-written application code.

The fourth is doing blocking UART, I2C, or SPI work inside timer callbacks or interrupt handlers.

The fifth is wrapping HAL so aggressively that the code becomes harder to debug than direct HAL calls.

The sixth is using broad include paths that make dependencies unclear.

The seventh is using broad source globs that compile files accidentally.

The eighth is keeping old bring-up tests active after real application behavior exists.

What This Proves

This article proves that the peripheral work from the series can be organized into maintainable boundaries without becoming a large framework.

The project can now separate:

  • Board facts.
  • Peripheral setup.
  • Application behavior.
  • Device-specific drivers.
  • Generated vendor support.
  • Build configuration.

That separation is enough to support the display, sensor, and driver articles that build on this foundation.

What This Does Not Prove

This does not prove the structure is final for every project.

Larger firmware may need event queues, RTOS tasks, DMA ownership rules, test seams, configuration layers, or stricter driver interfaces. Smaller firmware may need fewer folders.

The point is to choose structure that matches the current project size while leaving obvious places for code to grow.

Series Wrap-Up

This series started after the CMake and VSCode tooling foundation was complete.

It then walked through the practical STM32 peripherals needed by many small projects:

  • GPIO output.
  • GPIO input.
  • Clocks.
  • Timers.
  • UART logging.
  • I2C.
  • SPI.
  • Small-project organization.

That is enough foundation to start building real modules and displays instead of only proving that the board runs code.

Next Steps

Two paths naturally build on this series.

Display Interfaces From Scratch can use GPIO, I2C, SPI, timers, and project organization to bring up OLEDs, LCDs, seven-segment displays, LED matrices, and TFT modules.

From Reset Vector to Application can go deeper below HAL and revisit startup code, memory-mapped I/O, interrupts, timers, DMA, and reusable peripheral drivers from a lower-level perspective.