Embedded STM32 / HAL Development
This skill covers firmware development for STM32 microcontrollers using the STM32 HAL, including project structure, peripheral and interrupt handling, memory and timing constraints, and testing strategies for hardware-focused code.
Workflow for STM32 HAL Firmware Development
- Configure the hardware in CubeMX — Set up clocks, pins, and peripherals in the
.iocfile; generate the HAL initialization code. - Separate generated and hand-written code — Keep CubeMX-generated files untouched except in their designated
USER CODE BEGIN/ENDblocks; put application logic in separate files. - Initialize peripherals once — Centralize
HAL_*_Init()calls inmain()/MX_*_Init()and avoid ad hoc reconfiguration elsewhere in the code. - Write interrupt handlers — Keep ISRs (
HAL_*_Callbackfunctions,EXTI/DMA/timer IRQ handlers) short; set flags or push to a queue and defer real work to the main loop or an RTOS task. - Use DMA for high-throughput I/O — Configure DMA for UART/SPI/I2C/ADC transfers that would otherwise block or burn CPU cycles on polling.
- Add timeouts everywhere — Every blocking HAL call and every hardware wait loop needs a timeout and an explicit error path.
- Test in layers — Unit-test pure logic on a host build (no hardware dependency), then validate peripheral behavior with hardware-in-the-loop tests.
- Flash and debug — Use SWD/JTAG (ST-Link, OpenOCD, or J-Link) with a debugger, plus rate-limited serial logs, to verify behavior on real hardware.
Project Structure
- Keep board support (pin/clock configuration), drivers, middleware (e.g., FreeRTOS, USB stack), application logic, and tests in clearly separated directories.
- Isolate CubeMX-generated or vendor code (
Core/Src/main.c,Drivers/) from hand-written application code so regenerating with CubeMX doesn't clobber custom logic — only edit inside/* USER CODE BEGIN */ ... /* USER CODE END */markers in generated files. - Put hardware access behind narrow interfaces (e.g., a
motor_driver.hwithmotor_set_speed()) so application logic can be unit-tested on a host build without real peripherals. - Document the clock tree (
SYSCLK,HCLK,PCLK1/PCLK2and their max rates), pin mappings, peripheral ownership, and interrupt priority assignments in a single reference (README or header comments) — this is the first thing a debugging session needs.
STM32 HAL and Peripherals
- Initialize each peripheral in exactly one place; avoid scattering
HAL_*_Init()/HAL_*_MspInit()calls or ad hoc register writes across multiple files. - Always check the return value of HAL calls (
HAL_OK,HAL_ERROR,HAL_BUSY,HAL_TIMEOUT) and handle timeout/error cases explicitly — a silently ignoredHAL_TIMEOUTfromHAL_UART_Transmitis a classic source of "it works on my desk" bugs. - Keep blocking HAL calls (
HAL_UART_Transmit,HAL_I2C_Master_Receivewithout_IT/_DMAsuffix) out of time-critical paths like control loops or ISRs. - Use DMA for high-throughput UART, SPI, I2C, ADC, or timer-capture paths when the CPU shouldn't spend cycles byte-shuffling.
- Document buffer ownership and lifetime for every DMA operation — a buffer being read by DMA must not be modified or freed by the CPU until the transfer-complete callback fires.
- Use
volatileonly for memory shared with an ISR or memory-mapped hardware registers;volatileis not a substitute for a proper memory barrier or critical section when data is shared between contexts.
Example: Non-Blocking UART Receive with DMA and Idle-Line Detection
/* USER CODE BEGIN Includes */
#include "main.h"
#include <string.h>
#define RX_BUF_SIZE 128
static uint8_t rx_buf[RX_BUF_SIZE];
static volatile uint8_t rx_ready = 0;
static volatile uint16_t rx_len = 0;
extern UART_HandleTypeDef huart2;
extern DMA_HandleTypeDef hdma_usart2_rx;
/* USER CODE END Includes */
/* USER CODE BEGIN 2 */
void app_uart_start_receive(void)
{
/* Enable idle-line interrupt so a packet of unknown length completes
* the transfer without waiting for the buffer to fill. */
__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);
if (HAL_UART_Receive_DMA(&huart2, rx_buf, RX_BUF_SIZE) != HAL_OK) {
Error_Handler();
}
}
/* USER CODE END 2 */
/* USER CODE BEGIN 4 */
void USART2_IRQHandler(void)
{
if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart2);
HAL_UART_DMAStop(&huart2);
rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx);
rx_ready = 1; /* Deferred: main loop processes the packet. */
/* Re-arm for the next packet. */
HAL_UART_Receive_DMA(&huart2, rx_buf, RX_BUF_SIZE);
return;
}
HAL_UART_IRQHandler(&huart2);
}
/* USER CODE END 4 */
/* Main loop excerpt: heavy work deferred out of the ISR. */
void app_main_loop(void)
{
if (rx_ready) {
rx_ready = 0;
uint16_t len = rx_len;
/* Copy out or parse rx_buf[0..len) here. Do NOT touch rx_buf
* again until this point, since DMA may already be refilling it. */
(void)len;
}
}
Interrupts and Concurrency
- Keep ISRs short and deterministic — set a flag, copy a small fixed-size value, or push to a lock-free queue, then return.
- Defer heavy work (parsing, computation, logging) from interrupts to the main loop, an RTOS task, or an event queue processed outside interrupt context.
- Protect data shared between an ISR and the main context with critical sections (
__disable_irq()/__enable_irq(), ortaskENTER_CRITICAL()under an RTOS), atomics, or lock-free queues — never assume a multi-byte read/write is atomic. - Avoid dynamic allocation (
malloc/new) inside interrupt handlers; allocation is neither deterministic nor guaranteed reentrant-safe. - Make interrupt priority decisions explicit and documented (
NVIC_SetPriority) — a mis-prioritized interrupt can starve time-critical peripherals or violate FreeRTOS'sconfigMAX_SYSCALL_INTERRUPT_PRIORITYconstraint.
Memory and Timing
- Avoid heap allocation in firmware unless the project explicitly allows and budgets for it — prefer static allocation and fixed-size buffers/pools.
- Check stack usage for both ISRs and RTOS tasks (link-time stack usage reports, or
uxTaskGetStackHighWaterMark()under FreeRTOS) — stack overflow on embedded targets typically corrupts silently. - Keep lookup tables
constso the linker places them in flash instead of consuming scarce RAM. - Use fixed-width integer types (
uint8_t,int32_t,uint32_t) for anything hardware-facing (register values, protocol fields, buffer sizes) instead ofint/long, whose width isn't guaranteed. - Add a timeout to every hardware wait — polling a status flag with no bound will hang forever if the hardware never sets it (a common outcome of a misconfigured clock or a disconnected peripheral).
- Treat the independent/window watchdog as part of application design from day one, not a late add-on — decide the refresh strategy before writing the main loop, not after a field failure.
Testing and Debugging
- Unit test pure logic (protocol parsing, state machines, math) on a host build (native gcc/clang) with the hardware layer mocked or stubbed out behind the narrow interfaces from the project structure.
- Use hardware-in-the-loop tests for actual peripheral behavior (timing, electrical signaling, real sensor data) that a host build can't exercise.
- Add assertions (
assert()or a customconfigASSERT-style macro) for impossible hardware states in debug builds, compiled out in release builds if code size is tight. - Use SWD/JTAG (ST-Link/V2, OpenOCD, J-Link) for live debugging, a logic analyzer for signal-level issues, and serial logs with rate limiting (never flood a UART inside a tight loop or ISR).
- Keep fault handlers (
HardFault_Handler, etc.) useful: capture the reset reason (RCC->CSR), relevant fault status registers (SCB->CFSR,SCB->HFSR), and firmware build version/hash so a field crash is diagnosable after the fact.
Common Mistakes
- Modifying CubeMX-generated files outside
USER CODEblocks, so the next regeneration silently deletes the changes. - Busy-waiting forever on a hardware status flag with no timeout, hanging the firmware on any hardware anomaly.
- Sharing a buffer between DMA and the CPU without synchronization (cache invalidation on cores with a data cache, or simply reading before the transfer-complete flag/callback fires).
- Assuming a peripheral's register state is unchanged after waking from a low-power mode (Stop/Standby) — many peripherals require re-initialization after these modes.