news 2026/9/26 1:09:17

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 SBUS解码:DMA循环接收+IDLE中断,稳定不丢帧

玩过飞控、机器人底盘或者遥控车项目的朋友,一定绕不开一个词:SBUS。接收机输出、飞控信号采集、舵机总线控制,到处都有它的身影。这个协议本身不复杂,就是100000bps、8E2、25字节定长帧,真正麻烦的是怎么在单片机上稳定接收、不丢帧、CPU开销还低。

我这次用STM32F103CBT6做实车底盘电调信号中转,遥控接收机输出的就是SBUS。踩了一圈坑之后,最终定下来的方案是:HAL库 + DMA循环接收 + IDLE中断 + 状态机解析。这套组合实测下来非常稳,代码量也不大,中断几乎为零,CPU占用低到可以忽略。这篇就把完整思路、CubeMX配置、解析器代码和排坑过程全部拆开讲,给正在被串口接收折腾的朋友一个可以直接抄作业的参考。

1. 为什么接收SBUS首选DMA循环 + IDLE中断

1.1 SBUS的通信特性决定了接收方式

先看SBUS的物理特性。它的波特率是100000,8个数据位、偶校验、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_RX,Direction为Peripheral To Memory,Mode为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 信号反相问题导致全FF

SBUS信号的一个重要特性是反相的,也就是说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循环接收解放CPU,IDLE中断提供帧边界,状态机保证字节流对齐,三者缺一不可。如果你将来要接其他串口协议,比如无人机常见的MAVLink串口透传、物流小车的自定义传感协议,这套框架稍微改改参数和状态逻辑就能复用了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 1:08:47

智慧高速如何实现无感通行与车路协同预警

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:08:31

虚拟果蝇全脑仿真:从连接组到具身智能的完整闭环

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:08:31

SolidWorks打开STP文件弹出多个零件窗口的根源与解法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:07:43

电容位置如何决定EMC辐射成败:共模电流路径设计核心

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:07:09

嵌入式开发烧录下载仿真调试全链路实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:06:38

追番工具组合拳:五个站点打造高效追番工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华