Embedded & IoT No. 466

STM32 HAL Firmware skill for Claude Code

CubeMX projects that survive regeneration and real traffic. Claude treats the .ioc as the source of truth, keeps user code inside USER CODE blocks, and verifies the clock tree on a pin. Peripherals run on interrupts or DMA with error callbacks that recover, NVIC priorities are tabulated against the FreeRTOS syscall ceiling, and a bundled map-file script shows exactly where flash and RAM went.

  • namestm32-hal
  • size246 lines · ≈3,000 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and debugs STM32 firmware with STM32CubeMX, STM32CubeIDE or the VS Code extension, and the HAL and LL drivers - clock tree and flash wait states, GPIO, UART, SPI and I2C in polling, interrupt and DMA modes, HAL vs LL trade-offs, NVIC priority grouping with FreeRTOS, SWD and ST-LINK debugging, printf over SWO or UART, HardFault decoding, linker scripts and map files, cache-coherent DMA on Cortex-M7, and Stop/Standby entry and wake. Use when generating or editing a CubeMX project, a HAL callback never fires, UART DMA reception drops bytes, the MCU hangs in HAL_Delay or HardFault_Handler, flash or RAM is full, or code must survive CubeMX regeneration.

Try asking

  • UART DMA on my STM32F4 drops bytes when frames are short. Fix the reception
  • The board hangs in HAL_Delay after I added FreeRTOS. What is wrong with my priorities?
  • Flash is 98% full on this STM32 project. Find what is using it

Use it when

  • Generating or editing a STM32CubeMX project
  • A HAL callback never fires or a peripheral stops after an error
  • Debugging HardFaults, SWD connection problems or SWO output
  • Flash or RAM is running out, or DMA data looks stale on F7/H7

Skip it when

How to check the result

Regenerating from the .ioc and rebuilding changes nothing unintended, SYSCLK measures correctly on MCO, injected bus errors recover, and map_summary.py stays under the threshold.

The common mistake it prevents

Calling HAL_Delay or a blocking HAL transfer from a callback, which runs in interrupt context and waits forever for a tick that cannot preempt it.

~/.claude/skills/stm32-hal/SKILL.md

--- name: stm32-hal description: Builds and debugs STM32 firmware with STM32CubeMX, STM32CubeIDE or the VS Code extension, and the HAL and LL drivers - clock tree and flash… license: MIT ---

Done well means: the .ioc file is the single source of truth for pins and clocks and regenerating code loses nothing; the clock tree is verified on a pin, not assumed; every peripheral transfer that can block runs on interrupt or DMA with an error callback; interrupt priorities are written down and compatible with the RTOS; and the map file shows where every kilobyte of flash and RAM went.

Tooling (late 2026)

  • STM32CubeMX is standalone. Since STM32CubeIDE 2.0 (Nov 2025) the IDE no longer embeds it: configure and generate in CubeMX, then build and debug in CubeIDE, the ST VS Code extension, or CMake. Make standalone CubeMX the app that opens .ioc files.
  • Commit the .ioc and generated code. Record the CubeMX and firmware package versions (shown in the .ioc) in the README; regenerating with a newer package changes code.
  • Flash and verify from the command line with STM32CubeProgrammer: STM32_Programmer_CLI -c port=SWD -w build/app.elf -v -rst.

Ground rules

  • Write user code only between /* USER CODE BEGIN x */ and /* USER CODE END x */, or in your own files. CubeMX overwrites everything else on regeneration.
  • Never call HAL_Delay, blocking HAL_*_Transmit/Receive, printf, or malloc from an interrupt or HAL callback (callbacks run in interrupt context).
  • Check every HAL_StatusTypeDef return. HAL_BUSY and HAL_TIMEOUT are normal outcomes, not impossible ones.
  • With FreeRTOS: NVIC_PRIORITYGROUP_4, HAL timebase on a basic timer (TIM6/TIM7), and any ISR that calls a ...FromISR API at priority numerically greater than or equal to configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY (CubeMX default 5).
  • Erasing option bytes, enabling read-out protection (RDP level 1 or 2), or mass-erasing a board the user cares about needs explicit confirmation first. RDP level 2 is permanent.

Workflow

