Deep dive
How it works
In: timer clock, PWM frequency, duty per channel, dead time · Out: gate signals (OCx, OCxN) · State: counter, ARR (period), CCR (compare) with its shadow register · Cost: none per edge; one write per channel per period
- Pick divider (PSC+1) and ARR. Edge-aligned: period = ARR+1 ticks. Center-aligned: the counter climbs to ARR and falls back, period 2×ARR.
- The reference is high while the count is below CCR (PWM mode 1); duty = CCR/ARR, step size one tick.
- With preload, a CCR write goes to a shadow register; the update event copies it to the active one, so no period is cut.
- Half bridge: a dead-time unit delays the rising edge of each gate by dt ticks; the low gate is the complement. Falling edges are immediate.
- A break input forces the outputs safe in hardware.
- A compare channel can trigger the ADC at the period centre.
- DMA on the update event writes the next CCR, or a burst into several; a DShot frame is 16 such words.
- Servo and ESC formats: a 1000-2000 µs pulse (OneShot125: 125-250 µs, PX4), or a 16-bit DShot frame: 11-bit throttle, telemetry bit, checksum
(v ^ v>>4 ^ v>>8) & 0xF.
Code
A std Rust model: frequency to ARR, a center-aligned counter with preload, dead time against a slow switch, DShot frames to CCR values (compiled and run; asserts pass).
// Timer PWM model: resolution, preload, dead time, DShot. std only.
/// Smallest divider (PSC+1) fitting 16 bits -> (divider, ARR). Edge-aligned period = ARR+1 ticks; center-aligned 2*ARR.
fn timer_config(clk: u32, freq: u32, center: bool) -> (u32, u32) {
let (ticks, k) = (clk / freq, if center { 2 } else { 1 });
let div = (ticks / k).div_ceil(65536).max(1);
let n = ticks / (div * k);
(div, if center { n } else { n - 1 })
}
/// Center-aligned timer; with `preload` CCR changes only at the update event.
struct Tim { arr: u32, ccr: u32, shadow: u32, preload: bool, i: u32 }
impl Tim {
fn write_ccr(&mut self, v: u32) { self.shadow = v; if !self.preload { self.ccr = v; } }
fn step(&mut self) -> bool { // OCxREF for this tick (PWM mode 1)
if self.i == 0 { self.ccr = self.shadow; } // update event
let (up, cnt) = if self.i < self.arr { (true, self.i) } else { (false, 2 * self.arr - self.i) };
self.i = (self.i + 1) % (2 * self.arr);
if up { cnt < self.ccr } else { cnt <= self.ccr }
}
}
/// A gate turns on only after the reference held its level for dt+1 ticks.
fn dead_time(r: &[bool], dt: usize) -> (Vec<bool>, Vec<bool>) {
let win = |t: usize, v: bool| t >= dt && r[t - dt..=t].iter().all(|&x| x == v);
((0..r.len()).map(|t| win(t, true)).collect(), (0..r.len()).map(|t| win(t, false)).collect())
}
/// A real switch conducts `toff` ticks after its gate drops.
fn conducts(g: &[bool], toff: usize) -> Vec<bool> {
(0..g.len()).map(|t| g[t.saturating_sub(toff)..=t].iter().any(|&x| x)).collect()
}
/// 11-bit throttle, telemetry bit, 4-bit checksum (inverted if bidirectional).
fn dshot_frame(throttle: u16, telem: bool, bidir: bool) -> u16 {
let v = (throttle << 1) | telem as u16;
let crc = (v ^ (v >> 4) ^ (v >> 8)) & 0xF;
(v << 4) | if bidir { !crc & 0xF } else { crc }
}
/// CCR per bit, MSB first: a 1 is high 75 % of the bit, a 0 37.5 %.
fn dshot_ccr(frame: u16, ticks: u32) -> [u32; 16] {
assert!(ticks % 8 == 0); // both high times must be whole ticks
std::array::from_fn(|b| if frame >> (15 - b) & 1 == 1 { ticks * 3 / 4 } else { ticks * 3 / 8 })
}
fn main() {
// 1. Resolution at a 168 MHz timer clock (example input).
assert_eq!(timer_config(168_000_000, 20_000, false), (1, 8399)); // 8400 steps, 13 bits
assert_eq!(timer_config(168_000_000, 20_000, true), (1, 4200)); // 4200 steps, 12 bits
assert_eq!(timer_config(168_000_000, 50, false), (52, 64614)); // servo rate needs a divider
// 2. Duty and dead time: no overlap, on-times shrink by dt.
let (arr, ccr, dt, toff) = (100u32, 30u32, 4usize, 3usize);
let mut t = Tim { arr, ccr, shadow: ccr, preload: true, i: 0 };
let r: Vec<bool> = (0..8 * arr).map(|_| t.step()).collect();
let p = 2 * arr as usize; // one period
let on = |v: &[bool]| v[p..2 * p].iter().filter(|&&x| x).count();
assert_eq!(on(&r), 2 * ccr as usize); // duty = CCR / ARR
let (hi, lo) = dead_time(&r, dt);
assert!(hi.iter().zip(&lo).all(|(a, b)| !(a & b)));
assert_eq!((on(&hi), on(&lo)), (2 * ccr as usize - dt, 2 * (arr - ccr) as usize - dt));
// A late-turn-off switch shoots through at dt 0, not once dt > toff.
let nr: Vec<bool> = r.iter().map(|x| !x).collect();
let both = |a: &[bool], b: &[bool]| conducts(a, toff).iter().zip(conducts(b, toff)).any(|(x, y)| *x && y);
assert!(both(&r, &nr) && dt > toff && !both(&hi, &lo));
// 3. Preload: CCR written at tick 25 of period 1 applies from period 2.
for preload in [true, false] {
let mut t = Tim { arr: 40, ccr: 30, shadow: 30, preload, i: 0 };
let per: Vec<usize> = (0..3).map(|n| (0..80).filter(|&i| { if n == 1 && i == 25 { t.write_ccr(10) } t.step() }).count()).collect();
assert_eq!(per, if preload { [60, 60, 20] } else { [60, 35, 20] }); // 35: neither old nor new
}
// 4. DShot600: checksum vectors, per-bit CCR.
let f = dshot_frame(1046, false, false);
assert_eq!(f, 0b1000_0010_1100_0110);
assert_eq!(dshot_frame(1046, false, true), 0b1000_0010_1100_1001);
let ticks = 168_000_000 / 600_000;
let ccr = dshot_ccr(f, ticks);
assert_eq!((ticks, ccr[0], ccr[1]), (280, 210, 105));
assert_eq!(ccr.iter().fold(0u16, |a, &c| a << 1 | (c == 210) as u16), f);
}
Results at a 168 MHz timer clock (example input): 20 kHz is 8400 steps edge-aligned (13 bits), 4200 center-aligned (12 bits); 50 Hz needs divider 52, ARR 64614. With dt 4 the gates are on 2·CCR−4 and 2·(ARR−CCR)−4 ticks, never together; a switch turning off 3 ticks late shoots through at dt 0, not dt 4. Writing the duty mid-period gives high-times 60, 60, 20 with preload, 60, 35, 20 without. DShot600: 280 ticks per bit, CCR 210 or 105.
Complexity and constants
- One store per channel per period, O(1); edges are exact to one tick, while software PWM inherits interrupt-latency jitter.
- Resolution is
log2(clock/f)bits: at 168 MHz, 100 kHz gives 10 bits, 1 MHz 7 (arithmetic). - DShot frame: 106.67, 53.33, 26.67 µs at 150, 300, 600 kbit/s (arithmetic; matches Landa). Bidirectional DShot waits about 30 µs for the reply (PX4 source comment).
Efficiency
- Cost: after setup the timer makes edges with no CPU time. DShot via DMA costs 16 words per motor per frame (64 B in PX4's 32-bit layout). Bound: timer clock and DMA bus; switching loss grows with frequency (no figure).
- Levers:
- lower PWM frequency — one more bit per halving (arithmetic above) — when switching loss or EMI outweigh ripple
- DMA burst across compare registers (PX4
dshot.c) — no CPU per bit — multi-motor output - faster DShot rate — frame time halves per step — loop rates above about 8 kHz (Landa)
- Naive → standard → state of the art:
- GPIO toggled in a timer interrupt · CPU per edge, latency jitter
- hardware compare, preload, dead time · no CPU between updates
- DMA-fed DShot with eRPM reply on the same wire · command and telemetry on one pin
Implementation notes
- Frequency =
clock/(div × (ARR+1)); enable preload on CCR and ARR. - Dead time must exceed the slower gate's turn-off time plus driver skew (
dt > toffabove). - Outputs off and break armed at start; a watchdog reset must leave the bridge off.
- DShot throttle is 48-2047; 0-47 are commands (Betaflight
DSHOT_MIN_THROTTLE48). Pick the clock so ticks per bit is a multiple of 8, or 75 % and 37.5 % are not whole ticks (PX4 searches 12-40 ticks per bit). - Bidirectional DShot inverts the line and the checksum (
~crc & 0xF) and needs 300 kbit/s or more (Landa). - Send frames from the fixed-rate loop after mixing.
Pitfalls
- A CCR write without preload cuts a pulse (the 35-tick period above).
- Dead time too short: shoot-through heats or destroys the bridge.
- Dead time distorts low duty; compensate in the controller (FOC).
- DShot's 4-bit XOR checksum is far weaker than CRC-32C.
- Software PWM misses edges when interrupts are masked.
Variants and alternatives
In production
- Drones: Betaflight and PX4 send DShot from timer DMA; PX4 also drives PWM and OneShot125.
- Phones and Linux boards: the kernel PWM subsystem drives LEDs, fans, vibrators and backlights.
- RP2040 boards: 8 PWM slices, phase-correct mode, double-buffered compare.
- Motor drives and converters: complementary outputs, dead time and break on advanced timers, with PID or field-oriented control setting the duty.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Betaflight | C | betaflight/betaflight | n/a (firmware) | — | — | — | — | DShot packet in drivers/dshot.c |
| PX4 Autopilot | C++ | PX4/PX4-Autopilot | n/a (firmware) | — | — | — | — | timer-burst DMA DShot; PWM, OneShot |
| embassy-stm32 | Rust | embassy-rs/embassy | cargo add embassy-stm32 | — | — | — | — | ComplementaryPwm, set_dead_time |
| embedded-hal | Rust | rust-embedded/embedded-hal | cargo add embedded-hal | — | — | — | — | pwm::SetDutyCycle trait |
| Linux PWM subsystem | C | torvalds/linux | n/a (in kernel) | — | — | — | — | pwm_apply_might_sleep; sysfs |
| Pico SDK hardware_pwm | C | raspberrypi/pico-sdk | n/a (SDK) | — | — | — | — | slices, wrap, phase-correct, DMA pacing |
Sources
- PX4 Autopilot docs: DShot ESCs · OneShot · STM32 dshot.c
- Betaflight, dshot.c (
prepareDshotPacket). - Chris Landa, DSHOT - the missing Handbook, 2021, community article. article
- Raspberry Pi, Pico SDK hardware/pwm.h.
- Linux kernel docs, Pulse Width Modulation (PWM) interface.
- embedded-hal, pwm::SetDutyCycle.
- ST, AN4013 STM32 cross-series timer overview; AN4776 General-purpose timer cookbook for STM32 microcontrollers; RM0090 (STM32F4 reference manual). Counter modes, dead time and break above follow these; st.com did not answer, so unchecked, no links.