首页 / 资讯中心 / 文章详情

STM32 SBUS解码:DMA循环接收+IDLE中断,稳定不丢帧

STM32 SBUS解码:DMA循环接收+IDLE中断,稳定不丢帧 ★ FEATURED ARTICLE
玩过飞控、机器人底盘或者遥控车项目的朋友一定绕不开一个词SBUS。接收机输出、飞控信号采集、舵机总线控制到处都有它的身影。这个协议本身不复杂就是100000bps、8E2、25字节定长帧真正麻烦的是怎么在单片机上稳定接收、不丢帧、CPU开销还低。我这次用STM32F103CBT6做实车底盘电调信号中转遥控接收机输出的就是SBUS。踩了一圈坑之后最终定下来的方案是HAL库 DMA循环接收 IDLE中断 状态机解析。这套组合实测下来非常稳代码量也不大中断几乎为零CPU占用低到可以忽略。这篇就把完整思路、CubeMX配置、解析器代码和排坑过程全部拆开讲给正在被串口接收折腾的朋友一个可以直接抄作业的参考。1. 为什么接收SBUS首选DMA循环 IDLE中断1.1 SBUS的通信特性决定了接收方式先看SBUS的物理特性。它的波特率是1000008个数据位、偶校验、2个停止位一帧25字节。因为在8E2模式下每字节实际占用10个bit所以传输一帧需要25乘以10除以100000算下来是2.5ms。而遥控接收机的SBUS输出周期通常是7ms或14ms一帧也就是说大部分时间总线是空闲的。这个特性特别适合IDLE中断串口收到一整帧数据之后总线进入空闲状态硬件自动触发IDLE中断。配合DMA循环接收单片机主循环只需要处理数据和解析不需要每个字节都进一次中断。如果用传统的逐字节中断接收100000bps意味着每0.1ms就要进一次中断。虽然看起来也能扛住但如果主循环里还有PID计算、编码器读取、OLED刷新这些任务中断嵌套一多丢数据是迟早的事。尤其当你的项目还要处理多个串口时逐字节中断方案基本不现实。1.2 三种常见接收方案对比我在这个项目之前也试过别的方案。简单列个对比方案CPU开销丢帧风险实现复杂度适用场景逐字节中断接收高每字节进中断高中断抢占时易丢低数据量小、接收不频繁DMA循环 IDLE中断极低低中定长帧、帧间有空闲DMA半满/全满中断低中中高高速连续数据流DMA半满/全满中断方案我也试过。它能做到缓冲区一半满或者全满时提醒CPU来取数据CPU开销同样很低但有个问题它不关心一帧数据从哪里开始、到哪里结束。SBUS是25字节定长帧可DMA缓冲区大小往往是32或者64字节缓冲区里可能装着一帧半的数据。你无法通过半满全满中断精确定位帧边界还是要自己再做大量逻辑去拼帧。而IDLE中断的价值就在于总线空闲了说明当前这串数据已经发完了正好是一个天然的帧边界信号。配合DMA的写位置信息就能把“这一帧内容”完整锁定下来。1.3 环形缓冲区为什么要比一帧大这里有个细节容易被忽略。DMA循环接收模式会把数据不断写入固定的内存缓冲区写满之后自动回到开头继续写。如果我们把缓冲区大小设置成恰好25字节会出现一个很尴尬的情况读指针和写指针重合时你没法判断缓冲区是空的还是满的。这是环形缓冲区的经典问题。所以缓冲区一定要比单帧长度大我最终取了64字节。SBUS一帧25字节64字节能装下两帧多既能容纳连续两帧背靠背到达的情况又能避免读写指针重合导致判定歧义。2. SBUS协议拆解25字节里到底藏了什么2.1 帧结构逐字节解读SBUS一帧固定25字节从帧头0x0F开始到帧尾0x00结束。最核心的数据集中在开头部分字节0帧头固定为0x0F字节1到字节11共88个bit存放8个比例通道每个通道11bit字节12标志字节包含丢失帧、fail-safe等状态也包含一部分开关通道字节13到字节23扩展数据多数接收机填充为0部分协议用于第二组SBUS数据解析时一般忽略字节24帧尾常见为0x00很多新手上来就尝试把字节1到字节8直接当8个16位数据来读这是不对的。SBUS的比例通道不是按字节对齐的而是按bit连续排列第一个通道占第0到第10个bit第二个通道占第11到第21个bit以此类推。通道之间没有填充位。这就意味着解析时要用位操作把连续的bit流拆成一个个无符号整数。每个通道的取值范围是0到2047对应11bit能表示的最大值0x07FF。遥控器通常把中位值放在约1024附近。2.2 flags标志位里有什么第12字节的flags标志位在工程中非常有用。不同接收机对这个字节的定义略有差异但主流闭源协议里常见这几个bit标志bit位含义bit2lost frame表示当前帧是否丢帧bit3fail-safe表示接收机是否处于失控保护状态我在状态机里把这几个状态位单独提取出来存到解析结果结构体里。实际调试时很有用如果遥控器信号不好观察lost frame标志会频繁置位比通过帧计数推断丢帧快得多。另外部分协议把第9到第16通道的开关状态也放进flags字节。但具体bit映射每个厂家的接收机不完全一样我这里先不展开实际对接哪款接收机时用逻辑分析仪抓一帧确认即可。2.3 位解包的具体实现通道解包我用了一个很经典的写法从位流中取出目标通道的起始bit偏移然后把连续3个字节拼成一个32位整数右移去掉低位再截取低11位。static uint16_t sbus_channel(const uint8_t *payload, uint8_t ch) { uint16_t bit_offset ch * 11; uint16_t byte_index bit_offset / 8; uint8_t bit_shift bit_offset % 8; uint32_t tmp; tmp payload[byte_index]; tmp | ((uint32_t)payload[byte_index 1]) 8; tmp | ((uint32_t)payload[byte_index 2]) 16; return (uint16_t)((tmp bit_shift) 0x07FF); }为什么拼3个字节而不是2个因为11bit会跨字节边界。比如第二个通道从第11bit开始前5位在第1字节的bit3到bit7后6位在第2字节的bit0到bit5中间隔了一个字节。读3个字节再偏移可以一次性覆盖所有跨字节的情况不用自己做位拼接。通道数值换算成实际舵机脉宽也简单。遥控器输出范围一般是192到1792对应1ms到2ms的脉宽。换算公式就是脉宽 (channel - 192) * 1.0 / (1792 - 192) * 1000 1000单位us。如果你想直接映射到电机占空比把那两个上下限改成你控制量的范围就行。3. 实战配置CubeMX与HAL库这样设置3.1 CubeMX里的关键参数新建STM32F103CBT6工程时钟配置成72MHz主频这些基础操作不展开。重点说串口和DMA。USART1选择异步模式波特率手动填100000数据位选8bit停止位选2bit校验选Even。这里有个容易踩的坑HAL库的Word Length设置为8bit同时开启Even Parity硬件实际占用的还是9bit其中1bit是校验位。这正好对应SBUS的8E2不用怀疑配置错了。DMA配置要选USART1_RXDirection为Peripheral To MemoryMode为Circular数据宽度Peripheral和Memory都选Byte内存地址增量打开外设地址增量关闭。优先级可以选High。我的缓冲区数组定义在SRAM中长度64字节地址需要自然对齐普通全局数组即可。NVIC配置里务必打开USART1全局中断。DMA通道中断在这个方案里不是必须的因为IDLE中断已经负责了帧边界的通知。DMA的传输完成中断和半传输中断都没有开。3.2 串口初始化与DMA启动CubeMX生成的MX_USART1_UART_Init函数基础上我加了这几行关键调用HAL_UART_Init(huart1); HAL_UART_Receive_DMA(huart1, sbus_dma_buf, SBUS_DMA_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);顺序很重要。先初始化串口再开启DMA接收最后再使能IDLE中断。如果IDLE使能在DMA开启之前DMA还没开始搬运第一个帧间隙可能就漏掉一次IDLE事件。虽然丢了还能靠下一帧再触发但工程上没必要留这种不确定性。注意HAL_UART_Receive_DMA只需要调用一次之后DMA会循环往缓冲区里写数据不会再被触发回调打断。这也是DMA循环模式最省心的点。有些人每次收完一帧就再调用一次Receive_DMA那是Normal模式下的写法在循环模式里完全不用。3.3 IDLE中断处理的两种写法IDLE中断的处理方式不同版本的HAL库差异很大。老版本HAL_UART_IRQHandler不会调用任何IDLE相关回调新版本会调用HAL_UARTEx_RxEventCallback但前提是你要用HAL_UARTEx_EnableRxEvent配置过。为了不跟HAL库版本纠缠我这个项目直接在USART1_IRQHandler里手动判断IDLE标志void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_idle_flag 1; sbus_write_index (SBUS_DMA_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx)) % SBUS_DMA_BUF_SIZE; } HAL_UART_IRQHandler(huart1); }这样写的好处是无论HAL库怎么升级逻辑都不会变。中断里只做两件事清标志、记录DMA当前写位置。真正费时间的解析工作全部扔给主循环中断服务函数的执行时间只有几个时钟周期。__HAL_UART_CLEAR_IDLEFLAG这个宏在底层是通过读SR寄存器再读DR寄存器来实现的读DR的同时会把RXNE清掉。在DMA接收模式下RXNE不会因为读DR而产生额外影响因为DMA硬件接管了数据搬移不会走CPU中断。这是我查了HAL源码确认过的。3.4 DMA写位置的计算逻辑DMA的CNDTR寄存器表示还有多少次传输没完成。对于循环模式初始值等于缓冲区大小每收到一个字节就减1减到0之后重新加载缓冲区大小。所以当前写位置的计算公式是uint16_t write_index (SBUS_DMA_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx)) % SBUS_DMA_BUF_SIZE;举个例子缓冲区64字节初始CNDTR为64收到3个字节后CNDTR变成61当前写位置就是64减61等于3正好指向第4个空位也就是下一次写入的位置。CNDTR为0时64减0等于64取模后变成0表示写指针回到了缓冲区的开头这个边界情况用取模运算自动解决了。不过有一点要注意读取CNDTR寄存器不是原子的极端情况下可能读到中间值。严谨的做法是连续读两次直到两次结果相同再往下计算。这个技巧我放在后面的避坑清单里。4. 状态机解析器从DMA缓冲到16通道4.1 为什么非得用状态机DMA把数据源源不断写进缓冲区但缓冲区里存的是裸字节流帧头0x0F可能出现在任意位置。如果收到IDLE中断就直接从缓冲区开头解析大概率会解析出一堆垃圾。状态机解决的就是“字节流对齐”问题。它不假设当前字节是帧头而是逐字节扫描遇到0x0F才进入数据接收状态然后连续接收24个字节作为一帧。中途出错就返回到找帧头状态重新开始扫描。这样无论数据从缓冲区的哪个位置开始无论是否跨缓冲区边界都能正确恢复出完整帧。状态机还天然支持连续多帧粘连的情况。如果DMA缓冲区里一次塞进来两帧第一帧解析完成后状态机会从最后那个字节继续向后找新的帧头第二帧也能正常解析。4.2 解析器核心数据结构我定义了一个结构体管理SBUS状态和解析结果#define SBUS_FRAME_LEN 25 #define SBUS_CHANNEL_COUNT 16 #define SBUS_DMA_BUF_SIZE 64 typedef enum { SBUS_STATE_SYNC 0, SBUS_STATE_PAYLOAD } sbus_parse_state_t; typedef struct { sbus_parse_state_t state; uint8_t buf[SBUS_FRAME_LEN]; uint16_t index; uint16_t channels[8]; uint8_t flags; uint8_t lost_frame; uint8_t fail_safe; uint32_t frame_count; uint32_t last_frame_tick; } sbus_t;channels数组保存8个比例通道的原始值flags保存标志字节lost_frame和fail_safe直接从flags里拆出来。frame_count用来统计成功解析的帧数last_frame_tick记录最后一帧的毫秒时间戳用于超时检测。4.3 逐字节喂给状态机的实现每个从DMA缓冲区读到的字节都通过sbus_feed_byte交给状态机处理void sbus_feed_byte(sbus_t *s, uint8_t byte) { switch (s-state) { case SBUS_STATE_SYNC: if (byte 0x0F) { s-state SBUS_STATE_PAYLOAD; s-index 1; s-buf[0] byte; } break; case SBUS_STATE_PAYLOAD: s-buf[s-index] byte; if (s-index SBUS_FRAME_LEN) { if (byte 0x00) { sbus_frame_process(s, s-buf); } s-state SBUS_STATE_SYNC; sbus_feed_byte(s, byte); } break; default: s-state SBUS_STATE_SYNC; break; } }这里有个小心机第25个字节处理完之后无论它是合法的帧尾0x00还是恰好又是下一帧的帧头0x0F我都调用一次sbus_feed_byte(s, byte)把这个字节重新送去扫描。如果它是0x0F状态机立刻进入下一帧的接收等于节省了一次主循环轮询如果它是普通数据状态机就耐心等待下一个帧头。4.4 帧完成后的通道解包成功收到25字节并且帧尾是0x00时调用sbus_frame_process做真正的解包static void sbus_frame_process(sbus_t *s, const uint8_t *frame) { for (int i 0; i 8; i) { uint16_t bit_offset i * 11; uint16_t byte_index bit_offset / 8; uint8_t bit_shift bit_offset % 8; uint32_t tmp; tmp frame[byte_index]; tmp | ((uint32_t)frame[byte_index 1]) 8; tmp | ((uint32_t)frame[byte_index 2]) 16; s-channels[i] (uint16_t)((tmp bit_shift) 0x07FF); } s-flags frame[12]; s-lost_frame (s-flags 0x04) ? 1 : 0; s-fail_safe (s-flags 0x08) ? 1 : 0; s-frame_count; s-last_frame_tick HAL_GetTick(); }解包之后channels里就是8个0到2047的整数值。如果你需要9到16通道的开关量可以从flags和后续扩展字节里去挖这部分要看具体接收机的协议实现我这边暂时没用到没有深入研究。4.5 主循环如何消费DMA缓冲主循环的任务是轮询DMA写位置把上次处理过的新数据全部取出来喂给状态机while (1) { uint16_t write_index (SBUS_DMA_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx)) % SBUS_DMA_BUF_SIZE; while (sbus.last_index ! write_index) { sbus_feed_byte(sbus, sbus_dma_buf[sbus.last_index]); sbus.last_index (sbus.last_index 1) % SBUS_DMA_BUF_SIZE; } if (HAL_GetTick() - sbus.last_frame_tick 50) { sbus_feed_reset(sbus); } }while循环一直吃到当前写位置为止意味着主循环处理速度只要够快缓冲区的数据就不会堆积。如果主循环被别的任务堵住超过一帧周期DMA的写指针会追上读指针数据被覆盖这种情况下只能重新同步。所以主循环的任务调度必须保证最坏情况下也能在10ms内跑完一轮解析。超时兜底逻辑也很关键。如果50ms内没有成功解析出任何一帧说明链路大概率断了或者状态机卡在异常情况直接重置解析器状态回退到重新找帧头的状态。5. 踩坑实录与排查技巧5.1 信号反相问题导致全FFSBUS信号的一个重要特性是反相的也就是说TTL电平逻辑跟普通UART正好相反。直接把接收机SBUS输出接到STM32的RX引脚上串口会收到大量错误数据表现经常是全0xFF或者全0x00的乱流。正确做法是在接收机和单片机之间加一级反相电路。最简单的用一颗NPN三极管加一个上拉电阻就能搞定也可以直接用光耦隔离。飞控主板上通常已经做好了反相电路但自研底板或者面包板测试时这一步最容易漏。判断是不是反相问题的方法很简单逻辑分析仪抓RX引脚波形对比标准UART波形看空闲电平和起始位极性。如果波形完全是反的接反相器之后立刻就能正常解析。5.2 DMA模式错误导致只收一帧我在第一次测试时DMA模式漏改了Normol结果现象很典型开机后只能解析出第一帧后面完全没数据。因为Normal模式下DMA传输完设定长度后就停了后续数据进不了缓冲区IDLE中断自然也不会再触发。这个问题排查起来不算难但很容易被忽视。CubeMX的DMA Mode下拉框默认是Normal改成Circular之后代码里HAL_DMA_Init会自动使用循环模式不需要额外写代码。5.3 缓冲区太小导致帧解析错乱我之前图省事把缓冲区直接设成25字节结果解析时好时坏。原因前面讲过环形缓冲区读写指针重合时无法区分空和满而且DMA写指针转一圈回来覆盖数据的速度比主循环处理速度快。换成64字节之后这个问题彻底消失。缓冲区大小不要刚好25字节我建议取32以上64比较舒服既不浪费内存也足够容纳突发数据。5.4 我最终保留的调试流程最后分享一个我调这个项目的固定流程。先用一块STM32板子当发送器按8E2、100000波特率循环发送25字节测试帧人工构造包含帧头、通道数据和帧尾的完整序列。接收板跑状态机解析同时把解析出的通道原始值和帧计数通过另一个串口发到电脑上位机查看。这样能先验证软件协议栈排除接收机硬件的干扰。确认解析器工作正常后再接真实遥控接收机。如果解析出的数值抖动或者出现异常跳变优先查硬件反相电路和供电稳定性而不是怀疑解析代码。这套流程帮我省了很多排查时间。SBUS解析这件事本质上就是串口底层接收策略加协议状态机的组合。DMA循环接收解放CPUIDLE中断提供帧边界状态机保证字节流对齐三者缺一不可。如果你将来要接其他串口协议比如无人机常见的MAVLink串口透传、物流小车的自定义传感协议这套框架稍微改改参数和状态逻辑就能复用了。
阅读完成 · 觉得有帮助?
咨询建站