- [ ] 1. Read the .ioc, reference manual chapter, and datasheet pin table for the part
- [ ] 2. Configure clocks in CubeMX; verify SYSCLK on MCO or with a timer toggle
- [ ] 3. Configure peripherals: mode (poll / IT / DMA), DMA channels, NVIC priorities
- [ ] 4. Generate; add code in USER CODE blocks or separate files
- [ ] 5. Implement callbacks, including error callbacks and restart logic
- [ ] 6. Debug over SWD; add SWO or UART logging
- [ ] 7. Check memory with the map file; check stack headroom
- [ ] 8. Low-power entry and wake, watchdog, then a soak test

2. Clock tree

  • Prefer HSE (crystal) for UART baud accuracy, USB (needs a precise 48 MHz), CAN, and timing; HSI is fine for prototypes and low-cost parts within its tolerance.
  • When raising SYSCLK, flash wait states (FLASH_LATENCY_x) and the voltage scale must be set for the target frequency before switching; CubeMX does it, hand edits often do not. Wrong latency gives random HardFaults at speed.
  • Verify: route SYSCLK/n to the MCO pin and measure, or toggle a GPIO from a timer and check the period. In code, HAL_RCC_GetSysClockFreq() and SystemCoreClock must agree with the plan.
  • After waking from Stop, most families restart on HSI/MSI: call SystemClock_Config() again before using peripherals.

3. Pick the transfer mode

SituationMode
Init-time config writes, a few bytes, no RTOSPolling with a finite timeout
Short, sporadic transfers; CPU must stay freeInterrupt (_IT)
Streams, > ~16 bytes, high baud, or continuous ADCDMA (_DMA), circular for streams
Unknown-length UART framesHAL_UARTEx_ReceiveToIdle_DMA + HAL_UARTEx_RxEventCallback

UART reception of variable-length data:

/* USER CODE BEGIN 0 */
#define RX_BUF_LEN 256
static uint8_t rx_buf[RX_BUF_LEN];          /* DMA in circular mode (set in CubeMX) */
static volatile uint16_t rx_tail;

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t pos)
{
    if (huart->Instance != USART2) return;
    /* pos = index the DMA has written up to; data in [rx_tail, pos) is new (wraps) */
    ring_push_from_isr(rx_buf, RX_BUF_LEN, rx_tail, pos);   /* copy out, notify a task */
    rx_tail = pos;
}

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    /* overrun/framing/noise: restart reception or the UART stays silent */
    HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_LEN);
}
/* USER CODE END 0 */

/* in main(), USER CODE BEGIN 2 */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_LEN);
  • The callback fires on idle line, half transfer and transfer complete; recent HAL versions report which with HAL_UARTEx_GetRxEventType(). The ring logic above handles all three.
  • In normal (non-circular) DMA mode, re-arm reception inside the callback.
  • The USART global interrupt must be enabled in NVIC, not just the DMA stream interrupt; the idle event arrives through the USART IRQ.

I2C: implement HAL_I2C_ErrorCallback; on HAL_I2C_ERROR_AF (NACK) retry a bounded number of times; on a stuck bus (SDA held low after reset) deinit the peripheral, clock SCL manually as GPIO up to 9 pulses, then reinit. Wiring and pull-up problems belong to serial-protocols.

HAL vs LL

ConcernHALLL
Portability across STM32 familiesHighLow (register-level, family-specific)
Code size / cycles per callLarger, state checks and locksMinimal, mostly inline
Error handling, timeouts, callbacksBuilt inWrite your own
Use forMost peripherals, prototypes, DMA plumbingHot paths: per-sample ISRs, bit-banged timing, GPIO toggles, tight ADC/timer loops

Mixing is fine per peripheral (CubeMX lets you choose HAL or LL per peripheral in Project Manager -> Advanced Settings). Do not drive the same peripheral instance with both.

Callbacks

HAL calls weak functions (HAL_UART_TxCpltCallback, HAL_GPIO_EXTI_Callback, …). Override them in your file; check huart->Instance because one callback serves every instance. To register per-instance functions instead, enable USE_HAL_UART_REGISTER_CALLBACKS (and siblings) in CubeMX and call HAL_UART_RegisterCallback.

NVIC and FreeRTOS

