1. 为什么 SBUS 解析值得单独拿出来讲
SBUS 这个协议在航模、机器人、工业遥控领域出镜率极高,但真正自己从零写解析的人不多,大多数人第一次接触都是被它那套"反相串口 + 25 字节定长帧 + 11 位打包"的组合拳打懵。我最早做 SBUS 接收是在一块 F103 上,当时用标准库轮询,主循环里塞了个while死等,结果遥控一抖舵机就跟着抽风,后来换成 HAL 库 + DMA + IDLE 中断才彻底稳住。
这篇内容就是把我这几年在 STM32 上用 HAL 库做 SBUS 解析的完整思路摊开讲。核心方案是DMA 循环接收 + 串口 IDLE 空闲中断 + 状态机解析,三者配合能实现零丢帧、低 CPU 占用、可扩展性强的接收链路。适合正在做飞控、机器人遥控、云台控制、工业遥控接收端的同学,也适合已经会点 HAL 库但被 SBUS 帧格式卡住的开发者。读完你应该能直接把这套代码搬到自己的工程里,改改串口号和波特率就能跑。
先说清楚 SBUS 的几个硬性特征,这是后面所有设计的出发点:
- 物理层反相:SBUS 是反相串口,空闲电平为低,起始位为高。所以硬件上必须加一个反相电路(一个 NPN 三极管 + 两个电阻就能搞定),或者用带反相功能的接收芯片。软件层面 HAL 库没法直接处理反相,这点必须先解决。
- 波特率 100000,8E2:100k 波特率、8 数据位、偶校验、2 停止位。注意是 100k 不是 115200,很多人第一次配错就是这里。
- 25 字节定长帧:帧头 0x0F,帧尾根据故障标志位可能是 0x00、0x04、0x14、0x24 等。
- 16 通道 + 2 个数字通道:22 字节承载 16 个 11 位通道数据,剩下的是标志位。
把这四点记住,后面状态机的设计就顺理成章了。
2. 整体方案设计与选型考量
2.1 为什么不用轮询和普通中断
先说说为什么放弃轮询。SBUS 一帧 25 字节,100k 波特率下每字节约 100 微秒,一帧大约 2.5 毫秒,帧间隔通常 7 毫秒(14ms 周期里发两帧或按厂商不同)。如果用轮询,主循环必须每 100 微秒回来查一次,这对任何有实时任务的系统都是灾难。我试过在 F103 上轮询,主循环里稍微加点浮点运算就开始丢字节。
普通接收中断(RXNE)的问题是中断太频繁。25 字节就是 25 次中断,如果同时还有别的串口、定时器中断,CPU 会被打断得七零八落。而且每进一次中断都要读 DR、清标志,开销不小。
DMA 循环接收的好处是:CPU 完全不参与搬运,DMA 自己把串口数据往缓冲区里塞,我们只需要在合适的时候去读缓冲区。配合 IDLE 空闲中断,一帧数据接收完毕(总线空闲一个字节时间)触发一次中断,中断频率从 25 次/帧降到 1 次/帧,CPU 占用直接降一个数量级。
2.2 为什么用循环模式而不是普通模式
DMA 有两种模式:Normal 和 Circular。Normal 模式下 DMA 搬完指定长度就停,需要手动重启;Circular 模式下缓冲区满了自动回到起点继续搬,永不停止。
SBUS 是连续流式数据,帧与帧之间没有明显间隔(或者说间隔很短),如果用 Normal 模式,每次接收完都要在中断里重新配置 DMA,一旦配置慢了就丢数据。Circular 模式让 DMA 一直转,我们通过计算"当前写指针位置"来判断收到了多少字节,这才是流式协议的正确打开方式。
缓冲区大小我一般设成 50 字节(两帧),这样即使 IDLE 中断响应慢了一点,也不会覆盖掉还没处理的数据。设太小容易覆盖,设太大浪费 RAM 且增加处理延迟。
2.3 状态机在其中的角色
有了 DMA 缓冲区和 IDLE 中断,我们拿到的是"一段连续的字节流",但这段字节流里可能包含半帧、一帧、一帧半甚至多帧。怎么从字节流里准确切出完整的 25 字节帧?这就是状态机要干的事。
状态机的核心思路是:以帧头 0x0F 为锚点,逐字节推进,凑够 25 字节且帧尾合法才认为是一帧有效数据。这样即使中间丢了几个字节,状态机也能自动重新同步,不会一直错位下去。
我见过有人直接在 IDLE 中断里判断"收到 25 字节就解析",这种做法在数据干净时能用,但一旦出现丢字节或粘包就彻底崩。状态机虽然多写几十行代码,但鲁棒性完全不是一个级别。
3. 核心细节解析与实操要点
3.1 硬件反相电路不能省
这是最容易被忽略的一步。STM32 的 USART RX 引脚默认空闲是高电平,而 SBUS 信号空闲是低电平。如果不做反相,你会看到接收到的数据全是 0x00 或者乱码,怎么调软件都没用。
最简单的反相电路:一个 2N3904 或 S8050 NPN 三极管,基极串 1k 电阻接 SBUS 信号,集电极串 10k 上拉到 3.3V 并接 STM32 RX,发射极接地。这样 SBUS 低电平时三极管截止,RX 被上拉到高;SBUS 高电平时三极管导通,RX 被拉低。逻辑正好反相。
注意:有些接收机输出的是已经反相过的信号(标注为"非反相 SBUS"或"SBUS2"),这种可以直接接。买之前一定确认清楚,否则白折腾半天。
3.2 串口参数配置的坑
在 CubeMX 里配置 USART 时,参数必须严格按下面来:
| 参数 | 值 | 说明 |
|---|---|---|
| Baud Rate | 100000 | 不是 115200 |
| Word Length | 8 Bits | 8 数据位 |
| Parity | Even | 偶校验 |
| Stop Bits | 2 | 2 停止位 |
| Data Direction | Receive Only | 只收不发 |
| Over Sampling | 16 | 默认即可 |
偶校验这一项特别容易漏。SBUS 用偶校验,如果配成无校验,接收到的数据会错位,状态机永远找不到正确的帧头。
3.3 DMA 配置要点
DMA 配置里几个关键项:
- Mode:Circular
- Data Width:Byte(Peripheral 和 Memory 都是 Byte)
- Priority:High 或 Very High,SBUS 是实时数据,优先级低了容易被别的 DMA 抢占
- Memory Increment:Enable,缓冲区地址要递增
- Peripheral Increment:Disable,串口 DR 寄存器地址固定
缓冲区定义成uint8_t sbus_buf[50],全局变量,别放栈上。
3.4 IDLE 中断的开启方式
HAL 库默认不开启 IDLE 中断,需要手动调用:
__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);这一句通常放在MX_USART2_UART_Init()之后,或者放在HAL_UART_Receive_DMA()之后。注意顺序:先启动 DMA 接收,再开 IDLE 中断,否则可能第一个 IDLE 中断来时 DMA 还没准备好。
启动 DMA 接收的调用:
HAL_UART_Receive_DMA(&huart2, sbus_buf, 50);3.5 状态机的状态划分
状态机我设计成三个状态:
- WAIT_HEAD:等待帧头 0x0F
- RECV_DATA:收到帧头后,持续收集后续字节
- CHECK_FRAME:凑够 25 字节后校验帧尾
实际实现时,我更喜欢用一个frame_index计数器配合状态标志,而不是写三个 case。因为 SBUS 是定长帧,逻辑上更接近"找头 + 数够 25 个"。
4. 实操过程与核心环节实现
4.1 中断回调里的处理逻辑
HAL 库的 IDLE 中断不会自动进HAL_UART_IRQHandler的回调,需要自己在stm32f1xx_it.c的USART2_IRQHandler里判断:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); sbus_idle_callback(); } HAL_UART_IRQHandler(&huart2); }sbus_idle_callback()里做两件事:计算本次 DMA 写了多少字节,然后把这些字节喂给状态机。
计算已接收字节数的方法:
uint16_t recv_len = SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx);__HAL_DMA_GET_COUNTER返回的是 DMA 剩余待搬运的字节数,用缓冲区总大小减去它,就是已经写入的字节数。这是 Circular 模式下判断数据量的标准做法。
4.2 状态机解析代码
下面是我实际用的状态机核心逻辑,去掉了业务相关部分:
#define SBUS_FRAME_LEN 25 #define SBUS_HEAD 0x0F static uint8_t frame_buf[SBUS_FRAME_LEN]; static uint8_t frame_idx = 0; static uint8_t in_frame = 0; void sbus_parse_byte(uint8_t byte) { if (!in_frame) { if (byte == SBUS_HEAD) { frame_buf[0] = byte; frame_idx = 1; in_frame = 1; } return; } frame_buf[frame_idx++] = byte; if (frame_idx >= SBUS_FRAME_LEN) { uint8_t tail = frame_buf[SBUS_FRAME_LEN - 1]; if ((tail == 0x00) || (tail == 0x04) || (tail == 0x14) || (tail == 0x24)) { sbus_decode(frame_buf); } in_frame = 0; frame_idx = 0; } }这段代码的关键点:只有帧尾合法才解码。如果帧尾不对,说明这 25 字节里有错位,直接丢弃,等下一个 0x0F 重新开始。这样即使中间丢字节,最多丢一帧,下一帧立刻恢复。
4.3 通道数据解码
SBUS 的 16 个通道是 11 位打包的,22 字节承载 16 个通道,解码公式如下:
void sbus_decode(uint8_t *buf) { uint16_t ch[16]; ch[0] = ((uint16_t)buf[1] | ((uint16_t)buf[2] << 8)) & 0x07FF; ch[1] = ((uint16_t)buf[2] >> 3 | ((uint16_t)buf[3] << 5)) & 0x07FF; ch[2] = ((uint16_t)buf[3] >> 6 | ((uint16_t)buf[4] << 2) | ((uint16_t)buf[5] << 10)) & 0x07FF; ch[3] = ((uint16_t)buf[5] >> 1 | ((uint16_t)buf[6] << 7)) & 0x07FF; ch[4] = ((uint16_t)buf[6] >> 4 | ((uint16_t)buf[7] << 4)) & 0x07FF; ch[5] = ((uint16_t)buf[7] >> 7 | ((uint16_t)buf[8] << 1) | ((uint16_t)buf[9] << 9)) & 0x07FF; ch[6] = ((uint16_t)buf[9] >> 2 | ((uint16_t)buf[10] << 6)) & 0x07FF; ch[7] = ((uint16_t)buf[10] >> 5 | ((uint16_t)buf[11] << 3)) & 0x07FF; ch[8] = ((uint16_t)buf[12] | ((uint16_t)buf[13] << 8)) & 0x07FF; ch[9] = ((uint16_t)buf[13] >> 3 | ((uint16_t)buf[14] << 5)) & 0x07FF; ch[10] = ((uint16_t)buf[14] >> 6 | ((uint16_t)buf[15] << 2) | ((uint16_t)buf[16] << 10)) & 0x07FF; ch[11] = ((uint16_t)buf[16] >> 1 | ((uint16_t)buf[17] << 7)) & 0x07FF; ch[12] = ((uint16_t)buf[17] >> 4 | ((uint16_t)buf[18] << 4)) & 0x07FF; ch[13] = ((uint16_t)buf[18] >> 7 | ((uint16_t)buf[19] << 1) | ((uint16_t)buf[20] << 9)) & 0x07FF; ch[14] = ((uint16_t)buf[20] >> 2 | ((uint16_t)buf[21] << 6)) & 0x07FF; ch[15] = ((uint16_t)buf[21] >> 5 | ((uint16_t)buf[22] << 3)) & 0x07FF; /* 故障标志位 */ uint8_t failsafe = (buf[23] & 0x08) ? 1 : 0; uint8_t frame_lost = (buf[23] & 0x04) ? 1 : 0; /* 这里把 ch[] 和标志位交给上层业务 */ }这段解码看着吓人,其实规律很清晰:每 11 位一个通道,跨字节边界时用移位和或运算拼接。& 0x07FF是取低 11 位,防止高位脏数据。
实操心得:这段代码建议直接抄,别自己推。我见过太多人自己推的时候把某个通道的移位方向写反,结果通道 3 和通道 4 数据互换,调半天才发现。抄完用遥控器逐个通道推杆验证一遍,确认无误再往下做。
4.4 缓冲区指针管理
Circular 模式下有个细节要注意:DMA 写指针会绕回。如果一次 IDLE 中断里收到的数据跨越了缓冲区末尾,直接按recv_len从缓冲区头部读会读到旧数据。
我的处理方式是维护一个last_pos记录上次处理到的位置,每次计算本次新增数据时考虑绕回:
static uint16_t last_pos = 0; void sbus_idle_callback(void) { uint16_t curr_pos = SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); while (last_pos != curr_pos) { sbus_parse_byte(sbus_buf[last_pos]); last_pos++; if (last_pos >= SBUS_BUF_SIZE) last_pos = 0; } }这样无论 DMA 怎么绕,状态机拿到的都是连续的字节流。这是整个方案里最容易被写错的地方,很多人只处理了不绕回的情况,跑一段时间就出问题。
5. 常见问题与排查技巧实录
5.1 收不到数据或全是乱码
按下面顺序排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无数据 | 反相电路没做或接反 | 用示波器看 RX 引脚空闲电平,应为高 |
| 全是 0x00 | 波特率配错 | 确认是 100000 不是 115200 |
| 数据错位 | 校验位配错 | 确认是 Even 偶校验 |
| 偶尔乱码 | DMA 优先级低被抢占 | 提高 DMA 通道优先级 |
| 帧头找不到 | 停止位配错 | 确认是 2 停止位 |
5.2 IDLE 中断不触发
最常见的原因是忘了调__HAL_UART_ENABLE_IT。另一个原因是HAL_UART_Receive_DMA没调用,DMA 没启动,串口根本没在收数据,自然不会有 IDLE。
还有一种情况是中断优先级配置冲突,IDLE 中断被别的更高优先级中断一直压着。检查 NVIC 里 USART 的抢占优先级,别设得太低。
5.3 帧尾校验一直失败
如果帧头能找到但帧尾总是不对,八成是中间丢了字节。可能原因:
- DMA 缓冲区太小,一帧还没处理完就被新数据覆盖
- IDLE 中断处理太慢,比如在中断里做了浮点运算或打印
- 串口波特率实际偏差太大,接收机晶振和 STM32 晶振不匹配
避坑技巧:IDLE 中断里只做"搬运 + 喂状态机",所有业务处理(比如把通道值转成 PWM、发到上位机)都放到主循环里做。中断里越短越好,这是铁律。
5.4 通道值抖动
如果通道值稳定但偶尔跳变,检查两点:一是遥控器本身的中位校准,二是解码时的& 0x07FF有没有漏。漏了掩码会把相邻通道的高位带进来,表现为某个通道值突然变大。
5.5 多路 SBUS 同时接收
有些项目需要接两路甚至三路 SBUS(比如双遥控冗余)。这时候每路都要独立的缓冲区、独立的状态机变量、独立的 IDLE 回调。别想着复用一套变量,会串数据。DMA 通道也要分开,STM32 的 DMA 通道数量有限,规划好再分配。
6. 性能优化与扩展思路
6.1 CPU 占用实测
在 F103C8T6(72MHz)上实测,这套方案跑单路 SBUS,CPU 占用大约 1.5% 到 2%。其中 IDLE 中断本身开销很小,主要消耗在状态机逐字节处理和通道解码上。如果把解码也放到中断里,占用会升到 5% 左右,所以还是建议解码放主循环。
6.2 从单路扩展到多路
多路扩展时,我习惯把状态机封装成结构体:
typedef struct { uint8_t buf[SBUS_FRAME_LEN]; uint8_t idx; uint8_t in_frame; uint16_t last_pos; uint16_t ch[16]; uint8_t failsafe; uint8_t frame_lost; } sbus_ctx_t;每个串口对应一个sbus_ctx_t实例,解析函数接收sbus_ctx_t *ctx参数。这样代码干净,扩展方便,也不会出现变量串扰。
6.3 和 OTA、Modbus 等协议共存
很多项目里 SBUS 只是其中一个串口,另外还有 Modbus 或 OTA 升级用的串口。这时候要注意 DMA 通道分配和中断优先级。我的经验是:SBUS 用最高优先级(实时性要求最高),Modbus 次之,OTA 最低(升级时其他功能可以暂停)。DMA 通道如果不够用,可以考虑用 DMAMUX(部分型号支持)或者把低优先级串口改回中断接收。
6.4 数据校验的加强
标准 SBUS 只有帧尾校验,没有 CRC。如果应用场景对可靠性要求极高(比如工业遥控),可以在应用层加一层校验,比如连续两帧通道值差异超过阈值就判定为异常帧丢弃。这个逻辑放在解码之后、业务处理之前,成本很低但效果明显。
7. 我踩过的几个真实坑
第一个坑是反相电路用了 PNP 三极管。当时手头没有 NPN,想着 PNP 也能反相,结果电平逻辑完全反了,调了一下午才发现。反相电路必须用 NPN 或者专用反相芯片,别想着用 PNP 凑合。
第二个坑是DMA 缓冲区设成 25 字节。想着正好一帧,省内存。结果 IDLE 中断稍微晚一点,第二帧的头就覆盖了第一帧的尾,帧尾校验永远失败。后来改成 50 字节(两帧)就稳了。缓冲区至少留一帧的余量,这是血的教训。
第三个坑是在 IDLE 中断里调用了printf。调试时想看看收到多少字节,就在中断里加了个打印,结果串口输出把 SBUS 接收时序全打乱了,数据疯狂丢。中断里绝对不能做阻塞操作,调试信息要么用变量记录后主循环打印,要么用 GPIO 翻转配合逻辑分析仪看。
第四个坑是忘了清 IDLE 标志。__HAL_UART_CLEAR_IDLEFLAG这一句漏了,中断会反复触发,CPU 直接跑飞。这个标志必须清,而且要在读数据之前清。
8. 代码组织建议
最后说说工程结构。我一般把 SBUS 相关代码拆成三个文件:
sbus.h:结构体定义、函数声明、宏定义sbus.c:状态机、解码、上下文管理sbus_port.c:和 HAL 库的对接层,包括 IDLE 回调、DMA 启动、串口初始化后的钩子
这样分层的好处是,换芯片型号时只需要改sbus_port.c,核心解析逻辑完全不用动。我有个项目从 F103 换到 F407,只改了 port 层的几行代码,半天就迁移完了。
中断服务函数里保持极简:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); sbus_port_idle_handler(&huart2, &sbus_ctx[0]); } HAL_UART_IRQHandler(&huart2); }sbus_port_idle_handler里只做指针计算和喂状态机,不碰业务。业务层通过查询sbus_ctx[0].ch[]拿数据,或者用回调通知。这种"中断收数据、主循环处理"的模式,是我在多个量产项目里验证过最稳的结构。
如果你正在做基于 STM32 的遥控接收、飞控或者机器人项目,这套 SBUS 解析方案可以直接拿去用。先把反相电路焊对,再把串口参数配准,剩下的就是抄代码调通的事。真正花时间的从来不是解析逻辑本身,而是那些硬件和配置上的细节坑。