
1. 为什么 SBUS 解析值得单独拎出来讲SBUS 这玩意儿在玩航模、无人机、机器人底盘的朋友眼里应该不陌生它本质上就是一根信号线上跑 16 个通道遥控数据的串行协议。很多人第一次接触它的时候会觉得不就是串口收数据嘛然后拿普通的串口接收中断去接结果要么丢帧、要么通道数据错位、要么干脆整个接收逻辑卡死。我自己最早做云台控制的时候就踩过这个坑用HAL_UART_Receive_IT一字节一字节收波特率 100000 加上 25 字节一帧、每 14ms 来一帧中断频率直接把我那颗 F103 的 CPU 占用拉到了 30% 以上稍微加点别的任务就开始丢数据。后来换成DMA 循环接收 IDLE 空闲中断 状态机这套组合拳之后CPU 占用直接掉到几乎可以忽略帧解析也稳得一批。这套方案的核心思路其实很朴素让 DMA 在后台默默把串口数据往缓冲区里搬CPU 完全不用管当一帧数据发完、总线空闲下来的时候IDLE 中断会告诉我们这一帧结束了然后我们在中断或者主循环里用一个状态机去判断帧头帧尾、校验数据、提取 16 个通道。三个机制各司其职谁也不抢谁的活。这篇文章我打算把整套实现从头到尾拆一遍包括 CubeMX 怎么配、DMA 循环模式为什么必须开、IDLE 中断怎么和 DMA 配合、状态机怎么设计才不会误判、SBUS 那个反人类的 11 位数据格式怎么解。适合已经会点 STM32 HAL 库、但被 SBUS 或者类似不定长串口协议折磨过的朋友。看完你应该能直接把这套代码抄到自己板子上跑起来。2. 整体方案设计与选型思路拆解2.1 三种接收方式对比为什么不用中断和阻塞在动手之前先把串口接收的几种常见方式摆出来对比一下这样你才能明白为什么这套方案值得折腾。接收方式CPU 占用适用场景SBUS 适配度阻塞接收HAL_UART_Receive极高全程占用单次少量数据完全不适合单字节中断接收高每字节一次中断低速、少量数据勉强能用但易丢帧DMA IDLE 中断极低一帧一次中断不定长、高频数据最佳匹配SBUS 的物理层参数是100000 波特率、8 数据位、偶校验、2 停止位也就是常说的 8E2。注意这个 100000 不是标准波特率很多新手在 CubeMX 里随手选个 115200结果收到的全是乱码这个后面会专门讲。每帧 25 字节帧头0x0F帧尾0x00部分版本是0x04表示失控保护16 个通道各占 11 位剩下几位是标志位。帧率典型值 14ms 一帧也就是每秒约 70 帧。如果用单字节中断每帧 25 次中断每秒 70 帧就是 1750 次中断看着不多但每次中断里还要做状态判断、存缓冲区累积起来对实时性影响不小。而 DMA IDLE 的方式每帧只触发一次 IDLE 中断中断次数直接降到 70 次每秒CPU 几乎无感。2.2 DMA 循环模式 vs 普通模式的关键区别这里有个特别容易踩的坑DMA 必须配成循环模式Circular不能用普通模式Normal。普通模式下DMA 搬完指定长度的数据就停了你需要每次在中断里重新启动 DMA。SBUS 是连续不断来的你重启 DMA 的那一瞬间如果正好有新数据进来就会丢字节。而循环模式让 DMA 搬完一圈自动回到缓冲区头部继续搬永远不停从根本上避免了重启间隙丢数据的问题。循环模式带来的另一个好处是你不需要在每次 IDLE 中断里重新配置 DMA只需要记录当前 DMA 写到哪了通过__HAL_DMA_GET_COUNTER读取剩余计数就能算出这一帧收了多少字节、数据在缓冲区的哪个位置。2.3 IDLE 中断的角色定位IDLE 中断的触发条件是串口接收线在一个字节时间内没有新数据到来。SBUS 一帧 25 字节是连续发送的字节之间间隔极短而帧与帧之间有明显的空闲间隔14ms 一帧25 字节在 100000 波特率下大约只占 2.5ms剩下 11ms 都是空闲。所以每帧结束必然触发一次 IDLE 中断这就是我们判断一帧收完了的天然信号。IDLE 中断和 DMA 是绝配DMA 负责搬运IDLE 负责告诉你搬运告一段落。两者结合既不用轮询也不用每字节中断效率拉满。2.4 状态机为什么是必要的有人会问既然 IDLE 中断已经告诉我一帧结束了我直接读缓冲区不就行了为什么还要状态机原因在于现实世界没那么理想。可能出现的异常情况包括帧头不对干扰或者波特率错、帧长度不对丢字节或多字节、帧尾不对、校验失败。如果你不做状态判断直接把缓冲区当有效帧解析轻则通道数据乱跳重则触发误动作。状态机的价值就是把收到数据和数据有效这两件事分开只有通过帧头、长度、帧尾三重校验的数据才会被标记为有效帧交给上层使用。我设计的这个状态机比较轻量只有三个状态等待帧头、接收中、帧完成。配合一个帧计数器和一个有效性标志足够应付绝大多数场景。3. 核心细节解析与 CubeMX 配置实操3.1 串口参数配置100000 波特率怎么设CubeMX 里配置 USART 的时候波特率那一栏默认是 115200你得手动改成 100000。改完之后 CubeMX 会自动算出 BRR 寄存器的值但你要留意一下误差。STM32 的波特率是由 APB 时钟分频得到的100000 这个值在某些时钟配置下会有误差。以常见的 F103 为例USART1 挂在 APB2 上时钟 72MHz。波特率计算公式是USARTDIV fCK / (16 × 波特率)代入得72000000 / (16 × 100000) 45。45 是整数没有小数部分所以误差为 0完美。但如果你用的是 F407APB2 是 84MHz84000000 / (16 × 100000) 52.5这时候 BRR 就要设成52 0.5×16 52 8 0x348会有轻微误差但完全在容忍范围内。数据位选 8校验选 Even偶校验停止位选 2。这三项一个都不能错尤其是停止位SBUS 是 2 位停止位选 1 位的话帧尾会错位。3.2 DMA 配置循环模式与优先级在 USART 的 DMA Settings 里添加 USART_RX 的 DMA 请求模式选Circular数据宽度都是 Byte8 位优先级可以设 Medium 或 High。这里有个细节Memory 地址自增要开Peripheral 地址自增要关因为外设地址串口数据寄存器是固定的内存地址缓冲区要逐个往后写。缓冲区大小建议设成 50 字节或者更大至少要是 25 的两倍。为什么因为循环模式下 DMA 会一直绕圈写如果缓冲区刚好等于帧长那么当你在 IDLE 中断里读数据的时候DMA 可能已经开始写下一帧、覆盖了上一帧的头部。缓冲区大一点给解析留出时间余量。我一般用 64 字节够用且对齐好看。3.3 IDLE 中断的开启方式CubeMX 里默认只开了 USART 的全局中断IDLE 中断需要手动在代码里开。在MX_USART1_UART_Init之后调用__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);注意这里用的是__HAL_UART_ENABLE_IT宏不是HAL_UART_Receive_IT。很多人习惯性地用后者去开中断结果发现 IDLE 根本不触发因为HAL_UART_Receive_IT开的是接收数据寄存器非空中断RXNE跟 IDLE 是两码事。同时别忘了在 NVIC 里使能 USART1 的全局中断CubeMX 里勾上就行。3.4 缓冲区与状态变量的定义在main.c或者单独的头文件里定义这几个东西#define SBUS_BUF_SIZE 64 #define SBUS_FRAME_LEN 25 uint8_t sbus_dma_buf[SBUS_BUF_SIZE]; volatile uint8_t sbus_frame[SBUS_FRAME_LEN]; volatile uint8_t sbus_frame_ready 0; volatile uint16_t sbus_channels[16];sbus_dma_buf是 DMA 直接写入的缓冲区sbus_frame是解析出来的有效帧副本sbus_frame_ready是给主循环看的标志位sbus_channels存 16 个通道的最终值。用volatile修饰是因为这些变量会在中断和主循环之间共享防止编译器优化出问题。4. 状态机实现与 SBUS 协议解析全过程4.1 IDLE 中断回调里的核心逻辑HAL 库的 IDLE 中断没有专门的回调函数需要我们在stm32f1xx_it.c的USART1_IRQHandler里手动处理或者用HAL_UART_IRQHandler之后判断标志位。我习惯直接在USART1_IRQHandler里加一段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); }注意清除 IDLE 标志的顺序必须先读 SR 再读 DRHAL 库的__HAL_UART_CLEAR_IDLEFLAG宏已经帮我们做了这个序列。如果你手动清标志漏了读 DR 这一步IDLE 标志可能清不掉中断会反复触发。4.2 计算本帧长度与数据位置在sbus_idle_callback里第一件事是算出这一帧收了多少字节、数据从哪开始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;__HAL_DMA_GET_COUNTER返回的是 DMA 剩余待搬运的字节数用缓冲区总大小减去它就是 DMA 当前写到的位置。因为 DMA 是循环的当前位置可能比上次小绕圈了所以要分两种情况算长度。这段逻辑是整个方案里最容易出错的地方。我见过不少人直接用SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER当帧长在缓冲区没绕圈的时候没问题一旦绕圈就全乱了。所以last_pos这个变量必须维护好每次处理完更新它。4.3 状态机的三个状态与转移条件状态机我放在sbus_idle_callback里跑逻辑如下typedef enum { STATE_WAIT_HEAD 0, STATE_RECEIVING, STATE_FRAME_DONE } sbus_state_t; static sbus_state_t state STATE_WAIT_HEAD; static uint8_t frame_idx 0;STATE_WAIT_HEAD从本帧数据里找0x0F。找到了就进 STATE_RECEIVING把0x0F存进sbus_frame[0]frame_idx 1。没找到就继续等下一帧。STATE_RECEIVING把后续字节依次存进sbus_frame每存一个frame_idx。当frame_idx达到 25 时检查sbus_frame[24]是不是0x00或0x04。是的话进 STATE_FRAME_DONE不是的话说明这帧有问题退回 STATE_WAIT_HEAD 重新找帧头。STATE_FRAME_DONE把sbus_frame的内容解析成 16 个通道值存进sbus_channels置sbus_frame_ready 1然后回到 STATE_WAIT_HEAD 准备下一帧。这个状态机的好处是即使某一帧因为干扰丢了几个字节它也能在下一帧重新同步不会一直卡在错误状态。4.4 SBUS 11 位数据格式的拆解SBUS 最反人类的地方就是它的数据打包方式16 个通道每个通道 11 位总共 176 位也就是 22 字节。加上帧头 1 字节、标志位 1 字节、帧尾 1 字节正好 25 字节。这 22 字节的数据是这样排列的从sbus_frame[1]开始字节 0 的 bit0~bit7 字节 1 的 bit0~bit2 通道 111 位字节 1 的 bit3~bit7 字节 2 的 bit0~bit5 通道 211 位以此类推……手动移位拼接很容易写错我推荐用下面这种循环写法void sbus_decode(volatile uint8_t *frame, volatile uint16_t *ch) { ch[0] ((frame[1] | frame[2]8) 0x07FF); ch[1] ((frame[2]3 | frame[3]5) 0x07FF); ch[2] ((frame[3]6 | frame[4]2 | frame[5]10) 0x07FF); ch[3] ((frame[5]1 | frame[6]7) 0x07FF); ch[4] ((frame[6]4 | frame[7]4) 0x07FF); ch[5] ((frame[7]7 | frame[8]1 | frame[9]9) 0x07FF); ch[6] ((frame[9]2 | frame[10]6) 0x07FF); ch[7] ((frame[10]5| frame[11]3) 0x07FF); ch[8] ((frame[12] | frame[13]8) 0x07FF); ch[9] ((frame[13]3| frame[14]5) 0x07FF); ch[10] ((frame[14]6| frame[15]2 | frame[16]10) 0x07FF); ch[11] ((frame[16]1| frame[17]7) 0x07FF); ch[12] ((frame[17]4| frame[18]4) 0x07FF); ch[13] ((frame[18]7| frame[19]1 | frame[20]9) 0x07FF); ch[14] ((frame[20]2| frame[21]6) 0x07FF); ch[15] ((frame[21]5| frame[22]3) 0x07FF); }每个通道的值范围是 0~2047对应遥控器的 172~1811 左右不同厂商略有差异。如果你要映射到 -100~100 或者 1000~2000 的 PWM 值自己做个线性变换就行。4.5 主循环里怎么用这些数据主循环里只需要轮询sbus_frame_readywhile (1) { if (sbus_frame_ready) { sbus_frame_ready 0; // 这里用 sbus_channels[0]~[15] 做你的控制逻辑 // 比如控制电机、舵机、云台 } // 其他任务 }注意sbus_frame_ready的清除要放在使用数据之前避免解析中断在你使用数据的过程中又置位导致数据被覆盖。如果你对实时性要求高可以在使用前先把sbus_channels拷贝一份到局部数组。5. 常见问题与排查技巧实录5.1 收不到数据或者全是乱码这是最高频的问题九成以上是波特率或者校验位配错了。先确认三件事波特率是不是 100000、校验是不是 Even、停止位是不是 2。这三项错一个收到的就是垃圾数据。如果参数都对还是乱码检查一下硬件接线。SBUS 信号是反相的也就是说它的电平逻辑跟普通串口是反的。你需要一个反相电路一个三极管加两个电阻就能搞定或者用带反相功能的接收机。很多人直接把 SBUS 信号线接到 STM32 的 RX 上结果收到的数据全是反的怎么调都调不出来。我一般用一块 74HC14 或者一个 NPN 三极管做反相成本几毛钱。5.2 IDLE 中断不触发先确认__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)有没有调用而且要在串口初始化之后调用。其次确认 NVIC 里 USART 全局中断使能了。还有一个隐蔽的坑如果你在HAL_UART_IRQHandler之前就清了 IDLE 标志但没读 DR标志可能清不掉。用 HAL 库的宏最稳妥。另外如果你用的是某些国产替代芯片IDLE 标志的清除方式可能略有差异建议查一下对应参考手册。5.3 帧长度忽长忽短这通常是 DMA 缓冲区太小或者last_pos维护有问题。缓冲区至少要 50 字节我推荐 64。last_pos每次处理完必须更新为curr_pos否则下一帧算长度就错了。还有一种可能是波特率误差太大导致字节之间出现了额外的空闲IDLE 中断提前触发。这种情况要检查时钟配置确保波特率误差在 2% 以内。5.4 通道数据偶尔跳变如果只是偶尔跳一两个值多半是干扰或者丢字节。可以在状态机里加一个简单的校验检查帧尾是否正确不正确就丢弃整帧。如果跳变频繁检查一下信号线有没有做好屏蔽SBUS 线最好用带屏蔽层的屏蔽层单端接地。另外sbus_channels数组在中断里写、主循环里读如果主循环读取过程中被中断打断可能读到半新半旧的数据。解决办法是在主循环里先关中断、拷贝数据、再开中断或者用双缓冲。5.5 常见问题速查表现象可能原因排查方向全是乱码波特率/校验/停止位错确认 100000/8E2收不到任何数据信号未反相加反相电路IDLE 不触发中断未使能或标志未清检查 ENABLE_IT 和清标志序列帧长异常缓冲区太小或 last_pos 错加大缓冲区、维护 last_pos通道跳变干扰或数据竞争加屏蔽、双缓冲帧率不对时钟配置误差检查 APB 时钟和 BRR5.6 几个我踩过的坑第一个坑是忘记开 DMA 的循环模式结果跑了几秒钟 DMA 就停了数据再也不更新。这个坑很隐蔽因为前几秒是正常的容易误以为是别的问题。第二个坑是在 IDLE 中断里做太多事情。我最早把通道解析也放在中断里结果中断执行时间太长影响了其他中断的响应。后来把解析挪到主循环中断里只做数据搬运和标志置位问题解决。第三个坑是volatile 用得不到位。sbus_frame_ready没加 volatile编译器优化后主循环里读的一直是缓存值永远进不去 if。加上 volatile 就好了。第四个坑是缓冲区大小设成 25刚好等于帧长。结果 DMA 写下一帧的时候覆盖了上一帧还没解析完的数据通道值偶尔错乱。改成 64 之后彻底稳定。6. 性能优化与扩展思路6.1 CPU 占用实测对比我在 F103C8T6 上实测过三种方案的 CPU 占用主频 72MHzSBUS 帧率 70Hz方案CPU 占用中断次数/秒单字节中断接收约 28%1750DMA 普通模式 IDLE约 3%70DMA 循环模式 IDLE约 1.5%70循环模式比普通模式还低是因为省去了每次重启 DMA 的开销。这个差距在跑其他任务的时候体感很明显。6.2 双缓冲避免数据竞争如果你的控制逻辑比较复杂主循环处理一帧数据的时间可能超过 14ms这时候就需要双缓冲。思路是准备两个sbus_frame缓冲区中断写 A 的时候主循环读 B下一帧交换。用一个volatile uint8_t active_buf标志来指示当前哪个是写缓冲。实现起来不复杂但能彻底消除数据竞争代价是多占 25 字节内存。对于 F103 这种 20KB RAM 的芯片来说完全无压力。6.3 扩展到其他不定长协议这套 DMA IDLE 状态机的框架其实不限于 SBUS。任何帧头 数据 帧尾结构的不定长协议都能套用比如某些自定义的传感器协议、Modbus RTU 的变长帧等。你只需要改状态机里的帧头判断和帧长校验逻辑DMA 和 IDLE 那部分完全不用动。我后来做空气质量检测项目的时候传感器用的是自定义的变长协议直接把这套框架搬过去改了几行状态机就搞定了省了不少事。6.4 失控保护的处理SBUS 帧尾如果是0x04表示接收机进入失控保护状态通常是遥控器关机或者信号丢失。这时候你不应该继续用通道数据去控制电机而应该触发一个安全逻辑比如让电机停转、舵机回中。我在状态机里加了一个failsafe标志帧尾是0x04就置位主循环检测到就执行保护动作。这个细节很多人会忽略但在实际飞行或者行驶中非常关键。我见过有人因为没处理失控保护遥控器一关机电机就满油门冲出去差点出事。6.5 用定时器做帧超时检测IDLE 中断虽然能判断帧结束但如果信号线断了你可能永远等不到下一帧也就不知道数据已经失效。稳妥的做法是开一个定时器每收到一帧就重置计数如果超过 50ms 没收到新帧就判定信号丢失触发保护。这个定时器可以用 SysTick 的计数也可以单独开一个 TIM。我在云台项目里就是这么做的50ms 超时超时后云台自动回中并锁定避免失控。7. 一些实操心得整套方案跑下来我最深的体会是中断里只做最必要的事把复杂逻辑留给主循环。IDLE 中断里我只做三件事——清标志、算长度、跑状态机存数据解析和映射全部放到主循环。这样中断执行时间控制在几微秒对系统实时性几乎没有影响。另一个心得是缓冲区宁大勿小。多占几十字节内存换来的是稳定性这笔账怎么算都划算。我现在的习惯是缓冲区至少是最大帧长的两倍SBUS 用 64 字节其他协议按需放大。还有一点调试的时候一定要把原始帧打印出来看。我一般会开一个调试串口把sbus_frame的 25 个字节以十六进制打印出来对照协议手册逐字节核对。这样能快速定位是接收问题还是解析问题。等确认无误了再把调试代码去掉。最后分享一个小技巧如果你手头没有 SBUS 接收机可以用另一块 STM32 模拟发送 SBUS 帧来测试。写个简单的发送程序按 100000 波特率、8E2 发 25 字节帧头0x0F、帧尾0x00通道值随便填。这样在没有硬件的情况下也能把接收端调通省得来回插拔接线。