#6835·embassy

Two panics in embassy-stm32 DMA/timer code, potentially seven similar one more

Author: 0x53ACreated Aug 26, 2026Updated Aug 31, 2026

The short version is, I hit a panic in DMA handling code on STM32 and tasked Claude with a) finding the cause of this bug, and b) looking for similar bugs of the same class in surrounding code.

The first panic was found on embassy 0.6.0 and verified to still exist in master (ba27517), subsequent ones were directly checked on master (ba27517, fetched 2026-08-24, against stm32-metapac 21.0.0 at the pinned stm32-data-871bfd30e87c8bed5f66df2819bf5a5fc23f03b5).

I did look over the claims, and they sound reasonable to me, but I did not read the code deeply enough yet to send a PR. At the very least the first instance is a real panic, the others likely too. So I wanted to post the issue now, in case it's useful to others, but with the big disclaimer that a majority of it is unfiltered LLM output.

When I have some free time, likely end of september, I plan to go through the list myself, see what's still open, and send some PRs.

(The model used was Opus 5 in Claude Code)


1. into_ring_buffered_channel panics for any timer channel above CH1

Timer::setup_ring_buffer takes the DMA destination address through the one-channel register view, whatever channel it was handed. Tim1ch declares exactly one CCR, and the generated accessor asserts on the index, so anything but CH1 panics at startup.

rust
// TIM2_CH3 on PB10
let mut pwm = SimplePwm::new(p.TIM2, None, None, Some(ch3), None, khz(800), Default::default());
let mut ring = pwm.ch3().into_ring_buffered_channel(p.DMA1_CH3, &mut dma_buf);

Expected: a ring-buffered PWM channel. Actual: panicked at .../peripherals/timer_v2.rs: assertion failed: n < 1, during init, before any output.

Agent detail
  • embassy-stm32/src/timer/low_level.rs:1036 (main, ba27517); present in 0.6.0 too.
  • rust
    WritableRingBuffer::new(dma::Channel::new(dma, irq), req,
        self.regs_1ch().ccr(channel.index()).as_ptr() as *mut T::Word, dma_buf, opt)
    regs_1ch() yields Tim1ch, whose ccr(n) is assert!(n < 1). channel.index() is 0..3.
  • timer/input_capture.rs:333 does the analogous thing correctly via regs_gp16().ccr(..), so the right idiom already exists in the crate.
  • Fixed when setup_ring_buffer stops routing through regs_1ch().
  • Workaround in our firmware: use WritableRingBuffer directly, set TIM2_DIER.UDE, pass the TIM2_CCR3 address explicitly.

2. RingBuffer::clear() can report a spurious Overrun on the next access

DmaIndex::reset() zeroes pos before dma_sync() reads NDTR, and dma_sync's wrap heuristic is pos < self.pos — which can never fire against a freshly zeroed pos. If the DMA controller has already wrapped but the transfer-complete ISR has not run yet, reset() anchors the position from post-wrap NDTR while crediting zero laps; the ISR then delivers that pre-reset lap afterwards, a full cap too late. The index ends up one lap ahead of reality.

rust
ring.clear();                       // -> WritableDmaRingBuffer::reset()
ring.write_exact(&chunk).await?;    // first chunk of the next frame

Expected: Ok, the ring is empty. Actual: Err(Error::Overrun) on the very first chunk. On hardware, once per ~2000–4000 frames; the log says after 0 of 3538 slots and 90 us, i.e. it failed on the first poll without ever awaiting a DMA interrupt, so the producer was never late. Same hole in ReadableDmaRingBuffer::reset(), where the spurious lap lands on write_index and the first read() after clear() returns Overrun.

