STM32 SBUS接收机解析:DMA循环接收与IDLE中断实战
调试遥控接收机这事儿几乎每个做四轴、遥控车、机械臂、无人船项目的朋友都会碰到。我最早用的是传统PWM接收机一个通道一根线16通道就得接16根线飞控板子引脚都被占满了还容易接错。后来换成支持SBUS的接收机一根信号线把所有通道数据全部搞定项目整洁太多了。不过SBUS这根线并不是想象中那么好伺候它用的是100000bps非标波特率、8E2校验8个数据位、偶校验、2个停止位最关键的是输出信号是反相的。网上很多帖子把协议贴出来却没有把接收这条链路讲透尤其是HAL库下怎么把DMA循环接收和IDLE空闲中断配合起来大多数人只用了普通逐字节接收CPU被串口中断搞得手忙脚乱。这篇文章我就从0到1拆解整条链路CubeMX怎么配、DMA循环模式怎么用、IDLE中断为什么能精准卡帧边界、状态机怎么编才不出错。适合正在做遥控项目、准备把接收机数据接进飞控或者想弄明白串口DMA接收的嵌入式和机器人方向的同学。1. SBUS协议先讲透后面代码才有依据1.1 SBUS帧周期与帧结构SBUS协议是Futaba提出来的遥控接收机串行通信协议现在几乎所有主流航模接收机都在用。它的数据帧固定为25个字节以100Hz的频率输出也就是每10ms出一帧。10ms这个数字值得先记下来后面设计整个接收方案时所有的时间预算都围绕它展开。一帧25字节的布局如下第1字节是起始字节0x0F接下来22个字节是通道数据第24字节是标志位字节第25字节是结束字节0x00。22个通道字节里塞进了16个通道、每个通道11bit的信息算一下正好是16乘以11等于176bit而22字节等于176bit严丝合缝。标志位字节里面包含第17通道、第18通道的数字量还有frame lost和failsafe标志位。解析SBUS最核心的事情就是把这25字节从串口数据流里干净利落地切出来再把176bit按11bit一组切开还原成16路通道值。这里要特别提醒SBUS帧头是0x0F帧尾是0x00这只是最基础的判定条件。由于通道数据区里的字节是随遥控器摇杆位置变化的任何值都可能出现包括0x0F和0x00所以单纯判断帧头帧尾并不保险需要状态机配合帧计数来保证正确性。后面写状态机时我会把这个问题讲清楚。1.2 SBUS信号是反相的不能直接接串口RX很多第一次接SBUS的朋友会犯一个错误拿USB转TTL直接去读接收机的SBUS输出结果串口助手上一片乱码或者完全没数据。这是因为SBUS在物理层输出的是反相TTL电平正常UART空闲时是高电平SBUS信号反过来空闲时是低电平起始位、停止位的极性全部对调。如果直接把反相信号接到单片机的RX引脚单片机以为线路一直处于Break状态什么有效数据都收不到。解决办法有三种。第一种最常用加一个硬件反相器比如74HC14、74HC04这种施密特反相门或者在信号线上用三极管做一级反相。第二种是买那种内部已经处理过反相的接收机或者一体化的SBUS转串口模块。第三种是看单片机型号部分较新的STM32系列USART带RXINV位可以在寄存器里直接翻转接收极性比如部分F7、H7系列就有但F1、F4这些老将没有只能外部反相。我在项目里用的方案是74HC14反相器一个通道只要一颗芯片稳定可靠也不会引入额外的波特率误差。把接收机的SBUS脚接74HC14输入输出再接STM32的RX引脚中间注意共地。1.3 100000bps传输一帧需要多少时间100000bps是非标波特率但算传输时间很直接。每个字节在8E2格式下有1个起始位、8个数据位、1个偶校验位、2个停止位总共11bit所以一个字节用时为11除以100000等于0.00011秒即0.11ms。25个字节就是一帧2.75ms。帧周期10ms帧传输只占2.75ms剩下的7.25ms整个串口线路上没有任何电平变化处于空闲状态。这个7.25ms的空闲时间非常关键正是UART的IDLE空闲中断可以发挥作用的场景。硬件检测到数据线在收到最后一个字节之后持续处于空闲状态就会触发IDLE中断换句话说IDLE中断天然就对应SBUS的一帧结束时刻。把这个时刻作为解析的切入点就能做到帧边界清晰不需要自己去数25字节。2. 方案选型为什么是DMA加IDLE这种组合2.1 逐字节中断接收的问题在哪里先看最朴素的做法串口每收到一个字节触发一次RXNE中断在中断里把字节存进数组同时计数器加一。对于SBUS来说一帧25字节每10ms来一帧也就是平均每0.4ms就要进一次中断。这个频率看起来不算夸张但问题在于中断里做的事情越多主循环被抢占的时间就越长。如果项目里还有IIC、SPI、定时器编码器读取或者其他实时性要求高的逻辑频繁的串口中断就可能造成其他时序抖动。更尴尬的是如果主循环处理不及时数组下标很容易错位代码又得花精力处理“半帧”“跨帧”这类情况收数据的逻辑被人为搞复杂了。DMA的引入思路刚好反过来把串口收到的字节直接通过DMA搬运到内存缓冲区全程不需要CPU干预收完由硬件搬运CPU可以在主循环里忙别的事情。等DMA积累了数据CPU再一次性处理处理效率和响应确定性都更好。2.2 IDLE中断如何精准标记一帧结束IDLE中断是UART外设的一个硬件事件当RX引脚上收到数据后如果检测到线路空闲超过一个完整字节的时间就触发一次性中断。SBUS每帧之间就有约7.25ms的空闲远大于1个字节的时间0.11ms所以每帧结束后必然触发IDLE中断。这样一组合就形成了非常漂亮的接收模型DMA负责把串口字节持续写入缓冲区IDLE中断负责告诉CPU“这一帧的字节已经完整到齐了”。CPU在IDLE中断里只需要做一件事计算DMA上次处理位置和当前位置之间有多少新字节把这些字节按顺序喂给状态机即可。整个过程不依赖“数够25个字节”这种脆弱的判定因为帧边界是硬件检测出来的。2.3 三种接收方案对比我把常见方案放在一起对比过这样选型逻辑更清楚方案CPU占用帧边界判定代码复杂度适合场景逐字节RXNE中断接收高每字节一次中断自己数字节简单但易错数据量小、无其他实时要求的项目定时器超时加DMA正常模式中定时器中断定时器判定空闲超时中没有IDLE可用或者协议空闲时间不明DMA循环模式加IDLE中断低每帧仅一次事件硬件IDLE事件中等固定周期高波特率串行协议尤其SBUS第三种方案最终赢得我的选择。核心原因是它把中断频率从每字节一次降到了每帧一次CPU负担小了一个数量级。同时DMA循环模式让缓冲区永远处于接收状态不用担心DMA停了之后丢字节。代价是理解门槛稍微高一点但只要理解了CNDTR计数器的物理含义后面写代码非常顺手。3. CubeMX工程配置与缓冲区设计3.1 串口DMA参数这样设置不会返工以STM32F103C8T6为例使用USART2接收SBUS。打开CubeMX在RCC里把外部高速晶振HSE打开时钟树配置到72MHz主频。USART2模式选择异步通信Asynchronous波特率栏直接手动输入100000数据位选8奇偶校验选Even停止位选2。下拉列表里没有100000这个标准值是可以直接敲进去的。关键的一步在NVIC设置中一定要把USART2的全局中断使能打开。很多人这里漏掉导致后面IDLE中断永远不触发。背后的原理是IDLE标志属于UART外设事件必须由USART全局中断把标志读出来再由HAL库调用回调函数。DMA中断虽然也起着搬运数据的作用但IDLE事件不经过DMA中断通道。接着去DMA Settings添加USART2_RX方向PeripheralToMemory模式选Circular数据宽度外设和内存都设Byte外设地址不增、内存地址增。这个内存地址增就是“循环写缓冲区”的体现。生成代码后在USER CODE区域里调用HAL_UARTEx_ReceiveToIdle_DMA启动接收。3.2 缓冲区为什么取64而不是25缓冲区大小的选择有个很容易被忽略的门道。DMA循环模式下当传输数量到达缓冲区的一半时会产生半满中断事件如果这个事件触发了HAL_UARTEx_RxEventCallback而回调里又没有区分事件类型就会把半帧数据当成完整帧来处理导致解析紊乱。SBUS一帧25字节缓冲区取64字节时半满阈值为32字节大于25字节。这样一帧数据结束后先到的还是IDLE事件不会触发半满事件。缓冲区不建议取32因为半满阈值是16一帧还没收完就触发一次乱七八糟。缓冲区也不必取太大64或者128足够了太大的缓冲区并不会带来额外收益反而浪费内存。STM32F103的RAM本来就紧张64字节是经过权衡的结果。3.3 CNDTR差值计算的核心思路理解DMA循环接收的关键是DMA计数器CNDTR。CNDTR是一个16位递减计数器DMA每搬运一个字节就减一减到0后循环模式自动重装回缓冲区大小继续搬运。所以某一时刻的CNDTR值表示DMA下一次将写入缓冲区的位置。要计算从上一次处理到现在又新到了多少字节核心公式是received (previous_ndtr - current_ndtr BUFFER_SIZE) % BUFFER_SIZE这个公式对回绕做了处理。举个直观例子缓冲区64字节上次处理后CNDTR是50现在CNDTR是20说明DMA从50递减到20共搬运了30个字节。如果中间CNDTR从0回绕过一次这个公式同样能正确处理。正因为有回绕差值计算必须加上缓冲区大小再取模这是DMA循环接收代码里最容易写错的地方。为什么不建议直接用HAL_UARTEx_RxEventCallback回调里的Size参数因为该参数在DMA循环模式下设计语义是“距初始接收启动的偏移量”不是“本次新到的增量字节数”。第一帧时它可能还正常后续帧一旦DMA回绕这个数值就会变成从缓冲区头部重新计算的偏移量和实际新增字节对不上。我自己第一版代码直接用了Size跑到第二帧就发现数据错乱改成CNDTR差值后立刻稳定。所以后面给的所有代码都围绕CNDTR差值展开。4. 核心代码DMA循环接收加状态机解析4.1 启动接收与RxEventCallback的完整写法定义接收缓冲区、上次CNDTR快照变量以及一个帧就绪标志#define SBUS_RX_BUFFER_SIZE 64 #define SBUS_FRAME_SIZE 25 uint8_t sbus_rx_buffer[SBUS_RX_BUFFER_SIZE]; uint16_t sbus_rx_last_ndtr; volatile uint8_t sbus_frame_ready;启动接收的函数在初始化和每次重启接收时调用。关键点是在调用HAL_UARTEx_ReceiveToIdle_DMA之前先把当前CNDTR的值读出来作为锚点这样第一次回调也能用统一的差值公式HAL_StatusTypeDef SBUS_StartReceive(UART_HandleTypeDef *huart) { sbus_rx_last_ndtr __HAL_DMA_GET_COUNTER(huart-hdmarx); return HAL_UARTEx_ReceiveToIdle_DMA(huart, sbus_rx_buffer, SBUS_RX_BUFFER_SIZE); }回调函数里做的事情很纯粹计算新字节数把每个字节按顺序交给状态机。这里注意字节索引的起点应该是上一次的CNDTR快照值而不是当前值因为数据是从旧位置依次写到新位置之前的void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { uint16_t cur_ndtr; uint16_t received; uint16_t i; uint16_t idx; if (huart-Instance ! USART2) { return; } cur_ndtr (uint16_t)__HAL_DMA_GET_COUNTER(huart-hdmarx); received (uint16_t)((sbus_rx_last_ndtr - cur_ndtr SBUS_RX_BUFFER_SIZE) % SBUS_RX_BUFFER_SIZE); for (i 0; i received; i) { idx (uint16_t)((sbus_rx_last_ndtr i) % SBUS_RX_BUFFER_SIZE); sbus_feed_byte(sbus_rx_buffer[idx]); } sbus_rx_last_ndtr cur_ndtr; }这段代码的一个重要细节是在遍历缓冲区前保存旧值遍历完再更新sbus_rx_last_ndtr。如果把更新操作提前下一字节的索引就全部错位了。中断回调里只做“搬运字节到状态机”这件事不做通道值的算术解析更不做printf这类耗时操作确保中断退出足够快。4.2 状态机怎么编才不出错状态机我分了三个状态等待帧头、接收通道数据区、等待帧尾。typedef enum { SBUS_STATE_START 0, SBUS_STATE_DATA, SBUS_STATE_END } sbus_state_t; static sbus_state_t sbus_state SBUS_STATE_START; static uint8_t sbus_frame[SBUS_FRAME_SIZE]; static uint8_t sbus_frame_idx; uint8_t sbus_frame_ready; uint16_t sbus_channels[16]; uint8_t sbus_ch17; uint8_t sbus_ch18; uint8_t sbus_frame_lost; uint8_t sbus_failsafe;喂字节函数如下void sbus_feed_byte(uint8_t byte) { switch (sbus_state) { case SBUS_STATE_START: if (byte 0x0F) { sbus_frame[0] byte; sbus_frame_idx 1; sbus_state SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: if (sbus_frame_idx 23) { sbus_frame[sbus_frame_idx] byte; } if (sbus_frame_idx 23) { sbus_state SBUS_STATE_END; } break; case SBUS_STATE_END: if (byte 0x00) { sbus_frame[24] byte; sbus_frame_ready 1; } sbus_state SBUS_STATE_START; break; default: sbus_state SBUS_STATE_START; break; } }这个状态机的容错逻辑值得多解释两句。DATA状态里无论收到什么字节都往数组里放直到凑满22个字节后跳转END。如果这一帧因为干扰导致数据区字节数量不对FUZZY出现在DATA中并不同步复位而是继续累积。正常情况下25字节都齐了END状态确认0x00才给出ready标志。如果帧尾不是0x00说明这一帧有问题状态机无条件回到START等待下一个0x0F。有个稍微极端的情况上一帧失效后最后一个字节恰好是0x0F会被当成下一帧的帧头导致下一帧错位。实际测试中这种概率很低因为0x0F作为数据字节出现的概率是1/256而且还需要连续两次错位才会真正影响加上SBUS一帧只有一个数据字节可能是0x0F但大概率不会正好出现在帧尾。如果项目对可靠性要求极高可以再加超时复位逻辑即超过20ms没有收到完整帧直接把状态机拉回START。这个我在后面常见问题里展开。4.3 通道数据提取与标志位解析通道提取我用逐位搬运而不是常见的相邻字节拼接原因很简单可读性强不容易在11bit边界上算错。每一帧通道数据从字节1开始连续存了176bit第0通道占据bit0到bit10第1通道占据bit11到bit21以此类推。逐位计算uint16_t sbus_get_channel(uint8_t *data, uint8_t ch) { uint32_t bit_pos ch * 11u; uint16_t value 0; uint16_t mask 1u; uint8_t b; for (b 0; b 11u; b) { uint32_t byte_idx bit_pos 3; uint8_t bit_idx bit_pos 0x07; if (data[byte_idx] (1u bit_idx)) { value | mask; } bit_pos; mask 1; } return value; }11bit无符号数的范围是0到2047实际遥控器输出通常映射在约172到1811之间中位值约992。不同遥控器会有差异可以在上层用校准逻辑再做一次归一化但协议解析层只需要把原始值解出来。标志位解析固定取帧的第24字节数组索引23。按常用约定bit0为第17通道bit1为第18通道bit2为frame lostbit3为failsafe。这里以接收机手册为准不同品牌之间可能略有差异。完整解析函数在主循环里调用void sbus_parse_frame(void) { uint8_t i; for (i 0; i 16; i) { sbus_channels[i] sbus_get_channel(sbus_frame[1], i); } sbus_ch17 (sbus_frame[23] 0) 0x01; sbus_ch18 (sbus_frame[23] 1) 0x01; sbus_frame_lost (sbus_frame[23] 2) 0x01; sbus_failsafe (sbus_frame[23] 3) 0x01; }主循环里消费数据时要read-modify-write清掉sbus_frame_ready标志防止同一帧被重复处理while (1) { if (sbus_frame_ready) { sbus_frame_ready 0; sbus_parse_frame(); /* 在这里使用 sbus_channels[0..15] */ } /* 其他任务 */ }5. 常见问题与排查实录5.1 收不到数据或者乱码先检查信号反相。找一个USB转TTL工具直接看SBUS原始波形空闲电平是低电平时说明就是反相问题。反相没处理前串口看到的内容大概率是连续0xFF或者直接没有响应因为UART把空闲低电平判定为Break。再检查CubeMX里波特率是不是100000而不是115200。SBUS非标波特率少一个零或者多一个零结果都是乱码。第三检查校验位和停止位8E2配置成8N1也解析不对。还有一点容易被忽略接收机SBUS输出是3.3V TTL别接到5V电平的板卡RX上否则可能烧引脚。STM32的RX是容忍5V的型号还好但保险起见都做电平适配。5.2 IDLE中断一直不触发怎么办优先检查USART全局中断有没有在CubeMX里打开。IDLE标志属于UART事件必须由UART中断入口把标志取出来HAL_UART_IRQHandler才会分发到RxEventCallback。DMA中断解决的是DMA搬运方面的事IDLE事件不走DMA通道。再检查DMA模式是不是真的Circular。如果误选NormalDMA收满缓冲区后就不会再写入IDLE事件虽然能触发但后续数据全部丢失。这个问题在IOC文件里看DMA Settings非常明显一个字母之差行为差之千里。5.3 偶发丢帧和通道值跳动丢帧最常见的原因是主循环处理任务太重比如解析完通道值后立刻做浮点PID运算或者直接在业务代码里printf导致下几帧的数据在DMA缓冲区里堆积。好在DMA循环接收天生支持缓冲下一帧来的时候数据不会丢状态机也能处理多帧堆积的情况。但如果一帧还没被状态机消费完下一帧就又来了并且缓冲区被新数据覆盖旧数据就会丢失。解决办法是调整主循环结构把“状态机喂字节”和下层的“业务处理”彻底分开。IDLE回调里只做喂字节和置帧标志主循环里先处理完所有已就绪的帧再执行业务逻辑。如果业务逻辑确实很耗时建议给SBUS解析单独开一个小任务或者放在高优先级定时器中断里处理。偶发通道值跳动则要怀疑数据总线上的电磁干扰尤其是电机驱动和接收机线缆一起走线的时候。SBUS线用双绞线或者屏蔽线尽量远离电机电源线。软件上也要兜底只有当帧头、帧尾、帧长三项全部正确时才更新输出通道任何一个条件不满足保持上一帧的通道值不变。这样即使偶发坏帧也不会体现在遥控通道上。5.4 ORE溢出导致系统异常UART在DMA接收状态下如果DMA没能及时把数据搬走硬件会置上溢出错误ORE。一旦置上不清除的话会不断触发错误中断表现为主循环卡死或者串口完全不工作。HAL库的错误回调里至少要加上清除ORE标志的处理void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (__HAL_UART_GET_FLAG(huart, UART_FLAG_ORE)) { __HAL_UART_CLEAR_OREFLAG(huart); } }这个问题的根源往往不是软件逻辑而是中断优先级和阻塞函数调用。我见过有人直接在TxEventCallback或者RxEventCallback里做DMA发送等待导致接收中断被拖住ORF爆发。记住DMA回调尤其是接收回调内部绝对不能有任何等待循环。5.5 常用排查速查表现象大概率原因快速验证方式解决切入点完全无数据信号未反相示波器测引脚电平空闲为低即反相加74HC14反相器或改RXINV乱码波特率/校验位配置错核对100000、8E2重新配置USART参数IDLE不触发USART全局中断未使能查看NVIC配置CubeMX开启UART中断前几帧正常后续错乱用Size参数而非CNDTR差值打印每次received值改CNDTR差值计算通道偶尔跳动干扰或坏帧未兜底连续打印连续100帧检查异常增加帧头帧尾校验仅好帧更新输出系统卡死ORE溢出未清除调试器看HAL错误回调清ORE标志缩短中断处理时间另外提一下开了看门狗IWDG的场景。如果业务主循环里有一处阻塞时间过长看门狗会复位单片机。检查DMA接收是否在复位后及时重新启动接收否则看门狗复位后SBUS链路不会自动恢复。我一般把SBUS_StartReceive放在主循环复位检测里每次进入主循环都判断串口接收状态是否忙碌如果不忙碌就重新启动接收这样最稳。6. 代码移植与后续扩展建议这套代码的核心部分完全不依赖具体PN号。CNDTR差值计算、状态机、通道提取函数都是通用逻辑换芯片时只需要改三处串口句柄、DMA句柄传递方式、以及CubeMX重新生成的初始化代码。从F103移植到F407时我只改了HAL_UARTEx_ReceiveToIdle_DMA传入的句柄状态机部分一行没动这算是HAL库统一API带来的红利。如果后续要支持CRSF协议或者其他类似的串口遥控协议DMA加IDLE这套接收框架可以直接复用只需要改状态机和通道映射。因为CRSF的帧周期更短但帧结构不同IDLE中断同样能精准标记帧边界这一点对这种固定帧长、固定周期的协议都非常适用。还有一个可以扩展的是把失效保护逻辑放到解析层。当超过一定时间没有收到新帧将sbus_channels输出为预设的安全值比如油门通道归零舵面通道保持中位然后置一个通信丢失标志。这个逻辑放在sbus_parse_frame之后、业务逻辑之前最合理避免业务层每次都要判断数据新鲜度。我实际项目里还加了帧接收超时监视主循环里每1ms查询一次时间戳超过20ms没有新帧就把状态机强拉回START并清掉就绪标志。这种兜底逻辑在长距离飞行时很有用因为接收机信号完全丢失后SBUS线上不会再有IDLE事件状态机可能停在DATA状态等下一个0x0F不主动复位的话重新恢复信号后第一帧可能错位。加上这个超时复位后代码的鲁棒性才算真正完整。最后还有一个想说的操作细节调试时建议在DMA回调里用逻辑分析仪测量一个GPIO翻转的时长关注中断处理是否超过100us。我实际测过我这套代码的IDLE回调执行时间大约只有几十微秒在72MHz主频下这套方案的压力非常小。如果你在测试中发现回调执行时间异常增加优先检查是不是在里面不小心做了什么阻塞操作这往往是偶发死机的元凶。