STM32 SBUS解析:DMA循环接收与IDLE中断状态机实现
最近在搞一个遥控接收机的信号采集模块需要把接收机输出的SBUS协议解析成16通道PWM数值再通过串口转发给上位机。折腾了几天最后敲定 STM32 HAL 库 DMA 循环接收 IDLE 中断 状态机这套组合实际跑下来非常稳定CPU 占用极低。这篇文章把我的完整实现思路和代码细节整理出来给同样在玩SBUS解析的朋友一个可参考的方案。1. 协议与方案背景为什么SBUS需要这个组合1.1 SBUS到底是什么——物理层与帧格式SBUS是航模接收机常见的串行总线协议Futaba主导的开放协议很多接收机如FrSky X系列、R9MM等都支持输出。物理层是UART但有几个跟普通串口不一样的地方波特率是100000比标准波特率更小众数据位8位、偶校验、2个停止位8E2信号电平是反相的TTL原始输出是高电平代表逻辑0低电平代表逻辑1。帧结构比较特殊固定25个字节第1字节0x0F帧头中间22字节存放16个通道数据每个通道11位共176位正好塞满22字节然后是1字节标志位最后1字节0x00帧尾。标志位里BIT7表示第17通道BIT6表示第18通道BIT5表示信号丢失BIT4表示failSafe触发。帧间隔一般在14ms左右有些接收机支持高速模式输出7ms一帧所以整个协议是个比较典型的“定长帧 帧头帧尾校验”结构。关键点来了SBUS的100000波特率并不是STM32系统时钟能整数倍分频出来的好在串口接收端对波特率误差容忍度通常有3%左右下面会专门算。1.2 为什么用DMA循环接收而不是传统逐字节中断如果只用串口中断逐字节接收一个字节进一次中断14ms一帧25字节在多个串口同时工作或者主循环里有重负载时CPU频繁进出中断的开销非常明显。SBUS帧率高达70Hz甚至140Hz逐字节中断方式虽然也能跑但每次中断还要处理半字节错误、校验错误等耦合度很高后期想加别的协议接收会发现中断里全是代码。用DMA接收的本质是让外设直接把数据搬运到内存缓冲区不经过CPU。配合DMA的循环模式Circular模式可以做到接收缓冲区是一个环形结构硬件自动回绕软件只需要关注“从上次处理到当前DMA又搬了多少数据”一次性批量解析。这样CPU从“每字节一次中断”降到“每帧一次中断”开销直接少一个数量级。这个方案比较适合SBUS这类固定帧长、数据到达又快又规律的协议。DMA循环接收还有好处是数据搬移的时序由硬件保证不会因为中断优先级被打断导致丢字节配合IDLE中断做帧边界判定基本不会错位。1.3 IDLE中断在SBUS接收中的角色IDLE中断的全称是线路空闲中断UART在接收到一字节后如果检测到总线上一个字节的时间宽度内没有新的起始位就会触发IDLE事件。这正好用来标记“一帧数据已经传输完毕”因为SBUS帧与帧之间有约7ms的空闲远大于一个字节的时间IDLE事件基本等于“SBUS一帧接收完成”。对于STM32 HAL库IDLE中断默认不会像普通收发中断那样在HAL_UART_IRQHandler里分发回调这也是很多新手直接掉坑的地方。要么自己在UART中断服务函数里判断IDLE标志位要么用新版HAL提供的HAL_UARTEx_ReceiveToIdle_DMA接口它会自动处理IDLE事件并回调HAL_UARTEx_RxEventCallback。我的方案里选了自己判断IDLE标志位兼容性更好也更好理解。2. 硬件设计与CubeMX配置避坑2.1 信号反相问题最容易被忽略的第一关SBUS的物理层信号是反相逻辑而STM32的UART接收RX引脚期望的是常规极性也就是空闲时为高电平起始位为低电平。接收机的SBUS输出空闲时是低电平起始位是高电平直接接到STM32上会乱码。很多人在这一步卡了很久收到的数据要么全0要么全F根本进不了解析状态。处理方式一般有两个方向硬件上反相或者软件上依赖部分STM32型号的RXINV功能。硬件反相是最稳妥的因为不依赖芯片型号。常用的做法是用一个三极管或者非门反相我用的是一颗74LVC1G04单非门电路很简单输入串一个1K电阻输出直接进STM32串口RX。如果手头没有非门芯片用NPN三极管搭反相器也行注意翻转速度和电平幅度波特率100000下普通2N7002或S8050都足够快。软件上STM32F4/F7/H7系列的USART有RXINV位可以反转RX引脚极性CubeMX里在USART参数配置的Advanced Features下可以勾选“RX引脚极性反转”。但要注意不是所有型号都支持F1系列就没有这个功能而且反相配置对IDLE判断等也有潜在影响所以我建议能上硬件反相就优先硬件反相省得后面排查起来多一个变量。2.2 CubeMX关键参数逐项说明打开CubeMX选择芯片和时钟后配置USART的参数需要注意几个点。第一波特率直接填100000数据位选8位校验位选Even停止位选2位。这里有个知识点STM32的USART实际寄存器里8位数据偶校验在硬件上占用9个bit8数据位1校验位所以跟SBUS的8E2是完全兼容的不需要额外脑补。第二DMA设置里添加USART_RX方向选Peripheral to Memory模式一定要选Circular循环模式。增量地址外设地址不增量内存地址增量。数据宽度外设和内存都选Byte。这里有一个经常被忽略的配置项DMA的“Mode”如果选Normal数据缓冲区满了DMA就停止后面的数据直接丢失所以循环模式必须选好。第三还有一个叫“DMA Continuous Requests”的选项这个建议勾上。勾选后UART在DMA搬运期间不会自动关闭接收请求对于流式接收是必需的不然DMA传输完成后UART接收保持使能会有问题。很多教程里没提这个选项但我实测下来不勾它循环接收在某些场景下会出现首帧后不再接收的问题。第四NVIC配置里必须使能USART全局中断DMA中断可以不使能因为我们用的是IDLE中断来通知数据到达不需要DMA传输完成中断挤进来抢优先级。原因是循环模式DMA永远不会“传输完成”它在不断回绕传完成中断没有实际意义反而增加处理负担。2.3 DMA循环模式下的缓冲区大小设计DMA循环接收要提前准备好一个内存缓冲区缓冲区大小决定了一次最多能缓存多少字节。SBUS一帧25字节帧间隔约7ms在100000波特率下一个字节约耗时1ms不到10位×10us100us? 注100000波特率对应每bit 10us一个字节10bit100us25字节约2.5ms7ms空闲足够我们在这期间处理。缓冲区建议设成128或256字节不要非得卡成25的整数倍。原因很简单IDLE中断触发时我们通过DMA计数器得到的是“从起始位置到当前位置总共搬了多少字节”的差值这个差值可能跨越任意位置不一定是完整帧的倍数。缓冲区越大对“处理不及时”的容忍度越高即使主循环被高优先级任务卡了一下DMA还是在硬件层面不断接收不丢数据。我用的是256字节实际接收过程中一次IDLE事件搬进来的数据量往往是刚好一帧25字节偶尔会出现连续两帧挤在一起而产生50字节的批量数据这种情况状态机也能正确处理因为解析是逐字节推进的。缓冲区定义要使用volatile关键字并做对齐处理防止DMA写入被编译器优化掉或跨页访问出现问题。老版本串口DMA有个坑如果缓冲区起始地址跨越了DMA控制器的某个边界会导致数据传输错误。CubeMX生成的缓冲区如果放到了.bss段一般没问题但稳妥起见可以加__ALIGN_BEGIN或者用uint8_t声明后交给DMA即可。3. 代码实现IDLE回调、环形缓冲与状态机3.1 中断服务函数如何正确捕获IDLE事件CubeMX生成的代码里串口中断服务函数是USART1_IRQHandler默认实现只调用HAL_UART_IRQHandler。我这个方案里需要自己手动处理IDLE事件。修改后的代码如下void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 处理SBUS数据计算DMA已经搬入的数据量 sbus_uart_idle_handler(huart1); } HAL_UART_IRQHandler(huart1); }这段代码有两个细节要注意。第一个细节必须先读IDLE标志再清标志而且是把读和清放到一条语句里。UART的IDLE标志清除方式是先读SR寄存器再读DR寄存器写其他值清不了。HAL库提供了一个宏__HAL_UART_CLEAR_IDLEFLAG但这个宏在某些芯片上只是写一个非DR值不一定真正清掉。所以更稳妥的做法是直接读SR再读DR或者用这个宏配合一条假读语句。我在代码里用的是宏实测F1系列有效但如果你用的是新出的G0/L4系列最好确认一下标志是不是真的清了不然会一直进IDLE中断。第二个细节HAL_UART_IRQHandler和我们的IDLE处理函数谁先调用。我试过先调用HAL_UART_IRQHandler再清IDLE也试过我的处理函数放前面。实测下来影响不大但要保证HAL_UART_IRQHandler不要覆盖掉我们刚计算的数据长度。HAL库在IRQHandler里如果检测到接收错误或空闲标志会执行错误处理和回调所以我的建议是先处理IDLE再做标准HAL处理避免对DMA计数器的读取被HAL内部的某些操作干扰。3.2 IDLE回调里DMA计数器的计算DMA循环模式下要从DMA当前计数寄存器倒推出“本次新到了多少字节”。思路是利用DMA的NDTR寄存器这个寄存器在循环模式下会从缓冲区大小开始递减减到0后重新装载但每次IDLE事件发生时读到的值代表“还剩多少字节没搬”。计算公式是这样的uint16_t cur __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received (last_counter cur) ? (last_counter - cur) : (SBUS_RX_BUF_SIZE - cur last_counter); last_counter cur;last_counter是上一次处理完时DMA计数器的值。当新数据到达DMA计数器从last_counter递减到当前值cur两者之差就是本次DMA搬运了多少字节。如果中间缓冲区回绕了一次last_counter小于cur就加上缓冲区大小再减。实际测试会发现一个细节只要咱们处理速度够快IDLE中断触发后last_counter和cur的差值基本就是完整的一帧25字节连续收到两帧时这个值是50。状态机不需要关心一次进来多少字节它只是把缓冲区里的数据当作字节流逐字节消费。idle处理函数里拿到received长度后把缓冲区中从上次位置到当前位置的字节拷贝或者直接传递给解析状态机。因为DMA循环缓冲区本身是环形结构数据可能跨缓冲区末尾回绕所以拷贝时要做两次memcpy或者直接在这个函数里循环逐字节喂给状态机。我选择逐字节喂因为SBUS一帧才25字节循环开销微不足道而且天然处理了回绕问题不用额外判断。void sbus_uart_idle_handler(UART_HandleTypeDef *huart) { uint16_t cur __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received (last_counter cur) ? (last_counter - cur) : (SBUS_RX_BUF_SIZE - cur last_counter); last_counter cur; for (uint16_t i 0; i received; i) { // 循环缓冲区的真实索引处理回绕 sbus_rx_buffer[read_index] ...; read_index (read_index 1) % SBUS_RX_BUF_SIZE; sbus_parse_feed_byte(...); } }因为这块代码在中断上下文里所以处理逻辑必须短小精悍不能做浮点运算不能调用耗时函数尽量在微秒级完成。SBUS一帧数据才25字节逐字节喂状态机在100MHz的M4上也就是几个微秒的事非常安全。3.3 状态机核心逻辑设计状态机的价值在于对于一帧SBUS数据解包时不能只依赖“固定字节位置”因为一旦出现半帧错位或者丢字节直接按固定位置解析会产生灾难性的错误数据。引入状态机按序推进能够自动重新寻找帧头容错性大大提高。我们定义帧状态机的几个状态typedef enum { SBUS_STATE_WAIT_HEADER, // 等待帧头0x0F SBUS_STATE_RX_DATA, // 接收通道数据和标志位 SBUS_STATE_CHECK_END // 校验帧尾0x00 } sbus_state_t;逐字节喂给状态机的逻辑void sbus_parse_feed_byte(uint8_t data) { switch (sbus.state) { case SBUS_STATE_WAIT_HEADER: if (data 0x0F) { sbus.buf[0] data; sbus.index 1; sbus.state SBUS_STATE_RX_DATA; } break; case SBUS_STATE_RX_DATA: sbus.buf[sbus.index] data; // 帧头1字节 通道数据22字节 标志位1字节 24字节 if (sbus.index 24) { sbus.state SBUS_STATE_CHECK_END; } break; case SBUS_STATE_CHECK_END: sbus.buf[24] data; if (data 0x00) { sbus.valid 1; // 一帧有效数据解析完成 sbus_decode_channels(sbus); } else { // 帧尾错误重新开始找帧头注意不能丢弃当前字节因为当前字节可能是下一帧的帧头 sbus.state SBUS_STATE_WAIT_HEADER; if (data 0x0F) { sbus.buf[0] data; sbus.index 1; sbus.state SBUS_STATE_RX_DATA; } } break; } }这里最巧妙的是CHECK_END状态里如果帧尾校验失败当前字节不能直接丢掉要把它视作潜在的下一帧帧头再重新走一遍。这个细节很关键因为SBUS没有像传统帧协议那样有明确的帧头校验和CRC唯一的帧边界信号就是0x0F头和0x00尾一旦中间出现误码导致错位重新同步能力就靠这段逻辑。实际运行中只要接收机信号正常状态机稳定在WAIT_HEADER→RX_DATA→CHECK_END→WAIT_HEADER的循环中。如果接线不良或干扰较大会有偶发的帧尾错误这时状态机会退回首字节重新同步下一帧就能恢复不会产生持续性的错乱。3.4 16通道11bit数据提取SBUS的16个通道每个11位不是按字节对齐存储的而是连续排列的位流。解析时需要按位偏移去提取。通道数据从帧的第2字节开始索引1连续176位。提取公式可以这么写for (int ch 0; ch 16; ch) { uint16_t bitpos ch * 11; uint16_t bytepos bitpos / 8; uint8_t shift bitpos % 8; uint16_t value (sbus.buf[1 bytepos] shift) | ((uint16_t)sbus.buf[1 bytepos 1] (8 - shift)); sbus.channels[ch] value 0x07FF; // 11位掩码 }这里的索引要小心缓冲区中第1个字节索引0是帧头0x0F通道数据从索引1开始所以公式里加了1。bytepos最大是21因为176位最后一组是通道15的位偏移165~175位字节偏移20.625需要访问buf[22]和buf[23]正好在DATA区域范围内不会越界。提取出来的值是0~2047的范围对应接收机输出的PWM比例值。飞控领域常把SBUS通道值约化成1000~2000的PPM值实际使用时在业务层做一次线性缩放即可。这里不做缩放保持原始值方便调试对比。标志位解析也要跟上sbus.flag sbus.buf[23]; sbus.channel17 (sbus.flag 7) 1; sbus.channel18 (sbus.flag 6) 1; sbus.frame_lost (sbus.flag 5) 1; sbus.failsafe (sbus.flag 4) 1;这些标志位在飞控应用里非常重要frame_lost和failsafe直接决定要不要切换安全模式。我的模块里把这两个标志位单独剥出来置成一个全局状态业务逻辑直接读不用每次解析时去翻原始帧。4. 实测数据与问题排查实录4.1 实测波形与帧间隔数据把逻辑分析仪挂在SBUS串口线上可以看到典型的帧结构25字节密集输出之后是一段约7ms的空闲然后又是下一帧。在100000波特率下每个字节耗时100us多一点25字节密集传输约2.6ms加上7ms空闲总周期约10ms上下基本符合接收机默认的14ms周期不同品牌有差异部分支持7ms高速模式。空闲期间正好是IDLE中断的触发点。因为SBUS空闲时间远大于单字节时间IDLE中断不会在帧中间误触发。这一点和某些高密度协议不同也是SBUS能放心用IDLE中断的原因。用示波器测量了一下我们的系统响应从IDLE中断触发到16个通道数据解析完成并放入全局变量整个耗时约20usM4 100MHz优化等级O2对2.6ms的帧密集期来说占用不到1%CPU基本是空闲的。这也是DMA方案最大的优势传统逐字节中断在这个场景下至少占用个10%以上CPU时间。4.2 常见故障速查表整理一下我调试过程中遇到的典型问题做成速查表应该能帮大家节省很多排查时间。故障现象可能原因排查与解决收不到任何数据信号极性反了检查SBUS输出电压逻辑确认是否已经外部反相收到全是0x00或0xFF线路空闲电平不对示波器看空闲电平应为高电平反相后检查非门电路供电能收到数据但校验总是失败波特率偏差太大计算当前系统时钟下的实际波特率误差看是否超过3%IDLE中断一直触发标志位没清干净确认__HAL_UART_CLEAR_IDLEFLAG是否真正有效必要时手动读SR再读DR数据偶尔错位一帧后恢复帧同步丢失确认状态机CHECK_END失败后有把当前字节继续喂入而不是直接丢弃程序调试器暂停后恢复数据错乱调试器暂停期间DMA还在跑环形缓冲覆盖这是正常现象重新连接或复位接收机即可不影响实际运行与飞控通信时偶发丢包解析完成标志位读写时序问题解析标志在用完后就清零避免业务层重复读取旧数据4.3 调试技巧串口打印不要直接进中断调试SBUS解析时最忌讳的就是在IDLE中断回调里直接调用printf重定向的串口发送。串口发送使用同一个外设或者不同外设时中断优先级和阻塞时间都可能干扰接收时序。我调试时用的是“解析完成标志位主循环轮询打印”的方式中断里只置位标志把数据存到全局结构体主循环检测到标志后再格式化打印。这样打印耗时再长也不影响接收侧。另外一个实用的调试技巧是统计帧错误计数。我维护一个sbus_error_count变量每次帧尾校验失败就加1。比起肉眼观察乱码用这个计数器能很快判断信号质量。正常接线时这个计数应该基本不增长如果每秒几十次增长优先排查硬件极性、地线、接触电阻而不是软件逻辑。还有调试时尽量把SBUS接地点和STM32板子的地连好航模接收机的地线和模拟电源地线有时候会带来一点压差串口在这种高压差下容易出错。还要提醒一下DMA缓冲区定义在片内RAM不要在中断回调里同时被多个地方修改。我的做法是解析完成后的通道数组放在一个独立结构体中主循环读取这个结构体时先关中断再取数取完立即开中断。对STM32这类单核MCU关中断取数是最简单可靠的互斥手段避免主循环读到一半中断又更新了数据导致高低字节错位。5. 中断优先级与其他工程细节5.1 中断优先级设置的讲究NVIC优先级设置上USART1全局中断的抢占优先级我设成0子优先级0也就是最高优先级。为什么因为SBUS接收是控制链路的一部分丢一帧数据在飞控场景可能意味着几十毫秒的控制延迟优先级给最高是值得的。但要注意同组内如果有定时器中断负责编码器采集定时器优先级可以同级或稍低不能出现互锁。DMA中断我没有使能因为循环模式下DMA传输完成中断只会无意义地频繁触发。如果有些项目需要DMA半满中断来做双缓冲处理那DMA中断优先级应和串口中断保持一致避免DMA半满还没处理完IDLE又进来了。双缓冲方案适合数据量更大的协议SBUS这里用不到单DMA循环IDLE已经足够。5.2 与业务模块对接的工程做法解析出的通道数据最终要对外输出可能通过另一路串口、CAN、PWM或USB。对接时我建议把“协议解析”和“数据消费”解耦解析模块只维护sbus_data结构体和valid标志消费模块轮询valid标志然后取数据。这样做的好处是如果将来接收机换成了CRSF协议只需要替换解析模块对外接口保持不变。即使主循环里加了大运算任务只要不超过IDLE中断间隔解析也不会丢帧。我这个模块里valid标志用volatile声明并在消费后清零避免重复消费同一帧数据。对于需要更高实时性的场景valid标志可以换成信号量或者事件标志由消费任务阻塞等待但核心解析流程是完全一样的。最后再分享一个经验DMA循环接收 IDLE中断这套组合不止能用在小帧协议上常见的Modbus RTU主从、DJI云台协议、各类自定义变长协议都可以用同样的骨架实现只需要更换状态机的字节判断逻辑。把这个骨架吃透以后项目里遇到任何串口协议解析基本都能在半小时内适配出来。