1. 为什么SBUS解析值得单独拎出来讲
SBUS这个协议,玩过航模或者机器人底层通信的朋友应该都不陌生。它本质上是Futaba搞出来的一种串行总线协议,物理层跑的是反相串口,波特率固定100000,数据格式是8位数据位、2位停止位、偶校验。一帧25个字节,头字节0x0F,尾字节根据帧类型可能是0x00、0x04、0x14、0x24这些,中间22个字节承载16个通道的11位数据,外加两个标志位字节。
听起来不复杂对吧?但真正落到STM32上用HAL库去接,坑就来了。第一个坑是波特率——100000不是标准波特率,很多人用115200去接,结果收到的全是乱码。第二个坑是反相——SBUS信号是反相的,你得在硬件上加一个反相电路,或者用串口的RX引脚配置内部反相(部分STM32型号支持)。第三个坑是帧同步——你怎么知道一帧从哪开始、到哪结束?如果用传统的“收一个字节进一次中断”的方式,CPU会被频繁打断,尤其在100000波特率下,每100微秒就来一个字节,主循环基本不用干别的了。
所以这篇东西的核心思路就是:用DMA把接收这件事从CPU手里接过来,用IDLE中断来标记帧边界,再用一个状态机去解析数据。这套组合拳打下来,CPU占用率极低,解析逻辑清晰,而且扩展性强。适合谁看?适合已经会用CubeMX配串口、但还没把DMA+IDLE这套机制吃透的嵌入式开发者,也适合正在做遥控接收、云台控制、机器人底层通信这类项目的朋友。
我下面会从硬件层开始,一路讲到状态机的设计、代码实现、调试技巧,以及我在实际项目里踩过的那些坑。内容会比较长,但每一段都是实打实能用上的东西。
2. SBUS的物理层与数据帧:先把规矩搞清楚
2.1 电气特性与反相问题
SBUS的物理层其实就是一个反相串口。标准UART的空闲状态是高电平,起始位是低电平;而SBUS正好反过来,空闲是低电平,起始位是高电平。这就意味着你不能直接把SBUS信号接到STM32的RX引脚上,否则收到的数据全是反的。
解决方案有两种。第一种是硬件反相,用一个NPN三极管或者专用反相芯片(比如74HC14)把信号翻过来,再接到MCU的RX。第二种是软件反相,部分STM32型号(比如F0、F3、G0、G4系列)的USART支持TX/RX引脚互换和极性控制,可以通过配置寄存器实现内部反相。但F1和F4系列大部分型号不支持这个功能,所以如果你用的是F103C8T6或者F407ZGT6,老老实实加个反相电路吧。
我个人的经验是,如果你只是做实验,可以用一个简单的NPN三极管电路:SBUS信号接基极(通过一个1k电阻),集电极接MCU的RX并上拉一个10k电阻到3.3V,发射极接地。这样信号就被反相了。注意SBUS信号电平通常是3.3V或者5V,如果你的接收机输出是5V,记得加电平转换或者分压,别直接把5V怼到STM32的RX上。
2.2 帧格式详解
一帧SBUS数据固定25个字节,结构如下:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 0 | 0x0F | 帧头 |
| 1-22 | 通道数据 | 16个通道,每个11位 |
| 23 | 标志位 | bit0=信号丢失,bit1=失效保护 |
| 24 | 0x00/0x04/0x14/0x24 | 帧尾 |
通道数据的打包方式比较绕。22个字节一共176位,16个通道每个11位,刚好176位。打包顺序是:
- 通道1 = 字节1 + (字节2的低3位 << 8)
- 通道2 = (字节2的高5位) + (字节3 << 5) + (字节4的低1位 << 13)
- 以此类推
具体实现的时候,我建议直接用一个循环去移位提取,不要手写16个通道的表达式,容易出错。下面这段代码是我常用的提取方式:
uint16_t channels[16]; uint8_t *buf = sbus_frame; // 指向字节1 for (int i = 0; i < 16; i++) { int bit_index = i * 11; int byte_index = bit_index / 8; int bit_offset = bit_index % 8; uint32_t raw = buf[byte_index] | (buf[byte_index+1] << 8) | (buf[byte_index+2] << 16); channels[i] = (raw >> bit_offset) & 0x7FF; }这段代码的逻辑是:把三个字节拼成一个24位的值,然后右移偏移量,取低11位。注意buf指向的是帧的第1个字节(即通道数据起始),不是帧头。这个写法比手写表达式清晰得多,而且不容易出错。
2.3 波特率与串口配置
SBUS的波特率是100000,不是标准值。STM32的USART在配置波特率时,会根据APB时钟和BRR寄存器计算。以F103C8T6为例,APB2时钟72MHz,USART1挂载在APB2上。BRR的计算公式是:
BRR = fCK / baud
对于100000波特率,BRR = 72000000 / 100000 = 720。这个值刚好是整数,所以误差为0。但如果你用的是其他时钟频率,比如APB1的36MHz,BRR = 360,也是整数。所以SBUS的100000波特率在STM32上其实很容易配准,不像有些非标准波特率那样会产生误差。
串口配置方面,数据位8,停止位2,偶校验,无硬件流控。在CubeMX里配置的时候,注意停止位要选2,校验选Even。很多人容易忽略停止位,默认用1,结果收到的数据帧边界不对。
3. DMA循环接收 + IDLE中断:让CPU从字节中断里解放出来
3.1 为什么不用“收一个字节进一次中断”
传统的串口接收方式是每收到一个字节触发一次RXNE中断,在中断里把数据存到缓冲区。这种方式在低波特率下没问题,但在100000波特率下,每100微秒就中断一次。假设你主循环里还有PID计算、传感器读取、PWM输出这些任务,CPU会被频繁打断,实时性很难保证。
更麻烦的是,SBUS一帧25个字节,如果你在中断里逐字节判断帧头帧尾,逻辑会写得很碎,而且容易在帧边界处出错。比如你刚判断完帧头,下一个字节来了,你又得进中断,状态机被切得七零八落。
DMA的思路就完全不一样了。你让DMA在后台把串口收到的数据自动搬到内存缓冲区,CPU根本不用管。等一帧收完了,DMA告诉你“我搬完了”,你再一次性处理。这样CPU的负担就从“每字节中断”变成了“每帧中断”,频率从10000Hz降到了400Hz左右(SBUS帧率通常是14ms一帧,约71Hz,但IDLE中断会在帧结束后触发)。
3.2 DMA循环模式与缓冲区设计
DMA的循环模式(Circular Mode)是指DMA搬完指定长度的数据后,自动回到缓冲区开头继续搬。这个模式配合串口的IDLE中断,就能实现“永远在接收,帧结束才处理”的效果。
缓冲区大小怎么定?我一般设成50或者64字节。为什么不是25?因为SBUS帧虽然固定25字节,但实际接收时可能会有噪声或者帧间隔,缓冲区留大一点可以避免DMA在帧中间回绕导致数据错位。50字节意味着DMA搬完50个字节才回绕一次,而一帧只有25字节,所以正常情况下DMA不会在帧中间回绕。
但这里有个细节:如果你用循环模式,DMA的计数器(NDTR)会不断变化。你需要通过__HAL_DMA_GET_COUNTER来获取当前DMA还剩多少字节没搬,从而算出已经接收了多少字节。这个计算在IDLE中断里做,用来确定帧的长度和位置。
3.3 IDLE中断的触发机制
IDLE中断是串口的一个特性:当接收线路上出现一个字节时间的空闲(即没有新数据进来)时,硬件会置位IDLE标志,如果使能了IDLE中断,就会触发中断。对于SBUS来说,一帧25个字节连续发送,帧与帧之间有明显的间隔(通常几毫秒),所以IDLE中断会在每帧结束后触发一次。
这个机制的好处是:你不需要知道帧什么时候开始,只需要在IDLE中断里判断“从上次处理到现在,DMA搬了多少字节”,如果正好是25字节,那就是一帧完整数据。如果不是25,可能是噪声或者帧不完整,丢弃即可。
在HAL库里的操作步骤:
- 在CubeMX里使能USART的DMA接收,模式选Circular,数据宽度Byte。
- 在代码里调用
HAL_UART_Receive_DMA(&huart1, dma_buf, BUF_SIZE)启动DMA接收。 - 使能IDLE中断:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)。 - 在
stm32f1xx_it.c的USART1_IRQHandler里判断IDLE标志,清除标志,然后调用处理函数。
注意:HAL库的HAL_UART_IRQHandler会自动处理IDLE中断,但它不会告诉你IDLE发生了。所以你需要自己写IRQHandler,或者在HAL的IRQHandler之前判断IDLE标志。我一般直接重写IRQHandler,不调用HAL的那个,避免它把IDLE标志清掉。
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); sbus_idle_callback(); } HAL_UART_IRQHandler(&huart1); }这里有个坑:__HAL_UART_CLEAR_IDLEFLAG的清除方式是先读SR寄存器再读DR寄存器。如果你在清除之前调用了HAL_UART_IRQHandler,它可能已经把DR读走了,导致IDLE标志清不掉。所以顺序很重要,先清IDLE,再调HAL的处理函数。
4. 状态机设计:把解析逻辑从回调里抽出来
4.1 为什么需要状态机
很多人写SBUS解析,直接在IDLE中断里把25个字节读出来,然后逐字节判断帧头帧尾,提取通道数据。这种写法在简单场景下能用,但一旦你要处理丢帧、噪声、多帧缓存、通道映射这些需求,代码就会变得很乱。
状态机的思路是:把“接收”和“解析”分开。IDLE中断只负责把DMA缓冲区里的数据拷贝到一个帧缓冲区,并置一个标志位。主循环里检测到这个标志位后,再跑状态机去解析。这样中断里做的事情极少,不会阻塞其他中断,主循环的解析逻辑也可以写得很清晰。
4.2 状态定义与转移
我一般用三个状态:
SBUS_STATE_IDLE:等待帧头SBUS_STATE_RECEIVING:正在接收数据SBUS_STATE_COMPLETE:一帧接收完成,等待处理
但实际上,由于DMA已经帮我们把数据收完了,状态机更多是用来做“帧校验”和“数据提取”的。所以我的状态机是这样的:
typedef enum { SBUS_STATE_WAIT_HEADER, SBUS_STATE_CHECK_LENGTH, SBUS_STATE_PARSE_DATA, SBUS_STATE_ERROR } sbus_state_t;在IDLE回调里,我计算本次接收的字节数,如果等于25,就把数据拷贝到帧缓冲区,置frame_ready标志。主循环里检测到frame_ready后,跑状态机:
WAIT_HEADER:检查第一个字节是否是0x0F,不是就丢弃,回到IDLE。CHECK_LENGTH:检查帧长度是否为25,不是就报错。PARSE_DATA:提取16个通道数据,检查标志位,更新全局通道值。- 解析完成后回到
WAIT_HEADER,等待下一帧。
这个状态机的好处是,每一步都有明确的检查,出错可以定位到具体阶段。而且你可以很方便地在PARSE_DATA里加入滤波、通道映射、失效保护等逻辑。
4.3 帧同步与丢帧处理
SBUS帧率通常是14ms一帧,但如果你用IDLE中断,实际触发频率取决于帧间隔。如果接收机断电或者信号丢失,IDLE中断可能不会触发,或者触发时收到的字节数不对。这时候状态机应该能够检测到异常并复位。
我的做法是:在IDLE回调里,如果接收字节数不等于25,就丢弃这次数据,并递增一个错误计数器。如果连续多次错误,就复位DMA和状态机。另外,在标志位字节里,bit0表示信号丢失,bit1表示失效保护。如果这两个位被置位,说明接收机那边有问题,你可以选择保持上一次的通道值,或者输出安全值。
if (frame[23] & 0x01) { // 信号丢失 sbus_signal_lost = 1; } if (frame[23] & 0x02) { // 失效保护 sbus_failsafe = 1; }这两个标志在实际项目里很有用。比如你做无人机,信号丢失时应该触发返航或者降落;做机器人,失效保护时应该让电机停转。
5. 代码实现:从CubeMX配置到完整解析
5.1 CubeMX配置要点
在CubeMX里配置USART1:
- Mode: Asynchronous
- Baud Rate: 100000
- Word Length: 8 Bits
- Parity: Even
- Stop Bits: 2
- DMA Settings: Add USART1_RX, Mode: Circular, Data Width: Byte
- NVIC Settings: 使能USART1全局中断
注意:DMA的优先级建议设成Medium或者High,但不要设成Very High,以免抢占其他关键中断。USART1的中断优先级设成比DMA低一级,确保DMA搬完数据后中断能及时触发。
5.2 关键代码片段
先定义缓冲区和状态变量:
#define SBUS_BUF_SIZE 50 #define SBUS_FRAME_SIZE 25 uint8_t dma_buf[SBUS_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_signal_lost = 0; volatile uint8_t sbus_failsafe = 0;启动DMA接收和IDLE中断:
HAL_UART_Receive_DMA(&huart1, dma_buf, SBUS_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);IDLE回调函数:
void sbus_idle_callback(void) { static uint16_t last_pos = 0; uint16_t curr_pos = SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t len; if (curr_pos > last_pos) { len = curr_pos - last_pos; } else { len = SBUS_BUF_SIZE - last_pos + curr_pos; } if (len == SBUS_FRAME_SIZE) { // 拷贝数据到帧缓冲区 if (curr_pos >= SBUS_FRAME_SIZE) { memcpy(sbus_frame, &dma_buf[curr_pos - SBUS_FRAME_SIZE], SBUS_FRAME_SIZE); } else { uint16_t tail = SBUS_FRAME_SIZE - curr_pos; memcpy(sbus_frame, &dma_buf[SBUS_BUF_SIZE - tail], tail); memcpy(&sbus_frame[tail], dma_buf, curr_pos); } sbus_frame_ready = 1; } last_pos = curr_pos; }这段代码处理了DMA缓冲区回绕的情况。如果当前DMA位置小于上次位置,说明发生了回绕,需要分两段拷贝。这个逻辑看起来简单,但实际写的时候很容易在边界条件上出错,建议多测几次。
主循环里的状态机:
void sbus_process(void) { if (!sbus_frame_ready) return; sbus_frame_ready = 0; if (sbus_frame[0] != 0x0F) return; if (sbus_frame[24] != 0x00 && sbus_frame[24] != 0x04 && sbus_frame[24] != 0x14 && sbus_frame[24] != 0x24) return; uint8_t *buf = &sbus_frame[1]; for (int i = 0; i < 16; i++) { int bit_index = i * 11; int byte_index = bit_index / 8; int bit_offset = bit_index % 8; uint32_t raw = buf[byte_index] | (buf[byte_index+1] << 8) | (buf[byte_index+2] << 16); sbus_channels[i] = (raw >> bit_offset) & 0x7FF; } sbus_signal_lost = (sbus_frame[23] & 0x01) ? 1 : 0; sbus_failsafe = (sbus_frame[23] & 0x02) ? 1 : 0; }5.3 通道值映射与校准
SBUS的通道值范围是172到1811,中位值是992。但不同接收机可能有细微差异,所以实际项目里通常需要校准。我的做法是:上电时记录每个通道的最小值和最大值,然后线性映射到-100到100或者0到1000的范围。
int16_t sbus_map(uint16_t raw, uint16_t min, uint16_t max) { if (raw < min) raw = min; if (raw > max) raw = max; return (int16_t)((raw - min) * 2000 / (max - min) - 1000); }这个映射函数把原始值映射到-1000到1000。注意min和max需要根据实际接收机校准,不能直接用172和1811,因为有些接收机的输出范围会偏一点。
6. 调试与踩坑:那些文档里不会写的东西
6.1 反相电路没加,数据全是乱的
我第一次接SBUS的时候,忘了加反相电路,直接把接收机的信号线接到STM32的RX上。结果串口助手收到的全是0x00或者0xFF,偶尔有几个看似正常的字节,但帧头永远对不上。后来用示波器一看,信号完全是反的。加了一个三极管反相电路后,数据立刻就正常了。
这个坑很典型,因为SBUS的反相特性在协议文档里只是一句话带过,但实际硬件上如果不处理,根本收不到正确数据。如果你用的是F0或者G0系列,可以试试配置串口的RX极性反转,但F1和F4系列就别想了,老老实实加硬件。
6.2 IDLE标志清不掉,中断反复触发
这个问题我遇到过两次。第一次是因为在IRQHandler里先调用了HAL_UART_IRQHandler,它把DR读走了,导致IDLE标志清除失败。第二次是因为DMA的循环模式配置成了Normal,DMA搬完50个字节就停了,IDLE中断触发时DMA计数器已经不动了,算出来的长度永远是0。
解决方法是:确保先清IDLE标志,再调HAL的处理函数;DMA模式一定要选Circular,不要选Normal。另外,__HAL_UART_CLEAR_IDLEFLAG这个宏在有些HAL版本里实现不一样,有的是读SR再读DR,有的是直接写ICR寄存器。如果你用的是F4系列,可以直接写ICR寄存器清除IDLE标志,更可靠。
6.3 DMA缓冲区回绕导致数据错位
前面代码里处理回绕的逻辑,我一开始写错了。当时用curr_pos - last_pos算长度,没考虑回绕的情况,结果当DMA指针从49跳到0的时候,算出来的长度是负数(无符号数变成很大的值),导致memcpy越界,程序直接HardFault。
后来改成判断curr_pos > last_pos,如果小于就说明回绕了,长度等于SBUS_BUF_SIZE - last_pos + curr_pos。这个逻辑看起来简单,但在实际调试的时候,因为回绕不是每次都会发生,所以问题可能跑几个小时才出现一次,很难复现。建议在代码里加一个断言,如果算出来的长度大于SBUS_BUF_SIZE,就说明逻辑有问题。
6.4 帧率不稳定,通道值跳动
SBUS帧率标称是14ms,但实际接收时可能会因为接收机性能、信号质量、电源噪声等原因出现波动。如果主循环处理不及时,sbus_frame_ready标志可能会被覆盖,导致丢帧。我的做法是:在IDLE回调里用一个计数器记录丢帧次数,如果连续丢帧超过阈值,就触发一个错误处理。
另外,通道值跳动也可能是电源噪声引起的。SBUS信号对电源质量比较敏感,如果接收机和STM32共用一路电源,电机或者其他大功率设备启动时可能会干扰信号。建议给接收机单独供电,或者在信号线上加一个磁珠或者小电容滤波。
6.5 停止位配置错误导致帧边界不对
这个问题比较隐蔽。SBUS要求2位停止位,但CubeMX默认是1位。如果你忘了改,串口会在每个字节后提前采样,导致帧边界错位。表现就是偶尔能收到正确的帧,但大部分时候帧头对不上。我一开始以为是反相电路的问题,查了半天才发现是停止位没改。
所以配置串口的时候,一定要对照协议文档逐项检查:波特率100000、8位数据、偶校验、2位停止位。这四项缺一不可。
7. 性能优化与扩展思路
7.1 CPU占用率实测
我用F103C8T6在72MHz下跑这套方案,实测CPU占用率不到1%。具体来说,IDLE中断每14ms触发一次,中断里只做一次memcpy和标志置位,耗时大概几微秒。主循环里的状态机解析25个字节,耗时也就几十微秒。剩下的时间CPU完全可以跑PID、读传感器、更新PWM。
对比一下传统的字节中断方式:100000波特率下每100微秒中断一次,每次中断进出栈加上处理逻辑,至少消耗几微秒,CPU占用率轻松超过10%。如果主循环里还有浮点运算,实时性会很差。
7.2 双缓冲与多帧缓存
如果你的项目需要处理多帧数据,比如做数据记录或者协议转换,可以考虑双缓冲方案。IDLE回调里把数据拷贝到缓冲区A,主循环处理缓冲区B,处理完后交换。这样即使主循环处理慢一点,也不会丢帧。
实现方式很简单:定义两个帧缓冲区和一个指针,IDLE回调里根据当前指针选择写入哪个缓冲区,然后切换指针。主循环里读取另一个缓冲区。注意要用volatile修饰指针,防止编译器优化导致数据不同步。
7.3 扩展到其他协议
这套DMA+IDLE+状态机的框架不仅适用于SBUS,还可以扩展到很多类似的串口协议,比如:
- DBUS:大疆的遥控接收协议,波特率115200,帧长18字节,同样可以用DMA+IDLE接收。
- Modbus RTU:帧间隔3.5个字符时间,可以用IDLE中断检测帧结束,DMA搬运数据。
- 自定义二进制协议:只要帧长固定或者有明确的帧头帧尾,都可以用这套框架。
关键是把“接收”和“解析”分开,接收层用DMA+IDLE保证效率,解析层用状态机保证逻辑清晰。这样换协议的时候,只需要改解析层的状态机,接收层基本不用动。
7.4 中断优先级与实时性
如果你的系统里有多个中断源,比如定时器中断、ADC中断、DMA中断,需要注意优先级配置。USART的IDLE中断优先级不要设得太高,否则会抢占其他关键中断。我一般把USART中断设成中等优先级,DMA中断设成比USART低一级,确保DMA搬完数据后USART中断能及时响应。
另外,IDLE中断里不要做太耗时的操作,比如浮点运算或者复杂的逻辑判断。把这些放到主循环里做,中断里只做数据拷贝和标志置位。这样即使中断频繁触发,也不会影响系统的实时性。
8. 实际项目中的经验体会
这套方案我在三个项目里用过:一个是航模遥控接收,一个是机器人底层通信,还有一个是云台控制。每个项目的需求不同,但核心框架都是一样的。
航模那个项目对实时性要求最高,遥控信号丢失后必须在100ms内触发返航。我用SBUS的标志位字节检测信号丢失,一旦置位就立刻切换飞行模式。实测下来,从信号丢失到触发返航,延迟不超过20ms,完全满足要求。
机器人那个项目对通道数要求多,16个通道全用满了。我用状态机解析后,把通道值映射到电机速度、舵机角度、模式切换这些控制量上。因为DMA+IDLE的CPU占用率极低,主循环里还能跑PID和传感器融合,整体运行很流畅。
云台控制那个项目对精度要求高,通道值跳动会直接影响云台稳定性。我在解析后加了一个滑动平均滤波,窗口大小5,滤波后的通道值很平滑,云台抖动明显减小。
踩过的坑主要就是前面说的那几个:反相电路、IDLE标志清除、DMA回绕、停止位配置。这些问题在调试的时候很折磨人,但一旦解决,整套方案就非常稳定。我现在做新项目,只要用到串口接收,基本都会套用这个框架,省时省力。
最后分享一个小技巧:如果你手头没有SBUS接收机,可以用一个USB转串口模块加上串口助手,手动发送25字节的模拟帧来测试解析逻辑。把波特率设成100000,数据位8,停止位2,偶校验,然后发送0F 00 00 ... 00这样的帧,看看状态机能不能正确解析。这个方法在硬件还没到位的时候特别有用,可以先把软件逻辑调通。