Write the priority table into the project README:

IRQPriorityCalls RTOS API?
TIM6 (HAL timebase)TICK_INT_PRIORITY, often 15 (lowest)No; so HAL timeouts cannot advance inside any ISR
Motor PWM / fast capture1-4No (cannot call FromISR)
USART2, DMA1 stream 55-6Yes, xTaskNotifyFromISR
EXTI buttons10Yes
SysTick, PendSV (kernel)15Kernel
  • On Cortex-M, a lower number is a higher priority. ISRs above the syscall ceiling (0-4 with the default) run with zero RTOS latency but may not call any RTOS API.
  • configASSERT must be defined in development; the port asserts on ISR priority mistakes instead of corrupting the kernel later.
  • Task design, queues and stack sizing belong to freertos-tasks.

6. Debugging

  • SWD needs SWDIO, SWCLK, GND, and ideally NRST and target VCC sense. If the probe cannot connect, use “connect under reset” (hold NRST); firmware that reconfigures PA13/PA14 or enters low power immediately locks out normal attach.
  • printf over SWO (Cortex-M3/M4/M7 and Armv8-M Mainline parts with ITM; M0/M0+ have none): route _write to ITM_SendChar, enable SWV in the debugger with the correct core clock. Over UART: implement _write with HAL_UART_Transmit (task context only).
int _write(int fd, char *ptr, int len)
{
    (void)fd;
    for (int i = 0; i < len; i++) ITM_SendChar((uint32_t)ptr[i]);
    return len;
}
  • HardFault: read SCB->CFSR, SCB->HFSR, SCB->MMFAR, SCB->BFAR, and the stacked PC from the exception frame; look the PC up in the map/ELF (arm-none-eabi-addr2line -e app.elf 0x08001234). PRECISERR + valid BFAR = bad address; UNDEFINSTR/INVSTATE = jump through a bad function pointer; STKERR = stack overflow.
  • Keep the debugger attached through sleep with HAL_DBGMCU_EnableDBGSleepMode() / EnableDBGStopMode() during development only; they raise sleep current.

7. Memory map and linker script

  • The generated *_FLASH.ld defines MEMORY regions, _estack, _Min_Heap_Size and _Min_Stack_Size. The minimums are a link-time check, not a runtime guard.
  • Add -Wl,-Map=build/app.map -Wl,--print-memory-usage to the link flags, then:
python3 scripts/map_summary.py build/app.map --top 15 --fail-above 90

It prints per-region usage (counting .data in both RAM and its flash load address), the largest object files and input sections, and exits 1 above the threshold.

  • Usual flash hogs: printf with float support (-u _printf_float), unused HAL modules (disable them in CubeMX), -O0 builds. Use -Os or -O2 with -ffunction-sections -fdata-sections -Wl,--gc-sections.
  • Placing buffers in a specific RAM: add an output section in the linker script, then __attribute__((section(".dma_buf"), aligned(32))) static uint8_t buf[512];.

Cortex-M7 (F7/H7) DMA and cache

  • With D-cache enabled, DMA and CPU see different data. Before DMA TX: SCB_CleanDCache_by_Addr(buf, len); after DMA RX: SCB_InvalidateDCache_by_Addr(buf, len). Buffers must be 32-byte aligned and sized in multiples of 32, or invalidation clobbers neighbors.
  • Alternative: an MPU region marking the DMA buffer RAM non-cacheable.
  • On H7, DMA1/DMA2 cannot reach DTCM; place DMA buffers in AXI SRAM or D2 SRAM.

8. Low power and watchdog

ModeEntryWakeNotes
SleepHAL_PWR_EnterSLEEPMode(PWR_MAINREGULATOR_ON, PWR_SLEEPENTRY_WFI)Any interruptSuspend SysTick/HAL tick first or it wakes every 1 ms
Stop (Stop 0/1/2 on L4/U5)HAL_PWR_EnterSTOPMode(...) or HAL_PWREx_EnterSTOP2Mode(PWR_STOPENTRY_WFI)EXTI, RTC, LPUART, LPTIMRAM kept; reconfigure clocks after wake
Standby / ShutdownHAL_PWR_EnterSTANDBYMode()WKUP pins, RTC, resetRAM lost (except backup domain); boots from reset
  • HAL_SuspendTick() before Sleep/Stop, HAL_ResumeTick() after.
  • Clear wake-up flags before entering, or the MCU wakes immediately.
  • Power budgeting, measurement and hidden drains belong to low-power-firmware.
  • IWDG: start it in production builds, refresh from one place that proves the system is healthy (main loop or a supervisor task), never from a timer ISR. Freeze it in debug with the DBGMCU freeze bit.

