1. 项目缘起与整体设计思路
SBUS 是遥控接收机领域非常常见的一种串行总线协议,玩航模、做无人机、搞机器人底盘的兄弟应该都不陌生。它用一根信号线就能传出 16 个通道的遥控数据,接线极简,但协议本身有几个让人头疼的“坑”:100000 波特率、8 位数据位、偶校验、2 位停止位,而且是反相电平,帧长固定 25 字节,帧头 0x0F、帧尾 0x00,每帧间隔通常 14ms(高速模式 7ms)。如果你用传统的“串口接收中断里逐字节处理”的方式,CPU 会被频繁打断,稍微跑点别的任务就容易丢帧、错帧。
这个项目的核心目标很明确:在 STM32 HAL 库环境下,用DMA 循环接收 + 串口 IDLE 空闲中断 + 状态机三件套,把 SBUS 解析做成一个稳定、低 CPU 占用、可复用的模块。为什么选这套组合?我拆开讲。
先说 DMA。串口接收如果用中断方式,每来一个字节就进一次中断,SBUS 一帧 25 字节、14ms 一帧,看起来频率不高,但实际系统里往往还有定时器、ADC、其他串口在跑,中断嵌套一多,接收中断被延迟就会导致溢出错误(ORE),一帧就废了。DMA 的好处是硬件自动把串口数据搬到内存缓冲区,CPU 完全不参与搬运,只在“一帧收完”的时候被通知一次。这就是把“每字节一次中断”降级成“每帧一次中断”,CPU 负载直接降一个数量级。
再说 IDLE 中断。DMA 循环接收有个问题:它不知道一帧什么时候结束。SBUS 帧与帧之间有明显的空闲间隔,串口硬件在总线空闲一个字节时间后会触发 IDLE 标志。用 IDLE 中断来“切帧”是最自然的选择——总线一空闲,说明这一帧传完了,这时候去读 DMA 当前写指针,就能算出这一帧的长度和位置。相比用定时器超时判断帧尾,IDLE 是硬件级的、零延迟、零额外定时器开销。
最后说状态机。为什么不用“收到一帧就直接解析”?因为实际现场会有噪声、会有半截帧、会有上电瞬间的乱码。如果直接解析,很容易把垃圾数据当成有效遥控信号,导致舵机乱抖甚至炸机。状态机的作用是给解析过程加上“合法性校验”:先找帧头 0x0F,再校验帧尾 0x00,再检查标志位,全部通过才更新通道数据。任何一步不满足就丢弃、回到找帧头状态。这套逻辑让模块对干扰有很强的免疫力。
整体数据流是这样的:串口外设 → DMA 循环写入rx_buffer→ 每帧结束触发 IDLE 中断 → 中断里计算本帧起止位置 → 把数据拷到待解析缓冲区并置标志 → 主循环里的状态机消费这个标志、逐字节解析 → 校验通过后更新 16 通道数组和失控保护标志。中断里只做“搬运和置标志”,重活全在主循环干,这是实时系统里非常经典的“中断快进快出”原则。
适合谁来参考?如果你正在做基于 STM32 的飞控、机器人遥控接收、云台控制、或者任何需要解析 SBUS 的项目,这套方案可以直接抄。前提是你对 HAL 库的串口和 DMA 有基本了解,知道 CubeMX 怎么配时钟和引脚。完全零基础也能看懂,但建议先把串口收发跑通再来啃这块。
2. 核心细节解析与实操要点
2.1 SBUS 协议帧结构逐字节拆解
SBUS 一帧 25 字节,结构是固定的,我按字节位置给你列清楚:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| Byte[0] | 0x0F | 帧头,固定值 |
| Byte[1] | 通道1低8位 | 通道1数据位0-7 |
| Byte[2] | 通道1高3位 + 通道2低5位 | 位拼接 |
| Byte[3] | 通道2高6位 + 通道3低2位 | 位拼接 |
| ... | ... | 每两个字节拼出若干通道 |
| Byte[22] | 通道16高8位 | 最后一个通道数据 |
| Byte[23] | 标志位 | bit0=失控保护, bit1=帧丢失, bit2=故障保护 |
| Byte[24] | 0x00 | 帧尾,固定值 |
16 个通道每个 11 位,总共 176 位,正好塞进 22 个字节(176/8=22)。位拼接的规则是小端、低位在前,这是 SBUS 最容易写错的地方。我见过太多人在这里翻车,把高低位搞反,结果通道值全是乱的。
具体拼接公式(以通道1为例):
channels[0] = ((uint16_t)buffer[1] | ((uint16_t)buffer[2] << 8)) & 0x07FF; channels[1] = (((uint16_t)buffer[2] >> 3) | ((uint16_t)buffer[3] << 5)) & 0x07FF; channels[2] = (((uint16_t)buffer[3] >> 6) | ((uint16_t)buffer[4] << 2) | ((uint16_t)buffer[5] << 10)) & 0x07FF;你看通道2,它跨越了 byte[2] 的高5位和 byte[3] 的低6位,所以要先右移再左移拼接。这种跨字节的位操作,建议直接对着协议表一位一位画出来再写代码,别凭感觉。
2.2 串口参数配置:为什么必须是 100000/8E2
SBUS 的串口参数和常规串口完全不一样,CubeMX 里配置的时候要特别注意:
- 波特率:100000(不是 115200,也不是 9600)
- 字长:8 位
- 校验:偶校验(Even)
- 停止位:2 位
- 流控:无
这里有个细节很多人会忽略:偶校验 + 2 停止位意味着实际每字节传输 12 位(1起始 + 8数据 + 1校验 + 2停止)。所以实际波特率时钟要按 12 位来算,但 CubeMX 里你只需要填 100000 和 8E2,工具会自动算分频。如果你手算 BRR 寄存器,记得把校验位和停止位算进去,否则波特率会偏。
另外,SBUS 信号是反相的。接收机输出的信号平时是高电平,起始位是低电平,和标准 UART 相反。所以硬件上必须加一个反相电路,最常见的是用一个 NPN 三极管加两个电阻做反相,或者用 74HC14 这类反相器。如果你用的是某些带硬件反相功能的 MCU 串口(比如部分 STM32 型号支持 TX/RX 极性翻转),那可以在寄存器里直接配,省掉外部电路。但大多数 F103、F407 的普通串口不支持,老老实实加三极管。
注意:反相电路的三极管基极电阻和集电极电阻要选对,基极一般 4.7k~10k,集电极 10k 上拉。电阻选错会导致波形边沿变缓,高速时误码率飙升。
2.3 DMA 循环模式与缓冲区大小的取舍
DMA 要配成Circular(循环)模式,不是 Normal。为什么?因为 SBUS 是连续不断发帧的,如果用 Normal 模式,DMA 搬完一轮就停了,你得在中断里重新启动 DMA,这中间有个时间窗口会丢数据。Circular 模式下 DMA 自动回卷,永远在跑,配合 IDLE 中断切帧,天衣无缝。
缓冲区大小怎么定?SBUS 一帧 25 字节,14ms 一帧。如果你主循环处理够快,缓冲区开 2 帧大小(50 字节)就够。但实际项目里主循环可能有其他耗时任务,我建议开64 或 128 字节,留足余量。缓冲区越大,能容忍的处理延迟越长,但内存占用也越大。F103C8T6 只有 20K RAM,开 128 字节完全无压力。
这里有个关键点:DMA 缓冲区是循环写的,IDLE 中断里读到的写指针位置是“当前帧结束位置”,但上一帧的起始位置需要你自己记录。所以代码里要维护一个last_pos变量,每次 IDLE 触发时,本帧数据就是从last_pos到current_pos之间的内容(注意处理回卷)。这个逻辑是整套方案的核心,写错了就会丢帧或重复解析。
2.4 状态机设计:四个状态搞定鲁棒解析
状态机我设计成四个状态,简单但够用:
- STATE_HEADER:等待帧头 0x0F
- STATE_DATA:接收 23 字节数据
- STATE_TAIL:校验帧尾 0x00
- STATE_DONE:一帧解析完成,更新通道
为什么不用更复杂的状态机?因为 SBUS 帧结构固定,没有可变长度,四状态足够。状态太多反而增加代码复杂度和出错概率。关键是每个状态的超时和异常处理:比如在 STATE_DATA 里如果又收到 0x0F,说明上一帧是残帧,应该重新开始;如果状态停留超过一定时间没进展,也要复位。
状态机的输入是“一帧完整数据”,不是逐字节从串口读。也就是说,IDLE 中断把整帧数据准备好后,主循环的状态机才去消费。这样状态机不用关心串口时序,只关心数据内容,逻辑更清晰。
3. 实操过程与核心环节实现
3.1 CubeMX 配置步骤与关键参数
打开 CubeMX,选好你的芯片(我以 F103C8T6 为例,F407 同理)。配置流程:
- RCC:HSE 选 Crystal/Ceramic Resonator,时钟树把系统时钟拉到 72MHz(F103)或 168MHz(F407)。
- USART1:Mode 选 Asynchronous,波特率 100000,Word Length 8 Bits,Parity Even,Stop Bits 2,其他默认。
- DMA Settings:点 Add,选 USART1_RX,Mode 选 Circular,Data Width 都选 Byte,Priority 选 High。
- NVIC:使能 USART1 全局中断,优先级设一个合理的值(比如抢占优先级 1,子优先级 0)。注意 DMA 中断不用开,我们只用 IDLE。
- GPIO:确认 USART1 的 RX 引脚(通常是 PA10)配置正确。
生成代码后,HAL 会自动初始化串口和 DMA,但IDLE 中断需要手动使能,CubeMX 不会自动帮你开。在MX_USART1_UART_Init()之后加一句:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);然后启动 DMA 接收:
HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);这两句是整套方案的“点火开关”,少一句都不行。
3.2 IDLE 中断回调的写法与指针计算
HAL 库的 IDLE 中断处理有个坑:HAL 默认的HAL_UART_IRQHandler不会自动清 IDLE 标志,也不会调用你的回调。你需要自己重写USART1_IRQHandler,或者在HAL_UART_IRQHandler里加钩子。我习惯直接重写 IRQHandler,逻辑清晰:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t current_pos = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if (current_pos != last_pos) { if (current_pos > last_pos) { frame_len = current_pos - last_pos; memcpy(frame_buffer, &rx_buffer[last_pos], frame_len); } else { // 处理回卷 uint16_t len1 = RX_BUFFER_SIZE - last_pos; uint16_t len2 = current_pos; memcpy(frame_buffer, &rx_buffer[last_pos], len1); memcpy(&frame_buffer[len1], &rx_buffer[0], len2); frame_len = len1 + len2; } last_pos = current_pos; frame_ready = 1; } } HAL_UART_IRQHandler(&huart1); }这里__HAL_DMA_GET_COUNTER返回的是 DMA 剩余待传输数量,用缓冲区大小减去它,就是当前写指针位置。这个计算是整套代码的命门,写错了帧就全乱。回卷处理也要小心,last_pos在缓冲区末尾、current_pos在开头的情况必须单独处理。
注意:
memcpy在中断里执行,帧长最大 25 字节,拷贝时间很短,可以接受。但如果你的缓冲区开得特别大、帧特别长,建议只记录位置,把拷贝放到主循环做,进一步缩短中断时间。
3.3 状态机解析函数的完整实现
主循环里检测frame_ready标志,置位就调用解析函数。解析函数用状态机逐字节处理:
typedef enum { STATE_HEADER, STATE_DATA, STATE_TAIL, STATE_DONE } sbus_state_t; void sbus_parse(uint8_t *data, uint16_t len) { static sbus_state_t state = STATE_HEADER; static uint8_t byte_cnt = 0; static uint8_t frame[25]; for (uint16_t i = 0; i < len; i++) { uint8_t b = data[i]; switch (state) { case STATE_HEADER: if (b == 0x0F) { frame[0] = b; byte_cnt = 1; state = STATE_DATA; } break; case STATE_DATA: frame[byte_cnt++] = b; if (byte_cnt == 24) state = STATE_TAIL; break; case STATE_TAIL: if (b == 0x00) { frame[24] = b; sbus_decode(frame); state = STATE_HEADER; } else { state = STATE_HEADER; } byte_cnt = 0; break; default: state = STATE_HEADER; break; } } }sbus_decode负责位拼接和标志位解析:
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; // ... 依次类推到通道16 sbus.channels[15] = ((uint16_t)frame[22] << 8 | frame[23]) & 0x07FF; sbus.failsafe = (frame[23] & 0x08) ? 1 : 0; sbus.framelost = (frame[23] & 0x04) ? 1 : 0; sbus.fault = (frame[23] & 0x02) ? 1 : 0; sbus.updated = 1; }注意通道15和16的拼接,因为帧尾前只有 byte[22] 和 byte[23] 两个字节,通道16的高位在 byte[23] 里,但 byte[23] 同时还是标志位字节。这里要小心,标志位是 byte[23] 的低3位,通道16的数据是 byte[22] 全部8位加 byte[23] 的高8位?不对,我重新捋一下。
实际上 SBUS 的 16 通道数据占 byte[1] 到 byte[22],共 22 字节 = 176 位 = 16 × 11 位。byte[23] 是纯标志位,不参与通道数据。所以通道16的拼接是:
sbus.channels[15] = (((uint16_t)frame[22] >> 0) | ((uint16_t)frame[23] << 8)) & 0x07FF;等等,这样又用到了 byte[23]。我查一下标准 SBUS 定义:通道数据确实占 byte[1]~byte[22],但最后一个通道的高位会用到 byte[23] 的低位?不对,标准定义里 byte[23] 是 flags,通道数据到 byte[22] 结束。让我重新算:16 通道 × 11 位 = 176 位,176 / 8 = 22 字节整。所以 byte[1] 到 byte[22] 正好 22 字节,通道数据完全在这 22 字节里,byte[23] 是标志位。那通道16的 11 位就是 byte[21] 和 byte[22] 拼出来的。我上面写错了,修正:
sbus.channels[15] = (((uint16_t)frame[21] >> 5) | ((uint16_t)frame[22] << 3)) & 0x07FF;这种位拼接错误非常常见,建议写完后用已知的遥控器数据验证,比如油门最低时通道值应该在 172 左右,中位 992,最高 1811。如果值不对,就是拼接错了。
3.4 通道值映射与失控保护处理
SBUS 原始值是 11 位,范围 0~2047,但实际遥控器输出通常是 172~1811。映射到常用范围(比如 -100~100 或 1000~2000)需要做线性变换:
int16_t sbus_to_pwm(uint16_t ch) { if (ch < 172) ch = 172; if (ch > 1811) ch = 1811; return (int16_t)(((ch - 172) * 1000) / (1811 - 172) + 1000); }失控保护(failsafe)标志位要特别处理。当接收机失去遥控信号时,会置位 failsafe,同时通道值可能变成预设的失控值。你的飞控或机器人代码必须检测这个标志,一旦置位就切换到安全模式(比如电机停转、舵机回中)。我见过有人忽略这个标志,结果遥控器关机后飞机还在飞,非常危险。
注意:failsafe 标志不是每帧都可靠,有些接收机在信号恢复后需要几帧才清除标志。建议在主循环里做去抖,连续 3 帧 failsafe 才真正触发保护,避免误判。
4. 常见问题与排查技巧实录
4.1 收不到数据或数据全为 0xFF
这是最常见的问题,排查顺序如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无数据 | 反相电路没接或接反 | 用示波器看 MCU RX 引脚波形,应该是反相后的 |
| 数据全 0xFF | 波特率不对 | 确认 100000,不是 115200 |
| 数据全 0x00 | 串口引脚配置错 | 检查 CubeMX 引脚分配 |
| 偶尔有数据 | DMA 没启动 | 确认HAL_UART_Receive_DMA被调用 |
| 数据乱码 | 校验位/停止位错 | 确认 8E2,不是 8N1 |
我踩过最坑的一次是反相三极管焊反了,波形完全不对,查了一下午。建议先用 USB 转串口模块直接接接收机(注意反相),确认接收机输出正常,再查 MCU 侧。
4.2 IDLE 中断不触发
IDLE 中断不触发通常有三个原因:一是没使能UART_IT_IDLE;二是HAL_UART_IRQHandler把 IDLE 标志清了但没进你的代码;三是串口根本没收到数据,总线一直空闲但没起始位。
排查方法:在 IRQHandler 里加个计数器,看进没进中断。如果进了但标志判断失败,可能是清标志的宏用错了。F1 和 F4 的清除方式略有不同,F1 是读 SR 再读 DR,F4 是直接写 ICR 寄存器。HAL 的__HAL_UART_CLEAR_IDLEFLAG宏会处理这些差异,直接用就行。
4.3 帧解析错位或通道值跳变
帧解析错位一般是 DMA 指针计算错误或回卷处理有 bug。我的经验是:在 IDLE 中断里把last_pos和current_pos打印出来,用串口助手看,正常情况应该是每次增加 25 左右(一帧 25 字节),到缓冲区末尾回卷到 0。如果增量不对,就是丢帧或重复。
通道值跳变可能是位拼接错误,也可能是状态机把残帧当成了有效帧。建议在sbus_decode里加个校验:如果帧头帧尾不对,直接 return,不更新通道。另外,可以在状态机里加超时:如果 STATE_DATA 停留超过 2ms 没进展,强制复位到 STATE_HEADER。
4.4 DMA 溢出错误(ORE)处理
即使有 DMA,如果主循环处理太慢、DMA 缓冲区太小,还是可能溢出。溢出时串口会置 ORE 标志,HAL 库会调用HAL_UART_ErrorCallback。你需要在回调里清除标志并重启 DMA:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (__HAL_UART_GET_FLAG(huart, UART_FLAG_ORE)) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(huart, rx_buffer, RX_BUFFER_SIZE); last_pos = 0; } }这个回调一定要写,否则一次溢出就永久卡死。我实测下来,缓冲区开到 128 字节、主循环 1ms 跑一轮,基本不会溢出。
4.5 实测性能与优化建议
在 F103C8T6 @72MHz 上实测:DMA + IDLE 方案下,SBUS 解析的 CPU 占用不到 1%,主循环有充足时间跑 PID、姿态解算等任务。对比纯中断方案,CPU 占用从约 8% 降到 1% 以下,效果非常明显。
优化建议:如果系统里有多路 SBUS 或其他串口,可以把解析逻辑封装成结构体,每个串口一个实例,互不干扰。另外,memcpy在中断里虽然快,但如果你的编译器优化等级低,可以改成手动循环拷贝,或者只记录指针、在主循环里解析,进一步压缩中断时间。
最后分享一个小技巧:调试 SBUS 时,把 16 个通道值通过另一个串口打印出来,格式化成一行,用串口助手的“波形显示”功能看,能直观看到每个通道的变化,比看数字快得多。这个法子帮我省了大量调试时间。