news 2026/9/25 1:30:23

STM32 HAL库 SBUS解析:DMA循环接收+IDLE中断+状态机实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 HAL库 SBUS解析:DMA循环接收+IDLE中断+状态机实战

玩航模的朋友对 SBUS 一定不陌生,Futaba 等遥控器通过一根信号线就能把十几个通道的摇杆量送给飞控,取代传统 PWM 接收机的一大把线。SBUS 本质上是一个反相串口,波特率 100000,帧格式是 8E2。我在 STM32 上想高效稳定地解析它,最终跑通的就是标题里这套组合:HAL 库下用 DMA 循环接收,用 IDLE 空闲中断来切帧,再配合一个状态机把 DMA 缓冲区里的字节流处理成通道值。这篇文章我把当时的思路、CubeMX 配置、完整代码和踩坑过程都记下来,适合刚接触 STM32、看到 SBUS 协议一脸懵的读者,也适合被串口 DMA 接收搞过一头包的老哥。看完你至少能明白为什么非要用 DMA 加 IDLE,以及怎么让解析逻辑在一堆错位字节和噪声中活下来。

1. 方案选型:为什么非要用 DMA + IDLE + 状态机这套组合

1.1 SBUS 协议帧格式回顾

SBUS 每帧固定 25 字节,结构大致是:1 个起始字节 0x0F、22 个数据字节、1 个状态标志字节、1 个结束字节 0x00。那 22 个数据字节里面,压了 16 个通道的数据,每个通道占 11 bit,低位在前,连续排列。因为 16 × 11 = 176 bit,正好等于 22 × 8 = 176 bit,所以一个数据字节都不能少,少一个通道数据就会整体错位。

通信参数上,SBUS 是 8 个数据位、偶校验、2 个停止位,也就是常说 8E2。波特率固定 100000。接收机大概每 7ms 发一帧,也有高速模式 4ms 一帧。以 100k 波特率算,一个字节要占 12 bit,也就是 120us,一帧 25 字节在总线上大约占 3ms。这样算下来每帧之间必然有一段空闲时间,这就是后面 IDLE 中断能用的根本原因。

实战中第一件要做的事就是把接收机的信号电平搞清楚。我见过不少人把 SBUS 接收机直接接到开发板串口上,然后怎么调都调不通。因为 SBUS 是反相输出,标准 UART 的空闲电平是高电平,而 SBUS 空下来是低电平,直接接上去整条数据线都是反的,收到的字节几乎全是乱码。这个放到第 2 节展开,但你在读后面的代码前,先记住这个坑。

1.2 普通串口中断接收为什么容易丢数据

很多新手第一次做串口接收,用的都是 RXNE 中断:来一个字节进一次中断,在中断回调里把数据丢进自己的数组。这个写法在低速、低速率下没问题,比如 9600 波特率,一个字节间隔接近 1ms,CPU 有充足时间去读寄存器。但 SBUS 这种 100k 波特率下,一个字节只有大概 120us,中断频率大约 8kHz。这本身对 STM32 来说不是处理不了,真正的问题是中断优先级和响应延迟。

如果系统里还跑着别的中断,比如定时器、PWM 控制、编码器计数,任意一个高优先级中断占用几十微秒,串口接收寄存器里的数据就可能被下一个字节覆盖。UART 的接收寄存器只有一层,没有 FIFO(大多数 F1 系列),一旦 CPU 没及时取走,新的数据来了就产生 Overrun 错误,后面的数据全乱。SBUS 是连续的 25 字节流,中间不允许丢字节,丢一个,后面通道值就全错位了。

DMA 的意义就在这里:UART 收到字节后由 DMA 自动搬到内存缓冲区,不需要 CPU 挨个去读。哪怕 CPU 在忙别的事情,数据也已经进了内存,只要缓冲区还没被覆盖,回头再处理完全来得及。DMA 循环模式再进一步解决了“缓冲区满后怎么继续收”的问题,数据自动回绕,所以启动一次 DMA 接收之后,后面完全不用管。

1.3 IDLE 中断在里面的真正作用

有人可能会问:用 DMA 循环接收了,那 CPU 怎么知道一帧数据来了?DMA 自己不带帧判断能力,它只会不停往缓冲区里写字节。如果只靠 DMA 传输完成中断,那要等缓冲区写满才触发一次,根本不适合切帧。这时候就需要 UART 的 IDLE 空闲中断。

