第一次用逻辑分析仪抓Dshot波形的时候我盯着屏幕上的脉冲序列愣了半天这玩意儿看起来就是一堆PWM但跟我认知的PWM又完全对不上。后来才明白Dshot协议的物理层长得像PWM本质上却在用脉冲宽度传数字帧解码思路完全不一样。这篇文章记录我用STM32CubeMXDMA生成Dshot600波形、同时用逻辑分析仪做逆向解析的完整过程内容包括协议帧结构、GCR位编码、CRC校验怎么反推以及调试中踩过的几个坑希望对同样在折腾Dshot、电调和STM32的人有帮助。1. 为什么Dshot调试不能靠肉眼得靠逻辑分析仪1.1 它长着PWM的脸却干着数字传输的活普通PWM大家都很熟周期固定占空比连续变化接收端通过测量脉宽得到模拟量比如舵机角度、电机转速、LED亮度。它的特点是“连续”占空比可以在0%到100%之间任意变化接收端测到的也是一个模拟值。Dshot不一样。它虽然也是高低电平的方波但每个bit周期内只用“窄脉冲”和“宽脉冲”两种状态表示逻辑0和逻辑1。一帧信息固定是16bit原始数据经过GCR展开后变成一长串物理脉冲。接收端不是去测占空比而是按位时间把比特流解出来再拼成数字帧。换句话说Dshot是“拿PWM当载体做数字串行通信”本质跟UART、SPI更接近。这也是为什么不能用示波器看占空比的方式去调它得用逻辑分析仪按位展开、按帧切分、按编码规则还原。1.2 从“看起来正常”到“实际不可用”的教训我第一次接触Dshot听人说“STM32定时器PWM就能发”于是直接在CubeMX里找了个定时器通道配成PWM输出拿杜邦线接到电调上。结果电调响了一声电机纹丝不动。我以为是电调坏了换了一个还是一样。借来逻辑分析仪抓了一段信号才发现问题我压根没改ARR定时器还是默认65535的周期位时间比Dshot600慢了整整几十倍电调自然不认。这种问题光靠猜永远猜不出来看波形一眼就破案了。所以这篇文章里无论是配置还是排错核心思路就一句话先用逻辑分析仪拿到真实波形再去改代码。这也是标题里“逆向解析”的意义——不依赖某个现成库从物理波形往上反推协议。2. 动手逆向前先把Dshot的协议骨架摸清2.1 帧结构11位油门 1位遥测 4位CRCDshot单向帧固定16bit位分配如下位段长度含义Bit 0 - 1011 bit油门值范围0 - 2047Bit 111 bittelemetry request向电调请求遥测数据Bit 12 - 154 bitCRC校验油门值的含义要特别注意0表示电机停转1到47一般是无效区或特殊指令48到2047才对应0%到100%油门。很多新手直接把0到100的百分比塞进11位油门里结果电机要么不动要么乱转就是没做这个区间映射。举个例子如果想让电机输出约50%油门实际发送的油门值不是1024而是throttle 48 (uint16_t)((float)(2047 - 48) * 0.5f);也就是大约1048。这个换算很多Dshot驱动库里都封装好了但自己实现的时候最容易漏。2.2 位时序和GCR展开一个数据位变成4个物理位Dshot150、300、600、1200的区别在于原始bit时间Dshot速率bit时间物理时隙时间按1:4展开Dshot1506.667 us1.667 usDshot3003.333 us0.833 usDshot6001.667 us0.417 usDshot12000.833 us0.208 us每个原始bit在线路上被GCRGroup Code Recording展开成4个物理时隙。常见规则是逻辑0展开成1000也就是高电平占1/4逻辑1展开成1110也就是高电平占3/4。接收端不看绝对电平只看高电平宽度属于“短”还是“长”再把4个物理时隙还原成一个原始bit。这就是为什么在STM32的PWMDMA实现里一个原始bit对应一个CCR比较值一帧16bit展开之后是64个CCR值。后面配置DMA缓冲区的长度依据就在这里。需要提醒的是不同固件对GCR物理时隙的占空比实现略有差异有的用1/3和2/3有的用1/4和3/4。这两种都能工作因为接收端有阈值窗口只要0脉冲明显短、1脉冲明显长就行。配置完用逻辑分析仪实测确认即可不用纠结具体是35%还是37.5%。2.3 CRC校验算法真没有想象中复杂Dshot的4位CRC不是查表那种复杂CRC而是简单的折返异或。前12位数据油门11位 遥测1位放在一个变量里计算方式如下uint8_t dshot_crc(uint16_t value) { return (value ^ (value 4) ^ (value 8)) 0x0F; }这里的value就是前12位拼成的数。因为只是相邻位段做异或再取低4位完全可以手算甚至在逻辑分析仪上解码完之后现场验证非常方便。后面逆向章节会用到这个公式。3. 逻辑分析仪的配置与波形抓取3.1 采样率选多高才合适逻辑分析仪采样率是第一道坎。Dshot600的物理时隙约0.417us0脉冲高电平时间按1/4算只有0.417us左右1脉冲约1.25us。如果采样率不够短脉冲会被采成“一个尖”根本没法区分0和1。我的建议很直接采样率对Dshot600的效果8 MS/s不够0脉冲只有3到4个样本边缘量化误差太大16 MS/s勉强可以能看但容易误判适合粗调25 MS/s以上推荐0脉冲有10个以上样本可以稳定解码100 MS/s抓Dshot1200才需要这个级别现在市面上几十块、上百块的逻辑分析仪多数支持24M或25M采样率抓Dshot600和Dshot1200基本够用。如果手头只有8M采样率的设备建议从Dshot150或Dshot300开始调先把协议跑通再上高速率。3.2 接线、共地和触发设置接线其实很简单信号线接逻辑分析仪的CH1然后GND必须和电调/飞控的GND共地。不共地的话波形会在0到5V之间来回飘或者出现大量毛刺看着像坏了实际只是参考地不对。STM32的PWM输出是3.3V逻辑电平逻辑分析仪直接采样没问题。如果是5V电调信号多数逻辑分析仪也能承受但为了稳妥最好确认一下通道的耐压范围。触发建议设置成CH1上升沿触发采集时间抓1ms左右。Dshot600一帧大约64个物理时隙乘以0.417us也就是26.7us1ms足够覆盖好几帧。这样你能同时看到帧与帧之间的空闲低电平这对判断帧间隔非常重要。3.3 抓到的波形怎么看空闲时信号是低电平然后会突然出现一串脉冲。关键看两个东西两个相邻上升沿的时间间隔应该约等于物理时隙时间0.417us4个物理时隙合起来约等于一个原始bit时间1.667us。每个脉冲的高电平宽度只存在两种值短的是逻辑0长的是逻辑1。用PulseView或者Saleae的界面拉两个光标就能量出来。如果看到两个上升沿间隔不是0.417us而是忽长忽短说明你的定时器时钟或者ARR配错了或者帧结构根本不是Dshot而是某个普通PWM。4. 逆向解码实操从波形反推油门值4.1 按位时隙切割波形拿到一段正常波形后第一步是把64个物理时隙切出来。手动一个个数太痛苦我一般这样处理把逻辑分析仪导出的CSV数据丢进脚本统计每个高电平和低电平的持续时间按宽度分类成“短”和“长”再转成0/1序列。比如用Python简单处理import csv edges [] with open(dshot.csv) as f: for row in csv.reader(f): timestamp float(row[0]) level int(row[1]) edges.append((timestamp, level)) pulses [] for i in range(1, len(edges)): dt edges[i][0] - edges[i-1][0] level edges[i][1] pulses.append((level, dt)) # 短脉冲约0.417us长脉冲约1.25us设定阈值0.8us分类 bits [] for level, dt in pulses: if dt 0.8e-6: bits.append(1) else: bits.append(0) print(.join(str(b) for b in bits))这段脚本会把物理时隙直接压成64个0/1字符。实际处理时你可能还会遇到上升沿、下降沿成对出现的问题需要按电平变化把“高电平宽度”和“低电平宽度”分开统计。但核心思路就是这样先量宽度再分类。4.2 用GCR查表还原16位帧拿到64个物理bit后按照“每4个bit还原1个原始bit”的GCR规则就能得到16bit的原始数据帧。这里有个坑如果你手头没有现成的GCR表用别人的代码又怕版本不对怎么办我的做法是做一个“已知量测”先在STM32代码里固定发送一个已知油门值比如1048然后在逻辑分析仪上抓到对应的物理序列。因为你知道这个油门值的完整帧是什么你就能反推出GCR表至少能推出这几个表项对应的编码。这个反推过程才是“逆向解析”的精髓。不需要一开始就拿着完整码表而是通过几个已知样本把映射关系重建出来再用更多样本验证。建议至少测试三个不同油门值比如100、1048、2000这样能覆盖更多位模式。4.3 手动校验CRC确认协议还原成功还原出16bit原始帧之后一定要做CRC校验。这一步能防止你把位顺序搞反或者GCR编码认错。假设我从波形里还原出的帧是0x830B二进制就是1000001100001011。按Dshot帧结构拆分Bit 0到10油门值二进制10000011000正好是1048Bit 11遥测请求位0Bit 12到15CRC0xB也就是11然后用CRC公式验证。前12位拼成valuevalue (1048 1) | 0 0x830 crc (0x830 ^ (0x830 4) ^ (0x830 8)) 0x0F (0x830 ^ 0x083 ^ 0x008) 0x0F 0x8BB 0x0F 0xBCRC算出来是0xB和帧里拆出来的CRC完全一致。到这一步基本可以确认你解码出来的数据是对的协议逆向链路全部打通。如果CRC对不上先别怀疑CRC公式优先检查两个地方一是GCR展开后的4bit顺序是高位在前还是低位在前二是帧的位序是从最高位开始发送还是从最低位开始发送。这两个顺序问题在Dshot实现里很容易搞混用已知油门值和CRC一验就能定位。5. CubeMXDMA把Dshot波形从STM32里稳定送出来5.1 定时器时钟与ARR的计算逻辑Dshot之所以适合用定时器PWMDMA是因为它本质上就是“每个bit周期更新一次CCR”。如果你用中断里改CCRDshot600下1.667us就要进一次中断CPU基本干不了别的用DMA则完全在硬件里完成CPU只负责在帧间准备缓冲区。以STM32F103为例定时器时钟来自APB1实际最大可以到72MHz。Dshot600的原始bit时间是1.667us一个原始bit的计数周期 72MHz * 1.667us ≈ 120所以ARR取119PSC取0。这样计数器从0数到119正好一个原始bit时间。CCR值决定高电平宽度。常见配置里#define DSHOT600_BIT_TICKS 120 #define DSHOT600_ZERO_TICKS 45 #define DSHOT600_ONE_TICKS 90也就是说0脉冲高电平约45个计数周期也就是0.625us1脉冲高电平约90个计数周期也就是1.25us。这个配置走的是“0高1/3位时间、1高2/3位时间”的风格在Dshot600下实测电调识别稳定。如果你的芯片定时器时钟不是72MHz或者要跑Dshot150/300/1200先按相同逻辑算计数周期数 定时器时钟频率 / Dshot速率 ARR 计数周期数 - 1 CCR0 ≈ 计数周期数 / 3 CCR1 ≈ 计数周期数 * 2 / 3算出来可能不是整数取整即可。Dshot接收端的阈值窗口有一定余量差几个计数周期不影响识别。5.2 CubeMX图形化配置步骤用CubeMX配这套东西很快核心几步如下RCC里选HSE外部晶振把SYSCLK配到72MHz。进入Clock Configuration找到APB1总线时钟确认TIM3的Timer Clock是72MHz而不是36MHz。左侧选TIM3Channel1模式设为PWM Generation CH1。Prescaler填0Counter Period填119Pulse初始值随便填个90。切到DMA SettingsAdd一个DMA请求来源选TIM3_UP。Direction选Memory To PeripheralMode选NormalPeripheral Data Width和Memory Data Width都选Half Word。Peripheral Increment关掉Memory Increment打开。如果想在帧发送完成后得到通知可以在NVIC里打开DMA中断。生成代码。这里最容易翻车的是APB1分频后Timer Clock到底是不是72MHz。F103的APB1最高36MHz但定时器时钟在APB1分频时自动x2所以Timer Clock能到72MHz。CubeMX时钟树里会直接显示务必亲眼看一眼。5.3 DMA缓冲区与代码框架DMA缓冲区里放的不是“电平状态”而是每个物理时隙对应的CCR值。一帧16bit原始数据GCR展开后64个物理时隙所以缓冲区必须定义成64个16位元素uint16_t dshot_dma_buffer[64];构建帧的代码大致长这样#define DSHOT_FRAME_LENGTH 64 uint8_t dshot_crc(uint16_t value) { return (value ^ (value 4) ^ (value 8)) 0x0F; } void dshot_build_frame(uint16_t throttle, uint8_t telemetry) { uint16_t value ((throttle 0x07FF) 1) | (telemetry ? 1 : 0); uint16_t crc dshot_crc(value); uint16_t frame (value 4) | crc; for (int i 0; i 16; i) { uint16_t bit (frame (15 - i)) 0x01; uint32_t gcr bit ? 0xEEEEu : 0x1000u; // 0b1110 / 0b1000 示例具体以码表为准 for (int j 0; j 4; j) { dshot_dma_buffer[i * 4 j] (gcr 0x8000) ? DSHOT600_ONE_TICKS : DSHOT600_ZERO_TICKS; gcr 1; } } } void dshot_send(void) { HAL_TIM_PWM_Start_DMA(htim3, TIM_CHANNEL_1, (uint32_t *)dshot_dma_buffer, DSHOT_FRAME_LENGTH); }注意两次发送之间如果DMA配置的是Normal模式一帧发完DMA就停了需要重新调用HAL_TIM_PWM_Start_DMA。我一般会在DMA传输完成中断里做标记主循环根据标记决定是否发送下一帧。6. 调试中最容易炸的几个坑6.1 定时器时钟以为对实际差了一倍我在这上面浪费过一晚上。CubeMX里APB1显示36MHz很多人直接把ARR按36MHz来算结果输出的Dshot时序全部偏慢一倍。实际上F103的定时器时钟在APB1分频时会自动翻倍到72MHz但前提是你得看Clock Configuration里TIMx Clock那一栏。判断方法很简单用逻辑分析仪抓波形量相邻上升沿之间的间隔。理想Dshot600原始bit时间是1.667us如果你量到3.333us说明定时器时钟被当成36MHz算了ARR小了一倍赶紧回去查时钟树。6.2 DMA模式和缓冲区长度搞错DMA Mode选Normal还是Circular行为差别很大。Normal模式发完64个值自动停适合单帧触发Circular模式会不停地循环发送同一个缓冲区适合飞控那种需要固定帧率连续输出的场景。缓冲区长度必须是64个16位值。有人只看Dshot帧是16bit就把长度写成16结果DMA只搬了前四分之一的波形后面全是乱的。逻辑分析仪抓出来会发现一帧只有16个脉冲远不够64个物理时隙。另外重新发送时Normal模式要先HAL_TIM_PWM_Stop_DMA再HAL_TIM_PWM_Start_DMA不要图省事直接调Start。DMA传输完成会自动禁用直接Start在某些HAL版本里会报错或者状态异常。6.3 帧间隔没有留够电调直接摆烂Dshot帧和帧之间必须有足够的空闲低电平。如果一帧发完紧接着发下一帧或者帧尾没有低电平电调会把两帧当成一个乱七八糟的连续流根本解不出有效数据。有人想通过把缓冲区末尾几个CCR设为0来“制造低电平”这在PWM模式下不一定可靠。CCR0时比较器在计数为0时不会产生有效翻转输出状态取决于PWM模式很可能不是你想要的恒低电平。最稳妥的做法帧发送完成后直接停止PWM或把GPIO拉低再延时几十微秒到几百微秒再发下一帧。FPV飞行控制里常见的做法是保持约1kHz到4kHz的发送频率也就是每250us到1ms发一帧中间天然有大量空闲时间。6.4 采样率不够误把好波形判断成坏波形有一种“假故障”很迷惑人电调实际输出正常但由于逻辑分析仪采样率太低Dshot600的0脉冲被采得只剩下一个尖或者相邻脉冲被糊成一个长高电平看起来像丢帧、像时序错乱。遇到这种情况先别急着改代码把逻辑分析仪采样率调到25M以上再抓一次。如果波形变清晰了说明问题在观测工具不在目标系统。调试协议类信号采样率边缘化会让排查方向整个跑偏。6.5 调试时的安全建议最后提醒一句测试Dshot输出和电调连接时尽量把螺旋桨拆掉或者用一个小功率电机测试别在满油门状态下离电机太近。Dshot调试中如果CRC或位序搞错电调可能输出意外的高油门信号电机突然高速旋转非常危险。我自己的习惯是先在逻辑分析仪上确认一帧正确再接电调和电机实测。调试这类协议我做事的顺序基本固定先用逻辑分析仪把波形解释清楚再动手改代码遇到问题时准备几个已知油门值做对照实验比如100、1048、2000分别抓波形、解CRC哪一个不一致就说明哪个环节出了问题。养成这个习惯之后很多看起来玄学的Dshot问题最后都变成了简单的参数错误。