Skip to content

Fix interrupt storm lockup in BLHeli passthrough and get crash dumps for interrupt starvation - #34597

Merged
tridge merged 10 commits into
ArduPilot:masterfrom
tridge:pr-crashdump-fix
Oct 9, 2026
Merged

tridge merged 10 commits into
ArduPilot:masterfrom
tridge:pr-crashdump-fix

Conversation

@tridge

@tridge tridge commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes a lockup on Pixhawk6C (and many other F4/F7/H7 boards) during BLHeli/AM32 passthrough that produced a watchdog with no crash dump. Makes interrupt-starvation lockups produce a crash dump, and fixes the GPIO race that caused it.

Classification & Testing (check all that apply and add your own)

  • Checked by a human programmer
  • Non-functional change
  • No-binary change
  • Infrastructure change (e.g. unit tests, helper scripts)
  • Automated test(s) verify changes (e.g. unit test, autotest)
  • Tested manually, description below (e.g. SITL)
  • Tested on hardware
  • Logs attached
  • Logs available on request

Tested on a Pixhawk6C with an AM32 ESC on motor 1 and an ST-Link attached:

  • Before: the AM32 configurator CLI locked the board up about 1 in 4 runs, with a watchdog report of WDG: T-3 ... FL0 and no CrashDump.DAT.
  • Crash dump commits only (early version of this PR): both the real lockup and the new 42/24/71/104 storm trigger produce a full CrashDump.DAT. For the real lockup, Tools/debug/gdb_crashdump.sh shows RCOutput::serial_bit_irq in the EXTI5_9 ISR, with the main thread in AP_BLHeli::BL_GetACK.
  • AM32 CLI soak (100 runs):
  • 104 storm trigger: a decodable crash dump at each of 957b45d4, cae1c5fc and 5012443b. The backtrace runs from SysTick_Handler into the interrupted ISR and the main thread.
  • MAVLink bootloader update: completed with no false crash dump, at cae1c5fc, where the detector pause was in Util::flash_bootloader(). At the current head the pause has moved into ChibiOS::Flash::erasepage(). The update was ACKed and the board stayed up, but its status text wasn't captured.
  • Monitor-thread trap (detector disabled, temporary build): still produces a crash dump.

Software tests at the current head d053c010, all passing:

  • Builds (17 targets):
    • Pixhawk6C copter, signed copter and bootloader
    • CubeOrange, MatekF405 at its default and with FAILURE_INJECTION forced on, KakuteF7, SPRacingH7 (no crash dump), MatekH743 plane
    • AP_Periph on f103-GPS, f303-MatekGPS, MatekL431-Periph and MatekG474-Periph
    • iomcu and iomcu-f103
    • navio2 (Linux) and SITL
  • check_branch_conventions.py passes.
  • SITL crash triggers: 42/24/71/98 is ACCEPTED. 104 and an unknown code return UNSUPPORTED, and 99 still falls through to the DFU handling. A normal reboot is unaffected.
  • Autotests: Copter.ArmSwitchAfterReboot, Copter.ArmFeatures, Plane.WatchdogHome, Plane.ThrottleFailsafe.

Description

Root cause:

  • palSetLineMode() does an unlocked read-modify-write of the GPIO mode registers.
  • On Pixhawk6C, SPI1 restoring its SCK pin (PA5) raced with the BLHeli soft-serial read switching PA8 to input. That left PA8 in AF0, which is MCO1, outputting a 4MHz clock with the soft-serial EXTI enabled on the same pin.
  • The resulting EXTI storm (priority 6) starved the ChibiOS system timer (priority 7). No thread could wake, so the monitor thread never triggered a crash dump, the watchdog fired, and the WDG data was stale.
  • About 105 boards have an ESC output on an MCO pin (PA8/PC9) sharing a port with an active SPI SCK.

Crash dump commits:

  • GCS_MAVLink:
    • Move the 42/24/71 failure-creation commands into GCS_CrashTrigger.cpp.
    • Add 104 to create an EXTI interrupt storm (ChibiOS, not F1).
    • Failure creation is now only on by default for boards with more than 1MB of flash. This is a user-visible change: smaller boards need the FAILURE_INJECTION build option for these commands.
  • AP_HAL_ChibiOS:
    • Fix the LR recorded in the WDG fault data.
    • Use udf to trigger the lockup crash dump.
    • Add a SysTick lockup detector at NVIC priority 1, above all peripheral IRQs and the kernel lock. It triggers a crash dump 250ms before the nominal watchdog timeout. It is paused during flash sector erases (bootloader update, secure key update, flash storage), and STM32_WDG_TIMEOUT_MS must now be at least 1000.
    • The monitor-thread trap is compiled out when the detector is enabled.

