1. 方案选型与协议拆解
1.1 SBUS 信号长什么样
搞飞控和遥控转发的人应该都被 SBUS 协议折腾过。它最大的优点就是一根线把 16 个通道全部传过来,不用像 PWM 那样一路一路接。我最早接触的时候以为就是个普通 UART,结果直接接 STM32 的 RX 脚死活收不到数据,拿示波器一看才发现波形是反的。这就是 SBUS 的第一个坑:信号电平反向。遥控接收机输出的 SBUS 信号在空闲时是低电平,起始位是高电平,和我们平时看到的 UART 空闲高电平正好相反,所以必须先用反相器把它翻回正常 TTL 电平,再进串口。
SBUS 的速率是 100kbps,数据格式 8E2,也就是 8 个数据位、偶校验、2 个停止位。一帧固定 25 字节:第 0 字节是帧头 0x0F,第 1 到第 22 字节是通道数据,16 个通道每个 11 bit,一共 176 bit,正好塞进 22 字节;第 23 字节是 flags,里面打包了第 17、18 两个数字通道,还有 frame lost 和 failsafe 标志位;第 24 字节是帧尾 0x00。遥控器一般 100Hz 刷新率,也就是说每 10ms 左右就有一帧,算下来每帧实际传输时间大约 3ms,剩下 7ms 总线是空闲的,这个空闲窗口就是我们用 IDLE 中断来切帧的关键。
1.2 为什么不用串口中断逐字节接收
刚开始拿到这个方案时,我也纠结过:SBUS 每字节才 120us 间隔(1 起始位 + 8 数据位 + 1 校验位 + 2 停止位 = 12 bit,100kbps 下就是 120us),如果靠串口 RXNE 中断一个字节一个字节收,一旦主循环里跑点费时间的代码,很容易丢字节。尤其是边打印调试信息边解析时,串口中断响应不过来,就会出现帧头错位、通道值跳变这类问题。
DMA 循环接收的意义就是把“一个一个收”这件事从 CPU 手里彻底拿走。配置好 UART 的 DMA 接收后,串口硬件收到的字节自动写进内存缓冲区,CPU 完全不用管。等一整帧收完了,总线空闲会产生一个 IDLE 中断,此时 CPU 只需要去 DMA 计数器那里算一下新数据有多长,把数据交给状态机解析就行。这样每 10ms 才打断一次,中断负载几乎可以忽略,给其他任务留出了充足的时间。
1.3 状态机在方案里的角色
状态机不是必须的吗?如果不做抗干扰,确实可以“每次 IDLE 就认为收到一帧”。但实际环境里,遥控信号经过长线传输、电机电调干扰,DMA 缓冲区里可能混入噪声、半包甚至两帧拼接的情况。状态机的作用是:不管来的是什么字节流,它只认 0x0F 帧头,然后连续收满 24 个字节,再交给 11bit 解包函数。一旦中途出错,它能自动回到找帧头的状态,不让自己卡死。
所以我最终的方案是三条腿走路:DMA 干脏活累活搬数据,IDLE 中断负责告诉 CPU“有一波数据到齐了”,状态机负责在字节流里把合法帧挑出来。下面我会把 CubeMX 配置、核心代码、常见坑一步步讲清楚。
2. CubeMX 工程配置与硬件准备
2.1 串口参数和 DMA 配置
我使用 STM32F103 系列做示例,其他系列原理一样。在 CubeMX 里选一个带 DMA 的 USART,比如 USART1。波特率填 100000,数据位选 8,校验位选 Even,停止位选 2。这里有个容易懵的地方:STM32 的寄存器里,8 数据位 + 偶校验在 CubeMX 中会体现为 Word Length = 9 bits,因为校验位也在串口帧里面占一位,但实际我们读到的数据寄存器只有 8 位有效数据,硬件已经把校验位处理掉了,所以不要选成 9 位纯数据。
DMA 的设置是重中之重。添加 USART1_RX 的 DMA 请求后,方向一定选 Peripheral To Memory,内存地址递增开启,外设地址固定,数据宽度都是 Byte,模式必须选Circular。循环模式意味着 DMA 装满缓冲区后会自动回卷到开头继续接收,不需要软件干预。或者用 CubeMX 新一点的界面,叫 Circular 模式或 Circular 模式 Disabled 后,DMA 收满指定长度就停了,如果不重启 DMA,后续数据直接丢失,所以一定要确保循环模式开启。
优先级方面,如果系统里还有其他 DMA,建议把 USART1_RX 的优先级设成 High,因为遥控数据实时性要求比较高,被其他 DMA 抢占导致串口 FIFO 溢出就会丢数据。
2.2 中断优先级怎么定
NVIC 里需要使能 USART1 global interrupt。这里有个细节:我们要的是IDLE 中断,不是 DMA 中断,DMA 中断可以完全不开。因为 DMA 循环接收本身不会在每帧结束时触发任何中断,只有我们手动使能串口的 IDLE 中断,才能在总线空闲时被及时唤醒。
中断优先级建议设为 1 或者 2(抢占优先级尽量高)。如果跑 FreeRTOS,要注意串口中断优先级不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY,否则在中断里调用带FromISR后缀的 API 会出问题。我这里把串口中断放在主循环解析方案里,所以优先级给到 2,比较听话。
2.3 反相处理的两种办法
SBUS 电平反转是绕不开的。硬件上,最稳的是用一个三极管或者 74HC04 反相器,把接收机的 SBUS 输出反相后接 STM32 的 RX。很多现成的遥控接收机转接板里面就带了这个反相电路,直接买一块也行。
如果你不想加硬件,部分 STM32 系列串口支持 RX 输入反相。以 F103 为例,可以在初始化之后手动设置huart1.Instance->CR2 |= USART_CR2_RXINV;。但我个人不推荐全靠这个功能,因为不同芯片、不同系列对 RXINV 的支持情况不一样,HAL 库默认不处理这个位,万一某个批次芯片有 bug,排查起来很麻烦。我自己的习惯是硬件反相为主,RXINV 只是应急备选。
CubeMX 生成代码后,在main()里加两行启动代码:
HAL_UART_Receive_DMA(&huart1, sbus_dma_buf, SBUS_DMA_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);顺序很重要:先启动 DMA 接收,再使能 IDLE 中断。如果反过来,可能第一帧数据来了但 DMA 还没准备好,就会表现为前几帧丢失。
3. DMA 循环接收与 IDLE 中断处理实现
3.1 缓冲区大小和环形读指针计算
缓冲区大小我建议取 256 字节,至少是帧长的 10 倍。有人图省事把缓冲设成 25 字节刚刚好一帧,但这样有个隐患:如果主循环忙了几毫秒,下一帧数据来了之后,DMA 会立刻把这 25 字节覆盖掉,旧帧数据还没来得及拷贝就没了。用 256 字节相当于给了自己一个缓冲垫,10ms 一帧的情况下,即使偶尔卡顿 20ms 也还有余量。
DMA 循环接收时,内存写指针一直在往前走,绕过 256 字节边界后回卷。我们如何知道“这次新收到了哪些数据”?核心是利用 DMA 的剩余计数寄存器。STM32 的 DMA 每个通道都有一个 NDTR,记录还剩多少个数据没传。因为传输方向是外设到内存,NDTR 每次接收一个字节就减一,所以当前 DMA 写指针的位置可以用下面公式算出来:
uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t current_index = SBUS_DMA_BUF_SIZE - remain; // 下一个要写入的位置比如缓冲区 256,现在 remain 是 180,说明已经写了 76 字节,下一个字节会写到内存偏移 76 处。DMA 是循环的,所以 current_index 在 0~255 之间跳转。计算两次 IDLE 之间新收了多少数据,不需要直接比较两个计数器,而是用环形差:
uint16_t diff = (current_index + SBUS_DMA_BUF_SIZE - last_index) % SBUS_DMA_BUF_SIZE;last_index 是上一次 IDLE 时的 current_index。这个 diff 就是从上一次空闲到现在,串口一共收到的字节数。由于 SBUS 两帧间隔至少是 7ms,正常情况下 diff 应该等于 25 左右;如果噪声多,可能多一点,如果接收机没输出,可能为 0,状态机会自动忽略。
3.2 IDLE 中断处理函数怎么写
这里要注意 HAL 库的中断模型。传统写法是在USART1_IRQHandler里自己判断 IDLE 标志,然后清标志,再调用HAL_UART_IRQHandler让 HAL 帮忙处理其他错误中断。完整的代码是这样的:
extern DMA_HandleTypeDef hdma_usart1_rx; volatile uint8_t sbus_dma_buf[SBUS_DMA_BUF_SIZE]; volatile uint16_t sbus_last_index = 0; volatile uint16_t sbus_new_data_len = 0; volatile uint8_t sbus_data_ready = 0; void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t curr = SBUS_DMA_BUF_SIZE - remain; uint16_t received = (curr + SBUS_DMA_BUF_SIZE - sbus_last_index) % SBUS_DMA_BUF_SIZE; if (received > 0) { // 这里不直接解析,只把长度记下来,数据还在 DMA 缓冲里 sbus_new_data_len = received; sbus_last_index = curr; sbus_data_ready = 1; } } HAL_UART_IRQHandler(&huart1); }中断里只做标志记录,不做状态机解析,这是为了缩短中断时间。解析放到主循环里,即使解析慢一点也不会影响中断响应。__HAL_UART_CLEAR_IDLEFLAG这个宏对不同系列处理不同,F1 上会写 SR 寄存器,H7 上会处理 ISR/ICR,用 HAL 宏可以避免移植问题。
需要提醒的是,HAL_UART_IRQHandler里本身也会检查 IDLE 标志,如果我们在前面已经把 IDLE 标志清掉,它就不会再触发RxEventCallback,所以不用担心两边重复处理。
3.3 从环形缓冲区安全取数据
IDLE 只标记了长度,真正要用的数据还在 DMA 缓冲区里。因为 DMA 是循环的,数据可能从某个偏移开始,也可能跨越缓冲区末尾绕回开头。主循环里做切片拷贝时,要处理这种回绕。
比如 received 等于 25,last_index 是 240,那么这 25 字节分布在偏移 240~255 共 16 字节,再从 0~8 共 9 字节。直接 memcpy 会越界,所以分成两段拷贝:
void sbus_copy_new_data(uint8_t *dst, uint16_t start, uint16_t len) { uint16_t first = len; if (start + len > SBUS_DMA_BUF_SIZE) { first = SBUS_DMA_BUF_SIZE - start; } memcpy(dst, (uint8_t *)&sbus_dma_buf[start], first); if (first < len) { memcpy(dst + first, (uint8_t *)&sbus_dma_buf[0], len - first); } }我在实际项目中,会把 IDLE 中断里的 received 限制在一个合理上限,比如 50。如果某次 diff 超过 50,说明可能漏处理了很多帧,这种情况直接丢弃前面的旧数据,只留下最后一个有效窗口内的数据,避免状态机一次吞太多无用字节。判断条件大致是:
if (received > SBUS_MAX_CHUNK) { received = SBUS_MAX_CHUNK; sbus_last_index = curr; }但要注意,如果强行裁剪,可能丢掉半个帧头。更稳的办法是把 received 原样交给状态机,状态机自己会等 0x0F 重新同步,所以稍微多收点噪声问题不大。只要中断里不解析,主循环处理几十字节也就几十微秒而已。
4. 状态机解析 SBUS 帧
4.1 有限状态机的数据结构
状态机本质上就两件事:找帧头,收数据。所以我用一个非常简单的小结构体:
typedef enum { SBUS_STATE_SYNC = 0, SBUS_STATE_DATA } sbus_state_t; typedef struct { sbus_state_t state; uint8_t idx; uint8_t frame[SBUS_FRAME_LEN]; } sbus_parser_t;初始化时把 state 置成 SYNC,idx 清零。主循环拿到新数据后,逐个字节喂给状态机:
void sbus_parser_feed(sbus_parser_t *p, uint8_t byte) { switch (p->state) { case SBUS_STATE_SYNC: if (byte == 0x0F) { p->frame[0] = byte; p->idx = 1; p->state = SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: p->frame[p->idx++] = byte; if (p->idx >= SBUS_FRAME_LEN) { sbus_decode_frame(p->frame); p->state = SBUS_STATE_SYNC; p->idx = 0; } break; default: p->state = SBUS_STATE_SYNC; p->idx = 0; break; } }这段代码看起来简单,但它有几个隐藏的防御点。比如如果在 DATA 状态中途出现其他乱码,但长度还没到 25,状态机不会自我纠正;如果一帧数据被电调干扰劈成两半,中间多余字节会让 idx 错位。实际使用中,我会在 DATA 状态加一个超时计数器,比如超过 20ms 还没凑满 25 字节就强制回到 SYNC,重新等帧头。这个超时判断不能在字节喂养函数里做,而是在主循环里周期检查:
if (parser.state == SBUS_STATE_DATA && (HAL_GetTick() - last_byte_time > 20)) { parser.state = SBUS_STATE_SYNC; parser.idx = 0; }因为 SBUS 是 100Hz 刷新,正常一帧不会超过 10ms,20ms 超时足够宽容,又能防止状态机卡死。
4.2 对 0x0F 帧头的抗干扰处理
只凭一个 0x0F 就认为是帧头,在干净的环境下没问题,但在电机电调附近很容易误触发。SBUS 本身没有 CRC 校验,唯一能用的校验信息是帧尾 0x00,以及 flags 位。我做的强化验证是:如果收到完整 25 字节,先检查frame[24] == 0x00,如果不等于 0,说明这个“帧头”是噪声假货,丢弃整帧,并且继续回到找帧头状态。
一些接收机尾字节不一定是 0x00,尤其带扩展协议的设备。我查过几家遥控器,Futaba SBUS 标准确实固定 0x00,所以默认按 0 校验是安全的。如果你用的接收机输出异常,可以把这一行检查做成条件编译或宏开关,方便现场调整。
还可以加一个简单的时间校验:在 IDLE 数据进入状态机之前,记录这次数据的系统 tick,解析完一帧后判断与上一帧的间隔是否在 5ms 到 15ms 之间。如果不在,大概率是干扰伪造帧,直接丢弃。做飞控时我强烈建议加上这个校验,因为遥控器的 100Hz 定时是稳定的,干扰产生的 0x0F 不会恰好落在这个窗口内。
4.3 16 通道 11bit 数据解包
SBUS 的 22 字节通道数据是按位拼接的,不是每个通道一两个字节,所以解包最核心的算法是“从字节流的任意 bit 偏移处取 11 bit”。我写过几种实现,最不容易错的是一口气读 3 字节,然后右移偏移位,再按 0x7FF 掩码取 11 位。
uint16_t sbus_get_11bit(const uint8_t *buf, int channel) { int bit_pos = 1 + channel * 11; // 跳过帧头,从 bit 8 开始 int byte_idx = bit_pos / 8; int bit_off = bit_pos % 8; uint32_t v = buf[byte_idx] | (buf[byte_idx + 1] << 8) | (buf[byte_idx + 2] << 16); return (uint16_t)((v >> bit_off) & 0x07FF); }注意这里偏移计算里加的1是因为帧头占用第 0 字节,真正的通道数据从第 1 字节开始。每个通道占 11 bit,所以第 0 通道从 bit 8 开始取,第 15 通道从 bit 173 开始取,正好能取满 22 字节。最后一个通道读取时需要 buf[byte_idx + 2],其实就是 flags 字节,但因为 mask 只取 11 位,多读出来的高位会在& 0x07FF时被丢掉,不会污染结果。
解析完整帧后,我把结果填到一个结构体里,直接给上层飞控逻辑用:
typedef struct { uint16_t ch[16]; uint8_t ch17; uint8_t ch18; uint8_t frame_lost; uint8_t failsafe; } sbus_channels_t; void sbus_decode_frame(const uint8_t *frame) { sbus_channels_t out; for (int i = 0; i < 16; i++) { out.ch[i] = sbus_get_11bit(frame, i); } uint8_t flags = frame[23]; out.ch17 = flags & 0x01; out.ch18 = (flags >> 1) & 0x01; out.frame_lost = (flags >> 2) & 0x01; out.failsafe = (flags >> 3) & 0x01; }如果不追求极致内存,16 通道的 11bit 值可以直接用 uint16_t 数组存,后续软件要映射 PWM 或发送到地面站时直接使用。通道值范围一般是 0~2047,遥控器油门中位不一定恰好是 1024,要在上层根据遥控器类型做校准。
4.4 主循环里如何调度解析
整体调度我放在主循环里:
uint8_t temp_buf[SBUS_DMA_BUF_SIZE]; while (1) { if (sbus_data_ready) { sbus_data_ready = 0; sbus_copy_new_data(temp_buf, (sbus_last_index - sbus_new_data_len + SBUS_DMA_BUF_SIZE) % SBUS_DMA_BUF_SIZE, sbus_new_data_len); for (uint16_t i = 0; i < sbus_new_data_len; i++) { sbus_parser_feed(&parser, temp_buf[i]); } sbus_new_data_len = 0; } }准备一个temp_buf的目的是防止主循环解析过程中 DMA 继续往同一个缓冲区写数据,读一半被覆盖。拷贝 25~50 字节耗时极短,可以接受。如果你跑 RTOS,可以把这块放到一个 1ms 周期执行的低优先级任务里,逻辑一样。
5. 调试方法、常见问题与优化心得
5.1 怎么确认串口波形正常
调试 SBUS 第一步永远是查波形。用逻辑分析仪抓接收机输出的 SBUS 引脚,可以看到一串 25 字节的 UART 波形,波特率约 100kHz,空闲时是低电平,这就是“反相”特征。再用示波器看 STM32 的 RX 引脚,如果波形空闲时是高电平,说明反相已经处理好,可以进入软件调试。
没有逻辑分析仪也不要紧,可以直接用串口助手接收 TTL 反相后的数据,波特率 100000、8E2,打开十六进制显示。正常每 10ms 左右收到一串 AA(取决于数据内容),其中第 0 字节固定是 0F。如果一串乱码且第 0 字节不是 0F,大概率波特率或校验位没配对。这里注意,很多 USB-TTL 的波特率上限和校验支持都没问题,但必须先把反相弄好,否则根本解不出来。
5.2 数据丢帧和通道值乱跳怎么查
我遇到的第一类问题是“偶尔丢一帧”。排查下来基本是两个原因:一是串口优先级太低,被其他中断长时间打断;二是 DMA 缓冲区太小。解决办法是优先查 NVIC 配置,再看缓冲区是不是只有 25 字节,直接扩到 256 字节,丢帧问题立刻缓解。
第二类问题是“通道值乱跳、Failsafe 频繁触法”。这个多半是 0x0F 误同步导致解析错位。我建议优先检查帧尾 0x00 校验,以及两帧间隔时间校验。如果乱跳出现在电机大油门瞬间,大概率是电源干扰,接收机到飞控之间换成双绞线或者加磁环更有效。软件层面能做的只是尽量过滤错帧,但硬件抗干扰不解决,解析得再对也救不了。
第三类问题是“一切正常但偶发第一帧解析错误”。比如开发板上电瞬间 DMA 缓冲区里有残留数据,IDLE 第一次触发时,received 包含了上电以来的随机字节。状态机会先找 0x0F,如果残留里恰好有 0x0F 就可能出一帧错值。解决办法是上电后先清空 DMA 缓冲区,把 last_index 指向当前 DMA 写位置,并且等收到至少 3 帧完整数据后再把解析结果标记为有效。
5.3 HAL 库新旧版本差异
我最初参考网上的代码,用的是HAL_UARTEx_ReceiveToIdle_DMA,配合HAL_UARTEx_RxEventCallback,这个写法在较新的 HAL 库中支持得很好,CubeMX 里也能直接配置。但换成老版本库就用不了,于是我又回到“自己处理 IDLE + 读 DMA 计数”的方式。这个方式没有 HAL 版本依赖,只要能拿到 DMA 句柄就能用,更适合长期维护。
如果你用的是 H7 系列,要知道 DMA 支持 burst 模式,配置要点更多。上面这套代码在 F1、F4、G0 上都能跑,H7 上要注意 DMA 时钟和 memory 类型,其他逻辑相同。
5.4 实际操作中的小技巧
- 解析函数里不要加 printf,否则调试输出会反过来拖慢解析。可以在主循环里用一个计数器,每 1 秒打印一次接收帧数,判断是否稳定在 100 左右。
- 状态机的 frame 数组最好用普通栈变量,不要声明为 volatile,否则编译器会禁止很多优化。DMA 缓冲区和标志位才需要 volatile。
- 如果在中断里调用了
HAL_UART_Receive_DMA二次启动,要注意锁冲突。推荐的做法是只在初始化时启动一次,后续完全靠循环模式自动续传,避免反复重启带来的窗口期。
5.5 后续还能怎么扩展
这套“DMA 循环接收 + IDLE 中断 + 状态机”不止能解 SBUS,还可以直接套用到 MAVLink、XModem、Modbus 这类固定或半固定帧协议。只要把帧头、长度、超时校验换掉,其他结构不用动。如果你接下来要做 SBUS 转 PWM、SBUS 转 MAVLink,甚至多路 SBUS 信号同时接入,原理都是一样的,把缓冲区做成分组模式,一个 DMA 通道对应一个解析器即可。
我个人最深的体会是:不要把所有的逻辑都堆在 DMA 中断里。DMA 和 IDLE 只负责“把数据完整地搬进来”,协议解析必须交给状态机慢慢磨。每次想提高处理速度时,先看一眼是不是在中断里干了太多不必要的事。SBUS 解析做到后面其实没什么稀奇,但把这套结构和抗干扰思路玩熟了,许多串口协议项目都能举一反三。