Summary
Programming flash after halting a running application (no reset) deterministically crashes the flash-write algorithm into a double-fault lockup on an STM32H503, when the halted application uses the Cortex-M33's memory protection — an MPU configured with SRAM execute-never, and armed stack-limit registers (MSPLIM/PSPLIM). Any RTOS with default protection enabled produces this state; our application is a stock Zephyr build (CONFIG_ARM_MPU=y, CONFIG_BUILTIN_STACK_GUARD=y), but nothing in the failure is Zephyr-specific.
This matters because halt-then-program within a session is an ordinary workflow — an IDE or script attaches to a running board, halts it, and loads new firmware. Reset-entry flows are immune, which makes the failure look like flaky hardware rather than what it is: the armv7m algorithm runner executes the flash loader inside the halted application's protection state, and never sanitizes it.
Measured on 0.12.0-g0de861e (2026-08-26) from this repository, built --enable-cmsis-dap. We isolated the exact minimal target state that causes it (bisect below), and a two-write workaround that makes the same sequence pass 100 % of the time.
Setup
- Target: NUCLEO-H503RB, running a Zephyr application (MPU on:
MPU_CTRL=0x5, ENABLE|PRIVDEFENA, SRAM execute-never; stack limits armed: MSPLIM=0x20000f68, PSPLIM=0x20001768)
- Adapter: a CMSIS-DAP v2 probe,
transport select swd, adapter speed 1000
- Config:
-f target/stm32h5x.cfg (work area 0x20000000, size 0x8000)
- Every failing run was recovered with a reset-entry
program <hex> verify reset exit — 25+ recoveries, 100 % pass, image byte-verified each time. The application is healthy; only the halt-mid-run entry fails.
Repro
openocd ... -c init -c halt \
-c "flash write_image erase app.hex" -c "verify_image app.hex" \
-c "reset run" -c shutdown
Result, 6/6:
Error: timeout waiting for algorithm, a target reset is recommended
Error: error executing stm32l4 flash write algorithm
Error: block write failed
Error: error writing to flash at address 0x08000000 at offset 0x00000000
and the next connect prints clearing lockup after double fault.
Post-mortem while wedged: ICSR=0x0400F803 (HardFault active), CFSR=0x00100001 (IACCVIOL | UNDEFINSTR), VTOR=0x08000000, pc=0xEFFFFFFE, DHCSR shows S_LOCKUP. The chain: the algorithm's first instruction fetch from work-area SRAM violates the application's MPU (SRAM is XN) → fault → vector fetch through the vector table that write_image erase has just erased → double fault → lockup, which the host reports as the 5-second algorithm timeout.
Bisect: which target state kills the algorithm
Each arm is init; halt of the running application, then the state adjustment, then flash write_image erase + verify_image (fixture health verified before every run, recovery after every failure):
| state cleared after halt |
result |
| nothing (bare trigger) |
0/6 |
PRIMASK=1 (readback-verified) |
0/3 |
| SysTick off + all NVIC ICER/ICPR cleared + PENDSTCLR |
0/3 |
| ICACHE disabled + invalidated |
0/3 |
MPU off (MPU_CTRL=0, readback-verified) |
0/4 |
| MSPLIM/PSPLIM cleared (via DCRSR, REGSEL 0x1E/0x1F) |
0/3 |
| MPU off AND both stack limits cleared |
PASS 7/7 — 21 584 B written + verified in 1.6 s |
Leave-one-out from the everything-cleared arm: removing the MPU write → FAIL; removing the SPLIM writes → FAIL; removing the ICACHE / interrupt / PRIMASK arms → still PASS. MPU-off + SPLIM-clear is necessary and sufficient.
Instruction-level confirmation of the MPU half: a three-instruction nop; nop; bkpt stub written to the work area and resumed — lands in the application's HardFault handler with the MPU on, halts cleanly at the BKPT with MPU_CTRL=0. SRAM execution is forbidden by the halted application's MPU, and every flash algorithm executes from SRAM. The stack-limit half: the loader's stack lies below the application's PSPLIM, so the first push raises STKOF even with a clean fetch.
Where this lives in the code
armv7m_start_algorithm() saves and restores the core register context and forces xPSR.T (and optionally CONTROL for the requested core mode) — but it never touches MPU_CTRL, and on this target the register cache does not expose MSPLIM/PSPLIM at all, so they are neither saved, sanitized, nor restorable. The loader therefore runs under whatever protection the interrupted application had armed. Reset entry passes only because reset clears MPU_CTRL and both stack limits as a side effect.
Suggested fix / discussion
For ARMv7-M/ARMv8-M algorithm execution: save MPU_CTRL, write 0, restore after the algorithm; on v8-M additionally save/clear/restore MSPLIM and PSPLIM (via DCRSR — REGSEL 0x1E/0x1F are the live pair on this no-TrustZone part; the other banked aliases in 0x18–0x1D read as zero here). That is exactly the two-write recipe the bisect proves sufficient, and it is state the loader cannot legitimately want to inherit.
Workaround for users meanwhile: reset halt before an in-session flash, or issue the two clears manually after halt:
mww 0xE000ED94 0
mww 0xE000EDF8 0; mww 0xE000EDF4 0x0001001E
mww 0xE000EDF8 0; mww 0xE000EDF4 0x0001001F
Related
The same halt-of-running-app trigger class was independently characterized against pyOCD (pyocd/pyOCD#2023); notably its proximate cause there is host-side and different — applying this issue's target-side fix via memory writes does not rescue pyOCD (0/3), while it fixes OpenOCD 7/7. Two hosts, same trigger, two defects.
The deterministic repro stays armed on our bench — happy to test candidate patches, and to carry this to the mainline tracker/mailing list if that is the better venue (the mechanism looks generic armv7m, not fork-specific; only the stm32h5x flash driver of this fork was needed to reach it on an H503).
Summary
Programming flash after halting a running application (no reset) deterministically crashes the flash-write algorithm into a double-fault lockup on an STM32H503, when the halted application uses the Cortex-M33's memory protection — an MPU configured with SRAM execute-never, and armed stack-limit registers (MSPLIM/PSPLIM). Any RTOS with default protection enabled produces this state; our application is a stock Zephyr build (
CONFIG_ARM_MPU=y,CONFIG_BUILTIN_STACK_GUARD=y), but nothing in the failure is Zephyr-specific.This matters because halt-then-program within a session is an ordinary workflow — an IDE or script attaches to a running board, halts it, and loads new firmware. Reset-entry flows are immune, which makes the failure look like flaky hardware rather than what it is: the armv7m algorithm runner executes the flash loader inside the halted application's protection state, and never sanitizes it.
Measured on
0.12.0-g0de861e (2026-08-26)from this repository, built--enable-cmsis-dap. We isolated the exact minimal target state that causes it (bisect below), and a two-write workaround that makes the same sequence pass 100 % of the time.Setup
MPU_CTRL=0x5, ENABLE|PRIVDEFENA, SRAM execute-never; stack limits armed:MSPLIM=0x20000f68,PSPLIM=0x20001768)transport select swd,adapter speed 1000-f target/stm32h5x.cfg(work area0x20000000, size0x8000)program <hex> verify reset exit— 25+ recoveries, 100 % pass, image byte-verified each time. The application is healthy; only the halt-mid-run entry fails.Repro
Result, 6/6:
and the next connect prints
clearing lockup after double fault.Post-mortem while wedged:
ICSR=0x0400F803(HardFault active),CFSR=0x00100001(IACCVIOL | UNDEFINSTR),VTOR=0x08000000,pc=0xEFFFFFFE, DHCSR shows S_LOCKUP. The chain: the algorithm's first instruction fetch from work-area SRAM violates the application's MPU (SRAM is XN) → fault → vector fetch through the vector table thatwrite_image erasehas just erased → double fault → lockup, which the host reports as the 5-second algorithm timeout.Bisect: which target state kills the algorithm
Each arm is
init; haltof the running application, then the state adjustment, thenflash write_image erase+verify_image(fixture health verified before every run, recovery after every failure):PRIMASK=1(readback-verified)MPU_CTRL=0, readback-verified)Leave-one-out from the everything-cleared arm: removing the MPU write → FAIL; removing the SPLIM writes → FAIL; removing the ICACHE / interrupt / PRIMASK arms → still PASS. MPU-off + SPLIM-clear is necessary and sufficient.
Instruction-level confirmation of the MPU half: a three-instruction
nop; nop; bkptstub written to the work area and resumed — lands in the application's HardFault handler with the MPU on, halts cleanly at the BKPT withMPU_CTRL=0. SRAM execution is forbidden by the halted application's MPU, and every flash algorithm executes from SRAM. The stack-limit half: the loader's stack lies below the application's PSPLIM, so the first push raises STKOF even with a clean fetch.Where this lives in the code
armv7m_start_algorithm()saves and restores the core register context and forcesxPSR.T(and optionallyCONTROLfor the requested core mode) — but it never touchesMPU_CTRL, and on this target the register cache does not exposeMSPLIM/PSPLIMat all, so they are neither saved, sanitized, nor restorable. The loader therefore runs under whatever protection the interrupted application had armed. Reset entry passes only because reset clearsMPU_CTRLand both stack limits as a side effect.Suggested fix / discussion
For ARMv7-M/ARMv8-M algorithm execution: save
MPU_CTRL, write 0, restore after the algorithm; on v8-M additionally save/clear/restoreMSPLIMandPSPLIM(via DCRSR — REGSEL0x1E/0x1Fare the live pair on this no-TrustZone part; the other banked aliases in0x18–0x1Dread as zero here). That is exactly the two-write recipe the bisect proves sufficient, and it is state the loader cannot legitimately want to inherit.Workaround for users meanwhile:
reset haltbefore an in-session flash, or issue the two clears manually afterhalt:Related
The same halt-of-running-app trigger class was independently characterized against pyOCD (pyocd/pyOCD#2023); notably its proximate cause there is host-side and different — applying this issue's target-side fix via memory writes does not rescue pyOCD (0/3), while it fixes OpenOCD 7/7. Two hosts, same trigger, two defects.
The deterministic repro stays armed on our bench — happy to test candidate patches, and to carry this to the mainline tracker/mailing list if that is the better venue (the mechanism looks generic armv7m, not fork-specific; only the stm32h5x flash driver of this fork was needed to reach it on an H503).