去年给一套自研飞控做遥控信号接入的时候我直接在 STM32F103 上用串口中断逐字节收 SBUS结果 100kbps 的波特率下每 120 微秒就来一次中断姿态解算那边的任务时不时被卡一下。后来换成了 HAL 库下的 DMA 循环接收 IDLE 空闲中断 状态机解析CPU 占用几乎可以忽略丢帧率也从偶尔飘红变成了长期为零。这篇文章把整套方案的原理、配置、代码和踩坑记录完整写出来适合正在做飞控、机器人、遥控车这类项目准备把航模接收机 SBUS 信号接到 STM32 上的朋友参考。1. 先把 SBUS 的帧格式拆明白——100k、8E2、25 字节1.1 一帧 25 字节数据位和标志位都藏在里面SBUS 是 Futaba 提出的一种串行总线协议主要用在航模接收机和飞控、舵机之间传遥控通道数据。和传统的 PWM 接收机一根信号线传一个通道不同SBUS 只靠一根数据线就能把 16 个通道的值打包发出去通道分辨率是 11bit也就是每个通道的取值范围是 0 到 2047。一帧完整的数据是 25 个字节结构如下字节偏移内容说明00x0F固定的帧头也叫同步字节1 ~ 2216 通道 × 11bit22 字节按位打包23状态字节bit4 对应第 17 通道bit5 对应第 18 通道bit6 表示丢帧bit7 表示 failsafe240x00结束字节部分接收机不一定发慎做硬性校验这里最容易忽略的是第 23 个字节。很多人做解析时只顾着解通道数据把 bit6 和 bit7 当垃圾丢掉结果飞控在信号丢失时还傻傻地用最后一帧的值继续飞风险不小。正确做法是把这个状态字节单独拆出来一旦发现 frame_lost 位被置位上层应该立刻进入失控保护逻辑该切返航切返航、该锁油门锁油门。1.2 100000 波特率和 8E2 参数为什么容易配错SBUS 的串口参数不是常见的 115200 8N1而是 100000 波特率、8 个数据位、偶校验Even、2 个停止位通常简称 100k 8E2。这个配置有两个坑。第一个坑是波特率。看到遥控器说明书上写 SBUS 信号很多人下意识按 115200 去配。实际 SBUS 用的是 100000串口波形位时间整整差了约 15%收出来的数据表面上看大部分字节是对的偶尔乱码挑战性极强。我在调试时第一次就踩了当时逻辑分析仪解码一切正常但单片机解析出来的通道值总是偶发跳变排查了很久才怀疑到波特率上。第二个坑是校验位和停止位。SBUS 是偶校验加两个停止位。如果配成 8N1位时序和帧结构都会对不上等于把一个字符的时间窗口算错了收进来的数据即便帧头能对上通道数据也是错的。在 HAL 库的UART_InitTypeDef里对应配置是huart2.Init.BaudRate 100000; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.Parity UART_PARITY_EVEN; huart2.Init.StopBits UART_STOPBITS_2; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE;1.3 帧间的空闲窗口正是 IDLE 中断的最爱SBUS 一帧 25 个字节在 100kbps 8E2 的配置下每个字节大约占 12 个位周期也就是 120 微秒左右25 个字节传完大概是 3 毫秒。而遥控器的通道数据不是每帧紧挨着发的默认情况下接收机每 14 毫秒发一帧快速模式是 7 毫秒所以帧与帧之间至少有 4 毫秒以上的总线空闲时间。这个空闲时间非常关键因为 STM32 的串口外设有一个 IDLE 空闲中断当总线上出现空闲也就是一帧数据结束之后会触发一次中断。换句话说SBUS 这个协议的帧边界天然就有一个“停止标记”等着我们利用不需要自己用定时器去量字节间隔也不需要猜帧从哪里开始。这也是为什么 DMA 循环接收 IDLE 这套组合在 SBUS 上特别好用的根本原因DMA 负责把数据搬进内存IDLE 负责告诉你“这一帧已经收完了”两点一拼既是高效接收又是天然帧同步。2. DMA 循环接收 IDLE 中断这套方案为什么香2.1 和传统串口中断、普通 DMA 方案对比先对比一下常见的三种接收思路看完你就明白为什么选 DMA 循环 IDLE。方案CPU 开销帧边界识别复杂度稳定性串口逐字节中断每 120us 进一次中断100kbps 下压力很大自己搭超时定时器或靠帧头低一般易受延迟影响DMA 普通模式 传输完成中断每收到固定字节数中断一次无法解决帧起始位置对齐中差不适合变长/无对齐协议DMA 循环模式 IDLE 中断每帧结束只中断一次硬件空闲检测天然帧边界中高高逐字节中断方案最大的问题是中断频率太高。100kbps 不算快但对于跑姿态解算、还得刷 OLED 的小单片机来说每 120us 被打断一次系统实时性会明显变差。而且如果判断帧空闲用的是定时器超时阈值、定时器重装这些都要自己处理代码一多就容易出问题。普通 DMA 方案也有坑。SBUS 帧头和缓冲区起始地址没有固定对齐关系你没法预知 DMA 什么时候搬完一帧。除非你每次空闲中断后立刻重启 DMA再祈祷下一帧正好从缓冲区头开始否则数据会错位。DMA 循环接收的思路完全不同缓冲区是一个环形队列DMA 收到数据就写进缓冲区写满末尾之后自动回到开头继续写永远不停。然后 IDLE 中断负责告诉你“当前时刻总线空闲了我停在了哪个位置”。两者配合相当于 DMA 负责“持续录音”IDLE 负责“记录这句话说完了”。CPU 只需要在 IDLE 触发后从“上次处理的位置”到“当前 DMA 写指针位置”这段区间里把帧捞出来。2.2 CubeMX 配置的完整步骤我用的是 STM32F4 系列CubeMX 里的配置步骤基本可以照搬其他系列大同小异。串口参数按前面说的来100000 波特率、8 位、偶校验、2 停止位。注意 CubeMX 里波特率栏直接填 100000校验位选 Even停止位选 2。DMA 配置是重点。在 USART2 的 DMA Settings 页签里添加一个 RX 通道关键设置如下DirectionPeripheral To MemoryModeCircular这个必须选循环模式的核心Data WidthByte一个字符一个字节别选半字或字PriorityHigh保证接收不被其他 DMA 流抢占生成代码之后在初始化完成的位置启动 DMA 接收HAL_UART_Receive_DMA(huart2, sbus_rx_buf, SBUS_RX_BUF_SIZE);调用这一句之后DMA 就开始一直搬运串口数据了不需要反复重启。只要缓冲区够大哪怕主循环隔几毫秒才去处理一次数据也不会丢。2.3 手动开启 IDLE 中断的正确姿势很多人卡在这一步CubeMX 里勾了串口中断DMA 也配好了但 IDLE 事件就是不响应。原因是 HAL 库默认没有把 IDLE 中断完全托管尤其是老版本 HAL需要在串口的中断服务函数里自己处理。开启 IDLE 中断的代码__HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE);关键是在USART2_IRQHandler里怎么处理void USART2_IRQHandler(void) { if (RESET ! __HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart2); sbus_rx_pos SBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart2_rx); sbus_idle_flag 1; } HAL_UART_IRQHandler(huart2); }__HAL_DMA_GET_COUNTER拿到的是 DMA 还剩多少个字节没传用缓冲区总容量减去它就得到当前 DMA 已经写到了缓冲区的哪个位置也就是写指针。这是整套逻辑里最关键的一个数值。一个处理顺序问题要留意我习惯先判断 IDLE 标志、记录位置再调用HAL_UART_IRQHandler。不同版本的 HAL 对 UART 标志位的处理方式有差异如果先调 HAL 再判断 IDLE个别版本可能因为内部读 SR 和 DR 的操作把 IDLE 标志顺带清掉了导致判断丢失。先记录位置永远不亏。2.4 新旧 HAL 版本的差异新版 HAL 库其实提供了更省事的接口比如HAL_UARTEx_ReceiveToIdle_DMA()和对应的回调HAL_UARTEx_RxEventCallback()。如果你用的是比较新的 STM32CubeMX 生成的工程可以直接试用这个 API底层原理和手工实现是一样的都是 DMA 循环接收 IDLE 中断只是把启动和回调封装好了。但我个人还是建议先手工实现一遍原因有两个。一是老工程、老 HAL 版本不一定有这组新 API迁移到兼容性平台时手工方案更稳。二是手工方案能让你真正理解写指针、缓冲区回绕、状态机同步这些底层逻辑以后出了问题不至于两眼一抹黑。3. 状态机解析引擎缓冲区回绕与逐字节同步3.1 DMA 写指针追踪环形缓冲区不回绕问题的核心开了 DMA 循环模式后缓冲区本质是一个环形队列。DMA 收到数据就写写满末尾之后自动回绕到缓冲区开头继续写。这带来一个问题数据在物理内存上可能被切成了两段比如一帧 25 字节前 10 字节在缓冲区末尾后 15 字节在缓冲区开头。如果只用数组下标去硬切一帧很容易越界或错位。解决办法是永远用模运算来遍历#define SBUS_RX_BUF_SIZE 64 #define SBUS_FRAME_LEN 25 uint8_t sbus_rx_buf[SBUS_RX_BUF_SIZE]; volatile uint16_t sbus_rx_pos 0; // 由 IDLE 中断更新 volatile uint8_t sbus_idle_flag 0; uint16_t sbus_last_pos 0; // 主循环上次处理到的位置在中断里sbus_rx_pos记录的是当前 DMA 写指针主循环里sbus_last_pos记录的是上一次处理到的位置。每次 IDLE 触发后从sbus_last_pos一直处理到sbus_rx_pos之间的所有字节处理完更新sbus_last_pos。处理时有个细节最好先把目标位置缓存下来不要直接拿sbus_rx_pos作为循环结束条件。因为主循环跑的过程中如果又来了一个 IDLE 中断sbus_rx_pos会被更新导致 while 条件一直不成立一个循环停不下来。void sbus_task(void) { if (sbus_idle_flag) { sbus_idle_flag 0; uint16_t end_pos sbus_rx_pos; // 固定本次要处理到的位置 while (sbus_last_pos ! end_pos) { sbus_feed_byte(sbus_rx_buf[sbus_last_pos]); sbus_last_pos (sbus_last_pos 1) % SBUS_RX_BUF_SIZE; } } }3.2 三个核心函数数据入口、状态推进、通道解包状态机的作用是逐字节处理缓冲区里的数据从中识别出完整的 SBUS 帧。它只有两个主状态等待帧头、收集帧数据。typedef enum { SBUS_STATE_SYNC 0, SBUS_STATE_FRAME } sbus_state_t; sbus_state_t sbus_state SBUS_STATE_SYNC; uint8_t sbus_frame_buf[SBUS_FRAME_LEN]; uint32_t sbus_frame_start_tick 0; uint8_t sbus_frame_len 0; void sbus_feed_byte(uint8_t byte) { switch (sbus_state) { case SBUS_STATE_SYNC: if (byte 0x0F) { sbus_frame_buf[0] 0x0F; sbus_frame_len 0; sbus_frame_start_tick DWT-CYCCNT; sbus_state SBUS_STATE_FRAME; } break; case SBUS_STATE_FRAME: // 超时保护如果超过 10ms 没凑齐一帧强制回到帧头搜索 if ((DWT-CYCCNT - sbus_frame_start_tick) (uint32_t)(SystemCoreClock / 100u)) { sbus_state SBUS_STATE_SYNC; break; } sbus_frame_buf[sbus_frame_len] byte; if (sbus_frame_len SBUS_FRAME_LEN - 1) { sbus_state SBUS_STATE_SYNC; sbus_decode_frame(sbus_frame_buf, sbus_data); sbus_frame_cnt; } break; } }这段代码里有几个细节值得讲。状态机如果已经进入SBUS_STATE_FRAME就不要再被数据中间的 0x0F 干扰。因为一帧 25 字节里通道数据部分完全可能出现 0x0F这属于正常数据。只有两种情况会让它回到帧头搜索状态一是凑齐了完整 25 字节处理完一帧二是超时。超时保护是真实环境下必备的不然一旦开头出现噪声的伪 0x0F状态机就会一直收集到 25 个字节才恢复期间真正的帧头全被吞了。DWT-CYCCNT是 Cortex-M 内核的周期计数器用来做微秒级计时很方便。如果你的平台没有启用 DWT用HAL_GetTick()也可以只是毫秒级精度在个别高频模式下略糙可以接受。通道解包的函数如下。SBUS 的 22 字节通道数据本质上是一个 176bit 的连续位流第 i 个通道从第 i*11 位开始连续 11bittypedef struct { uint16_t ch[16]; uint8_t ch17; uint8_t ch18; uint8_t frame_lost; uint8_t failsafe; } sbus_packet_t; sbus_packet_t sbus_data; void sbus_decode_frame(const uint8_t *frame, sbus_packet_t *out) { const uint8_t *data frame[1]; for (int i 0; i 16; i) { uint32_t bit_pos i * 11; uint16_t value 0; for (int b 0; b 11; b) { uint32_t global_bit bit_pos b; value | ((data[global_bit 3] (global_bit 7)) 0x01) b; } out-ch[i] value; } out-ch17 (frame[23] 0x10) ? 1 : 0; out-ch18 (frame[23] 0x20) ? 1 : 0; out-frame_lost (frame[23] 0x40) ? 1 : 0; out-failsafe (frame[23] 0x80) ? 1 : 0; }这个逐位循环的写法最直观执行 16×11 176 次位判断放在 STM32F103 上也就几微秒完全够用。如果追求极致效率可以手工展开成移位拼接的写法不过没必要可读性差还容易写错。3.3 为什么非要状态机直接 memcpy 不行吗这里有一个很多人都会闪过的念头DMA 缓冲区和 SBUS 帧长度都是字节对齐的IDLE 中断也来了直接把当前位置前推 25 字节复制出来不就行了省掉状态机不是更简单直接 memcpy 的坑有三个。第一个坑是缓冲区对齐问题。IDLE 触发时DMA 写指针停在某个任意位置这并不代表当前帧头正好在“写指针减 25”的地方。缓冲区里可能有残留噪声、上一帧处理不及时留下的半帧也可能有数据被 DMA 循环回绕切了两段。盲目往前推 25 字节复制出来的很可能不是一帧从 0x0F 开头的完整数据。第二个坑是回绕。当帧数据跨缓冲区边界时直接往前推指针得到的区间在内存上不是连续的memcpy 会把环形缓冲区绕开的那部分错误地当成线性内存处理。虽然可以补个跨边界拼包逻辑但代码直接复杂一倍还容易越界。第三个坑是鲁棒性。状态机天然容忍缓冲区里的噪声它是逐字节扫描的找到合法的 0x0F 帧头才会进入收集状态。而 memcpy 方案必须假定“每个 IDLE 都对应一个完整帧”这个前提在有干扰的环境下并不成立。所以状态机不是为了炫技它承担的是“在不确定的字节流里可靠地切出合法帧”这件事。4. 实测与踩坑帧校验、ORE、边界情况4.1 逻辑分析仪下的真实波形与帧间隔为了验证整套方案我把 SBUS 信号接在逻辑分析仪上以 1MHz 采样率抓了一段波形串口解码设置成 100000 8E2。实测结果符合前面的计算一帧 25 字节持续大约 2.9 到 3 毫秒帧与帧之间的空闲大约 4 毫秒以上用逻辑分析仪的协议解码能看到明显的帧头 0x0F 和帧尾 0x00。这时候再去对照单片机的解析结果sbus_frame_cnt会以大约 70Hz 的速率稳定增长快速模式下会接近 140Hz 左右。如果发现帧计数远低于这个水平优先怀疑是不是帧头没对上、状态机超时阈值太小或者 DMA 缓冲区被覆盖。我在实测时还发现一个现象用普通 USB 转串口工具从电脑发假 SBUS 数据时如果上位机发送端没有做好 8E2 配置发出来的波形在逻辑分析仪上可能看起来“差不多”但单片机能收到的合法帧会明显减少。这个坑最容易出现在调试初期。4.2 尾字节 0x00 和 flags 校验怎么用前面帧格式里提到过帧尾第 24 个字节很多资料写的是 0x00我建议把它当“软校验”而不是硬性条件。原因是不同接收机对帧尾的处理并不统一。Futaba 原厂接收机基本都会发 0x00但个别协议转换器、山寨接收机发的可能是 0x0A 之类的其他值甚至干脆不发第 25 个字节。如果你把“尾字节等于 0x00”作为合法帧的硬条件很容易把一些能正常工作的设备拒之门外。我实际采用的做法是帧尾 0x00 只做统计不进合法性判断真正要判断的是 flags 里 bit6 和 bit7。frame_lost置位说明接收机信号质量差failsafe置位说明信号已丢失此时解析出来的通道数据已经没有参考意义控制逻辑应该马上切到失控保护分支。4.3 ORE 过载错误为什么时不时冒出来调试过程中另一个容易被忽略的问题是串口过载错误 ORE。当 USART 接收寄存器里的数据还没有被取走下一个字节又到达时硬件会置位 ORE。在 DMA 循环模式下DMA 搬数据的速度理论上不亚于串口接收速度但如果系统时钟配置不稳定、DMA 优先级太低或者中断处理里做了过长的耗时操作ORE 就可能出现。ORE 出现之后最直观的表现是HAL_UART_ErrorCallback()被触发或者数据流里偶尔丢一个字节导致状态机好不容易凑出的一帧因为缺字节被丢掉。我在工程里对错误回调做了计数跟踪void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { __HAL_UART_CLEAR_OREFLAG(huart); sbus_err_ore_cnt; } }如果sbus_err_ore_cnt持续增长优先检查 DMA 的优先级是不是被其他外设压得太低以及主循环里有没有长时间关中断的操作。把串口 DMA 的优先级提到最高通常能解决大部分 ORE 问题。4.4 长时间运行的稳定性建议最后分享几个让整套解析稳定跑上几个月不会出妖蛾子的经验。缓冲区大小不要太抠门。SBUS 一帧 25 字节我用过 64 字节也用过 128 字节。建议至少 64 字节最好 128 字节。缓冲区太小的情况下如果主循环有一个比较重的耗时任务DMA 写指针可能绕过sbus_last_pos把那一段还没处理的数据覆盖掉造成帧丢失。时间窗校验要不要加。我最初在sbus_decode_frame之前加过时间窗判断要求一帧 25 字节必须在 5ms 内收完。实验下来它对纯 SBUS 信号没有副作用但在干扰严重的环境下误判概率会上升。如果只接一个接收机、环境相对干净可以不加如果有信号线穿过电机电调附近建议保留超时保护。状态机处理不要放在中断里。IDLE 中断里只做两件事记录 DMA 写指针、置标志位。真正的逐字节解析放在主循环或 RTOS 的低优先级任务里。这样能保证中断处理时间极短也不容易和 DMA 搬运互相干扰。最后分享一个调试点这段代码我后来抽成了一个通用串行解码模块解析 SRXL、FPort 这类同样带帧头和固定帧长的协议时只需要替换同步字节、帧长和通道位宽状态机骨架完全不用动。调试 SBUS 解析时我个人有一个很顺手的办法先用 USB 转串口工具接电脑在 PC 端按 SBUS 格式循环发假数据频率调到接近 140Hz连续跑几分钟观察单片机端的帧计数和丢帧计数。比起直接上真遥控器这样更容易把波特率、校验位、缓冲区大小这些参数一次性调对。等 PC 端验证没问题再接真实接收机问题定位范围会小很多。