Agent detail
  • embassy-stm32/src/dma/ringbuffer/mod.rs; readable variant at line 153. Master reworked dma_sync (pos + cap instead of cap - 1, total()/normalize()) but not this window.
  • Two sub-cases; only one is harmful. A TC firing strictly between dma.reset_complete_count() and the NDTR read inflates both indices by one lap, diff is unchanged, cost is one interrupt period of latency. The damaging case is the TC that is still pending: NDTR already reloaded, complete_count not yet incremented.
  • Reachable from RingBufferedUartRx::clear(), ring-buffered ADC, SAI, I2S. read_latest() swallows the error; read() / read_exact() do not.
  • Live on both back-ends: dma/dma_bdma.rs and dma/gpdma/ringbuffered.rs both route clear() into the same reset(), so it reaches the 401 gpdma_v1 chips (H5, H7R/S, N6, U3, U5) too. Notably gpdma/ringbuffered.rs:19 was written to close the neighbouring race, names DmaUnsynced in its comment, and snapshots complete_count + BNDT in one critical section — so this class is already known upstream; reset() just was not revisited.
  • Why the existing tests can't see it: dma/ringbuffer/tests/prop_test/mod.rs models the DMA as self.pos = next % CAP; self.wraps += next / CAP; — position and lap count always move together, so the mock cannot represent a pending TC by construction. A mock that keeps "laps completed by hardware" and "laps credited by the ISR" separate reaches it in one line.
  • Repro (host, no hardware): copy dma/word.rs and dma/ringbuffer/mod.rs out of embassy into a fresh lib crate, add a test module in place of mod tests;, model the two lap counters separately. Failing: writable_reset_spurious_overrun_when_isr_is_deferred, readable_reset_spurious_overrun_when_isr_is_deferred, dma_sync_deferred_lap_is_applied_a_lap_late (reports 60 where the true linear position is 188). Control writable_reset_is_fine_when_isr_already_ran passes today. In both reset cases the DMA had moved 10 of 128 slots, so there is no real overrun.
  • Fixed when DmaIndex::reset() stops zeroing pos ahead of dma_sync(), or dma_sync() consults the hardware TC flag rather than only the ISR-maintained counter.

3. FDCAN silently runs at the wrong bitrate when the prescaler exceeds 9 bits

calc_can_timings is shared between bxCAN and FDCAN and range-checks the prescaler against bxCAN's 10-bit BRP (prescaler > 1024). FDCAN's NBTP.NBRP is 9 bits and DBTP.DBRP is 5, and both accessors mask instead of validating. So a legitimate prescaler above 511 has its top bits thrown away after the solver already verified its own arithmetic.

rust
// 170 MHz kernel clock
can.set_bitrate(10_000);
// prescaler = 1000, bs1 = 14, bs2 = 2; self-check 170e6 / (1000 * 17) = 10000 -> Ok
// nbrp() = 1000 & 0x1FF = 488  ->  170e6 / (488 * 17)

Expected: 10 kbit/s, or an error. Actual: 20 491 bit/s, no diagnostic. Over a grid of 19 realistic FDCAN kernel clocks × 8 CiA 301 bitrates, 9 pairs are silently wrong — worst 96 MHz @ 10 k → 68 kbit/s (6.8×).

Agent detail
  • can/util.rs:67 (the 1024 check); can/fd/config.rs:32 nbrp() = u16::from(self.prescaler) & 0x1FF, :87 dbrp() = (… & 0x001F) as u8; written at can/fd/peripheral.rs:441 as set_nbrp(btr.nbrp() - 1).
  • Affected nominal pairs: (96,10k)→68181, (100,10k)→55309, (120,10k)→31512, (125,20k)→110619, (160,10k)→20491, (170,10k)→20491, (180,20k)→136363, (200,20k)→110619, (240,20k)→63025.
  • DBRP: 8 of 133 (kernel, data rate) pairs, e.g. 170 MHz @ 250 k → prescaler 40, DBRP 8. set_fd_data_bitrate documents "250k/1M" in its own source comment, so in scope.
  • No grid pair produced nbrp() == 0, so the nbrp() - 1 underflow is unreachable today — any prescaler that is an exact multiple of 512 would do it.
  • ntseg2()/nsjw()/dtseg1()/dtseg2()/dsjw() also mask but are safe by luck: calc_can_timings bounds bs1 <= 16, bs2 <= 8.
  • Third defect, same file: can/fdcan.rs:253(1 + u8::from(bit_timing.seg1)) * u16::from(bit_timing.prescaler) as u8. as binds tighter than *, so the prescaler truncates to u8 and the multiply happens in u8. 17 of 133 pairs overflow, incl. 170 MHz at 250 k and 500 k (intended 600 and 300). Debug: attempt to multiply with overflow at init. Release: wraps, 300 → 44. tdco() then clamps to 0x3F although the field is 7 bits.
  • bxCAN sibling: can/bxcan/registers.rs:41 (set_bit_timing, public, rustdoc points users at an online calculator) masks with widths matching neither the shared struct's documented ranges nor bxCAN's own fields: BRP & 0x1FF where the field is 10 bits, TS2 and SJW & 0x7F where the fields are 4 and 2. prescaler = 512512 & 0x1FF == 00u16 - 1 → BRP 1023, i.e. prescaler 1024, 2× too slow (debug: panic). seg1 = 20 → TS1 3. Not reachable from set_bitrate — a grid over 15 bxCAN-era APB1 clocks (8–54 MHz) × 8 CiA rates never exceeds prescaler 511 — the exposure is the public set_bit_timing path.
  • NominalBitTiming (can/util.rs:9) is shared, public-fielded, not #[non_exhaustive], and its documented ranges are FDCAN's with seg1/seg2 apparently transposed (seg2 documented 1..255 against a 7-bit NTSEG2).
  • Preferred fix: give calc_can_timings the caller's prescaler limit (1024 bxCAN / 512 FDCAN nominal / 32 FDCAN data) so it returns TimingCalcError::InvalidPrescaler. Failing that, make nbrp()/dbrp() return Result or at least debug_assert! the mask is a no-op. And compute tdc_offset in u32 with an explicit .min(127).