Gotchas

  • HAL_Delay inside an ISR or callback whose priority is equal to or higher than the timebase interrupt never returns: the tick that would end it cannot preempt.
  • HAL_GetTick() stops advancing when the timebase interrupt is masked or the timebase timer is reconfigured by user code.
  • Forgetting __HAL_RCC_GPIOx_CLK_ENABLE() (or the DMA clock) in hand-written init: the peripheral silently does nothing.
  • EXTI lines are shared by pin number across ports: PA0 and PB0 cannot both be interrupts.
  • volatile on flags shared with ISRs does not make count++ atomic; wrap read-modify-write and multi-word state in a critical section (__disable_irq() / __enable_irq(), or taskENTER_CRITICAL_FROM_ISR under FreeRTOS).
  • CubeMX regeneration resets main.c code outside USER CODE blocks and may re-add MX_*_Init calls you deleted; disable the peripheral in the .ioc instead.
  • Boot pins: BOOT0 high boots the system bootloader; a floating BOOT0 gives “works sometimes after power cycle”.

Verification

  1. Clean build from a fresh CubeMX generation (regenerate, rebuild, diff shows only intended changes).
  2. SYSCLK measured on MCO or a timer pin matches the plan within crystal tolerance.
  3. Each peripheral path tested with its error injected: unplug the I2C device, send UART garbage at the wrong baud, overrun the RX buffer; the error callback recovers.
  4. map_summary.py under threshold; stack painted with a pattern at boot and checked after a soak shows headroom.
  5. A soak of at least an hour at maximum data rate with the watchdog enabled and no resets, asserts, or lost frames.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/map_summary.py 158 lines · 5.7 KB
#!/usr/bin/env python3
"""Summarize flash/RAM usage from a GNU ld (arm-none-eabi) .map file.

Usage:
    python3 map_summary.py build/app.map [--top 15] [--fail-above 90]

Reports, per memory region from the "Memory Configuration" table:
  used bytes, size, percent (output sections counted at their run address, and
  initialized data such as .data also counted at its flash load address),
then the largest object files and largest individual input sections.

Exits 1 when any region is above --fail-above percent, 2 on unreadable input.
"""
import argparse
import os
import re
import sys
from collections import defaultdict

HEX = r"0x[0-9a-fA-F]+"
REGION_RE = re.compile(rf"^(\S+)\s+({HEX})\s+({HEX})(?:\s+(\S+))?\s*$")
OUT_FULL = re.compile(rf"^(\.\S+|\S+)\s+({HEX})\s+({HEX})(?:\s+load address\s+({HEX}))?\s*$")
OUT_NAME_ONLY = re.compile(r"^(\.\S+)\s*$")
ADDR_SIZE = re.compile(rf"^\s+({HEX})\s+({HEX})(?:\s+load address\s+({HEX}))?(?:\s+(\S.*))?$")
IN_FULL = re.compile(rf"^ (\S+)\s+({HEX})\s+({HEX})\s+(\S.*)$")
IN_NAME_ONLY = re.compile(r"^ (\S+)\s*$")
SKIP_OUT = (".debug", ".comment", ".ARM.attributes", ".stab", ".note.gnu")


