做遥控模型、飞控、机器人遥控底盘的朋友应该都有体会,SBUS 是目前接收机输出通道数据最常用的串口协议之一,但真正要在 STM32 上把这路信号吃干净,你会连续碰到好几道坎:信号是反相的、波特率不是常规的 115200、帧结构又紧凑,稍微没处理好就是满屏乱码或者一进中断就卡死。这篇帖子把我最终稳定跑通的这套方案完整记录了下来——HAL 库下 DMA 循环接收 + IDLE 中断 + 状态机解析 SBUS,从 CubeMX 配置、底层接收机制到 16 通道解包和踩坑实录,一次讲透。适合正在做飞控、遥控车、机器人底盘,以及任何想把遥控接收机数据接进 MCU 的开发者参考。
1. 先把 SBUS 协议和接收方案说清楚
1.1 SBUS 信号到底是什么
SBUS 本质是一路反相的 UART 信号,物理层参数非常特殊:波特率 100000,8 个数据位 + 1 位偶校验 + 2 位停止位,缩写就是 8E2。一个完整数据帧固定 25 个字节,按 14ms 周期(部分高速接收机是 7ms)连续往外发。25 个字节的布局是这样的:第 0 字节是帧头 0x0F,第 1 到第 22 字节装载 16 个通道的 11bit 数值,第 23 字节是状态标志位,第 24 字节是帧尾 0x00。
这里有两个非常容易误解的点。第一,100000 这个波特率不是常用值,很多上位机软件默认下拉里根本没有,必须手动填;一旦你按 115200 去收,收到的就是完全没规律的垃圾数据。第二,既然是反相 UART,那么空闲状态下信号线是低电平,帧数据的起始位表现为上升沿,这和普通 TTL 串口正好相反。很多人第一步就栽在这里,示波器一看波形全对,接进单片机就是收不到。
标志位字节我后面解包时还会详细说,这里先给个速查表:
| 位 | 含义 |
|---|---|
| bit0 | 通道 17 的有效值标志 |
| bit1 | 通道 18 的有效值标志 |
| bit2 | 帧丢失标志,置 1 表示接收机信号中断过 |
| bit3 | 失控保护(failsafe)触发标志 |
每个通道 11bit,取值范围 0~2047,中位值 1024。也就是说遥控杆在中立位置时,对应通道解出来的原始值应该在 1024 附近,而不是 0 也不是 2047,这个数值可以作为后面调试解包是否正确的第一判断依据。
1.2 为什么是 DMA + IDLE 中断 + 状态机这套组合
我最早做 SBUS 解析时用的是最简单的中断接收:每个字节进一次 USART 接收中断,在中断里把字节塞进数组。28 万波特率换算下来大概每 120 微秒就来一次中断,看起来不密集,但 SBUS 是 25 字节的完整帧,如果只是简单累加,一旦中间丢一个字节,后面所有通道全部错位,而且你不知道什么时候该判定一帧结束。
后来换成“定时器超时判帧”,接收中断里每收到一个字节就重置定时器,定时器溢出认为一帧结束。这能解决问题,但每 120 微秒一次中断依然占着 CPU,在飞控这种要跑姿态解算和控制环的场景里,纯粹是浪费。
最终稳定下来的方案是三件套配合:
- DMA 循环接收:数据从外设到内存的搬运完全由 DMA 完成,CPU 零参与。UART 收到一个字节,DMA 自动写进缓冲区,写满缓冲区后自动绕回开头继续写,形成环形缓冲。
- IDLE 中断:UART 空闲中断在接收线上空闲超过一个字节时间后触发。SBUS 每帧之间至少有 4ms 以上的空闲,用 IDLE 中断来标记“一帧发完了”,比定时器更精准也更省事。
- 状态机:收到一个完整帧后,不盲目相信数据,而是逐字节喂给状态机,先匹配帧头 0x0F,再收集满 25 字节,最后校验帧尾 0x00,全部通过才允许解包。
这套组合的真实优势在于:正常接收时 CPU 只在每帧结束时被 IDLE 中断打扰一次,也就是大约 14ms 才进一次中断,其余时间 DMA 默默搬运,状态机在主循环或者中断里随便消费数据都不慌。
2. 硬件准备与 CubeMX 初始化配置
2.1 信号反相:最容易翻车的一步
SBUS 信号是反相的,而 STM32 的 USART 在 F103 这类芯片上并没有硬件反相功能(F4/F7/H7 部分型号的 USART_CR2 里有 TXINV/RXINV,但 F103 没有),所以必须在硬件上做一次反相,把 SBUS 信号转成正常 TTL 电平再送进 MCU 的 RX 引脚。
最简单的反相电路是一只 NPN 三极管加两个电阻,SBUS 信号从基极进,集电极上拉到 3.3V,从集电极输出接到单片机 RX。三极管在这里就是反相器:输入高电平(SBUS 数据位 1)时三极管导通,集电极被拉到低;输入低电平(SBUS 数据位 0 或空闲)时三极管截止,集电极被上拉到高。这样就把反相 UART 掰成了正常 UART。
我之前手边没有三极管,也用过 74HC04 这种六反相器芯片,效果一样,就是多占点板子面积。还有人直接把接收机的 SBUS 信号接到普通空闲的 GPIO,用 IO 翻转模拟反相接收,但这对时序要求太苛刻,不建议。
调试时最好用示波器或逻辑分析仪确认一下反转后的波形。正常 SBUS 空闲是高电平,出现帧时先是一个下降的起始位,然后数据位按 8E2 排列。如果示波器看到空闲时是低电平,说明信号还没反相,接进单片机前必须处理。
提示:某些飞控板或接收机转接板会自带反相电路,板上会标注 SBUS_INV 或者直接写上“SBUS”丝印。如果是从这类板子上飞线出来,先查原理图确认是否已经反相,避免重复反相。
2.2 CubeMX 里必须勾对的配置项
打开 CubeMX 新建工程后,UART 和 DMA 的配置直接决定后面代码能不能跑通。我以 USART1 和 STM32F103 为例,把关键配置项列出来:
串口参数:
- Baud Rate:手动输入 100000,注意不是 115200。
- Word Length:9 bits(包括校验位),对应 8 位数据 + 1 位偶校验。
- Parity:Even。
- Stop Bits:2。
- Mode:异步收发或者只接收,按需选择,解析 SBUS 通常只收不发。
DMA 参数,给 USART1_RX 添加一个 DMA 通道:
- Direction:Peripheral To Memory。
- Mode:Circular,循环模式是整套方案的地基。
- Peripheral Increment:Disable。
- Memory Increment:Enable。
- Data Width:Peripheral 和 Memory 都选 Byte。
- 如果 CubeMX 版本里有 Continuous Requests 选项,保持 Enable。这个选项保证 DMA 在每次传输完成后持续响应外设请求,配合循环模式才能不间断接收。
中断参数:
- USART1 global interrupt:Enable。IDLE 标志位要在这里处理。
- DMA 的全局中断:如果你打算完全靠 IDLE 判帧,DMA 中断其实可以不开,后面我会解释为什么。
时钟这块要单独提一句。F103 的 USART1 挂在 APB2 总线上,典型主频下 APB2 是 72MHz。串口波特率计算是 USARTDIV = fck / (16 × baud),代入就是 72000000 / (16 × 100000) = 45,整数,没有分频误差,所以 100000 波特率在 F103 上是可以精确实现的。你要是换个主频或者挂在 APB1 上,记得自己重新算一遍,出现非整数分频时波特率会有偏差,SBUS 这种对时序敏感的协议就可能出问题。
2.3 生成工程后的初始化顺序与收包启动
CubeMX 生成的代码里,初始化顺序默认是 MX_GPIO_Init、MX_DMA_Init、MX_USART1_UART_Init。这个顺序不能乱改,因为 HAL_UART_MspInit 内部会引用已经配置好的 DMA 句柄,如果 DMA 初始化在 UART 之后执行,UART 的 DMA 通道就没有被正确关联,HAL_UART_Receive_DMA 启动时会直接返回错误。
初始化的核心代码这样写:
uint8_t sbus_rx_buffer[64]; MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); if (HAL_UART_Receive_DMA(&huart1, sbus_rx_buffer, 64) != HAL_OK) { Error_Handler(); }注意 HAL_UART_Receive_DMA 只需要调用一次。循环模式下 DMA 会持续把新收到的字节写进缓冲区,不需要也不应该在每次 IDLE 中断后重新调用。重复调用反而可能让 DMA 停在初始状态,丢数据。
3. DMA 循环接收 + IDLE 中断:核心代码实现
3.1 缓冲区设计与 DMA 计数法
缓冲区大小我选了 64 字节,不是随便拍的。一个 SBUS 帧 25 字节,如果某次处理不及时或者前一次 IDLE 中断刚好错过了,缓冲区里最多可能积压两帧,也就是 50 字节。64 既足够容纳,又不会浪费内存。如果你在资源更紧张的芯片上做,32 字节也能跑,但余量太小,中断稍微晚一点就会覆盖数据;反过来 RAM 充裕的话用 128 更稳,成本只是多几十字节。
判断当前收到了多少数据,用的是 DMA 计数器。DMA 在循环模式下有一个剩余传输计数器,表示缓冲区里还有多少字节没被写入。初始值是缓冲区大小,每写入一个字节减一,写满后绕回缓冲区开头继续写,计数器也重新从缓冲区大小开始递减。
所以“本次 IDLE 时刻新增的数据长度”等于:
uint16_t rx_len = (uint16_t)(SBUS_DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx));这个计算有前提:上一次消费完数据后,必须把 DMA 计数器对应的“已读位置”记录下来,而不是把计数器清零。更准确地说,我们要维护一个读索引,每次从读索引位置开始,连续读 rx_len 个字节。
3.2 IDLE 中断处理与缓冲区读取
IDLE 标志的处理位置最好放在 USART 中断服务函数里。HAL 库本身不处理 IDLE 标志(至少在经典版本里不处理),所以我们要在调用 HAL_UART_IRQHandler 之后手动判断和清标志。
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t dma_counter = (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t rx_len = (uint16_t)(SBUS_DMA_BUFFER_SIZE - dma_counter); if (rx_len > 0) { for (uint16_t i = 0; i < rx_len; i++) { uint8_t byte = sbus_rx_buffer[sbus_read_index]; sbus_read_index = (sbus_read_index + 1) % SBUS_DMA_BUFFER_SIZE; SBUS_Parse_Feed(byte); } } } }几个容易踩的细节:
清标志和读计数器必须紧挨着,中间不要插入耗时操作。IDLE 中断触发后,如果处理得太慢,DMA 可能继续写入了新数据,计数器变化后你读到的长度就会偏大,把下一帧的开头也卷进来。不过 SBUS 帧间空闲通常有 4~11ms,在这么短的中断处理里一般不会发生,但养成好习惯没坏处。
rx_len 为 0 时一定要跳过。某些场景下 UART 线上噪声也会触发 IDLE 中断,此时并没有新数据,读索引不动,不会产生问题。如果不去判断长度,空转一次也无所谓,但会引起后续状态机误判,所以干脆用长度兜底。
读索引是环形缓冲的核心。DMA 写满 64 字节后会绕回开头,所以读索引超过 63 时也需要取模回到 0,否则读到的就是旧数据。
3.3 和 HAL 自带 RxEvent 回调的取舍
新版 HAL 库(如 1.12 之后)提供了 HAL_UARTEx_ReceiveToIdle_DMA 函数和 HAL_UARTEx_RxEventCallback 回调,它把 IDLE 检测直接封装进了 HAL 内部,代码更简洁:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, sbus_rx_buffer, SBUS_DMA_BUFFER_SIZE); void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { // Size 就是本次有效数据长度 // 注意这里 Size 可能为 0,必须判断 // 处理完数据后需要重新调用 ReceiveToIdle_DMA } }这个方案写起来省事,但有两个地方要注意。一是每次回调后要重新调用 HAL_UARTEx_ReceiveToIdle_DMA 才能继续接收;二是回调里拿到的 Size 是相对本次启动时缓冲区首地址的偏移,如果你的业务逻辑复杂,反而不如手动管理读索引直观。
我的建议是:如果你不想跟 HAL 内部状态机扯皮,用经典的手动 IDLE + 循环 DMA 是最透明的,出了问题也好定位。如果你用的是新版 HAL 并且确认版本行为熟悉,RxEvent 回调可以少写几行代码。两个方案我都跑通过,本文后面以手动方式展开,因为它的原理对排查问题更有帮助。
4. 状态机解析与 16 通道数据解包
4.1 状态机设计:三态足够用
收到一批字节后,最忌讳的做法是直接认定 buffer[0] 是帧头、buffer[24] 是帧尾就去解包。因为 DMA 缓冲区是环形的,而且无线环境里偶尔会有毛刺导致多字节或者少字节,一帧数据的开头不一定恰好落在缓冲区起点。
状态机的好处是逐字节扫描,不依赖数据在缓冲区里的绝对位置。我的实现用三个状态:
typedef enum { SBUS_STATE_WAIT_HEADER = 0, SBUS_STATE_IN_FRAME, SBUS_STATE_TAIL_CHECK } SBUS_State_t; static SBUS_State_t sbus_state = SBUS_STATE_WAIT_HEADER; static uint8_t sbus_frame[25]; static uint8_t sbus_frame_index = 0;WAIT_HEADER 状态下,每个进来的字节都跟 0x0F 比较,相等则把这字节当作帧头存入缓冲区,进入 IN_FRAME 状态,否则继续等待。IN_FRAME 状态下,后续字节依次存入帧缓冲区,索引累加,当索引达到 25 时进入 TAIL_CHECK。TAIL_CHECK 里检查缓冲区最后一个字节是不是 0x00,是就解包,不是就丢弃整帧,重新回到 WAIT_HEADER。
void SBUS_Parse_Feed(uint8_t byte) { switch (sbus_state) { case SBUS_STATE_WAIT_HEADER: if (byte == 0x0F) { sbus_frame[0] = byte; sbus_frame_index = 1; sbus_state = SBUS_STATE_IN_FRAME; } break; case SBUS_STATE_IN_FRAME: sbus_frame[sbus_frame_index++] = byte; if (sbus_frame_index >= 25) { sbus_state = SBUS_STATE_TAIL_CHECK; } break; case SBUS_STATE_TAIL_CHECK: if (sbus_frame[24] == 0x00) { SBUS_DecodeChannels(); } sbus_state = SBUS_STATE_WAIT_HEADER; break; default: sbus_state = SBUS_STATE_WAIT_HEADER; break; } }这里有个值得优化的细节:如果 IN_FRAME 状态收集满 25 字节后发现帧尾不是 0x00,说明这个 0x0F 可能是噪声撞上的,不是真帧头。此时如果当前这个字节恰好又是 0x0F,可以直接把它当作新帧头重新开始,而不是丢进 WAIT_HEADER 等下一个字节。不过对 SBUS 这种带校验的协议来说,漏解一帧影响极小,这个优化属于锦上添花。
4.2 22 字节还原 16 路 11bit 通道值
解包是 SBUS 解析里最有成就感也是最容易写错的部分。16 个通道、每个 11bit,合计 176bit,正好塞进 22 个字节里。因为 11 不是 8 的整数倍,通道与通道之间是连续位拼接,没有对齐,所以提取每个通道时都需要跨字节移位。
通道 1 从第 0 bit 开始,取 byte0 的 8 bit 和 byte1 的低 3 bit;通道 2 紧接着从第 11 bit 开始,取 byte1 的高 5 bit 和 byte2 的低 6 bit,以此类推。直接写移位代码最直观:
uint16_t sbus_channels[16]; void SBUS_DecodeChannels(void) { uint8_t *d = &sbus_frame[1]; // 帧头后才是通道数据 sbus_channels[0] = (uint16_t)((d[0] | d[1] << 8) & 0x07FF); sbus_channels[1] = (uint16_t)((d[1] >> 3 | d[2] << 5) & 0x07FF); sbus_channels[2] = (uint16_t)((d[2] >> 6 | d[3] << 2 | d[4] << 10) & 0x07FF); sbus_channels[3] = (uint16_t)((d[4] >> 1 | d[5] << 7) & 0x07FF); sbus_channels[4] = (uint16_t)((d[5] >> 4 | d[6] << 4) & 0x07FF); sbus_channels[5] = (uint16_t)((d[6] >> 7 | d[7] << 1 | d[8] << 9) & 0x07FF); sbus_channels[6] = (uint16_t)((d[8] >> 2 | d[9] << 6) & 0x07FF); sbus_channels[7] = (uint16_t)((d[9] >> 5 | d[10] << 3) & 0x07FF); sbus_channels[8] = (uint16_t)((d[11] | d[12] << 8) & 0x07FF); sbus_channels[9] = (uint16_t)((d[12] >> 3 | d[13] << 5) & 0x07FF); sbus_channels[10] = (uint16_t)((d[13] >> 6 | d[14] << 2 | d[15] << 10) & 0x07FF); sbus_channels[11] = (uint16_t)((d[15] >> 1 | d[16] << 7) & 0x07FF); sbus_channels[12] = (uint16_t)((d[16] >> 4 | d[17] << 4) & 0x07FF); sbus_channels[13] = (uint16_t)((d[17] >> 7 | d[18] << 1 | d[19] << 9) & 0x07FF); sbus_channels[14] = (uint16_t)((d[19] >> 2 | d[20] << 6) & 0x07FF); sbus_channels[15] = (uint16_t)((d[20] >> 5 | d[21] << 3) & 0x07FF); }每一行最后都 & 0x07FF,是为了把多移进来的高位截掉,只留低 11 位。这个掩码不能省,少了它通道值会混入下一段数据的位。
写完后验证方法很简单:遥控器油门推到最大,对应通道解出来应接近 2047;推到最小接近 0;中立位置接近 1024。如果解出来的通道值乱跳或者明显不符合这个规律,先检查移位长度的规律,再检查帧缓冲区起始指针是不是偏了一位。
4.3 失联检测与输出映射
除了 16 个通道,状态标志位也要利用起来。接收机一旦检测到信号丢失或者触发失控保护,会在第 23 字节里把相应位置 1。解析时顺便读出来,飞控就可以据此切安全模式:
uint8_t sbus_frame_lost = 0; uint8_t sbus_failsafe = 0; uint8_t flags = sbus_frame[23]; if (flags & 0x01) { sbus_channels[16] = 2047; // 通道17开启 } else { sbus_channels[16] = 0; } if (flags & 0x02) { sbus_channels[17] = 2047; // 通道18开启 } else { sbus_channels[17] = 0; } sbus_frame_lost = (flags & 0x04) ? 1 : 0; sbus_failsafe = (flags & 0x08) ? 1 : 0;失联检测不能只靠 frames_lost 标志,还要加一层软件超时。因为接收机直接断电或者信号彻底断了之后,UART 线上再也不会来数据,IDLE 中断也不会再触发,如果你只在解析到帧时才刷新状态,那主循环里会一直用最后一帧的旧数据。标准做法是用系统节拍记录最后有效帧时间:
uint32_t sbus_last_frame_tick = 0; // 在 SBUS_DecodeChannels 里 sbus_last_frame_tick = HAL_GetTick(); // 主循环里做超时判断 if ((HAL_GetTick() - sbus_last_frame_tick) > 100) { sbus_signal_lost = 1; } else { sbus_signal_lost = 0; }超时阈值我一般设 100ms。SBUS 正常是 14ms 一帧,偶尔丢一两帧正常,但超过 100ms 还没数据,基本可以认定链路断了。
最后把原始通道值映射成你要用的量。比如接舵机要转成 PWM 脉宽,SBUS 原始值 0~2047 对应脉宽 1000~2000us,换算公式是pulse_us = 1000 + (ch * 1000 / 2047),并且输出前做限幅,防止越界值打坏舵机。如果你做的是飞控,可能更关心把 0~2047 归一化到 -100%~+100% 或者 1000~2000 的纯量值,这一步按具体业务需求来。
5. 实测踩坑与调试心得
5.1 串口工具看不到数据或全是 0x00
这是反相没处理好的典型症状。SBUS 反相信号接进不支持反相的串口工具,你会看到两种现象:完全收不到数据,或者收到一堆规律性的 0x00/0xFF。
排查时先用示波器量 RX 引脚:正常 TTL 空闲是高电平,SBUS 反相同样空闲是低电平。如果接到 MCU 前的信号空闲已经是低电平,说明这里就是反相信号,必须在前面加反相电路。
还要确认串口工具的波特率是不是真设成了 100000。很多工具下拉框只有 115200,需要手动输入。用 SSCOM、MobaXterm 这类支持自定义波特率的工具,设成 100000、8 位数据、偶校验、2 位停止,才能看到正常的 0x0F 帧头。
5.2 IDLE 中断收到了但长度不对
我之前遇到过一种情况:IDLE 中断每次都进,但解出来的 rx_len 忽大忽小,甚至会超过一帧长度。
原因多半是清 IDLE 标志和读 DMA 计数器之间插了其他代码。比如你在中断里先做了串口打印、状态机初始化之类的操作,DMA 这段时间里又写入了新字节,计数器变化后长度自然就不准。解决方法是把读计数器放在清标志后的第一件事,并且在读取完成前不调用任何可能阻塞的函数。
另一个隐蔽问题是 DMA 中断没关。如果你在 CubeMX 里把 DMA 全局中断也开了,DMA 半满、全满回调会和 IDLE 中断交替执行,一旦回调里不小心重新调用了 HAL_UART_Receive_DMA,DMA 会被复位,计数器清零,IDLE 之后读到的 rx_len 就是 0。我的做法是只开 USART 中断,DMA 中断保持关闭,反正我们从来不依赖半满/全满事件。
5.3 一直进 ErrorCallback 或 DMA 停止
用 HAL_UART_Receive_DMA 做循环接收时,如果 UART 发生过载错误(ORE),HAL 的 ErrorCallback 会被触发。默认情况下,出错后 UART 接收状态可能停在 BUSY_RX,DMA 也可能停在半路上,之后再也不收数据。
出现这个问题的常见原因有两个:一是 UART 进过多次噪声干扰,二是中断处理太慢,导致下一个字节来的时候上一字节还没被拿走。对 SBUS 来说,接收机信号质量正常时几乎不会触发 ORE,但飞行环境里电机电调干扰大,偶尔来一下还是可能。
稳妥的处理是在 ErrorCallback 里做一次恢复:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(&huart1, sbus_rx_buffer, SBUS_DMA_BUFFER_SIZE); } }注意不要频繁调用 HAL_UART_Receive_DMA。如果确认数据流正常,只在 ErrorCallback 里恢复一次就够。
5.4 调试工具推荐与验证流程
USB 转 TTL 模块是基础工具,但要选支持自定义波特率的。CP2102、CH340 一般都能手动设置 100000,接上已经反相后的信号,在电脑上就能看到原始 SBUS 帧。先用串口助手确认能够稳定出现 0x0F 开头、0x00 结尾的 25 字节帧,再进行单片机侧的调试,能省很多时间。
逻辑分析仪也很有用,尤其当你怀疑时序问题时。选 100000 波特率解码 UART,部分软件支持信号反相选项,直接勾选“反相”就能解码原始 SBUS 信号,不用额外接反相电路。我在调试时经常用逻辑分析仪同时抓 RX 引脚和解码结果,一眼能看出是哪一级出了问题。
最后建议在单片机里加一个调试通道,把原始 25 字节以十六进制格式通过另一个串口发到电脑上,跑几分钟对比一下有没有错帧、少帧。确认底层无误后,再在状态机解包处加断点或者输出通道值,逐步验证。这种“先验证原始字节流,再验证解析结果”的顺序,是我每次做串口协议解析都会用的流程。
6. 一点个人经验
DMA 循环接收 + IDLE 中断这套东西,单独看每个知识点都不难,但组合起来之后,细节特别容易互相咬合出错。我最早一次调试,反相没处理,串口助手看到的全是 0x00,我一度以为是波特率问题,折腾了整整一晚上才发现是电平问题。后来我把“先查电平、再查波特率、再查配置、最后查代码”写成了一张检查清单,贴在工位旁边,之后再做任何串口协议都没翻过车。
调试 SBUS 我还有一个习惯:先在主循环里用串口把解出来的通道值周期性打印出来,接上接收机,手动拨动摇杆,看数值变化是否跟操作一致。这一步过了之后,再接舵机或者电调做闭环验证。千万不要跳步直接上执行机构,否则协议解析和硬件问题混在一起,排查成本会翻好几倍。
另外,这套方案里的状态机思路完全可以平移到其他协议上。DSM、CRSF、PPM 这些遥控协议,本质上都是“帧头 + 数据 + 帧尾”的结构,改改帧长和校验规则就能复用。如果你后续要做多协议接收机兼容,把状态机抽象成协议描述表,顺便把 CRC 校验也做进去,会是一个非常灵活的基础框架。