Over the years I have written a fair bit about DShot, its faster variants, and even a road not taken. All of that was about getting commands to the ESC cleanly. This post is about the trick that made everything quieter and sharper: getting data back from the ESC, on the same single wire, and putting it to work.
That trick is bidirectional DShot, and the payoff is RPM filtering – arguably the biggest single jump in quad flight performance of the last decade. Here is how both halves actually work.
A quick refresher on the DShot frame
DShot is digital, so a throttle value is a packet, not a pulse width. Each frame is just 16 bits: an 11-bit value, a single telemetry-request bit, and a 4-bit CRC. Building it is refreshingly simple:
// 11-bit value, 1 telemetry-request bit, 4-bit CRC = one 16-bit frame
packet = (value << 1) | (requestTelemetry ? 1 : 0);
unsigned csum = 0, csum_data = packet;
for (int i = 0; i < 3; i++) { // XOR the three nibbles together
csum ^= csum_data;
csum_data >>= 4;
}
if (useDshotTelemetry) {
csum = ~csum; // bidirectional inverts the CRC
}
packet = (packet << 4) | (csum & 0xf);
Values 48–2047 are throttle; 0–47 are commands (beep, spin direction, save, and so on). Notice that last if: the moment you turn on bidirectional DShot, even the CRC is computed differently. That is the first hint that bidir is not a bolt-on – it changes the protocol.
Telemetry back on the same wire
The clever part of bidirectional DShot is that it does not add a wire. The single signal line that carries throttle to the ESC also carries RPM back from it.
To make that work the line is inverted – it idles high instead of low. The flight controller sends its (inverted) frame, then releases the line and switches the pin to an input. About 30 µs later the ESC drives the same pin to answer, sending a 21-bit, GCR-encoded telemetry frame at 5/4 of the outbound bitrate. Inside that is a 12-bit period value and a 4-bit checksum.
GCR (group-coded recording) is a self-clocking encoding that guarantees enough transitions for the receiver to stay in sync – the same family of trick used on old floppy and tape drives. Betaflight decodes the 5-bit GCR symbols back to nibbles through a small lookup table, then checks the result: the XOR of the nibbles must come out to 0xf or the frame is thrown away.
From a period to actual RPM
What the ESC sends back is not RPM directly – it is the period of one electrical rotation, cleverly packed. The 12 bits are an exponent and a mantissa, eee mmmmmmmmm, which keeps both small and large periods in range:
// telemetry value arrives as eee mmmmmmmmm (3-bit exponent, 9-bit mantissa) period_us = (value & 0x01ff) << ((value & 0xfe00) >> 9); // period (microseconds) -> eRPM (in steps of 100) erpm_100 = (600000 + period_us / 2) / period_us; // eRPM -> real RPM using the motor's pole count (default 14) rpm = erpm_100 * 100 / (motor_poles / 2);
That last line is the one people get wrong. A brushless motor turns once per pole-pair, not per electrical cycle, so you must tell Betaflight how many poles your motors have. Almost every quad motor is 14-pole, which is why motor_poles = 14 is the default – but if your RPM numbers look exactly double or half what you expect, this is the setting to check.
What RPM filtering does with it
Now the good bit. The dominant noise a gyro sees is the motors and props, and it sits at each motor’s rotation frequency and its harmonics. Before RPM telemetry we had to guess where that noise was and smother it with broad low-pass and dynamic notch filtering – which also smothers the signal and adds the latency that makes a quad feel mushy.
RPM filtering throws that guesswork out. Because we now know each motor’s exact frequency, every control loop, we can place narrow notch filters precisely on top of the noise:
frequencyHz = (harmonic + 1) * motorFrequencyHz // per motor, per harmonic
By default Betaflight runs 3 harmonics per motor, on all three gyro axes. On a quad that is 4 × 3 × 3 = 36 individual notch filters, every one tracking a real, measured frequency in real time (the implementation caps out at 3 axes × 8 motors × 3 harmonics = 72 biquads). As a motor spools up, its notches slide up with it.
A few defaults worth knowing:
rpm_filter_harmonics = 3– how many multiples of the base frequency to notch.rpm_filter_q = 500– the notch Q (5.0); higher is narrower, removing noise while keeping more signal.rpm_filter_min_hz = 100– ignore frequencies below this, with a fade-out range so notches do not slam on and off near idle.rpm_filter_lpf_hz = 150– smooths the RPM data feeding the filters.
Because the notches are surgical, you can afford to back off the broadband gyro and D-term low-pass filters. Less blanket filtering means less phase delay, and less delay means tighter, more locked-in flight. That is the whole win: remove the noise exactly where it lives, and stop paying the latency tax everywhere else.
Turning it on
It is a short list in the CLI, but the order of operations matters – no eRPM, no RPM filter:
set dshot_bidir = ON set motor_poles = 14 set rpm_filter_harmonics = 3 set rpm_filter_min_hz = 100 set rpm_filter_q = 500 save
Enable bidirectional DShot, set your pole count honestly, and the RPM filter has what it needs. Check the eRPM values in the motors tab look sane (and scale correctly with throttle), and you are flying on real data instead of guesses.
It is a lovely bit of engineering when you step back: one wire, doing double duty, feeding a bank of filters that retune themselves thousands of times a second. From the humble 16-bit frame we started with years ago, to this. Not bad for a protocol some people said was a solution looking for a problem.
Header photo: “A10 13L Hacker Brushless Motor with Propellor” by Dcaldero8983, CC BY-SA 3.0, via Wikimedia Commons (cropped).