On this page
- What This Article Covers
- What the Project Has Now
- A Practical Small-Project Layout
- Application Code
- Board Support
- Peripheral Setup
- Drivers
- Generated Code
- Interrupt and Callback Boundaries
- Bring-Up Utilities Are Not Always Permanent
- CMake Source Lists
- When Not to Abstract
- A Reasonable First Boundary
- Common Mistakes
- What This Proves
- What This Does Not Prove
- Series Wrap-Up
- Next Steps
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.cinitializes the logging UART and provideslog_write().i2c.cinitializes the selected I2C instance and may exposei2c_scan()for bring-up.spi.cinitializes the selected SPI instance and provides basic transmit helpers.gpio.cinitializes 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.chandles startup sequence only.boardnames physical pins and board-level helpers.peripheralsinitializes MCU peripherals and exposes thin project helpers.driversowns module-specific protocols.appowns behavior and state.generatedstays 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.