4. SPI runs above the configured frequency — up to +44% from rounding, unboundedly from saturation

compute_baud_rate picks the prescaler band by rounding to nearest (switch divider when the ratio exceeds 1.5× the current one), and the _ => 0b111 arm swallows every ratio above 191. A frequency in an SPI config is normally copied out of the slave's datasheet as a maximum. There is a panic! for a request the clock is too fast for and nothing at all for one it is too slow for.

rust
// 72 MHz SPI kernel clock (classic F1/F4), SD card / display controller limit
let spi = Spi::new(p.SPI1, .., { let mut c = Config::default(); c.frequency = mhz(25); c });
// 250 MHz kernel, conservative bring-up speed
c.frequency = khz(100);

Expected: SCK ≤ 25 MHz; SCK ≤ 100 kHz or an error. Actual: 36 MHz (+44%); 976.6 kHz (+877%), silently. 152 of 401 reachable (kernel, request) pairs overclock SCK; 74 more are below the slowest achievable and accepted anyway.

Agent detail
  • spi/mod.rs:1538, no #[cfg] — every family. Fed by Spi::new, set_config, apply_config; Config::frequency is the only way to set SCK and is documented only as "Clock frequency."
  • Band boundaries are [2, 5, 11, 23, 39, 95, 191]. Round-to-nearest would give [2, 5, 11, 23, 47, 95, 191]39 should be 47, so ratios 40..47 get /64 where the function's own rule gives /32, i.e. half the intended SCK. That typo is why the table implements no single policy. Rounding the divider up would give [2, 4, 8, 16, 32, 64, 128].
  • Samples: 170 MHz/8 MHz → 10.625 MHz (+33%); 48 MHz/100 kHz → 187.5 kHz; 32 MHz/100 kHz → 125 kHz. 100 kHz is unreachable on any kernel clock above 25.6 MHz.
  • The correct twin is one crate away: embassy-rp/src/spi.rs:82 calc_prescs does div_roundup(clk_peri, freq * 2) and panics above 127 * 256.
  • Suggested fix: let ratio = kernel_clock.0.div_ceil(freq.0); with bands 1..=2, 3..=4, 5..=8, 9..=16, 17..=32, 33..=64, 65..=128, 129..=256 and an explicit error beyond. Better as ConfigError than panic!set_config already returns Result on some paths. It is technically a behaviour change (boards may be relying on the overshoot), so probably wants a changelog entry.
  • I2S sibling, i2s.rs:832: an independent third prescaler solver. Rounding to nearest is correct there (audio wants the closest rate), but division < 4 => (false, 2) and division > 511 => (true, 255) clamp silently at both ends. 44 of 480 (clock, Fs, MCLK, format) combinations get a different sample rate than requested — 250 MHz/8 kHz/no-MCLK/16-bit → 15.3 kHz (+91%); 48 MHz/192 kHz/MCLK → 46.9 kHz (−76%). MCLK multiplies the required clock by 256, so 192 kHz needs ≥196.6 MHz and most parts play at the wrong pitch instead of being told. Same fix: report the clamps.
  • Caveat: this assumes Config::frequency means "at most this". embassy-stm32 does not say either way; embassy-rp implements that reading. If upstream intends "approximately this", the rounding item becomes a docs bug and the saturation and boundary items stand.