Root cause commits:

  • AP_HAL_ChibiOS:
    • Fix nested locking in the soft-serial read cleanup.
    • Add stm32_set_line_mode() (locked) and stm32_toggle_line() (single BSRR write), and use them for all runtime pin changes in the main firmware.
  • AP_BoardConfig and AP_Periph are converted to the new helpers.

Known limitations:

  • On F4/F7 a fast LSI clock can make the watchdog fire before the detector (the same as the existing monitor trap).
  • The flash crash-dump backend walks the ChibiOS registry from the fault handler, so a dump can be lost there. The SD backend is the tested path.
  • IOMCU firmware pin changes are not converted.

MAVProxy: isrstorm command in ArduPilot/MAVProxy#1768.

Note that this bug can impact systems other than bl-heli pass-thru. The bl-heli code is the most impacted, but it could lead to:

  • lock of SCK in SPI transactions, so for example sensor corruption, FRAM corruption, sdcard corruption
  • loss of UART flow control

These could be triggered by GPIO operations on the same port as a SPI SCK pin by lua scripts for example

Test flown on a CubeOrange quadplane

@andyp1per andyp1per left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very nice! I would need to do some testing of the BLHeli part. My main comment is that this should be two PRs - one is the locking around the line setting and blheli fixes, the other is the enhancements to the crash detection machinery. The second part could have a much larger blast radius so I think needs more careful testing (I'm assuming you will want this on by default).

@tridge

tridge commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

My main comment is that this should be two PRs

for 4.7 I have split it:
#34598

For 4.8 I think separate commits in this PR makes sense

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Global Size Summary (Total Flash delta in bytes)

Built from 442e981 rebased onto 0ed15ec

Board Periph tracker blimp bootloader copter heli iofw plane rover sub
DrotekP3Pro _ _ _ 0 _ _ _ _ _ _
Durandal _ +284 +284 _ +284 +284 _ +284 +284 +284
Hitec-Airspeed +32 _ _ _ _ _ _ _ _ _
KakuteF7 _ -900 -900 _ -900 -900 _ -900 -900 -900
MambaH743v4 _ +268 +268 _ +268 +268 _ +268 +268 +268
MatekF405-CAN _ _ _ 0 _ _ _ _ _ _
MatekF405 _ -880 -880 _ -880 -880 _ -880 -880 -880
MatekH743-bdshot _ +288 +288 _ +288 +288 _ +288 +288 +288
Pixhawk1-1M _ -884 -884 _ -884 -884 _ -884 -884 -884
disco _ +62 +80 _ +84 +80 _ +80 +80 +82
f103-GPS -28 _ _ _ _ _ _ _ _ _

@AP-Review

AP-Review commented Oct 3, 2026 •

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-10-03)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Verdict: ACCEPT

Reviewed at head 5012443b3e.
Full report: https://firmware.ardupilot.org/Tools/APReview/DevCallReviews/PRReviews/ardupilot/ardupilot/34597/1.html#pr34597

All three review passes (primary, cold, validation) are status complete at the pinned head 5012443. None of them raised a finding, and there were no earlier findings to carry over. The fresh snapshot shows the same head, the same title and the same two thread entries. The head has not moved, and this review claims nothing about any later code.

I re-checked the work against the pinned tree. git diff e204ca77a8..5012443b3e exited 0 and matches job.diff byte for byte, apart from one trailing newline. I read the whole diff: 24 files, 11 commits. These claims were re-checked directly (VERIFIED).

The SysTick lockup detector (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-1100ce5f00d9546fe251ecf118739a7d7377cbaef323e1fed75b3491a111954eR136 and https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-1100ce5f00d9546fe251ecf118739a7d7377cbaef323e1fed75b3491a111954eR170) runs at priority 1. ChibiOS chcore.h sets CORTEX_FAST_PRIORITIES to 2 and puts the kernel BASEPRI at 3 or above, so priority 1 preempts chSysLock and any EXTI storm at kernel priority. Nothing in AP_HAL_ChibiOS or the ChibiOS port sets SCB->SHCSR USGFAULTENA, so the UDF from __builtin_trap() escalates to HardFault and the existing crash-dump path, as the commit says. The detector is gated in https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-6f738d7e0d8b705a8ae321cd568cb55120769120abbc646023c43f78e98274b5R19 on crash dump support, a free-running system timer, and not being a bootloader or IOMCU build. The flash-operation counter defers the trigger only up to the watchdog limit. Normal callers are serialized by Flash::sem (cold evidence). The new locking in stm32_set_line_mode() (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-b70babf7d0694ef34cd28ef37f7df80d70d126d7bf434b695182b39681a8131fR396) and palReadLineMode() is never reached from a fault handler. palReadLineMode() is only called from thread or constructor context. The one remaining raw palSetLineMode() outside IOMCU firmware is SPIBus::crashdump_restore_sck(), which deliberately stays lock-free. In the soft-serial storm abort (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-9518a3032ff11b70bdc3078d6be8d160b771c13473adb35beca6ca28e4625eacR2239 and https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-9518a3032ff11b70bdc3078d6be8d160b771c13473adb35beca6ca28e4625eacR2453), BYTE_BITS is 10. That gives a 20-edge window and a total cap of 20*(len+2), at least twice what valid 8N1 data can produce. An aborted read returns 0 bytes, and the AP_BLHeli callers already treat that as failure. Taking LR from the stacked frame in CrashCatcher (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-e4fa092a5ed9f4e10b815c34d1f73966c05959bb6c8744f84cf00d034281560dR67) fixes the old read of lr_thd from the post-advance SP. The new default in https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-4cd119b9294f231e0ed6840378759b4303f85b997288ec80e4be1baf24a4f312R80 limits failure creation to boards with more than 1024 KB of flash. HAL_PROGRAM_SIZE_LIMIT_KB is defined for every HAL board type, and SITL and Linux keep the feature. This is an intended behaviour change for 1MB boards and is visible in build_options.py. The EXTI storm trigger (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-8bec54820d1c5a09e26c07c67ba3e2133c494044eb3218b64ca667ce2b94a5efR59) builds on F4 as well as H7.

