embedded-stm32

v2026.09.24

Best practices for embedded C/C++ development on STM32 microcontrollers using the HAL, covering peripherals, DMA, interrupts, memory constraints, and hardware-focused testing. Use when writing STM32 HAL code, configuring peripherals generated by STM32CubeMX, working with interrupts or DMA, debugging with SWD/JTAG, managing memory-constrained firmware, or writing hardware-in-the-loop or host-build tests for embedded C.

GitHub
Install command
npx skhub add mindrally/embedded-stm32
Markdown
SKILL.md

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

  1. Configure the hardware in CubeMX — Set up clocks, pins, and peripherals in the .ioc file; generate the HAL initialization code.
  2. Separate generated and hand-written code — Keep CubeMX-generated files untouched except in their designated USER CODE BEGIN/END blocks; put application logic in separate files.
  3. Initialize peripherals once — Centralize HAL_*_Init() calls in main()/MX_*_Init() and avoid ad hoc reconfiguration elsewhere in the code.
  4. Write interrupt handlers — Keep ISRs (HAL_*_Callback functions, 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.
  5. 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.
  6. Add timeouts everywhere — Every blocking HAL call and every hardware wait loop needs a timeout and an explicit error path.
  7. Test in layers — Unit-test pure logic on a host build (no hardware dependency), then validate peripheral behavior with hardware-in-the-loop tests.
  8. 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.h with motor_set_speed()) so application logic can be unit-tested on a host build without real peripherals.
  • Document the clock tree (SYSCLK, HCLK, PCLK1/PCLK2 and 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 ignored HAL_TIMEOUT from HAL_UART_Transmit is a classic source of "it works on my desk" bugs.
  • Keep blocking HAL calls (HAL_UART_Transmit, HAL_I2C_Master_Receive without _IT/_DMA suffix) 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 volatile only for memory shared with an ISR or memory-mapped hardware registers; volatile is 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(), or taskENTER_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's configMAX_SYSCALL_INTERRUPT_PRIORITY constraint.

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 const so 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 of int/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 custom configASSERT-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 CODE blocks, 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.
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

Apache-2.0

Source path

embedded-stm32

Default branch

main

Latest commit

9718410

Tree SHA

81ba3c7