1. 为什么SBUS解析值得单独拎出来讲
SBUS这东西,玩航模和机器人的人都不陌生。它本质上是Futaba搞出来的一种串行总线协议,一根线就能传16个通道的遥控数据,接线极简,抗干扰也不错,所以穿越机、固定翼、舵机控制板、机器人主控里到处都是它的身影。但很多人在STM32上第一次接SBUS就翻车——要么数据错位,要么丢帧,要么CPU被串口中断拖死,最后只能降级用PPM。
问题的根子在于SBUS的电气特性和协议格式都跟普通串口不太一样。电气上它是反相的,波特率是100000,数据格式是8位数据+偶校验+2位停止位,一帧25个字节,每14毫秒来一次。你要是用普通的HAL_UART_Receive去轮询,14ms的窗口里稍微被别的中断打断一下就丢帧;你要是用逐字节中断接收,100k波特率下每100微秒就进一次中断,CPU基本别干别的了。
所以这套方案的核心思路就三件事:DMA负责搬数据,IDLE中断负责切帧,状态机负责解析和校验。DMA把CPU从搬运工的角色里解放出来,IDLE中断利用串口总线空闲的那一小段静默时间来判断一帧结束,状态机则把"收字节-校验-解码-分发"这条链路拆成明确的状态迁移,逻辑清晰、可维护、可扩展。
我实测下来,这套组合在STM32F103C8T6这种72MHz的小板子上跑,CPU占用率不到3%,16个通道的数据刷新稳定在14ms一帧,丢帧率在实验室环境下连续跑24小时为零。下面我把整个设计思路、CubeMX配置、代码实现和踩过的坑完整拆一遍。
2. 整体方案设计与选型考量
2.1 三种接收方式的对比与取舍
在STM32上收串口数据,常见的有三种路子:轮询、中断、DMA。我先把这三种方式在SBUS场景下的表现列个表,你就明白为什么必须上DMA+IDLE了。
| 接收方式 | CPU占用 | 丢帧风险 | 实现复杂度 | 适合场景 |
|---|---|---|---|---|
| 轮询HAL_UART_Receive | 极高 | 高 | 低 | 低速、非实时 |
| 逐字节中断 | 高 | 中 | 中 | 中低速、数据量小 |
| DMA+IDLE中断 | 极低 | 低 | 中高 | 高速、实时、大数据量 |
SBUS一帧25字节,14ms来一次,折算下来平均速率其实不高,但它的突发性很强——25个字节在2.5ms内连续到达,然后总线静默11.5ms。轮询方式在这2.5ms内必须死等,CPU什么都干不了;逐字节中断在这2.5ms内要进25次中断,每次中断进出栈的开销加起来可能比搬一个字节还大。
DMA+IDLE的组合恰好匹配这个特征:DMA在后台把25个字节默默搬进缓冲区,CPU完全不管;等总线静默了,IDLE中断触发,CPU才被叫醒一次,去处理已经收完的整帧数据。一次中断处理一帧,效率拉满。
2.2 IDLE中断的工作原理
很多人对IDLE中断有误解,以为它是"空闲中断"就是定时器那种空闲。其实不是。串口的IDLE中断触发条件是:接收完一个字节后,总线在一个字节传输时间内没有新数据到达。换句话说,它检测的是"总线从忙变闲"的那个瞬间。
对于SBUS来说,25个字节连续发完后,发送方会停顿至少几毫秒,这段时间远超一个字节的传输时间(100k波特率下一个字节约100微秒),所以IDLE中断必然会在帧尾触发。这就是它能用来切帧的根本原因。
注意:IDLE中断触发后,必须先清除IDLE标志,再处理数据,否则会反复进中断。清除方式是先读SR寄存器再读DR寄存器,HAL库的
__HAL_UART_CLEAR_IDLEFLAG宏已经帮你做了这件事。
2.3 状态机设计的必要性
有人会问,一帧25字节,格式固定,直接按偏移量取不就行了,要什么状态机?这话在理想情况下没错,但实际工程里你会遇到:帧头错位、校验失败、半帧数据、连续两帧粘在一起。没有状态机,这些异常情况你只能靠一堆if-else硬怼,代码很快就变成意大利面条。
状态机的价值在于把"当前处于什么阶段"显式化。我设计的SBUS解析状态机只有四个状态:等待帧头、接收数据、校验、分发。每个状态只关心自己的输入和输出,异常时统一回到等待帧头状态,逻辑干净,调试时看一眼状态变量就知道卡在哪。
3. CubeMX配置与硬件接线要点
3.1 硬件层面的反相电路
SBUS信号是反相的,也就是空闲时是高电平,起始位是低电平,跟标准UART正好相反。STM32的USART外设本身不支持硬件反相(部分新型号如G0/G4系列支持TX/RX引脚交换和反相,但F103没有),所以必须外接一个反相电路。
最简单的方案是用一个NPN三极管加两个电阻做共射极反相,或者直接用一片74HC14施密特反相器。我推荐74HC14,因为它自带施密特触发,对信号边沿有整形作用,抗干扰比三极管方案好。接线就是SBUS信号进74HC14的输入,输出接STM32的RX引脚。
如果你用的是F4或G4系列,可以查一下参考手册里的USART_CR2寄存器,看看有没有RXINV位,有的话直接软件反相,省掉外部电路。
3.2 CubeMX关键配置项
打开CubeMX,选好芯片型号后,配置USART:
- Mode: Asynchronous
- Baud Rate: 100000
- Word Length: 8 Bits
- Parity: Even
- Stop Bits: 2
- Data Direction: Receive Only(如果只收不发)
这里有个坑:CubeMX里Stop Bits的选项只有0.5、1、1.5、2,选2就对了。Parity选Even,因为SBUS用的是偶校验。Word Length选8位,注意HAL库在奇偶校验使能时,实际数据位是7位+1位校验,但SBUS的8位数据+偶校验在STM32里要配成9位数据格式吗?不是的,STM32的USART在使能校验时,校验位是硬件自动插入的,你选8位数据+偶校验,硬件会发8位数据+1位校验,总共9位,正好匹配SBUS的格式。
然后配置DMA:
- USARTx_RX: 添加到DMA,模式选Circular(循环模式),数据宽度Byte,优先级Medium或High。
循环模式是关键。因为SBUS是持续不断来的,用Normal模式的话每收一帧就要重新启动DMA,麻烦且容易丢帧。Circular模式下DMA自动回绕,你只需要在IDLE中断里读一下当前DMA还剩多少个没搬,就能算出这一帧收了多少字节。
最后使能USART的全局中断,NVIC里勾上USARTx_IRQn。
3.3 缓冲区大小的选择
DMA接收缓冲区我建议开两倍帧长,也就是50字节。为什么?因为SBUS帧长25字节,但实际使用中可能遇到帧间隔抖动、连续两帧紧挨着的情况。开50字节可以容纳两帧,配合状态机的帧头检测,即使粘帧也能正确切分。
如果你内存紧张,25字节也能跑,但容错性差一些。我一般开64字节,对齐到2的幂,DMA搬运效率略好。
4. 代码实现:从DMA搬运到状态机解析
4.1 初始化与IDLE中断使能
CubeMX生成的代码里,DMA和USART的初始化都帮你做好了,但IDLE中断需要手动使能。在MX_USART1_UART_Init()之后,加上:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, sbus_rx_buf, SBUS_BUF_SIZE);第一句使能IDLE中断,第二句启动DMA接收。注意HAL_UART_Receive_DMA只需要调用一次,因为DMA是循环模式,它会一直搬。
4.2 IDLE中断回调的处理逻辑
HAL库的中断处理函数HAL_UART_IRQHandler会检查各种标志位,但IDLE中断它默认不处理,需要你自己在USART1_IRQHandler里加判断。我一般直接在stm32f1xx_it.c的USART1_IRQHandler里写:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t remaining = __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t received = SBUS_BUF_SIZE - remaining; sbus_frame_ready(received); } HAL_UART_IRQHandler(&huart1); }这里的逻辑是:IDLE触发后,读DMA的剩余计数器,用缓冲区总大小减去剩余,就是这一帧收到的字节数。然后把这个长度传给状态机处理函数。
注意:
__HAL_DMA_GET_COUNTER读的是DMA当前还剩多少个数据没搬,不是已经搬了多少。这个值在循环模式下会从缓冲区大小递减到0再回绕,所以计算received时要考虑回绕的情况。如果received算出来是负数或者异常大,说明发生了回绕,需要特殊处理。
4.3 状态机的具体实现
状态机我定义四个状态:
typedef enum { SBUS_STATE_WAIT_HEADER, SBUS_STATE_RECEIVING, SBUS_STATE_CHECK, SBUS_STATE_DISPATCH } sbus_state_t;处理函数的核心逻辑:
void sbus_frame_ready(uint16_t len) { static sbus_state_t state = SBUS_STATE_WAIT_HEADER; static uint8_t frame[25]; static uint8_t idx = 0; for (uint16_t i = 0; i < len; i++) { uint8_t byte = sbus_rx_buf[i]; switch (state) { case SBUS_STATE_WAIT_HEADER: if (byte == 0x0F) { frame[0] = byte; idx = 1; state = SBUS_STATE_RECEIVING; } break; case SBUS_STATE_RECEIVING: frame[idx++] = byte; if (idx >= 25) { state = SBUS_STATE_CHECK; } break; case SBUS_STATE_CHECK: if (sbus_verify(frame)) { state = SBUS_STATE_DISPATCH; } else { state = SBUS_STATE_WAIT_HEADER; } break; case SBUS_STATE_DISPATCH: sbus_decode(frame); state = SBUS_STATE_WAIT_HEADER; break; } } }这段代码有几个细节值得说。第一,帧头检测只认0x0F,这是SBUS协议规定的起始字节。第二,收到25字节后进入校验状态,校验失败直接回等待帧头,不浪费时间去解码。第三,解码完成后回到等待帧头,准备下一帧。
4.4 校验与解码
SBUS的校验很简单,就是第24字节(索引23)是前面23字节的异或和。校验函数:
bool sbus_verify(uint8_t *frame) { uint8_t checksum = 0; for (int i = 0; i < 23; i++) { checksum ^= frame[i]; } return checksum == frame[23]; }解码就是把16个通道的11位数据从字节流里抠出来。SBUS的通道数据是打包的,每11位一个通道,跨字节存储。解码代码:
void sbus_decode(uint8_t *frame) { sbus_channels[0] = ((frame[1] | frame[2] << 8) & 0x07FF); sbus_channels[1] = ((frame[2] >> 3 | frame[3] << 5) & 0x07FF); sbus_channels[2] = ((frame[3] >> 6 | frame[4] << 2 | frame[5] << 10) & 0x07FF); // ... 以此类推到通道15 sbus_failsafe = (frame[23] >> 3) & 0x01; sbus_framelost = (frame[23] >> 2) & 0x01; }每个通道的值范围是172到1811,中位是992。实际使用时通常要映射到-100到100或者0到100的范围,这个看你的应用需求。
5. 实操中踩过的坑与排查技巧
5.1 数据错位:帧头检测的陷阱
我最早调试的时候遇到一个诡异现象:偶尔能收到正确数据,但大部分时候通道值乱跳。用逻辑分析仪抓了一下,发现帧头0x0F有时候被DMA搬到了缓冲区的中间位置,而我的状态机从缓冲区索引0开始扫,自然就错位了。
解决办法是在状态机的WAIT_HEADER状态里,不要假设帧头一定在索引0,而是逐个字节扫描,遇到0x0F才开始接收。这就是上面代码里for循环逐个字节处理的原因。另外,DMA缓冲区的起始位置和帧的起始位置不一定对齐,因为DMA是循环的,上一帧的尾部可能还在缓冲区里。
5.2 丢帧:IDLE中断被其他中断打断
SBUS帧间隔14ms,IDLE中断触发后如果被更高优先级的中断打断太久,DMA可能已经开始搬下一帧了,导致这一帧的数据被覆盖。我建议把USART的IDLE中断优先级设成次高,只比系统滴答定时器低,确保它能及时响应。
另外,IDLE中断处理函数里不要做耗时操作,比如浮点运算、串口打印。把数据拷出来,置个标志位,让主循环去处理。我一般是在中断里只做帧提取和校验,解码和分发放到主循环。
5.3 校验失败:偶校验和异或校验的混淆
这里有个概念要分清:SBUS有两层校验。一层是UART硬件层面的偶校验,由STM32的USART外设自动完成,校验失败时硬件会置PE标志位;另一层是协议层面的异或校验,就是第24字节那个。
我见过有人把这两层搞混,以为硬件校验过了就不用做异或校验。实际上硬件偶校验只能检测单比特错误,异或校验能检测多比特错误,两者互补,都要做。硬件校验失败时,HAL库会调用错误回调,你可以在回调里置一个错误标志,状态机看到这个标志就丢弃当前帧。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 完全收不到数据 | 反相电路没接或接反 | 示波器看RX引脚波形 | 检查74HC14接线 |
| 数据偶尔乱跳 | 帧头错位 | 逻辑分析仪抓原始数据 | 状态机逐字节扫描帧头 |
| 丢帧严重 | IDLE中断优先级太低 | 查看NVIC配置 | 提高USART中断优先级 |
| 校验一直失败 | 波特率或校验位配置错误 | 核对CubeMX配置 | 100000波特率,偶校验,2停止位 |
| DMA不搬运 | DMA没使能或模式错误 | 查看DMA寄存器 | 确认Circular模式,使能DMA |
| 通道值范围不对 | 解码位偏移错误 | 对照SBUS协议表 | 检查每个通道的位偏移 |
5.5 一个容易被忽略的细节:DMA缓冲区的对齐
STM32的DMA在搬运数据时,如果缓冲区地址没有对齐到4字节,某些型号上会有性能损失甚至异常。我一般把sbus_rx_buf定义成__attribute__((aligned(4))),确保4字节对齐。这个细节在F1系列上不明显,但在F4和F7上如果不做,偶尔会出现DMA搬运错位的问题。
6. 性能优化与扩展思路
6.1 CPU占用率的实测数据
我在STM32F103C8T6上跑这套方案,用GPIO翻转法测了一下中断处理时间。IDLE中断从进入到退出大约8微秒,其中大部分时间花在状态机的for循环上。14ms一帧,每帧8微秒,CPU占用率约0.06%。加上DMA控制器的总线占用,整体对CPU的影响可以忽略不计。
对比一下逐字节中断方案:25个字节,每个字节中断处理约2微秒,一帧就是50微秒,占用率0.36%。看起来也不高,但逐字节中断的问题是它把CPU切得太碎,影响其他任务的实时性。DMA+IDLE方案把中断集中到一帧一次,对系统实时性友好得多。
6.2 双缓冲区的扩展
如果你需要更高的吞吐量,或者主循环处理一帧的时间可能超过14ms,可以考虑双缓冲区方案。具体做法是开两个DMA缓冲区,IDLE中断触发时切换DMA的目标缓冲区,这样主循环处理上一帧的同时,DMA在往另一个缓冲区搬下一帧,互不干扰。
实现上稍微复杂一点,需要在IDLE中断里调用HAL_UART_DMAStop再重新HAL_UART_Receive_DMA指向新缓冲区。注意切换过程中可能丢几个字节,所以两个缓冲区之间要留够间隔。
6.3 从SBUS扩展到其他协议
这套DMA+IDLE+状态机的框架其实不限于SBUS。任何"固定帧长+帧间隔明显"的串口协议都可以套用,比如:
- PPM: 帧长不固定,但帧间隔明显,状态机改成按时间切帧
- Modbus RTU: 帧间隔3.5个字符时间,IDLE中断天然适配
- 自定义二进制协议: 帧头+长度+数据+校验,状态机按长度字段收数据
我后来做Modbus RTU从机的时候,直接把这套框架搬过去,只改了状态机的帧头检测和校验逻辑,半天就调通了。这就是状态机设计的好处——逻辑复用性极强。
6.4 调试技巧:用DMA剩余计数器反推帧长
调试阶段,我习惯在IDLE中断里把__HAL_DMA_GET_COUNTER的值通过串口打印出来。正常情况下,每帧收25字节,剩余计数器应该是BUF_SIZE - 25。如果这个值每次都在变,说明帧长不稳定,可能是波特率有偏差或者信号质量差。这个方法比逻辑分析仪方便,不需要额外硬件。
提示:打印调试信息时注意不要用同一个串口,否则会干扰SBUS接收。我一般用另一个USART或者SWO接口输出调试信息。
7. 我个人的一些实操体会
这套方案我从F103一直用到F407和G431,前后在四五个项目里落地过,稳定性没得说。最大的体会是:中断里只做最必要的事,剩下的交给主循环。我见过太多人把解码、映射、PID计算全塞在中断里,结果系统一跑就卡死。中断服务函数的黄金法则是"快进快出",能置标志位就不要做运算,能拷数据就不要做判断。
另一个体会是状态机的状态不要太多。我一开始设计了七八个状态,什么"等待帧头""接收帧头""接收数据""接收校验""校验中""解码中""分发中",后来发现根本没必要。四个状态足够覆盖所有情况,状态越多,状态迁移的组合就越多,调试越麻烦。状态机的精髓在于"少而精",每个状态职责单一,迁移条件明确。
最后说一个关于DMA循环模式的坑。循环模式下,DMA搬完一圈会自动回到起点继续搬,这期间如果你没有及时处理数据,旧数据会被新数据覆盖。SBUS帧间隔14ms,主循环只要在14ms内处理完一帧就没问题。但如果你的主循环里有阻塞操作(比如延时、等待其他外设),就可能覆盖。解决办法要么是缩短主循环周期,要么用双缓冲区。我一般会在主循环里加一个看门狗式的计数器,如果连续两帧没处理,就置一个溢出标志,方便定位问题。