ARTICLE DETAIL

资讯详情

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

STM32上SBUS协议解析:DMA循环接收+IDLE中断+状态机实战

STM32上SBUS协议解析:DMA循环接收+IDLE中断+状态机实战 做飞控、车模、机器人底盘这类项目遥控接收机的 SBUS 协议迟早会碰到。我第一次用它的时候直接拿着逻辑分析仪看信号发现不是逻辑分析仪能直接识别的 115200而是一路反相的 100000bps 串口当时还不明白为什么飞控代码里全是“DMA 循环接收 IDLE 中断 状态机”这套组合。把这套链路完整跑通后我最大的感受是SBUS 本身不复杂复杂的是如何在 Cortex-M 上稳定、低延迟、不丢帧地把 25 个字节抠出来。这篇文章就把我实际移植和测试过程中用到的 HAL 库实现、DMA 环形缓冲区管理、IDLE 中断处理以及字节级状态机解析全部拆开讲清楚。1. 解析之前先把 SBUS 的物理层和帧格式盘明白1.1 电气层100000bps、8E2、反相电平SBUS 全称 S.BUS最早是通信协议厂商在遥控模型领域推的一种串行总线协议。它底层就是 UART 串口但有三点和普通串口不一样波特率不是 115200而是 100000bps。数据格式不是 8N1而是 8E2也就是 8 个数据位、偶校验、2 个停止位。信号电平是反相的。反相这个点最坑。我们把遥控接收机的 SBUS 输出直接接到 STM32 的 RX 引脚上如果中间没有做电平反相收到的字节就会完全不正常。0x0F 这个同步字节反相之后会变成 0xF015 变成 240整个帧解析直接失败。串口本身不会帮你纠正这个逻辑因为硬件只认 RX 引脚上的高低电平。解决办法有几种最简单的是用一颗反相器芯片比如 74HC04、CD4069 这类逻辑门把信号反转后再进单片机也可以在 F3、F7、L4 等支持 RXINV 位反转功能的 STM32 系列上直接配置 USART 的 RX 信号极性反转。注意老一代的 F1、F4 的 USART 很多没有这个寄存器位不能靠软件解决只能改硬件。我的实际建议是做开发板或者转接板的时候直接把反相器电路画在接收机输入到 MCU 之间调试会省很多事。1.2 帧格式25 字节无 CRC靠边界字符对齐SBUS 每一帧固定 25 字节结构如下字节序号内容说明00x0F帧头同步字节1 ~ 2216 个通道数据每个通道 11 bit共 176 bit打包成 22 字节23标志字节包含第 17、18 通道以及丢帧、失控保护状态240x00帧尾结束字节接收机按照固定周期向外发帧常见的周期是 7ms 或 14ms具体取决于接收机工作模式和协议版本。也就是说两帧之间一定会有电平空闲时间这个空闲时间就是 IDLE 中断能够利用的边界信号。协议没有 CRC、没有校验和帧头 0x0F、帧尾 0x00 是唯一的对齐依据。这意味着解析器不能只依赖“字节流按顺序推进”这样简单的逻辑一旦串口出现一个字节丢失后续帧就会整体错位必须有状态机来重新同步。1.3 固定帧长也要做边界检测的原因有人会问既然 SBUS 固定 25 字节我直接收到 25 个字节解析一次不就行了实际工程里不能这么做。原因有两个第一接收机上电后不会立刻进入稳定输出状态串口线上可能出现前半段乱码。如果只按长度硬切第一个字节不是 0x0F后面整条数据流都会错位。第二嵌入式环境里干扰会导致个别字节丢失。丢失一个字节后如果不在下一个 IDLE 边界重新对齐那么后续 25 字节块里的每个通道值都会是错的而且这种错误很难在应用层发现。IDLE 中断在这里的作用是告诉我“串口线上已经空闲了一段时间”这段时间就是帧之间的停顿点。我在空闲点处理缓冲区里的数据配合状态机去寻找 0x0F就能比较可靠地恢复对齐。2. 接收方案为什么选 DMA 循环接收 IDLE 中断2.1 三种主流接收方式对比在 STM32 上接收 SBUS最常见的方案有三种方案原理优点缺点RXNE 单字节中断每个字节进一次中断读取后放入数组实现简单思路直观每 83us 进一次中断CPU 被频繁打断波特率稍快或主频较低时会出问题DMA 半满/全满中断DMA 缓冲区半满或全满触发一次回调中断频率低只能按固定长度分段无法感知帧边界需要额外配合超时逻辑DMA 循环接收 IDLE 中断DMA 不停往环形缓冲区写入串口空闲时触发 IDLE 中断中断少、不丢字节、帧边界清晰逻辑略微绕需要理解环形缓冲区和 DMA 当前计数SBUS 速率 100000bps每个字符大概 83us。如果不开 DMA应用代码会一直被打断在跑姿态解算、电机控制的时候这种高频中断往往导致控制环路抖动。用 DMA 之后数据搬运交给硬件CPU 只需要在每一帧空闲的时候做一次解析这是目前飞控领域普遍采用的做法。2.2 IDLE 中断到底在检测什么串口 IDLE 中断检测的不是“帧结束”而是“总线上已经空闲了整整一个字符时间”。对于 SBUS 来说接收机每 7ms 或 14ms 发一帧帧和帧之间一定有超过一个字符时间的空闲所以每个帧间隔都会触发一次 IDLE。这里有个容易混淆的点IDLE 中断触发之后DMA 并没有停止它仍然在等待下一个字节写入。我们只是在 IDLE 中断里记下“目前 DMA 已经把数据写到了哪里”然后处理环形缓冲区中从上一次处理位置到当前位置之间的数据。2.3 利用 DMA 的 NDTR 反推当前写入位置DMA 循环模式下每写入一个字节硬件计数寄存器 NDTR 就减 1。当 NDTR 减到 0硬件会自动重新装载初始值继续新一轮循环。所以当前 DMA 写入位置可以这样算current_pos BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx);由于 IDLE 期间不会来新字节NDTR 暂时静止在中断里读到的是一个稳定值。这个技巧是整套方案的核心理解它之后再调代码思路会非常清晰。缓冲区大小建议选 2 的幂次比如 256 或 512。这样计算环形索引可以用按位与代替取模代码简洁也避免编译器生成除法指令。3. CubeMX 配置里的关键项与踩坑点3.1 串口参数配置在 CubeMX 里打开串口外设配置如下Baud Rate100000Word Length8 BitsParityEvenStop Bits2 Bits请确认串口外设的“高级参数”里不要擅自加上什么自动流控SBUS 不需要。MODE 选择异步模式只打开 RX 也是可以的因为我们不需要向接收机发送数据。这里再强调一下CubeMX 生成的代码里如果芯片支持 RX 信号反转可以在 GPIO 设置里找 “RX Inversion” 或者在串口参数里配置如果不支持这个参数不会出现你就必须外置反相电路。F103、F407 这类经典芯片通常没有 RXINV不要浪费时间去翻寄存器直接上反相器最稳妥。3.2 DMA 配置DMA 是这套方案的另一只脚。需要把 USART1_RX 对应的 DMA 请求添加为 DMA 通道配置成循环模式DirectionPeripheral To MemoryModeCircularPeripheral IncrementDisabledMemory IncrementEnabledPeripheral Data WidthByteMemory Data WidthBytePriorityHigh 或 Very HighPriority 建议给到 High。虽然 SBUS 数据量不大但 DMA 正在搬运过程中如果不断被其他 DMA 请求打断可能会出现字节间隔抖动。对于实时性要求比较高的场景优先级给高一点没有坏处。3.3 中断优先级设置串口 IDLE 中断需要开启。使用 CubeMX 时把 USART1 global interrupt 勾上。优先级需要注意如果系统里跑了 FreeRTOS我建议把串口中断优先级设为比 Tick 中断更高的抢占优先级同时高于电机控制里比较紧急的定时器中断或者至少不要低于它们。为什么这么强调优先级因为 IDLE 中断里要做环形缓冲区位置记录和简单解析这个操作很短但优先级过低会导致中断被其他紧急中断打断拖得越久下一帧数据就可能覆盖掉当前还在解析的数据。实测下来把抢占优先级设为 0~2 之间比较安全。3.4 缓冲区大小取舍缓冲区开 256 字节已经足够。SBUS 一帧 25 字节8ms 内循环写入 25 字节即使解析被高优先级任务挤到很后面缓冲区也不会写穿。如果你同时跑 OTA、蓝牙转发等占用 DMA 的场景可以开到 512 字节多消耗几十字节 RAM解决不少偶发问题。4. IDLE 中断与循环缓冲区窗口的衔接代码4.1 定义缓冲区与结构体先定义环形缓冲区大小、状态以及最终解析结果#define SBUS_BUF_SIZE 256u #define SBUS_BUF_MASK (SBUS_BUF_SIZE - 1u) #define SBUS_FRAME_LEN 25u #define SBUS_CH_NUM 16u static volatile uint8_t sbus_rx_buf[SBUS_BUF_SIZE]; static volatile uint16_t sbus_last_pos; typedef struct { uint16_t ch[SBUS_CH_NUM]; uint8_t ch17; uint8_t ch18; uint8_t frame_lost; uint8_t failsafe; uint8_t new_data; } sbus_channel_t; sbus_channel_t sbus;sbus_last_pos 记录上次已经处理到的缓冲区位置。这个变量在 IDLE 中断里更新DMA 写入位置也是从 NDTR 反推出来的两者本质都是缓冲区下标。4.2 自定义串口中断服务函数在 CubeMX 生成的工程里默认的中断服务函数调用的是 HAL_UART_IRQHandler。这里我推荐直接写自己的中断服务函数不调用 HAL_UART_IRQHandler。原因是 HAL 的接收逻辑主要靠 DMA 驱动IDLE 事件本身在标准 HAL 接收流程里没有被完整利用自己接管反而更干净void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_ORE) ! RESET) { __HAL_UART_CLEAR_OREFLAG(huart1); } if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_handle_idle(); } }注意清 ORE 和清 IDLE 的顺序不能乱。OERE 标志如果一直存在会影响后续 IDLE 判断所以我在中断里先清溢出标志再处理 IDLE。实际项目里如果串口线接触不良溢出计数会不断累加可以在应用层输出提示。4.3 IDLE 事件里计算窗口并喂给状态机sbus_handle_idle 的核心是计算本次空闲到上次处理位置之间的字节窗口void sbus_handle_idle(void) { uint16_t cur_pos; uint16_t start_pos; uint16_t len; cur_pos (uint16_t)(SBUS_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_usart1_rx)); start_pos sbus_last_pos; len (uint16_t)(cur_pos - sbus_last_pos); sbus_last_pos cur_pos; for (uint16_t i 0; i len; i) { sbus_feed(sbus_rx_buf[(start_pos i) SBUS_BUF_MASK]); } }因为缓冲区大小是 256cur_pos 与 sbus_last_pos 的差值天然是 0~255 之间的环状距离不需要额外判断是否越界。起始位置 start_pos 可能是任意值访问缓冲区时直接按位与 SBUS_BUF_MASK就能正确处理跨末尾回绕。4.4 关于 HAL_UARTEx_ReceiveToIdle_DMA 的提醒新版本 HAL 库提供了官方 APIHAL_UARTEx_ReceiveToIdle_DMA看起来也能在空闲时回调。但它是单次接收模式不是循环模式。每次 IDLE 触发后都会停止接收必须在回调里重新调用启动函数。这个重新启动的时间窗口里输入引脚恰好有一个新字节的话大概率会丢掉。所以我在项目里坚持用“HAL 库负责初始化和 DMA 配置串口中断自己接管 IDLE”的做法。这样既保留了 HAL 库的便利性又避开了官方 API 一次性接收在连续帧场景下的丢字节问题。5. 状态机解析从字节流到 16 通道数据5.1 状态设计与转移解析 SBUS 帧的状态机分为三个状态状态含义转移条件ST_WAIT_SOF等待帧头 0x0F收到 0x0F进入 ST_BODYST_BODY收集第 1~23 个后续字节帧位置到达 24进入 ST_TAILST_TAIL确认帧尾 0x00无论是否校验成功都回到 ST_WAIT_SOF这里把第 24 个字节单独抽出来做校验因为帧尾必须是 0x00。如果帧尾不对说明这一帧中间丢过字节不能使用直接丢弃并回到等待状态。状态机实现如下static uint8_t sbus_frame[SBUS_FRAME_LEN]; static uint8_t sbus_frame_pos; static uint8_t sbus_state; #define ST_WAIT_SOF 0u #define ST_BODY 1u #define ST_TAIL 2u static void sbus_feed(uint8_t b) { switch (sbus_state) { case ST_WAIT_SOF: if (b 0x0F) { sbus_frame[0] b; sbus_frame_pos 1; sbus_state ST_BODY; } break; case ST_BODY: sbus_frame[sbus_frame_pos] b; if (sbus_frame_pos 24u) { sbus_state ST_TAIL; } break; case ST_TAIL: sbus_state ST_WAIT_SOF; if (b 0x00) { sbus_parse_frame(); } break; default: sbus_state ST_WAIT_SOF; break; } }有一个细节需要解释ST_BODY 状态从 frame_pos 1 开始接收第 1 个到第 23 个后续字节。当 frame_pos 累加到 24说明已经收了 SOF 加 23 字节其中包含 22 字节通道数据加 1 字节标志位。剩下的最后一个 0x00 帧尾由 ST_TAIL 状态接收校验。这样每一帧的执行路径就严格对应 25 字节。5.2 数据字节里出现 0x0F 怎么办SBUS 的 22 字节通道数据里完全可能出现值为 0x0F 的字节。在 ST_BODY 状态中这个字节会被当作普通数据收集不会跳回等待状态。因为状态机已经明确“当前在收帧体”不需要再去找帧头。真正需要警惕的是另一种情况如果因为丢字节导致帧尾校验失败状态机会回到 ST_WAIT_SOF并等待下一个 0x0F。由于 SBUS 每个帧间隔都有 IDLE下一个 IDLE 窗口会提供新的完整帧数据所以只要接收机没有彻底损坏重新同步很快。5.3 解析结果生成与标志位读取sbus_parse_frame 函数负责把 25 字节原始帧转换成通道值static void sbus_parse_frame(void) { const uint8_t *d sbus_frame; const uint8_t *p d[1]; // 通道提取见下一章 sbus.ch[0] ((uint16_t)(p[0]) | ((uint16_t)p[1] 8)) 0x07FF; // ... 其余通道 sbus.ch17 (d[23] 0x80) ? 1 : 0; sbus.ch18 (d[23] 0x40) ? 1 : 0; sbus.failsafe (d[23] 0x08) ? 1 : 0; sbus.frame_lost (d[23] 0x04) ? 1 : 0; sbus.new_data 1; }标志字节各 bit 的含义不需要全部猜最重要的就四个bit7 表示第 17 通道bit6 表示第 18 通道bit3 代表失控保护触发bit2 代表信号丢失。应用层拿这两个状态位做急停、安全策略会非常方便。6. 通道位打包、标志位处理与数值归一化6.1 11 bit 通道数据的打包规则SBUS 里 16 个通道每个通道 11 bit总共 176 bit刚好塞进 22 字节。因为不是按字节对齐的通道之间会跨字节拼接。提取的时候需要按位拆包。从 22 字节 payload 的第一个字节开始低位在前可以手写一个通用位提取函数也可以直接按公式展开。我在实际工程里直接用下面这套公式省掉循环移位static void sbus_extract_channels(sbus_channel_t *sb, const uint8_t *p) { sb-ch[0] (uint16_t)((p[0]) | ((uint16_t)p[1] 8)) 0x07FF; sb-ch[1] (uint16_t)((p[1] 3) | ((uint16_t)p[2] 5)) 0x07FF; sb-ch[2] (uint16_t)((p[2] 6) | ((uint16_t)p[3] 2) | ((uint16_t)p[4] 10)) 0x07FF; sb-ch[3] (uint16_t)((p[3] 1) | ((uint16_t)p[4] 7)) 0x07FF; sb-ch[4] (uint16_t)((p[4] 4) | ((uint16_t)p[5] 4)) 0x07FF; sb-ch[5] (uint16_t)((p[5] 7) | ((uint16_t)p[6] 1) | ((uint16_t)p[7] 9)) 0x07FF; sb-ch[6] (uint16_t)((p[6] 2) | ((uint16_t)p[7] 6)) 0x07FF; sb-ch[7] (uint16_t)((p[7] 5) | ((uint16_t)p[8] 3)) 0x07FF; sb-ch[8] (uint16_t)((p[8]) | ((uint16_t)p[9] 8)) 0x07FF; sb-ch[9] (uint16_t)((p[9] 3) | ((uint16_t)p[10] 5)) 0x07FF; sb-ch[10] (uint16_t)((p[10] 6) | ((uint16_t)p[11] 2) | ((uint16_t)p[12] 10)) 0x07FF; sb-ch[11] (uint16_t)((p[11] 1) | ((uint16_t)p[12] 7)) 0x07FF; sb-ch[12] (uint16_t)((p[12] 4) | ((uint16_t)p[13] 4)) 0x07FF; sb-ch[13] (uint16_t)((p[13] 7) | ((uint16_t)p[14] 1) | ((uint16_t)p[15] 9)) 0x07FF; sb-ch[14] (uint16_t)((p[14] 2) | ((uint16_t)p[15] 6)) 0x07FF; sb-ch[15] (uint16_t)((p[15] 5) | ((uint16_t)p[16] 3)) 0x07FF; }注意这套公式基于“payload 从 p[0] 开始”的假设对应帧里的 d[1]。如果你的帧数组结构里没有单独把 payload 指针提出来记得把下标整体加一。6.2 三种打包模式中常见的对齐错误很多人在手写公式时会犯一个错把第 3 通道公式写成最低位从 p[2]、p[3] 开始。原因是第 2 通道只占用了 p[2] 的一部分于是在脑内把“下一字节开头”当成对齐点。但位流不是字节对齐的通道 2 结束于 p[3] 的最高位附近通道 3 从 p[3] 剩余位之后开始。所以必须严格按 bit 位置推。如果不想手推公式也可以用通用位读取函数用一个 bit 游标循环 16 次每次取 11 bit。代码跑得略慢一点但是不会错。SBUS 帧率最高才 200Hz解析耗时几微秒完全可接受。6.3 通道值到 PWM 脉宽的归一化SBUS 原始通道值范围一般是 173~1811对应标准遥控器的脉宽 1000~2000us。工程上经常需要把原始值转换成实际占空比或者 PWM 微秒数int32_t raw sbus.ch[i]; int32_t us 1000 (raw - 172) * (2000 - 1000) / (1811 - 172); if (us 1000) us 1000; if (us 2000) us 2000;如果你用的是某些特殊接收机通道范围可能输出 0~2047那么比例系数就要按实际测到的 min/max 调整。建议在调试阶段先把所有通道原始值打印出来看五个遥控杆位打到极限时数值到底落在什么范围再填归一化参数。6.4 标志位的应用建议ch17、ch18 常用于扩展通道。如果没有用到直接把标志位保留在结构体里不要丢弃。失控保护这一位一定要及时处理我在项目里就是检测到 failsafe 置位后舵机输出立刻回到安全位置同时点亮一个警告灯。如果没有这个处理失控时设备可能保持最后姿态很容易出事故。7. 实测调试经验与几个易忽略的坑7.1 先量波形再看解析结果调试 SBUS 的第一步永远是逻辑分析仪或示波器量接收机输出引脚。重点看三样波形是否反相。正常情况下 IDLE 时接收机输出为低电平数据位是高电平和传统 UART 相反。波特率是否接近 100000。用逻辑分析仪的 UART 解码器选 100000、8E2、反相通道如果解码出的十六进制是 0F 开头说明信号链路已经正确。帧间隔是否稳定。示波器上帧头到下一个帧头之间是 7ms 或 14ms 左右的低电平停顿。这个环节做完后续基本不会出现“为什么解析出来全是乱码”的问题。跳过这个步骤往往会在代码里找不到原因。7.2 溢出标志是排查接线的第一指标如果 IDLE 中断里经常出现 ORE 标志别急着查状态机。ORE 溢出通常在两种情况下出现DMA 没有正确循环、或者信号线质量差导致字节间隔抖动。先检查hdma_usart1_rx是不是真的配置成了 Circular 模式如果 CubeMX 里选成了 Normal缓冲区会在第一次收满后停止搬运UART 的 RXNE 挂起不再被 DMA 取走即便用软件清除 ORE也只能维持很短时间。7.3 不要让解析函数长时间霸占中断状态机解析和通道提取都放在中断里执行整个流程非常短但要防止有人在函数里加打印、加浮点运算。串口打印本身是阻塞的会拖长 IDLE 中断时间。调试阶段可以用标志位sbus.new_data在主循环里打印不要直接在中断里调试输出。如果必须在中断里打印通道值也请确保串口发送使用的是 DMA 方式否则每打印一个字都要等串口移位结束几帧 SBUS 数据早就过去了。7.4 RTOS 环境下的互斥处理跑 FreeRTOS 时主任务读取 sbus.ch[] 数组串口中断写这个数组需要做临界保护。最简单的方法是在主任务读取前关中断读完后开中断__disable_irq(); memcpy(app_sbus, sbus, sizeof(sbus)); __enable_irq();因为 sbus 数据结构很小关中断时间极短不会有实时性问题。千万不要把 sbus 传指针给多个任务同时读这种数据竞争排查起来比较痛苦。7.5 HAL 版本差异带来的隐藏坑我遇到过一种诡异现象用的 STM32F4 系列HAL 库版本升级之后原本正常工作的串口 DMA 接收突然“卡住”。后来发现是新版 HAL 的 HAL_UART_IRQHandler 里加入了 IDLE 处理逻辑它会主动清掉 IDLE 标志导致我自定义的中断处理函数可能永远等不到 IDLE。如果碰到这种情况优先检查是不是重复调用了 HAL_UART_IRQHandler。我的做法是CubeMX 生成代码后把串口中断服务函数整体替换掉不调用 HAL 的那一层。这样无论 HAL 库怎么升级IDLE 处理逻辑都始终掌握在自己手里行为可预期。7.6 缓冲区尺寸、通道顺序与地面站验证最后再提醒一个容易忽略的点不同品牌接收机输出的 SBUS 通道顺序不一定一样。比如 Futaba 接收机输出是 1~16 通道按标准顺序有些第三方接收机可能把通道顺序打乱。上位机显示的时候把遥控器的油门杆推到高位如果对应通道值没有跟着变先确认通道编号映射不要急着怀疑解析算法。验证整个方案是否正常我习惯把 DER 值打印出来然后连续拨动遥控器所有通道观察串口输出是否在每帧 IDLE 后被刷新。确认解析稳定后再交到应用层使用。这套 DMA 循环接收加 IDLE 中断加状态机的组合我后来在多个项目上复用只要注意上面几个坑基本都能一次跑通。
返回列表