☰
STM32 DMA+IDLE中断+状态机高效解析SBUS协议
2026/9/25 4:55:17 网站建设 项目流程

前两天整理一个无人机飞行控制器项目时,又翻出之前调通的SBUS接收代码,趁着空档把整套思路写下来。如果你玩过STM32 + 遥控接收机,一定遇到过这类问题:用串口中断逐字节解析SBUS协议,CPU一直被硬件打断,数据还偶尔错乱;想用DMA一次性接收25字节,又发现SBUS的帧边界并没有那么好切。这篇文章要分享的就是我在多个项目里验证过、稳定跑下来的一套方案:HAL库下用DMA循环接收 + USART的IDLE空闲中断 + 轻量状态机,彻底解决SBUS协议解析中的帧边界、CPU占用和错位问题。适合刚学会CubeMX、准备接遥控接收机做飞控或机器人底盘的开发者,也适合想把串口接收从“中断逐字节”升级为“硬件搬运 + 每帧一次性处理”的老手。

1. 先搞清楚 SBUS 协议到底长什么样

1.1 一帧25字节,16个通道是11bit打包的

SBUS是Futaba推的串行遥控协议,现在几乎成了航模接收机的标配输出。物理层就是一根信号线,波特率固定100000bps,8位数据位,偶校验,2位停止位。这个“8E2”很多人在HAL库里会填错,后面第3章专门讲。先记住一个关键点:因为校验位占一位,串口线上实际每字节要走11bit,所以算传输耗时要用25×11,不是25×8。

完整的一帧SBUS数据是25字节,拆开看分四块:

偏移长度内容
01字节帧头,固定0x0F
1 ~ 2222字节16个通道数据,每通道11bit,按位连续打包
231字节FLAG标志字节,不同接收机定义略有差异
241字节帧尾,正常为0x00

22字节放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循环模式下,缓冲区大小是N,NDTR从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 Bits
  • Parity选Even
  • Stop Bits选2 Bits

这里解释一下,HAL库里的Word Length指的是“数据位 + 校验位”的总位数。8E2的真实含义是8位数据、1位偶校验、2位停止位,所以总位数是9。有人在这里选了8 Bits + Even + 2 Stop,结果解出来全是乱码,就是这个原因。

接下来配置DMA:添加USART1_RX的DMA请求,Direction选Peripheral To Memory,Mode选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设为0,Sub 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最大是165,byte_index最大是20,但代码访问了d[byte_index+2],也就是最多读到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映射到1000,1712映射到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 Bits,Parity选Even,Stop Bits选2。如果选成8 Bits,HAL会认为没有校验位,数据位和校验位的关系完全错位,解出来的通道值自然一塌糊涂。这类问题排查时最直接的方法,就是先打印原始字节流,确认是不是每个数据都以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写指针越过缓冲区末尾回绕到0,read_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只是一个例子,这套组合可以平移到几乎所有不定长串口协议上。实测中有一个小技巧也值得分享:调试阶段在状态机里加一个计数器,统计丢掉的字节数和找不到帧头的次数,比盲调波特率有效得多。等这些数字稳定趋近于零,这套接收链路就可以放心交付了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询