做飞控、遥控器地面站或航模接收机相关开发的朋友基本绕不开 SBUS 这个词。SBUS 是航模遥控器最常用的一种串行总线协议遥控器把油门、方向、开关量全部塞进一帧数据里通过一根信号线送给飞控或者舵机控制器。我在实际项目中又得收信号又得保证不丢帧、不卡数据前后试过普通串口中断、定时器轮询等好几种方式最满意、可维护性也最好的方案就是 HAL 库下的 DMA 循环接收 IDLE 中断 状态机解析。这套组合的适用范围也很广不只是 SBUS任何不定长串口协议基本都能套用所以很值得把原理连同踩坑一起聊透。1. 先说清楚 SBUS 到底是什么SBUS 虽然穿的是串口物理层但它跟普通的 ASCII 串口协议完全不同。普通串口协议大多是一个字节对应一个数据或者用简单帧头帧尾做包。SBUS 则是一个 25 字节的固定长度帧里面包含了 16 个比例通道、2 个数字开关通道还有帧丢失和失控保护标志。因为遥控器端还要同时承担多个任务这个协议在设计上就很“压缩”对接收端的时序和解析要求自然高一些。1.1 SBUS 的电气和波特率细节SBUS 走的是常规 UART 信号但波特率比较特殊是100000而不是常用的 9600、115200。数据格式更怪是8E2也就是 8 个数据位、偶校验、2 个停止位。这一套配置在普通串口调试助手里反而不太常见很多人第一次拿到接收机按 115200 去读读出来全是乱码就是这个原因。波特率上有个点需要额外注意外部接收机或者飞控所用的 SBUS 信号大多是从接收机串口直接输出的。从那根线上读到的逻辑电平和 STM32 的 USART 引脚是兼容的一般情况下不用额外加转换电路。但如果接收机端是高电压的比如 5V 逻辑而 STM32 是 3.3V那最好加电平转换或者分压电阻这个我在调试时吃过一次亏后文会细说。1.2 25 字节帧结构拆解SBUS 一帧的标准长度是 25 字节具体布局如下偏移内容说明00x0F固定帧头也叫同步字节1 ~ 22通道数据16 个通道 × 11 bit紧密排列在 22 字节内23标志位包含 CH17、CH18、帧丢失、失控保护等240x00固定帧尾你可能会问16 个通道每个 11 bit这不才 176 bit 吗转换成字节是 22 字节剩下的 3 字节干什么用这就是 SBUS 设计比较巧妙的地方。176 bit 正好塞进 22 字节后再把标志位和帧尾补上凑成 25 字节。接收的时候只要连续捕获 25 字节并且帧头是 0x0F、帧尾是 0x00就能认为是一包合法 SBUS 数据。那 16 个通道具体怎么塞进 22 字节这是很多人第一次接触 SBUS 时最容易懵的地方。它不是每个通道单独占 2 个字节而是按位连续排布。通道 0 占位置 0 到 10通道 1 占位置 11 到 21通道 2 占位置 22 到 32以此类推。因为 11 不是 8 的倍数所以绝大多数通道都会跨字节边界。拆包时必须用移位和掩码而不能直接按字节读取。1.3 常规串口接收为什么容易翻车如果没有 DMA用普通串口中断接收 SBUS能跑么能跑但很别扭。100000 波特率下每字节大约耗时 130 微秒一帧 25 字节也就是 3.25 毫秒左右。听起来时间很充裕可只要系统里同时有高频率控制任务、ADC 采集、显示刷新、看门狗喂狗中断就很容易被延迟或被打断。更麻烦的是SBUS 是连续发送的帧与帧之间只有几个字节的间隔。如果某一帧处理慢了下一帧就开始覆盖。串口中断方式下字节进一个处理一个任何一个后台任务卡顿都会直接造成丢字节进而丢整帧。用定时器轮询读取 RXNE 标志也有类似的窘境轮询周期短了就浪费 CPU长了就丢数据。所以对 SBUS 这种“数据量不大、但持续不断”的信号最理想的模型是让硬件自己把字节收进内存CPU 只在有完整的一段数据时介入。这就是 DMA 循环接收的意义所在。2. 整套方案的运转逻辑DMA 循环接收 IDLE 中断 状态机我最终落实的方案简单说就是三层配合DMA 负责从串口外设把收到的字节自动搬运到一块内存缓冲区全程不用 CPU 手动读寄存器。IDLE 中断负责告诉 CPU“总线空闲了这一批数据到了”再触发解析。状态机负责从字节流里识别出一帧 SBUS 数据的边界并完成通道解包。这三层各管各的互不干扰。DMA 只负责搬运IDLE 只负责发通知状态机只负责做语义分析。实际跑起来以后SBUS 接收对 CPU 的占用几乎可以忽略不计。2.1 DMA 循环接收的核心价值DMA 循环接收英文叫 DMA circular mode。它的意思是 DMA 把数据搬运到缓冲区后如果到了缓冲区末尾不会停止而是自动回到缓冲区开头继续搬运形成一个环形缓冲区。这里有个关键点HAL 库的HAL_UART_Receive_DMA()启动后只是把 DMA 配置成循环模式它并不自动知道每一帧在哪里结束。DMA 能做的只是“持续不断地往内存里写”真正决定“一帧到底是什么时候结束”的是总线上的空闲间隙。SBUS 的帧与帧之间有比较明显的空闲间隔这正是 IDLE 中断的用武之地。2.2 IDLE 中断为什么配合得这么好UART 外设有一个 IDLE 标志它表示接收线上出现了一段空闲状态。当一串数据接收完毕后如果线上空闲超过一个字节的时间就触发 IDLE 事件。在 STM32 里这个事件可以置位标志并进入串口全局中断。IDLE 中断配合 DMA 循环接收可以这样组合DMA 不断接收字节写入环形缓冲区。当 SBUS 帧发送完毕后线上出现空闲IDLE 标志置位。在串口中断里读取 DMA 当前计数值算出刚才这段时间里新收到了多少字节。把这个字节段交给状态机处理然后清掉 IDLE 标志等待下一次空闲。这种做法比固定接收长度灵活得多。不管 SBUS 帧之间间隔是 14 毫秒还是 4 毫秒也不管一帧到底是不是严格的 25 字节只要线上一空闲就能立刻拿到最新收到的数据段。2.3 状态机负责处理“字节流碎片”状态机在这里不是可有可无的它是最能体现工程经验的一层。因为 SBUS 数据在 DMA 缓冲区中并不一定正好完整地从帧头开始。IDLE 触发时缓冲区里可能是一帧的后半截加另一帧的前半截也可能中间混入了噪声字节。我采用的状态机很简单等待帧头 0x0F收到后进入接收负载状态按字节累积直到凑满 25 字节最后校验帧尾 0x00。只要校验通过就认为拿到一个合法帧然后解包 16 个通道值。这个状态机的状态不多逻辑也清晰但它最大的好处是“每次只要喂给它一个字节它就能自主推进”。不管 IDLE 中断把数据切成几段、每段多长状态机都能按照自己的节奏重组出完整帧。这比一次性把整段缓冲区拿去做帧头搜索要可靠得多。下面我把状态迁移规律列一下当前状态输入条件转移动作等待帧头字节为 0x0F存入帧缓冲区进入接收负载状态等待帧头其他字节忽略继续等待接收负载帧缓冲未满存入帧缓冲区累加长度接收负载帧缓冲已满校验帧头、帧尾成功后解包回到等待帧头这里有个容易误解的点SBUS 数据里有可能出现 0x0F 这个值而它并不是真正的帧头。我的做法是只有“当前不在帧中”时才把 0x0F 当作帧头如果已经在接收负载状态0x0F 就老老实实作为通道数据的一部分存下来。这样就不会因为数据里恰好出现 0x0F 而乱掉。3. CubeMX 初始化与 HAL 库配置这个方案的代码看起来不复杂但 CubeMX 里的配置稍微有几个陷阱。我以常见的 STM32F103C8T6 为例串口使用 USART1DMA 使用 DMA1。换到其他型号时原理基本一致只要注意 DMA 请求映射就行。3.1 串口参数配置100000 和 8E2CubeMX 中配置 USART1需要把波特率设为 100000数据位选 8 bit校验位选 Even停止位选 2 bit。也就是说尽管最终物理帧是 9 个数据位但 HAL 库里的逻辑数据位还是 8 bit奇偶校验位另外配置。huart1.Instance USART1; huart1.Init.BaudRate 100000; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_2; huart1.Init.Parity UART_PARITY_EVEN; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1);需要注意的是HAL 库会根据Parity设置自动把 USART 的状态寄存器配置成允许 9 bit 模式。也就是说虽然你写的是UART_WORDLENGTH_8B但实际外设接收时是按 9 bit 处理的其中最高位是偶校验位。dma 控制器搬运到内存的还是 8 bit 有效数据不会把校验位搬进去这点不用担心。3.2 DMA 循环模式配置CubeMX 中给 USART1 添加 DMA Request选择 USART1_RX。方向是外设到内存模式一定要选 Circular。地址增量方面外设地址不增加内存地址增加。数据宽度都选 Byte。hdma_usart1_rx.Instance DMA1_Channel5; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_CIRCULAR; hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_usart1_rx);很多新手容易漏掉的一件事是DMA 中断并不一定需要使能。因为我们主要靠 IDLE 中断来触发处理DMA 的传输完成中断在循环模式下只在缓冲区写满一轮时触发并不是收帧节奏。如果你在 CubeMX 的 NVIC 里没勾选 DMA 中断代码一样能工作。当然勾选了也不影响只是会有额外的中断触发没必要的话建议不开启减少干扰。3.3 别忘了时钟树和硬件流控初始化成功的前提是 UART 时钟来源正确。在 STM32F103 上USART1 挂在 APB2 上最高 72MHz。CubeMX 默认时钟树一般没问题但如果工程改过时钟波特率可能就会偏。还有一点是硬件流控。SBUS 接收机上一般没有 CTS/RTS 信号所以HwFlowCtl必须设成UART_HWCONTROL_NONE。如果从别处拷贝的工程里带着硬件流控串口就根本收不到数据因为外设在等 CTS 电平。CubeMX 生成好初始化代码后在主程序里调用HAL_UART_Receive_DMA(huart1, sbus_dma_buf, SBUS_DMA_BUF_SIZE);这里sbus_dma_buf是内存缓冲区大小一般建议 128 字节。SBUS 一帧才 25 字节128 足够覆盖帧与帧之间的空隙也能容忍一定程度的回绕重叠情况。4. 主程序与中断程序实现详解配置好之后真正核心的代码就两个部分IDLE 中断里取数据、状态机解析。这两块做好整个方案就跑通了。4.1 IDLE 中断处理取出一段新数据先定义一个串口中断服务函数void USART1_IRQHandler(void) { if (huart1.Instance-SR USART_SR_IDLE) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_handle_idle(); } HAL_UART_IRQHandler(huart1); }__HAL_UART_CLEAR_IDLEFLAG的作用是清除 IDLE 标志避免同一个标志反复触发中断。清标志后就是关键的一步计算从上次处理到现在DMA 又写入了多少字节。void sbus_handle_idle(void) { uint16_t dma_cnt __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t now_pos SBUS_DMA_BUF_SIZE - dma_cnt; uint16_t len; if (now_pos sbus_last_pos) { len now_pos - sbus_last_pos; if (len 0) { sbus_state_feed(sbus_parser, sbus_dma_buf[sbus_last_pos], len); } } else { uint16_t len1 SBUS_DMA_BUF_SIZE - sbus_last_pos; uint16_t len2 now_pos; if (len1 0) { sbus_state_feed(sbus_parser, sbus_dma_buf[sbus_last_pos], len1); } if (len2 0) { sbus_state_feed(sbus_parser, sbus_dma_buf[0], len2); } } sbus_last_pos now_pos; }这里的分支逻辑对应环形缓冲区的两种情况一种是 DMA 当前位置没有回绕那么最新数据就是[sbus_last_pos, now_pos)这一段另一种是 DMA 已经绕回缓冲区开头那么最新数据被切成两段先处理尾部一段再处理头部一段。用行话说这段逻辑就是“环形缓冲区的差分读取”。它不依赖 DMA 在字节边界上对齐IDLE 什么时候来都能算出准确的新增字节数。4.2 状态机主体实现状态机的数据结构很简单typedef struct { uint8_t state; uint8_t cnt; uint8_t buf[SBUS_FRAME_LEN]; uint32_t tick_start; } sbus_parser_t; #define SBUS_FRAME_LEN 25 #define SBUS_STATE_SYNC 0 #define SBUS_STATE_FRAME 1喂数据的入口函数void sbus_state_feed(sbus_parser_t *p, uint8_t *data, uint16_t len) { uint16_t i; for (i 0; i len; i) { sbus_state_byte(p, data[i]); } }单字节状态推进void sbus_state_byte(sbus_parser_t *p, uint8_t byte) { if (p-state SBUS_STATE_SYNC) { if (byte SBUS_START) { p-cnt 0; p-buf[p-cnt] byte; p-tick_start HAL_GetTick(); p-state SBUS_STATE_FRAME; } } else { if (HAL_GetTick() - p-tick_start 10) { p-state SBUS_STATE_SYNC; return; } p-buf[p-cnt] byte; if (p-cnt SBUS_FRAME_LEN) { uint8_t ok (p-buf[0] SBUS_START) (p-buf[SBUS_FRAME_LEN - 1] SBUS_END); if (ok) { sbus_unpack_channels(p-buf); } p-state SBUS_STATE_SYNC; } } }上面加入了一个超时保护如果进入接收负载状态后超过 10 毫秒没有凑满一帧就强制回到等待帧头状态。这个保护非常必要。真实环境中可能接受机掉线、接头松动、信号跳变如果状态机停在“接收负载”状态不出来后续哪怕来了新帧头 0x0F也会被当成旧帧的一部分导致连续好多帧都解不出来。10 毫秒的超时保证状态机最多卡一帧周期就能自己恢复。4.3 SBUS 通道解包实现当状态机校验通过后p-buf里就是完整的 25 字节。接下来提取 16 个 11 bit 通道值。uint16_t sbus_channel[16]; uint8_t sbus_ch17, sbus_ch18; uint8_t sbus_frame_lost, sbus_failsafe; void sbus_unpack_channels(const uint8_t *frame) { const uint8_t *p frame[1]; uint16_t i; for (i 0; i 16; i) { uint16_t idx (i * 11) / 8; uint16_t shift (i * 11) % 8; uint32_t tmp (uint32_t)p[idx] | ((uint32_t)p[idx 1] 8) | ((uint32_t)p[idx 2] 16); sbus_channel[i] (uint16_t)((tmp shift) 0x07FF); } sbus_ch17 (frame[23] 0x01) ? 1 : 0; sbus_ch18 (frame[23] 0x02) ? 1 : 0; sbus_frame_lost (frame[23] 0x04) ? 1 : 0; sbus_failsafe (frame[23] 0x08) ? 1 : 0; }这段代码里的乘法i * 11如果担心在非常低端的 MCU 上开销大也可以改成查表或循环累加但实测下来16 个通道的运算量极低远不是瓶颈。真要追求极致效率可以维护一个bitpos 11的累加变量不过我一般不会为了这点开销牺牲代码可读性。最后一个通道也就是通道 15idx (15 * 11) / 8 20shift (15 * 11) % 8 5。这里读取了p[20]、p[21]、p[22]三个字节。因为帧缓冲区是 25 字节所以这三个索引都不越界即使第三个字节是无关的帧尾或标志位也不会影响结果。4.4 调用流程总览整个系统跑起来之后的调用关系很清晰主循环初始化时调用HAL_UART_Receive_DMA()启动循环接收。SBUS 数据持续被 DMA 搬运到缓冲区。每帧数据结束后串口 IDLE 中断触发。IDLE 中断调用sbus_handle_idle()取出新数据段。数据段交给状态机逐字节推进。状态机凑满 25 字节并校验通过后调用sbus_unpack_channels()。主循环直接读取全局sbus_channel[]数组即可。整个过程里主循环除了最后读取通道值几乎不需要参与 SBUS 收数。CPU 占用率极低。5. 调试实录与踩坑记录代码写出来是一回事调通是另一回事。下面这几个坑都是我实际踩过的按频率和严重程度排个序给遇到类似问题的人一个排查参考。5.1 DMA 回绕时的数据段拼接问题我第一次写sbus_handle_idle()时只处理了now_pos sbus_last_pos的情况结果跑一段时间后偶尔出现一帧解析不出来而且很有规律性每隔一个缓冲区周期就丢一帧。原因就是 DMA 回绕时数据被拆成了两段。sbus_last_pos可能是 120now_pos变成 10这时候如果用now_pos - sbus_last_pos去算长度会得到一个负数转成 uint16 秒变巨大值状态机直接吃进一堆无效数据。解决办法就是写清楚回绕分支先处理缓冲区尾部的len1再处理缓冲区头部的len2。这个逻辑不能省也别依赖所谓“大概率不会回绕到正好处理边界”的侥幸心理。DMA 是硬件持续搬运的只要系统运行时间够长回绕必然出现。对这个问题还有一个隐蔽的变体DMA 当前计数刚好在读取瞬间被硬件写回导致位置计算和实际缓冲区数据不完全一致。通常的表现是偶尔多一字节或少一字节过一会又自己恢复。这种一般不需要过度处理状态机本身有校验和超时重同步能容忍一两个字节的抖动。5.2 偶校验位带来的错位困扰8E2 模式下串口线上实际传输的是一个 9 bit 的数据单元最后一位是偶校验。如果接收端配错成 8N1那么所有字节都会差一位读出来的数据看起来像移位后的乱码。这种错位不是个别字节错而是整段数据都不对。我建议第一次调 SBUS 时先不要看通道数据先检查能不能收到合法的 0x0F 帧头。只要帧头对了大概率是 UART 配置没问题。如果帧头对但通道值明显不合理比如所有值都在 0 或 2047 附近跳不妨先确认是不是偶校验配置丢失了。有个很容易忽略的细节有些 STM32 的 HAL 库版本里UART_WORDLENGTH_8B与UART_WORDLENGTH_9B的组合会影响到奇偶校验位是否参与 DMA。实测下来只要Parity配成UART_PARITY_EVEN有效数据位仍然是 8 bitDMA 传给内存的数据是干净的字节不需要额外去掉校验位。5.3 串口错误标志对 DMA 的影响串口通信时间长了难免出现帧错误、溢出错误。UART 模块里一旦置位了溢出错误ORE在某些芯片上会影响到 DMA 继续接收甚至会一直卡住。所以中断里最好顺带检查一下错误标志。我现在的串口中断处理是这样的void USART1_IRQHandler(void) { uint32_t sr huart1.Instance-SR; if (sr USART_SR_IDLE) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_handle_idle(); } if (sr (USART_SR_ORE | USART_SR_FE | USART_SR_PE)) { __HAL_UART_CLEAR_OREFLAG(huart1); if (sr USART_SR_FE) { sbus_last_pos SBUS_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); } } HAL_UART_IRQHandler(huart1); }清掉溢出标志后DMA 一般会恢复正常继续接收。这里特别注意如果发生了帧错误缓冲区里的数据可能是错位的我选择把sbus_last_pos直接同步到当前 DMA 位置相当于扔掉这一段脏数据让状态机重新从下一个可能的帧头开始。5.4 用 HAL_UARTEx_ReceiveToIdle_DMA 可以省掉手动 IDLE如果你用的 STM32 型号比较新比如 G 系列、L4 系列、F4 系列HAL 库新版本提供了一个更省事的接口HAL_UARTEx_ReceiveToIdle_DMA()。这个接口把 DMA 接收和 IDLE 检测封装到一起每当串口空闲时会自动调用HAL_UARTEx_RxEventCallback并且回调里会直接给你一个Size表示本次收到了多少字节。用这个新接口上面手写的 IDLE 中断取数据逻辑可以大幅简化。F103 这种老型号的 HAL 库不一定有这个接口但如果项目允许换芯片我非常建议优先用新接口代码量能少三分之一也不容易写错 DMA 回绕的边界。5.5 状态机遇到噪声或干扰时怎么自恢复SBUS 信号在油门舵机附近走线很容易被电机电磁干扰带进噪声。我实测遇到过接收机掉线时的状态机卡死问题。掉线时数据流突然中断IDLE 中断不再频繁触发状态机可能停在“接收负载”状态。等重新上线后新帧的 0x0F 会被当成旧帧中的普通数据连续几帧都解不出来。加入超时机制后这个问题明显缓解。再配合帧头帧尾双重校验状态机一般会在下一个空闲间隙重新回到等待帧头状态。如果项目里对实时性要求特别高还可以在解包后再检查“通道值是否在 0 到 2047 之间”做一层数据合理性过滤把明显异常的数据剔除。6. 实际使用中的建议与扩展思路这套方案在我项目里跑了一段时间后总体表现很让我满意。DMA 循环接收把最频繁的字节搬运从 CPU 中解放出来IDLE 中断精准地在每一帧结束时触发状态机则保证了任何字节碎片都能正确重组成完整帧。在实际使用中有几个建议可以提供参考。第一DMA 缓冲区大小不要贪小。SBUS 一帧 25 字节57 字节甚至 64 字节理论上都够但我建议选 128。因为缓冲区大一点DMA 回绕的周期就长状态机需要处理的分段情况就少调试期也少一些边角问题。缓冲区占用 128 字节 RAM几乎可以忽略不计。第二中断优先级要合理。USART1 的全局中断优先级建议比主控制任务稍微高一点但又不要高于更关键的系统时钟类中断。这样既保证 SBUS 数据不丢也不会把其他实时任务全部饿死。第三这个方案的扩展性远比 SBUS 本身大。它本质上是一个通用的“不定长串口帧接收器”只要把状态机里的帧长、帧头、帧尾换成别的值就能拿来解析 GPS、雷达、数传模块等设备。我后来做另一个项目时需要解析 8 字节的 NMEA 子集指令直接把同一个状态机改两个参数就复用上了开发效率提升很明显。另外如果需要在 STM32 端回传调试信息可以把 SBUS 原始帧数据通过 USB 虚拟串口输出配合上位机实时观察通道曲线。我通常会在发送逻辑里保留一个开关平时只发解析后的 16 个通道值遇到异常时才把原始帧 dump 出来这样既能快速定位问题又不会让传输数据量太大。这套方案的接入门槛其实不高难点主要在理解 DMA 环形缓冲区的读写时序以及对 UART 的 8E2 模式要有清晰的认识。只要这两块想通了剩下的状态机就是一个搬砖活按照协议逐字节推进就好。希望这篇文章能让后来的人少踩几个我已经踩过的坑。