ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32下SBUS信号解析最佳实践:DMA循环接收+IDLE中断+状态机

STM32下SBUS信号解析最佳实践:DMA循环接收+IDLE中断+状态机 开门见山说结论做 SBUS 接收最舒服的组合就是 DMA 循环接收 串口 IDLE 空闲中断 一个轻量状态机。这套方案我拿来解析 Futaba 接收机输出的 SBUS 信号连续跑了几百个小时没出过丢帧错位的问题中间换过好几版实现最后沉淀下来的就是这一套。这篇文章把协议、配置、代码、还有我踩过的坑一起写清楚适合正在做飞控、遥控接收机解析、或者想在 STM32 HAL 库下处理不定长串口帧的读者参考。1. 为什么收 SBUS 先别急着写代码方案定型才是关键很多朋友拿到 SBUS 接收机第一反应就是开一个串口中断进一个字节收一个字节。SBUS 的波特率只有 100000bps单看这个数字确实不高一帧 25 字节算下来也就 3ms 左右好像逐字节中断也能扛。但等你真正接到飞控或者舵机控制板上就会发现事情没那么简单。1.1 逐字节中断为什么容易翻车逐字节 UART 中断的主要问题不是 CPU 性能不够而是中断频率高、处理时间碎片化。SBUS 一帧周期典型值在 14ms 左右但某些接收机在快速模式下可以压缩到 7ms 一帧。假设你还在中断里做通道解码、数据拷贝、标志位操作一次中断几十微秒算下来不会把 CPU 吃满可一旦系统里还有定时器中断、SPI 通信、传感器轮询中断互相挤压就容易在某个瞬间丢掉串口数据。丢一个字节整帧错位SBUS 又不像普通串口协议有清晰的帧头定位容错机制状态直接崩掉。HAL 库还有一层额外开销。HAL_UART_RxCpltCallback 这种回调函数在接收完成后才会触发逐字节模式下中断里要先跑 HAL 库的状态机再跳进回调本身就有延迟。更麻烦的是HAL_UART_Receive_IT()每次接收完成后都要重新调用一次如果上一帧接收完成而你还没来得及重新开始接收中间这个窗口期的字节就永远丢了。早期我在这上面栽过跟头最后彻底放弃逐字节方案。1.2 固定长度 DMA 接收的尴尬既然逐字节不行自然会想到 DMA。SBUS 帧长看起来是固定的 25 字节直接配一个 25 字节的 DMA 缓冲区接收完成中断里把数据拿走听起来干净利落。但 SBUS 的帧长没有那么严格固定基础帧是 25 字节其中第 25 字节是可选的 RSSI 信号强度字节有的接收机输出 24 字节有的输出 25 字节。如果你用固定 25 字节的 DMA一旦实际数据是 24 字节DMA 就会把下一帧的第一个字节0x0F吞进来凑数导致一帧数据整体错位一个字节后面全是乱的。你可能会说接收完成中断触发后再判断最后一个字节是不是 0x00 不就行了。但这解决不了根本问题DMA 每次接收完成都要重新配置一次缓冲区地址和传输长度中间同样存在切换窗口期。SBUS 帧与帧之间的间隔在快速模式下可能只有几百微秒窗口期稍微长一点就漏帧了。1.3 为什么最终选了 DMA 循环 IDLE 中断最后我定型的方案是串口 DMA 设置为循环接收模式数据字节不断写入 DMA 缓冲区由硬件自动完成CPU 完全不用管。然后利用 UART 的空闲中断IDLE Line Detect在一帧数据发送完毕、总线进入空闲状态时触发中断。中断里做的事非常少就是读取当前 DMA 写到了哪个位置把上一次处理位置到这个位置之间的新数据交给状态机解析。这个方案的巧妙之处在于DMA 循环模式天然解决了固定长度接收的错位问题因为数据永远是连续写入环形缓冲区的我们不需要关心某一帧该从哪个地址开始。IDLE 中断则解决了不定长帧的帧边界问题每次发生空闲说明一次 DMA 传输的一段空闲间隔结束了这正好对应一帧数据或者半帧数据取决于发送节奏。最后状态机负责在字节流里找帧头、裁剪有效数据彻底摆脱了一帧必须正好落在缓冲区边界的限制。1.4 状态机在这个方案里的定位DMA 循环 IDLE 中断只能保证我把新收到的字节交给你了但数据流是连续的不保证每次交给你的字节刚好从 0x0F 帧头开始。比如说上一次 IDLE 中断触发时缓冲区里可能正好截断了半个帧那么下一次中断交过来的新数据就是从帧中间开始的。这时候就需要一个状态机去追踪播放进度当前在找帧头、还在收数据区、还是在等帧尾每个字节进来都按照当前状态决定怎么处理。这样即使一帧被 IDLE 中断切成了两段、三段最后也能完整拼出来。2. SBUS 协议细节与反相问题最容易搞错的物理层聊完了方案必须先确认协议本身。SBUS 这个名字听着高级本质还是串口协议但它有几个反直觉的设定前面不弄清楚后面代码全是白写。2.1 帧格式25 字节里的秘密标准 SBUS 帧格式如下字节序号内容说明00x0F帧头固定值1 ~ 22通道数据16 个通道每个通道 11 bit共 176 bit正好塞满 22 字节230x00帧尾固定值24RSSI / 扩展可选字节部分接收机输出表示信号强度 0~100所以常规接收一帧最少 24 字节带 RSSI 则 25 字节。通道 0~15 每个 11 bit意味着通道取值范围是 0~2047。实际遥控器输出时中立点附近通常在 992~1056 之间满幅范围常见 352~1811这个范围用于映射舵机脉宽。这里容易踩的坑是不要默认每个通道满量程都是 0~2047不同接收机和遥控器校准不同量产项目里最好做一次通道值归一化或校准映射。帧尾 0x00 也很关键。SBUS 数据区里可能随机出现 0x00所以不能光靠一个 0x00 就判定帧尾必须配合完整帧长度判断。状态机里的做法是只有已经收满 22 字节数据区再等到的字节才作为帧尾候选这样既不会把数据区里的 0x00 当成帧尾也不会把帧尾漏掉。2.2 波特率 100000 和 8E2 的坑SBUS 的物理层参数是100000bps8 数据位偶校验Even Parity2 停止位即常说的 8E2。这个2 个停止位和偶校验的组合是第一个大坑。很多朋友照搬普通串口配置 8N1结果收出来全是乱码还以为是信号线接错了。为什么偏偏要用 8E2这是 Futaba 协议的老规矩。2 个停止位给了接收设备更宽裕的解析时间偶校验则提供基础的错误检测。换个角度想普通串口一字节是 1 起始 8 数据 1 校验 1 停止 11 bitSBUS 是 1 起始 8 数据 1 校验 2 停止 12 bit。同样波特率下SBUS 的实际数据吞吐率比普通 8N1 要低但可靠性更高。在 CubeMX 里配置时要注意字长和校验位是联动的。如果你选了 Even Parity数据位会自动加 1 位校验位此时实际的有效数据位要配成 8而 CubeMX 里 Serial parameters 的 Word Length 选项要选 8 Bitseven parity 状态下它等效于报文里的 9 bits 其中 8 位数据 1 位校验。别选成 9 Bits否则数据位校验位一共 9 位会直接错位。2.3 最大的物理层坑反相电平SBUS 传输的是反相 UART 信号。也就是说常规 UART 空闲时是高电平SBUS 空闲时是低电平起始位从高变低SBUS 则是从低变高。STM32 的 USART 硬件不支持电平反相配置如果你直接把接收机的 SBUS 输出引脚接到 MCU 的 RX 引脚收出来的东西是完全乱的因为起始位、数据位、停止位全都反了。解决方式有两种。第一种是硬件反相用一个 NPN 三极管或者专用反相器芯片把 SBUS 反相信号转成正常 UART 电平。这个方案最稳我在 FPV 接收机线路上常用一个 2N7000 MOSFET 加一个上拉电阻就搞定了成本几毛钱。第二种是确认接收机是否同时输出了非反相的 SBUS 信号有些地面站接收机支持固件配置输出极性那就直接接。但如果你做通用产品建议默认按反相处理硬件上加反相电路代码里不做任何电平相关的假设。2.4 通道解码尺度的理解22 字节塞 16 个 11bit 通道本质就是比特流拼接。公式非常机械每个通道占 11 位第 n 个通道从整个 176bit 流的第 n*11 位开始。用代码实现时最常见的方式是从第 1 个字节开始逐位搬运void sbus_decode_channels(const uint8_t *buf, uint16_t *channels) { uint32_t bit_buf 0; int bit_pos 0; for (int ch 0; ch 16; ch) { bit_buf 0; bit_pos ch * 11; int byte_pos bit_pos / 8; int bit_shift bit_pos % 8; bit_buf ((uint32_t)buf[1 byte_pos] bit_shift); bit_buf | ((uint32_t)buf[1 byte_pos 1] (8 - bit_shift)); bit_buf | ((uint32_t)buf[1 byte_pos 2] (16 - bit_shift)); channels[ch] bit_buf 0x07FF; } }这个函数有个边界细节最后一个通道ch15的 bit_pos165byte_pos20也就是要读 buf[21]、buf[22]、buf[23] 三个字节。如果 buf 是 24 字节模式没有 RSSIbuf[23] 恰好是帧尾 0x00读进来做高位补位没问题因为 11bit 只用得到后面 3bit0x00 的高位不会影响结果。但如果你把缓冲区只开了 24 字节代码里读 buf[23] 会越界所以缓冲区至少留 25 字节或者提前把帧尾 0x00 拷贝到第 24 位。3. CubeMX 下的最小配置从串口到 DMA 再到 IDLE 中断方案定好了协议也清楚了接下来是动手配置。我一般用 STM32CubeMX 生成工程但 IDLE 中断那一步它不会帮你全做完下面把完整的配置链路串一遍。3.1 串口参数配置CubeMX 里选择你用的 UART比如 USART1设置如下Baud Rate: 100000Word Length: 8 Bits配合偶校验时注意看是否联动变成 9 BitsParity: EvenStop Bits: 2这三个参数必须对着 SBUS 协议敲死任何一个不对DMA 收上来的数据都是错位的。检查一下 CubeMX 界面当 Parity 选 EvenWord Length 显示 8 Bits 时实际串口报文是 1 起始位 8 数据位 1 校验位 2 停止位这是我们要的 8E2 效果。如果你选择 9 Bits Even报文会变成 9 数据位其中高 1 位可能是校验 2 停止位直接踩坑。3.2 DMA 配置与缓冲区大小DMA Settings 里添加 USART1_RXDirection 选 Peripheral To MemoryMode 必须选 Circular。Circular 模式是这套方案的心脏它让 DMA 自动循环写入永远不停止不需要我们在每帧完成后重新启动传输。Data Width 保持 Byte内存地址递增模式开启。缓冲区大小我建议开 128 字节。为什么选这个数SBUS 一帧最大 25 字节缓冲区如果太小比如只开 32 字节虽然理论上够装一帧但 DMA 循环模式下如果你在处理中断时下一帧数据已经写进来了就可能覆盖还没处理的区域。128 字节可以稳稳装下 4~5 帧数据即使中断因为系统调度延迟了也不会丢数据。而且 128 字节是 2 的整数次幂DMA 地址对齐也舒服。3.3 使能 IDLE 中断的时机CubeMX 不会直接给你勾选UART IDLE interrupt需要手动操作。最容易踩的坑是顺序应该先启动 DMA 接收再使能 IDLE 中断不能反过来。正确顺序HAL_UART_Receive_DMA(huart1, sbus_dma_buf, SBUS_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);先启动 DMA确保硬件已经准备接收再开 IDLE 中断。如果先开中断DMA 还没开始接收总线上任何一点噪声都可能触发一次虚假的 IDLE 中断把状态机的节奏搞乱。USE 空行分隔。4. 核心代码DMA 回绕裁剪、IDLE 中断处理与状态机解析配置完成后核心代码主要是三块启动与中断处理、DMA 数据裁剪处理环形缓冲区回绕、状态机解析。这里给出一个可以直接移植的骨架。4.1 缓冲区与全局变量设计#define SBUS_BUF_SIZE 128 #define SBUS_FRAME_MAX 25 static uint8_t sbus_dma_buf[SBUS_BUF_SIZE]; static volatile uint16_t sbus_last_index 0; static volatile uint8_t sbus_frame_ready 0; static uint8_t sbus_frame_buf[SBUS_FRAME_MAX]; static uint8_t sbus_parse_state 0; static uint8_t sbus_body_cnt 0;sbus_last_index记录上一次 IDLE 中断时 DMA 写入的位置每次中断都拿它跟当前 DMA 写位置做差得到新数据区间。sbus_frame_ready是交递给主循环的标志位标志位置位后主循环去sbus_frame_buf里做通道解码。4.2 DMA 当前位置与回绕处理DMA 当前写位置怎么拿通过读取 DMA 计数寄存器uint16_t sbus_get_dma_pos(void) { return SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); }__HAL_DMA_GET_COUNTER返回的是 DMA 还剩多少次传输没完成用缓冲区总长度减掉它就是当前已经写入的位置。这个位置范围是 0~127随着 DMA 循环写入不断增长到 127 后再回绕到 0。IDLE 中断触发时我们要处理从sbus_last_index到当前位置之间的字节。但 DMA 是循环缓冲区当前位置可能比上一次的小说明 DMA 已经绕了一圈。这时要分两段处理先处理上一次位置到缓冲区末尾的数据再从缓冲区开头处理到当前位置。void sbus_process_idle(void) { uint16_t cur_pos sbus_get_dma_pos(); if (cur_pos sbus_last_index) { sbus_feed_bytes(sbus_dma_buf[sbus_last_index], cur_pos - sbus_last_index); } else { sbus_feed_bytes(sbus_dma_buf[sbus_last_index], SBUS_BUF_SIZE - sbus_last_index); sbus_feed_bytes(sbus_dma_buf[0], cur_pos); } sbus_last_index cur_pos; }这行代码看着简单但回绕处理是整个 DMA 循环方案最容易写错的地方。漏掉cur_pos sbus_last_index分支或者分支里忘了分两段处理都会导致缓冲区开头那一段新数据被丢掉。4.3 UART 中断处理函数有了数据裁剪函数接下来就是串口中断处理。注意必须手动写 IRQHandlerHAL 库自带的HAL_UART_IRQHandler不会帮你处理 IDLE 中断的业务逻辑。void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_process_idle(); } }我强调一点先调用HAL_UART_IRQHandler(huart1)很重要。虽然我们的 DMA 循环模式不需要接收完成中断回调但 UART 的过载错误ORE会在 HAL 库里被处理掉不处理的话会卡死后续接收。调用 HAL 库的处理函数再自己处理 IDLE顺序不能反。4.4 状态机的完整实现状态机的目标是从连续的字节流中准确识别出一帧 SBUS 数据。我设计了三个状态找帧头、收数据区、等帧尾。void sbus_feed_bytes(uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { uint8_t byte data[i]; switch (sbus_parse_state) { case 0: // 等待帧头 0x0F if (byte 0x0F) { sbus_frame_buf[0] byte; sbus_body_cnt 0; sbus_parse_state 1; } break; case 1: // 接收 22 字节数据区 sbus_frame_buf[1 sbus_body_cnt] byte; sbus_body_cnt; if (sbus_body_cnt 22) { sbus_parse_state 2; } break; case 2: // 等待帧尾 0x00 sbus_frame_buf[24] byte; if (byte 0x00) { sbus_frame_ready 1; // 完整帧就绪 } sbus_parse_state 0; // 无论帧尾是否正确都回到找帧头 break; } } }这个状态机的边界处理非常关键。状态 0 遇到 0x0F 才进入数据区接收数据区不关心内容是不是 0x00这是为了避开数据区本身可能出现的 0x00 干扰。状态 2 只有完整收满 22 字节数据后才进入因此这里的 0x00 判断不会被数据区污染。就算帧尾不是 0x00极端情况下接收机输出异常状态机也能自动回到找帧头状态不会卡死。这个状态机有个经典问题如果一帧数据被 IDLE 中断切成了两半比如第一次中断正好在字节 15 处第二次中断从字节 16 继续没问题状态机是持久的数据区计数sbus_body_cnt保留了上次进度。这正是我们需要状态机而不是每次中断重新找帧头的原因。4.5 主循环消费与通道提取状态机把字节流拼成完整帧后主循环只需要消费标志位提取通道while (1) { if (sbus_frame_ready) { sbus_frame_ready 0; sbus_decode_channels(sbus_frame_buf, channel_values); // 这里可以继续做混控、舵机输出、遥测上报等业务 } }channel_values是 16 个 uint16_t 的数组。解码函数在前面 2.4 小节已经给出注意缓冲区边界问题即可。另外一帧 SBUS 数据从接收完成到解析出 16 个通道总耗时应该在微秒级完全不会对主循环造成卡顿。4.6 为什么不用 HAL 库的其它接收接口现在新的 HAL 库提供了HAL_UARTEx_ReceiveToIdle_DMA()这类灵活接收接口封装了 DMA IDLE 的部分逻辑。我试过功能没问题但缺点是回调机制和版本差异老版本库函数名不一样新版本回调参数又改了代码迁移成本高。自己写 IRQHandler 只需要固定几个宏不依赖 HAL 版本变化长期维护更稳。另外自己手动裁剪缓冲区逻辑完全透明出了问题可以直接看寄存器状态不用翻 HAL 库封装层。5. 实测里值得注意的几个坑与调试验证手段代码写完了下面这部分全是实际测试中遇到的真实问题。有些问题排查了很久写下来省得你再踩一遍。5.1 逻辑分析仪是第一生产力SBUS 调试第一个建议先别急着看串口助手。SBUS 是 100000bps 8E2大多数串口助手软件对 8E2 支持并不好而且反相电平问题会让串口助手直接显示乱码。我的调试顺序是先用逻辑分析仪抓 RX 引脚波形确认有没有反相、波特率对不对、帧间隔是否符合预期。用逻辑分析仪看 SBUS 波形时注意观察空闲电平。如果空闲电平是低说明信号确实是反相的需要检查反相电路。如果空闲电平已经正常拉高那可能是接收机已经输出了正相兼容信号。这一步确认完再进代码层面调试能够省掉大量无用功。5.2 ORE 过载错误为什么会突然卡死HAL 库 DMA 接收时会遇到一个隐蔽问题如果 DMA 因为某些原因没有及时取走数据UART 硬件寄存器里新数据覆盖了旧数据硬件会置位 ORE 错误标志。这个标志不手动清除UART 会一直认为出错后续所有中断都进不来现象就是 SBUS 解析突然停止再也没有新帧。我在代码里HAL_UART_IRQHandler(huart1)会自动处理 ORE 吗部分 HAL 版本会但为了确保万无一失建议在判断 IDLE 之前先做一个错误标志清理if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_ORE)) { __HAL_UART_CLEAR_OREFLAG(huart1); }这个保护加一次不会对性能有任何影响但能避免偶发性的死等。更多时候 ORE 的原因不是代码问题而是中断延迟过高比如你在中断里做了长时间的浮点运算或者打印日志导致数据来不及读。所以 ORE 出现时优先看是不是中断里干了不该干的活。5.3 帧周期稳定但通道值漂移多半是波特率偏差SBUS 协议对波特率误差比较敏感尤其是 8E2 的 12bit 帧加上 2 个停止位接收端对每个字节的采样点容限比 8N1 更窄。STM32 的时钟一般很准问题通常出在外部设备——某些国产接收机的晶振本身就偏。现象是DMA 能收到数据但状态机经常卡在错误状态或者通道值持续漂移。排查方法用逻辑分析仪的协议解析看实际波特率。如果实际波特率是 99800 或者 100400差异只有 0.2%但积累到一整帧就足以产生错误。这种情况下可以尝试调整 STM32 的 USART 过采样配置或者接受设备本身的精度问题在代码里增强错误容忍度比如对帧头 0x0F 前后的位进行多次采样判断但这个实现成本高一般不建议。5.4 丢帧粘帧和 RSSI 字节的取舍前面提到 SBUS 帧尾后有可选字节有些接收机输出 24 字节有些输出 25 字节。如果你固定按 25 字节解析24 字节模式的接收机最后一帧会把下一个 0x0F 当作 RSSI 字节结果通道值全部正常但状态机在下一帧找帧头时会漏掉一个字节。反过来如果固定按 24 字节解析25 字节模式下 RSSI 会被当成下一帧的帧头直接错乱。我的处理方式状态机里收完 22 字节数据区后进入等待帧尾状态收到 0x00 后立即产生一帧数据此时如果后面还有一个字节 0x00RSSI 字节它会被当作下一帧的帧头去匹配而 0x00 不等于 0x0F所以会被忽略不会对下一帧造成影响。状态机天然兼容两种模式不需要特地判断接收机型号。5.5 中断优先级怎么定DMA 循环接收 IDLE 中断这套方案下串口中断优先级建议比系统节拍中断高或者至少不低于其他高频中断。实测中我把 UART 中断优先级设置为最高抢占优先级 0因为 IDLE 中断处理时间极短反正就几十条指令高优先级不会对系统造成明显影响。反而如果优先级低了其他中断正在跑IDLE 处理被推迟DMA 缓冲区里多写一帧没问题但如果有连续多帧高速到达处理不及时就可能触发 ORE 或覆盖。5.6 实测数据参考我自己用 STM32F103 STM32CubeMX FW_PACK V1.8.0 的 HAL 库测试缓冲区 128 字节100000bps 8E2接收 Futaba R7008SB 输出的 SBUS 信号连续运行 24 小时解析出的通道值稳定状态机一帧未丢。用另一个国产接收机测试帧周期不稳定但能正常解析。然后把缓冲区改成 32 字节测试在接收机快速连续输出帧时偶尔出现丢帧说明 128 字节不是奢侈是实际需求。最后再分享一个提升鲁棒性的小技巧调试稳定后还可以给 SBUS 解析加一层喂狗式检测。比如状态机收到一个完整帧但帧尾不是 0x00别急着丢可以打印出来看看是不是偶尔一次错帧。或者加一个接收超时判断如果超过 100ms 没收到任何 SBUS 数据说明接收机可能断电或者信号线断了此时应该让输出通道回到安全值。这个超时逻辑不需要额外定时器直接在状态机里记录上一次完整帧的时间戳主循环用当前时间减一下就行。这套方案跑顺之后你会发现它不止能收 SBUS任何不定长串口协议都可以套用。比如常见的 MAVLink 串口协议、GPS NMEA 协议、甚至自定义的调试协议核心都是 DMA 循环接收 IDLE 中断判断帧边界 状态机解析内容。方法是一样的换一个状态机的状态定义而已。这也是我为什么愿意把这套方案沉淀下来的原因——一次搞定处处复用。
返回列表