ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 DMA+IDLE中断+状态机高效解析SBUS协议

STM32 DMA+IDLE中断+状态机高效解析SBUS协议 前两天整理一个无人机飞行控制器项目时又翻出之前调通的SBUS接收代码趁着空档把整套思路写下来。如果你玩过STM32 遥控接收机一定遇到过这类问题用串口中断逐字节解析SBUS协议CPU一直被硬件打断数据还偶尔错乱想用DMA一次性接收25字节又发现SBUS的帧边界并没有那么好切。这篇文章要分享的就是我在多个项目里验证过、稳定跑下来的一套方案HAL库下用DMA循环接收 USART的IDLE空闲中断 轻量状态机彻底解决SBUS协议解析中的帧边界、CPU占用和错位问题。适合刚学会CubeMX、准备接遥控接收机做飞控或机器人底盘的开发者也适合想把串口接收从“中断逐字节”升级为“硬件搬运 每帧一次性处理”的老手。1. 先搞清楚 SBUS 协议到底长什么样1.1 一帧25字节16个通道是11bit打包的SBUS是Futaba推的串行遥控协议现在几乎成了航模接收机的标配输出。物理层就是一根信号线波特率固定100000bps8位数据位偶校验2位停止位。这个“8E2”很多人在HAL库里会填错后面第3章专门讲。先记住一个关键点因为校验位占一位串口线上实际每字节要走11bit所以算传输耗时要用25×11不是25×8。完整的一帧SBUS数据是25字节拆开看分四块偏移长度内容01字节帧头固定0x0F1 ~ 2222字节16个通道数据每通道11bit按位连续打包231字节FLAG标志字节不同接收机定义略有差异241字节帧尾正常为0x0022字节放16个通道靠的是“位流”而不是“字节流”第一个通道占用bit0到bit10第二个通道占用bit11到bit21依此类推。16 × 11 176bit正好22字节。这个打包方式决定了解析时不能直接按字节搬必须做位运算。1.2 为什么逐字节解析SBUS容易翻车我最开始做SBUS接收时用的就是最朴素的串口中断方案每来一个字节进一次中断判断是不是0x0F是就开始攒数据攒满25字节就解析。当时在F103上跑没觉得有什么问题后来把系统复杂度加上去就露馅了。首先是CPU占用问题。100kbps下每字节耗时0.11ms一帧25字节约2.75ms中间每一字节都要打断MCU一次。如果系统里还有编码器中断、定时器PWM更新、无线透传、显示刷新极端情况下串口中断会被拖住接收缓冲区溢出丢帧就来了。其次是边界问题。SBUS虽然帧长固定25字节但接收机刚上电时、信号异常时都可能出现半帧数据或0x0F丢帧。如果只靠“攒满25字节就解析”DMA缓冲区一旦错位后面每一帧都会跟着错。这时候必须依赖一个能重新同步的机制也就是状态机。这也是我最终采用“DMA循环接收 IDLE中断 状态机”的根本原因硬件负责接收和报告边界软件状态机负责对齐、容错和解包。2. 方案选型这套组合到底解决了什么问题2.1 三种串口接收方式横向对比在STM32上收不定长串口数据常用的有三条路。先给个对比表大家心里有数方式帧边界判定CPU负载抗错位能力适用场景轮询读DR没有全靠软件拼最高差演示代码不建议实跑串口逐字节中断自己判断帧头帧尾中高中等数据量小、系统空闲DMA循环 IDLE中断硬件空闲中断低较高高速/不定长/高频协议轮询就不多说了纯让CPU死等。逐字节中断是入门标准做法代码直观、逻辑简单但每字节都进中断的开销不可忽略。DMA循环接收的思路是把“搬运字节”这件事从CPU手里彻底拿掉交给DMA硬件自动往内存写CPU只在整帧接收完成的边界点处理一次也就是IDLE空闲中断触发的时候。2.2 IDLE中断是SBUS帧边界最好的裁判SBUS每一帧传输结束后总线上通常会留出一段空闲时间。常见Futaba接收机的帧间隔大约7ms而一帧25字节在100kbps下只需要2.75ms左右也就是说数据结束后至少有4ms以上的时间总线是空闲的。USART硬件检测到RX线空闲一个字节时间后就会触发IDLE中断这个时机正好落在两帧之间天然就是帧边界。具体实现时IDLE中断要跟DMA循环接收配合。进入回调后第一件事就是读DMA的当前计数寄存器NDTR算出DMA已经写到了缓冲区哪个位置。DMA循环模式下缓冲区大小是NNDTR从N递减到0后会立刻回卷到N。所以当前写位置wpos 缓冲区大小 - NDTR再减去上一次处理位置就是这段时间新到的字节数。这套逻辑很多人会写错主要错在读NDTR的时机太晚、缓冲区回绕没取模、忘记清IDLE标志。较新的HAL库版本里IDLE中断会自动调用HAL_UART_IdleCpltCallback弱回调如果HAL库比较老没有这个回调也可以在USART1_IRQHandler里手动判断IDLE标志后调用同一套核心处理。两种方式本质一样我把逻辑抽成一个独立函数方便复用。2.3 状态机解决的是错位和半帧问题有人觉得有了IDLE中断直接在回调里“从缓冲区拷贝25字节”不就行了我也这么试过实际翻过车。DMA循环缓冲区里新来的数据不一定正好是25字节接收机重启、信号抖动、总线异常时一个空闲周期内可能攒了半帧加一帧也可能只有几个零碎字节。如果处理逻辑是“固定取25字节”遇到这种情况直接错位而且一次错位会一直错下去。状态机的意义就是不管来的是多少字节、从哪个位置开始都逐字节往里喂通过“找帧头0x0F”这个动作不断把流重新对齐。硬件给边界、软件给容错这句话就是这套方案的核心思想。3. CubeMX配置和硬件准备3.1 关键参数100000波特率8E2在CubeMX里怎么填SBUS只需要一根接收线我习惯接USART1的RX也就是PA10。CubeMX里选择USART1模式设成Asynchronous波特率填100000。后面几个参数非常关键千万别填错Word Length选9 BitsParity选EvenStop Bits选2 Bits这里解释一下HAL库里的Word Length指的是“数据位 校验位”的总位数。8E2的真实含义是8位数据、1位偶校验、2位停止位所以总位数是9。有人在这里选了8 Bits Even 2 Stop结果解出来全是乱码就是这个原因。接下来配置DMA添加USART1_RX的DMA请求Direction选Peripheral To MemoryMode选Circular内存地址增量打开外设地址增量关掉数据宽度两边都选Byte。Circular模式是关键它保证DMA写满数组后自动回卷继续写不需要每帧都重新启动。3.2 电平反相问题SBUS不是“正常”的串口信号SBUS协议本身不复杂最坑的是电气特性。很多Futaba接收机的S.BUS输出是反相TTL信号线空闲时是低电平数据比特的低电平反而是高电平。但STM32的USART只能收正向串口逻辑直接把反相SBUS信号接到PA10上是无论如何都解析不出来的。解决办法是加一个反相电路。最便宜的方式是用一个NPN三极管SBUS信号通过基极电阻接三极管三极管集电极上拉3.3V后接STM32的RX。SBUS输出低电平时三极管截止RX被上拉为高电平SBUS输出高电平时三极管导通RX被拉低。这样信号就翻转为STM32认识的正向串口格式。也可以用74HC04这类反相门但注意供电必须是3.3V不能直接上5V。如果你的接收机模块已经内置反相或者用的是飞控板上处理好的SBUS接口就不用折腾这一步。还要注意电平范围。多数接收机是3.3V输出直接接STM32没有问题如果是5V电平最好加分压电阻做保护避免长期运行损伤引脚。3.3 中断和优先级配置清单CubeMX里最后一项容易忽略的是中断配置。我们需要的是串口空闲中断所以必须打开USART1全局中断也就是USART1_IRQn。NVIC里我会把USART1中断优先级拉到最高Preemption Priority设为0Sub Priority设为0。原因很简单IDLE中断回调里要做“读NDTR 喂状态机”这几步处理窗口越小越好。如果串口中断优先级太低被其他中断打断太久DMA缓冲区可能被下一帧数据覆盖轻则丢帧重则数据断流。DMA那边不需要开中断Circul模式也不需要DMA传输完成中断。整个方案实际只用USART1一个中断源中断嵌套复杂度很低。硬件连线也很简单STM32最小系统板的PA10接SBUS信号线板子和接收机共地调试用最好再接一个USB转串口方便打印日志。注意SBUS只需要接收线不需要连接TX。4. 代码实现从DMA缓冲区到状态机逐字节解析4.1 数据结构和全局定义代码我按模块化写直接建一个sbus.c和sbus.h把协议解析跟业务逻辑分开。先看数据结构和缓冲区定义#include main.h #define SBUS_UART huart1 #define SBUS_DMA_BUF_SIZE 64U #define SBUS_FRAME_SIZE 25U typedef enum { SBUS_STATE_WAIT_HEADER 0, SBUS_STATE_COLLECT_DATA } sbus_decoder_state_t; typedef struct { uint8_t buf[SBUS_FRAME_SIZE]; uint8_t index; sbus_decoder_state_t state; } sbus_decoder_t; typedef struct { uint16_t channels[16]; // 原始11bit通道值范围0~2047 uint8_t flags; // 标志字节原样保存 uint8_t valid; // 置1表示有一帧新数据可读取 } sbus_packet_t; static uint8_t dma_buffer[SBUS_DMA_BUF_SIZE]; static volatile uint16_t read_pos; static sbus_decoder_t sbus_dec; static sbus_packet_t sbus_pkt;DMA缓冲区大小我选了64字节可以容纳两帧多一点的SBUS数据。一帧25字节100kbps下2.75ms传完中间还有空闲间隔64字节足够缓冲也不用担心NDTR计数超过16位。以后如果要做其他更长帧的串口协议缓冲区可以扩展到256解析逻辑不用动。4.2 IDLE中断回调从环形缓冲区提取新数据初始化函数很简洁放在main函数里调用void SBUS_Init(void) { read_pos 0; sbus_dec.state SBUS_STATE_WAIT_HEADER; sbus_dec.index 0; sbus_pkt.valid 0; __HAL_UART_ENABLE_IT(SBUS_UART, UART_IT_IDLE); HAL_UART_Receive_DMA(SBUS_UART, dma_buffer, SBUS_DMA_BUF_SIZE); }这里先使能IDLE中断再启动DMA接收。较新的HAL库会自动调用HAL_UART_IdleCpltCallback弱回调你就直接在回调里转发到核心处理函数void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance SBUS_UART.Instance) { SBUS_IdleLine_ISR(); } }如果你的HAL库比较老没有这个弱回调可以在USART1_IRQHandler中手动判断后调用同一套逻辑。注意两种接入方式选一种就行不要重复处理IDLE事件。核心处理函数如下这就是整套方案的中枢void SBUS_IdleLine_ISR(void) { uint16_t write_pos; uint16_t len; uint16_t i; if (__HAL_UART_GET_FLAG(SBUS_UART, UART_FLAG_ORE) ! RESET) { __HAL_UART_CLEAR_OREFLAG(SBUS_UART); } write_pos SBUS_DMA_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(SBUS_UART.hdmarx); len (write_pos SBUS_DMA_BUF_SIZE - read_pos) % SBUS_DMA_BUF_SIZE; for (i 0; i len; i) { SBUS_Decode_Byte(dma_buffer[(read_pos i) % SBUS_DMA_BUF_SIZE]); } read_pos write_pos; }这段逻辑是整个方案的核心。__HAL_DMA_GET_COUNTER返回的是DMA还剩余多少字节没搬运因为缓冲区是环形的所以当前写位置就是缓冲区大小减剩余计数。length计算用模运算确保缓冲区回绕时也能得到正确的新数据长度。最后把这些字节逐个交给状态机。清ORE标志那一行千万别删。DMA循环接收模式下如果CPU响应不及时串口溢出标志会置位轻则丢字节重则后续数据全部断流。每次空闲中断顺手清一下能避免很多莫名其妙的问题。4.3 状态机核心先找帧头再拼数据状态机逻辑分两段WAIT_HEADER阶段不停找0x0F找到后进入COLLECT_DATA阶段往下收满25字节再校验帧尾是不是0x00。void SBUS_Decode_Byte(uint8_t byte) { switch (sbus_dec.state) { case SBUS_STATE_WAIT_HEADER: if (byte 0x0F) { sbus_dec.buf[0] byte; sbus_dec.index 1; sbus_dec.state SBUS_STATE_COLLECT_DATA; } break; case SBUS_STATE_COLLECT_DATA: sbus_dec.buf[sbus_dec.index] byte; if (sbus_dec.index SBUS_FRAME_SIZE) { if (sbus_dec.buf[SBUS_FRAME_SIZE - 1] 0x00) { SBUS_Parse(sbus_dec.buf, sbus_pkt); sbus_pkt.valid 1; } sbus_dec.state SBUS_STATE_WAIT_HEADER; } break; default: sbus_dec.state SBUS_STATE_WAIT_HEADER; break; } }第25字节的帧尾校验不是SBUS协议强制的但加上能有效防止“错位后把数据区的0x0F误当成帧头”。如果数据区里恰好出现0x0F状态机不会理它只有当下连续25个字节收满、且最后一个字节是0x00才认定这是一帧完整SBUS数据。这个状态机在接收机刚上电、DMA缓冲区残留半帧数据时尤其有用。不管之前缓冲区里堆了什么垃圾只要出现0x0F状态机就能重新同步。我实测过用串口助手往里面发随机字节状态机最多错一帧下一帧自动恢复非常稳。4.4 11bit通道值解码与归一化通道值解析是SBUS里面最容易写错的部分。16个通道在22字节里的排布是按位连续的不能用“每通道取两个字节”的简单方式。我直接给一个按位提取的函数用位索引去抠代码短也不容易数错比特位。void SBUS_Parse(const uint8_t *frame, sbus_packet_t *pkt) { const uint8_t *d frame[1]; // 跳过帧头指向22字节通道区 uint8_t ch; for (ch 0; ch 16; ch) { uint16_t bit_index ch * 11; uint16_t byte_index bit_index / 8; uint16_t bit_shift bit_index % 8; uint32_t val d[byte_index] | (d[byte_index 1] 8) | (d[byte_index 2] 16); pkt-channels[ch] (uint16_t)((val bit_shift) 0x07FF); } pkt-flags frame[23]; }这里有个容易踩的坑就是数组越界。通道15的bit_index最大是165byte_index最大是20但代码访问了d[byte_index2]也就是最多读到d[22]。好在整个frame数组是25字节d指向frame[1]d[22]实际是frame[23]的FLAG字节并没有越界。正因为frame完整包含25字节这种写法才安全。如果单独把通道区的22字节截成一个数组再解析就会访问到数组末尾之外很容易出现随机值。原始11bit值的范围是0~2047但遥控器摇杆正常工作时居中值大约在1024附近有效行程大概在352~1712之间。如果要对接舵机PWM信号也就是1000~2000的脉宽需要做个线性映射。我通常用整数运算避免浮点开销uint16_t SBUS_ToPulseWidth(uint16_t raw) { if (raw 352) raw 352; if (raw 1712) raw 1712; return (uint16_t)(((uint32_t)(raw - 352) * 1000U) / 1360U 1000U); }352映射到10001712映射到2000中间线性。如果你的遥控器行程范围略有差异这两个边界值可以自己微调思路一样。拿到完整数据后主循环里这样消费void SBUS_Task(void) { sbus_packet_t pkt; if (sbus_pkt.valid) { pkt sbus_pkt; sbus_pkt.valid 0; // 将pkt.channels[0...15]映射后交给控制逻辑 // 例如 SERVO_SetPulse(SBUS_ToPulseWidth(pkt.channels[0])); } }中断回调里只负责填充sbus_pkt上层业务逻辑放到主循环或RTOS任务中这是嵌入式开发里避免中断嵌套问题的基本原则。5. 调试经验与问题排查5.1 数据全乱或通道值跳变上电后如果串口打印出来的通道值要么是2047要么乱跳最高频的原因有三个信号反相没处理、CubeMX里8E2填错、波特率误差。反相问题在3.2节已经说了最好用逻辑分析仪或示波器量一下RX引脚波形。正常串口空闲是高电平SBUS反相输出则相反。如果没有示波器可以用USB转TTL去监听SBUS线如果串口助手能收到可读文本说明信号是正常串口逻辑如果完全乱码大概率是反相或波特率不对。CubeMX的8E2填法我再说一遍Word Length选9 BitsParity选EvenStop Bits选2。如果选成8 BitsHAL会认为没有校验位数据位和校验位的关系完全错位解出来的通道值自然一塌糊涂。这类问题排查时最直接的方法就是先打印原始字节流确认是不是每个数据都以0x0F开头、帧尾是不是0x00。如果0x0F之间的字节数不是稳定的25先别急着怀疑状态机去查物理层配置。5.2 偶尔丢帧或数据断流表现为接收一段时间后通道值不更新了或者每隔几帧丢一帧。我踩过的坑和解决办法总结如下ORE溢出检查有没有清UART_FLAG_ORE。循环DMA模式下一旦溢出标志置位后续接收可能停住。我的SBUS_IdleLine_ISR里每次都会清这个标志实测能解决大部分“跑一段时间后无数据”的问题。IDLE中断优先级太低如果USART1中断被其他高优先级任务长时间打断IDLE处理会延迟DMA缓冲区可能被下一帧覆盖。建议把USART1的NVIC优先级设成整个工程最高。中断回调里做重活有人习惯在回调里直接printf、写OLED这些操作在低速外设上会拖到毫秒级下一帧数据一来就完蛋。回调里只做缓冲区到状态机的搬运其他事情全部交给主循环。丢帧问题还有状态机兜底即使丢了半帧下一帧只要帧头正常就能恢复同步所以偶尔丢一帧在多数项目里不影响控制效果。要注意的是别让丢帧演变成持续断流。5.3 DMA缓冲区回绕时的隐蔽Bug这个坑最阴因为现象时有时无。如果你在回调里写的是for (i read_pos; i write_pos; i) { SBUS_Decode_Byte(dma_buffer[i]); }缓冲区没有回绕时一切正常一旦DMA写指针越过缓冲区末尾回绕到0read_pos和write_pos的大小关系就反了for循环根本无法执行数据全部丢失而且极难复现。正确写法是用模运算len (write_pos SBUS_DMA_BUF_SIZE - read_pos) % SBUS_DMA_BUF_SIZE; for (i 0; i len; i) { SBUS_Decode_Byte(dma_buffer[(read_pos i) % SBUS_DMA_BUF_SIZE]); }不管是正向回绕还是反向边界都能正确算出数据长度和索引。如果你想追求极致性能可以拆成两个for循环分段处理避免取模开销。但对100kbps的SBUS来说取模开销完全可以忽略先把逻辑写正确最重要。还有一个NDTR读取时机的坑。IDLE中断触发后要第一时间读__HAL_DMA_GET_COUNTER因为DMA还在运行、数据还在持续写入读晚了计数器就变了。把NDTR读取放在中断回调第一行是保证数据完整性的关键。5.4 中断负载与性能实测这套方案跑下来串口中断的触发频率大约等于每帧一次相比逐字节中断直接少了一个数量级。F103这种72MHz的MCU解析SBUS完全没压力CPU可以安心去处理PWM、姿态解算、无线通信等其他任务。我实测用100kbps、7ms帧间隔跑一整晚通道映射到舵机PWM没有任何卡顿或跳变。以后如果要在同一套串口上接收其他协议比如无人机上常见的CRSF、DJI串口协议这个接收底座可以直接复用只需要替换状态机里的帧头识别和帧长判断。分层模块化的好处就在这里DMA循环接收和IDLE中断框架是通用的具体协议只影响状态机内部实现。最后分享一点体会这套方案最早是我在一个飞控调试项目里落地的当时还在用逐字节中断解析后来加了无线数传和OLED显示串口被打断的情况越来越多才开始研究DMA加IDLE的方案。初版代码很稚嫩我直接按25字节切帧结果接收机刚上电的半帧问题折腾了一整个晚上。后来加上了状态机调试才算真正结束。现在回头看最有价值的不是那几段代码而是“硬件给边界、软件给容错”的思路让串口硬件检测总线空闲让DMA老实搬数据让状态机吞掉所有异常输入各司其职整条链路就非常稳定。SBUS只是一个例子这套组合可以平移到几乎所有不定长串口协议上。实测中有一个小技巧也值得分享调试阶段在状态机里加一个计数器统计丢掉的字节数和找不到帧头的次数比盲调波特率有效得多。等这些数字稳定趋近于零这套接收链路就可以放心交付了。
返回列表