2026/9/25 2:01:46

STM32 HAL库SBUS协议解析:DMA循环接收+IDLE中断+状态机实战

STM32 HAL库SBUS协议解析:DMA循环接收+IDLE中断+状态机实战 玩航模或者做飞控的朋友对 SBUS 协议肯定都不会陌生。接收机输出一路串行信号却能同时承载 16 个通道的数据比传统 PWM 逐个通道输出要紧凑得多。但真要在 STM32 上把 SBUS 信号稳定解析出来尤其是用 HAL 库这套东西坑还是不少。这篇文章就复盘一下我实际做的一个项目基于 HAL 库用 DMA 循环接收 IDLE 中断 状态机实现 SBUS 协议解析。整个过程我会把方案怎么选、配置怎么点、代码怎么写、调试踩了哪些坑都讲清楚希望能给准备啃这块的同学省点时间。先说明一下这篇文章默认你手头有 STM32 开发板用的是 CubeMX 生成工程 HAL 库编译器随意Keil、IAR、STM32CubeIDE 都行。我会尽量把原理和代码逻辑拆开讲即使你之前没接触过 DMA 接收和空闲中断跟着思路走也能把它调通。1. 为什么是“DMA 循环接收 IDLE 中断 状态机”这个组合1.1 SBUS 协议的关键特征SBUS 是航模接收机常用的串行总线协议波特率 100000数据格式是 8 个数据位、偶校验、2 个停止位信号电平是反相的。也就是说平时我们看到的标准 UART 空闲电平是高电平而 SBUS 空闲电平是低电平所以要接一个反相器再进串口 RX。这个细节非常重要硬件不对后面什么都白搭。物理层搞清楚之后看数据帧。SBUS 一帧固定 25 字节开头是 0x0F结尾是 0x00中间 22 字节是通道数据。16 个通道每个通道占 11 bit总共 176 bit正好 22 字节。除此之外还有 1 字节的 flags 位其实 SBUS 完整帧还可以带 17~18 通道的标志位但最常用的就是 16 通道 11 bit 解析。如果你用普通串口中断一字节一字节收每秒 100000 bit每帧约 3ms一字节约 0.88ms也就是 3ms 内要进 25 次中断。短期看没问题但如果主循环里还在做控制算法、跑电机、刷屏幕中断频繁抢占很容易出现丢字节。而且串口中断写法还容易漏掉帧边界因为 SBUS 没有特别明显的帧头帧尾——0x0F 和 0x00 在通道数据里也可能出现。1.2 DMA 循环接收解决了什么问题DMA 的作用是帮 CPU 把串口收到的数据搬到内存缓冲区里搬运过程不占用 CPU 时间。我们把 DMA 配置成循环模式缓冲区开 128 字节或者 256 字节串口每收到一个字节DMA 自动存进缓冲区存满后从头再存。CPU 只需要在合适的时机去缓冲区里读数据。循环接收的好处是不管上位机什么时候发数据底层都能先接着不会因为 CPU 忙而丢字节。SBUS 每秒约 300 帧每帧 25 字节DMA 完全扛得住。配合 IDLE 中断可以在串口总线空闲时立刻知道“这一帧收完了”然后去缓冲区把这一帧抠出来。1.3 IDLE 中断的妙用IDLE 中断是串口外设的一个空闲检测功能。当串口 RX 线上由“有数据”变成“空闲”时会触发 IDLE 标志。SBUS 帧与帧之间本来就有空闲时间所以 IDLE 中断天然就是“一帧接收完成”的通知。和 DMA 接收完成中断比IDLE 中断更灵活。SBUS 的 25 字节不是定长吗为什么不用 DMA 接收固定 25 字节然后中断问题在于 DMA 循环接收时我们不知道第一帧数据从哪里开始如果恰好 DMA 从缓冲区中间开始存接收完成中断只代表缓冲区满了不代表一帧完整。如果用 DMA 正常模式一次收 25 字节每帧都要重新启动 DMA中间会有短暂关闭和重配的过程加上 CPU 介入忙的时候可能丢帧。IDLE 中断则是被动等待空闲一帧数据到达后总线空闲马上触发。这时候 DMA 只会把这一帧完整地留在缓冲区里不会等到缓冲区满才通知你。配合 DMA 循环接收既能保证数据不丢又能及时拿到帧边界。1.4 状态机在这里的意义既然 IDLE 中断能告诉我们“一帧收完了”直接从缓冲区里读 25 字节不就行了为什么还要状态机因为 DMA 循环接收的缓冲区是一个环形结构。如果 CPU 和 DMA 访问不同步可能出现读到的数据一半是上一帧、一半是这一帧。更麻烦的是如果一帧数据因为干扰被拆开或者 IDLE 误触发缓冲区里的数据可能不是从 0x0F 开始的。我们先用状态机在缓冲区里搜索起始字节 0x0F再按固定字节数量取数最后校验结束字节和通道数据合法性这样无论 DMA 落在哪个位置都能正确拼出一帧。状态机的另一个作用是避免帧错位。SBUS 的 0x0F 和 0x00 在通道数据里完全可能出现如果单纯按“遇到 0x0F 就是一帧开始”来解析很容易被假帧头带偏。状态机通过 25 字节长度约束和末尾 0x00 校验把假帧头过滤掉。可以说状态机是整个解析逻辑正确性的兜底。2. 硬件准备与 CubeMX 配置要点2.1 硬件反相电路SBUS 信号是反相 TTL 电平而 STM32 的 USART 接收引脚要求标准正逻辑。网上常见的做法是用一个 NPN 三极管做反相或者用逻辑芯片的反相器。我项目里用的是最简单的单三极管方案SBUS 信号从接收机出来经过一个 10k 电阻到三极管基极集电极上拉到 3.3V发射极接地。集电极输出接 STM32 的 RX 引脚。这样当 SBUS 信号为低电平时三极管截止RX 为高SBUS 信号为高电平时三极管导通RX 为低刚好完成反相。如果你不想自己搭电路买一片现成的 SBUS 转串口模块也行不过自己做更稳成本就几毛钱。硬件上还要注意共地接收机和 STM32 必须共地否则数据会乱。2.2 CubeMX 里的串口参数设置打开 CubeMX选择你的 STM32 型号配置 USART1我用的是 USART1你也可以用其他串口波特率100000数据位8校验位Even停止位2不使用硬件流控注意数据位是 8校验方式是偶校验停止位是 2。SBUS 是 8E2有些资料里写的 8 数据位 偶校验 2 停止位别搞成 8N1。如果校验位和停止位配错数据会全部错位而且 IDLE 中断也可能不触发。引脚上把 UART1 的 RX 对应引脚连接到反相电路输出端。如果板子上有 USB 转串口可以用来打印调试信息但不要和 SBUS 共用同一个串口否则干扰排查会很痛苦。2.3 DMA 配置要点在 CubeMX 中给 USART1 添加 DMA 请求选择 USART1_RXMode 设置为 Circular循环模式数据宽度 Peripheral 和 Memory 都选 Byte。DMA 优先级设为 High 或 Very High工整一点建议高优先级。循环模式是核心一定要选对。另外DMA 的 Memory Address Increment 要开启。Peripheral 地址不递增Memory 地址递增。数据方向是 Peripheral to Memory。这样串口收到一个字节DMA 自动放到内存数组的下一个位置。开启 DMA 后记得在 NVIC 设置中把 USART1_DMA_RX 中断优先级设得比主循环高但不要高过系统实时性要求。一般设 Preemption Priority 为 2 或 3Sub Priority 0 即可。还要开启 USART1 全局中断因为后面要用 IDLE 中断。2.4 生成代码后的手动补充CubeMX 生成的代码默认不会开启 IDLE 中断也不会启动 DMA 循环接收。在 main 函数的初始化部分需要手动加两行__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, sbus_rx_buf, SBUS_RX_BUF_SIZE);第一行使能串口的空闲中断第二行启动 DMA 循环接收。这里有个细节HAL_UART_Receive_DMA传入的缓冲区地址和大小决定了 DMA 搬运目标。我们用sbus_rx_buf作为环形缓冲区大小建议 128 或者 256。25 字节一帧128 足够容纳 5 帧即使偶尔调试慢一点也不会冲掉关键数据。3. 核心代码实现DMA 接收、IDLE 中断与状态机解析3.1 全局变量和缓冲区定义#define SBUS_RX_BUF_SIZE 128 #define SBUS_FRAME_SIZE 25 uint8_t sbus_rx_buf[SBUS_RX_BUF_SIZE]; volatile uint16_t sbus_rx_index 0; volatile uint8_t sbus_frame_ready 0;sbus_rx_index用来记录当前 DMA 已经写到的位置。sbus_frame_ready是帧完成标志主循环检测到它置 1 后开始解析。注意这些变量要加volatile因为它们会在中断里被修改。缓冲区大小如果用 128DMA 的余量计数器范围就是 0~127。我们需要在 IDLE 中断里读取huart1.hdmarx-Instance-NDTR得到 DMA 剩余未传输的字节数然后算出当前写入位置sbus_rx_index SBUS_RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx);NDTR是递减计数的所以用总大小减去剩余计数就是当前 DMA 已经写入的字节位置。3.2 IDLE 中断处理函数在stm32f1xx_it.c或对应系列的中断文件里的USART1_IRQHandler中需要添加对 IDLE 标志的处理。HAL 库不直接提供 IDLE 回调要自己判断void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_rx_index SBUS_RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx); sbus_frame_ready 1; } HAL_UART_IRQHandler(huart1); }注意先用__HAL_UART_CLEAR_IDLEFLAG清标志再取 DMA 计数器否则顺序反了可能拿到旧值。IDLE 标志在 HAL 库中的清除方式比较特殊调用这个宏比直接操作SR寄存器更稳妥。每次进入 IDLE 中断sbus_rx_index就是这一帧最后一个字节之后的位置。结合上一次解析时保存的位置就可以从环形缓冲区中提取一帧。但这里有一个重要问题如果 IDLE 中断触发时恰好 DMA 已经循环了一圈缓冲区头部数据可能被覆盖我们最好一次性把整帧数据拷贝到独立数组再做状态机解析。我建议在 IDLE 中断里只设置标志解析动作放到主循环执行避免中断处理时间过长。3.3 状态机主体框架状态机主要在sbus_parse_task()函数里运行主循环不断调用它。状态分为typedef enum { SBUS_STATE_IDLE, SBUS_STATE_SYNC, SBUS_STATE_DATA, SBUS_STATE_CHECK } SBUS_State;一个比较稳妥的处理方式是先把环形缓冲区中的最新一帧数据按起始位置拆到临时数组再交给状态机解析。因为 DMA 循环缓冲区里可能有前一次残留数据我们需要先找到 0x0F。下面是我用的解析逻辑简化伪码配合实际工程调整uint8_t sbus_raw_frame[SBUS_FRAME_SIZE]; uint16_t sbus_channels[16]; uint8_t sbus_frame_valid 0; void sbus_parse_task(void) { if (!sbus_frame_ready) return; sbus_frame_ready 0; // 从环形缓冲区中取出最新的一帧 uint8_t frame[SBUS_FRAME_SIZE]; uint16_t buf_size SBUS_RX_BUF_SIZE; uint16_t start_pos (sbus_rx_index buf_size - SBUS_FRAME_SIZE) % buf_size; for (int i 0; i SBUS_FRAME_SIZE; i) { frame[i] sbus_rx_buf[(start_pos i) % buf_size]; } // 用状态机校验并解析 parse_sbus_frame(frame); }注意这里取帧是直接用sbus_rx_index往前倒推 25 字节隐含假设了 IDLE 触发时这一帧是完整的。如果 SBUS 发送方存在帧间隔抖动这个假设基本成立因为 IDLE 中断触发条件就是总线空闲而一帧结束之后才会空闲。如果中途有干扰帧内容可能不是完整 25 字节就需要状态机来兜底。3.4 状态机解析核心parse_sbus_frame中实现状态流转void parse_sbus_frame(uint8_t *frame) { SBUS_State state SBUS_STATE_SYNC; uint8_t buf[SBUS_FRAME_SIZE]; uint8_t len 0; for (int i 0; i SBUS_FRAME_SIZE; i) { switch (state) { case SBUS_STATE_SYNC: if (frame[i] 0x0F) { buf[len] frame[i]; state SBUS_STATE_DATA; } // 不是帧头就继续找 break; case SBUS_STATE_DATA: buf[len] frame[i]; if (len 24) // 等待最后一个字节 { state SBUS_STATE_CHECK; } break; case SBUS_STATE_CHECK: buf[len] frame[i]; if (buf[0] 0x0F buf[24] 0x00) { sbus_frame_valid extract_sbus_channels(buf, sbus_channels); } len 0; state SBUS_STATE_SYNC; break; default: len 0; state SBUS_STATE_SYNC; break; } } }这个状态机其实做了两件事找帧头然后强制按 25 字节长度收满一帧最后检查末尾是否为 0x00。如果末尾不是 0x00说明帧头可能是通道数据里的假 0x0F这一帧丢弃重新回到找帧头的状态。实际运行时我还会加一个超时看门狗如果连续一段时间没有 IDLE 中断就把状态机复位到 SYNC。这在信号异常时能快速恢复。3.5 通道数据位拼接SBUS 的核心难点是把 22 字节的通道数据拆成 16 个 11 bit 通道。这里我推荐用位流方式处理把 22 字节看作一个连续的 bit 数组每个通道依次取 11 bit。uint8_t extract_sbus_channels(uint8_t *frame, uint16_t *channels) { uint8_t *data frame[1]; // 跳过帧头 0x0F uint32_t bit_buf 0; uint8_t bit_count 0; uint32_t bit_stream[22]; // 简化实际按字节展开 for (int ch 0; ch 16; ch) { uint16_t value 0; int start_bit ch * 11; int byte_idx start_bit / 8; int bit_offset start_bit % 8; value data[byte_idx] bit_offset; value | data[byte_idx 1] (8 - bit_offset); if (bit_offset 5) // 11 bit 跨了三个字节 { value | data[byte_idx 2] (16 - bit_offset); } value 0x07FF; channels[ch] value; } return 1; }这个公式可能第一次看比较绕。举个例子通道 1 的起始 bit 在第 0 字节 bit0那么 11 bit 从第 0 字节的 bit0~bit7 和第 1 字节的 bit0~bit2 拼接(data[0] 0) | (data[1] 8)再掩码0x07FF。但对通道 2起始 bit 是 11落在第 1 字节 bit3 上就需要(data[1] 3) | (data[2] 5) | (data[3] 13)。上面代码用byte_idx 2判断跨三字节的情况配合value 0x07FF可以覆盖所有 16 个通道。如果你觉得这样容易错还有一个更简单粗暴的方法把 22 字节拼成一个 176 bit 的数组然后用循环一位一位地读uint8_t bits[176]; for (int i 0; i 22; i) { for (int j 0; j 8; j) { bits[i * 8 j] (data[i] j) 0x01; } } for (int ch 0; ch 16; ch) { uint16_t value 0; for (int bit 0; bit 11; bit) { value | bits[ch * 11 bit] bit; } channels[ch] value; }这个方法可读性强代价是解析速度稍慢一点但 SBUS 每秒才 300 帧这种位操作开销完全可以忽略。我实测过一次在 72MHz 主频的 STM32F103 上跑位展开方式解析一帧耗时才不到 20 微秒对飞控来说毫无压力。3.6 主循环调用while (1) { sbus_parse_task(); // 其他业务代码 if (sbus_frame_valid) { // 使用 sbus_channels[0] ~ sbus_channels[15] 做遥控逻辑 // 例如油门、副翼、升降、方向、模式开关等 } }sbus_frame_valid在解析成功后置 1用完之后记得清零。如果没有新的有效帧这个值要保持上一次的状态还是清零看你业务需求。我通常给 SBUS 数据加一个失效时间判断如果超过 50ms 没有收到有效帧就认为接收机断连触发安全保护。4. 实战调试记录与避坑指南4.1 IDLE 中断不触发最常见的问题是 IDLE 中断一直不进来。排查顺序是检查串口配置是否正确尤其是波特率、停止位、校验位。SBUS 是 100000 波特不要手滑填成 115200。如果配置不对根本收不到数据自然没有空闲中断。确认 RX 引脚连接的信号是反相后的正逻辑。你可以用示波器或逻辑分析仪在 STM32 RX 引脚测波形如果发现空闲是高电平、有数据时拉低说明反相正常。如果空闲电平是低说明反相没做好或者信号直接接到了 RX。检查工程里是否真正调用了__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)。CubeMX 不会自动加这行一定要手动加。另外有些系列 HAL 库对 IDLE 中断的处理有 BUG旧版本可能需要在中断里手动读取 SR 寄存器再清 IDLE确认你用的库版本没有这个问题。DMA 没有启动也会导致 IDLE 不触发。因为 HAL_UART_Receive_DMA 会设置串口的 DMA 使能如果这一步没调用串口收到数据后 DMA 不搬缓冲区不动虽然 IDLE 标志仍会置位但后面取 DMA 计数器会得到奇怪的值。4.2 解析出来的通道值跳变通道值跳变通常不是协议逻辑问题而是帧错位。比如你收到的一帧不是从 0x0F 开始而是从中间某字节开始那么解析出来的 16 个通道值会乱跳。解决办法就是上文中状态机里先找 0x0F再校验末尾 0x00。我发现很多教程直接写死“从缓冲区 index 0 开始取 25 字节”这在 DMA 第一次启动时还能碰运气一旦 DMA 循环写回数据位置随机就会出问题。另外SBUS 通道数据理论上是 0~2047 范围中间值约 1024如果你发现所有通道都在 0 或 2047 附近极限跳变大概率是位拼接时移位方向搞反了。SBUS 是低位在前我在位拼接时用的是右移但有些参考代码写成左移导致通道值错乱。拿接收机接一个遥控器推动摇杆看对应通道值是否平滑变化就能判断拼接方向对不对。4.3 偶发丢帧或帧不完整如果你发现 SBUS 帧率不稳定一会儿有效一会儿无效首先检查接收机供电。SBUS 接收机工作电压一般是 3.3V~5V如果电源纹波大接收机输出波形质量差就会导致串口误码。可以在接收机电源脚并联一个 100uF 电解电容和 0.1uF 陶瓷电容。其次是中断优先级问题。如果 DMA 中断优先级太低或者主循环里有长耗时任务例如刷 OLED、写 SD 卡可能导致 DMA 还没来得及把数据搬完下一帧就来了造成缓冲区覆盖。我的做法是DMA 中断优先级设为高IDLE 中断也是对应串口中断的高优先级同时把 SBUS 解析放主循环解析函数不要做阻塞操作。4.4 缓冲区大小和取帧位置的关系很多初学者会把sbus_rx_index直接当作帧头位置然后从那里开始读 25 字节。这是错的。sbus_rx_index表示 DMA 当前写入位置也就是这一帧结束后的下一个位置不是帧头。正确取帧位置应该是(sbus_rx_index BUF_SIZE - 25) % BUF_SIZE。我在调试时就因为这个理解错误浪费了大半天时间。如果你用 128 字节缓冲区而 SBUS 一帧只有 25 字节理论上缓冲区最多能存 5 帧。但在 DMA 循环模式下只要解析速度足够快缓冲区里始终保存着最近接收到的一整段数据。我们取最后一帧时不需要关心之前的旧数据直接用上面的公式取最后 25 字节即可。这样即使偶尔解析任务被高优先级任务打断也不会取到不完整的帧。4.5 中断里做解析的后果如果为了省事在 IDLE 中断里直接调用sbus_parse_task()短时间内问题不大但一旦解析耗时过长会抢占其他中断甚至导致系统崩溃。我实测在中断里做位拼接解析因为要处理大量移位和数组操作中断响应时间明显变长。尤其是当你未来还想在同一个串口上跑其他协议时中断里干脏活会非常痛苦。所以务必保持“中断只置标志位主循环做解析”的原则。5. 实际操作后的几点体会把这一套流程跑通之后我发现 SBUS 解析的真正难点不在 DMA 和 IDLE而在对“环形缓冲区 状态机”的理解。很多网上代码一抄就能跑但换个缓冲区大小、换个波特率、换到别的串口就原形毕露。核心还是要懂 DMA 的 NDTR 计数器怎么用IDLE 标志怎么清状态机怎么防止假帧头。我在这个项目中最后还加了一个小功能把解析到的 SBUS 通道值通过另一个串口输出到上位机用匿名上位机或 QGroundControl 的调试曲线看波形确认 16 个通道是否都正确解析。这样调试效率高很多推荐你也试一下。另外如果你在写其他协议比如 DJI 的 DBUS、Futaba 的 SBUS2这套 DMAIDLE状态机框架完全可以复用只需要改波特率、帧长和通道位宽。后续扩展时记得把缓冲区大小、帧长、通道数这些参数全部用宏定义好不要让魔法数字散落在代码里否则维护起来很痛苦。SBUS 解析只是遥控链路里很小的一环但它涉及了串口、DMA、中断、状态机多个知识点把这些吃透了再去搞其他通信协议会顺畅很多。希望这篇文章能帮你少踩几个坑。