The retained logs from the earlier passes also back these points. Builds passed for Pixhawk6C, CubeOrange and CubeBlack copter, MatekF405 plane, three AP_Periph targets, iomcu iofirmware, a clean Pixhawk6C bootloader and SITL copter. The host harnesses for the serial abort and for CrashCatcher pass. check_branch_conventions passes: every commit touches exactly one allowed subsystem. No parameters are added and no allocations change.

On the thread: andyp1per asked for the BLHeli and pin-locking fixes to be split from the crash-detection work, and said the BLHeli part needs testing. tridge replied that 4.7 is split into PR #34598, and that for 4.8 separate commits in this PR make sense. The pinned stack does have separate per-subsystem commits. The contents of #34598 were not fetched and are UNCONFIRMED.

No run exercised anything on hardware, so these remain open test gaps. They do not block this review. The detector actually firing during EXTI starvation on H7 and on F4/F7 is untested; on F4/F7 a flash erase stall can also stop SysTick, which the commit message acknowledges as best effort. Real IWDG timing and crash-dump completion are untested. BLHeli passthrough to a real ESC is untested. Since the detector defaults on for every crash-dump board, the hardware testing andyp1per asked for is still warranted. The validation pass did not independently reproduce the primary's SITL crash-trigger run or its full board matrix; it rebuilt Pixhawk6C copter only. CI was still pending when the head was pinned.

Comment thread libraries/AP_HAL_ChibiOS/hwdef/common/watchdog.h Outdated

@peterbarker peterbarker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good for me past the minor

@AP-Review

AP-Review commented Oct 7, 2026 •

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-10-07)

Automated review note — AI-generated (Claude+Codex), validated against the live diff. Please sanity-check before acting.
Verdict: REQUEST CHANGES

Reviewed at head 442e981fb1.
Full report: https://firmware.ardupilot.org/Tools/APReview/DevCallReviews/PRReviews/ardupilot/ardupilot/34597/2.html#pr34597

Reviewed at 442e981. The head moved from 5012443, and the earlier ACCEPT does not carry over.

Blocking: the lockup detector is paused only around the bootloader erase in Util::flash_bootloader() (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-a4a3d06086bfc0e82120db26f5962d8ffc4f623e8e3090a2604e29b78c2225b1R303). AP_CheckFirmware::write_bootloader() (libraries/AP_CheckFirmware/AP_CheckFirmware_secure_command.cpp:169) erases the same page 0 for secure-command key updates on signed builds, with no pause. stm32_flash_erasepage() holds the kernel lock, so nothing can pat during the erase. If the erase runs past about 1.8s, the detector traps with the bootloader part-erased, where master would ride on to the 2.048s IWDG. VERIFIED in source and in the signed Pixhawk6C ELF (no pause call in write_bootloader); real erase timing is UNCONFIRMED on hardware. Pausing inside stm32_flash_erasepage() or Flash::erasepage() would cover every caller.

Smaller points:

Builds at this head passed for Pixhawk6C (including signed), CubeOrange, MatekF405, MatekH743, the Pixhawk6C bootloader and f103-GPS AP_Periph, and commit conventions pass. Hardware behaviour, SITL and CI (pending) were not covered.

