Foreword
很久之前写过DSHOT,这次捡起来实现双向DSHOT
https://elmagnifico.tech/2020/06/03/Dshot-STM32-PWM-HAL/
单线DSHOT由于单线复用,实现起来非常麻烦,要考虑的东西很多。而相关文章又非常少,只能挨个翻看git issues,搜索零星的信息组合在一起。
某种程度上说DSHOT+BLH ESC有点类似现在的FOC驱动器,只不过是比较挫、弱化版、单向版的FOC,任何使用BLH ESC的电机都能使用的。
当然实际的DSHOT,无法精准控制电机的转速,得到的电机转速也是有限制的,不能趋近于0
Bidirectional DSHOT
https://github.com/betaflight/betaflight/pull/8554#issuecomment-512507625
总结一下,这是Bidirectional DSHOT初次实现,反转了正常DSHOT协议,并且有些和标准的DSHOT实现是不一样的,其实可以认为是一个变种DSHOT,后续这种DSHOT也被BLH的最新固件支持,变成了DSHOT的基础实现。其实这个PR还有一个点也非强,他希望兼容了BLH_S,以前老的16位单片机也能用上DSHOT,也能使用转速反馈,非常牛逼了。
Bidirectional DSHOT的一些特性
- 单线、双向传输
- Telemetry 只有转速信息,并且传回的内容是eRPM/100以后的值
- Telemetry 返回的数据是GCR格式的,且起始位必然是拉低的
- 校验位计算最后需要翻转4位BIT
- Bidirectional DSHOT的最终数据输出电平是和普通DSHOT相反的
- DSHOT 600 及以上不太支持,实现困难,TIM采样困难
- 32位 BLH 需要32.7版本以后的
- 8或者16位 BLH_S 需要使用Bluejay版本的固件
- ESC上电以后需要稳定输出一会才能正确回复Telemetry,否则可能单次请求Telemetry不会回复
- 一旦发送的是Bidirectional DSHOT帧,那么必然会回复Telemetry,无论是否开启Telemetry
注意,由于Bidirectional DSHOT的帧是需要反转的,所以0,1的表示在这里反过来了
根据BLH最新的测试版说明,32.92.2版本扩展了Bidirectional DSHOT的Telemetry信息,包含温度、电压、电流信息了,不再是单独的转速了。
死区
显然,用单线做收发,不可避免地要遇到死区的问题,PWM的死区比普通GPIO好一点,是相对优化过的,但是普通GPIO,从输出转换到输入,需要一定时间,并且连接的器件也要同时切换,否则有可能出现小短路的情况。
名词解释
- GCR,一种编码方式,他扩大了传输的数据量并且提高了传输速率,但是更方便硬件去做检测和识别了
- bit bang/bit-bang 其实就是GPIO模拟DSHOT,比如软I2C,软SPI,这种用普通GPIO模拟某种协议的方式,就叫bit-bang
- 3x,一般来说如果你想解码一个信号,要求你获取信号的频率是原始信号的x倍,你才能得到一个比较好的解码效果,这里默认使用3倍采样
- 5/4,GCR编码后使得原来的4bits变成了5bits,所以传输速度就提升了
- bidir DSHOT,双向DSHOT,也就是单线DSHOT,实现转速可读
- Run-length limited,其实就是在带宽有限的通信链路上,如何组织数据,从而提高数据传输速度
- eRPM,电调回传的是磁极数的间隔时间,电机上磁极一定是成对出现的,一般电机是14或者12个
- RPM,转速每分钟
- RPS,转速每秒
实现方式
一般来说DSHOT都是通过PWM+DMA实现的,但是众所周知H7以下的STM32板子DMA通道都是固定的,如果一开始设计的时候没有考虑到这个事情,就很有可能会出现DMA冲突,PWM+DMA实现不了,进而导致DSHOT无法使用,也就没法推进了。
看了一下老的issues,发现他们提出来了一种解决办法,通过TIM+普通GPIO+DMA实现DSHOT,这相当于是说就算PWM用不了,他也能直接做GPIO去实现,或者直接利用空闲的GPIO实现DSHOT,而不需要被DMA或者PWM通道绑定给卡住。
实际的实现是通过开启TIM的update事件作为DMA的触发器,触发去读取GPIO的IDR寄存器,进而将整个数据缓存下来,同理如果要输出也是可以使用类似的方法。多数情况下IO都会选择同组的,那么只需要一路TIM就能实现了,对于资源消耗也是相当小的,GPIO的DMA选择性就非常多了。
代码分析
驱动结构
motor.c
是总体电机控制的驱动
// End point initialization is called from mixerInit before motorDevInit; can't use vtable...
void motorInitEndpoints(const motorConfig_t *motorConfig, float outputLimit, float *outputLow, float *outputHigh, float *disarm, float *deadbandMotor3dHigh, float *deadbandMotor3dLow)
{
checkMotorProtocol(&motorConfig->dev);
if (isMotorProtocolEnabled()) {
if (!isMotorProtocolDshot()) {
analogInitEndpoints(motorConfig, outputLimit, outputLow, outputHigh, disarm, deadbandMotor3dHigh, deadbandMotor3dLow);
}
#ifdef USE_DSHOT
else {
dshotInitEndpoints(motorConfig, outputLimit, outputLow, outputHigh, disarm, deadbandMotor3dHigh, deadbandMotor3dLow);
}
#endif
}
}
在这里调用了dshot endpoints的初始化,可以认为只是初始化了一下前期使用的一些变量
void motorDevInit(const motorDevConfig_t *motorDevConfig, uint16_t idlePulse, uint8_t motorCount)
{
memset(motors, 0, sizeof(motors));
bool useUnsyncedPwm = motorDevConfig->useUnsyncedPwm;
if (isMotorProtocolEnabled()) {
if (!isMotorProtocolDshot()) {
motorDevice = motorPwmDevInit(motorDevConfig, idlePulse, motorCount, useUnsyncedPwm);
}
#ifdef USE_DSHOT
else {
#ifdef USE_DSHOT_BITBANG
if (isDshotBitbangActive(motorDevConfig)) {
motorDevice = dshotBitbangDevInit(motorDevConfig, motorCount);
} else
#endif
{
motorDevice = dshotPwmDevInit(motorDevConfig, idlePulse, motorCount, useUnsyncedPwm);
}
}
#endif
}
if (motorDevice) {
motorDevice->count = motorCount;
motorDevice->initialized = true;
motorDevice->motorEnableTimeMs = 0;
motorDevice->enabled = false;
} else {
motorNullDevice.vTable = motorNullVTable;
motorDevice = &motorNullDevice;
}
}
接着就是电机设备的初始,这里就会根据实际使用的协议来初始化了。
motorDevice_t *dshotBitbangDevInit(const motorDevConfig_t *motorConfig, uint8_t count)
{
dbgPinLo(0);
dbgPinLo(1);
motorPwmProtocol = motorConfig->motorPwmProtocol;
bbDevice.vTable = bbVTable;
motorCount = count;
bbStatus = DSHOT_BITBANG_STATUS_OK;
#ifdef USE_DSHOT_TELEMETRY
useDshotTelemetry = motorConfig->useDshotTelemetry;
#endif
memset(bbOutputBuffer, 0, sizeof(bbOutputBuffer));
for (int motorIndex = 0; motorIndex < MAX_SUPPORTED_MOTORS && motorIndex < motorCount; motorIndex++) {
const unsigned reorderedMotorIndex = motorConfig->motorOutputReordering[motorIndex];
const timerHardware_t *timerHardware = timerGetConfiguredByTag(motorConfig->ioTags[reorderedMotorIndex]);
const IO_t io = IOGetByTag(motorConfig->ioTags[reorderedMotorIndex]);
uint8_t output = motorConfig->motorPwmInversion ? timerHardware->output ^ TIMER_OUTPUT_INVERTED : timerHardware->output;
bbPuPdMode = (output & TIMER_OUTPUT_INVERTED) ? BB_GPIO_PULLDOWN : BB_GPIO_PULLUP;
#ifdef USE_DSHOT_TELEMETRY
if (useDshotTelemetry) {
output ^= TIMER_OUTPUT_INVERTED;
}
#endif
if (!IOIsFreeOrPreinit(io)) {
/* not enough motors initialised for the mixer or a break in the motors */
bbDevice.vTable.write = motorWriteNull;
bbDevice.vTable.updateStart = motorUpdateStartNull;
bbDevice.vTable.updateComplete = motorUpdateCompleteNull;
bbStatus = DSHOT_BITBANG_STATUS_MOTOR_PIN_CONFLICT;
return NULL;
}
int pinIndex = IO_GPIOPinIdx(io);
bbMotors[motorIndex].pinIndex = pinIndex;
bbMotors[motorIndex].io = io;
bbMotors[motorIndex].output = output;
#if defined(STM32F4)
bbMotors[motorIndex].iocfg = IO_CONFIG(GPIO_Mode_OUT, GPIO_Speed_50MHz, GPIO_OType_PP, bbPuPdMode);
#elif defined(STM32F7) || defined(STM32G4) || defined(STM32H7)
bbMotors[motorIndex].iocfg = IO_CONFIG(GPIO_MODE_OUTPUT_PP, GPIO_SPEED_FREQ_LOW, bbPuPdMode);
#endif
IOInit(io, OWNER_MOTOR, RESOURCE_INDEX(motorIndex));
IOConfigGPIO(io, bbMotors[motorIndex].iocfg);
if (output & TIMER_OUTPUT_INVERTED) {
IOLo(io);
} else {
IOHi(io);
}
// Fill in motors structure for 4way access (XXX Should be refactored)
motors[motorIndex].io = bbMotors[motorIndex].io;
}
return &bbDevice;
}
dshotBitbang设备初始化,根据实际的电机数量,依次获取对应的IO和Timer专门用于处理Bitbang,之后就是对IO进行初始化。通过代码看到实际使用的Timer就是Tim1
和Tim8
,他们的通道数量比较多,适合做电机使用
const timerHardware_t bbTimerHardware[] = {
#if defined(STM32F4) || defined(STM32F7)
#if !defined(STM32F411xE)
DEF_TIM(TIM8, CH1, NONE, TIM_USE_NONE, 0, 1),
DEF_TIM(TIM8, CH2, NONE, TIM_USE_NONE, 0, 1),
DEF_TIM(TIM8, CH3, NONE, TIM_USE_NONE, 0, 1),
DEF_TIM(TIM8, CH4, NONE, TIM_USE_NONE, 0, 0),
#endif
DEF_TIM(TIM1, CH1, NONE, TIM_USE_NONE, 0, 1),
DEF_TIM(TIM1, CH1, NONE, TIM_USE_NONE, 0, 2),
DEF_TIM(TIM1, CH2, NONE, TIM_USE_NONE, 0, 1),
DEF_TIM(TIM1, CH3, NONE, TIM_USE_NONE, 0, 1),
DEF_TIM(TIM1, CH4, NONE, TIM_USE_NONE, 0, 0),
#elif defined(STM32G4) || defined(STM327H)
// XXX TODO: STM32G4 and STM32H7 can use any timer for pacing
// DMA request numbers are duplicated for TIM1 and TIM8:
// - Any pacer can serve a GPIO port.
// - For quads (or less), 4 pacers can cover the worst case scenario of
// 4 motors scattered across 4 different GPIO ports.
// - For hexas (and larger), more channels may become necessary,
// in which case the DMA request numbers should be modified.
DEF_TIM(TIM8, CH1, NONE, TIM_USE_NONE, 0, 0, 0),
DEF_TIM(TIM8, CH2, NONE, TIM_USE_NONE, 0, 1, 0),
DEF_TIM(TIM8, CH3, NONE, TIM_USE_NONE, 0, 2, 0),
DEF_TIM(TIM8, CH4, NONE, TIM_USE_NONE, 0, 3, 0),
DEF_TIM(TIM1, CH1, NONE, TIM_USE_NONE, 0, 0, 0),
DEF_TIM(TIM1, CH2, NONE, TIM_USE_NONE, 0, 1, 0),
DEF_TIM(TIM1, CH3, NONE, TIM_USE_NONE, 0, 2, 0),
DEF_TIM(TIM1, CH4, NONE, TIM_USE_NONE, 0, 3, 0),
后续的motor所有操作就是基于VTable来的了
static motorVTable_t bbVTable = {
.postInit = bbPostInit,
.enable = bbEnableMotors,
.disable = bbDisableMotors,
.isMotorEnabled = bbIsMotorEnabled,
.updateStart = bbUpdateStart,
.write = bbWrite,
.writeInt = bbWriteInt,
.updateComplete = bbUpdateComplete,
.convertExternalToMotor = dshotConvertFromExternal,
.convertMotorToExternal = dshotConvertToExternal,
.shutdown = bbShutdown,
};
唯一需要注意的地方就是,dshot需要先updateStart,再写值,然后再complete
void motorWriteAll(float *values)
{
#ifdef USE_PWM_OUTPUT
if (motorDevice->enabled) {
#if defined(USE_DSHOT) && defined(USE_DSHOT_TELEMETRY)
if (!motorDevice->vTable.updateStart()) {
return;
}
#endif
for (int i = 0; i < motorDevice->count; i++) {
motorDevice->vTable.write(i, values[i]);
}
motorDevice->vTable.updateComplete();
}
#else
UNUSED(values);
#endif
}
如何开启一次传输
static bool bbUpdateStart(void)
{
#ifdef USE_DSHOT_TELEMETRY
if (useDshotTelemetry) {
#ifdef USE_DSHOT_TELEMETRY_STATS
const timeMs_t currentTimeMs = millis();
#endif
// 首先是等待上一次telemetry的完成
// Wait for telemetry reception to complete before decode
bool telemetryPending;
bool telemetryWait = false;
const timeUs_t startTimeUs = micros();
do {
telemetryPending = false;
for (int i = 0; i < usedMotorPorts; i++) {
telemetryPending |= bbPorts[i].telemetryPending;
}
telemetryWait |= telemetryPending;
// 如果超时了,就直接退出了,本次写失败
if (cmpTimeUs(micros(), startTimeUs) > DSHOT_TELEMETRY_TIMEOUT) {
return false;
}
} while (telemetryPending);
if (telemetryWait) {
DEBUG_SET(DEBUG_DSHOT_TELEMETRY_COUNTS, 2, debug[2] + 1);
} else {
for (int motorIndex = 0; motorIndex < MAX_SUPPORTED_MOTORS && motorIndex < motorCount; motorIndex++) {
#ifdef USE_DSHOT_CACHE_MGMT
// 这里是处理ST的cache问题,将DMA缓存区域无效化,防止后续数据出问题
// Only invalidate the buffer once. If all motors are on a common port they'll share a buffer.
bool invalidated = false;
for (int i = 0; i < motorIndex; i++) {
if (bbMotors[motorIndex].bbPort->portInputBuffer == bbMotors[i].bbPort->portInputBuffer) {
invalidated = true;
}
}
if (!invalidated) {
SCB_InvalidateDCache_by_Addr((uint32_t *)bbMotors[motorIndex].bbPort->portInputBuffer,
DSHOT_BB_PORT_IP_BUF_CACHE_ALIGN_BYTES);
}
#endif
#ifdef STM32F4
uint32_t rawValue = decode_bb_bitband(
bbMotors[motorIndex].bbPort->portInputBuffer,
bbMotors[motorIndex].bbPort->portInputCount - bbDMA_Count(bbMotors[motorIndex].bbPort),
bbMotors[motorIndex].pinIndex);
#else
// 解析上一次的值
uint32_t rawValue = decode_bb(
bbMotors[motorIndex].bbPort->portInputBuffer,
bbMotors[motorIndex].bbPort->portInputCount - bbDMA_Count(bbMotors[motorIndex].bbPort),
bbMotors[motorIndex].pinIndex);
#endif
if (rawValue == DSHOT_TELEMETRY_NOEDGE) {
DEBUG_SET(DEBUG_DSHOT_TELEMETRY_COUNTS, 1, debug[1] + 1);
continue;
}
DEBUG_SET(DEBUG_DSHOT_TELEMETRY_COUNTS, 0, debug[0] + 1);
dshotTelemetryState.readCount++;
// 简单判断是否正确,更新Telmetry状态
if (rawValue != DSHOT_TELEMETRY_INVALID) {
// Check EDT enable or store raw value
if ((rawValue == 0x0E00) && (dshotCommandGetCurrent(motorIndex) == DSHOT_CMD_EXTENDED_TELEMETRY_ENABLE)) {
dshotTelemetryState.motorState[motorIndex].telemetryTypes = 1 << DSHOT_TELEMETRY_TYPE_STATE_EVENTS;
} else {
dshotTelemetryState.motorState[motorIndex].rawValue = rawValue;
}
} else {
dshotTelemetryState.invalidPacketCount++;
}
#ifdef USE_DSHOT_TELEMETRY_STATS
updateDshotTelemetryQuality(&dshotTelemetryQuality[motorIndex], rawValue != DSHOT_TELEMETRY_INVALID, currentTimeMs);
#endif
}
dshotTelemetryState.rawValueState = DSHOT_RAW_VALUE_STATE_NOT_PROCESSED;
}
#endif
}
for (int i = 0; i < usedMotorPorts; i++) {
bbDMA_Cmd(&bbPorts[i], DISABLE);
bbOutputDataClear(bbPorts[i].portOutputBuffer);
}
return true;
}
基本可以看到start仅仅是对上一次telemetry的处理,开启一次新的DSHOT传输还不在这里。
实际执行DSHOT写入的地方是在bbWriteInt
static void bbWriteInt(uint8_t motorIndex, uint16_t value)
{
bbMotor_t *const bbmotor = &bbMotors[motorIndex];
if (!bbmotor->configured) {
return;
}
// fetch requestTelemetry from motors. Needs to be refactored.
motorDmaOutput_t * const motor = getMotorDmaOutput(motorIndex);
bbmotor->protocolControl.requestTelemetry = motor->protocolControl.requestTelemetry;
motor->protocolControl.requestTelemetry = false;
// If there is a command ready to go overwrite the value and send that instead
if (dshotCommandIsProcessing()) {
value = dshotCommandGetCurrent(motorIndex);
if (value) {
bbmotor->protocolControl.requestTelemetry = true;
}
}
bbmotor->protocolControl.value = value;
// 准备数据
uint16_t packet = prepareDshotPacket(&bbmotor->protocolControl);
bbPort_t *bbPort = bbmotor->bbPort;
#ifdef USE_DSHOT_TELEMETRY
if (useDshotTelemetry) {
bbOutputDataSet(bbPort->portOutputBuffer, bbmotor->pinIndex, packet, DSHOT_BITBANG_INVERTED);
} else
#endif
{
bbOutputDataSet(bbPort->portOutputBuffer, bbmotor->pinIndex, packet, DSHOT_BITBANG_NONINVERTED);
}
}
到这里也只是准备好了DSHOT准备写入的数据,实际发送还在后面
static void bbUpdateComplete(void)
{
// If there is a dshot command loaded up, time it correctly with motor update
if (!dshotCommandQueueEmpty()) {
if (!dshotCommandOutputIsEnabled(bbDevice.count)) {
return;
}
}
#ifdef USE_DSHOT_CACHE_MGMT
for (int motorIndex = 0; motorIndex < MAX_SUPPORTED_MOTORS && motorIndex < motorCount; motorIndex++) {
// Only clean each buffer once. If all motors are on a common port they'll share a buffer.
bool clean = false;
for (int i = 0; i < motorIndex; i++) {
if (bbMotors[motorIndex].bbPort->portOutputBuffer == bbMotors[i].bbPort->portOutputBuffer) {
clean = true;
}
}
if (!clean) {
SCB_CleanDCache_by_Addr(bbMotors[motorIndex].bbPort->portOutputBuffer, MOTOR_DSHOT_BUF_CACHE_ALIGN_BYTES);
}
}
#endif
for (int i = 0; i < usedMotorPorts; i++) {
bbPort_t *bbPort = &bbPorts[i];
// 切换到输出模式
#ifdef USE_DSHOT_TELEMETRY
if (useDshotTelemetry) {
if (bbPort->direction == DSHOT_BITBANG_DIRECTION_INPUT) {
bbPort->inputActive = false;
bbSwitchToOutput(bbPort);
}
} else
#endif
{
#if defined(STM32G4)
// Using circular mode resets the counter one short, so explicitly reload
bbSwitchToOutput(bbPort);
#endif
}
// 开启发送DMA
bbDMA_Cmd(bbPort, ENABLE);
}
// 开启TIM DMA
lastSendUs = micros();
for (int i = 0; i < usedMotorPacers; i++) {
bbPacer_t *bbPacer = &bbPacers[i];
bbTIM_DMACmd(bbPacer->tim, bbPacer->dmaSources, ENABLE);
}
}
DSHOT校验和
查看Betaflight中关于DSHOT部分的源码,默认开启了DSHOT_TELEMETRY
就会使用Bidirectional DSHOT
FAST_CODE uint16_t prepareDshotPacket(dshotProtocolControl_t *pcb)
{
uint16_t packet;
ATOMIC_BLOCK(NVIC_PRIO_DSHOT_DMA) {
packet = (pcb->value << 1) | (pcb->requestTelemetry ? 1 : 0);
pcb->requestTelemetry = false; // reset telemetry request to make sure it's triggered only once in a row
}
// 这里求解出来普通Dshot的后4位的异或和
// compute checksum
unsigned csum = 0;
unsigned csum_data = packet;
for (int i = 0; i < 3; i++) {
csum ^= csum_data; // xor data by nibbles
csum_data >>= 4;
}
// append checksum
#ifdef USE_DSHOT_TELEMETRY
// 一旦使用了Telemetry 就会反转后四位
if (useDshotTelemetry) {
csum = ~csum;
}
#endif
csum &= 0xf;
packet = (packet << 4) | csum;
return packet;
}
飞控发送 DSHOT帧,但是最低的4bits=其他4bits做异或和,再取反
如果ESC检测到了这个情况,也就是最低4bits是反的,就会切换模式在同一根线上发送一个Telemetry帧
转速计算
然后这个Telemetry包是这么解析的,Telemetry的原始数据,一共是21bits,其中第一bit一定是0,表示数据开始,而之后紧跟的20bits,其实是每4bits使用GCR转换成的,也就是每5bit解析成一个4bits,然后重新组装
0 aaaa bbbbb fffff ddddd 原始21bits
e e e m m m m m m m m m c c c c 解码后原始16bits
e e e m m m m m m m m m c c c c 解码后原始16bits
e e e m m m m m m m m m 校验成功以后的转速数据 12bits
后4个c是异或和的校验码
前间3个e是预周期的位移量,叫做左移位数E
中间9个m是预周期值,这个值需要左移E次,才能得到实际的周期数值
如果仅仅使用12bits来表示转速,还是有点不够,最低转速太高了(主要是这里定义的是两个电极之间的延迟,而不是直接的转速,这样实时性比较高,12bit最大就是4096us,算下来大概最低能检测转速是34,14电极,还是很快的),表示0转速传的值是0xFFF,而非0
static uint32_t dshot_decode_eRPM_telemetry_value(uint16_t value)
{
// eRPM range
if (value == 0x0fff) {
return 0;
}
// Convert value to 16 bit from the GCR telemetry format (eeem mmmm mmmm)
value = (value & 0x01ff) << ((value & 0xfe00) >> 9);
if (!value) {
return DSHOT_TELEMETRY_INVALID;
}
// Convert period to erpm * 100
return (1000000 * 60 / 100 + value / 2) / value;
}
通过次方表示,这样实现了仅仅用12位表示接近16位整数的范围的值,实际能表示大概为1-65408,对应可以测量到的电机最小转速就是1000000/65408=15.28886
每秒 对于14电极的电机来说,大概相当于是转了2圈
而平常对转速的描述是分钟,所以还需要*60
,就变成了eRPM,至于代码里为什么还多了一个value/2
,就不知道了
传回的内容是eRPM/100以后的值,转换成rpm
// Used with serial esc telem as well as dshot telem
uint32_t erpmToRpm(uint16_t erpm)
{
// rpm = (erpm * 100) / (motorConfig()->motorPoleCount / 2)
return (erpm * 200) / motorConfig()->motorPoleCount;
}
bit bang实现
这里主要是参考一下bit bang是怎么实现的
#define MOTOR_DSHOT_BIT_PER_SYMBOL 1
#define MOTOR_DSHOT_STATE_PER_SYMBOL 3 // Initial high, 0/1, low
#define MOTOR_DSHOT_BIT_HOLD_STATES 3 // 3 extra states at the end of transmission required to allow ESC to sample the last bit correctly.
#define MOTOR_DSHOT_FRAME_BITS 16
#define MOTOR_DSHOT_FRAME_TIME_NS(rate) ((MOTOR_DSHOT_FRAME_BITS / MOTOR_DSHOT_BIT_PER_SYMBOL) * MOTOR_DSHOT_SYMBOL_TIME_NS(rate))
#define MOTOR_DSHOT_TELEMETRY_WINDOW_US (30000 + MOTOR_DSHOT_FRAME_TIME_NS(rate) * (1.1)) / 1000
#define MOTOR_DSHOT_CHANGE_INTERVAL_NS(rate) (MOTOR_DSHOT_SYMBOL_TIME_NS(rate) / MOTOR_DSHOT_STATE_PER_SYMBOL)
#define MOTOR_DSHOT_GCR_CHANGE_INTERVAL_NS(rate) (MOTOR_DSHOT_CHANGE_INTERVAL_NS(rate) * 5 / 4)
// DMA buffers
// Note that we are not sharing input and output buffers,
// as output buffer is only modified for middle bits
// DMA output buffer:
// DShot requires 3 [word/bit] * 16 [bit] = 48 [word]
extern uint32_t bbOutputBuffer[MOTOR_DSHOT_BUF_CACHE_ALIGN_LENGTH * MAX_SUPPORTED_MOTOR_PORTS];
这里主要理解bbOutputBuffer
是怎么设计的
首先DSHOT每一帧一共是16位数据,输出的时候,每一位,用一个SYMBOL
表示。
一个SYMBOL
又有3个状态,也就是初始-高状态、数据状态、低状态。
其实它对应的就是DSHOT的帧
把bit1和bit0都分解成三份,第一份必然是高,第二份则是1和0的区分,第三份必然是0,当然这样会导致和原始的DSHOT帧的占空比略微不同(原版DSHOT是75%和25%的占空比,现在变成了66%和33%)
每一帧的结尾为了让ESC可以完整采样,又额外加了一个SYMBOL
,不过这个是全高的状态
- 主要是如果MCU在输出结束以后立马切换到输入模式,可能会造成传输线上的电平立马被拉低,这可能会导致ESC那边还没采样到最后一位,这个数据就被破坏了,为了确保传输质量,多传输了1bit。
这样得到最后bbOutputBuffer
的长度是51bits
// DMA input buffer
// (30us + <frame time> + <slack>) / <input sampling clock period>
// <frame time> = <DShot symbol time> * 16
// Temporary size for DS600
// <frame time> = 26us
// <sampling period> = 0.44us
// <slack> = 10%
// (30 + 26 + 3) / 0.44 = 134
// In some cases this was not enough, so we add 6 extra samples
#define DSHOT_BB_PORT_IP_BUF_LENGTH 140
通过注释大概可以知道,当发完一个DSHOT帧以后,有30us的时间去切换输入->输出。
然后就是等待Telemetry,拿到以后,还要空一点点时间给ESC切回去,等下一个帧。
static uint32_t decode_bb_value(uint32_t value, uint16_t buffer[], uint32_t count, uint32_t bit)
{
#ifndef DEBUG_BBDECODE
UNUSED(buffer);
UNUSED(count);
UNUSED(bit);
#endif
#define iv 0xffffffff
// First bit is start bit so discard it.
value &= 0xfffff;
// 这里是GCR的字典匹配
static const uint32_t decode[32] = {
iv, iv, iv, iv, iv, iv, iv, iv, iv, 9, 10, 11, iv, 13, 14, 15,
iv, iv, 2, 3, iv, 5, 6, 7, iv, 0, 8, 1, iv, 4, 12, iv };
// 每5位转换成4位的实际值
uint32_t decodedValue = decode[value & 0x1f];
decodedValue |= decode[(value >> 5) & 0x1f] << 4;
decodedValue |= decode[(value >> 10) & 0x1f] << 8;
decodedValue |= decode[(value >> 15) & 0x1f] << 12;
// 计算校验和
uint32_t csum = decodedValue;
csum = csum ^ (csum >> 8); // xor bytes
csum = csum ^ (csum >> 4); // xor nibbles
if ((csum & 0xf) != 0xf || decodedValue > 0xffff) {
#ifdef DEBUG_BBDECODE
memcpy(dshotTelemetryState.inputBuffer, sequence, sizeof(sequence));
for (unsigned i = 0; i < count; i++) {
bbBuffer[i] = !!(buffer[i] & (1 << bit));
}
#endif
value = DSHOT_TELEMETRY_INVALID;
} else {
// 计算正确,移除校验和的部分
value = decodedValue >> 4;
}
return value;
}
由于是3倍采样,所以还有一个函数是decode_bb_bitband
用来从采样数据里筛选出来目标帧,并将其转换成raw数据
uint32_t decode_bb_bitband( uint16_t buffer[], uint32_t count, uint32_t bit)
{
uint8_t startMargin;
#ifdef DEBUG_BBDECODE
memset(sequence, 0, sizeof(sequence));
sequenceIndex = 0;
#endif
uint32_t value = 0;
bitBandWord_t* p = (bitBandWord_t*)BITBAND_SRAM((uint32_t)buffer, bit);
bitBandWord_t* b = p;
bitBandWord_t* endP = p + (count - MIN_VALID_BBSAMPLES);
// Jump forward in the buffer to just before where we anticipate the first zero
p += preambleSkip;
// 寻找头 第一bit必然是0,所以找一个下降沿
// Eliminate leading high signal level by looking for first zero bit in data stream.
// Manual loop unrolling and branch hinting to produce faster code.
while (p < endP) {
if (__builtin_expect((!(p++)->value), 0) ||
__builtin_expect((!(p++)->value), 0) ||
__builtin_expect((!(p++)->value), 0) ||
__builtin_expect((!(p++)->value), 0)) {
break;
}
}
startMargin = p - b;
DEBUG_SET(DEBUG_DSHOT_TELEMETRY_COUNTS, 3, startMargin);
if (p >= endP) {
// not returning telemetry is ok if the esc cpu is
// overburdened. in that case no edge will be found and
// BB_NOEDGE indicates the condition to caller
return DSHOT_TELEMETRY_NOEDGE;
}
int remaining = MIN(count - (p - b), (unsigned int)MAX_VALID_BBSAMPLES);
bitBandWord_t* oldP = p;
uint32_t bits = 0;
// 重新标定结尾
endP = p + remaining;
#ifdef DEBUG_BBDECODE
sequence[sequenceIndex++] = p - b;
#endif
while (endP > p) {
// 寻找上升沿
do {
// Look for next positive edge. Manual loop unrolling and branch hinting to produce faster code.
if(__builtin_expect((p++)->value, 0) ||
__builtin_expect((p++)->value, 0) ||
__builtin_expect((p++)->value, 0) ||
__builtin_expect((p++)->value, 0)) {
break;
}
} while (endP > p);
if (endP > p) {
#ifdef DEBUG_BBDECODE
sequence[sequenceIndex++] = p - b;
#endif
// 找到一个上升沿
// A level of length n gets decoded to a sequence of bits of
// the form 1000 with a length of (n+1) / 3 to account for 3x
// oversampling.
const int len = MAX((p - oldP + 1) / 3, 1);
bits += len;
value <<= len;
value |= 1 << (len - 1);
oldP = p;
// 上升沿记录一下
// 找下降沿
// Look for next zero edge. Manual loop unrolling and branch hinting to produce faster code.
do {
if (__builtin_expect(!(p++)->value, 0) ||
__builtin_expect(!(p++)->value, 0) ||
__builtin_expect(!(p++)->value, 0) ||
__builtin_expect(!(p++)->value, 0)) {
break;
}
} while (endP > p);
if (endP > p) {
#ifdef DEBUG_BBDECODE
sequence[sequenceIndex++] = p - b;
#endif
// 找到下降沿 记录一下
// A level of length n gets decoded to a sequence of bits of
// the form 1000 with a length of (n+1) / 3 to account for 3x
// oversampling.
const int len = MAX((p - oldP + 1) / 3, 1);
bits += len;
value <<= len;
value |= 1 << (len - 1);
oldP = p;
}
}
}
// 如果找到的bits 少于18,说明不正确
if (bits < 18) {
return DSHOT_TELEMETRY_NOEDGE;
}
// 由于最后一bit可能是高,但是上面的流程可能记录不到,所以会有一个额外的上升沿
// length of last sequence has to be inferred since the last bit with inverted dshot is high
const int nlen = 21 - bits;
if (nlen < 0) {
return DSHOT_TELEMETRY_NOEDGE;
}
#ifdef DEBUG_BBDECODE
sequence[sequenceIndex] = sequence[sequenceIndex] + (nlen) * 3;
sequenceIndex++;
#endif
// The anticipated edges were observed
preambleSkip = startMargin - DSHOT_TELEMETRY_START_MARGIN;
if (nlen > 0) {
value <<= nlen;
value |= 1 << (nlen - 1);
}
return decode_bb_value(value, buffer, count, bit);
}
bit bang 驱动
void bbGpioSetup(bbMotor_t *bbMotor);
void bbTimerChannelInit(bbPort_t *bbPort);
void bbDMAPreconfigure(bbPort_t *bbPort, uint8_t direction);
void bbDMAIrqHandler(dmaChannelDescriptor_t *descriptor);
void bbSwitchToOutput(bbPort_t * bbPort);
void bbSwitchToInput(bbPort_t * bbPort);
void bbTIM_TimeBaseInit(bbPort_t *bbPort, uint16_t period);
void bbTIM_DMACmd(TIM_TypeDef* TIMx, uint16_t TIM_DMASource, FunctionalState NewState);
void bbDMA_ITConfig(bbPort_t *bbPort);
void bbDMA_Cmd(bbPort_t *bbPort, FunctionalState NewState);
int bbDMA_Count(bbPort_t *bbPort);
主要接口都在这里,Betaflight底层实现了一个bitbang的标准库还有一个lowlevel的库
这里就是一些基本的硬件配置,主要就是通过DMA设置GPIO或者读取GPIO
TIM在这里触发的是UpdateEvent,而不是PWM通道,通过Update触发DMA传值给GPIO
在bbWriteInt
中可以看到,给过来的DSHOT数据帧还需要再次被处理,会将整个DSHOT数据翻转
static void bbOutputDataSet(uint32_t *buffer, int pinNumber, uint16_t value, bool inverted)
{
uint32_t middleBit;
// 使用telemetery 就需要翻转中间bit
if (inverted) {
// 是写入GPIO BSRR 所以低位写1置位
middleBit = (1 << (pinNumber + 0));
} else {
// 高位写1 复位
middleBit = (1 << (pinNumber + 16));
}
for (int pos = 0; pos < 16; pos++) {
// 这里则是翻转BIT
if (!(value & 0x8000)) {
buffer[pos * 3 + 1] |= middleBit;
}
value <<= 1;
}
}
DMA中断处理程序,可以看到如果开启了Telemetry那么会在DMA完成以后切换到输入模式。
FAST_IRQ_HANDLER void bbDMAIrqHandler(dmaChannelDescriptor_t *descriptor)
{
dbgPinHi(0);
bbPort_t *bbPort = (bbPort_t *)descriptor->userParam;
bbDMA_Cmd(bbPort, DISABLE);
bbTIM_DMACmd(bbPort->timhw->tim, bbPort->dmaSource, DISABLE);
if (DMA_GET_FLAG_STATUS(descriptor, DMA_IT_TEIF)) {
while (1) {};
}
DMA_CLEAR_FLAG(descriptor, DMA_IT_TCIF);
#ifdef USE_DSHOT_TELEMETRY
if (useDshotTelemetry) {
if (bbPort->direction == DSHOT_BITBANG_DIRECTION_INPUT) {
bbPort->telemetryPending = false;
#ifdef DEBUG_COUNT_INTERRUPT
bbPort->inputIrq++;
#endif
} else {
#ifdef DEBUG_COUNT_INTERRUPT
bbPort->outputIrq++;
#endif
// Switch to input
bbSwitchToInput(bbPort);
bbPort->telemetryPending = true;
bbTIM_DMACmd(bbPort->timhw->tim, bbPort->dmaSource, ENABLE);
}
}
#endif
dbgPinLo(0);
}
小结
bitbang通过TIM的update事件,启动DMA,当DMA被触发时,读取或者写入值到GPIO的寄存器,从而实现GPIO模拟PWM。
启动DMA写入时,DMA只是单次执行,执行完成以后立马将GPIO切换到输入模式,输入模式下的DMA也是单次执行。
当DMA读取完成以后,立马将GPIO再切换回输出模式,继续DMA写,开始下一轮循环。
读取到的GPIO变化后,再通过GCR解码,获取到解码后的Telemetry,然后计算出来erpm,再转成rpm。
Run-length limited
Run-length limited 这个概念国内搜起来很容易和游程搞混,其实是不一样的东西,游程在这里其实和Dshot GCR没啥关系
游程
游程,一个序列中取值相同,连在一起的元素合起来叫做一个游程,连续元素的个数,叫做这个游程的长度
0 0 0 1 1 1 1 0 1 0 1 1 0 0 1
0 1 0 1 0 1 0 1
比如上述,一共15个bit,也就游程长度是8
其中长度为4的是:1111
其中长度为3的是:000
其中长度为2的是:11,00
其中长度为1的是:0,1,0,1
游程长度编码(RLC,Run-length Code)
现在使用游程多半是用来压缩数据的,以前使用游程可能是为了兼容硬件上的某些情况而不得不用。游程长度编码是十分简单的压缩方式,编码速度也非常快,核心就是通过去除冗余字符,来减少数据文件所占存储空间的目的
简单来说,游程长度编码的主要任务是统计连续相同字符的个数,解码时要根据字符及连续相同字符的个数,恢复原来的数据
一般来说使用(n,m)
来表示,就是说有m个形式为n的字符,对于比特流之类的东西,就可以用这种方式编码,来减少传输量
这些概念其实和DSHOT这里要用的没啥关系,但是由于描述很像,容易搞混。
RLL
RLL,其实是以前的模电和数电转换的时候,都是通过电平变化来识别0或者1的,由于机械或者物理性能限制,导致这种方式想要表示0或者1,他们的物理实现频率是不同的。比如你1秒可以写1000次1,而写0的频率只能达到500次,在这种情况下限制波特率的就是写0的次数了,想要最大化带宽,就必须尽可能少的写0,RLL在此时就有用了。
RLL(n,m),指定两个连续1之间,最少有n个0,最多有m个0。其实RLL还有2个参数,剩下这个两个其实就是编码前的bit数量,一般用来说明传输速度的改变
RLL还有一个特性,在调制解调中,只有电平变化,才表示bit发生了改变,否则认为是0,如果没有这个前提,下面的图示根本看不懂
常见的编码方式
FM:(0,1) RLL
FM:(0,1) RLL,这种方式看起来只是多了一个1
,实际上这个1可以作为时钟的1
,从而可以形成差分编码的方式,这种方式让编码变长了,但是传输效率提升了。
其实是当年FM调配的物理实现有些不同,物理上写1的频率是写0的两倍,所以这里增加1
刚好满足了写1的速度,让两边可以同步控制
0 -> 10
1 -> 11
通过RLL(0,1)编码后,两个连续1之间最少是0个0 11 11
,最多是1个0 11 10 11
10110010 -> 1110111110101110
图中下面的尖峰是表示电平翻转,而平表示没产生变化,红点则是每个数据之间的分割点,刚好是编码后的样子,同时符合RLL特性的
GCR:(0,2) RLL
GCR:(0,2) RLL,这个是IBM提出来的一种编码方式,主要是用来提高传输的速率,通过这种编码方式,将最多相邻的0,控制在了2个以内,从而提高了传输速度
这相邻的0,在后续的解包中提供了非常重要的作用,当0过多的时候,由于采样比较低,就容易出现无法识别清楚当前到底是出现了2个0还是1个0,同理当0最多只有2个的时候,低采样时就能轻易区分1个0和2个0的情况。
0000 -> 11001
0001 -> 11011
0010 -> 10010
0011 -> 10011
0100 -> 11101
0101 -> 10101
0110 -> 10110
0111 -> 10111
1000 -> 11010
1001 -> 01001
1010 -> 01010
1011 -> 01011
1100 -> 11110
1101 -> 01101
1110 -> 01110
1111 -> 01111
比如传输下面的数据
1011 0010 -> 01011 10010
就变成了图中所示情况
这种编码方式,如果要进行检测,那么只需要在每个沿时间进行检测即可,比如下图中的绿色箭头所在即是沿
检测
红点是每一段的分割点,这个沿
不进行检测。可以看到沿
刚好是编码中隐藏的原始数据,电平发生翻转表示1,电平不动表示0
有了这里的想法,用GPIO的跳变检测就更加简单了
其他相关问题
如果ESC种设置了,Auto Telemetry
,那么如果不使用Dshot协议,这个Telemetry也会自动返回相关信息,所以对其他协议更友好了。
https://github.com/iNavFlight/inav/issues/5165
单线Dshot 由于检测时间比较少,Dshot 600 出现了大量报错,导致Betaflight直接不再支持Dshot 600
https://github.com/bitdump/BLHeli/issues/464
https://github.com/betaflight/betaflight/issues/9886#issuecomment-655085419
实测图像
网上想找个DSHOT各种图像还是挺困难的,要么看不清,要么也没说明具体数值是多少,看的一脸懵逼,我给出一些实例,方便参考对比
- 注意需要连续给Bidirectional DSHOT帧,并且让出传输线,ESC开始阶段是不能回复Telemetry的,需要正确输出一段时间电调解锁以后才能得到回复,电调音乐会阻止Telemetry的输出,所以实际输出是电调解锁音乐之后,刚开始只用一次帧来触发回复怎么都得不到,后来连续给就正常了。
DSHOT 300
以下所有图像都是基于DSHOT 300来说明的,其他频率会有所不同,需要单独测试
这是正常的DSHOT 300,没有反转校验位、没有请求Telemetry的48
油门输出
Bidirectional DSHOT 300
这是反转后、没有请求Telemetry的图像,油门值还是48
Bidirectional DSHOT 300 with Telemetry
这是反转后并且请求Telemetry的,并且ESC回复了Telemetry,油门值还是48
解析Telemetry图像
发送完Bidirectional DSHOT帧以后,大概就是30us的时间,留给IO切换到输入模式,并等待Telemetry返回
整个Telemetry信号时间大概是47us,实际情况可能话要多一点
第一bit必然是0,表示开始传输,所以跳过,然后根据GCR的编码方式,每次电平跳变就是数据1,否则是数据0。
此时是在0速度的情况下,获取的Telemetry,所以最后解码得到的数值是1111 1111 1111 0000
后4位都是0,校验结果是F
符合要求。
换算以后是0x0FFF
,在转换成转速的时候,这个特殊值,直接转换成了0
,符合当前的实际情况
Timing
检测和回复Telemetry各有一个关键时间
Telemetry的回复时间是发送DSHOT帧之后,31us左右,就会回复,如果此时IO没切换到输入模式,则会丢失。
第二个关键时间,是Telemetry回复以后,必须快速再切换回输出模式,比如上图中可以看到虽然正确切换到了输入,但是输入模式保留时间过长了,导致ESC无法识别DSHOT帧,电机就不会解锁,自然也就没有任何输出。
这个图是极限切换时间,就是当Telemetry回复以后,2.4us内就切换到了输入模式,并且继续发下一个DSHOT帧,此时ESC可以正常响应。
但是实际上不应该这么极限,最少要给ESC一帧的反应时间,也就是3.3us
实际第一个DSHOT帧和第二个DSHOT帧之间需要保证时间在87-106us,否则会出现ESC长时间都无法解锁,更不会回复Telemetry。
由于回复响应是31us左右,而Telemetry的回复帧长度大概在52us左右,那么剩下留给输入切换到输出的时间就是4-23us,尽量不要卡这种极限,容易出现无法识别的情况。
以上数值都是基于我的DSHOT300来测试的,我此时的DSHOT单bit是3.3us周期
DSHOT600的情况下,一个循环大概是249.5us,换算下来DSHOT控制和获取Telemetry的频率是4000Hz (其实换成DSHOT300也是大概4000Hz,这个主要是受限于主控的GPIO和TIM在输入输出切换的时间,实际上具体是DSHOT反倒没多大影响)
新驱动设计
bit bang是通过三倍采样,来读取下面的每一个电变化的(红圈部分)
但是其实我可以通过绿色箭头标明的沿来判断,当前数值,直接就能读取到原始bit中的所有1,其余位就自动是0了。
黄色箭头虽然也是沿,但是由于是4bit的分割点,所以不纳入计算。
这样的话,完全不需要3倍的采样timer,不会受到DSHOT频率的影响,无论多快的频率都能处理。
核心想法:
通过GPIO的上升沿、下降沿中断,记录所有1,并记录1产生的时间,通过5bits时间,可以排除掉黄色的下降沿或者上升沿,只需要一个100ns定时器即可。
如果定时器超时了,那么就认为本次失败了,直接关闭中断,切换回输出模式。只需要将每个1之间的时间除以固定的区间,就能得到1的位置了。
这个想法有点问题,IO中断非常频繁,会影响到其他地方,所以还是DMA来干这个事情更好点
Summary
到这里差不多整个Bidirectional DSHOT基本就解析完了,日后如果要移植双向DSHOT,可以参考
Quote
https://github.com/betaflight/betaflight/pull/8554
https://zhuanlan.zhihu.com/p/520878086
https://en.wikipedia.org/wiki/Run-length_limited#GCR:_(0,2)_RLL
https://github.com/iNavFlight/inav/issues/2710
https://github.com/iNavFlight/inav/issues/5165
https://github.com/iNavFlight/inav/pull/5674
https://youtu.be/sPktdBh2Gcw
https://github.com/mathiasvr/bluejay/issues/1
https://github.com/bitdump/BLHeli/issues/513
https://betaflight.com/docs/wiki/archive/DSHOT-ESC-Protocol
https://betaflight.com/docs/development/Dshot
https://betaflight.com/docs/tuning/4.2-Tuning-Notes#dshot-settings
https://github.com/bitdump/BLHeli/issues/685
https://brushlesswhoop.com/dshot-and-bidirectional-dshot/