1. 为什么SBUS协议值得单独写一套解析框架
SBUS是遥控接收机领域事实上的标准协议,Futaba、FrSky、乐迪等厂商的接收机几乎都支持它。它的物理层很特殊:反相串口、100000波特率、8数据位、偶校验、2停止位,一帧25字节,包含16个通道的11位数据、2个数字通道标志位和一帧结束字节0x00。很多刚接触STM32的朋友第一次接SBUS,用普通串口接收方式去读,结果要么丢帧,要么数据错位,要么CPU被串口中断拖死。
我在实际项目里踩过最典型的坑就是:用HAL_UART_Receive_IT逐字节接收,波特率100k下每帧25字节,遥控器刷新周期通常7ms到14ms,看起来不忙,但一旦主循环里有其他耗时任务,中断响应延迟就会导致帧内字节丢失,表现为通道值偶尔跳变。后来换成DMA循环接收加IDLE中断,CPU占用几乎为零,帧完整性也稳了。
这套方案的核心思路是三层配合:DMA负责搬运数据不打扰CPU,IDLE中断负责告诉CPU“一帧数据到齐了”,状态机负责把25字节的原始流解析成16个通道值。三者各司其职,缺一不可。下面我把整个设计思路、CubeMX配置、代码实现和调试经验完整拆开讲。
2. 整体方案设计与选型考量
2.1 为什么不用普通串口中断接收
普通串口中断接收的方式是每收到一个字节触发一次中断,CPU在中断里把数据存进缓冲区。SBUS一帧25字节,按100k波特率算,一个字节传输时间约100微秒,25字节约2.5毫秒。如果每字节都进中断,2.5毫秒内要进25次中断,每次中断进出开销按1微秒算,光中断开销就25微秒,看起来不多,但问题在于中断优先级冲突。
实际项目中,如果同时有定时器中断、ADC中断在跑,串口中断被延迟几十微秒很正常。SBUS字节间隔只有100微秒,延迟超过这个窗口就会丢字节。我实测过,在主循环里加一个OLED刷新(I2C软件模拟),丢帧率能到5%以上。DMA方式则完全绕开这个问题,数据由硬件直接搬到内存,CPU只在整帧到齐后处理一次。
2.2 DMA循环模式与IDLE中断的配合逻辑
DMA循环模式(Circular)的意思是缓冲区满了之后自动回到开头继续写,不需要CPU干预。配合IDLE中断,逻辑是这样的:串口空闲线检测到总线空闲(一帧结束),触发IDLE中断,此时DMA已经把这帧数据搬到了缓冲区,CPU在IDLE中断里读取DMA剩余计数,算出这帧收了多少字节,然后交给状态机解析。
这里有个关键点:DMA缓冲区要足够大,至少能装下两帧以上。因为IDLE中断触发到CPU实际处理之间有时间差,如果缓冲区太小,新一帧数据可能覆盖还没处理的旧帧。我一般用64字节或128字节缓冲区,SBUS一帧25字节,足够容纳两帧还有余量。
2.3 状态机解析的必要性
SBUS帧有固定格式:首字节0x0F,25字节一帧,末字节0x00。但实际接收中,由于干扰或上电时序问题,缓冲区里可能出现半帧、错位帧。如果不用状态机,直接按固定偏移取数据,一旦错位就全乱。状态机的作用是逐字节扫描,找到帧头0x0F后开始计数,收满25字节且末字节为0x00才认为是一帧有效数据,否则丢弃重新找帧头。
我用的状态机很简单,三个状态:等待帧头、接收数据、校验结束。每个字节进来根据当前状态决定下一步动作,逻辑清晰,不容易出错。
3. CubeMX配置与硬件连接要点
3.1 串口参数配置
在CubeMX里选一个USART,比如USART2,模式选Asynchronous。参数设置如下:
| 参数 | 值 | 说明 |
|---|---|---|
| Baud Rate | 100000 | SBUS标准波特率 |
| Word Length | 8 Bits | 数据位8 |
| Parity | Even | 偶校验 |
| Stop Bits | 2 | 双停止位 |
| Data Direction | Receive Only | 只接收,SBUS是单向的 |
这里有个坑:HAL库默认的串口初始化可能不支持偶校验加2停止位,需要检查生成的代码里huart2.Init.Parity和huart2.Init.StopBits是否正确。我遇到过CubeMX版本问题导致StopBits被设成1,结果收不到数据,排查了半天。
3.2 DMA配置
在DMA Settings里添加USART2_RX,模式选Circular,数据宽度Byte,优先级Medium或High。不要开FIFO,SBUS数据量小,开FIFO反而增加延迟。
3.3 硬件反相电路
SBUS信号是反相的,普通串口RX引脚直接接会收到反码。两种解决方案:一是用硬件反相电路,一个NPN三极管加两个电阻就能搞定;二是用软件反相,但STM32的USART不支持硬件反相,软件反相需要在每个字节上做位翻转,DMA方式下不方便。我推荐硬件反相,电路简单可靠。
具体电路:SBUS信号接三极管基极(串1k电阻),集电极接3.3V(串10k上拉),发射极接地,集电极输出就是反相后的信号,接STM32的RX引脚。实测这个电路在100k波特率下波形干净,没有明显延迟。
3.4 IDLE中断使能
在USART2的NVIC Settings里勾选USART2全局中断。然后在代码里手动使能IDLE中断,HAL库没有直接提供IDLE中断的使能函数,需要操作寄存器:
__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);这行代码放在HAL_UART_Receive_DMA之后。
4. 代码实现与关键细节拆解
4.1 全局变量定义
#define SBUS_RX_BUF_SIZE 64 #define SBUS_FRAME_SIZE 25 uint8_t sbus_rx_buf[SBUS_RX_BUF_SIZE]; uint8_t sbus_frame[SBUS_FRAME_SIZE]; volatile uint8_t sbus_frame_ready = 0; volatile uint16_t sbus_channels[16]; volatile uint8_t sbus_failsafe = 0; volatile uint8_t sbus_lost_frame = 0;sbus_rx_buf是DMA目标缓冲区,sbus_frame是解析后的有效帧,sbus_frame_ready标志位告诉主循环有新数据。
4.2 启动DMA接收和IDLE中断
void SBUS_Init(void) { HAL_UART_Receive_DMA(&huart2, sbus_rx_buf, SBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); }注意HAL_UART_Receive_DMA的第三个参数是缓冲区大小,DMA循环模式下这个大小就是循环周期。我设64字节,意味着DMA写到第64字节后自动回到第0字节。
4.3 IDLE中断处理函数
IDLE中断在HAL库的HAL_UART_IRQHandler里没有直接回调,需要自己写中断服务函数:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); uint16_t rx_len = SBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t start_pos = (rx_len < SBUS_RX_BUF_SIZE) ? (SBUS_RX_BUF_SIZE - rx_len) : 0; SBUS_Parse(sbus_rx_buf, SBUS_RX_BUF_SIZE, start_pos, rx_len); } HAL_UART_IRQHandler(&huart2); }这里有个容易搞错的地方:DMA循环模式下,__HAL_DMA_GET_COUNTER返回的是剩余未传输的字节数,不是已传输数。已传输数等于缓冲区大小减去剩余数。但循环模式下,如果DMA已经绕了一圈,这个计算会出错。我的处理方式是记录上一次的DMA位置,计算增量。不过对于SBUS这种每帧25字节、缓冲区64字节的场景,一帧数据不会跨越缓冲区边界两次,简单计算就够用。
更稳妥的做法是维护一个读指针,每次IDLE中断时从上次读位置读到当前DMA写位置。我实际项目里用的是简化版,因为SBUS帧短,缓冲区大,基本不会出现绕圈问题。
4.4 状态机解析函数
typedef enum { SBUS_STATE_HEADER, SBUS_STATE_DATA, SBUS_STATE_END } SBUS_State_t; void SBUS_Parse(uint8_t *buf, uint16_t buf_size, uint16_t start, uint16_t len) { static SBUS_State_t state = SBUS_STATE_HEADER; static uint8_t frame_idx = 0; for (uint16_t i = 0; i < len; i++) { uint16_t pos = (start + i) % buf_size; uint8_t byte = buf[pos]; switch (state) { case SBUS_STATE_HEADER: if (byte == 0x0F) { sbus_frame[0] = byte; frame_idx = 1; state = SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: sbus_frame[frame_idx++] = byte; if (frame_idx >= SBUS_FRAME_SIZE) { state = SBUS_STATE_END; } break; case SBUS_STATE_END: if (byte == 0x00) { SBUS_Decode(sbus_frame); sbus_frame_ready = 1; } frame_idx = 0; state = SBUS_STATE_HEADER; break; } } }状态机的逻辑是:找到0x0F后开始存数据,存满25字节后检查下一字节是否为0x00,是则解码,否则丢弃重新找帧头。这里有个细节:SBUS帧的第24字节(索引24)是帧结束标志0x00,但有些接收机可能发0x04或0x14表示丢帧或失控。所以严格来说,结束字节不一定是0x00,需要根据具体接收机文档调整。我用的FrSky接收机结束字节是0x00,但为了兼容性,可以放宽为检查帧头0x0F和帧长25字节。
4.5 通道数据解码
SBUS的16个通道数据是11位打包的,每两个字节包含一个完整通道和一个通道的高3位。解码代码如下:
void SBUS_Decode(uint8_t *frame) { sbus_channels[0] = ((uint16_t)frame[1] | ((uint16_t)frame[2] << 8)) & 0x07FF; sbus_channels[1] = ((uint16_t)frame[2] >> 3 | ((uint16_t)frame[3] << 5)) & 0x07FF; sbus_channels[2] = ((uint16_t)frame[3] >> 6 | ((uint16_t)frame[4] << 2) | ((uint16_t)frame[5] << 10)) & 0x07FF; sbus_channels[3] = ((uint16_t)frame[5] >> 1 | ((uint16_t)frame[6] << 7)) & 0x07FF; sbus_channels[4] = ((uint16_t)frame[6] >> 4 | ((uint16_t)frame[7] << 4)) & 0x07FF; sbus_channels[5] = ((uint16_t)frame[7] >> 7 | ((uint16_t)frame[8] << 1) | ((uint16_t)frame[9] << 9)) & 0x07FF; sbus_channels[6] = ((uint16_t)frame[9] >> 2 | ((uint16_t)frame[10] << 6)) & 0x07FF; sbus_channels[7] = ((uint16_t)frame[10] >> 5 | ((uint16_t)frame[11] << 3)) & 0x07FF; sbus_channels[8] = ((uint16_t)frame[12] | ((uint16_t)frame[13] << 8)) & 0x07FF; sbus_channels[9] = ((uint16_t)frame[13] >> 3 | ((uint16_t)frame[14] << 5)) & 0x07FF; sbus_channels[10] = ((uint16_t)frame[14] >> 6 | ((uint16_t)frame[15] << 2) | ((uint16_t)frame[16] << 10)) & 0x07FF; sbus_channels[11] = ((uint16_t)frame[16] >> 1 | ((uint16_t)frame[17] << 7)) & 0x07FF; sbus_channels[12] = ((uint16_t)frame[17] >> 4 | ((uint16_t)frame[18] << 4)) & 0x07FF; sbus_channels[13] = ((uint16_t)frame[18] >> 7 | ((uint16_t)frame[19] << 1) | ((uint16_t)frame[20] << 9)) & 0x07FF; sbus_channels[14] = ((uint16_t)frame[20] >> 2 | ((uint16_t)frame[21] << 6)) & 0x07FF; sbus_channels[15] = ((uint16_t)frame[21] >> 5 | ((uint16_t)frame[22] << 3)) & 0x07FF; sbus_failsafe = (frame[23] & 0x08) ? 1 : 0; sbus_lost_frame = (frame[23] & 0x04) ? 1 : 0; }这段代码看着复杂,其实就是位操作。每个通道11位,跨字节边界,用移位和或运算拼起来。注意& 0x07FF是必须的,因为移位后可能带进高位脏数据。
4.6 主循环处理
while (1) { if (sbus_frame_ready) { sbus_frame_ready = 0; if (sbus_failsafe) { // 失控保护处理 } else { // 正常使用sbus_channels数组 int16_t ch1 = sbus_channels[0]; // ... } } // 其他任务 }主循环里只检查标志位,不做耗时操作,保证解析及时。
5. 调试过程中踩过的坑与排查方法
5.1 收不到数据:先查硬件反相
第一次接SBUS,串口助手收到一堆乱码,以为是波特率不对。后来用示波器看波形,发现信号是反的。加上反相电路后正常。排查顺序:示波器看RX引脚波形,确认空闲电平是高还是低。SBUS空闲时应该是低电平(反相后),如果看到高电平,说明反相电路没工作。
5.2 数据偶尔跳变:检查DMA缓冲区大小
有次调试发现通道值每隔几秒跳一下,用逻辑分析仪抓串口数据,发现偶尔丢一帧。原因是DMA缓冲区设了32字节,只够一帧多,IDLE中断处理稍慢就被新数据覆盖。改成64字节后问题消失。经验值:缓冲区至少是帧长的2.5倍。
5.3 IDLE中断不触发:检查标志位清除
HAL库的IDLE标志清除比较特殊,不能直接用__HAL_UART_CLEAR_FLAG,要用__HAL_UART_CLEAR_IDLEFLAG。我一开始用错了宏,中断只触发一次就不再触发。正确写法是先读SR寄存器再读DR寄存器,或者直接用__HAL_UART_CLEAR_IDLEFLAG。
5.4 通道值范围不对:确认解码位宽
SBUS通道值是11位,范围172到1811,中位992。如果解出来是0到2047,说明没做& 0x07FF。如果解出来是0到255,说明只取了低8位。用遥控器打满舵,看通道值是否在172和1811附近,能快速判断解码是否正确。
5.5 偶校验错误:检查串口初始化
偶校验加2停止位,有些STM32型号的USART不支持2停止位,或者CubeMX生成的代码里StopBits被设成1。检查huart2.Init.StopBits是否为UART_STOPBITS_2,如果不是,手动改。
6. 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 完全无数据 | 硬件反相未做 | 示波器看RX波形 | 加反相电路 |
| 数据乱码 | 波特率/校验/停止位不对 | 核对串口参数 | 100000,8,E,2 |
| 偶尔丢帧 | DMA缓冲区太小 | 增大缓冲区 | 至少64字节 |
| IDLE只触发一次 | 标志位清除错误 | 检查清除宏 | 用__HAL_UART_CLEAR_IDLEFLAG |
| 通道值跳变 | 帧错位 | 加状态机校验 | 检查帧头帧尾 |
| 通道值范围错 | 解码位宽错 | 打满舵看极值 | 加& 0x07FF |
| CPU占用高 | 用了逐字节中断 | 改DMA | 换DMA循环模式 |
7. 性能实测与优化建议
我用STM32F103C8T6跑这套方案,主频72MHz,实测CPU占用率不到1%。具体数据:SBUS帧率约14ms一帧,每帧25字节,IDLE中断每14ms触发一次,中断处理时间约20微秒(含状态机解析),主循环里解码16个通道约5微秒。加起来每14ms消耗25微秒CPU时间,占用率0.18%。
优化建议:如果项目里SBUS只是辅助功能,可以把解析放在IDLE中断里直接做完,主循环只读结果。如果SBUS是核心控制输入,建议在IDLE中断里只做帧提取,解码放主循环,避免中断里耗时过长。
另外,DMA缓冲区地址要32位对齐,虽然STM32的DMA对字节传输不要求对齐,但对齐后访问效率更高。用__attribute__((aligned(4)))修饰缓冲区数组即可。
8. 这套框架还能怎么扩展
这套DMA加IDLE加状态机的框架不只适用于SBUS。任何不定长、有固定帧头帧尾的串口协议都能套用,比如Modbus RTU、自定义传感器协议、GPS NMEA语句。只需要改状态机里的帧头判断和帧长校验逻辑,DMA和IDLE部分完全复用。
我后来用同样的框架解析过某品牌激光雷达的串口数据,帧长可变,状态机里加了个长度字段解析,照样跑得很稳。所以这套东西值得花时间吃透,以后遇到串口协议直接套模板,省事。
最后分享一个小技巧:调试阶段可以在IDLE中断里翻转一个GPIO,用示波器看中断触发频率,能快速判断帧率是否正常。正常SBUS应该是7ms或14ms触发一次,如果频率不对,说明接收有问题。这个法子比串口打印直观多了,也不占用串口资源。