Skip to content

fix(esp-hal): avoid dividing by a failed RTC clock calibration when sleeping - #6019

Closed
jphein wants to merge 1 commit into
esp-rs:mainfrom
jphein:fix/sleep-calibration-divzero
Closed

fix(esp-hal): avoid dividing by a failed RTC clock calibration when sleeping#6019
jphein wants to merge 1 commit into
esp-rs:mainfrom
jphein:fix/sleep-calibration-divzero

Conversation

@jphein

@jphein jphein commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Description

RtcClock::calibrate returns 0 when the TIMG calibration times out. That is a documented outcome, not a corner case — the doc comment on calibrate calls it out explicitly:

This function will time out and return 0 if the time for the given number of cycles to be counted exceeds the expected time twice. This may happen if 32k XTAL is being calibrated, but the oscillator has not started up (due to incorrect loading capacitance, board design issue, or lack of 32 XTAL on board).

The 0 is stored verbatim (calibrate_rtc_slow_clock writes it to LP_AON.store1, calibrate_rtc_fast_clock to RC_FAST_CAL_VAL) and read back unmodified by rtc_slow_cal_period / rtc_fast_cal_period. SleepTimeConfig then divides by it:

pub fn us_to_slowclk(&self, us: u32) -> u32 {
    (us << RtcClock::CAL_FRACT) / self.slowclk_period
}

So on a board where the slow clock does not start — precisely the situation the calibration timeout exists to detect — the firmware logs "... calibration failed" and then panics with a divide-by-zero on the next attempt to enter light or deep sleep. us_to_slowclk / us_to_fastclk are on the unconditional path of every PMU-chip sleep entry (pmu_sleep_calculate_hw_wait_time, the param.hp_sys / param.lp_sys setup in esp32c5.rs, esp32c6.rs, esp32c61.rs, esp32h2.rs, esp32p4.rs), so there is no way to reach sleep without hitting it.

This substitutes the period implied by the clock's nominal frequency when calibration is unavailable, and logs a warning. Sleep timing is then only as accurate as the uncalibrated oscillator, which is a much better outcome than aborting the firmware.

Fixes #6016

On the choice of fallback value

The fallback must be in the same representation calibrate returns: microseconds per clock cycle, as a fixed-point number with RtcClock::CAL_FRACT (19) fractional bits. calibrate already computes exactly that from a known frequency in its Xtal32kClk short-circuit:

let period_64 = (1_000_000u64 << RtcClock::CAL_FRACT) / freq_hz;

nominal_cal_period is that same expression, so a fallback is numerically identical to what a successful calibration of a perfectly nominal oscillator would have produced.

The two frequencies:

  • RC_FASTcalibrate_rtc_fast_clock calibrates TimgCalibrationClockConfig::RcFastDivClk, whose clock-tree frequency is rc_fast_clk_frequency(). Where a chip revision uses the divided calibration tick, measure_rtc_clock divides the requested cycle count by the same divider before measuring and calibrate divides by the undivided slowclk_cycles, so the result is the period of RC_FAST itself either way. Nominal: 17.5 MHz (C6/C61), 20 MHz (C5/P4), 8 MHz (H2).
  • RTC_SLOWRtcClock::slow_freq() is documented as "the nominal value of the RTC_SLOW_CLK source" and resolves the currently configured source (RC_SLOW / XTAL32K / OSC_SLOW), which is the same source calibrate_rtc_slow_clock selects. Nominal: 136 kHz for RC_SLOW on C6.

Two alternatives were considered and not taken:

  • Return an error / Option from the sleep entry points. More correct in principle, but it is an API break across every sleep_* entry point, and it forces callers to handle a case that is unrecoverable at that point anyway. Worth doing separately if maintainers prefer it.
  • Reuse the last known-good calibration. There isn't necessarily one: the values are 0 before the first successful calibration, so this does not cover the first-sleep case, which is the one users actually hit.

Happy to switch to either if that is the preference — the shape of the guard would be the same.

Testing

cargo check and cargo clippy (with unstable, and with unstable,defmt) are clean for every chip that compiles pmu_common.rs — C5, C6, C61, H2 on riscv32imac-unknown-none-elf and P4 on riscv32imafc-unknown-none-elf — and cargo check is also clean for C6 without unstable.

I do not have a board that reproduces a calibration timeout, so the failure path itself is argued from the code rather than observed on hardware. The happy path is unchanged: a non-zero calibration result is returned exactly as before, byte for byte.

Numerically, the fallbacks come out as: 29959 for RC_FAST at 17.5 MHz, 26214 at 20 MHz, 65536 at 8 MHz, and 3855058 for RC_SLOW at 136 kHz. Round-tripping the last one through slowclk_to_us gives 3855058 >> 19 = 7 µs per cycle, matching 1/136 kHz = 7.35 µs.

Changelog

esp-hal

  • Fixed: Entering light or deep sleep no longer panics with a division by zero when RTC clock calibration times out; the nominal clock frequency is used instead and a warning is logged.

…leeping

`RtcClock::calibrate` returns 0 when the TIMG calibration times out (it
already logs "calibration failed"). That 0 is stored verbatim and read
back by `rtc_slow_cal_period`/`rtc_fast_cal_period`, so
`SleepTimeConfig::us_to_slowclk`/`us_to_fastclk` divided by zero and
panicked. Entering light or deep sleep therefore aborted the firmware on
any board where the slow clock does not start - for example a board
selecting XTAL32K with a missing or badly loaded 32.768 kHz crystal,
which is exactly the failure the calibration timeout exists to detect.

Fall back to the period implied by the clock's nominal frequency when
calibration is unavailable, and log a warning. Sleep timing is then as
accurate as the uncalibrated oscillator, which is a far better outcome
than a panic.

Fixes esp-rs#6016
Copilot AI review requested due to automatic review settings August 1, 2026 15:57
@jphein
jphein requested review from bjoernQ and bugadani as code owners August 1, 2026 15:57

Copilot AI 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.

Pull request overview

This PR hardens the PMU sleep timing calculations in esp-hal by preventing a divide-by-zero when RTC clock calibration returns 0 (timeout / unavailable). Instead of panicking on sleep entry, it falls back to a nominal clock period derived from the configured clock source’s nominal frequency and emits a warning.

Changes:

  • Added a small helper (nominal_cal_period) to compute a fixed-point “µs per cycle” period in the same representation as RtcClock::calibrate.
  • Guarded rtc_fast_cal_period() / rtc_slow_cal_period() results and substituted a nominal period when the stored calibration period is 0.
  • Logged a warning when falling back to nominal periods for RC_FAST or RTC_SLOW.

@bjoernQ

bjoernQ commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

IIRC ESP-IDF does the same what we do on main (i.e. they assert on period and fail on divide by zero in non-debug)

@bugadani

bugadani commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

If we fail the calibration, we shouldn't assume nominal frequency because something is wrong with the setup somewhere.

@bugadani bugadani closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sleep path divides by a failed clock calibration (panics "attempt to divide by zero") — RtcClock::calibrate returning 0 is never checked

4 participants