From 3c04a7903120f16b2ee7f5b64be02c2fd87756d3 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Wed, 2 Sep 2026 10:47:23 +0000 Subject: [PATCH] fix(stm32wl): improve reboot-to-DFU reliability (#11698) - In enter_dfu, arm enterDfuAtMsec = millis() + 5s and return instead of resetting inline; the want_response ACK then goes out the normal path and Power::powerCommandsCheck() calls enterDfuMode() at the deadline. Nudge the deadline off 0 in the rare case the addition wraps to it, since powerCommandsCheck() reads 0 as unarmed. The delay is the client's detach window - and the margin a WebSerial web flasher needs (meshtastic/web-flasher#426). - In enterDfuMode(), stop the GPS and drain/end every configured UART before the reset. The ROM bootloader autobauds off the first byte on USART1 (PB6/PB7) or USART2 (PA2/PA3), and on every WL variant a console UART or the GPS stream sits on those pins. Factor the drain into quiesceSerial() and reuse it in cpuDeepSleep(). - Move earlyBootCheck from constructor(101) to .preinit_array, ahead of the core's premain()/SystemClock_Config() whatever the link order, and reset RCC before jumping to system memory. The handler used to reset the MCU inline, before the ACK was sent and while the client still held the console UART. The STM32WL ROM bootloader autobauds off the first byte received; a stray byte during the handoff (a trailing protobuf frame, a port-close DTR/RTS glitch) desynced it and left the device unreachable at any baud until a hard reset. STM32WL only: every hunk is behind #if defined(ARCH_STM32) or lives in main-stm32wl.cpp. nrf52, rp2040 and the rest are unchanged. Known limitation: gps->disable() only issues a UBX sleep command, so a non-u-blox or otherwise free-running GPS with no hardware enable/standby pin keeps transmitting on its UART past this point. If that UART is USART1 (PB6/PB7) or USART2 (PA2/PA3), the ROM bootloader can still autobaud onto the GPS stream instead of the host. New STM32WL hardware designs should keep GPS UARTs off those two bootloader-autobaud pins, or provide a way to power down or hold the GPS in reset before DFU. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --- src/Power.cpp | 8 +++ src/main.cpp | 3 + src/main.h | 3 + src/mesh/Throttle.h | 4 +- src/modules/AdminModule.cpp | 15 ++++- src/platform/stm32wl/main-stm32wl.cpp | 85 +++++++++++++++++++-------- 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 84bf12aaf5..09b20f6ff6 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -976,6 +976,14 @@ void Power::powerCommandsCheck() shutdownAtMsec = 0; shutdown(); } + +#ifdef ARCH_STM32 + // Deferred DFU entry; the delay is armed by AdminModule's enter_dfu handler (rationale there). + if (enterDfuAtMsec && Throttle::deadlinePassed(enterDfuAtMsec)) { + enterDfuAtMsec = 0; + enterDfuMode(); // never returns + } +#endif } void Power::reboot() diff --git a/src/main.cpp b/src/main.cpp index b5db79b6bb..7fde17346e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1283,6 +1283,9 @@ void setup() uint32_t rebootAtMsec; // If not zero we will reboot at this time (used to reboot shortly after the update completes) uint32_t shutdownAtMsec; // If not zero we will shutdown at this time (used to shutdown from python or mobile client) bool suppressRebootBanner; // If true, suppress "Rebooting..." overlay (used for OTA handoff) +#ifdef ARCH_STM32 +uint32_t enterDfuAtMsec; // If not zero, enter DFU mode at this millis() deadline (see main.h) +#endif #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) volatile bool lockdownReloadPending; // see main.h - deferred NodeDB reload after lockdown unlock diff --git a/src/main.h b/src/main.h index 19b1bace0d..49a63365bc 100644 --- a/src/main.h +++ b/src/main.h @@ -92,6 +92,9 @@ extern uint32_t timeLastPowered; extern uint32_t rebootAtMsec; extern uint32_t shutdownAtMsec; extern bool suppressRebootBanner; +#ifdef ARCH_STM32 +extern uint32_t enterDfuAtMsec; // 0 = unset; else millis() deadline for the deferred DFU jump +#endif #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) // Set by PhoneAPI::handleLockdownAuthInline after a successful unlock. diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index f9d68a4143..7df932dfab 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -34,8 +34,8 @@ class Throttle /// "passed" - the split that has to survive, because which way "inactive" falls is the caller's /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four /// meanings they give the sentinel today: - /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec (the cheapest pair to convert), and - /// GPS.cpp fixHoldEnds, whose arm site remaps a 0 result to 1 by hand. + /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec, GPS.cpp fixHoldEnds, AdminModule.cpp + /// enterDfuAtMsec - the last two remap a 0 result to 1 at the arm site by hand. /// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0` /// guard, so this third state wants naming rather than repeating. /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 4995f3c822..abc71034e6 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -68,6 +68,11 @@ AdminModule *adminModule; +#ifdef ARCH_STM32 +// Client detach window before the DFU jump (see the enter_dfu case). +static constexpr uint32_t STM32_DFU_DETACH_DELAY_MS = 5000; +#endif + #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) static bool licensedIdentityWillMigrate() { @@ -619,7 +624,15 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #if HAS_SCREEN IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0)); #endif -#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32) +#if defined(ARCH_STM32) + // Delay the jump so this ACK reaches the client and it releases the port before the + // STM32WL ROM bootloader takes the UART and autobauds off the next byte it sees. + LOG_INFO("Entering DFU in %us - disconnect now", (STM32_DFU_DETACH_DELAY_MS + 999) / 1000); + enterDfuAtMsec = millis() + STM32_DFU_DETACH_DELAY_MS; + // Guard against enterDfuAtMsec rolling over to 0, the sentinel powerCommandsCheck() reads as unarmed. + if (enterDfuAtMsec == 0) + enterDfuAtMsec = 1; +#elif defined(ARCH_NRF52) || defined(ARCH_RP2040) enterDfuMode(); #endif break; diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 4d54f22895..1f363edfa9 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,6 +1,7 @@ #include "FSCommon.h" #include "configuration.h" #include "error.h" +#include "gps/GPS.h" #include "gps/RTC.h" #include #include @@ -20,23 +21,27 @@ static bool stm32wlRtcValid = false; #endif // ─── Bootloader redirect ────────────────────────────────────────────────────── -// Uses .noinit SRAM instead of TAMP backup registers: STM32duino's clock init can wipe the -// backup domain via __HAL_RCC_BACKUPRESET_FORCE/RELEASE before setup() runs, but .noinit -// survives NVIC_SystemReset() and this constructor fires before HAL_Init() touches anything. +// Magic word in .noinit, not TAMP backup regs (STM32duino clock init can wipe those): it +// survives NVIC_SystemReset(), and the .preinit_array hook below runs before HAL_Init(). +// STM32WLxx system-memory bootloader base, AN2606 "STM32WLxx bootloader": +// https://www.st.com/resource/en/application_note/an2606-stm32-microcontroller-system-memory-boot-mode-stmicroelectronics.pdf #define BOOTLOADER_MAGIC 0xD00DB007UL #define SYS_MEM_BASE 0x1FFF0000UL -// Placed in .noinit - not zeroed at startup, survives NVIC_SystemReset(). +// .noinit - not zeroed at startup, survives NVIC_SystemReset(). __attribute__((section(".noinit"), used)) volatile uint32_t g_bootloaderMagic; -// Fires before main() / HAL_Init(). Must use only core Cortex-M registers. -__attribute__((constructor(101), used)) static void earlyBootCheck(void) +// Runs from .preinit_array (below), before every constructor incl. the core's premain()/init(), +// so RCC/SysTick/HAL are still at reset. Core Cortex-M / CMSIS registers only. +__attribute__((used)) static void earlyBootCheck(void) { if (g_bootloaderMagic != BOOTLOADER_MAGIC) return; g_bootloaderMagic = 0; + // Return SysTick/NVIC/RCC to reset state before the jump - ST's system bootloader expects it. + // https://community.st.com/t5/stm32-mcus/how-to-jump-to-system-bootloader-from-application-code-on-stm32/ta-p/49424 SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; @@ -44,20 +49,56 @@ __attribute__((constructor(101), used)) static void earlyBootCheck(void) NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } + + // Same writes CMSIS system_stm32wlxx.c SystemInit() makes, repeated here in case a hook + // between reset and .preinit_array moved SYSCLK onto the PLL (RM0461 reset values). + RCC->CR |= 0x00000061U; // MSION, MSI range 4 MHz + RCC->CFGR = 0x00070000U; // SYSCLK=MSI, AHB/APB prescalers /1, MCO off + RCC->CR = 0x00000061U; // HSEON/HSEBYP/CSSON/PLLON off + RCC->PLLCFGR = 0x22040100U; + RCC->CIER = 0x00000000U; + RCC->CICR = 0x0000033FU; // clear all RCC interrupt flags + __DSB(); __ISB(); SCB->VTOR = SYS_MEM_BASE; __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); - // Should never be reached: the bootloader ROM does not return. A bare reset - // (rather than returning normally) avoids unwinding through this function's - // epilogue, which would restore registers relative to the now-repointed MSP. + // Not reached: the bootloader ROM does not return. Reset rather than return, to avoid + // unwinding this function's epilogue against the now-repointed MSP. NVIC_SystemReset(); } +// __libc_init_array runs .preinit_array entries before any .init_array constructor, so this +// precedes the core's premain() (constructor(101)) regardless of link order. +__attribute__((section(".preinit_array"), used)) static void (*const earlyBootCheckEntry)(void) = &earlyBootCheck; + +// Drain and release a UART before the reset, as cpuDeepSleep() does. +static void quiesceSerial(HardwareSerial &port) +{ + if (port) { + port.flush(); + port.end(); + } +} + void enterDfuMode() { g_bootloaderMagic = BOOTLOADER_MAGIC; + + // The ROM bootloader autobauds off the first byte on USART1 (PB6/PB7) or USART2 (PA2/PA3), + // and every WL UART and GPS sits on those pins. Silence them all before the reset. +#if !MESHTASTIC_EXCLUDE_GPS + if (gps) + gps->disable(); +#endif + quiesceSerial(Serial); +#ifdef ENABLE_HWSERIAL1 + quiesceSerial(Serial1); +#endif +#ifdef ENABLE_HWSERIAL2 + quiesceSerial(Serial2); +#endif HAL_NVIC_SystemReset(); } @@ -140,25 +181,19 @@ void cpuDeepSleep(uint32_t msecToWake) // Hardware can't shutdown, but firmware has already prepared itself for shutdown // Do not leave the device unresponsive, reset instead LOG_WARN("STM32WL: hardware RTC failed, can't deep sleep/shutdown"); - if (Serial) { - Serial.flush(); - Serial.end(); + quiesceSerial(Serial); + HAL_NVIC_SystemReset(); + } else { + quiesceSerial(Serial); + + if (msecToWake != portMAX_DELAY) { + LowPower.shutdown(msecToWake); + } else { + LowPower.shutdown(); } + // RTC wakes from shutdown into MCU reset, so this code should never be reached HAL_NVIC_SystemReset(); } - - if (Serial) { - Serial.flush(); - Serial.end(); - } - - if (msecToWake != portMAX_DELAY) { - LowPower.shutdown(msecToWake); - } else { - LowPower.shutdown(); - } - // RTC wakes from shutdown into MCU reset, so this code should never be reached - HAL_NVIC_SystemReset(); #endif }