5. calc_can_timings has a dead sample-point guard and a misplaced assert!

bs1 and bs2 are u8, and as u16 binds to the parenthesised expression — so the division happens in u8 before the widening. Since bs2 >= 1 the quotient is always 0, sample_point_permill is always 0, the 90% guard can never fire, and the round-to-zero fallback the function deliberately prepares is unreachable.

rust
// can/util.rs:92
let sample_point_permill = 1000 * ((1 + bs1) / (1 + bs1 + bs2)) as u16;
if sample_point_permill > MAX_SAMPLE_POINT_PERMILL { /* 900 — never taken */ }

Expected: an STM32F103 at 72 MHz (APB1 36 MHz) running CANopen at 500 kbit/s gets seg1 9 / seg2 2 → 83.3%. Actual: seg1 10 / seg2 1 → 91.6%, above the function's own limit. 17 of 504 (clock, bitrate) pairs exceed 900 permill.

Separately, core::assert!(bs1_bs2_sum > bs1) sits ten lines above the BSNotInRange check that exists to report exactly this case:

rust
calc_can_timings(Hertz(170_000_000), 800_000)   // this board's clock

Expected: Err(TimingCalcError::BSNotInRange { bs1, bs2 }). Actual: assertion failed: bs1_bs2_sum > bs1 — a panic out of a Result-returning pub function. Also (2 MHz, 500 k) and (4 MHz, 1 M).

Agent detail
  • can/util.rs:88-97. Fix: 1000 * (1 + bs1) as u16 / (1 + bs1 + bs2) as u16, and delete the assert so control reaches can/util.rs:100.
  • The prescaler is unchanged by the fallback, so fixing the expression corrects the sample point without altering the achieved bitrate (accepted_solutions_produce_the_requested_bitrate passes before and after).
  • Offenders: 12/36/54 MHz @ 500 k and 54 MHz @ 83.3 k/250 k and 2 MHz @ 83.3 k → 91.6%; 110 MHz @ 10 k…500 k and 275 MHz @ 50 k…500 k → 90.9%.
  • Assert cases arise when the divisor search exits with bs1_bs2_sum of 2 or 3: sum 3 → bs1 3, bs2 0; sum 2 → bs1 2, bs2 0. For (170 MHz, 800 k), prescaler_bs = 212 = 4×53, which none of 5..=17 divides.
  • All four in-tree callers (can/fdcan.rs:238, :251, can/bxcan/mod.rs:130, :248) unwrap! the result, so an unsupported pair panics either way — but the assert replaces a typed error naming bs1/bs2 with a bare message that over defmt says nothing.
  • CiA 301 recommends 87.5%. 91.6% still works on a short bench bus; what it eats is the margin for propagation delay and oscillator drift, which is why it survives smoke tests.
  • Repro: copy can/util.rs into a lib crate (only its two crate-internal paths need rewriting), run a grid of 42 APB clocks × 12 CiA bitrates.
  • Minor sibling: usart/mod.rs:2337 calculate_brr divides by the requested baudrate without checking it, so set_baudrate(0)Result<(), ConfigError> — is a division-by-zero panic. Explicitly not a finding: its >2% error at 8 MHz/460800 and 16 MHz/921600 is the granularity of 16× oversampling, not a defect.

6. STM32H5: the PLL is put in the medium VCO range with inputs up to 16 MHz

DS14258 specifies the two VCO ranges as two operating modes, each with its own PLL input window: wide = 2–16 MHz input / 128–560 MHz VCO (Table 48), medium = 1–2 MHz input / 150–420 MHz VCO (Table 49). embassy enforces "input below 2 MHz ⇒ must use medium" but not the converse, and tests the medium window first, so MediumVco is selected for any VCO in 150–420 MHz whatever the input. PLL1RGE is then set independently from the real input.

