开门见山说结论:做 SBUS 接收最舒服的组合,就是 DMA 循环接收 + 串口 IDLE 空闲中断 + 一个轻量状态机。这套方案我拿来解析 Futaba 接收机输出的 SBUS 信号,连续跑了几百个小时没出过丢帧错位的问题,中间换过好几版实现,最后沉淀下来的就是这一套。这篇文章把协议、配置、代码、还有我踩过的坑一起写清楚,适合正在做飞控、遥控接收机解析、或者想在 STM32 HAL 库下处理不定长串口帧的读者参考。
1. 为什么收 SBUS 先别急着写代码:方案定型才是关键
很多朋友拿到 SBUS 接收机,第一反应就是开一个串口中断,进一个字节收一个字节。SBUS 的波特率只有 100000bps,单看这个数字确实不高,一帧 25 字节算下来也就 3ms 左右,好像逐字节中断也能扛。但等你真正接到飞控或者舵机控制板上,就会发现事情没那么简单。
1.1 逐字节中断为什么容易翻车
逐字节 UART 中断的主要问题不是 CPU 性能不够,而是中断频率高、处理时间碎片化。SBUS 一帧周期典型值在 14ms 左右,但某些接收机在快速模式下可以压缩到 7ms 一帧。假设你还在中断里做通道解码、数据拷贝、标志位操作,一次中断几十微秒,算下来不会把 CPU 吃满,可一旦系统里还有定时器中断、SPI 通信、传感器轮询,中断互相挤压,就容易在某个瞬间丢掉串口数据。丢一个字节,整帧错位,SBUS 又不像普通串口协议有清晰的帧头定位容错机制,状态直接崩掉。
HAL 库还有一层额外开销。HAL_UART_RxCpltCallback 这种回调函数在接收完成后才会触发,逐字节模式下,中断里要先跑 HAL 库的状态机,再跳进回调,本身就有延迟。更麻烦的是,HAL_UART_Receive_IT()每次接收完成后都要重新调用一次,如果上一帧接收完成,而你还没来得及重新开始接收,中间这个窗口期的字节就永远丢了。早期我在这上面栽过跟头,最后彻底放弃逐字节方案。
1.2 固定长度 DMA 接收的尴尬
既然逐字节不行,自然会想到 DMA。SBUS 帧长看起来是固定的 25 字节,直接配一个 25 字节的 DMA 缓冲区,接收完成中断里把数据拿走,听起来干净利落。但 SBUS 的帧长没有那么严格固定:基础帧是 25 字节,其中第 25 字节是可选的 RSSI 信号强度字节,有的接收机输出 24 字节,有的输出 25 字节。如果你用固定 25 字节的 DMA,一旦实际数据是 24 字节,DMA 就会把下一帧的第一个字节(0x0F)吞进来"凑数",导致一帧数据整体错位一个字节,后面全是乱的。
你可能会说,接收完成中断触发后,再判断最后一个字节是不是 0x00 不就行了。但这解决不了根本问题:DMA 每次接收完成,都要重新配置一次缓冲区地址和传输长度,中间同样存在切换窗口期。SBUS 帧与帧之间的间隔,在快速模式下可能只有几百微秒,窗口期稍微长一点,就漏帧了。
1.3 为什么最终选了 DMA 循环 + IDLE 中断
最后我定型的方案是:串口 DMA 设置为循环接收模式,数据字节不断写入 DMA 缓冲区,由硬件自动完成,CPU 完全不用管。然后利用 UART 的空闲中断(IDLE Line Detect),在一帧数据发送完毕、总线进入空闲状态时触发中断。中断里做的事非常少,就是读取当前 DMA 写到了哪个位置,把上一次处理位置到这个位置之间的新数据,交给状态机解析。
这个方案的巧妙之处在于:DMA 循环模式天然解决了固定长度接收的错位问题,因为数据永远是连续写入环形缓冲区的,我们不需要关心某一帧该从哪个地址开始。IDLE 中断则解决了不定长帧的帧边界问题,每次发生空闲,说明一次 DMA 传输的"一段空闲间隔"结束了,这正好对应一帧数据(或者半帧数据,取决于发送节奏)。最后状态机负责在字节流里找帧头、裁剪有效数据,彻底摆脱了"一帧必须正好落在缓冲区边界"的限制。
1.4 状态机在这个方案里的定位
DMA 循环 + IDLE 中断只能保证"我把新收到的字节交给你了",但数据流是连续的,不保证每次交给你的字节刚好从 0x0F 帧头开始。比如说,上一次 IDLE 中断触发时,缓冲区里可能正好截断了半个帧,那么下一次中断交过来的新数据就是从帧中间开始的。这时候就需要一个状态机去追踪播放进度:当前在找帧头、还在收数据区、还是在等帧尾,每个字节进来都按照当前状态决定怎么处理。这样即使一帧被 IDLE 中断切成了两段、三段,最后也能完整拼出来。
2. SBUS 协议细节与反相问题:最容易搞错的物理层
聊完了方案,必须先确认协议本身。SBUS 这个名字听着高级,本质还是串口协议,但它有几个反直觉的设定,前面不弄清楚,后面代码全是白写。
2.1 帧格式:25 字节里的秘密
标准 SBUS 帧格式如下:
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0 | 0x0F | 帧头,固定值 |
| 1 ~ 22 | 通道数据 | 16 个通道,每个通道 11 bit,共 176 bit,正好塞满 22 字节 |
| 23 | 0x00 | 帧尾,固定值 |
| 24 | RSSI / 扩展 | 可选字节,部分接收机输出,表示信号强度 0~100 |
所以常规接收一帧,最少 24 字节,带 RSSI 则 25 字节。通道 0~15 每个 11 bit,意味着通道取值范围是 0~2047。实际遥控器输出时,中立点附近通常在 992~1056 之间,满幅范围常见 352~1811,这个范围用于映射舵机脉宽。这里容易踩的坑是:不要默认每个通道满量程都是 0~2047,不同接收机和遥控器校准不同,量产项目里最好做一次通道值归一化或校准映射。
帧尾 0x00 也很关键。SBUS 数据区里可能随机出现 0x00,所以不能光靠一个 0x00 就判定帧尾,必须配合完整帧长度判断。状态机里的做法是:只有已经收满 22 字节数据区,再等到的字节才作为帧尾候选,这样既不会把数据区里的 0x00 当成帧尾,也不会把帧尾漏掉。
2.2 波特率 100000 和 8E2 的坑
SBUS 的物理层参数是:100000bps,8 数据位,偶校验(Even Parity),2 停止位,即常说的 8E2。这个"2 个停止位"和"偶校验"的组合是第一个大坑。很多朋友照搬普通串口配置 8N1,结果收出来全是乱码,还以为是信号线接错了。
为什么偏偏要用 8E2?这是 Futaba 协议的老规矩。2 个停止位给了接收设备更宽裕的解析时间,偶校验则提供基础的错误检测。换个角度想,普通串口一字节是 1 起始 + 8 数据 + 1 校验 + 1 停止 = 11 bit,SBUS 是 1 起始 + 8 数据 + 1 校验 + 2 停止 = 12 bit。同样波特率下,SBUS 的实际数据吞吐率比普通 8N1 要低,但可靠性更高。
在 CubeMX 里配置时要注意,字长和校验位是联动的。如果你选了 Even Parity,数据位会自动加 1 位校验位,此时实际的有效数据位要配成 8,而 CubeMX 里 Serial parameters 的 Word Length 选项要选 8 Bits(even parity 状态下它等效于报文里的 9 bits 其中 8 位数据 1 位校验)。别选成 9 Bits,否则数据位+校验位一共 9 位,会直接错位。
2.3 最大的物理层坑:反相电平
SBUS 传输的是反相 UART 信号。也就是说,常规 UART 空闲时是高电平,SBUS 空闲时是低电平;起始位从高变低,SBUS 则是从低变高。STM32 的 USART 硬件不支持电平反相配置,如果你直接把接收机的 SBUS 输出引脚接到 MCU 的 RX 引脚,收出来的东西是完全乱的,因为起始位、数据位、停止位全都反了。
解决方式有两种。第一种是硬件反相,用一个 NPN 三极管或者专用反相器芯片,把 SBUS 反相信号转成正常 UART 电平。这个方案最稳,我在 FPV 接收机线路上常用一个 2N7000 MOSFET 加一个上拉电阻就搞定了,成本几毛钱。第二种是确认接收机是否同时输出了非反相的 SBUS 信号,有些地面站接收机支持固件配置输出极性,那就直接接。但如果你做通用产品,建议默认按反相处理,硬件上加反相电路,代码里不做任何电平相关的假设。
2.4 通道解码尺度的理解
22 字节塞 16 个 11bit 通道,本质就是比特流拼接。公式非常机械:每个通道占 11 位,第 n 个通道从整个 176bit 流的第 n*11 位开始。用代码实现时,最常见的方式是从第 1 个字节开始逐位搬运:
void sbus_decode_channels(const uint8_t *buf, uint16_t *channels) { uint32_t bit_buf = 0; int bit_pos = 0; for (int ch = 0; ch < 16; ch++) { bit_buf = 0; bit_pos = ch * 11; int byte_pos = bit_pos / 8; int bit_shift = bit_pos % 8; bit_buf = ((uint32_t)buf[1 + byte_pos] >> bit_shift); bit_buf |= ((uint32_t)buf[1 + byte_pos + 1] << (8 - bit_shift)); bit_buf |= ((uint32_t)buf[1 + byte_pos + 2] << (16 - bit_shift)); channels[ch] = bit_buf & 0x07FF; } }这个函数有个边界细节:最后一个通道(ch=15)的 bit_pos=165,byte_pos=20,也就是要读 buf[21]、buf[22]、buf[23] 三个字节。如果 buf 是 24 字节模式(没有 RSSI),buf[23] 恰好是帧尾 0x00,读进来做高位补位没问题,因为 11bit 只用得到后面 3bit,0x00 的高位不会影响结果。但如果你把缓冲区只开了 24 字节,代码里读 buf[23] 会越界,所以缓冲区至少留 25 字节,或者提前把帧尾 0x00 拷贝到第 24 位。
3. CubeMX 下的最小配置:从串口到 DMA 再到 IDLE 中断
方案定好了,协议也清楚了,接下来是动手配置。我一般用 STM32CubeMX 生成工程,但 IDLE 中断那一步它不会帮你全做完,下面把完整的配置链路串一遍。
3.1 串口参数配置
CubeMX 里选择你用的 UART,比如 USART1,设置如下:
- Baud Rate: 100000
- Word Length: 8 Bits(配合偶校验时注意看是否联动变成 9 Bits)
- Parity: Even
- Stop Bits: 2
这三个参数必须对着 SBUS 协议敲死,任何一个不对,DMA 收上来的数据都是错位的。检查一下 CubeMX 界面:当 Parity 选 Even,Word Length 显示 8 Bits 时,实际串口报文是 1 起始位 + 8 数据位 + 1 校验位 + 2 停止位,这是我们要的 8E2 效果。如果你选择 9 Bits + Even,报文会变成 9 数据位(其中高 1 位可能是校验)+ 2 停止位,直接踩坑。
3.2 DMA 配置与缓冲区大小
DMA Settings 里添加 USART1_RX,Direction 选 Peripheral To Memory,Mode 必须选 Circular。Circular 模式是这套方案的心脏,它让 DMA 自动循环写入,永远不停止,不需要我们在每帧完成后重新启动传输。Data Width 保持 Byte,内存地址递增模式开启。
缓冲区大小我建议开 128 字节。为什么选这个数?SBUS 一帧最大 25 字节,缓冲区如果太小,比如只开 32 字节,虽然理论上够装一帧,但 DMA 循环模式下,如果你在处理中断时,下一帧数据已经写进来了,就可能覆盖还没处理的区域。128 字节可以稳稳装下 4~5 帧数据,即使中断因为系统调度延迟了,也不会丢数据。而且 128 字节是 2 的整数次幂,DMA 地址对齐也舒服。
3.3 使能 IDLE 中断的时机
CubeMX 不会直接给你勾选"UART IDLE interrupt",需要手动操作。最容易踩的坑是顺序:应该先启动 DMA 接收,再使能 IDLE 中断,不能反过来。
正确顺序:
HAL_UART_Receive_DMA(&huart1, sbus_dma_buf, SBUS_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);先启动 DMA,确保硬件已经准备接收,再开 IDLE 中断。如果先开中断,DMA 还没开始接收,总线上任何一点噪声都可能触发一次虚假的 IDLE 中断,把状态机的节奏搞乱。
USE 空行分隔。
4. 核心代码:DMA 回绕裁剪、IDLE 中断处理与状态机解析
配置完成后,核心代码主要是三块:启动与中断处理、DMA 数据裁剪(处理环形缓冲区回绕)、状态机解析。这里给出一个可以直接移植的骨架。
4.1 缓冲区与全局变量设计
#define SBUS_BUF_SIZE 128 #define SBUS_FRAME_MAX 25 static uint8_t sbus_dma_buf[SBUS_BUF_SIZE]; static volatile uint16_t sbus_last_index = 0; static volatile uint8_t sbus_frame_ready = 0; static uint8_t sbus_frame_buf[SBUS_FRAME_MAX]; static uint8_t sbus_parse_state = 0; static uint8_t sbus_body_cnt = 0;sbus_last_index记录上一次 IDLE 中断时 DMA 写入的位置,每次中断都拿它跟当前 DMA 写位置做差,得到新数据区间。sbus_frame_ready是交递给主循环的标志位,标志位置位后,主循环去sbus_frame_buf里做通道解码。
4.2 DMA 当前位置与回绕处理
DMA 当前写位置怎么拿?通过读取 DMA 计数寄存器:
uint16_t sbus_get_dma_pos(void) { return SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); }__HAL_DMA_GET_COUNTER返回的是 DMA 还剩多少次传输没完成,用缓冲区总长度减掉它,就是当前已经写入的位置。这个位置范围是 0~127,随着 DMA 循环写入不断增长到 127 后再回绕到 0。
IDLE 中断触发时,我们要处理从sbus_last_index到当前位置之间的字节。但 DMA 是循环缓冲区,当前位置可能比上一次的小,说明 DMA 已经绕了一圈。这时要分两段处理:先处理上一次位置到缓冲区末尾的数据,再从缓冲区开头处理到当前位置。
void sbus_process_idle(void) { uint16_t cur_pos = sbus_get_dma_pos(); if (cur_pos >= sbus_last_index) { sbus_feed_bytes(&sbus_dma_buf[sbus_last_index], cur_pos - sbus_last_index); } else { sbus_feed_bytes(&sbus_dma_buf[sbus_last_index], SBUS_BUF_SIZE - sbus_last_index); sbus_feed_bytes(&sbus_dma_buf[0], cur_pos); } sbus_last_index = cur_pos; }这行代码看着简单,但回绕处理是整个 DMA 循环方案最容易写错的地方。漏掉cur_pos < sbus_last_index分支,或者分支里忘了分两段处理,都会导致缓冲区开头那一段新数据被丢掉。
4.3 UART 中断处理函数
有了数据裁剪函数,接下来就是串口中断处理。注意必须手动写 IRQHandler,HAL 库自带的HAL_UART_IRQHandler不会帮你处理 IDLE 中断的业务逻辑。
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); sbus_process_idle(); } }我强调一点:先调用HAL_UART_IRQHandler(&huart1)很重要。虽然我们的 DMA 循环模式不需要接收完成中断回调,但 UART 的过载错误(ORE)会在 HAL 库里被处理掉,不处理的话会卡死后续接收。调用 HAL 库的处理函数,再自己处理 IDLE,顺序不能反。
4.4 状态机的完整实现
状态机的目标是从连续的字节流中准确识别出一帧 SBUS 数据。我设计了三个状态:找帧头、收数据区、等帧尾。
void sbus_feed_bytes(uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint8_t byte = data[i]; switch (sbus_parse_state) { case 0: // 等待帧头 0x0F if (byte == 0x0F) { sbus_frame_buf[0] = byte; sbus_body_cnt = 0; sbus_parse_state = 1; } break; case 1: // 接收 22 字节数据区 sbus_frame_buf[1 + sbus_body_cnt] = byte; sbus_body_cnt++; if (sbus_body_cnt >= 22) { sbus_parse_state = 2; } break; case 2: // 等待帧尾 0x00 sbus_frame_buf[24] = byte; if (byte == 0x00) { sbus_frame_ready = 1; // 完整帧就绪 } sbus_parse_state = 0; // 无论帧尾是否正确,都回到找帧头 break; } } }这个状态机的边界处理非常关键。状态 0 遇到 0x0F 才进入数据区接收,数据区不关心内容是不是 0x00,这是为了避开数据区本身可能出现的 0x00 干扰。状态 2 只有完整收满 22 字节数据后才进入,因此这里的 0x00 判断不会被数据区污染。就算帧尾不是 0x00(极端情况下接收机输出异常),状态机也能自动回到找帧头状态,不会卡死。
这个状态机有个经典问题:如果一帧数据被 IDLE 中断切成了两半,比如第一次中断正好在字节 15 处,第二次中断从字节 16 继续,没问题,状态机是持久的,数据区计数sbus_body_cnt保留了上次进度。这正是我们需要状态机而不是"每次中断重新找帧头"的原因。
4.5 主循环消费与通道提取
状态机把字节流拼成完整帧后,主循环只需要消费标志位,提取通道:
while (1) { if (sbus_frame_ready) { sbus_frame_ready = 0; sbus_decode_channels(sbus_frame_buf, channel_values); // 这里可以继续做混控、舵机输出、遥测上报等业务 } }channel_values是 16 个 uint16_t 的数组。解码函数在前面 2.4 小节已经给出,注意缓冲区边界问题即可。另外,一帧 SBUS 数据从接收完成到解析出 16 个通道,总耗时应该在微秒级,完全不会对主循环造成卡顿。
4.6 为什么不用 HAL 库的其它接收接口
现在新的 HAL 库提供了HAL_UARTEx_ReceiveToIdle_DMA()这类灵活接收接口,封装了 DMA + IDLE 的部分逻辑。我试过,功能没问题,但缺点是回调机制和版本差异:老版本库函数名不一样,新版本回调参数又改了,代码迁移成本高。自己写 IRQHandler 只需要固定几个宏,不依赖 HAL 版本变化,长期维护更稳。另外自己手动裁剪缓冲区,逻辑完全透明,出了问题可以直接看寄存器状态,不用翻 HAL 库封装层。
5. 实测里值得注意的几个坑与调试验证手段
代码写完了,下面这部分全是实际测试中遇到的真实问题。有些问题排查了很久,写下来省得你再踩一遍。
5.1 逻辑分析仪是第一生产力
SBUS 调试第一个建议:先别急着看串口助手。SBUS 是 100000bps 8E2,大多数串口助手软件对 8E2 支持并不好,而且反相电平问题会让串口助手直接显示乱码。我的调试顺序是:先用逻辑分析仪抓 RX 引脚波形,确认有没有反相、波特率对不对、帧间隔是否符合预期。
用逻辑分析仪看 SBUS 波形时,注意观察空闲电平。如果空闲电平是低,说明信号确实是反相的,需要检查反相电路。如果空闲电平已经正常拉高,那可能是接收机已经输出了正相兼容信号。这一步确认完,再进代码层面调试,能够省掉大量无用功。
5.2 ORE 过载错误为什么会突然卡死
HAL 库 + DMA 接收时会遇到一个隐蔽问题:如果 DMA 因为某些原因没有及时取走数据,UART 硬件寄存器里新数据覆盖了旧数据,硬件会置位 ORE 错误标志。这个标志不手动清除,UART 会一直认为出错,后续所有中断都进不来,现象就是 SBUS 解析突然停止,再也没有新帧。
我在代码里HAL_UART_IRQHandler(&huart1)会自动处理 ORE 吗?部分 HAL 版本会,但为了确保万无一失,建议在判断 IDLE 之前,先做一个错误标志清理:
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_ORE)) { __HAL_UART_CLEAR_OREFLAG(&huart1); }这个保护加一次不会对性能有任何影响,但能避免偶发性的死等。更多时候 ORE 的原因不是代码问题,而是中断延迟过高,比如你在中断里做了长时间的浮点运算或者打印日志,导致数据来不及读。所以 ORE 出现时,优先看是不是中断里干了不该干的活。
5.3 帧周期稳定但通道值漂移?多半是波特率偏差
SBUS 协议对波特率误差比较敏感,尤其是 8E2 的 12bit 帧,加上 2 个停止位,接收端对每个字节的采样点容限比 8N1 更窄。STM32 的时钟一般很准,问题通常出在外部设备——某些国产接收机的晶振本身就偏。现象是:DMA 能收到数据,但状态机经常卡在错误状态,或者通道值持续漂移。
排查方法:用逻辑分析仪的协议解析看实际波特率。如果实际波特率是 99800 或者 100400,差异只有 0.2%,但积累到一整帧就足以产生错误。这种情况下,可以尝试调整 STM32 的 USART 过采样配置,或者接受设备本身的精度问题,在代码里增强错误容忍度(比如对帧头 0x0F 前后的位进行多次采样判断,但这个实现成本高,一般不建议)。
5.4 丢帧粘帧和 RSSI 字节的取舍
前面提到 SBUS 帧尾后有可选字节,有些接收机输出 24 字节,有些输出 25 字节。如果你固定按 25 字节解析,24 字节模式的接收机最后一帧会把下一个 0x0F 当作 RSSI 字节,结果通道值全部正常,但状态机在下一帧找帧头时会漏掉一个字节。反过来,如果固定按 24 字节解析,25 字节模式下 RSSI 会被当成下一帧的帧头,直接错乱。
我的处理方式:状态机里收完 22 字节数据区后,进入等待帧尾状态,收到 0x00 后立即产生一帧数据,此时如果后面还有一个字节 0x00(RSSI 字节),它会被当作下一帧的帧头去匹配,而 0x00 不等于 0x0F,所以会被忽略,不会对下一帧造成影响。状态机天然兼容两种模式,不需要特地判断接收机型号。
5.5 中断优先级怎么定
DMA 循环接收 + IDLE 中断这套方案下,串口中断优先级建议比系统节拍中断高,或者至少不低于其他高频中断。实测中,我把 UART 中断优先级设置为最高(抢占优先级 0),因为 IDLE 中断处理时间极短,反正就几十条指令,高优先级不会对系统造成明显影响。反而如果优先级低了,其他中断正在跑,IDLE 处理被推迟,DMA 缓冲区里多写一帧没问题,但如果有连续多帧高速到达,处理不及时就可能触发 ORE 或覆盖。
5.6 实测数据参考
我自己用 STM32F103 + STM32CubeMX FW_PACK V1.8.0 的 HAL 库测试,缓冲区 128 字节,100000bps 8E2,接收 Futaba R7008SB 输出的 SBUS 信号,连续运行 24 小时,解析出的通道值稳定,状态机一帧未丢。用另一个国产接收机测试,帧周期不稳定但能正常解析。然后把缓冲区改成 32 字节测试,在接收机快速连续输出帧时偶尔出现丢帧,说明 128 字节不是奢侈,是实际需求。
最后再分享一个提升鲁棒性的小技巧
调试稳定后,还可以给 SBUS 解析加一层"喂狗"式检测。比如状态机收到一个完整帧,但帧尾不是 0x00,别急着丢,可以打印出来看看是不是偶尔一次错帧。或者加一个接收超时判断:如果超过 100ms 没收到任何 SBUS 数据,说明接收机可能断电或者信号线断了,此时应该让输出通道回到安全值。这个超时逻辑不需要额外定时器,直接在状态机里记录上一次完整帧的时间戳,主循环用当前时间减一下就行。
这套方案跑顺之后,你会发现它不止能收 SBUS,任何不定长串口协议都可以套用。比如常见的 MAVLink 串口协议、GPS NMEA 协议、甚至自定义的调试协议,核心都是 DMA 循环接收 + IDLE 中断判断帧边界 + 状态机解析内容。方法是一样的,换一个状态机的状态定义而已。这也是我为什么愿意把这套方案沉淀下来的原因——一次搞定,处处复用。