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.
- name
stm32-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
- Designing RTOS tasks, queues and stack budgets — use freertos-tasks
- Writing STM32 firmware in Rust with embassy — use embedded-rust
- Debugging I2C, SPI or UART wiring and signal integrity — use serial-protocols
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.
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
.iocfiles. - Commit the
.iocand 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, blockingHAL_*_Transmit/Receive,printf, ormallocfrom an interrupt or HAL callback (callbacks run in interrupt context). - Check every
HAL_StatusTypeDefreturn.HAL_BUSYandHAL_TIMEOUTare normal outcomes, not impossible ones. - With FreeRTOS:
NVIC_PRIORITYGROUP_4, HAL timebase on a basic timer (TIM6/TIM7), and any ISR that calls a...FromISRAPI at priority numerically greater than or equal toconfigLIBRARY_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()andSystemCoreClockmust 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
| Situation | Mode |
|---|---|
| Init-time config writes, a few bytes, no RTOS | Polling with a finite timeout |
| Short, sporadic transfers; CPU must stay free | Interrupt (_IT) |
| Streams, > ~16 bytes, high baud, or continuous ADC | DMA (_DMA), circular for streams |
| Unknown-length UART frames | HAL_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
| Concern | HAL | LL |
|---|---|---|
| Portability across STM32 families | High | Low (register-level, family-specific) |
| Code size / cycles per call | Larger, state checks and locks | Minimal, mostly inline |
| Error handling, timeouts, callbacks | Built in | Write your own |
| Use for | Most peripherals, prototypes, DMA plumbing | Hot 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:
| IRQ | Priority | Calls RTOS API? |
|---|---|---|
| TIM6 (HAL timebase) | TICK_INT_PRIORITY, often 15 (lowest) | No; so HAL timeouts cannot advance inside any ISR |
| Motor PWM / fast capture | 1-4 | No (cannot call FromISR) |
| USART2, DMA1 stream 5 | 5-6 | Yes, xTaskNotifyFromISR |
| EXTI buttons | 10 | Yes |
| SysTick, PendSV (kernel) | 15 | Kernel |
- 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.
configASSERTmust 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.
printfover SWO (Cortex-M3/M4/M7 and Armv8-M Mainline parts with ITM; M0/M0+ have none): route_writetoITM_SendChar, enable SWV in the debugger with the correct core clock. Over UART: implement_writewithHAL_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+ validBFAR= 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.lddefinesMEMORYregions,_estack,_Min_Heap_Sizeand_Min_Stack_Size. The minimums are a link-time check, not a runtime guard. - Add
-Wl,-Map=build/app.map -Wl,--print-memory-usageto 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:
printfwith float support (-u _printf_float), unused HAL modules (disable them in CubeMX),-O0builds. Use-Osor-O2with-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
| Mode | Entry | Wake | Notes |
|---|---|---|---|
| Sleep | HAL_PWR_EnterSLEEPMode(PWR_MAINREGULATOR_ON, PWR_SLEEPENTRY_WFI) | Any interrupt | Suspend 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, LPTIM | RAM kept; reconfigure clocks after wake |
| Standby / Shutdown | HAL_PWR_EnterSTANDBYMode() | WKUP pins, RTC, reset | RAM 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_Delayinside 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.
volatileon flags shared with ISRs does not makecount++atomic; wrap read-modify-write and multi-word state in a critical section (__disable_irq()/__enable_irq(), ortaskENTER_CRITICAL_FROM_ISRunder FreeRTOS).- CubeMX regeneration resets
main.ccode outside USER CODE blocks and may re-addMX_*_Initcalls you deleted; disable the peripheral in the.iocinstead. - Boot pins: BOOT0 high boots the system bootloader; a floating BOOT0 gives “works sometimes after power cycle”.
Verification
- Clean build from a fresh CubeMX generation (regenerate, rebuild, diff shows only intended changes).
- SYSCLK measured on MCO or a timer pin matches the plan within crystal tolerance.
- 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.
map_summary.pyunder threshold; stack painted with a pattern at boot and checked after a soak shows headroom.- 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
#!/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.