UART 外设在接收总线上检测到一段空闲时间,也就是一片数据之后的一个字节时间以上没有新数据,就会置上 IDLE 标志并触发中断。SBUS 每帧之间必定有空隙,所以一次完整的 SBUS 帧传完,总线上没有新信号,UART 就会立刻产生一次 IDLE 事件。我们在 IDLE 中断回调里,通过 DMA 的当前剩余计数算出这帧期间到底收了多少字节,然后把新收到的这段字节全部交给状态机去解析。

这个思路比“每收满 25 字节就解析一次”灵活得多。因为 DMA 缓冲区里可能残留着上一次的半帧数据,也可能一帧正好跨在缓冲区回绕的位置,还可能一帧被拆成两次 IDLE 才收完。如果拿到一个固定 25 字节数组再解析,边界情况特别多。而用“IDLE 切帧 + 状态机逐字节消费”,本质上只认 0x0F 开头和 0x00 结尾,不管字节是从哪里断开的,都能自我恢复。

2. 硬件连接与 CubeMX 初始化细节

2.1 反相信号处理:这一步做不对后面全部白搭

SBUS 信号是反相 TTL,这个“反相”太容易坑人了。常规 UART 信号在线路空闲时是逻辑高,起始位是拉低;SBUS 正好反过来,空闲是低电平,起始位是拉高。如果你直接把接收机输出接到 STM32 的 RX 引脚,串口外设看到的高电平变成了低电平,起始位检测完全不匹配,整帧数据自然解不出来。

解决方式有两条路。第一条是硬件反相,这是兼容性最好的办法。用一个 NPN 三极管搭一级反相器,或者用 74HC04、SN74LVC1G04 这类反相门芯片,把信号翻过来再接串口。第二种是看你的 STM32 型号支不支持 UART 极性反转。部分新系列芯片的 USART 外设支持把 RX/TX 极性配置成低电平有效,在 CubeMX 的 UART 配置里把极性 Polarity 改成 Low 就行。

我实际踩过的一个坑是:手里某块 F103 板子试了 CubeMX 里的极性选项,结果发现底层并不真正支持完全反转,还是得靠外接反相器。如果你不确定芯片是否支持,先用示波器或者逻辑分析仪量一下接收机输出,确认波形是反相之后再决定是硬件改线还是软件改极性。千万不要因为一个反相问题,去怀疑后面好不容易调通的 DMA 和中断代码,那样会浪费大量时间。

2.2 CubeMX 里的串口、DMA 和 NVIC 参数配置

以 STM32F103 的 USART1 为例。在 CubeMX 中把 USART1 设为异步模式,然后手动修改参数。波特率直接填 100000,不要选列表里现成的 9600 或 115200。Word Length 要特别注意,因为启用了偶校验,数据部分 8 bit 加上校验位实际上要以 9 bit 的帧长来工作,所以先选 9 Bits,再把 Parity 设成 Even。停止位 Stop Bits 设成 2。这样才是完整的 8E2。

接着在 DMA Settings 里给 USART1_RX 添加一个 DMA 通道。Direction 是 Peripheral To Memory,Mode 必须选 Circular,否则收满缓冲区后就停了。Peripheral Increment 不用开,Memory Increment 必须开,Data Width 都选 Byte。DMA 优先级别建议给 High,因为 SBUS 是连续数据流,优先级太低容易被别的 DMA 挤掉。

NVIC 里记得打开 USART1 全局中断。有人会误以为用了 DMA 就不需要串口中断了,其实 IDLE 事件是通过 UART 本身的中断上报的,DMA 中断在循环接收场景下反而没那么重要,可以不开启 DMA 的传输完成中断。

2.3 生成代码后要做的改动

CubeMX 生成的初始化函数会把 USART 和 DMA 配置好,但不会自动启动 DMA 接收,也不开启 IDLE 中断。所以 main.c 里在初始化完成之后、while(1) 之前,需要手动加上一行 HAL_UART_Receive_DMA 启动接收。另外还要用宏__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)把 UART 的空闲中断打开。这两个动作缺一不可,而且顺序建议先启动 DMA,再使能 IDLE 中断,避免还没准备好数据就触发空闲事件。

时钟树也要看一眼。100000 波特率不是一个常规标准值,需要靠分频得到。比如 F103 的 USART1 挂 APB2,如果 APB2 时钟是 72MHz,那分频系数是 72000000 / (100000 × 16) = 45,正好是整数,误差为 0。如果你的板子系统时钟不是这些组合,导致波特率有偏差,接收会出现偶发字节错,这个在后面故障排查部分也会提到。

