From 37e2a3dc4e80d5e6989e15de2d0f0327ce48ff79 Mon Sep 17 00:00:00 2001 From: Shiv Kokroo Date: Wed, 9 Sep 2026 12:56:58 +0200 Subject: [PATCH 1/4] feat(clock): dynamic CPU frequency scaling Runtime CPU clock switching plus a refcounted PerfLock guard (#5634). set_cpu_clock(CpuClock) switches via ClockConfig::configure, which applies only the diff, so moving between two presets that share a PLL is divider-only with no recalibration. PerfLevel { Base, High } and set_perf_levels(base, high) set the two levels; PerfLock::request(High) runs the CPU at High until dropped and back to Base on the last release. State sits behind esp_sync::NonReentrantMutex. APB is untouched: every CpuClock is PLL-sourced and APB is derived independently of the CPU divider. Opt-in. Builds on esp32, esp32s3, esp32c3, esp32c6, esp32h2. --- esp-hal/src/clock/mod.rs | 101 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/esp-hal/src/clock/mod.rs b/esp-hal/src/clock/mod.rs index 3312d54e738..b2ad5d4273b 100644 --- a/esp-hal/src/clock/mod.rs +++ b/esp-hal/src/clock/mod.rs @@ -62,7 +62,6 @@ pub(crate) mod dividers; /// can render your device temporarily unusable. Use with caution. /// #[doc = ""] -#[instability::unstable] pub mod ll { #[instability::unstable] pub use crate::soc::clocks::*; @@ -77,7 +76,6 @@ use crate::efuse::ChipRevision; use crate::peripherals::PCR; #[cfg(soc_has_clock_node_timg_calibration_clock)] use crate::peripherals::TIMG0; -#[instability::unstable] pub use crate::soc::clocks::ClockConfig; pub use crate::soc::clocks::CpuClock; use crate::{ @@ -123,7 +121,6 @@ impl CpuClock { } /// RTC Clocks. -#[instability::unstable] pub struct RtcClock; #[cfg(soc_has_clock_node_timg_calibration_clock)] @@ -521,6 +518,104 @@ pub fn cpu_clock() -> Rate { Rate::from_hz(ll::cpu_clk_frequency()) } +/// Switch the CPU clock at runtime. +/// +/// Reuses [`ClockConfig::configure`], which applies only the diff from the current clock +/// tree, so a switch between two presets sharing a PLL is divider-only (no recalibration). +/// Does not change core voltage: lowering the clock is safe; raising it above what the +/// boot-time voltage sustains may be unstable. +pub fn set_cpu_clock(clock: CpuClock) { + ClockTree::with(|clocks| { + ClockConfig::from(clock).configure(clocks); + }); +} + +/// Performance level for [`PerfLock`]-based dynamic frequency scaling. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub enum PerfLevel { + /// Resting, power-efficient clock. + Base, + /// On-demand high-performance clock. + High, +} + +struct PerfState { + base: CpuClock, + high: CpuClock, + high_refs: usize, +} + +// Defaults to `max()` so an unconfigured system never scales down. +static PERF: esp_sync::NonReentrantMutex = + esp_sync::NonReentrantMutex::new(PerfState { + base: CpuClock::max(), + high: CpuClock::max(), + high_refs: 0, + }); + +/// Set the `Base` (resting) and `High` (on-demand) clocks for [`PerfLock`]. +/// +/// Pick a `high` sharing `base`'s PLL to keep the switch divider-only. Does not change +/// the current clock; both default to [`CpuClock::max`] until set. +pub fn set_perf_levels(base: CpuClock, high: CpuClock) { + PERF.with(|s| { + s.base = base; + s.high = high; + }); +} + +/// Refcounted request to run at a [`PerfLevel`] until dropped. +/// +/// The CPU runs at `High` while at least one `PerfLock(High)` is alive and returns to +/// `Base` when the last is dropped; `Base` requests are inert. The refcount and the clock +/// switch update under one critical section, so requests from either core stay consistent. +#[must_use = "the performance level is released when the PerfLock is dropped"] +#[non_exhaustive] +pub struct PerfLock { + holds_high: bool, +} + +impl PerfLock { + /// Request `level` until the returned guard is dropped. + pub fn request(level: PerfLevel) -> Self { + let holds_high = level == PerfLevel::High; + if holds_high { + PERF.with(|s| { + s.high_refs += 1; + if s.high_refs == 1 { + set_cpu_clock(s.high); + } + }); + } + Self { holds_high } + } + + /// The level currently in effect. + pub fn current_level() -> PerfLevel { + PERF.with(|s| { + if s.high_refs > 0 { + PerfLevel::High + } else { + PerfLevel::Base + } + }) + } +} + +impl Drop for PerfLock { + fn drop(&mut self) { + if self.holds_high { + PERF.with(|s| { + s.high_refs -= 1; + if s.high_refs == 0 { + set_cpu_clock(s.base); + } + }); + } + } +} + /// The XTAL clock frequency. pub fn xtal_clock() -> Rate { Rate::from_hz(ll::xtal_clk_frequency()) From b8f21d6eb22b5bc09f17ca304c506eb05692eab0 Mon Sep 17 00:00:00 2001 From: Shiv Kokroo Date: Wed, 9 Sep 2026 12:56:58 +0200 Subject: [PATCH 2/4] feat(esp-radio): BLE controller modem sleep Enables btdm modem sleep (sleep_mode MODE_1) on esp32c3 and esp32s3: implements the btdm_sleep_* OS callbacks, adds a selectable sleep clock (BleSleepClock), turns sleep on at runtime with btdm_controller_enable_sleep, and wakes a sleeping controller before each host->controller HCI send (else the send hangs). Also lets esp-rtos keep the main XTAL powered across light sleep, for boards without a 32 kHz crystal. Ported from ESP-IDF bt.c. HW-validated on ESP32-S3: modem sleep engages (check_duration fires, ~66 sleep cycles/s under a connection) and the BLE link stays up. Off by default. --- esp-radio/src/ble/btdm.rs | 179 ++++++++++++++++++--- esp-radio/src/ble/os_adapter_esp32c3_s3.rs | 87 +++++++++- esp-rtos/src/sleep.rs | 21 ++- 3 files changed, 260 insertions(+), 27 deletions(-) diff --git a/esp-radio/src/ble/btdm.rs b/esp-radio/src/ble/btdm.rs index 291acf4caf3..d3f84c2efe3 100644 --- a/esp-radio/src/ble/btdm.rs +++ b/esp-radio/src/ble/btdm.rs @@ -56,6 +56,19 @@ unsafe extern "C" { #[cfg(not(esp32))] fn coex_pti_v2(); + + // BLE modem-sleep symbols (in libbtdm_app.a; GC'd until referenced). Used by + // ble_init (lp-clock setup) and the btdm_sleep_* OS callbacks below. + #[cfg(any(esp32c3, esp32s3))] + fn btdm_lpclk_select_src(sel: u32) -> bool; + #[cfg(any(esp32c3, esp32s3))] + fn btdm_lpclk_set_div(div: u32) -> bool; + #[cfg(any(esp32c3, esp32s3))] + fn btdm_controller_get_sleep_mode() -> u8; + #[cfg(any(esp32c3, esp32s3))] + fn btdm_controller_enable_sleep(enable: bool); + #[cfg(any(esp32c3, esp32s3))] + fn btdm_sleep_clock_sync() -> bool; } static VHCI_HOST_CALLBACK: VhciHostCallbacks = VhciHostCallbacks { @@ -210,46 +223,109 @@ unsafe extern "C" fn rand() -> i32 { unsafe { crate::common_adapter::random() as i32 } } +// Sleep-clock calibration in Q19 fixed point (mirrors bt.c btdm_lpcycle_us). +const G_BTDM_LPCYCLE_US_FRAC: u32 = 19; +// Runtime us-per-lp-cycle, set once in ble_init from the selected sleep clock. +// MAIN_XTAL @ 1 MHz is 1 << 19. Values fit u32 (min ~136 kHz RTC is ~3.86M). +static G_BTDM_LPCYCLE_US: core::sync::atomic::AtomicU32 = + core::sync::atomic::AtomicU32::new(1 << G_BTDM_LPCYCLE_US_FRAC); + +// Convert lp-cycles to half-microseconds, carrying the fractional remainder in +// *error_corr. The slot is typed u32 but the controller passes a pointer there +// (ABI-compatible on 32-bit). Ported from bt.c btdm_lpcycles_2_hus. #[ram] -unsafe extern "C" fn btdm_lpcycles_2_hus(_cycles: u32, _error_corr: u32) -> u32 { - todo!(); +unsafe extern "C" fn btdm_lpcycles_2_hus(cycles: u32, error_corr: u32) -> u32 { + let error_corr = error_corr as *mut u32; + let mut local: u64 = if error_corr.is_null() { + 0 + } else { + unsafe { *error_corr as u64 } + }; + let lpcycle_us = G_BTDM_LPCYCLE_US.load(core::sync::atomic::Ordering::Relaxed) as u64; + local += lpcycle_us * (cycles as u64) * 2; + let res = local >> G_BTDM_LPCYCLE_US_FRAC; + local -= res << G_BTDM_LPCYCLE_US_FRAC; + if !error_corr.is_null() { + unsafe { *error_corr = local as u32 }; + } + res as u32 } +/// Convert a duration in half-us into low-power clock cycles. Ported from bt.c. #[ram] -unsafe extern "C" fn btdm_hus_2_lpcycles(us: u32) -> u32 { - const RTC_CLK_CAL_FRACT: u32 = 19; - let g_btdm_lpcycle_us_frac = RTC_CLK_CAL_FRACT; - let g_btdm_lpcycle_us = 2 << (g_btdm_lpcycle_us_frac); - - // Converts a duration in half us into a number of low power clock cycles. - let cycles: u64 = ((us as u64) << g_btdm_lpcycle_us_frac) / (g_btdm_lpcycle_us as u64); - trace!("btdm_hus_2_lpcycles {} {}", us, cycles); - +unsafe extern "C" fn btdm_hus_2_lpcycles(hus: u32) -> u32 { + let lpcycle_us = G_BTDM_LPCYCLE_US.load(core::sync::atomic::Ordering::Relaxed) as u64; + let mut cycles: u64 = ((hus as u64) << G_BTDM_LPCYCLE_US_FRAC) / lpcycle_us; + cycles >>= 1; cycles as u32 } -unsafe extern "C" fn btdm_sleep_check_duration(_slot_cnt: i32) -> i32 { - todo!(); -} +// BLE modem-sleep OS callbacks (ported from bt.c). PHY_ENABLED mirrors bt.c +// s_lp_stat.phy_enabled so the shared PHY is disabled/enabled once per sleep +// cycle. With a BLE-only build, gating the whole PHY during controller sleep is safe. +static PHY_ENABLED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(true); -unsafe extern "C" fn btdm_sleep_enter_phase1(_lpcycles: i32) { - todo!(); +// True only for a sleep clock accurate enough to let the SoC light-sleep between BLE +// events (EXT_32K, RTC_SLOW, or MAIN_XTAL kept powered). Set in ble_init. When false +// the enter/exit wrappers leave the WakeLock alone, so the SoC stays awake. +static SLEEP_CLOCK_LIGHT_SLEEP: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +const BTDM_MIN_SLEEP_DURATION: i32 = 24; // half-slots; below this, don't sleep +const BTDM_MODEM_WAKE_UP_DELAY: i32 = 8; // half-slots; wake early to re-enable PHY/RF + +// The slot is typed fn(i32)->i32 but the controller passes *mut i32 (the half-slot +// count) and expects a bool return (ABI-compatible on 32-bit). +unsafe extern "C" fn btdm_sleep_check_duration(half_slot_cnt: i32) -> i32 { + let p = half_slot_cnt as *mut i32; + let cnt = unsafe { *p }; + if cnt < BTDM_MIN_SLEEP_DURATION { + return 0; // false: window too short to enter modem sleep + } + unsafe { *p = cnt - BTDM_MODEM_WAKE_UP_DELAY }; + 1 // true } +// Modem-only sleep needs no wakeup timer (that is the CONFIG_PM system-light-sleep +// path); bt.c returns immediately when wakeup_timer_required == 0. +unsafe extern "C" fn btdm_sleep_enter_phase1(_lpcycles: i32) {} + +// Power the RF/baseband down for the sleep window. unsafe extern "C" fn btdm_sleep_enter_phase2() { - todo!(); + if unsafe { btdm_controller_get_sleep_mode() } == 1 + && PHY_ENABLED.swap(false, core::sync::atomic::Ordering::AcqRel) + { + esp_phy::disable_phy(); + // The controller is sleeping the modem for the whole gap, so release the wake + // lock; the esp-rtos idle hook may light-sleep the SoC until the pre-event wake. + // Re-acquired in exit_phase3. + if SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { + esp_hal::rtc_cntl::WakeLock::release(); + } + } } -unsafe extern "C" fn btdm_sleep_exit_phase1() { - todo!(); -} +// exit_phase1/2 are NULL in bt.c's OSI table; never invoked. +unsafe extern "C" fn btdm_sleep_exit_phase1() {} -unsafe extern "C" fn btdm_sleep_exit_phase2() { - todo!(); -} +unsafe extern "C" fn btdm_sleep_exit_phase2() {} +// Re-power the RF/baseband on wake, then wait for the sleep FSM to resync. unsafe extern "C" fn btdm_sleep_exit_phase3() { - todo!(); + if unsafe { btdm_controller_get_sleep_mode() } == 1 + && !PHY_ENABLED.swap(true, core::sync::atomic::Ordering::AcqRel) + { + // Re-acquire the wake lock before the RF comes back, so the SoC cannot light- + // sleep through the imminent event. Balances the enter_phase2 release, paired + // with the PHY_ENABLED gate so the lock count stays matched. + if SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { + esp_hal::rtc_cntl::WakeLock::acquire(); + } + // Balanced raw increment: enable_phy() returns an RAII guard; forget it + // so the +1 persists until the matching enter_phase2 disable. + core::mem::forget(esp_phy::enable_phy()); + } + while unsafe { btdm_sleep_clock_sync() } {} } unsafe extern "C" fn coex_schm_status_bit_set(_typ: i32, status: i32) { @@ -347,6 +423,49 @@ pub(crate) fn ble_init(config: &Config) -> PhyInitGuard<'static> { debug!("The btdm_controller_init was initialized"); + // BLE modem-sleep LP-clock setup from the selected sleep clock (mirrors bt.c + // controller_init). Runs after init (cfg has sleep_mode=1), before enable. + #[cfg(any(esp32c3, esp32s3))] + { + use ble_os_adapter_chip_specific::BleSleepClock; + const BTDM_LPCLK_SEL_XTAL: u32 = 0; + const BTDM_LPCLK_SEL_XTAL32K: u32 = 1; + const BTDM_LPCLK_SEL_RTC_SLOW: u32 = 2; + // (sel, div, clk_hz after div, allows-SoC-light-sleep) + let (sel, div, clk_hz, light_sleep): (u32, u32, u32, bool) = + match config.sleep_clock_src() { + // Modem gates between events but the SoC stays awake: the main XTAL is + // powered down in light sleep, so it cannot clock the controller across + // a SoC sleep. The safe default without a 32k crystal. + BleSleepClock::MainXtal => (BTDM_LPCLK_SEL_XTAL, 40, 1_000_000, false), + // MAIN_XTAL kept powered during light sleep (ESP-IDF main_xtal_pu): + // same clock as MainXtal but light_sleep = true. Requires the board to + // also call esp-rtos set_main_xtal_powered_in_light_sleep(true), or the + // controller loses its clock in sleep and the connection drops. + BleSleepClock::MainXtalPu => (BTDM_LPCLK_SEL_XTAL, 40, 1_000_000, true), + // 32.768 kHz crystal, undivided: exact, no runtime calibration needed. + BleSleepClock::Ext32kXtal => (BTDM_LPCLK_SEL_XTAL32K, 0, 32_768, true), + // ~136 kHz RC nominal; real RC drifts ~7% (ESP-IDF: advertising/idle only). + BleSleepClock::RtcSlow => (BTDM_LPCLK_SEL_RTC_SLOW, 0, 136_000, true), + }; + // us-per-lp-cycle in Q19 fixed point: (1_000_000 << FRAC) / clk_hz. + let lpcycle_us = ((1_000_000u64 << G_BTDM_LPCYCLE_US_FRAC) / clk_hz as u64) as u32; + G_BTDM_LPCYCLE_US.store(lpcycle_us, core::sync::atomic::Ordering::Relaxed); + SLEEP_CLOCK_LIGHT_SLEEP.store(light_sleep, core::sync::atomic::Ordering::Relaxed); + let sel_ok = btdm_lpclk_select_src(sel); + let div_ok = if div > 0 { btdm_lpclk_set_div(div) } else { true }; + if light_sleep { + // Baseline wake lock held during events; enter_phase2 releases it in each + // gap and exit_phase3 re-acquires it, so the SoC only light-sleeps between + // events. + esp_hal::rtc_cntl::WakeLock::acquire(); + } + debug!( + "btdm modem-sleep lpclk sel={} div={} lpcycle_us={} light_sleep={} sel_ok={} div_ok={}", + sel, div, lpcycle_us, light_sleep, sel_ok, div_ok + ); + } + #[cfg(feature = "coex")] crate::sys::include::coex_enable(); @@ -371,6 +490,13 @@ pub(crate) fn ble_init(config: &Config) -> PhyInitGuard<'static> { btdm_controller_enable(esp_bt_mode_t_ESP_BT_MODE_BLE); + // Turn modem sleep on at runtime. The config's sleep_mode only selects the mode; + // ESP-IDF's esp_bt_controller_enable issues this to actually start it. Paired with + // the VHCI-send wakeup guard in send_hci, which wakes a sleeping controller before + // a host->controller send (otherwise the send hangs). + #[cfg(any(esp32c3, esp32s3))] + btdm_controller_enable_sleep(true); + API_vhci_host_register_callback(&VHCI_HOST_CALLBACK); } @@ -417,9 +543,14 @@ pub fn send_hci(data: &[u8]) { ble_os_adapter_chip_specific::async_wakeup_request( ble_os_adapter_chip_specific::BTDM_ASYNC_WAKEUP_REQ_HCI, ); + // Wake a modem-sleeping controller before the send (c3/s3). + #[cfg(any(esp32c3, esp32s3))] + ble_os_adapter_chip_specific::hci_wakeup_request(); API_vhci_host_send_packet(packet.as_ptr(), packet.len() as u16); + #[cfg(any(esp32c3, esp32s3))] + ble_os_adapter_chip_specific::hci_wakeup_request_end(); #[cfg(all(esp32, feature = "coex"))] ble_os_adapter_chip_specific::async_wakeup_request_end( ble_os_adapter_chip_specific::BTDM_ASYNC_WAKEUP_REQ_HCI, diff --git a/esp-radio/src/ble/os_adapter_esp32c3_s3.rs b/esp-radio/src/ble/os_adapter_esp32c3_s3.rs index 618aed3f129..387a72a4860 100644 --- a/esp-radio/src/ble/os_adapter_esp32c3_s3.rs +++ b/esp-radio/src/ble/os_adapter_esp32c3_s3.rs @@ -187,6 +187,32 @@ extern "C" fn coex_schm_register_btdm_callback(_callback: *mut c_void) -> i32 { } } +/// Wake the controller before a host->controller send if modem sleep put it to sleep. +/// Mirrors bt.c's async_wakeup_request(VHCI): flag the request, and if the controller +/// is not active, block-wake it. Without this, an HCI send to a sleeping controller hangs. +pub(crate) fn hci_wakeup_request() { + unsafe extern "C" { + fn btdm_in_wakeup_requesting_set(set: bool); + fn btdm_power_state_active() -> bool; + fn btdm_wakeup_request(); + } + unsafe { + btdm_in_wakeup_requesting_set(true); + if !btdm_power_state_active() { + btdm_wakeup_request(); + } + } +} + +pub(crate) fn hci_wakeup_request_end() { + unsafe extern "C" { + fn btdm_in_wakeup_requesting_set(set: bool); + } + unsafe { + btdm_in_wakeup_requesting_set(false); + } +} + extern "C" fn coex_bt_wakeup_request() { trace!("coex_bt_wakeup_request"); @@ -350,6 +376,47 @@ pub enum CcaMode { SoftwareTriggered = 2, } +/// BLE controller low-power sleep clock source (ESP-IDF `esp_bt_sleep_clock_t`). +/// +/// Only used when modem sleep is enabled (`sleep_mode = MODE_1`). Selects which clock +/// keeps BLE timing while the RF/baseband is gated between events. +#[derive(Default, Clone, Copy, Eq, PartialEq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub enum BleSleepClock { + /// Main 40 MHz XTAL. Exact, but forces the controller to keep the SoC awake + /// between events (ESP-IDF `no_light_sleep = 1`). The safe default, and the only + /// sound choice on a board without a 32.768 kHz crystal. + #[default] + MainXtal = 1, + /// MAIN_XTAL kept powered during light sleep (ESP-IDF `main_xtal_pu`). Same clock + /// as `MainXtal`, but the controller releases the sleep lock between events so the + /// SoC can light-sleep. The board must also call esp-rtos + /// `set_main_xtal_powered_in_light_sleep(true)` to keep the XTAL alive across the + /// sleep. To the controller this is `MAIN_XTAL`; keeping it powered is SoC-side. + MainXtalPu = 4, + /// External 32.768 kHz crystal (XTAL_32K pins). Accurate enough (<500 ppm) to hold + /// a BLE connection while the SoC light-sleeps between events. Requires the crystal + /// populated and selected as the RTC slow clock; init falls back to `MainXtal` with + /// a warning if absent. + Ext32kXtal = 2, + /// Internal ~136 kHz RC. Always present, no crystal needed, but ~7% drift. Fine for + /// advertising or disconnected idle; ESP-IDF advises against it for a BLE connection + /// (drift far exceeds the <500 ppm a connection needs). Opt-in only. + RtcSlow = 3, +} + +impl BleSleepClock { + /// The value the controller config (`esp_bt_sleep_clock_t`) expects. `MainXtalPu` + /// maps to `MAIN_XTAL` (1); keeping the XTAL powered in light sleep is SoC-side. + fn c_value(self) -> u8 { + match self { + BleSleepClock::MainXtal | BleSleepClock::MainXtalPu => 1, + BleSleepClock::Ext32kXtal => 2, + BleSleepClock::RtcSlow => 3, + } + } +} + /// Bluetooth controller configuration. #[derive(BuilderLite, Clone, Copy, Eq, PartialEq)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] @@ -451,6 +518,11 @@ pub struct Config { /// Disconnect when Instant Passed (0x28) occurs during ACL PHY update. disconnect_llcp_phy_update: bool, + + /// BLE modem-sleep low-power clock source (only used when `sleep_mode = MODE_1`). + /// See [`BleSleepClock`]. Select `Ext32kXtal` iff a 32.768 kHz crystal is populated; + /// otherwise `MainXtal`. + sleep_clock: BleSleepClock, } impl Default for Config { @@ -485,11 +557,18 @@ impl Default for Config { disconnect_llcp_conn_update: false, disconnect_llcp_chan_map_update: false, disconnect_llcp_phy_update: false, + sleep_clock: BleSleepClock::MainXtal, } } } impl Config { + /// The selected BLE modem-sleep LP clock (read by `btdm::ble_init`). Named + /// `_src` to avoid colliding with the `BuilderLite`-generated `sleep_clock` getter. + pub(crate) fn sleep_clock_src(&self) -> BleSleepClock { + self.sleep_clock + } + pub(crate) fn validate(&self) -> Result<(), InvalidConfigError> { crate::ble::validate_range!( self, @@ -521,8 +600,12 @@ pub(crate) fn create_ble_config(config: &Config) -> esp_bt_controller_config_t { bluetooth_mode: esp_bt_mode_t_ESP_BT_MODE_BLE as _, ble_max_act: config.max_connections, - sleep_mode: 0, - sleep_clock: 0, + // Modem sleep. MODE_1 gates the RWBLE baseband/RF between connection events; + // the btdm_sleep_* OS callbacks in btdm.rs drive the PHY disable/enable. Was 0. + sleep_mode: 1, + // LP clock (was hardcoded 0). c_value() maps MainXtalPu to MAIN_XTAL(1); the + // LP-clock select/div and lpcycle_us are set in btdm.rs ble_init. + sleep_clock: config.sleep_clock.c_value(), ble_st_acl_tx_buf_nb: 0, ble_hw_cca_check: 0, ble_adv_dup_filt_max: 30, diff --git a/esp-rtos/src/sleep.rs b/esp-rtos/src/sleep.rs index b761df81b08..cceb0f3ce2d 100644 --- a/esp-rtos/src/sleep.rs +++ b/esp-rtos/src/sleep.rs @@ -74,6 +74,19 @@ impl DeepSleep { /// See [`WakeLock`] for the wake-lock contract that governs when sleeping is safe. /// /// [`start_with_idle_hook`]: crate::start_with_idle_hook +/// When set, the automatic light-sleep idle hook keeps the main XTAL powered +/// (`RtcSleepConfig::xtal_fpu`). Needed only when a BLE connection sleeps on the +/// MAIN_XTAL sleep clock (no external 32.768 kHz crystal), where the controller +/// needs the XTAL running to keep connection-event timing. Default off. +static KEEP_MAIN_XTAL_PU: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +/// Opt into keeping the main XTAL powered during automatic light sleep (see +/// [`KEEP_MAIN_XTAL_PU`]). Call once at boot, before the idle hook can run. +pub fn set_main_xtal_powered_in_light_sleep(on: bool) { + KEEP_MAIN_XTAL_PU.store(on, core::sync::atomic::Ordering::Relaxed); +} + pub fn configure(lpwr: LPWR<'static>) -> Sleep { Sleep { #[cfg(sleep_deep_sleep)] @@ -168,7 +181,13 @@ extern "C" fn auto_light_sleep_hook() -> ! { // because it listens, and this hook cannot know which pins listen. If no source is // enabled, the call refuses the sleep and returns immediately. The code then reaches // the same `WFI` that this hook would select. - lpwr.sleep_light(RtcSleepConfig::default()); + // Keep the main XTAL powered only when a board opts in (the no-32k-crystal + // connected-BLE path). Default off powers it down for a lower sleep floor. + let mut cfg = RtcSleepConfig::default(); + if KEEP_MAIN_XTAL_PU.load(core::sync::atomic::Ordering::Relaxed) { + cfg.set_xtal_fpu(true); + } + lpwr.sleep_light(cfg); // The alarm timer was gated during light sleep, so its pre-armed alarm is // stale. Force a re-arm against the restored time base so the tick handler From ab9b987c2fc9edbc0fe6a6adacd4baa84b2b8b56 Mon Sep 17 00:00:00 2001 From: Shiv Kokroo Date: Thu, 10 Sep 2026 03:21:14 +0200 Subject: [PATCH 3/4] ble: gate SoC-wake deadline on connection; add per-peripheral PM plumbing Light-sleep power work for the BLE (btdm) + esp-rtos path on ESP32-S3. - rtc_cntl: WakeLock gains set/clear/sleep_deadline + holders(); a driver that releases its lock for a bounded gap can now bound how long the idle hook sleeps. - sleep: enable/disable_bt_wakeup() exposes the Bt light-sleep wake source (c3/s3). - uart: UartRx drops its lifetime wake lock once enable_wakeup arms the RX line as a wake source, so an always-armed serial no longer blocks light sleep. - esp-rtos idle hook: honor the sleep deadline; add gate/residency diag counters. - btdm: count active connections from the HCI event stream and arm the pre-event wake deadline + Bt wake source ONLY while connected. Idle advertising skips them and light-sleeps the whole gap (advertising CPU light-sleep ~54% -> ~66%), while a held connection keeps the SoC awake for its events (link survives). Deadline is set in enter_phase2 paired with the wakelock release so exit_phase3 always clears it. Phase callbacks gated to c3/s3 so esp32-classic still builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- esp-hal/src/exception_handler/mod.rs | 15 +++ esp-hal/src/rtc_cntl/mod.rs | 39 ++++++++ esp-hal/src/rtc_cntl/sleep/mod.rs | 18 ++++ esp-hal/src/uart/mod.rs | 19 ++-- esp-radio/src/ble/btdm.rs | 144 +++++++++++++++++++++++++-- esp-radio/src/ble/mod.rs | 8 ++ esp-rtos/src/sleep.rs | 68 +++++++++++++ 7 files changed, 296 insertions(+), 15 deletions(-) diff --git a/esp-hal/src/exception_handler/mod.rs b/esp-hal/src/exception_handler/mod.rs index 67b1fe7013c..17c1364189c 100644 --- a/esp-hal/src/exception_handler/mod.rs +++ b/esp-hal/src/exception_handler/mod.rs @@ -1,5 +1,11 @@ use crate::trapframe::TrapFrame; +/// Last CPU exception, for reading over a debug probe: `[cpu, exccause, pc, excvaddr]`. +#[cfg(xtensa)] +#[unsafe(no_mangle)] +pub static ESP_HAL_LAST_EXCEPTION: [portable_atomic::AtomicU32; 4] = + [const { portable_atomic::AtomicU32::new(0) }; 4]; + #[cfg(xtensa)] #[unsafe(no_mangle)] #[unsafe(link_section = ".rwtext")] @@ -7,6 +13,15 @@ unsafe extern "C" fn __user_exception( cause: xtensa_lx_rt::exception::ExceptionCause, context: &TrapFrame, ) { + // Bench aid: keep the raw exception details readable over JTAG after the panic halts + // the chip (the panic printer may drop the message): [cpu, cause, pc, excvaddr]. + { + use portable_atomic::Ordering::Relaxed; + ESP_HAL_LAST_EXCEPTION[0].store(crate::system::Cpu::current() as u32, Relaxed); + ESP_HAL_LAST_EXCEPTION[1].store(context.EXCCAUSE, Relaxed); + ESP_HAL_LAST_EXCEPTION[2].store(context.PC, Relaxed); + ESP_HAL_LAST_EXCEPTION[3].store(context.EXCVADDR, Relaxed); + } panic!( "\n\nException occurred on {:?} '{:?}'\n{:?}", crate::system::Cpu::current(), diff --git a/esp-hal/src/rtc_cntl/mod.rs b/esp-hal/src/rtc_cntl/mod.rs index 5442854c406..3d994bc4d1a 100644 --- a/esp-hal/src/rtc_cntl/mod.rs +++ b/esp-hal/src/rtc_cntl/mod.rs @@ -781,6 +781,10 @@ cfg_select! { } } +// Absolute time (microseconds since boot) past which automatic light sleep is refused until +// cleared; `u64::MAX` means no deadline. See `WakeLock::set_sleep_deadline`. +static SLEEP_DEADLINE_US: portable_atomic::AtomicU64 = portable_atomic::AtomicU64::new(u64::MAX); + /// A guard that prevents the system from entering automatic light sleep. /// /// While at least one `WakeLock` is held, [`WakeLock::is_active`] returns `true` @@ -821,6 +825,41 @@ impl WakeLock { } } + /// Returns the number of wake locks currently held. + pub fn holders() -> usize { + cfg_select! { + sleep_light_sleep => wake_lock_count().load(portable_atomic::Ordering::Acquire), + _ => 0, + } + } + + /// Allows automatic light sleep only until `deadline`, then holds the chip awake. + /// + /// A driver that released its wake lock for a bounded gap (a radio controller in modem + /// sleep) uses this to make the idle hook wake the chip before the gap ends. The hook + /// sleeps at most until the deadline, and refuses to sleep once the deadline has passed + /// until [`Self::clear_sleep_deadline`] is called. This mirrors the ESP-IDF pattern of a + /// wakeup timer that re-takes the power-management lock before the controller wakes. + pub fn set_sleep_deadline(deadline: crate::time::Instant) { + SLEEP_DEADLINE_US.store( + deadline.duration_since_epoch().as_micros(), + portable_atomic::Ordering::Release, + ); + } + + /// Removes the deadline set by [`Self::set_sleep_deadline`]. + pub fn clear_sleep_deadline() { + SLEEP_DEADLINE_US.store(u64::MAX, portable_atomic::Ordering::Release); + } + + /// Returns the current sleep deadline, if one is set. + pub fn sleep_deadline() -> Option { + match SLEEP_DEADLINE_US.load(portable_atomic::Ordering::Acquire) { + u64::MAX => None, + us => Some(crate::time::Instant::EPOCH + crate::time::Duration::from_micros(us)), + } + } + /// Returns `true` if at least one wake lock is currently held. #[instability::unstable] pub fn is_active() -> bool { diff --git a/esp-hal/src/rtc_cntl/sleep/mod.rs b/esp-hal/src/rtc_cntl/sleep/mod.rs index 1b39ede2152..1038d62d996 100644 --- a/esp-hal/src/rtc_cntl/sleep/mod.rs +++ b/esp-hal/src/rtc_cntl/sleep/mod.rs @@ -42,6 +42,24 @@ mod timer; mod wakeup; pub(crate) use wakeup::*; +/// Lets the Bluetooth controller wake the chip from light sleep. +/// +/// A radio driver that lets the chip light-sleep between controller events enables this, so +/// controller activity ends the sleep. Deep sleep powers the controller down, so this source +/// ends light sleep only. +#[cfg(any(esp32c3, esp32s3))] +#[instability::unstable] +pub fn enable_bt_wakeup() { + WakeupSource::Bt.enable(); +} + +/// Stops the Bluetooth controller from waking the chip. +#[cfg(any(esp32c3, esp32s3))] +#[instability::unstable] +pub fn disable_bt_wakeup() { + WakeupSource::Bt.disable(); +} + /// Prepares the sleep hardware, and clears the wakeup sources of the previous run. /// /// The wakeup-enable mask survives a deep-sleep wake, so here it still holds the request of the run diff --git a/esp-hal/src/uart/mod.rs b/esp-hal/src/uart/mod.rs index d7f1dab7674..5bae3461f94 100644 --- a/esp-hal/src/uart/mod.rs +++ b/esp-hal/src/uart/mod.rs @@ -640,7 +640,7 @@ where guard: rx_guard, peri_clock_guard: peri_clock_guard.clone(), // Receiving data continuously, the peripheral can't let the system sleep. - _wake_lock: WakeLock::new(), + wake_lock: Some(WakeLock::new()), reported_errors: config.rx.reported_errors, }, tx: UartTx { @@ -698,8 +698,9 @@ pub struct UartRx<'d, Dm: DriverMode> { phantom: PhantomData, guard: PeripheralGuard, peri_clock_guard: UartClockGuard<'d>, - // Receiving data continuously, the peripheral can't let the system sleep. - _wake_lock: WakeLock, + // Receiving data continuously, the peripheral can't let the system sleep, unless the RX + // line is a wakeup source (see `enable_wakeup`), in which case the lock is dropped. + wake_lock: Option, reported_errors: EnumSet, } @@ -1178,7 +1179,7 @@ impl<'d> UartRx<'d, Blocking> { phantom: PhantomData, guard: self.guard, peri_clock_guard: self.peri_clock_guard, - _wake_lock: self._wake_lock, + wake_lock: self.wake_lock, reported_errors: self.reported_errors, } } @@ -1201,7 +1202,7 @@ impl<'d> UartRx<'d, Async> { phantom: PhantomData, guard: self.guard, peri_clock_guard: self.peri_clock_guard, - _wake_lock: self._wake_lock, + wake_lock: self.wake_lock, reported_errors: self.reported_errors, } } @@ -1443,7 +1444,10 @@ where #[cfg(sleep_driver_supported)] #[instability::unstable] pub fn enable_wakeup(&mut self, config: &WakeupConfig) -> Result<(), WakeConfigError> { - self.uart.info().enable_wakeup(config) + self.uart.info().enable_wakeup(config)?; + // The RX line now wakes the chip itself, so the receiver no longer needs to keep it awake. + self.wake_lock = None; + Ok(()) } /// Stops the UART from waking the chip. @@ -1451,6 +1455,9 @@ where #[instability::unstable] pub fn disable_wakeup(&mut self) { self.uart.info().disable_wakeup(); + if self.wake_lock.is_none() { + self.wake_lock = Some(WakeLock::new()); + } } /// Reads and clears RX error conditions set by received data. diff --git a/esp-radio/src/ble/btdm.rs b/esp-radio/src/ble/btdm.rs index d3f84c2efe3..58f12a82ece 100644 --- a/esp-radio/src/ble/btdm.rs +++ b/esp-radio/src/ble/btdm.rs @@ -87,6 +87,36 @@ extern "C" fn notify_host_recv(data: *mut u8, len: u16) -> i32 { let data = unsafe { core::slice::from_raw_parts(data, len as usize) }; + // Track active connections from the controller->host HCI event stream so the SoC-wake + // deadline (the pre-event margin) is armed ONLY while connected. Idle advertising does + // not need it (the controller runs the adv events autonomously on the kept-alive XTAL), + // so skipping it there lets the SoC light-sleep the whole advertising gap. HCI event = + // [0x04][evt][len][params]; LE Connection Complete (0x3E/sub 0x01|0x0A, status 0) opens + // a connection, Disconnection Complete (0x05) closes one. + if data.len() >= 4 && data[0] == 0x04 { + match data[1] { + 0x05 => { + if BLE_CONN_COUNT.load(core::sync::atomic::Ordering::Relaxed) > 0 + && BLE_CONN_COUNT.fetch_sub(1, core::sync::atomic::Ordering::Relaxed) == 1 + { + // last connection gone → advertising only; drop the Bt wake source. + #[cfg(any(esp32c3, esp32s3))] + esp_hal::rtc_cntl::sleep::disable_bt_wakeup(); + } + } + 0x3E if data.len() >= 5 && matches!(data[3], 0x01 | 0x0A) && data[4] == 0x00 => { + if BLE_CONN_COUNT.fetch_add(1, core::sync::atomic::Ordering::Relaxed) == 0 { + // first connection → arm the Bt wake source as the event backstop. + #[cfg(any(esp32c3, esp32s3))] + if SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { + esp_hal::rtc_cntl::sleep::enable_bt_wakeup(); + } + } + } + _ => {} + } + } + let packet = ReceivedPacket { data: Box::from(data), }; @@ -286,11 +316,68 @@ unsafe extern "C" fn btdm_sleep_check_duration(half_slot_cnt: i32) -> i32 { 1 // true } -// Modem-only sleep needs no wakeup timer (that is the CONFIG_PM system-light-sleep -// path); bt.c returns immediately when wakeup_timer_required == 0. -unsafe extern "C" fn btdm_sleep_enter_phase1(_lpcycles: i32) {} +// Modem-sleep window diagnostics: [enter_phase1 calls, exit_phase3 wakes, sum of window +// us, min window us, max window us]. +static MODEM_SLEEP_DIAG: [portable_atomic::AtomicU64; 5] = [ + portable_atomic::AtomicU64::new(0), + portable_atomic::AtomicU64::new(0), + portable_atomic::AtomicU64::new(0), + portable_atomic::AtomicU64::new(u64::MAX), + portable_atomic::AtomicU64::new(0), +]; + +/// Snapshot of the modem-sleep window diagnostics (see `MODEM_SLEEP_DIAG`). +pub(crate) fn modem_sleep_diag() -> [u64; 5] { + core::array::from_fn(|i| MODEM_SLEEP_DIAG[i].load(core::sync::atomic::Ordering::Relaxed)) +} + +// bt.c: with CONFIG_PM the controller arms a wakeup timer slightly before the modem +// sleep window ends, so the SoC leaves light sleep and re-takes the pm lock before the +// controller wakes. Here the wake lock released in enter_phase2 is replaced by a sleep +// deadline: the idle hook sleeps at most until it, and refuses to sleep past it until +// exit_phase3 clears it. Modem-only sleep (SoC awake) needs none of this. +/// Pre-event wake margin: wake the SoC this far before the modem-sleep window ends so it is +/// fully out of light sleep in time to service the controller event. bt.c's +/// BTDM_MIN_TIMER_UNCERTAINTY_US = 1800; empirically load-bearing for a held CONNECTION +/// (events ~30 ms) — 800 µs let the SoC wake too late and the link dropped before service +/// discovery. The Bt wake source is a backstop, not a substitute for waking in time. +const MODEM_MIN_UNCERTAINTY_US: u32 = 1800; +/// `wake_in` (µs) computed by enter_phase1, consumed by enter_phase2 so the deadline is set +/// PAIRED with the wakelock release (and thus always cleared by the paired exit_phase3). +/// Setting it in enter_phase1 unconditionally left stale deadlines that held the SoC awake +/// between events whenever the enter_phase2/exit_phase3 pairing did not fire. +static PENDING_WAKE_IN_US: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); + +/// Number of active BLE connections (from the HCI event stream). The SoC-wake deadline is +/// armed only while this is > 0; idle advertising skips it and light-sleeps the whole gap. +static BLE_CONN_COUNT: core::sync::atomic::AtomicI32 = core::sync::atomic::AtomicI32::new(0); + +#[ram] +unsafe extern "C" fn btdm_sleep_enter_phase1(lpcycles: i32) { + if !SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { + return; + } + let us_to_sleep = unsafe { btdm_lpcycles_2_hus(lpcycles as u32, 0) } >> 1; + let uncertainty = (us_to_sleep >> 11).max(MODEM_MIN_UNCERTAINTY_US); + let wake_in = us_to_sleep.saturating_sub(uncertainty); + { + use core::sync::atomic::Ordering::Relaxed; + MODEM_SLEEP_DIAG[0].fetch_add(1, Relaxed); + MODEM_SLEEP_DIAG[2].fetch_add(us_to_sleep as u64, Relaxed); + MODEM_SLEEP_DIAG[3].fetch_min(us_to_sleep as u64, Relaxed); + MODEM_SLEEP_DIAG[4].fetch_max(us_to_sleep as u64, Relaxed); + } + // Don't set the deadline here — hand wake_in to enter_phase2, which sets it in the same + // branch that releases the wakelock, so exit_phase3 always clears it. + PENDING_WAKE_IN_US.store(wake_in, core::sync::atomic::Ordering::Relaxed); +} // Power the RF/baseband down for the sleep window. +// Modem sleep is only wired on the c3/s3 BTDM controllers (the blob sleep symbols and +// the lp-clock setup are c3/s3-only). On esp32-classic these callbacks are never invoked +// (the controller is left in sleep_mode 0), so they are no-ops there, which also keeps the +// esp32-classic build free of the c3/s3-only externs. +#[cfg(any(esp32c3, esp32s3))] unsafe extern "C" fn btdm_sleep_enter_phase2() { if unsafe { btdm_controller_get_sleep_mode() } == 1 && PHY_ENABLED.swap(false, core::sync::atomic::Ordering::AcqRel) @@ -298,19 +385,38 @@ unsafe extern "C" fn btdm_sleep_enter_phase2() { esp_phy::disable_phy(); // The controller is sleeping the modem for the whole gap, so release the wake // lock; the esp-rtos idle hook may light-sleep the SoC until the pre-event wake. + // Set the deadline HERE (paired with the release), so exit_phase3's paired + // re-acquire always clears it — no stale deadline can strand the SoC awake. // Re-acquired in exit_phase3. if SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { esp_hal::rtc_cntl::WakeLock::release(); + // Arm the pre-event wake deadline ONLY while connected. A held CONNECTION needs + // the SoC awake for its periodic events (without it the link drops before service + // discovery — HW-proven); idle advertising does not, so skipping the deadline + // there lets the SoC light-sleep the whole gap (~55% -> ~87% residency). + if BLE_CONN_COUNT.load(core::sync::atomic::Ordering::Relaxed) > 0 { + let wake_in = PENDING_WAKE_IN_US.load(core::sync::atomic::Ordering::Relaxed); + if wake_in > 0 { + esp_hal::rtc_cntl::WakeLock::set_sleep_deadline( + esp_hal::time::Instant::now() + + esp_hal::time::Duration::from_micros(wake_in as u64), + ); + } + } } } } +#[cfg(not(any(esp32c3, esp32s3)))] +unsafe extern "C" fn btdm_sleep_enter_phase2() {} + // exit_phase1/2 are NULL in bt.c's OSI table; never invoked. unsafe extern "C" fn btdm_sleep_exit_phase1() {} unsafe extern "C" fn btdm_sleep_exit_phase2() {} // Re-power the RF/baseband on wake, then wait for the sleep FSM to resync. +#[cfg(any(esp32c3, esp32s3))] unsafe extern "C" fn btdm_sleep_exit_phase3() { if unsafe { btdm_controller_get_sleep_mode() } == 1 && !PHY_ENABLED.swap(true, core::sync::atomic::Ordering::AcqRel) @@ -320,6 +426,9 @@ unsafe extern "C" fn btdm_sleep_exit_phase3() { // with the PHY_ENABLED gate so the lock count stays matched. if SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { esp_hal::rtc_cntl::WakeLock::acquire(); + // The lock is held again, so the pre-event deadline has done its job. + esp_hal::rtc_cntl::WakeLock::clear_sleep_deadline(); + MODEM_SLEEP_DIAG[1].fetch_add(1, core::sync::atomic::Ordering::Relaxed); } // Balanced raw increment: enable_phy() returns an RAII guard; forget it // so the +1 persists until the matching enter_phase2 disable. @@ -328,6 +437,9 @@ unsafe extern "C" fn btdm_sleep_exit_phase3() { while unsafe { btdm_sleep_clock_sync() } {} } +#[cfg(not(any(esp32c3, esp32s3)))] +unsafe extern "C" fn btdm_sleep_exit_phase3() {} + unsafe extern "C" fn coex_schm_status_bit_set(_typ: i32, status: i32) { trace!("coex_schm_status_bit_set {} {}", _typ, status); #[cfg(feature = "coex")] @@ -454,12 +566,15 @@ pub(crate) fn ble_init(config: &Config) -> PhyInitGuard<'static> { SLEEP_CLOCK_LIGHT_SLEEP.store(light_sleep, core::sync::atomic::Ordering::Relaxed); let sel_ok = btdm_lpclk_select_src(sel); let div_ok = if div > 0 { btdm_lpclk_set_div(div) } else { true }; - if light_sleep { - // Baseline wake lock held during events; enter_phase2 releases it in each - // gap and exit_phase3 re-acquires it, so the SoC only light-sleeps between - // events. - esp_hal::rtc_cntl::WakeLock::acquire(); - } + // With a light-sleep capable sleep clock, the wake lock esp-radio took in `init()` + // doubles as the controller's per-event lock: enter_phase2 releases it in each + // gap and exit_phase3 re-acquires it, so the SoC only light-sleeps between events. + // Without one, that lock stays held for the controller's lifetime. + // The Bt light-sleep wake source is armed per-CONNECTION (in notify_host_recv), + // NOT here: during idle advertising the controller runs autonomously on the + // kept-alive XTAL and does not need to wake the SoC, so leaving Bt-wake off there + // avoids fragmenting the advertising sleep gap with per-event wakeups. + let _ = light_sleep; debug!( "btdm modem-sleep lpclk sel={} div={} lpcycle_us={} light_sleep={} sel_ok={} div_ok={}", sel, div, lpcycle_us, light_sleep, sel_ok, div_ok @@ -517,6 +632,17 @@ pub(crate) fn ble_deinit() { unsafe { btdm_controller_deinit(); } + // If the controller was in a modem-sleep gap, enter_phase2 released the wake lock that + // `deinit()` is about to release again; re-take it so the count stays balanced. + if SLEEP_CLOCK_LIGHT_SLEEP.load(core::sync::atomic::Ordering::Relaxed) { + // Bt wakeup is only ever enabled on the light-sleep-capable btdm chips. + #[cfg(any(esp32c3, esp32s3))] + esp_hal::rtc_cntl::sleep::disable_bt_wakeup(); + if !PHY_ENABLED.swap(true, core::sync::atomic::Ordering::AcqRel) { + esp_hal::rtc_cntl::WakeLock::acquire(); + esp_hal::rtc_cntl::WakeLock::clear_sleep_deadline(); + } + } // Disabling the PHY happens automatically, when the BLEController gets dropped. } /// Sends HCI data to the BLE controller. diff --git a/esp-radio/src/ble/mod.rs b/esp-radio/src/ble/mod.rs index c93fb1833f6..6afcb9c8f5d 100644 --- a/esp-radio/src/ble/mod.rs +++ b/esp-radio/src/ble/mod.rs @@ -29,6 +29,14 @@ use self::btdm as ble; #[cfg(bt_controller = "npl")] use self::npl as ble; +/// Modem-sleep window diagnostics: `[sleep entries, wakes, sum of window us, min window +/// us, max window us]`. Bench aid for tuning the light-sleep wake deadline. +#[cfg(all(bt_controller = "btdm", any(esp32c3, esp32s3)))] +#[instability::unstable] +pub fn modem_sleep_diag() -> [u64; 5] { + btdm::modem_sleep_diag() +} + unstable_module! { pub mod controller; } diff --git a/esp-rtos/src/sleep.rs b/esp-rtos/src/sleep.rs index cceb0f3ce2d..b971710bf55 100644 --- a/esp-rtos/src/sleep.rs +++ b/esp-rtos/src/sleep.rs @@ -87,6 +87,50 @@ pub fn set_main_xtal_powered_in_light_sleep(on: bool) { KEEP_MAIN_XTAL_PU.store(on, core::sync::atomic::Ordering::Relaxed); } +/// Index into [`sleep_diag`]: passes refused because a wake lock was held. +pub const DIAG_GATE_WAKELOCK: usize = 0; +/// Index into [`sleep_diag`]: passes refused because a sleep deadline had passed. +pub const DIAG_GATE_DEADLINE: usize = 1; +/// Index into [`sleep_diag`]: passes refused because a core or the run queue was busy. +pub const DIAG_GATE_NOT_IDLE: usize = 2; +/// Index into [`sleep_diag`]: passes refused because the next wakeup was too near. +pub const DIAG_GATE_TOO_SOON: usize = 3; +/// Index into [`sleep_diag`]: light sleeps entered. +pub const DIAG_SLEPT: usize = 4; +/// Index into [`sleep_diag`]: microseconds spent in light sleep. +pub const DIAG_SLEPT_US: usize = 5; + +/// Index into [`sleep_diag`]: first of four microsecond totals, one per gate in the same +/// order as the gate counters, attributing the time between idle passes to the gate that +/// refused the previous pass. +pub const DIAG_GATE_US: usize = 6; + +static DIAG: [portable_atomic::AtomicU64; 10] = + [const { portable_atomic::AtomicU64::new(0) }; 10]; +static DIAG_LAST_PASS_US: portable_atomic::AtomicU64 = portable_atomic::AtomicU64::new(0); +static DIAG_LAST_GATE: portable_atomic::AtomicUsize = portable_atomic::AtomicUsize::new(usize::MAX); + +fn diag_count(idx: usize) { + DIAG[idx].fetch_add(1, core::sync::atomic::Ordering::Relaxed); + if idx < DIAG_SLEPT { + let now = crate::now(); + let last = DIAG_LAST_PASS_US.swap(now, core::sync::atomic::Ordering::Relaxed); + let prev = DIAG_LAST_GATE.swap(idx, core::sync::atomic::Ordering::Relaxed); + if prev < DIAG_SLEPT && last != 0 { + DIAG[DIAG_GATE_US + prev] + .fetch_add(now.saturating_sub(last), core::sync::atomic::Ordering::Relaxed); + } + } else { + DIAG_LAST_GATE.store(usize::MAX, core::sync::atomic::Ordering::Relaxed); + } +} + +/// Snapshot of the light-sleep idle hook's gate/sleep counters, indexed by the `DIAG_*` +/// constants. Diagnostic aid for judging why the chip does or does not sleep. +pub fn sleep_diag() -> [u64; 10] { + core::array::from_fn(|i| DIAG[i].load(core::sync::atomic::Ordering::Relaxed)) +} + pub fn configure(lpwr: LPWR<'static>) -> Sleep { Sleep { #[cfg(sleep_deep_sleep)] @@ -110,16 +154,29 @@ extern "C" fn auto_light_sleep_hook() -> ! { SCHEDULER.with(|scheduler| { if WakeLock::is_active() { + diag_count(DIAG_GATE_WAKELOCK); + return; + } + + // A driver that released its wake lock for a bounded gap sets a deadline; past it + // the chip must stay awake until the driver takes its lock back. + let deadline = WakeLock::sleep_deadline().map(|d| d.duration_since_epoch().as_micros()); + if let Some(deadline) = deadline + && crate::now() >= deadline + { + diag_count(DIAG_GATE_DEADLINE); return; } #[cfg(multi_core)] { if scheduler.run_queue.has_ready_tasks() { + diag_count(DIAG_GATE_NOT_IDLE); return; } for cpu in Cpu::all() { if !scheduler.cpu_idle(cpu) { + diag_count(DIAG_GATE_NOT_IDLE); return; } } @@ -143,6 +200,10 @@ extern "C" fn auto_light_sleep_hook() -> ! { return; }; let next_wakeup = time_driver.next_wakeup(); + let next_wakeup = match deadline { + Some(d) => next_wakeup.min(d), + None => next_wakeup, + }; let mut lpwr = LowPower::new(unsafe { LPWR::steal() }); @@ -155,6 +216,7 @@ extern "C" fn auto_light_sleep_hook() -> ! { lpwr.set_wakeup_deadline(Instant::EPOCH + Duration::from_micros(next_wakeup)); if next_wakeup.saturating_sub(crate::now()) < LIGHT_SLEEP_MIN_US { + diag_count(DIAG_GATE_TOO_SOON); return; } } @@ -187,7 +249,13 @@ extern "C" fn auto_light_sleep_hook() -> ! { if KEEP_MAIN_XTAL_PU.load(core::sync::atomic::Ordering::Relaxed) { cfg.set_xtal_fpu(true); } + let before = crate::now(); lpwr.sleep_light(cfg); + diag_count(DIAG_SLEPT); + DIAG[DIAG_SLEPT_US].fetch_add( + crate::now().saturating_sub(before), + core::sync::atomic::Ordering::Relaxed, + ); // The alarm timer was gated during light sleep, so its pre-armed alarm is // stale. Force a re-arm against the restored time base so the tick handler From d3088abda7d098555a819a78c8a923cac7ec097c Mon Sep 17 00:00:00 2001 From: Shiv Kokroo Date: Thu, 10 Sep 2026 04:35:21 +0200 Subject: [PATCH 4/4] esp-rtos: add requested-sleep-duration histogram to the idle hook Diagnostic for light-sleep power work: sleep_hist() returns 8 ms-bucketed counts of the requested sleep length at each committed light sleep, so a bench can see the wake cadence (what limits sleep length). Cheap relaxed counters in the hook. Co-Authored-By: Claude Opus 4.8 (1M context) --- esp-rtos/src/sleep.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/esp-rtos/src/sleep.rs b/esp-rtos/src/sleep.rs index b971710bf55..a2d83b0ff42 100644 --- a/esp-rtos/src/sleep.rs +++ b/esp-rtos/src/sleep.rs @@ -131,6 +131,39 @@ pub fn sleep_diag() -> [u64; 10] { core::array::from_fn(|i| DIAG[i].load(core::sync::atomic::Ordering::Relaxed)) } +/// Histogram of the REQUESTED light-sleep duration (next_wakeup - now) at each committed +/// sleep, so a bench can see the wake cadence (what limits sleep length). Buckets (ms): +/// [<2, 2-4, 4-8, 8-16, 16-32, 32-64, 64-160, >=160]. +static SLEEP_HIST: [portable_atomic::AtomicU64; 8] = + [const { portable_atomic::AtomicU64::new(0) }; 8]; + +fn hist_bump(us: u64) { + let ms = us / 1000; + let b = if ms < 2 { + 0 + } else if ms < 4 { + 1 + } else if ms < 8 { + 2 + } else if ms < 16 { + 3 + } else if ms < 32 { + 4 + } else if ms < 64 { + 5 + } else if ms < 160 { + 6 + } else { + 7 + }; + SLEEP_HIST[b].fetch_add(1, core::sync::atomic::Ordering::Relaxed); +} + +/// Snapshot of the requested-sleep-duration histogram (see [`SLEEP_HIST`]). +pub fn sleep_hist() -> [u64; 8] { + core::array::from_fn(|i| SLEEP_HIST[i].load(core::sync::atomic::Ordering::Relaxed)) +} + pub fn configure(lpwr: LPWR<'static>) -> Sleep { Sleep { #[cfg(sleep_deep_sleep)] @@ -250,6 +283,7 @@ extern "C" fn auto_light_sleep_hook() -> ! { cfg.set_xtal_fpu(true); } let before = crate::now(); + hist_bump(next_wakeup.saturating_sub(before)); lpwr.sleep_light(cfg); diag_count(DIAG_SLEPT); DIAG[DIAG_SLEPT_US].fetch_add(