SBUS 这个协议在航模和机器人圈子里太常见了,接收机出来一根线,一根信号线就能拿到十几个通道的数据。但真到 STM32 上用 HAL 库去接它,很多人第一反应是"串口中断收呗",结果一上电就发现:数据帧长度固定 25 字节,波特率还是反常规的 100000 加 8E2 配置,用普通中断一个字节一个字节进中断,CPU 基本被这 25 个字节绑死,稍微跑点别的任务就开始丢帧。我最早做云台控制板的时候就是这么干的,主循环里稍微加个 PID 运算,SBUS 就开始间歇性失联,排查了半天才发现是接收环节把 CPU 吃满了。
后来换成DMA 循环接收 + IDLE 空闲中断 + 状态机这套组合,才算真正把这个问题解决干净。DMA 负责把串口数据默默搬进缓冲区,CPU 完全不参与搬运;IDLE 中断负责在"一帧数据收完、总线空闲下来"的那一刻通知我;状态机负责在中断之外把原始字节流解析成有意义的通道值。三者各司其职,CPU 占用率从原来的百分之十几直接降到几乎可以忽略。这篇就把这套方案的完整实现思路、配置细节和踩过的坑一次讲清楚,适合已经会用 CubeMX 点灯、但对 DMA 和串口空闲中断还不太熟的嵌入式开发者。
1. 先把 SBUS 这个协议的特殊性摸清楚
1.1 它为什么不能用普通串口参数去接
SBUS 是 Futaba 那套体系里的串行总线协议,物理层是反相 UART,也就是说它的电平逻辑跟标准 TTL 串口是反的。这一点是新手最容易翻车的地方:你直接把接收机的信号线接到 STM32 的 RX 上,串口配置全对,但就是收不到任何数据,示波器一看波形全是反的。解决办法要么加一个反相电路(一个三极管加两个电阻就行),要么用带反相功能的接收机,要么在软件层面把极性反过来处理。我一般推荐硬件反相,稳定且不占 CPU。
参数上它也很"另类":波特率100000,不是常见的 9600 或 115200;数据格式是8 位数据、偶校验、2 位停止位,也就是常说的 8E2。STM32 的 USART 外设完全支持这套参数,CubeMX 里把 Parity 设成 Even、Stop Bits 设成 2 就能对上。这里有个细节:开了偶校验之后,HAL 库接收到的数据字节最高位会被校验位占用,所以你在解析前必须把每个字节跟 0x00 做一次与运算,或者干脆在 CubeMX 里把 Word Length 设成 9 位来容纳校验位。我习惯用 8 位数据加校验的方式,解析时统一& 0x00清掉最高位,简单直接。
1.2 25 字节一帧里到底装了什么
SBUS 一帧固定25 个字节,结构非常规整:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 0 | 帧头 0x0F | 固定起始标志 |
| 1-22 | 16 个通道数据 | 每通道 11 位,共 22 字节 |
| 23 | 标志位 | 包含失控、丢帧等状态 |
| 24 | 帧尾 0x00 | 固定结束标志 |
16 个通道每个 11 位,加起来 176 位,正好 22 字节。这个位打包方式是 SBUS 解析的核心难点:它不是每个通道占固定字节,而是像流水一样把 11 位一位一位拼起来。比如通道 1 占第 1 字节的全部 8 位加第 2 字节的低 3 位,通道 2 占第 2 字节的高 5 位加第 3 字节的低 6 位,以此类推。手动去移位拼接非常容易出错,所以后面状态机里我会用一个循环统一处理。
通道值的范围是172 到 1811,中位大约在 992。这个范围不是 0 到 2047,是因为 SBUS 协议本身留了余量。实际用的时候你需要做一个线性映射,把它转成舵机或电调能接受的范围。标志位字节里,bit0 是丢帧标志,bit1 是失控保护标志,这两个在飞控里非常关键,一定要读出来做安全判断。
1.3 为什么"循环 DMA + IDLE"是天生一对
普通 DMA 接收有个问题:你不知道一帧什么时候结束。如果用 DMA 普通模式,收满指定长度就停,但 SBUS 帧长固定 25 字节,理论上可以设成收 25 字节触发一次。可实际中如果中间丢了一个字节,整个缓冲区就错位了,后面全乱。而DMA 循环模式让缓冲区首尾相接,DMA 永远在转,不会停,配合IDLE 空闲中断就完美了:总线上一帧数据发完会有一段空闲时间,USART 硬件检测到空闲就触发 IDLE 中断,我在中断里读取 DMA 当前写指针的位置,就知道这一帧收到了多少字节、从哪里开始。
这套组合的精髓在于:DMA 负责搬运,IDLE 负责定界,CPU 只在帧结束时被唤醒一次。相比每字节一次中断,CPU 负载降低了一个数量级。而且循环模式下缓冲区是环形复用的,不需要每次重新启动 DMA,代码更简洁。
2. CubeMX 里那些容易配错的参数
2.1 USART 参数与 DMA 请求的对应关系
在 CubeMX 里配置 USART 时,几个关键项必须一次到位:
- Baud Rate:100000
- Word Length:8 Bits
- Parity:Even
- Stop Bits:2
- Mode:Asynchronous
然后在 DMA Settings 里添加 USART_RX 请求,模式选Circular,数据宽度 Byte。这里有个坑:很多人只加了 RX 的 DMA,忘了 NVIC 里要开USARTx global interrupt。IDLE 中断属于 USART 的全局中断范畴,不开这个,IDLE 永远不会触发。我见过不止一个项目卡在这里,代码逻辑全对,就是收不到帧结束信号。
另外 DMA 的优先级建议设成 Medium 或 High,别设 Low。如果系统里还有别的 DMA 通道在跑(比如 ADC 采集),优先级太低会导致串口 DMA 被抢占,缓冲区指针更新不及时,IDLE 中断里读到的位置就不准。
2.2 缓冲区大小的取舍
循环 DMA 的缓冲区大小不是随便定的。太小了,一帧 25 字节还没收完就被覆盖;太大了,浪费 RAM 而且 IDLE 中断里处理的数据跨度大。我的经验是设成帧长的整数倍再留点余量,比如 64 字节或 128 字节。64 字节能容纳两帧多,即使 IDLE 中断响应稍有延迟也不会丢数据。
这里要理解循环 DMA 的"写指针回绕"问题:DMA 写到缓冲区末尾会自动回到开头继续写。所以你在 IDLE 中断里计算"这一帧收了多少字节"时,不能简单用当前指针减上次指针,必须考虑回绕。我一般用一个last_pos变量记录上次处理到的位置,然后:
uint16_t cur_pos = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len = (cur_pos >= last_pos) ? (cur_pos - last_pos) : (BUFFER_SIZE - last_pos + cur_pos);__HAL_DMA_GET_COUNTER返回的是 DMA 剩余待传输数量,用缓冲区总长减去它就是当前写指针位置。这个宏在 HAL 库里是现成的,不用自己去读寄存器。
2.3 中断优先级的实际影响
IDLE 中断的优先级要结合你的系统来定。如果 SBUS 只是遥控输入,优先级中等即可;但如果你的飞控对遥控响应实时性要求极高,可以适当提高。不过要注意,IDLE 中断里不要做太重的活,我见过有人在中断里直接跑通道映射和滤波,结果中断执行时间过长,影响了其他外设。正确做法是中断里只做"标记一帧到达 + 拷贝数据到解析缓冲",真正的解析放到主循环或任务里。
3. 状态机解析:把 25 字节变成 16 个通道
3.1 为什么不用一次性解析而要上状态机
有人会问:帧长固定 25 字节,帧头帧尾都固定,直接判断一下不就行了,要什么状态机?这话在理想情况下没错,但实际串口通信里会遇到各种异常:上电瞬间收到半帧、干扰导致帧头错位、丢字节导致后续全乱。如果不用状态机,一旦错位,你可能要等很久才能重新同步。
状态机的价值在于逐字节推进、随时可复位。它把解析过程拆成"等帧头、收数据、验帧尾"几个明确状态,任何一个环节不满足就回到初始状态重新找帧头。这样即使中间出错,最多丢一帧,下一帧立刻能重新同步。对于 SBUS 这种连续不断发送的协议,这个特性非常重要。
3.2 状态划分与转移条件
我用的状态机比较简单,三个状态足够:
- STATE_HEAD:等待帧头 0x0F
- STATE_DATA:接收 23 个数据字节(含通道和标志位)
- STATE_END:校验帧尾 0x00
转移逻辑是这样的:在 HEAD 状态收到 0x0F 就进 DATA,同时把数据索引清零;DATA 状态每收一个字节存进临时数组,收满 23 个进 END;END 状态检查是不是 0x00,是就标记一帧完整,否则丢弃重来。整个过程用一个index变量跟踪进度,逻辑清晰,出错也好排查。
这里有个细节:标志位字节(第 23 字节)也在 DATA 状态里收,收完之后单独解析它的 bit0 和 bit1。我习惯把它存到一个sbus_flags变量里,主循环里判断失控标志来决定是否进入保护逻辑。
3.3 11 位通道数据的位拼接技巧
这是整个解析里最绕的部分。16 个通道、每个 11 位、共 22 字节,位是连续排列的。最直观的做法是用一个位缓冲区,把 22 字节全部展开成 176 位,然后每 11 位切一刀。但这样代码量大、效率低。更优雅的做法是用一个循环配合移位:
uint8_t byte_index = 0; uint8_t bit_index = 0; for (int ch = 0; ch < 16; ch++) { uint16_t value = 0; for (int b = 0; b < 11; b++) { if (data[byte_index] & (1 << bit_index)) { value |= (1 << b); } bit_index++; if (bit_index == 8) { bit_index = 0; byte_index++; } } channels[ch] = value; }这段代码逐位读取,逻辑上绝对正确,但效率一般。实际项目中我会用查表或者预计算的移位方案优化,不过在 100000 波特率下,一帧 25 字节的解析时间完全够用,没必要过度优化。先把功能跑通,再谈性能。
解析出来的原始值范围是 172 到 1811,需要映射到实际用途。比如控制舵机,通常映射到 1000 到 2000 微秒;控制电调,映射到 1000 到 2000 也是常见做法。映射公式:
uint16_t mapped = (raw - 172) * 1000 / (1811 - 172) + 1000;注意这里用整数运算,先乘后除避免精度损失。如果 raw 超出范围,要做限幅处理,防止异常值把执行机构打飞。
4. 中断服务函数与主循环的配合
4.1 IDLE 中断里到底该做什么
IDLE 中断服务函数是整个方案的中枢,但它必须"轻"。我的做法是:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t cur_pos = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len = (cur_pos >= last_pos) ? (cur_pos - last_pos) : (BUFFER_SIZE - last_pos + cur_pos); if (len > 0) { memcpy(parse_buf, &dma_buf[last_pos], len); frame_len = len; frame_ready = 1; } last_pos = cur_pos; } HAL_UART_IRQHandler(&huart1); }注意几个点:第一,先清 IDLE 标志再处理,否则会反复进中断;第二,HAL_UART_IRQHandler要放在最后调用,它处理其他串口中断标志;第三,拷贝数据用memcpy而不是逐字节循环,效率高;第四,只做标记,不解析。
提示:
__HAL_UART_CLEAR_IDLEFLAG这个宏在不同 HAL 版本里实现不一样,有的版本需要先读 SR 再读 DR 才能清掉。如果发现中断反复触发,检查一下这个宏的实际展开。
4.2 主循环里的状态机驱动
主循环里检测frame_ready标志,一旦置位就把parse_buf里的数据喂给状态机。这里有个设计选择:状态机是在中断里跑还是主循环里跑?我强烈建议主循环。中断里跑状态机,一旦解析逻辑复杂或者加了调试打印,中断时间会失控。主循环里跑,即使这一帧没处理完,下一帧数据也在 DMA 缓冲区里等着,不会丢。
主循环的结构大概是这样:
while (1) { if (frame_ready) { frame_ready = 0; sbus_parse(parse_buf, frame_len); } // 其他任务 }sbus_parse里就是前面说的状态机逻辑。如果收到的frame_len不是 25,直接丢弃,说明这一帧不完整或者有干扰。
4.3 丢帧与失控的处理策略
SBUS 标志位里的丢帧和失控标志必须认真对待。丢帧说明信号质量差,失控说明接收机没收到发射机信号。我的处理策略是:
- 连续丢帧超过阈值(比如 10 帧),降低控制输出或进入悬停
- 失控标志置位,立即进入保护状态,输出预设的安全值
- 正常帧恢复后,平滑过渡回正常控制,避免突变
这些逻辑放在主循环里,跟状态机解析解耦。中断只管收,主循环管业务,职责分明。
5. 实测中那些文档不会告诉你的坑
5.1 上电第一帧永远是垃圾数据
这个坑我踩过好几次。STM32 上电时 USART 和 DMA 初始化有先后顺序,如果 DMA 还没准备好,串口就开始收数据,第一帧往往是残缺的。解决办法是在初始化完成后先清一次缓冲区和last_pos,并且状态机在收到第一个完整帧之前不输出任何控制量。另外,接收机上电也需要时间,通常几百毫秒后才开始发有效数据,所以上电后延时一小段再使能接收更稳妥。
5.2 偶校验导致的最高位污染
前面提过,开了偶校验后,HAL 库读到的字节最高位是校验位,不是数据。如果你直接拿这个字节去解析,通道值会全错。我一开始没注意,调了半天以为位拼接写错了,后来才发现是校验位在捣乱。解决办法就是在解析前统一data[i] &= 0x7F,把最高位清掉。这个操作对帧头 0x0F 和帧尾 0x00 没影响,因为它们最高位本来就是 0。
5.3 DMA 缓冲区回绕时的数据断裂
循环 DMA 最隐蔽的坑是:一帧数据可能跨越缓冲区末尾和开头。比如缓冲区 64 字节,上一帧结束在位置 60,这一帧 25 字节就会写到 60 到 63 再回绕到 0 到 20。如果你在 IDLE 中断里只从last_pos拷贝到cur_pos,遇到回绕就会拷错。我前面的代码里用了一个三元表达式处理回绕,但更稳妥的做法是分两段拷贝:先拷last_pos到缓冲区末尾,再拷开头到cur_pos。这个细节在数据量大或者中断延迟高的时候特别重要。
5.4 中断里调用 HAL 库函数的隐患
HAL_UART_IRQHandler在中断里调用是标准做法,但要注意它内部可能会调用回调函数。如果你在回调里做了耗时操作,整个中断就废了。我的建议是:要么不用 HAL 的回调机制,直接在 IRQHandler 里处理 IDLE;要么用回调但保证回调里只做标记。另外,HAL 库某些版本的HAL_UART_IRQHandler会检查错误标志并做处理,如果串口有噪声导致错误中断频繁触发,也会拖慢系统。可以在初始化时适当配置错误中断的使能。
5.5 波特率误差的累积效应
100000 这个波特率不是标准值,STM32 的 USART 分频器不一定能精确分出来。如果时钟配置不当,波特率误差可能超过 2%,导致偶发性的帧错误。我一般会在 CubeMX 里检查一下实际波特率,确保误差在 1% 以内。如果误差偏大,可以微调系统时钟或者 USART 的分频系数。这个在 F1 系列上尤其要注意,F4 和 H7 的时钟树更灵活,问题少一些。
6. 从能跑到好用:几个进阶优化方向
6.1 双缓冲让解析和接收彻底解耦
单缓冲区在极端情况下会有竞争:IDLE 中断正在往parse_buf拷贝,主循环同时在读parse_buf。虽然概率低,但一旦发生就是数据错乱。解决办法是双缓冲:中断拷贝到 buffer A,主循环处理 buffer B,处理完交换。用一个标志位控制交换时机,简单有效。对于高可靠性场景,这个优化值得做。
6.2 用定时器做帧超时兜底
IDLE 中断依赖总线空闲,但如果接收机故障一直拉着总线不放,IDLE 永远不触发,你就一直收不到帧。加一个定时器做超时检测:如果超过正常帧间隔(SBUS 一般 14ms 一帧,超时设 30ms)还没收到完整帧,就主动复位状态机和 DMA 指针。这是工业级应用里常见的兜底手段。
6.3 通道数据的滤波与死区
原始通道值会有抖动,直接拿去控制电机会有细微抖动。我一般加一个一阶低通滤波:
filtered = filtered * 0.8 + raw * 0.2;再配合一个死区判断,变化小于阈值就不更新输出。这样控制手感更稳,也能延长舵机寿命。滤波系数根据你的控制周期调整,周期短可以取小一点,周期长取大一点。
6.4 把解析结果打包成结构体
散落的全局变量多了容易乱,我习惯把 SBUS 相关的东西打包成一个结构体:
typedef struct { uint16_t channels[16]; uint8_t flags; uint8_t frame_lost; uint8_t failsafe; uint32_t last_frame_tick; } SBUS_Data_t;这样传递和调试都方便,也便于后续扩展。结构体里带上时间戳,主循环里可以判断数据新鲜度,超时就报警。
这套方案我从 F103 一直用到 F407 和 H7,核心逻辑没变过,只是时钟配置和 DMA 通道号不同。SBUS 解析本身不复杂,难的是把接收、定界、解析、容错这几件事拆清楚,各归各位。DMA 循环接收解决"搬"的问题,IDLE 中断解决"界"的问题,状态机解决"解"的问题,三者配合好了,CPU 几乎无感,稳定性也上来了。最后提醒一句:调试阶段一定要把原始字节流打印出来看,别一上来就盯着通道值,很多时候问题出在帧结构层面,通道值只是表象。