tridge added 8 commits October 9, 2026 07:55
no functional change, this moves the 42/24/71 failure creation
handling out of handle_preflight_reboot() into its own file
saves flash on boards with 1MB or less of flash. It can still be
enabled with the FAILURE_INJECTION build option
42/24/71/104 creates a never ending EXTI software interrupt storm on
an unused EXTI channel on ChibiOS boards. This starves the ChibiOS
system timer and all threads, reproducing the lockup seen when a
soft-serial EXTI line is fed a fast clock, and is used to test that
such lockups produce a crash dump
CrashCatcher advances info.sp past the exception frame, so reading
lr_thd from it gave a stack value, not the stacked LR
CrashCatcher reads the instruction at the faulting PC, so fault on a
readable undefined instruction rather than jumping to an invalid
address
the monitor thread can't trigger a crash dump if an interrupt storm
starves the ChibiOS timer, as no thread will ever wake. This was seen
with a soft-serial EXTI line receiving a 4MHz MCO clock during BLHeli
passthrough, giving a watchdog with no crash dump.

Use SysTick at priority 1 (above all peripheral interrupts and the
kernel lock) to watch for watchdog pats, triggering a crash dump
250ms before the nominal watchdog timeout. Only enabled on boards
with crash dump support and a TIM based system timer. The monitor
thread crash dump trigger is not needed when this is enabled.

The detector is paused while erasing a flash sector (bootloader update,
secure key update, flash storage), as an erase can take close to the
watchdog timeout. STM32_WDG_TIMEOUT_MS must be at least 1000ms.

The watchdog uses the LSI clock, which on F4/F7 can be fast enough
for the watchdog to fire before the detector, so the crash dump is
best effort there
chEvtGetAndClearEvents() and chVTReset() take the kernel lock
themselves, so calling them inside chSysLock() unlocked the critical
section early. Use the I-class variants
palSetLineMode() does an unlocked read-modify-write of the GPIO mode
registers, and palToggleLine() writes the whole output register. When
two threads change pins on the same port these can corrupt each other.

This was seen with BLHeli passthrough on Pixhawk6C: SPI1 restoring its
SCK pin (PA5) raced with soft-serial switching PA8 to input, leaving
PA8 in alternate function 0 (MCO1, a 4MHz clock) with its EXTI
interrupt enabled. The resulting interrupt storm locked up the board.

Add stm32_set_line_mode(), which locks the mode change, and
stm32_toggle_line(), which toggles with a single BSRR write. Use them
for all runtime pin changes in the main firmware (IOMCU firmware is
not changed). Also lock palReadLineMode() and palLineSetPushPull()
tridge added 2 commits October 9, 2026 07:55
board_init_debug() runs after other threads have started, so use
stm32_set_line_mode() to avoid racing other pin mode changes on the
same port
palToggleLine() writes the whole port output register, which can undo
concurrent writes to other pins on the same port
@AP-Review

Copy link
Copy Markdown

Automated review note — AI-generated (Claude+Codex), validated against the live diff. Please sanity-check before acting.
Verdict: ACCEPT

Reviewed at head d053c01043.
Full report: https://firmware.ardupilot.org/Tools/APReview/DevCallReviews/PRReviews/ardupilot/ardupilot/34597/3.html#pr34597

Reviewed at d053c01. The head moved from 442e981, so this covers the new head only.

The earlier blocker is fixed. The lockup-detector pause now sits inside ChibiOS::Flash::erasepage() (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-09076c35be9b3e238ffef7bad7a430eb3459c8053876f0bf2a28e448c0fced6bR16), so the secure-command bootloader erase, the bootloader update and flash storage erases are all covered. VERIFIED by grep and in the signed Pixhawk6C ELF: 2 pause calls around the single erase. The STM32_WDG_TIMEOUT_MS >= 1000 check is now an unconditional #error outside the detector gate (https://github.com/ArduPilot/ardupilot/pull/34597/files#diff-6f738d7e0d8b705a8ae321cd568cb55120769120abbc646023c43f78e98274b5R11). The preprocessor confirms 999 fails and 1000/2048 pass. The PR description has been updated and names the tested heads. The other commits are unchanged from the last review and still hold.

Builds at this head passed for signed Pixhawk6C, MatekF405, CubeOrange, f103-GPS AP_Periph, iomcu and the Pixhawk6C bootloader, and commit conventions pass. Not covered: hardware behaviour (erase timing, EXTI starvation, BLHeli passthrough), SITL, individual CI jobs and #34598.

@tridge

tridge commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

I would need to do some testing of the BLHeli part.

I've done a lot of BLHeli tests myself, plus I've removed the rate limiter change on the serial code, so I don't think I need to wait for your re-review and testing now

@tridge
tridge dismissed andyp1per’s stale review October 9, 2026 00:36

removed the large blheli path changes, now just adds the interrupt disable about pin changes which is the key thing and is definitely required

@tridge
tridge merged commit 337719d into ArduPilot:master Oct 9, 2026
110 of 111 checks passed
@rmackay9

rmackay9 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

@tridge,

Do we want this included in 4.7.3-beta1?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

6 participants