ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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 波特率一个字节间隔接近 1msCPU 有充足时间去读寄存器。但 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 MemoryMode 必须选 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 bit16 通道各 11 bit 也正好是 176 bit所以循环结束时ch应该等于 16。每次取出的值用0x07FF掩码保留低 11 位这就是遥控器对应通道的原始值范围通常是 0 到 2047。中位一般接近 1024我习惯拿到之后直接换算成 -100~100 或者 0~1000 的油门量具体怎么映射看你的飞控或驱动需求。3.5 错误恢复串口出错时如何重启 DMASBUS 在航模环境里工作接收机到飞控之间难免有干扰和震动串口偶尔会出现奇偶校验错误、帧错误或 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 至 30usCPU 占用比传统逐字节接收低了一个量级。剩下大部分时间都留给控制算法以及姿态解算。如果觉得 100000 这个波特率不寻常可以顺手在 CubeMX 里看一下分频计算是否正确能算出整数就不需要担心。实际测量中我发现部分国产接收机输出的脉宽会略有漂移但这不影响解析只要微处理器和 DMA 能跟上数据流即可。5.2 把 DMA IDLE 状态机这套骨架迁移到其他协议这个框架不止能解 SBUS。只要是“连续串行字节流 固定帧结构”的协议比如常用的 MAVLink、CRSF、MSP或者自己定义的简单工业协议都可以复用同样的骨架用循环 DMA 收数据用 IDLE 切帧用状态机解析帧头和帧尾。需要改动的往往只有帧结构、校验方式和解包逻辑。比如 CRSF 协议同样是窄波特率下的高频数据流字节间隙很小但用 IDLE 切帧往往会出现一帧数据还没发完就触发的误判。这种情况下就要看协议里有没有明确的帧头字段可以让状态机尝试按长度切帧IDLE 仅作为超时兜底。思路是共通的关键在于你敢不敢把 DMA 缓冲区当作一条无边界的数据流去消费而不是死板地等“缓冲区满再处理”。最后说一个实际操作中的体会不要一开始就想把协议解析和业务控制写在一起。先用一个独立的 C 文件把“数据流 - 状态机 - 通道值”这一层做干净后面无论是改成 CRSF、还是加校验、加断线检测都只是在这个文件里做小动作。SBUS 本身协议不复杂真正复杂的是串口底层状态和帧边界识别。只要把 DMA 循环接收和 IDLE 中断的机制吃透这套代码用起来会非常顺手。
返回列表