
做航模遥控器解析的时候SBUS 协议基本是绕不开的一关。以前用普通串口中断一字节一字节收波特率一高、主逻辑一忙就出现丢帧错帧排查起来特别头疼。后来把方案彻底重做了一遍换成 STM32 HAL 库下“DMA 循环接收 IDLE 中断 状态机解析”这套组合才算真正把 SBUS 接收做稳了。这篇内容把我当时的完整设计思路、CubeMX 配置、代码实现和踩坑记录都整理出来适合正在用 STM32 调 SBUS、或者想优化串口不定长接收方案的开发者参考。这里说的 SBUS是航模遥控器接收机输出通道数据最常用的串行协议之一。它用 100000 波特率、8 数据位、偶校验、2 停止位帧长固定 25 字节一帧差不多 14ms 刷新一次。很多人一开始会下意识用常规串口中断接收但真的跑到实际飞控里就会发现常规中断方式不仅 CPU 开销大还很难处理“不定长帧边界”“干扰错位”这种问题。DMA 循环接收能把数据搬运完全交给外设IDLE 中断负责在总线空闲时给出帧结束信号再用状态机把字节流拼成完整 SBUS 帧并解出 16 个通道这套组合是我目前用下来最稳的形态。1. 项目背景与方案选型思路1.1 SBUS 协议的关键细节SBUS 协议帧结构非常固定25 字节长度从没错过起始字节 0x0F随后 22 字节通道数据第 24 字节是标志位最后第 25 字节固定是 0x00。字段长度说明起始字节1 字节固定 0x0F通道数据22 字节16 个通道 × 11bit共 176bit标志字节1 字节bit0ch17bit1ch18bit2framelostbit3failsafe结束字节1 字节固定 0x00通道数据部分比较特殊16 个通道每个占用 11bit所以总位数为 176bit正好填满 22 字节。解包时不能按字节直接读取必须按位拼接。很多新手第一次接触会觉得这协议怎么这么别扭其实这是为了用尽量少的字节传输尽量多的通道在航模遥控领域已经是非常成熟的标准做法。另外 SBUS 的信号电平是反向的硬件上必须做反相处理。这个细节后面配置的时候会专门讲软件上不管怎么调电平不对就什么都收不到。1.2 为什么不用普通串口中断接收如果只是简单接收几个字节普通串口中断确实够用。但 SBUS 是固定周期连续刷新14ms 一帧CPU 如果每收到一个字节就进一次中断100000 波特率下大概 10us 一个字节系统里再有点控制算法、显示刷新、传感器读取的任务中断就会非常频繁。更麻烦的是如果主循环某段代码关中断或者执行时间太长接收缓冲很容易溢出丢帧就变得不可控。DMA 接收则完全不同。串口收到数据后由 DMA 自动搬到内存缓冲区CPU 完全不用参与逐字节搬运只有当总线空闲、一帧数据结束时IDLE 中断才会触发一次。这样 CPU 的工作量被大大压缩绝大部分时间都在处理业务逻辑而不是忙于响应串口中断。这里有一个比较关键的点SBUS 是一帧一帧发送的帧与帧之间总会有一段空闲时间。UART 外设检测到总线上连续超过一个字节周期没有新数据时就会触发 IDLE 空闲中断。借助这个特性DMA 循环接收可以一直不停止地收数据而 IDLE 中断则告诉我们“刚才这一包数据已经收完了”。两者的配合刚好解决“不定长接收”的经典痛点。1.3 状态机在协议解析中的价值DMA 加 IDLE 中断已经能把数据稳定收进来但为什么还要引入状态机因为我实际测试时发现接收机输出的数据并不总是完美对齐的。信号刚上电时DMA 缓冲区里可能是半帧调试器打断点的时候总线可能出现额外停顿把一帧拆成两段周围有干扰时帧中间还可能混入错误字节。状态机的优势就在于“逐字节消费”的理念。它不关心这次 IDLE 中断给的是不是完整的一帧而是把每个字节按状态依次推进遇到帧头就进入接收数据状态收满 22 字节后等待标志位最后校验结束字节必须是 0x00。这样哪怕数据被切成好几段甚至中间多了几个干扰字节状态机也能根据帧头帧尾重新同步不会把整个解析流程卡死。从工程角度看这是一种比“攒够 25 字节再一次性解析”更健壮的方案。2. 硬件准备与 CubeMX 关键配置2.1 SBUS 反相电路处理SBUS 协议使用反向电平常规 UART 接收到的空闲状态是低电平而不是标准 UART 的高电平空闲。如果直接把接收机信号接到 STM32 的 RX 引脚大概率收到的全是乱码因为电平极性和 MCU 预期完全相反。最简单的反相电路可以用一颗 NPN 三极管实现。接收机的 SBUS 信号先输入三极管基极集电极上拉到 3.3V 并连接到 STM32 的 RX 引脚发射极接地。信号为高电平时三极管导通集电极被拉低信号为低电平时三极管截止集电极被上拉电阻拉到高电平这样就完成了反相逻辑转换。也可以用 74HC04 或者 74HC14 这类逻辑门芯片直接反相效果更稳定还能顺便做一下波形整形。实际布局时建议反相电路尽量靠近 MCU 引脚上拉电阻选择 10kΩ 左右即可不需要太复杂。如果使用的是某些集成接收机模块有些型号会同时引出正向和反向两种信号那就不需要额外搭建反相电路直接接对应引脚就行。判断方法很简单用示波器或逻辑分析仪看空闲电平标准 UART 空闲是高电平SBUS 空闲是低电平完全反着来。2.2 CubeMX 串口与 DMA 配置我以 F103 系列的 USART1 为例其他系列配置逻辑完全一致。打开 CubeMX 后把 USART1 设置为异步模式波特率填 100000字长选 8 Bits 含校验位校验位选 Even停止位选 2。这里一定要特别注意SBUS 是 8E2不是常规的 8N1如果停止位或者校验位配错数据能收到但解析结果肯定不对。DMA 设置里添加 USART1_RX 请求Channel 根据 F103 的映射选择 DMA1 Channel5Direction 选 Peripheral To MemoryMode 选 CircularData Width 选 BytePriority 建议选 High。还有一个容易被忽略的选项如果 CubeMX 版本里能看到 Continuous Requests一定要确认它处于 Enabled 状态。这个选项保证 DMA 在循环模式下收到外设连续请求时能一直搬运数据否则可能只搬运一次就停了表现就是“第一帧能收到后面全部静默”。NVIC 设置中打开 USART1 全局中断。需要注意DMA 的传输完成中断在这套方案里可以不开启因为帧边界完全由 UART 的 IDLE 中断来确认DMA 中断开了反而容易混淆逻辑。2.3 时钟树对 100000 波特率的影响SBUS 选择 100000 波特率对于 STM32F103 来说是个很“友好”的频率。串口波特率计算本质是拿外设时钟除以分频系数F103 的 USART1 挂在 APB2 上时钟最高 72MHzUSARTDIV 72000000 / 100000 720分频结果是整数波特率误差为 0非常理想。USART2/3 挂在 APB1 上经过倍频后也能得到 36MHz除以 100000 等于 360同样是整数无误差。如果换到其他型号的 MCU建议在 CubeMX 的 Clock Configuration 页面里确认一下目标波特率的分频误差。误差超过 1% 就可能出现误码尤其 SBUS 还有偶校验位误码会直接导致整帧解析失败。F103 在这个参数上有天然优势所以用起来心里会踏实很多。3. 代码实现DMA 循环接收与 IDLE 中断处理3.1 缓冲区与状态机结构体设计DMA 循环接收需要一块固定大小的缓冲区我习惯定义成 128 字节。为什么不是正好 25 字节因为 SBUS 数据到达时间间隔固定但 DMA 回绕位置和帧起始位置不一定对齐缓冲区稍微大一点可以降低回绕覆盖风险也方便观察毛刺数据。128 字节对于 14ms 一帧、每帧 25 字节的 SBUS 来说非常充裕。状态机相关结构体可以统一封装typedef enum { SBUS_WAIT_START 0, SBUS_RECEIVING_DATA, SBUS_WAIT_FLAGS, SBUS_WAIT_END } SBUS_ParseState_t; typedef struct { SBUS_ParseState_t state; uint8_t frame[25]; uint8_t index; uint16_t channels[16]; uint8_t flags; uint8_t frameLost; uint8_t failSafe; } SBUS_t;这里把 frame 数组和解析需要的通道数据都放进结构体方便多个实例或者后续扩展。嵌入式项目里这种“结构体包状态”的写法非常推荐比零散全局变量清晰得多。3.2 启动 DMA 循环接收与 IDLE 中断初始化代码只需要在系统启动时调用一次HAL_UART_Receive_DMA(huart1, sbusDmaBuf, SBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);第一行开启 DMA 循环接收数据会源源不断写入 sbusDmaBuf 缓冲区。第二行通过宏直接使能 UART 的空闲中断这是 CubeMX 图形界面里没法直接点出来的配置必须在代码里补上。注意顺序上先开 DMA 接收再开 IDLE 中断避免 IDLE 中断触发时 DMA 还没准备好出现边界计算错误。还有一个极其重要的经验循环模式下HAL_UART_Receive_DMA只需要调用一次。不要在收到一帧后又调用一次去“重新开启接收”因为循环模式一直处于接收状态重复调用大概率返回 HAL_BUSY甚至可能导致 DMA 状态异常。很多初学者在这里出问题代码里反复写 Receive_DMA 反而把外设状态搞坏了。3.3 IDLE 中断回调与数据长度计算IDLE 中断触发时代表总线已经空闲DMA 缓冲区里新到的那段数据就是我们需要的。计算方式是利用 DMA 当前计数寄存器void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint16_t curPos SBUS_RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t recvLen (curPos SBUS_RX_BUF_SIZE - sbusRxLastPos) % SBUS_RX_BUF_SIZE; for (uint16_t i 0; i recvLen; i) { uint16_t idx (sbusRxLastPos i) % SBUS_RX_BUF_SIZE; sbus_parse_byte(sbus, sbusDmaBuf[idx]); } sbusRxLastPos curPos; } }这段代码是整套方案的灵魂。__HAL_DMA_GET_COUNTER返回的是 DMA 还没搬运完成的字节数用缓冲区大小减掉它就是当前位置。用当前位置减去上次记录的位置再对缓冲区大小取模得到的就是本次新增的数据长度。循环缓冲区天然会有回绕问题取模运算可以保证即使数据跨过缓冲区末尾也能正确得到字节序号然后逐个喂给状态机。这里有个和 HAL 版本相关的坑较新的 HAL 库在HAL_UART_IRQHandler中已经预留了 IDLE 处理逻辑会自动调用HAL_UART_IdleCpltCallback。但我遇到过某些版本没有完整实现或者库函数被裁剪的情况回调死活不触发。如果确认 IDLE 中断已使能、NVIC 已打开但回调就是不进来可以改成手动在串口中断服务函数里判断标志void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t curPos SBUS_RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 后续处理同上 } HAL_UART_IRQHandler(huart1); }手动方式有一个细节需要了解清除 IDLE 标志需要先读状态寄存器再读数据寄存器HAL 提供的__HAL_UART_CLEAR_IDLEFLAG宏就是做这件事。在 DMA 模式下读数据寄存器会不会和 DMA 抢数据实测下来基本不会因为 IDLE 触发时总线上已经空闲数据寄存器里没有待搬运的新字节DMA 不会受影响。但如果现场干扰严重DR 里恰好有个残留字节被 CPU 读走那这个字节就会丢失影响很小但值得知道有这个风险存在。3.4 串口错误回调处理高速串口接收和噪声环境下溢出错误无法完全避免。HAL 库提供了错误回调我在实际项目中是这样处理的void HAL_UART_ErrorCallback(UART_HandleTypeDto *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_OREFLAG(huart); sbus_reset(sbus); } }溢出或者帧错误一旦发生DMA 的接收流可能被打乱最简单的处理方式就是清掉错误标志把状态机重置回等待帧头状态。SBUS 下一帧 14ms 后就会到达重新同步成本很低完全不用担心丢这一帧会造成什么严重后果。这里比较关键的是“主动重置状态机”否则状态机可能卡在某个中间状态后续正常数据都进不了解析流程。4. 状态机解析 SBUS 帧4.1 状态划分与跳转逻辑整个状态机的流转可以这样理解最初处于等待起始字节状态只有收到 0x0F 才认定可能是一帧的开始进入接收通道数据状态。接下来接收 22 字节通道数据收满后进入等待标志位状态再收到一字节标志位进入等待结束字节状态。最后一帧必须以 0x00 结尾才正式把缓存解析成通道值然后回到等待帧头状态开始下一轮解析。这个流程里有两个隐性的容错设计。第一个是中间任何状态如果收到不符合预期的字节不会立刻把整个缓冲区扔掉而是继续往后找 0x0F 重新同步。第二个是只要最后一字节不是 0x00就判定这一帧无效直接回到等待帧头状态不给错误帧任何“上报”的机会。这种按字节推进的解析方式天然具备很强的抗干扰能力。4.2 状态机核心代码实现状态机的主体函数如下void sbus_parse_byte(SBUS_t *sbus, uint8_t byte) { switch (sbus-state) { case SBUS_WAIT_START: if (byte 0x0F) { sbus-frame[0] byte; sbus-index 1; sbus-state SBUS_RECEIVING_DATA; } break; case SBUS_RECEIVING_DATA: sbus-frame[sbus-index] byte; if (sbus-index 23) { sbus-state SBUS_WAIT_FLAGS; } break; case SBUS_WAIT_FLAGS: sbus-frame[23] byte; sbus-state SBUS_WAIT_END; break; case SBUS_WAIT_END: sbus-frame[24] byte; if (byte 0x00) { sbus_decode_channels(sbus); } sbus-state SBUS_WAIT_START; break; default: sbus-state SBUS_WAIT_START; break; } }注意接收通道数据阶段index涨到 23 时说明已经收满“0x0F 22 字节通道数据”接下来第 24 字节就是标志位第 25 字节是结束位。为什么这里不直接把 24 字节也放进同一个状态里因为标志位和结束字节在协议语义上不同分开状态会让后续扩展更容易比如以后需要校验标志位内容直接加在SBUS_WAIT_FLAGS里就行。4.3 16 通道位拼接解包SBUS 通道数据不是按字节对齐的解析时需要通过位偏移把 11bit 一个的通道值拼出来。核心思想是先把目标通道起始位置对应的 3 个字节组合成一个 24bit 的整数然后右移掉起始位偏移取低 11bit。代码实现static uint16_t sbus_get_channel(const uint8_t *raw, uint8_t ch) { uint16_t bitPos ch * 11; uint16_t bytePos bitPos / 8; uint16_t bitOffset bitPos % 8; uint32_t val (uint32_t)raw[bytePos] | ((uint32_t)raw[bytePos 1] 8) | ((uint32_t)raw[bytePos 2] 16); val bitOffset; val 0x07FF; return (uint16_t)val; } void sbus_decode_channels(SBUS_t *sbus) { uint8_t *raw sbus-frame[1]; for (uint8_t i 0; i 16; i) { sbus-channels[i] sbus_get_channel(raw, i); } sbus-flags sbus-frame[23]; sbus-frameLost (sbus-flags 2) 0x01; sbus-failSafe (sbus-flags 3) 0x01; }这里为什么可以大胆读取 raw[bytePos 2]因为对于第 16 个通道下标 15bitPos165bytePos20读取 raw[20]、raw[21]、raw[22]而 raw 指向 frame[1]raw[22] 实际就是 frame[23] 即标志位。CPU 拼出 24bit 后右移 bitOffset只保留低 11bit恰好不会用到标志位的高位数据读取越界问题在逻辑上被避免了。当然如果对代码洁癖比较强可以把 raw 缓冲区扩展成 24 字节多出的一个字节填充 0逻辑上更严谨但性能上几乎没差别。5. 实测效果、常见问题与排查技巧5.1 调试中遇到的三类典型场景第一次调试这套方案时我遇到过串口打印出的通道数据偶尔跳变的情况。用逻辑分析仪抓 SBUS 原始波形后发现接收机在刚上电的几百毫秒内会输出一些不完整帧状态机如果等到收满 25 字节再解析就会把这种半帧误判成正常帧。引入状态机逐步校验后这类错误帧在等待结束字节时就会被 0x00 校验拦截掉不会再影响输出。第二类场景是调试器打断点。连接 ST-Link 调试时一旦在 IDLE 回调里打断点总线上就会因为暂停出现超过一个字节周期的空闲IDLE 提前触发收到的数据自然就不完整。这个现象不是代码 bug而是调试方式导致的假象。实际飞行或正常运行中不会有这种停顿所以遇到类似情况时先想想是不是调试器干扰了时序。第三类场景是 DMA 缓冲区覆盖。最开始我把缓冲区设置成 32 字节偶尔会发现在高速刷新时解析出的通道值出现“撕裂”——某一帧的通道数据混进了上一帧的末尾字节。后来把缓冲区扩大到 128 字节情况立即好转。虽然不是绝对必要但缓冲区适当大一点配合循环取模计算整体健壮性会明显提升。5.2 常见问题速查表现象可能原因排查手段完全收不到数据SBUS 反相没有处理 / 波特率不对逻辑分析仪观察电平极性确认空闲电平为低电平IDLE 中断不触发未使能 UART_IT_IDLE / NVIC 未打开检查__HAL_UART_ENABLE_IT和中断优先级配置通道值随机跳变帧边界错位 / 干扰字节混入检查状态机是否严格校验帧头帧尾必要时打印 frame 数组第一帧之后不再更新循环模式下重复调用了 Receive_DMA确认初始化只调用一次去掉其他位置的重复调用偶发整帧丢失串口溢出错误在 ErrorCallback 中清溢出标志并重置状态机停止位或校验配置SBUS 是 8E2确认 CubeMX 中 ParityEvenStop Bits25.3 实际使用中的几点经验缓冲区大小我最终选择了 128 字节原因不只是防覆盖。DMA 循环接收的缓冲区越大数据回绕周期越长状态机在跨缓冲区边界时出错的可能性就越低。而且 128 字节对齐也比较友好对 F103 这种没有缓存一致性问题的 MCU 来说没有任何额外负担。IDLE 回调里直接逐字节喂状态机的写法在中断里实际耗时只有几微秒对实时性影响极小。但我后来在更复杂的项目里会把待解析缓冲区先拷贝到一块独立内存置一个帧就绪标志然后让主循环里的协议任务去解析。这样中断处理函数尽可能短解析动作也可以被调度器统一管理。对于 SBUS 这种数据量很小的协议两种方式都够用看个人工程风格。这套方案不止适用于 SBUS。MODBUS、MAVLink、自定义上行链路只要是“不定长帧 帧间有明显间隔”的串口协议都可以用 DMA 循环接收 IDLE 中断 状态机这套框架来解析。我后来做其他设备通信时几乎就是直接改改帧长度和校验规则框架完全复用。以后遇到类似需求完全可以在这套代码基础上迭代。