3. 核心代码实现:DMA 循环接收 + IDLE 中断 + 状态机解析

3.1 启动 DMA 循环接收和 IDLE 中断

代码里我建议单独搞一个 sbus_drv 模块,把缓冲区、状态机、处理入口都封装起来,不要让 main.c 里塞满业务逻辑。以下代码核心思路是定义一块环形缓冲区,然后用 DMA 往里面循环写。

#define SBUS_RX_BUF_SIZE 256 uint8_t sbus_rx_buf[SBUS_RX_BUF_SIZE]; volatile uint16_t sbus_last_pos = 0; volatile uint32_t sbus_frame_cnt = 0; volatile uint16_t sbus_channels[16];

在 main 初始化之后启动接收:

HAL_UART_Receive_DMA(&huart1, sbus_rx_buf, SBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

因为 DMA 配的是 Circular 模式,只要工频没有因为错误停止,数据就会一直循环写入缓冲区。启动一次之后,不需要在每次 IDLE 之后重新调用 HAL_UART_Receive_DMA。这也是用循环 DMA 比普通 DMA 舒服的地方。

3.2 IDLE 中断处理与 DMA 剩余计数计算

USART1 的中断服务函数默认在 stm32f1xx_it.c 里,CubeMX 会生成一个空壳。我们需要在里面先调用 HAL_UART_IRQHandler 处理通用的 UART 事件,再额外处理 IDLE 标志。这里有个关键点:HAL 库默认不处理 IDLE 中断,所以我们必须自己读标志、清标志,然后执行解析入口。

void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); sbus_on_idle(); } }

接下来计算当前 DMA 写到了缓冲区的哪个位置。HAL 库里可以用__HAL_DMA_GET_COUNTER拿到 DMA 还没搬运完成的字节数,也就是剩余计数。DMA 配置的传输大小是 SBUS_RX_BUF_SIZE,所以当前已经写到缓冲区的位置就是:

uint16_t cur_pos = SBUS_RX_BUF_SIZE - ((uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx));

再用sbus_last_pos记录上一次已经处理到的位置。在 IDLE 回调里,从上次位置一直处理到当前位置,同时要考虑缓冲区回绕。下面这段就是核心:

void sbus_on_idle(void) { uint16_t cur_pos; cur_pos = SBUS_RX_BUF_SIZE - ((uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx)); cur_pos %= SBUS_RX_BUF_SIZE; if (cur_pos == sbus_last_pos) { return; } while (sbus_last_pos != cur_pos) { sbus_parse_byte(sbus_rx_buf[sbus_last_pos]); sbus_last_pos++; if (sbus_last_pos >= SBUS_RX_BUF_SIZE) { sbus_last_pos = 0; } } }

很多人第一次看到__HAL_DMA_GET_COUNTER容易算错。比如 DMA 初始装了 256,收了一个字节后 counter 变成 255,当前写位置就是 256 - 255 = 1,也就是第二个字节的位置。如果正好赶上 DMA 回绕,counter 是 0,减出来是 256,所以我对 cur_pos 取了一次模,让它落在 0~255 区间。这个细节不处理,关键时刻会多算一个位置。

3.3 状态机:识别帧头、数据区、标志位和帧尾

状态机的作用,是把任意位置涌入的字节流整理成完整一帧。我用的是一个简单四状态模型:等待帧头、接收 22 字节数据、接收标志位、等待帧尾。伪代码逻辑如下:

typedef enum { SBUS_ST_WAIT_START, SBUS_ST_DATA, SBUS_ST_FLAG, SBUS_ST_TAIL } sbus_state_t; static sbus_state_t sbus_st = SBUS_ST_WAIT_START; static uint8_t sbus_frame_data[22]; static uint8_t sbus_frame_idx = 0; static uint8_t sbus_frame_flags = 0; void sbus_parse_byte(uint8_t b) { switch (sbus_st) { case SBUS_ST_WAIT_START: if (b == 0x0F) { sbus_frame_idx = 0; sbus_st = SBUS_ST_DATA; } break; case SBUS_ST_DATA: sbus_frame_data[sbus_frame_idx++] = b; if (sbus_frame_idx >= 22) { sbus_st = SBUS_ST_FLAG; } break; case SBUS_ST_FLAG: sbus_frame_flags = b; sbus_st = SBUS_ST_TAIL; break; case SBUS_ST_TAIL: sbus_st = SBUS_ST_WAIT_START; if (b == 0x00) { sbus_decode_channels(sbus_frame_data, (uint16_t *)sbus_channels); sbus_frame_cnt++; } break; } }

