Two panics in embassy-stm32 DMA/timer code, potentially seven similar one more
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.
// 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.
embassy-stm32/src/timer/low_level.rs:1036(main,ba27517); present in 0.6.0 too.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()yieldsTim1ch, whoseccr(n)isassert!(n < 1).channel.index()is 0..3.timer/input_capture.rs:333does the analogous thing correctly viaregs_gp16().ccr(..), so the right idiom already exists in the crate.- Fixed when
setup_ring_bufferstops routing throughregs_1ch(). - Workaround in our firmware: use
WritableRingBufferdirectly, setTIM2_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.
ring.clear(); // -> WritableDmaRingBuffer::reset()
ring.write_exact(&chunk).await?; // first chunk of the next frameExpected: 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.
embassy-stm32/src/dma/ringbuffer/mod.rs; readable variant at line 153. Master reworkeddma_sync(pos + capinstead ofcap - 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,diffis unchanged, cost is one interrupt period of latency. The damaging case is the TC that is still pending: NDTR already reloaded,complete_countnot 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.rsanddma/gpdma/ringbuffered.rsboth routeclear()into the samereset(), so it reaches the 401gpdma_v1chips (H5, H7R/S, N6, U3, U5) too. Notablygpdma/ringbuffered.rs:19was written to close the neighbouring race, namesDmaUnsyncedin its comment, and snapshotscomplete_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.rsmodels the DMA asself.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.rsanddma/ringbuffer/mod.rsout of embassy into a fresh lib crate, add a test module in place ofmod 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). Controlwritable_reset_is_fine_when_isr_already_ranpasses today. In both reset cases the DMA had moved 10 of 128 slots, so there is no real overrun. - Fixed when
DmaIndex::reset()stops zeroingposahead ofdma_sync(), ordma_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.
// 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 detailcan/util.rs:67(the 1024 check);can/fd/config.rs:32nbrp() = u16::from(self.prescaler) & 0x1FF,:87dbrp() = (… & 0x001F) as u8; written atcan/fd/peripheral.rs:441asset_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_bitratedocuments "250k/1M" in its own source comment, so in scope. - No grid pair produced
nbrp() == 0, so thenbrp() - 1underflow 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_timingsboundsbs1 <= 16,bs2 <= 8.- Third defect, same file:
can/fdcan.rs:253—(1 + u8::from(bit_timing.seg1)) * u16::from(bit_timing.prescaler) as u8.asbinds tighter than*, so the prescaler truncates tou8and the multiply happens inu8. 17 of 133 pairs overflow, incl. 170 MHz at 250 k and 500 k (intended 600 and 300). Debug:attempt to multiply with overflowat 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& 0x1FFwhere the field is 10 bits, TS2 and SJW& 0x7Fwhere the fields are 4 and 2.prescaler = 512→512 & 0x1FF == 0→0u16 - 1→ BRP 1023, i.e. prescaler 1024, 2× too slow (debug: panic).seg1 = 20→ TS1 3. Not reachable fromset_bitrate— a grid over 15 bxCAN-era APB1 clocks (8–54 MHz) × 8 CiA rates never exceeds prescaler 511 — the exposure is the publicset_bit_timingpath. NominalBitTiming(can/util.rs:9) is shared, public-fielded, not#[non_exhaustive], and its documented ranges are FDCAN's withseg1/seg2apparently transposed (seg2documented 1..255 against a 7-bit NTSEG2).- Preferred fix: give
calc_can_timingsthe caller's prescaler limit (1024 bxCAN / 512 FDCAN nominal / 32 FDCAN data) so it returnsTimingCalcError::InvalidPrescaler. Failing that, makenbrp()/dbrp()returnResultor at leastdebug_assert!the mask is a no-op. And computetdc_offsetinu32with 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.
// 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 detailspi/mod.rs:1538, no#[cfg]— every family. Fed bySpi::new,set_config,apply_config;Config::frequencyis 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:82calc_prescsdoesdiv_roundup(clk_peri, freq * 2)and panics above127 * 256. - Suggested fix:
let ratio = kernel_clock.0.div_ceil(freq.0);with bands1..=2, 3..=4, 5..=8, 9..=16, 17..=32, 33..=64, 65..=128, 129..=256and an explicit error beyond. Better asConfigErrorthanpanic!—set_configalready returnsResulton 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), butdivision < 4 => (false, 2)anddivision > 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::frequencymeans "at most this". embassy-stm32 does not say either way;embassy-rpimplements 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.
// 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:
calc_can_timings(Hertz(170_000_000), 800_000) // this board's clockExpected: 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).
can/util.rs:88-97. Fix:1000 * (1 + bs1) as u16 / (1 + bs1 + bs2) as u16, and delete the assert so control reachescan/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_bitratepasses 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_sumof 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.rsinto 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:2337calculate_brrdivides by the requested baudrate without checking it, soset_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.
// 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.
rcc/h.rs:996; the one-directional constraint is at:984. 45stm32h5chips (H503/H523/H533/H562/H563/H573).- Fix: select on the input, not the VCO —
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!(..) };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.rsis 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: theref_rangematch enforces only the high side of the 1–16 MHz PFD window (x => panic!); anything below 1 MHz falls into..=1_999_999 => Range1and 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 smallref_clkdrags 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.rsis 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_RANGEconstants were wrong, on the strength of stm32-metapac'sPllvcoseldoc 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.
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.
can/fdcan.rs:328; the offending line islet idx = 1 << idx;. The correct form is one file away:can/fd/peripheral.rs:146`has_pending_frame(idx) -> self.regs.txbrp().read().tr
Source: embassy-rs/embassy