rust
// the clock tree used by examples/stm32h5/src/bin/dts.rs and adc_dma.rs,
// and by the H563 arm of tests/stm32/src/common.rs
config.rcc.pll1 = Some(Pll {
    source: PllSource::HSI,        // 64 MHz
    prediv: PllPreDiv::DIV4,       // -> 16 MHz PFD, Pllrge::Range8
    mul: PllMul::MUL25,            // -> VCO 400 MHz
    ..
});

Expected: WideVco (400 MHz is inside 128–560 too, so nothing is lost). Actual: VCOSEL = MediumVco, RGE = Range8 (8–16 MHz) — a pairing neither table covers, with an input 8× the documented maximum. 55 244 of 85 344 accepted configurations (65%), including embassy's own H5 examples and the H563 HIL config.

Agent detail
  • rcc/h.rs:996; the one-directional constraint is at :984. 45 stm32h5 chips (H503/H523/H533/H562/H563/H573).
  • rust
    let vco_range = if VCO_RANGE.contains(&vco_clk) { Pllvcosel::MediumVco }
        else if wide_allowed && VCO_WIDE_RANGE.contains(&vco_clk) { Pllvcosel::WideVco }
        else { panic!(..) };
    Fix: select on the input, not the VCO — if ref_range == Pllrge::Range1 { assert VCO_RANGE; MediumVco } else { assert VCO_WIDE_RANGE; WideVco }. Safe on H5 because the wide window strictly contains the medium one; verified by a control test (the_fix_costs_no_configurations) — nothing that works today stops working.
  • The failure mode of an out-of-window PLL is jitter and marginal lock, not a dead part, which is why the affected examples demonstrably run. examples/stm32h5/src/bin/eth.rs is not affected — DIVM 2, PLLN 125, VCO 500 MHz, outside the medium window. Whether an example is correct is decided by where its VCO happens to land.
  • Second defect, rcc/h.rs:974: the ref_range match enforces only the high side of the 1–16 MHz PFD window (x => panic!); anything below 1 MHz falls into ..=1_999_999 => Range1 and is programmed as Range1. DIVM is 6 bits so this is reachable — 217 (source, DIVM) pairs, from 4 MHz / DIVM 5 = 800 kHz. Usually masked because a small ref_clk drags the VCO below 150 MHz and the VCO check catches it first.
  • Grid: sources 4/8/12/16/24/25/32/48/64 MHz × DIVM 1..=63 × PLLN 4..=512. Self-contained, no embassy source needed.
  • H7 not verified. rcc/h.rs is shared and the structure is the same, but the H7 wide windows start at 192 MHz (pwr_h7rm0468) or 384 MHz (stm32h7rs), so a 150–192 MHz VCO is only reachable through the medium range and selecting it there is forced, not mistaken. Needs RM0433 / RM0455 / RM0468 / RM0477.
  • Trap worth flagging: an earlier draft of this claimed the VCO_WIDE_RANGE constants were wrong, on the strength of stm32-metapac's Pllvcosel doc strings and a vendor forum answer. Both carry the H7 numbers for every family — stm32-data reproduces ST's SVD text verbatim and ST copy-pasted H7 into the H5 block. The constants are correct; the datasheet table that disproved that claim is what turned up the real defect, one column over.

7. CanTx::flush(idx) passes a bitmask where a bit index is expected

Txbrp::trp(n) selects bit n. flush converts its mailbox number to a one-hot mask first and then passes the mask as the index. Every argument is wrong; half of them silently.

rust
can_tx.write(&frame).await;
can_tx.flush(0).await;      // -> trp(1): watches mailbox 1
can_tx.flush(2).await;      // -> trp(4)

Expected: waits until mailbox 0 / 2 has actually transmitted. Actual: flush(0) returns Ready while mailbox 0 is still transmitting (and blocks when mailbox 1 is busy and 0 is free). flush(2)/flush(3)assertion failed: n < 3usize on can_fdcan_v1 (G4, H7); on can_fdcan_v2 (H5, U5) they watch nonexistent mailboxes 4 and 8.

Agent detail
  • can/fdcan.rs:328; the offending line is let idx = 1 << idx;. The correct form is one file away: can/fd/peripheral.rs:146 `has_pending_frame(idx) -> self.regs.txbrp().read().tr