这个状态机有一个好处:它不会因为 DMA 缓冲区在任意位置被切开而丢失帧同步。比如上一次 IDLE 处理到某帧的第 10 个数据字节,那么下一次 IDLE 从中断处继续解析即可。如果因为干扰导致数据错位,只有在等到帧头 0x0F 才会重新进入状态,本身具备一定的容错能力。把标志位单独做一步,是为了后续如果要做失控保护,可以直接读取sbus_frame_flags。

3.4 通道值解包:把 22 字节变成 16 个 11 bit 数值

SBUS 的通道数据是 11 bit 打包,跨字节排列。最常见、也最不容易写错的解包方法,是用一个位缓冲器连续消费字节流。每收到 8 bit,就把它们塞进一个临时变量,当临时变量里的累计 bit 数达到 11 时,就取出一个通道值。

void sbus_decode_channels(const uint8_t data[22], uint16_t channels[16]) { uint32_t bitbuf = 0; int bits = 0; int ch = 0; for (int i = 0; i < 22; i++) { bitbuf |= ((uint32_t)data[i]) << bits; bits += 8; while (bits >= 11) { channels[ch++] = bitbuf & 0x07FF; bitbuf >>= 11; bits -= 11; } } }

因为 22 字节一共 176 bit,16 通道各 11 bit 也正好是 176 bit,所以循环结束时ch应该等于 16。每次取出的值用0x07FF掩码保留低 11 位,这就是遥控器对应通道的原始值,范围通常是 0 到 2047。中位一般接近 1024,我习惯拿到之后直接换算成 -100~+100 或者 0~1000 的油门量,具体怎么映射看你的飞控或驱动需求。

3.5 错误恢复:串口出错时如何重启 DMA

SBUS 在航模环境里工作,接收机到飞控之间难免有干扰和震动,串口偶尔会出现奇偶校验错误、帧错误或 Overrun。如果不管,有些 HAL 库版本在错误产生后会把 DMA 停掉,串口接收也就停了。我的做法是在HAL_UART_ErrorCallback里对错误类型做统一恢复,重新启动 DMA 接收。

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(&huart1, sbus_rx_buf, SBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } }

这里注意我在重启 DMA 后重新使能了 IDLE 中断,因为错误处理过程中有些库会临时关闭相关中断。如果你的程序中还有其他错误标志,比如 PE、FE,也可以通过 HAL 库提供的宏一并清除。这种保险动作虽然看起来不起眼,但能让系统在长时间连续运行时更可靠。

4. 调试记录:这些坑我都替你踩了一遍

4.1 一切问题先上逻辑分析仪看波形

这句话我反复讲给身边做嵌入式的朋友听。调 SBUS 这一类串口协议,不要一上来就抱着代码啃。先用逻辑分析仪或者示波器接在 RX 引脚上看数据波形。有的逻辑分析仪可以选择到解析 SBUS,但我用得多的是普通 UART 的分析方式,只是记住 SBUS 是反相信号,在分析仪里通常要勾选 Invert 或者加反相器之后再抓包。

我遇到过一种很隐蔽的情况:硬件反相器焊接正常,示波器看波形也对,但收到的数据还是全 0xFE 这种反码。后来发现是示波器探头接到了接收机信号输出,而没接 STM32 RX 引脚,中间那段线上被板子的下拉电阻影响,电平没有完全反转。所以抓波形一定要在 MCU 引脚上量,而不是在接收机端量完就以为万事大吉。

4.2 DMA 计数器回绕和 last_pos 更新

用循环 DMA 接收,最容易出现逻辑 bug 的地方就是回绕。当你处理完一部分数据后,sbus_last_pos停在比如 250,下一次 IDLE 到来时 DMA 已经写到 5 了,也就是缓冲区从 250 走向 255 后又绕回 0~4。如果把当前位置简单地当作一个递增数值去减,两个数减出来的差值是负的,处理逻辑就会乱。

我上面给出的代码用 while 循环配合自增取模,可以正确处理回绕。要注意的是这个处理过程必须在中断里完成,尽量不要把它拆到 main 循环里等,因为等久了 DMA 缓冲区可能被下一帧覆盖,旧数据就被冲掉了。如果你的系统有多帧 IDLE 合并的情况,比如总线连续来两帧、中间没有足以触发 IDLE 的空隙,也没关系,状态机会把两帧都处理掉。

4.3 帧错位和状态机自恢复

