做飞控、车模、机器人底盘这类项目,遥控接收机的 SBUS 协议迟早会碰到。我第一次用它的时候,直接拿着逻辑分析仪看信号,发现不是逻辑分析仪能直接识别的 115200,而是一路反相的 100000bps 串口,当时还不明白为什么飞控代码里全是“DMA 循环接收 + IDLE 中断 + 状态机”这套组合。把这套链路完整跑通后,我最大的感受是:SBUS 本身不复杂,复杂的是如何在 Cortex-M 上稳定、低延迟、不丢帧地把 25 个字节抠出来。这篇文章就把我实际移植和测试过程中用到的 HAL 库实现、DMA 环形缓冲区管理、IDLE 中断处理以及字节级状态机解析全部拆开讲清楚。
1. 解析之前,先把 SBUS 的物理层和帧格式盘明白
1.1 电气层:100000bps、8E2、反相电平
SBUS 全称 S.BUS,最早是通信协议厂商在遥控模型领域推的一种串行总线协议。它底层就是 UART 串口,但有三点和普通串口不一样:
- 波特率不是 115200,而是 100000bps。
- 数据格式不是 8N1,而是 8E2,也就是 8 个数据位、偶校验、2 个停止位。
- 信号电平是反相的。
反相这个点最坑。我们把遥控接收机的 SBUS 输出直接接到 STM32 的 RX 引脚上,如果中间没有做电平反相,收到的字节就会完全不正常。0x0F 这个同步字节反相之后会变成 0xF0,15 变成 240,整个帧解析直接失败。串口本身不会帮你纠正这个逻辑,因为硬件只认 RX 引脚上的高低电平。
解决办法有几种:最简单的是用一颗反相器芯片,比如 74HC04、CD4069 这类逻辑门,把信号反转后再进单片机;也可以在 F3、F7、L4 等支持 RXINV 位反转功能的 STM32 系列上,直接配置 USART 的 RX 信号极性反转。注意,老一代的 F1、F4 的 USART 很多没有这个寄存器位,不能靠软件解决,只能改硬件。我的实际建议是:做开发板或者转接板的时候,直接把反相器电路画在接收机输入到 MCU 之间,调试会省很多事。
1.2 帧格式:25 字节,无 CRC,靠边界字符对齐
SBUS 每一帧固定 25 字节,结构如下:
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0 | 0x0F | 帧头同步字节 |
| 1 ~ 22 | 16 个通道数据 | 每个通道 11 bit,共 176 bit,打包成 22 字节 |
| 23 | 标志字节 | 包含第 17、18 通道以及丢帧、失控保护状态 |
| 24 | 0x00 | 帧尾结束字节 |
接收机按照固定周期向外发帧,常见的周期是 7ms 或 14ms,具体取决于接收机工作模式和协议版本。也就是说,两帧之间一定会有电平空闲时间,这个空闲时间就是 IDLE 中断能够利用的边界信号。
协议没有 CRC、没有校验和,帧头 0x0F、帧尾 0x00 是唯一的对齐依据。这意味着解析器不能只依赖“字节流按顺序推进”这样简单的逻辑,一旦串口出现一个字节丢失,后续帧就会整体错位,必须有状态机来重新同步。
1.3 固定帧长也要做边界检测的原因
有人会问:既然 SBUS 固定 25 字节,我直接收到 25 个字节解析一次不就行了?实际工程里不能这么做。原因有两个:
第一,接收机上电后不会立刻进入稳定输出状态,串口线上可能出现前半段乱码。如果只按长度硬切,第一个字节不是 0x0F,后面整条数据流都会错位。
第二,嵌入式环境里干扰会导致个别字节丢失。丢失一个字节后,如果不在下一个 IDLE 边界重新对齐,那么后续 25 字节块里的每个通道值都会是错的,而且这种错误很难在应用层发现。
IDLE 中断在这里的作用是:告诉我“串口线上已经空闲了一段时间”,这段时间就是帧之间的停顿点。我在空闲点处理缓冲区里的数据,配合状态机去寻找 0x0F,就能比较可靠地恢复对齐。
2. 接收方案为什么选 DMA 循环接收 + IDLE 中断
2.1 三种主流接收方式对比
在 STM32 上接收 SBUS,最常见的方案有三种:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| RXNE 单字节中断 | 每个字节进一次中断,读取后放入数组 | 实现简单,思路直观 | 每 83us 进一次中断,CPU 被频繁打断,波特率稍快或主频较低时会出问题 |
| DMA 半满/全满中断 | DMA 缓冲区半满或全满触发一次回调 | 中断频率低 | 只能按固定长度分段,无法感知帧边界,需要额外配合超时逻辑 |
| DMA 循环接收 + IDLE 中断 | DMA 不停往环形缓冲区写入,串口空闲时触发 IDLE 中断 | 中断少、不丢字节、帧边界清晰 | 逻辑略微绕,需要理解环形缓冲区和 DMA 当前计数 |
SBUS 速率 100000bps,每个字符大概 83us。如果不开 DMA,应用代码会一直被打断,在跑姿态解算、电机控制的时候,这种高频中断往往导致控制环路抖动。用 DMA 之后,数据搬运交给硬件,CPU 只需要在每一帧空闲的时候做一次解析,这是目前飞控领域普遍采用的做法。
2.2 IDLE 中断到底在检测什么
串口 IDLE 中断检测的不是“帧结束”,而是“总线上已经空闲了整整一个字符时间”。对于 SBUS 来说,接收机每 7ms 或 14ms 发一帧,帧和帧之间一定有超过一个字符时间的空闲,所以每个帧间隔都会触发一次 IDLE。
这里有个容易混淆的点:IDLE 中断触发之后,DMA 并没有停止,它仍然在等待下一个字节写入。我们只是在 IDLE 中断里记下“目前 DMA 已经把数据写到了哪里”,然后处理环形缓冲区中从上一次处理位置到当前位置之间的数据。
2.3 利用 DMA 的 NDTR 反推当前写入位置
DMA 循环模式下,每写入一个字节,硬件计数寄存器 NDTR 就减 1。当 NDTR 减到 0,硬件会自动重新装载初始值,继续新一轮循环。所以当前 DMA 写入位置可以这样算:
current_pos = BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);由于 IDLE 期间不会来新字节,NDTR 暂时静止,在中断里读到的是一个稳定值。这个技巧是整套方案的核心,理解它之后再调代码,思路会非常清晰。
缓冲区大小建议选 2 的幂次,比如 256 或 512。这样计算环形索引可以用按位与代替取模,代码简洁,也避免编译器生成除法指令。
3. CubeMX 配置里的关键项与踩坑点
3.1 串口参数配置
在 CubeMX 里打开串口外设,配置如下:
- Baud Rate:100000
- Word Length:8 Bits
- Parity:Even
- Stop Bits:2 Bits
请确认串口外设的“高级参数”里不要擅自加上什么自动流控,SBUS 不需要。MODE 选择异步模式,只打开 RX 也是可以的,因为我们不需要向接收机发送数据。
这里再强调一下:CubeMX 生成的代码里,如果芯片支持 RX 信号反转,可以在 GPIO 设置里找 “RX Inversion” 或者在串口参数里配置;如果不支持,这个参数不会出现,你就必须外置反相电路。F103、F407 这类经典芯片通常没有 RXINV,不要浪费时间去翻寄存器,直接上反相器最稳妥。
3.2 DMA 配置
DMA 是这套方案的另一只脚。需要把 USART1_RX 对应的 DMA 请求添加为 DMA 通道,配置成循环模式:
- Direction:Peripheral To Memory
- Mode:Circular
- Peripheral Increment:Disabled
- Memory Increment:Enabled
- Peripheral Data Width:Byte
- Memory Data Width:Byte
- Priority:High 或 Very High
Priority 建议给到 High。虽然 SBUS 数据量不大,但 DMA 正在搬运过程中如果不断被其他 DMA 请求打断,可能会出现字节间隔抖动。对于实时性要求比较高的场景,优先级给高一点没有坏处。
3.3 中断优先级设置
串口 IDLE 中断需要开启。使用 CubeMX 时,把 USART1 global interrupt 勾上。优先级需要注意:如果系统里跑了 FreeRTOS,我建议把串口中断优先级设为比 Tick 中断更高的抢占优先级,同时高于电机控制里比较紧急的定时器中断,或者至少不要低于它们。
为什么这么强调优先级?因为 IDLE 中断里要做环形缓冲区位置记录和简单解析,这个操作很短,但优先级过低会导致中断被其他紧急中断打断,拖得越久,下一帧数据就可能覆盖掉当前还在解析的数据。实测下来,把抢占优先级设为 0~2 之间比较安全。
3.4 缓冲区大小取舍
缓冲区开 256 字节已经足够。SBUS 一帧 25 字节,8ms 内循环写入 25 字节,即使解析被高优先级任务挤到很后面,缓冲区也不会写穿。如果你同时跑 OTA、蓝牙转发等占用 DMA 的场景,可以开到 512 字节,多消耗几十字节 RAM,解决不少偶发问题。
4. IDLE 中断与循环缓冲区窗口的衔接代码
4.1 定义缓冲区与结构体
先定义环形缓冲区大小、状态以及最终解析结果:
#define SBUS_BUF_SIZE 256u #define SBUS_BUF_MASK (SBUS_BUF_SIZE - 1u) #define SBUS_FRAME_LEN 25u #define SBUS_CH_NUM 16u static volatile uint8_t sbus_rx_buf[SBUS_BUF_SIZE]; static volatile uint16_t sbus_last_pos; typedef struct { uint16_t ch[SBUS_CH_NUM]; uint8_t ch17; uint8_t ch18; uint8_t frame_lost; uint8_t failsafe; uint8_t new_data; } sbus_channel_t; sbus_channel_t sbus;sbus_last_pos 记录上次已经处理到的缓冲区位置。这个变量在 IDLE 中断里更新,DMA 写入位置也是从 NDTR 反推出来的,两者本质都是缓冲区下标。
4.2 自定义串口中断服务函数
在 CubeMX 生成的工程里,默认的中断服务函数调用的是 HAL_UART_IRQHandler。这里我推荐直接写自己的中断服务函数,不调用 HAL_UART_IRQHandler。原因是 HAL 的接收逻辑主要靠 DMA 驱动,IDLE 事件本身在标准 HAL 接收流程里没有被完整利用,自己接管反而更干净:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_ORE) != RESET) { __HAL_UART_CLEAR_OREFLAG(&huart1); } if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); sbus_handle_idle(); } }注意,清 ORE 和清 IDLE 的顺序不能乱。OERE 标志如果一直存在,会影响后续 IDLE 判断,所以我在中断里先清溢出标志,再处理 IDLE。实际项目里如果串口线接触不良,溢出计数会不断累加,可以在应用层输出提示。
4.3 IDLE 事件里计算窗口并喂给状态机
sbus_handle_idle 的核心是计算本次空闲到上次处理位置之间的字节窗口:
void sbus_handle_idle(void) { uint16_t cur_pos; uint16_t start_pos; uint16_t len; cur_pos = (uint16_t)(SBUS_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx)); start_pos = sbus_last_pos; len = (uint16_t)(cur_pos - sbus_last_pos); sbus_last_pos = cur_pos; for (uint16_t i = 0; i < len; i++) { sbus_feed(sbus_rx_buf[(start_pos + i) & SBUS_BUF_MASK]); } }因为缓冲区大小是 256,cur_pos 与 sbus_last_pos 的差值天然是 0~255 之间的环状距离,不需要额外判断是否越界。起始位置 start_pos 可能是任意值,访问缓冲区时直接按位与 SBUS_BUF_MASK,就能正确处理跨末尾回绕。
4.4 关于 HAL_UARTEx_ReceiveToIdle_DMA 的提醒
新版本 HAL 库提供了官方 APIHAL_UARTEx_ReceiveToIdle_DMA,看起来也能在空闲时回调。但它是单次接收模式,不是循环模式。每次 IDLE 触发后都会停止接收,必须在回调里重新调用启动函数。这个重新启动的时间窗口里,输入引脚恰好有一个新字节的话,大概率会丢掉。
所以我在项目里坚持用“HAL 库负责初始化和 DMA 配置,串口中断自己接管 IDLE”的做法。这样既保留了 HAL 库的便利性,又避开了官方 API 一次性接收在连续帧场景下的丢字节问题。
5. 状态机解析:从字节流到 16 通道数据
5.1 状态设计与转移
解析 SBUS 帧的状态机分为三个状态:
| 状态 | 含义 | 转移条件 |
|---|---|---|
| ST_WAIT_SOF | 等待帧头 0x0F | 收到 0x0F,进入 ST_BODY |
| ST_BODY | 收集第 1~23 个后续字节 | 帧位置到达 24,进入 ST_TAIL |
| ST_TAIL | 确认帧尾 0x00 | 无论是否校验成功,都回到 ST_WAIT_SOF |
这里把第 24 个字节单独抽出来做校验,因为帧尾必须是 0x00。如果帧尾不对,说明这一帧中间丢过字节,不能使用,直接丢弃并回到等待状态。
状态机实现如下:
static uint8_t sbus_frame[SBUS_FRAME_LEN]; static uint8_t sbus_frame_pos; static uint8_t sbus_state; #define ST_WAIT_SOF 0u #define ST_BODY 1u #define ST_TAIL 2u static void sbus_feed(uint8_t b) { switch (sbus_state) { case ST_WAIT_SOF: if (b == 0x0F) { sbus_frame[0] = b; sbus_frame_pos = 1; sbus_state = ST_BODY; } break; case ST_BODY: sbus_frame[sbus_frame_pos++] = b; if (sbus_frame_pos == 24u) { sbus_state = ST_TAIL; } break; case ST_TAIL: sbus_state = ST_WAIT_SOF; if (b == 0x00) { sbus_parse_frame(); } break; default: sbus_state = ST_WAIT_SOF; break; } }有一个细节需要解释:ST_BODY 状态从 frame_pos = 1 开始,接收第 1 个到第 23 个后续字节。当 frame_pos 累加到 24,说明已经收了 SOF 加 23 字节,其中包含 22 字节通道数据加 1 字节标志位。剩下的最后一个 0x00 帧尾由 ST_TAIL 状态接收校验。这样每一帧的执行路径就严格对应 25 字节。
5.2 数据字节里出现 0x0F 怎么办
SBUS 的 22 字节通道数据里,完全可能出现值为 0x0F 的字节。在 ST_BODY 状态中,这个字节会被当作普通数据收集,不会跳回等待状态。因为状态机已经明确“当前在收帧体”,不需要再去找帧头。
真正需要警惕的是另一种情况:如果因为丢字节导致帧尾校验失败,状态机会回到 ST_WAIT_SOF,并等待下一个 0x0F。由于 SBUS 每个帧间隔都有 IDLE,下一个 IDLE 窗口会提供新的完整帧数据,所以只要接收机没有彻底损坏,重新同步很快。
5.3 解析结果生成与标志位读取
sbus_parse_frame 函数负责把 25 字节原始帧转换成通道值:
static void sbus_parse_frame(void) { const uint8_t *d = sbus_frame; const uint8_t *p = &d[1]; // 通道提取,见下一章 sbus.ch[0] = ((uint16_t)(p[0]) | ((uint16_t)p[1] << 8)) & 0x07FF; // ... 其余通道 sbus.ch17 = (d[23] & 0x80) ? 1 : 0; sbus.ch18 = (d[23] & 0x40) ? 1 : 0; sbus.failsafe = (d[23] & 0x08) ? 1 : 0; sbus.frame_lost = (d[23] & 0x04) ? 1 : 0; sbus.new_data = 1; }标志字节各 bit 的含义不需要全部猜,最重要的就四个:bit7 表示第 17 通道,bit6 表示第 18 通道,bit3 代表失控保护触发,bit2 代表信号丢失。应用层拿这两个状态位做急停、安全策略会非常方便。
6. 通道位打包、标志位处理与数值归一化
6.1 11 bit 通道数据的打包规则
SBUS 里 16 个通道,每个通道 11 bit,总共 176 bit,刚好塞进 22 字节。因为不是按字节对齐的,通道之间会跨字节拼接。提取的时候需要按位拆包。
从 22 字节 payload 的第一个字节开始,低位在前,可以手写一个通用位提取函数,也可以直接按公式展开。我在实际工程里直接用下面这套公式,省掉循环移位:
static void sbus_extract_channels(sbus_channel_t *sb, const uint8_t *p) { sb->ch[0] = (uint16_t)((p[0]) | ((uint16_t)p[1] << 8)) & 0x07FF; sb->ch[1] = (uint16_t)((p[1] >> 3) | ((uint16_t)p[2] << 5)) & 0x07FF; sb->ch[2] = (uint16_t)((p[2] >> 6) | ((uint16_t)p[3] << 2) | ((uint16_t)p[4] << 10)) & 0x07FF; sb->ch[3] = (uint16_t)((p[3] >> 1) | ((uint16_t)p[4] << 7)) & 0x07FF; sb->ch[4] = (uint16_t)((p[4] >> 4) | ((uint16_t)p[5] << 4)) & 0x07FF; sb->ch[5] = (uint16_t)((p[5] >> 7) | ((uint16_t)p[6] << 1) | ((uint16_t)p[7] << 9)) & 0x07FF; sb->ch[6] = (uint16_t)((p[6] >> 2) | ((uint16_t)p[7] << 6)) & 0x07FF; sb->ch[7] = (uint16_t)((p[7] >> 5) | ((uint16_t)p[8] << 3)) & 0x07FF; sb->ch[8] = (uint16_t)((p[8]) | ((uint16_t)p[9] << 8)) & 0x07FF; sb->ch[9] = (uint16_t)((p[9] >> 3) | ((uint16_t)p[10] << 5)) & 0x07FF; sb->ch[10] = (uint16_t)((p[10] >> 6) | ((uint16_t)p[11] << 2) | ((uint16_t)p[12] << 10)) & 0x07FF; sb->ch[11] = (uint16_t)((p[11] >> 1) | ((uint16_t)p[12] << 7)) & 0x07FF; sb->ch[12] = (uint16_t)((p[12] >> 4) | ((uint16_t)p[13] << 4)) & 0x07FF; sb->ch[13] = (uint16_t)((p[13] >> 7) | ((uint16_t)p[14] << 1) | ((uint16_t)p[15] << 9)) & 0x07FF; sb->ch[14] = (uint16_t)((p[14] >> 2) | ((uint16_t)p[15] << 6)) & 0x07FF; sb->ch[15] = (uint16_t)((p[15] >> 5) | ((uint16_t)p[16] << 3)) & 0x07FF; }注意,这套公式基于“payload 从 p[0] 开始”的假设,对应帧里的 d[1]。如果你的帧数组结构里没有单独把 payload 指针提出来,记得把下标整体加一。
6.2 三种打包模式中常见的对齐错误
很多人在手写公式时会犯一个错:把第 3 通道公式写成最低位从 p[2]、p[3] 开始。原因是第 2 通道只占用了 p[2] 的一部分,于是在脑内把“下一字节开头”当成对齐点。但位流不是字节对齐的,通道 2 结束于 p[3] 的最高位附近,通道 3 从 p[3] 剩余位之后开始。所以必须严格按 bit 位置推。
如果不想手推公式,也可以用通用位读取函数:用一个 bit 游标循环 16 次,每次取 11 bit。代码跑得略慢一点,但是不会错。SBUS 帧率最高才 200Hz,解析耗时几微秒完全可接受。
6.3 通道值到 PWM 脉宽的归一化
SBUS 原始通道值范围一般是 173~1811,对应标准遥控器的脉宽 1000~2000us。工程上经常需要把原始值转换成实际占空比或者 PWM 微秒数:
int32_t raw = sbus.ch[i]; int32_t us = 1000 + (raw - 172) * (2000 - 1000) / (1811 - 172); if (us < 1000) us = 1000; if (us > 2000) us = 2000;如果你用的是某些特殊接收机,通道范围可能输出 0~2047,那么比例系数就要按实际测到的 min/max 调整。建议在调试阶段先把所有通道原始值打印出来,看五个遥控杆位打到极限时,数值到底落在什么范围,再填归一化参数。
6.4 标志位的应用建议
ch17、ch18 常用于扩展通道。如果没有用到,直接把标志位保留在结构体里,不要丢弃。失控保护这一位一定要及时处理,我在项目里就是检测到 failsafe 置位后,舵机输出立刻回到安全位置,同时点亮一个警告灯。如果没有这个处理,失控时设备可能保持最后姿态,很容易出事故。
7. 实测调试经验与几个易忽略的坑
7.1 先量波形,再看解析结果
调试 SBUS 的第一步,永远是逻辑分析仪或示波器量接收机输出引脚。重点看三样:
- 波形是否反相。正常情况下 IDLE 时接收机输出为低电平,数据位是高电平,和传统 UART 相反。
- 波特率是否接近 100000。用逻辑分析仪的 UART 解码器,选 100000、8E2、反相通道,如果解码出的十六进制是 0F 开头,说明信号链路已经正确。
- 帧间隔是否稳定。示波器上帧头到下一个帧头之间是 7ms 或 14ms 左右的低电平停顿。
这个环节做完,后续基本不会出现“为什么解析出来全是乱码”的问题。跳过这个步骤,往往会在代码里找不到原因。
7.2 溢出标志是排查接线的第一指标
如果 IDLE 中断里经常出现 ORE 标志,别急着查状态机。ORE 溢出通常在两种情况下出现:DMA 没有正确循环、或者信号线质量差导致字节间隔抖动。先检查hdma_usart1_rx是不是真的配置成了 Circular 模式,如果 CubeMX 里选成了 Normal,缓冲区会在第一次收满后停止搬运,UART 的 RXNE 挂起不再被 DMA 取走,即便用软件清除 ORE,也只能维持很短时间。
7.3 不要让解析函数长时间霸占中断
状态机解析和通道提取都放在中断里执行,整个流程非常短,但要防止有人在函数里加打印、加浮点运算。串口打印本身是阻塞的,会拖长 IDLE 中断时间。调试阶段可以用标志位sbus.new_data在主循环里打印,不要直接在中断里调试输出。
如果必须在中断里打印通道值,也请确保串口发送使用的是 DMA 方式,否则每打印一个字都要等串口移位结束,几帧 SBUS 数据早就过去了。
7.4 RTOS 环境下的互斥处理
跑 FreeRTOS 时,主任务读取 sbus.ch[] 数组,串口中断写这个数组,需要做临界保护。最简单的方法是在主任务读取前关中断,读完后开中断:
__disable_irq(); memcpy(&app_sbus, &sbus, sizeof(sbus)); __enable_irq();因为 sbus 数据结构很小,关中断时间极短,不会有实时性问题。千万不要把 sbus 传指针给多个任务同时读,这种数据竞争排查起来比较痛苦。
7.5 HAL 版本差异带来的隐藏坑
我遇到过一种诡异现象:用的 STM32F4 系列,HAL 库版本升级之后,原本正常工作的串口 DMA 接收突然“卡住”。后来发现是新版 HAL 的 HAL_UART_IRQHandler 里加入了 IDLE 处理逻辑,它会主动清掉 IDLE 标志,导致我自定义的中断处理函数可能永远等不到 IDLE。
如果碰到这种情况,优先检查是不是重复调用了 HAL_UART_IRQHandler。我的做法是:CubeMX 生成代码后,把串口中断服务函数整体替换掉,不调用 HAL 的那一层。这样无论 HAL 库怎么升级,IDLE 处理逻辑都始终掌握在自己手里,行为可预期。
7.6 缓冲区尺寸、通道顺序与地面站验证
最后再提醒一个容易忽略的点:不同品牌接收机输出的 SBUS 通道顺序不一定一样。比如 Futaba 接收机输出是 1~16 通道按标准顺序,有些第三方接收机可能把通道顺序打乱。上位机显示的时候,把遥控器的油门杆推到高位,如果对应通道值没有跟着变,先确认通道编号映射,不要急着怀疑解析算法。
验证整个方案是否正常,我习惯把 DER 值打印出来,然后连续拨动遥控器所有通道,观察串口输出是否在每帧 IDLE 后被刷新。确认解析稳定后,再交到应用层使用。这套 DMA 循环接收加 IDLE 中断加状态机的组合,我后来在多个项目上复用,只要注意上面几个坑,基本都能一次跑通。