def parse(path):
    regions, outputs, inputs = [], [], []
    with open(path, encoding="utf-8", errors="replace") as fh:
        lines = fh.read().splitlines()

    i, n = 0, len(lines)
    while i < n and not lines[i].startswith("Memory Configuration"):
        i += 1
    i += 1
    while i < n and not lines[i].startswith("Linker script and memory map"):
        m = REGION_RE.match(lines[i])
        if m and m.group(1) not in ("Name", "*default*"):
            regions.append({"name": m.group(1), "origin": int(m.group(2), 16),
                            "length": int(m.group(3), 16), "used": 0})
        i += 1

    pending_out = pending_in = None
    while i < n:
        line = lines[i]
        i += 1
        if not line.strip():
            continue
        if not line.startswith(" "):
            pending_in = None
            m = OUT_FULL.match(line)
            if m:
                outputs.append((m.group(1), int(m.group(2), 16), int(m.group(3), 16),
                                int(m.group(4), 16) if m.group(4) else None))
                pending_out = None
                continue
            m = OUT_NAME_ONLY.match(line)
            pending_out = m.group(1) if m else None
            continue
        if pending_out:
            m = ADDR_SIZE.match(line)
            if m:
                outputs.append((pending_out, int(m.group(1), 16), int(m.group(2), 16),
                                int(m.group(3), 16) if m.group(3) else None))
            pending_out = None
            continue
        if line.startswith(" *fill*") or line.startswith(" *("):
            pending_in = None
            continue
        m = IN_FULL.match(line)
        if m:
            inputs.append((m.group(1), int(m.group(2), 16), int(m.group(3), 16), m.group(4).strip()))
            pending_in = None
            continue
        if pending_in:
            m = ADDR_SIZE.match(line)
            if m and m.group(4):
                inputs.append((pending_in, int(m.group(1), 16), int(m.group(2), 16), m.group(4).strip()))
            pending_in = None
            continue
        m = IN_NAME_ONLY.match(line)
        if m and not m.group(1).startswith("0x"):
            pending_in = m.group(1)
    return regions, outputs, inputs


def region_for(regions, addr):
    for r in regions:
        if r["origin"] <= addr < r["origin"] + r["length"]:
            return r
    return None


def human(b):
    return f"{b / 1024:.1f} KiB" if b >= 1024 else f"{b} B"


def main():
    ap = argparse.ArgumentParser(description="Summarize a GNU ld map file")
    ap.add_argument("mapfile")
    ap.add_argument("--top", type=int, default=15)
    ap.add_argument("--fail-above", type=float, default=90.0, help="percent; exit 1 if any region exceeds it")
    a = ap.parse_args()
    try:
        regions, outputs, inputs = parse(a.mapfile)
    except OSError as exc:
        print(f"ERROR: {exc}")
        return 2
    if not regions:
        print("ERROR: no 'Memory Configuration' table found; is this a GNU ld map file (-Wl,-Map=...)?")
        return 2

    for name, vma, size, lma in outputs:
        if size == 0 or name.startswith(SKIP_OUT):
            continue
        r = region_for(regions, vma)
        if r:
            r["used"] += size
        if lma is not None and lma != vma:
            rl = region_for(regions, lma)
            if rl and rl is not r:
                rl["used"] += size

    print(f"{'Region':<12}{'Used':>12}{'Size':>12}{'Use%':>8}")
    over = []
    for r in regions:
        pct = 100.0 * r["used"] / r["length"] if r["length"] else 0.0
        flag = "  <-- over limit" if pct > a.fail_above else ""
        if flag:
            over.append(r["name"])
        print(f"{r['name']:<12}{human(r['used']):>12}{human(r['length']):>12}{pct:>7.1f}%{flag}")

    by_obj = defaultdict(int)
    for sec, addr, size, obj in inputs:
        if size and region_for(regions, addr) and not sec.startswith(SKIP_OUT):
            by_obj[os.path.basename(obj)] += size
    print(f"\nLargest object files (top {a.top}):")
    for obj, size in sorted(by_obj.items(), key=lambda kv: -kv[1])[: a.top]:
        print(f"  {size:>9}  {obj}")

    print(f"\nLargest input sections (top {a.top}):")
    real = [x for x in inputs if x[2] and region_for(regions, x[1]) and not x[0].startswith(SKIP_OUT)]
    for sec, addr, size, obj in sorted(real, key=lambda x: -x[2])[: a.top]:
        r = region_for(regions, addr)
        print(f"  {size:>9}  {r['name']:<8} {sec:<40} {os.path.basename(obj)}")

    if over:
        print(f"\nFAIL: {', '.join(over)} above {a.fail_above:.0f}%")
        return 1
    print(f"\nOK: all regions at or below {a.fail_above:.0f}%")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc