1. 项目缘起与整体设计思路SBUS 是遥控接收机领域非常常见的一种串行总线协议玩航模、做无人机、搞机器人底盘的兄弟应该都不陌生。它用一根信号线就能传出 16 个通道的遥控数据接线极简但协议本身有几个让人头疼的“坑”100000 波特率、8 位数据位、偶校验、2 位停止位而且是反相电平帧长固定 25 字节帧头 0x0F、帧尾 0x00每帧间隔通常 14ms高速模式 7ms。如果你用传统的“串口接收中断里逐字节处理”的方式CPU 会被频繁打断稍微跑点别的任务就容易丢帧、错帧。这个项目的核心目标很明确在 STM32 HAL 库环境下用DMA 循环接收 串口 IDLE 空闲中断 状态机三件套把 SBUS 解析做成一个稳定、低 CPU 占用、可复用的模块。为什么选这套组合我拆开讲。先说 DMA。串口接收如果用中断方式每来一个字节就进一次中断SBUS 一帧 25 字节、14ms 一帧看起来频率不高但实际系统里往往还有定时器、ADC、其他串口在跑中断嵌套一多接收中断被延迟就会导致溢出错误ORE一帧就废了。DMA 的好处是硬件自动把串口数据搬到内存缓冲区CPU 完全不参与搬运只在“一帧收完”的时候被通知一次。这就是把“每字节一次中断”降级成“每帧一次中断”CPU 负载直接降一个数量级。再说 IDLE 中断。DMA 循环接收有个问题它不知道一帧什么时候结束。SBUS 帧与帧之间有明显的空闲间隔串口硬件在总线空闲一个字节时间后会触发 IDLE 标志。用 IDLE 中断来“切帧”是最自然的选择——总线一空闲说明这一帧传完了这时候去读 DMA 当前写指针就能算出这一帧的长度和位置。相比用定时器超时判断帧尾IDLE 是硬件级的、零延迟、零额外定时器开销。最后说状态机。为什么不用“收到一帧就直接解析”因为实际现场会有噪声、会有半截帧、会有上电瞬间的乱码。如果直接解析很容易把垃圾数据当成有效遥控信号导致舵机乱抖甚至炸机。状态机的作用是给解析过程加上“合法性校验”先找帧头 0x0F再校验帧尾 0x00再检查标志位全部通过才更新通道数据。任何一步不满足就丢弃、回到找帧头状态。这套逻辑让模块对干扰有很强的免疫力。整体数据流是这样的串口外设 → DMA 循环写入rx_buffer→ 每帧结束触发 IDLE 中断 → 中断里计算本帧起止位置 → 把数据拷到待解析缓冲区并置标志 → 主循环里的状态机消费这个标志、逐字节解析 → 校验通过后更新 16 通道数组和失控保护标志。中断里只做“搬运和置标志”重活全在主循环干这是实时系统里非常经典的“中断快进快出”原则。适合谁来参考如果你正在做基于 STM32 的飞控、机器人遥控接收、云台控制、或者任何需要解析 SBUS 的项目这套方案可以直接抄。前提是你对 HAL 库的串口和 DMA 有基本了解知道 CubeMX 怎么配时钟和引脚。完全零基础也能看懂但建议先把串口收发跑通再来啃这块。2. 核心细节解析与实操要点2.1 SBUS 协议帧结构逐字节拆解SBUS 一帧 25 字节结构是固定的我按字节位置给你列清楚字节位置内容说明Byte[0]0x0F帧头固定值Byte[1]通道1低8位通道1数据位0-7Byte[2]通道1高3位 通道2低5位位拼接Byte[3]通道2高6位 通道3低2位位拼接......每两个字节拼出若干通道Byte[22]通道16高8位最后一个通道数据Byte[23]标志位bit0失控保护, bit1帧丢失, bit2故障保护Byte[24]0x00帧尾固定值16 个通道每个 11 位总共 176 位正好塞进 22 个字节176/822。位拼接的规则是小端、低位在前这是 SBUS 最容易写错的地方。我见过太多人在这里翻车把高低位搞反结果通道值全是乱的。具体拼接公式以通道1为例channels[0] ((uint16_t)buffer[1] | ((uint16_t)buffer[2] 8)) 0x07FF; channels[1] (((uint16_t)buffer[2] 3) | ((uint16_t)buffer[3] 5)) 0x07FF; channels[2] (((uint16_t)buffer[3] 6) | ((uint16_t)buffer[4] 2) | ((uint16_t)buffer[5] 10)) 0x07FF;你看通道2它跨越了 byte[2] 的高5位和 byte[3] 的低6位所以要先右移再左移拼接。这种跨字节的位操作建议直接对着协议表一位一位画出来再写代码别凭感觉。2.2 串口参数配置为什么必须是 100000/8E2SBUS 的串口参数和常规串口完全不一样CubeMX 里配置的时候要特别注意波特率100000不是 115200也不是 9600字长8 位校验偶校验Even停止位2 位流控无这里有个细节很多人会忽略偶校验 2 停止位意味着实际每字节传输 12 位1起始 8数据 1校验 2停止。所以实际波特率时钟要按 12 位来算但 CubeMX 里你只需要填 100000 和 8E2工具会自动算分频。如果你手算 BRR 寄存器记得把校验位和停止位算进去否则波特率会偏。另外SBUS 信号是反相的。接收机输出的信号平时是高电平起始位是低电平和标准 UART 相反。所以硬件上必须加一个反相电路最常见的是用一个 NPN 三极管加两个电阻做反相或者用 74HC14 这类反相器。如果你用的是某些带硬件反相功能的 MCU 串口比如部分 STM32 型号支持 TX/RX 极性翻转那可以在寄存器里直接配省掉外部电路。但大多数 F103、F407 的普通串口不支持老老实实加三极管。注意反相电路的三极管基极电阻和集电极电阻要选对基极一般 4.7k~10k集电极 10k 上拉。电阻选错会导致波形边沿变缓高速时误码率飙升。2.3 DMA 循环模式与缓冲区大小的取舍DMA 要配成Circular循环模式不是 Normal。为什么因为 SBUS 是连续不断发帧的如果用 Normal 模式DMA 搬完一轮就停了你得在中断里重新启动 DMA这中间有个时间窗口会丢数据。Circular 模式下 DMA 自动回卷永远在跑配合 IDLE 中断切帧天衣无缝。缓冲区大小怎么定SBUS 一帧 25 字节14ms 一帧。如果你主循环处理够快缓冲区开 2 帧大小50 字节就够。但实际项目里主循环可能有其他耗时任务我建议开64 或 128 字节留足余量。缓冲区越大能容忍的处理延迟越长但内存占用也越大。F103C8T6 只有 20K RAM开 128 字节完全无压力。这里有个关键点DMA 缓冲区是循环写的IDLE 中断里读到的写指针位置是“当前帧结束位置”但上一帧的起始位置需要你自己记录。所以代码里要维护一个last_pos变量每次 IDLE 触发时本帧数据就是从last_pos到current_pos之间的内容注意处理回卷。这个逻辑是整套方案的核心写错了就会丢帧或重复解析。2.4 状态机设计四个状态搞定鲁棒解析状态机我设计成四个状态简单但够用STATE_HEADER等待帧头 0x0FSTATE_DATA接收 23 字节数据STATE_TAIL校验帧尾 0x00STATE_DONE一帧解析完成更新通道为什么不用更复杂的状态机因为 SBUS 帧结构固定没有可变长度四状态足够。状态太多反而增加代码复杂度和出错概率。关键是每个状态的超时和异常处理比如在 STATE_DATA 里如果又收到 0x0F说明上一帧是残帧应该重新开始如果状态停留超过一定时间没进展也要复位。状态机的输入是“一帧完整数据”不是逐字节从串口读。也就是说IDLE 中断把整帧数据准备好后主循环的状态机才去消费。这样状态机不用关心串口时序只关心数据内容逻辑更清晰。3. 实操过程与核心环节实现3.1 CubeMX 配置步骤与关键参数打开 CubeMX选好你的芯片我以 F103C8T6 为例F407 同理。配置流程RCCHSE 选 Crystal/Ceramic Resonator时钟树把系统时钟拉到 72MHzF103或 168MHzF407。USART1Mode 选 Asynchronous波特率 100000Word Length 8 BitsParity EvenStop Bits 2其他默认。DMA Settings点 Add选 USART1_RXMode 选 CircularData Width 都选 BytePriority 选 High。NVIC使能 USART1 全局中断优先级设一个合理的值比如抢占优先级 1子优先级 0。注意 DMA 中断不用开我们只用 IDLE。GPIO确认 USART1 的 RX 引脚通常是 PA10配置正确。生成代码后HAL 会自动初始化串口和 DMA但IDLE 中断需要手动使能CubeMX 不会自动帮你开。在MX_USART1_UART_Init()之后加一句__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后启动 DMA 接收HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE);这两句是整套方案的“点火开关”少一句都不行。3.2 IDLE 中断回调的写法与指针计算HAL 库的 IDLE 中断处理有个坑HAL 默认的HAL_UART_IRQHandler不会自动清 IDLE 标志也不会调用你的回调。你需要自己重写USART1_IRQHandler或者在HAL_UART_IRQHandler里加钩子。我习惯直接重写 IRQHandler逻辑清晰void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t current_pos RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if (current_pos ! last_pos) { if (current_pos last_pos) { frame_len current_pos - last_pos; memcpy(frame_buffer, rx_buffer[last_pos], frame_len); } else { // 处理回卷 uint16_t len1 RX_BUFFER_SIZE - last_pos; uint16_t len2 current_pos; memcpy(frame_buffer, rx_buffer[last_pos], len1); memcpy(frame_buffer[len1], rx_buffer[0], len2); frame_len len1 len2; } last_pos current_pos; frame_ready 1; } } HAL_UART_IRQHandler(huart1); }这里__HAL_DMA_GET_COUNTER返回的是 DMA 剩余待传输数量用缓冲区大小减去它就是当前写指针位置。这个计算是整套代码的命门写错了帧就全乱。回卷处理也要小心last_pos在缓冲区末尾、current_pos在开头的情况必须单独处理。注意memcpy在中断里执行帧长最大 25 字节拷贝时间很短可以接受。但如果你的缓冲区开得特别大、帧特别长建议只记录位置把拷贝放到主循环做进一步缩短中断时间。3.3 状态机解析函数的完整实现主循环里检测frame_ready标志置位就调用解析函数。解析函数用状态机逐字节处理typedef enum { STATE_HEADER, STATE_DATA, STATE_TAIL, STATE_DONE } sbus_state_t; void sbus_parse(uint8_t *data, uint16_t len) { static sbus_state_t state STATE_HEADER; static uint8_t byte_cnt 0; static uint8_t frame[25]; for (uint16_t i 0; i len; i) { uint8_t b data[i]; switch (state) { case STATE_HEADER: if (b 0x0F) { frame[0] b; byte_cnt 1; state STATE_DATA; } break; case STATE_DATA: frame[byte_cnt] b; if (byte_cnt 24) state STATE_TAIL; break; case STATE_TAIL: if (b 0x00) { frame[24] b; sbus_decode(frame); state STATE_HEADER; } else { state STATE_HEADER; } byte_cnt 0; break; default: state STATE_HEADER; break; } } }sbus_decode负责位拼接和标志位解析void sbus_decode(uint8_t *frame) { sbus.channels[0] ((uint16_t)frame[1] | ((uint16_t)frame[2] 8)) 0x07FF; sbus.channels[1] (((uint16_t)frame[2] 3) | ((uint16_t)frame[3] 5)) 0x07FF; sbus.channels[2] (((uint16_t)frame[3] 6) | ((uint16_t)frame[4] 2) | ((uint16_t)frame[5] 10)) 0x07FF; // ... 依次类推到通道16 sbus.channels[15] ((uint16_t)frame[22] 8 | frame[23]) 0x07FF; sbus.failsafe (frame[23] 0x08) ? 1 : 0; sbus.framelost (frame[23] 0x04) ? 1 : 0; sbus.fault (frame[23] 0x02) ? 1 : 0; sbus.updated 1; }注意通道15和16的拼接因为帧尾前只有 byte[22] 和 byte[23] 两个字节通道16的高位在 byte[23] 里但 byte[23] 同时还是标志位字节。这里要小心标志位是 byte[23] 的低3位通道16的数据是 byte[22] 全部8位加 byte[23] 的高8位不对我重新捋一下。实际上 SBUS 的 16 通道数据占 byte[1] 到 byte[22]共 22 字节 176 位 16 × 11 位。byte[23] 是纯标志位不参与通道数据。所以通道16的拼接是sbus.channels[15] (((uint16_t)frame[22] 0) | ((uint16_t)frame[23] 8)) 0x07FF;等等这样又用到了 byte[23]。我查一下标准 SBUS 定义通道数据确实占 byte[1]~byte[22]但最后一个通道的高位会用到 byte[23] 的低位不对标准定义里 byte[23] 是 flags通道数据到 byte[22] 结束。让我重新算16 通道 × 11 位 176 位176 / 8 22 字节整。所以 byte[1] 到 byte[22] 正好 22 字节通道数据完全在这 22 字节里byte[23] 是标志位。那通道16的 11 位就是 byte[21] 和 byte[22] 拼出来的。我上面写错了修正sbus.channels[15] (((uint16_t)frame[21] 5) | ((uint16_t)frame[22] 3)) 0x07FF;这种位拼接错误非常常见建议写完后用已知的遥控器数据验证比如油门最低时通道值应该在 172 左右中位 992最高 1811。如果值不对就是拼接错了。3.4 通道值映射与失控保护处理SBUS 原始值是 11 位范围 0~2047但实际遥控器输出通常是 172~1811。映射到常用范围比如 -100~100 或 1000~2000需要做线性变换int16_t sbus_to_pwm(uint16_t ch) { if (ch 172) ch 172; if (ch 1811) ch 1811; return (int16_t)(((ch - 172) * 1000) / (1811 - 172) 1000); }失控保护failsafe标志位要特别处理。当接收机失去遥控信号时会置位 failsafe同时通道值可能变成预设的失控值。你的飞控或机器人代码必须检测这个标志一旦置位就切换到安全模式比如电机停转、舵机回中。我见过有人忽略这个标志结果遥控器关机后飞机还在飞非常危险。注意failsafe 标志不是每帧都可靠有些接收机在信号恢复后需要几帧才清除标志。建议在主循环里做去抖连续 3 帧 failsafe 才真正触发保护避免误判。4. 常见问题与排查技巧实录4.1 收不到数据或数据全为 0xFF这是最常见的问题排查顺序如下现象可能原因排查方法完全无数据反相电路没接或接反用示波器看 MCU RX 引脚波形应该是反相后的数据全 0xFF波特率不对确认 100000不是 115200数据全 0x00串口引脚配置错检查 CubeMX 引脚分配偶尔有数据DMA 没启动确认HAL_UART_Receive_DMA被调用数据乱码校验位/停止位错确认 8E2不是 8N1我踩过最坑的一次是反相三极管焊反了波形完全不对查了一下午。建议先用 USB 转串口模块直接接接收机注意反相确认接收机输出正常再查 MCU 侧。4.2 IDLE 中断不触发IDLE 中断不触发通常有三个原因一是没使能UART_IT_IDLE二是HAL_UART_IRQHandler把 IDLE 标志清了但没进你的代码三是串口根本没收到数据总线一直空闲但没起始位。排查方法在 IRQHandler 里加个计数器看进没进中断。如果进了但标志判断失败可能是清标志的宏用错了。F1 和 F4 的清除方式略有不同F1 是读 SR 再读 DRF4 是直接写 ICR 寄存器。HAL 的__HAL_UART_CLEAR_IDLEFLAG宏会处理这些差异直接用就行。4.3 帧解析错位或通道值跳变帧解析错位一般是 DMA 指针计算错误或回卷处理有 bug。我的经验是在 IDLE 中断里把last_pos和current_pos打印出来用串口助手看正常情况应该是每次增加 25 左右一帧 25 字节到缓冲区末尾回卷到 0。如果增量不对就是丢帧或重复。通道值跳变可能是位拼接错误也可能是状态机把残帧当成了有效帧。建议在sbus_decode里加个校验如果帧头帧尾不对直接 return不更新通道。另外可以在状态机里加超时如果 STATE_DATA 停留超过 2ms 没进展强制复位到 STATE_HEADER。4.4 DMA 溢出错误ORE处理即使有 DMA如果主循环处理太慢、DMA 缓冲区太小还是可能溢出。溢出时串口会置 ORE 标志HAL 库会调用HAL_UART_ErrorCallback。你需要在回调里清除标志并重启 DMAvoid HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (__HAL_UART_GET_FLAG(huart, UART_FLAG_ORE)) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(huart, rx_buffer, RX_BUFFER_SIZE); last_pos 0; } }这个回调一定要写否则一次溢出就永久卡死。我实测下来缓冲区开到 128 字节、主循环 1ms 跑一轮基本不会溢出。4.5 实测性能与优化建议在 F103C8T6 72MHz 上实测DMA IDLE 方案下SBUS 解析的 CPU 占用不到 1%主循环有充足时间跑 PID、姿态解算等任务。对比纯中断方案CPU 占用从约 8% 降到 1% 以下效果非常明显。优化建议如果系统里有多路 SBUS 或其他串口可以把解析逻辑封装成结构体每个串口一个实例互不干扰。另外memcpy在中断里虽然快但如果你的编译器优化等级低可以改成手动循环拷贝或者只记录指针、在主循环里解析进一步压缩中断时间。最后分享一个小技巧调试 SBUS 时把 16 个通道值通过另一个串口打印出来格式化成一行用串口助手的“波形显示”功能看能直观看到每个通道的变化比看数字快得多。这个法子帮我省了大量调试时间。