SBUS 解析的另一个坑,是上电瞬间收到的第一帧可能是不完整的。比如接收机刚启动时,总线上的输出还没建立稳定,或者你的 DMA 启动晚于接收机发送,那么缓冲区开始位置可能是一帧的中间而不是帧头。如果代码里等到满 25 字节才解析,大概率第一帧就是错位的。

状态机天然解决了这个问题:解析器始终等待 0x0F,只有遇到帧头才认为一帧开始。如果遇到帧头后又没正常收到 0x00 帧尾,它会自动回到等待帧头状态,不会把残缺帧当作有效数据。代价是极端情况下可能出现一帧被丢弃,这在实际中完全可以接受,飞控系统更关心后续数据的正确性,而不是每一帧都完整。

4.4 千万别忽略 UART 错误中断

前面几条都是软件层面的问题,这条更像是“稳定性焦虑”。SBUS 接收现场如果有电机电调在运转,电磁干扰很容易让串口出现奇偶校验错误。一旦 UART 错误标志被置位,如果 HAL 库的底层处理已经把 DMA 停了,而你只盯着一个 IDLE 中断回调看,很难发现接收已经悄悄死掉。

我把错误恢复回调加上之后,系统连续跑了几天都没有出现“断开后无法恢复”的问题。另外,如果你的控制逻辑里有高优先级任务长时间占用 CPU,比如一个死循环或者硬件加密计算,DMA 仍然会把数据搬进缓冲区,但这期间 IDLE 中断如果一直得不到响应,缓冲区会越积越多,最终被覆盖。遇到这种情况要么扩大缓冲区,要么提高 UART 中断优先级,不要幻想 DMA 能包办一切。

5. 实测效果与扩展思路

5.1 实测数据与 CPU 占用

这套方案我在 72MHz 的 STM32F103 上跑过,也在一款更高主频的 F4 上跑过。逻辑分析仪抓到的通道数值稳定,帧计数持续增长,没有出现解析卡死。用定时器翻 GPIO 测了一下,一次 IDLE 中断处理大约 20 至 30us,CPU 占用比传统逐字节接收低了一个量级。剩下大部分时间都留给控制算法以及姿态解算。

如果觉得 100000 这个波特率不寻常,可以顺手在 CubeMX 里看一下分频计算是否正确,能算出整数就不需要担心。实际测量中我发现部分国产接收机输出的脉宽会略有漂移,但这不影响解析,只要微处理器和 DMA 能跟上数据流即可。

5.2 把 DMA + IDLE + 状态机这套骨架迁移到其他协议

这个框架不止能解 SBUS。只要是“连续串行字节流 + 固定帧结构”的协议,比如常用的 MAVLink、CRSF、MSP,或者自己定义的简单工业协议,都可以复用同样的骨架:用循环 DMA 收数据,用 IDLE 切帧,用状态机解析帧头和帧尾。需要改动的往往只有帧结构、校验方式和解包逻辑。

比如 CRSF 协议同样是窄波特率下的高频数据流,字节间隙很小,但用 IDLE 切帧往往会出现一帧数据还没发完就触发的误判。这种情况下就要看协议里有没有明确的帧头字段,可以让状态机尝试按长度切帧,IDLE 仅作为超时兜底。思路是共通的,关键在于你敢不敢把 DMA 缓冲区当作一条无边界的数据流去消费,而不是死板地等“缓冲区满再处理”。

最后说一个实际操作中的体会:不要一开始就想把协议解析和业务控制写在一起。先用一个独立的 C 文件把“数据流 -> 状态机 -> 通道值”这一层做干净,后面无论是改成 CRSF、还是加校验、加断线检测,都只是在这个文件里做小动作。SBUS 本身协议不复杂,真正复杂的是串口底层状态和帧边界识别。只要把 DMA 循环接收和 IDLE 中断的机制吃透,这套代码用起来会非常顺手。

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

车载总线协议解析与云端诊断设备实测心得

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

作者头像 李华
网站建设 2026/9/25 1:27:56

Linux应急响应日志分析:SSH爆破识别与攻击链还原实战

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

作者头像 李华
网站建设 2026/9/25 1:27:32

基于BLE的ESP32无线调试方案解析:从PyBLE到平板开发实战

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

作者头像 李华
网站建设 2026/9/25 1:27:30

PLC工程师如何用AI提升编程效率与可靠性

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

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

I²C物理层深度解析:开漏驱动与多主仲裁实战指南

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

作者头像 李华