ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 SBUS协议解析:DMA循环接收+IDLE中断+状态机,稳定不丢帧

STM32 SBUS协议解析:DMA循环接收+IDLE中断+状态机,稳定不丢帧 搞飞控或者遥控车项目的朋友应该都有体会SBUS 协议解析这件事看着不就是读串口嘛实际上做到稳定不丢帧、不卡CPU、不误触发还是有不少门道的。最早我做 SBUS 接收机适配时第一版用的是“逐字节中断 环形数组”在裸机上勉强能跑后来把项目挪到带 FreeRTOS 的板子上问题立刻全冒出来了中断频率太高、任务切换被打断、偶尔丢字节导致帧头漂移整个通道数据抖得像抽风一样。后来换成 STM32 HAL 库的 DMA 循环接收 串口空闲中断IDLE 状态机解析一套组合拳下来CPU占用几乎可以忽略稳定性也上来了。这篇文章就把这套方案的完整思路、配置细节、核心代码和踩坑经历全部摊开讲一讲适合正在调遥控器、飞控、机器人底盘的朋友直接参考。1. SBUS 帧格式与“DMA 循环 IDLE 中断”为什么是绝配1.1 SBUS 不是普通 115200 串口帧格式先理清楚SBUS 是 Futaba 提出的串行总线协议后来在 FrSky 等接收机上也被广泛支持。它的电气层虽然也是 UART但参数和常见调试串口完全不一样参数值波特率100000 bps数据位8 位校验位偶校验 Even停止位2 位输出电平TTL信号反极性一帧 SBUS 数据固定 25 字节结构如下偏移内容0帧头 0x0F1 - 2216 个通道数据每个通道 11 bit共 176 bit23Flag 标志字节24结束帧 0x00通道数据的 176 bit 正好塞满 22 字节所以 16 通道解析其实就是一个紧凑的位段提取问题。Flag 字节里通常包含额外通道、帧丢失和失控保护标志不同接收机厂家的位定义略有差异但一般规律是低 bit 位放通道 17/18再往上放 frame lost、failsafe 这些状态位。1.2 逐字节中断解析的第一个坑中断频率实在太高100000 bps 的 UART一个字节大约 100 微秒就传完。如果每收一个字节就进一次串口接收中断中断频率接近 10 kHz。这个频率在裸机跑 LOGIC 解析还凑合一旦系统里还有传感器采集、电机控制、通信协议栈CPU 大量时间就浪费在中断进出栈上。更麻烦的是中断里不适合做耗时的位段解析只能把字节丢进缓冲区又额外增加一次拷贝开销。十多年前的标准库写法大多是在接收中断里判断帧头 0x0F再用一个计数变量接收剩下的 24 字节。问题在于这种方法对时间极其敏感如果串口中断因为更高优先级中断被打断一个字节没及时读走后面所有数据全部错位必须在下一帧才能恢复。SBUS 又是连续周期发送的遥控数据一旦错位需要好几十毫秒才能重新同步对于飞控这种实时性要求极高的场景非常致命。1.3 DMA 循环接收 IDLE 中断的分工思路DMA 循环接收解决“高频搬数据”的问题UART 每收到一个字节硬件 DMA 自动把它写入内存缓冲区CPU 完全不参与中断频率从接近 10 kHz 降到了每帧一次。IDLE 中断解决“什么时候处理这一帧”的问题UART 线上空闲超过一个字节时间后串口硬件会置起 IDLE 标志。SBUS 接收机以约 100 Hz 的频率发帧帧本身传输时间约 2.5 毫秒帧间隔差不多 7.5 毫秒这个空闲间隔足以稳定触发 IDLE 中断。所以 DMA 负责搬运IDLE 负责按帧切分状态机负责从切分好的数据流里恢复出完整帧三者各司其职。把时间轴拉直来看接收机发完一帧 25 字节后总线空闲UART 产生 IDLE 中断CPU 在中断回调里读取 DMA 当前计数寄存器算出本次空闲发生前一共接收了多少字节然后把这些字节从环形缓冲区里取出来喂给状态机。整个过程在一帧周期内只发生一次CPU 占用几乎可以忽略。2. CubeMX 配置细节100000 8E2、DMA 循环模式和空闲中断的勾选逻辑2.1 串口参数别照搬 115200 模板很多人第一步就在 CubeMX 里把波特率填成 115200然后怎么调都调不通。SBUS 的波特率 100000 并不是常规的 115200多两个百分点的差异就会让数据完全错乱。正确配置是Baud Rate: 100000Word Length: 8 BitsParity: EvenStop Bits: 2这里有个容易忽略的点偶校验在 HAL 层面由硬件自动处理校验位不会出现在 DMA 收到的数据里。也就是说 DMA 缓冲区里看到的字节就是去掉起始位、校验位、停止位后的 8 bit 数据0x0F、0x00 这些帧特征字节不会被校验位干扰。如果初始化时不配偶校验硬件会按无校验解析SBUS 帧头 0x0F 极大概率对不上。2.2 DMA 设置循环模式是核心Continuous Requests 别漏在 CubeMX 的 DMA Settings 里为 UART_RX 添加一个 DMA 请求方向选择 Peripheral To MemoryMode 选择 Circular。这里最关键的是“Continuous Requests”这个选项它决定 DMA 传输完一轮缓冲区后是否自动重新加载。正常情况下选择 Circular Mode 后Continuous Requests 会被自动置为 Enable但不同 CubeMX 版本显示方式不一样有时是灰色不可修改的。如果生成的代码里该配置被遗漏接收一轮后 DMA 就罢工了串口再也不进新数据。我给新项目做初始化检查时会直接读一下生成的MX_DMA_Init代码确认hdma_usartX_rx.Init.Mode DMA_CIRCULAR。DMA 数据宽度全部选 Byte外设地址增量不需要开内存地址增量必须开因为我们要把连续的字节依次放到缓冲区里。缓冲区长度方面建议至少是 SBUS 帧长 25 字节的整数倍再放大一些比如 64 字节或 128 字节。原因后面实测部分会详细说缓冲区太小容易在 IDLE 回调没来得及消费完之前被 DMA 覆盖。2.3 中断配置只开 UART 全局中断DMA 中断可以关很多人习惯把 DMA 中断也同时打开其实在纯 IDLE 切帧方案里DMA 中断不仅没必要还容易添乱。DMA 半满/全满中断会和 IDLE 中断互相竞争导致同一段数据处理两次。我的建议是在 NVIC Settings 里只勾选 USARTx global interruptDMA global interrupt 不勾。UART 全局中断是必须开的因为 IDLE 中断是 UART 外设产生的不使能 USART 中断就永远进不了回调。中断优先级的设置原则是比系统中时间长、任务切换频繁的中断相对高一些但又不要高过最紧迫的控制中断。在 FreeRTOS 项目里我一般把 UART 中断优先级设为比 SysTick 低一档配合HAL_UARTEx_RxEventCallback使用时不容易出现并发问题。2.4 别忘了硬件反相SBUS 的物理层是反极性的 TTL 信号直接把接收机 SBUS 输出接 STM32 的 RX 脚大概率收不到任何有效数据。大多数飞控原理图里会放一个三极管或专用反相器确保给 MCU 的是正相串口信号。如果用的是第三方接收机还要确认它输出的到底是 SBUS 还是 inverted SBUS。有些接收机号称 SBUS 输出实际引脚上已经是经过反相处理的可以直接接 MCU有些需要自己加反相电路。我曾经在调试时用逻辑分析仪观察波形发现 UART 空闲电平是低电平这就是反相信号的特征后来加了一级三极管反相才正常。这个坑看起来和软件无关但会直接导致你怀疑 DMA 配置有问题。3. 让 DMA 计数器和空闲中断协同工作的核心代码实现3.1 用 NDTR 寄存器反推写指针DMA 循环接收模式下硬件内部有一个计数器 NDTR代表“还剩多少个字节没传完”。初始化时 NDTR 等于缓冲区长度每接收一个字节它就减一减到 0 后循环模式会把 NDTR 重新装载为缓冲区长度。因此当前 DMA 已经写入到缓冲区的位置可以用下面的公式算uint16_t write_index (SBUS_RX_BUF_LEN - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart2_rx)) % SBUS_RX_BUF_LEN;% SBUS_RX_BUF_LEN是为了处理 NDTR 刚好等于 0 的情况。NDTR 为 0 时DMA 刚刚完成一整轮传输实际写入位置应该重新回到缓冲区起点而不是索引 64。理解了这个原理后续的读指针、写指针比较就顺理成章了。读指针是我们内部维护的“上次处理到哪里”的索引写指针是 DMA 硬件跑到的位置两者组合就能确定有效数据区间。3.2 空闲中断回调切出有效数据块使用新版 STM32 HAL 库时空闲中断会在HAL_UARTEx_RxEventCallback回调里被处理。这个回调的第二个参数Size在不同 HAL 版本里含义不一致为了稳妥我更习惯直接在回调里读取 DMA 计数器来计算写指针不依赖Size参数。完整代码如下#define SBUS_RX_BUF_LEN 64 #define SBUS_FRAME_LEN 25 #define SBUS_HEADER 0x0F #define SBUS_TAIL 0x00 static UART_HandleTypeDef *sbus_uart; static uint8_t sbus_rx_buf[SBUS_RX_BUF_LEN]; static volatile uint16_t sbus_rd_idx 0; void sbus_start_parse(UART_HandleTypeDef *huart) { sbus_uart huart; sbus_rd_idx 0; __HAL_UART_CLEAR_IDLEFLAG(huart); HAL_UART_Receive_DMA(huart, sbus_rx_buf, SBUS_RX_BUF_LEN); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance ! sbus_uart-Instance) { return; } __HAL_UART_CLEAR_IDLEFLAG(huart); uint16_t wr_idx (SBUS_RX_BUF_LEN - (uint16_t)__HAL_DMA_GET_COUNTER(huart-hdmarx)) % SBUS_RX_BUF_LEN; if (wr_idx sbus_rd_idx) { sbus_parse_stream(sbus_rx_buf[sbus_rd_idx], wr_idx - sbus_rd_idx); } else { sbus_parse_stream(sbus_rx_buf[sbus_rd_idx], SBUS_RX_BUF_LEN - sbus_rd_idx); sbus_parse_stream(sbus_rx_buf[0], wr_idx); } sbus_rd_idx wr_idx; }这里sbus_parse_stream是状态机消费函数会逐个字节解析。如果你的 HAL 库版本比较老找不到HAL_UARTEx_RxEventCallback可以在串口中断服务函数里自己判断__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)逻辑是一样的只是手动清标志位。老工程升级时我一般会直接把手动处理逻辑改造为 HAL 新版回调后续维护更轻松。3.3 为什么不用 HAL_UART_RxCpltCallbackHAL_UART_RxCpltCallback是 DMA 传输完成回调它在缓冲区被填满时才触发。如果缓冲区正好设为 25 字节且开启循环模式每次 DMA 传完 25 字节都会触发看似能用但问题在于DMA 到达缓冲区起点并不等于 SBUS 帧起点可能一帧数据落在缓冲区末尾和下一次开头两段数据分割点完全随机。IDLE 中断则不同它在总线空闲时触发而 SBUS 帧之间有固定的空闲间隔所以 IDLE 天然是 SBUS 的帧边界。这也是这套方案最核心的出发点。还有一个细节sbus_rd_idx更新放在解析之后因此如果解析耗时太久导致 DMA 循环覆盖了尚未解析的数据就会丢帧。这在实际项目里极少发生因为 SBUS 帧间隔有 7.5 毫秒而解析 25 字节和提取通道值只需要几十微秒。如果你在中断回调里做了很多任务级操作那就应该把解析搬到任务上下文回调里只做拷贝和事件通知。3.4 错误中断要兜底串口在强电磁干扰环境下偶发校验错误、溢出错误时HAL 库会调用HAL_UART_ErrorCallback。如果不处理严重情况下 DMA 接收可能停住。比较稳妥的做法是在错误回调里重新启动接收void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance sbus_uart-Instance) { __HAL_UART_CLEAR_PEFLAG(huart); __HAL_UART_CLEAR_OREFLAG(huart); __HAL_UART_CLEAR_NEflag(huart); sbus_start_parse(huart); } }4. 状态机解析从字节流中稳定重构 25 字节 SBUS 帧4.1 DMAIDLE 都做到了还要状态机干什么有人会问IDLE 中断已经把一帧数据切出来了为什么不能直接从缓冲区开头取 25 字节完事这里有三个漏洞初始化后DMA 可能从帧中间开始接收第一个空闲中断切出来的数据块大概率是不完整帧。接收机偶尔发出错帧、丢字节导致数据块边界不整齐。缓冲区中的写指针可能读到的是半帧需要丢弃直到下一帧到来。状态机的作用就是对字节流做边界恢复。只要发现有效帧头 0x0F就开始缓存后续字节最后验证结束字节 0x00确认后提取通道值。这是串口解析中最经典的“逐字节状态迁移”思路代码简单但非常稳健。4.2 三个状态足以覆盖 SBUS 全场景我把状态机拆成三态状态含义SBUS_WAIT_HEADER搜索帧头 0x0FSBUS_FILL_FRAME接收第 1 到第 23 个字节SBUS_CHECK_TAIL验证第 24 个字节等于 0x00当系统刚上电或者数据流错乱时状态机停留在 SBUS_WAIT_HEADER不断丢弃无用字节一旦碰到 0x0F 就进入 SBUS_FILL_FRAME开始攒帧。随后每个字节依次填入缓冲直到剩下最后的尾字节转入 SBUS_CHECK_TAIL。如果最后字节不是 0x00说明这一帧数据无效状态机回到 WAIT_HEADER等待下一次帧头。对应代码如下typedef enum { SBUS_WAIT_HEADER 0, SBUS_FILL_FRAME, SBUS_CHECK_TAIL } sbus_parse_state_t; static sbus_parse_state_t sbus_state; static uint8_t sbus_frame[SBUS_FRAME_LEN]; static uint16_t sbus_frame_idx; void sbus_parse_byte(uint8_t data) { switch (sbus_state) { case SBUS_WAIT_HEADER: if (data SBUS_HEADER) { sbus_frame[0] data; sbus_frame_idx 1; sbus_state SBUS_FILL_FRAME; } break; case SBUS_FILL_FRAME: sbus_frame[sbus_frame_idx] data; if (sbus_frame_idx SBUS_FRAME_LEN - 1) { sbus_state SBUS_CHECK_TAIL; } break; case SBUS_CHECK_TAIL: if (data SBUS_TAIL) { sbus_frame[sbus_frame_idx] data; sbus_publish_frame(); } sbus_state SBUS_WAIT_HEADER; break; } } void sbus_parse_stream(const uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { sbus_parse_byte(data[i]); } }注意sbus_frame_idx SBUS_FRAME_LEN - 1这个判断帧里包含帧头在内共 25 字节帧头已经写入FILL_FRAME 状态接收后续第 1 到第 23 字节接收到第 23 字节时索引从 23 变为 24正好等于 24于是转入 CHECK_TAIL。下一字节即第 24 字节用来验证尾字节。4.3 通道数据提取11 bit 连排小端位段SBUS 每个通道 11 bit16 个通道按顺序紧密排列。我们可以用统一公式提取static void sbus_publish_frame(void) { const uint8_t *p sbus_frame[1]; for (int i 0; i 16; i) { uint16_t bit_pos i * 11; uint32_t raw p[bit_pos / 8] | ((uint32_t)p[bit_pos / 8 1] 8) | ((uint32_t)p[bit_pos / 8 2] 16); sbus_channels[i] (uint16_t)((raw (bit_pos % 8)) 0x07FF); } uint8_t flags sbus_frame[23]; sbus_ch17 flags 0x0001; sbus_ch18 flags 0x0002; sbus_frame_lost flags 0x0004; sbus_failsafe flags 0x0008; sbus_data_updated true; }这里p指向 22 字节通道数据区的起始位置。用三个连续字节拼接成一个 24 bit 整数再右移bit_pos % 8最后用0x07FF掩码取 11 bit。为什么要拼接三个字节因为 11 bit 的通道数据几乎总会跨字节边界只读两个字节在高位部分不够完整。第三个字节的高位部分会在掩码时被丢弃所以不会把 Flag 位污染到通道数据里。flag的低两位在不同接收机定义里可能不同有的代表通道 17 和 18有的代表额外开关量。最好以你手上接收机的官方协议文档为准。frame lost 和 failsafe 的位位置也建议实测验证例如人为关闭遥控器后观察sbus_failsafe是否为真。4.4 状态机面对脏数据的自恢复能力这种状态机的最大优势是“永远不堵死”。无论输入什么垃圾字节它最终都会回到 WAIT_HEADER。比如某次干扰导致帧中间丢失了一个数据字节状态机收满 24 字节后发现尾字节不是 0x00自动丢弃整帧重新等下一个 0x0F。由于接收机每 10 毫秒就发一帧恢复时间最长也就十几毫秒对遥控控制来说完全可接受。如果不用状态机而是直接在 IDLE 回调里定位缓冲区并固定取 25 字节那么一旦缓冲区里存在半帧垃圾数据整个解析逻辑就会连续错位可能要好几百毫秒才恢复正常。这就是标题里强调状态机的真正原因。5. 实测中的坑错帧、半满中断、DMA覆盖与校验位处理5.1 上电后第一个空闲中断引发的误触发初始化完 DMA 接收后如果 UART 线路上处于空闲状态IDLE 中断可能会立刻触发一次。此时 DMA 缓冲区全是初始值 0状态机在 WAIT_HEADER 状态下会把它们全部丢弃所以逻辑上没有危害。但有一个隐患这次 IDLE 中断会把读指针推进到当前写指针位置。如果此时 DMA 已经开始接收了半帧数据下一次 IDLE 真正到来时数据区间是完整的。总体没有问题我第一次调试时看到回调异常频繁一度以为是程序问题后来确认是初始化后总线空闲所致只要没有破坏读指针就不用管。5.2 DMA 半满/全满中断会和 IDLE 中断打架我早期在 CubeMX 里图省事把 DMA 全局中断一起开了结果发现通道数据偶尔跳变。排查后确认是 DMA 半满中断触发后在回调里也调用了解析函数和 IDLE 回调处理了同一段数据。更隐蔽的是DMA 中断优先级如果高于 UART 中断可能在 IDLE 回调读取 NDTR 的瞬间发生 DMA 中断嵌套导致计算出的写指针出现偏差。解决方案很简单DMA 中断不勾选整个 DMA 接收只靠 UART 的 IDLE 中断驱动。DMA 本身不需要中断参与循环模式会一直在后台跑。5.3 偶校验错误后接收停摆SBUS 的偶校验在硬件层做正常时我们感觉不到它的存在。但遇到电平干扰或者接错线时串口硬件会频繁报 PE 错误。问题在于 HAL 库默认处理流程里一旦发生错误可能停止 DMA 接收并进入 ErrorCallback。如果回调里什么都不做串口就再也不收数据了。所以 ErrorCallback 是必写项。我一般这样兜底清掉所有错误标志重新调用sbus_start_parse。这个操作非常轻量不会造成数据冲突因为接收已经停摆重新启动只会让后续数据正常进入缓冲区。5.4 缓冲区大小和覆盖风险的取舍缓冲区越小IDLE 回调需要越频繁地消费数据缓冲区越大能容忍的系统阻塞时间越长。SBUS 帧长 25 字节帧间隔大约 7.5 毫秒如果缓冲区只有 32 字节一次空闲中断后如果任务调度延迟超过 2.5 毫秒下一帧数据就可能覆盖当前还未消费的区域。把缓冲区设为 64 字节后相当于可以缓冲两帧多实测在 FreeRTOS 下即使中断响应偶发延迟几个毫秒也不会丢数据。代价是解析出的数据可能比实际接收晚一两个周期对遥控通道这种慢变信号完全无感。如果你的项目对延迟极敏感可以把缓冲区压缩到 30 字节左右并在终端中断回调里做极简处理但可维护性会下降。我自己的默认配置是 64 字节。5.5 用逻辑分析仪确认波形再查软件遇到软件怎么调都不通的时候先别急着怀疑状态机。我建议先测两处一是波特率是否为 100000二是波形是否反相。很多 USB 转串口工具不支持 100000 波特率要么会显示乱码要么根本无数据。用逻辑分析仪抓 SBUS 输出验证帧头 0x0F 在波形上是否有清晰的低电平起始沿能解决一大半玄学问题。6. 这套解析方案的改造与复用建议6.1 不只适用于 SBUS也适用其它不定长串口协议DMA 循环接收 IDLE 中断切帧 状态机解析这套架构本质上是“利用协议帧之间的空闲时间来划分数据边界”。几乎所有带帧空闲间隔的串口协议都能套用常见的有 MAVLink 的MAVLink 2分包格式、一些惯导设备的 binary 协议、甚至自定义的调试帧协议。改成其它协议时只需要替换状态机内部的字节缓存逻辑和帧校验逻辑。DMA、IDLE、环形缓冲区的骨架完全不用动。我在项目里就把同一个串口驱动框架用在了 SBUS 和激光雷达数据解析上差别只是配置波特率和状态机实现。6.2 配合 FreeRTOS 使用的推荐数据流如果系统里跑 FreeRTOS我不建议在 IDLE 回调里做全部解析。更合理的设计是IDLE 中断里只计算有效数据区间把字节拷贝到一个静态队列或者直接置一个事件标志。在接收任务里等待事件标志再从 DMA 缓冲区读取数据调用sbus_parse_stream。解析完成后的通道数据放到全局结构体供控制任务直接读取。这样做的好处是解析耗时不会阻塞中断上下文而且天然规避了多个任务同时读写的临界区问题。代价是最多多一次拷贝对 SBUS 这种极小数据量来说影响可忽略。6.3 多路 SBUS 接收机场景状态机结构体化一个项目接两个 SBUS 接收机并不罕见比如飞控主副接收机或者云台与底盘分开控制。最简单做法是把状态机的变量收进一个结构体每个串口实例对应一个结构体typedef struct { sbus_parse_state_t state; uint8_t frame[SBUS_FRAME_LEN]; uint16_t frame_idx; uint16_t channels[16]; uint8_t frame_lost; uint8_t failsafe; } sbus_parser_t;再把sbus_parse_byte(sbus_parser_t *parser, uint8_t data)作为通用接口。这样两路接收机互不污染代码也可复用。如果图省事用了全局变量两个串口的回调就会互相踩数据调试起来相当痛苦。6.4 把解析结果转换成 PWM 控制量SBUS 通道原始值范围一般是 0 到 2047中位值约 1024。很多场景下需要把通道值映射到 PWM 脉宽例如 1000 到 2000 微秒uint32_t pwm_us 1000 (uint32_t)(sbus_channels[i] * 1000UL / 2047);这个映射计算可以和状态机解析解耦。只要解析部分保证sbus_channels数据更新及时上层无论做 PID 还是舵机控制都足够用。如果还需要把 SBUS 转成传统 PPM 输出也可以基于这套基础数据继续扩展。我个人在实际项目中最深的体会是SBUS 解析这件事协议本身并不复杂真正决定系统稳不稳的是数据搬运方式和帧同步策略。DMA 循环接收把 CPU 从高频中断里解放出来IDLE 中断提供了准确的帧边界状态机则让整条链路在脏数据面前依然能快速恢复。这套组合我后来在好几个项目里反复复用每次只需要改改串口引脚和波特率几乎没有翻过车。希望这篇文章能帮你一次把这个方案跑通。
返回列表