最近在搞一个遥控接收机的信号采集模块,需要把接收机输出的SBUS协议解析成16通道PWM数值,再通过串口转发给上位机。折腾了几天,最后敲定 STM32 + HAL 库 + DMA 循环接收 + IDLE 中断 + 状态机这套组合,实际跑下来非常稳定,CPU 占用极低。这篇文章把我的完整实现思路和代码细节整理出来,给同样在玩SBUS解析的朋友一个可参考的方案。
1. 协议与方案背景:为什么SBUS需要这个组合
1.1 SBUS到底是什么——物理层与帧格式
SBUS是航模接收机常见的串行总线协议,Futaba主导的开放协议,很多接收机如FrSky X系列、R9MM等都支持输出。物理层是UART,但有几个跟普通串口不一样的地方:波特率是100000,比标准波特率更小众;数据位8位、偶校验、2个停止位(8E2);信号电平是反相的TTL,原始输出是高电平代表逻辑0,低电平代表逻辑1。
帧结构比较特殊,固定25个字节:第1字节0x0F帧头,中间22字节存放16个通道数据,每个通道11位,共176位正好塞满22字节,然后是1字节标志位,最后1字节0x00帧尾。标志位里BIT7表示第17通道,BIT6表示第18通道,BIT5表示信号丢失,BIT4表示failSafe触发。帧间隔一般在14ms左右,有些接收机支持高速模式输出7ms一帧,所以整个协议是个比较典型的“定长帧 + 帧头帧尾校验”结构。
关键点来了:SBUS的100000波特率并不是STM32系统时钟能整数倍分频出来的,好在串口接收端对波特率误差容忍度通常有3%左右,下面会专门算。
1.2 为什么用DMA循环接收而不是传统逐字节中断
如果只用串口中断逐字节接收,一个字节进一次中断,14ms一帧25字节,在多个串口同时工作或者主循环里有重负载时,CPU频繁进出中断的开销非常明显。SBUS帧率高达70Hz甚至140Hz,逐字节中断方式虽然也能跑,但每次中断还要处理半字节错误、校验错误等,耦合度很高,后期想加别的协议接收会发现中断里全是代码。
用DMA接收的本质是让外设直接把数据搬运到内存缓冲区,不经过CPU。配合DMA的循环模式(Circular模式),可以做到接收缓冲区是一个环形结构,硬件自动回绕,软件只需要关注“从上次处理到当前,DMA又搬了多少数据”,一次性批量解析。这样CPU从“每字节一次中断”降到“每帧一次中断”,开销直接少一个数量级。
这个方案比较适合SBUS这类固定帧长、数据到达又快又规律的协议。DMA循环接收还有好处是数据搬移的时序由硬件保证,不会因为中断优先级被打断导致丢字节,配合IDLE中断做帧边界判定,基本不会错位。
1.3 IDLE中断在SBUS接收中的角色
IDLE中断的全称是线路空闲中断,UART在接收到一字节后,如果检测到总线上一个字节的时间宽度内没有新的起始位,就会触发IDLE事件。这正好用来标记“一帧数据已经传输完毕”,因为SBUS帧与帧之间有约7ms的空闲,远大于一个字节的时间,IDLE事件基本等于“SBUS一帧接收完成”。
对于STM32 HAL库,IDLE中断默认不会像普通收发中断那样在HAL_UART_IRQHandler里分发回调,这也是很多新手直接掉坑的地方。要么自己在UART中断服务函数里判断IDLE标志位,要么用新版HAL提供的HAL_UARTEx_ReceiveToIdle_DMA接口,它会自动处理IDLE事件并回调HAL_UARTEx_RxEventCallback。我的方案里选了自己判断IDLE标志位,兼容性更好,也更好理解。
2. 硬件设计与CubeMX配置避坑
2.1 信号反相问题:最容易被忽略的第一关
SBUS的物理层信号是反相逻辑,而STM32的UART接收RX引脚期望的是常规极性,也就是空闲时为高电平,起始位为低电平。接收机的SBUS输出空闲时是低电平,起始位是高电平,直接接到STM32上会乱码。很多人在这一步卡了很久,收到的数据要么全0要么全F,根本进不了解析状态。
处理方式一般有两个方向:硬件上反相,或者软件上依赖部分STM32型号的RXINV功能。硬件反相是最稳妥的,因为不依赖芯片型号。常用的做法是用一个三极管或者非门反相,我用的是一颗74LVC1G04单非门,电路很简单,输入串一个1K电阻,输出直接进STM32串口RX。如果手头没有非门芯片,用NPN三极管搭反相器也行,注意翻转速度和电平幅度,波特率100000下,普通2N7002或S8050都足够快。
软件上,STM32F4/F7/H7系列的USART有RXINV位,可以反转RX引脚极性,CubeMX里在USART参数配置的Advanced Features下可以勾选“RX引脚极性反转”。但要注意不是所有型号都支持,F1系列就没有这个功能,而且反相配置对IDLE判断等也有潜在影响,所以我建议能上硬件反相就优先硬件反相,省得后面排查起来多一个变量。
2.2 CubeMX关键参数逐项说明
打开CubeMX选择芯片和时钟后,配置USART的参数需要注意几个点。
第一,波特率直接填100000,数据位选8位,校验位选Even,停止位选2位。这里有个知识点:STM32的USART实际寄存器里,8位数据+偶校验在硬件上占用9个bit(8数据位+1校验位),所以跟SBUS的8E2是完全兼容的,不需要额外脑补。
第二,DMA设置里添加USART_RX,方向选Peripheral to Memory,模式一定要选Circular循环模式。增量地址:外设地址不增量,内存地址增量。数据宽度外设和内存都选Byte。这里有一个经常被忽略的配置项:DMA的“Mode”如果选Normal,数据缓冲区满了DMA就停止,后面的数据直接丢失,所以循环模式必须选好。
第三,还有一个叫“DMA Continuous Requests”的选项,这个建议勾上。勾选后UART在DMA搬运期间不会自动关闭接收请求,对于流式接收是必需的,不然DMA传输完成后UART接收保持使能会有问题。很多教程里没提这个选项,但我实测下来,不勾它循环接收在某些场景下会出现首帧后不再接收的问题。
第四,NVIC配置里必须使能USART全局中断,DMA中断可以不使能,因为我们用的是IDLE中断来通知数据到达,不需要DMA传输完成中断挤进来抢优先级。原因是循环模式DMA永远不会“传输完成”,它在不断回绕,传完成中断没有实际意义,反而增加处理负担。
2.3 DMA循环模式下的缓冲区大小设计
DMA循环接收要提前准备好一个内存缓冲区,缓冲区大小决定了一次最多能缓存多少字节。SBUS一帧25字节,帧间隔约7ms,在100000波特率下一个字节约耗时1ms不到(10位×10us=100us? 注:100000波特率对应每bit 10us,一个字节10bit=100us,25字节约2.5ms),7ms空闲足够我们在这期间处理。
缓冲区建议设成128或256字节,不要非得卡成25的整数倍。原因很简单:IDLE中断触发时,我们通过DMA计数器得到的是“从起始位置到当前位置总共搬了多少字节”的差值,这个差值可能跨越任意位置,不一定是完整帧的倍数。缓冲区越大,对“处理不及时”的容忍度越高,即使主循环被高优先级任务卡了一下,DMA还是在硬件层面不断接收,不丢数据。
我用的是256字节,实际接收过程中一次IDLE事件搬进来的数据量往往是刚好一帧25字节,偶尔会出现连续两帧挤在一起而产生50字节的批量数据,这种情况状态机也能正确处理,因为解析是逐字节推进的。
缓冲区定义要使用volatile关键字,并做对齐处理,防止DMA写入被编译器优化掉或跨页访问出现问题。老版本串口DMA有个坑:如果缓冲区起始地址跨越了DMA控制器的某个边界,会导致数据传输错误。CubeMX生成的缓冲区如果放到了.bss段一般没问题,但稳妥起见可以加__ALIGN_BEGIN,或者用uint8_t声明后交给DMA即可。
3. 代码实现:IDLE回调、环形缓冲与状态机
3.1 中断服务函数如何正确捕获IDLE事件
CubeMX生成的代码里,串口中断服务函数是USART1_IRQHandler,默认实现只调用HAL_UART_IRQHandler。我这个方案里需要自己手动处理IDLE事件。修改后的代码如下:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 处理SBUS数据:计算DMA已经搬入的数据量 sbus_uart_idle_handler(&huart1); } HAL_UART_IRQHandler(&huart1); }这段代码有两个细节要注意。
第一个细节:必须先读IDLE标志再清标志,而且是把读和清放到一条语句里。UART的IDLE标志清除方式是先读SR寄存器再读DR寄存器,写其他值清不了。HAL库提供了一个宏__HAL_UART_CLEAR_IDLEFLAG,但这个宏在某些芯片上只是写一个非DR值,不一定真正清掉。所以更稳妥的做法是直接读SR再读DR,或者用这个宏配合一条假读语句。我在代码里用的是宏,实测F1系列有效,但如果你用的是新出的G0/L4系列,最好确认一下标志是不是真的清了,不然会一直进IDLE中断。
第二个细节:HAL_UART_IRQHandler和我们的IDLE处理函数谁先调用。我试过先调用HAL_UART_IRQHandler再清IDLE,也试过我的处理函数放前面。实测下来影响不大,但要保证HAL_UART_IRQHandler不要覆盖掉我们刚计算的数据长度。HAL库在IRQHandler里如果检测到接收错误或空闲标志,会执行错误处理和回调,所以我的建议是先处理IDLE,再做标准HAL处理,避免对DMA计数器的读取被HAL内部的某些操作干扰。
3.2 IDLE回调里DMA计数器的计算
DMA循环模式下,要从DMA当前计数寄存器倒推出“本次新到了多少字节”。思路是利用DMA的NDTR寄存器:这个寄存器在循环模式下会从缓冲区大小开始递减,减到0后重新装载,但每次IDLE事件发生时读到的值代表“还剩多少字节没搬”。
计算公式是这样的:
uint16_t cur = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t received = (last_counter > cur) ? (last_counter - cur) : (SBUS_RX_BUF_SIZE - cur + last_counter); last_counter = cur;last_counter是上一次处理完时DMA计数器的值。当新数据到达,DMA计数器从last_counter递减到当前值cur,两者之差就是本次DMA搬运了多少字节。如果中间缓冲区回绕了一次,last_counter小于cur,就加上缓冲区大小再减。
实际测试会发现一个细节:只要咱们处理速度够快,IDLE中断触发后last_counter和cur的差值基本就是完整的一帧25字节,连续收到两帧时这个值是50。状态机不需要关心一次进来多少字节,它只是把缓冲区里的数据当作字节流逐字节消费。
idle处理函数里拿到received长度后,把缓冲区中从上次位置到当前位置的字节拷贝或者直接传递给解析状态机。因为DMA循环缓冲区本身是环形结构,数据可能跨缓冲区末尾回绕,所以拷贝时要做两次memcpy,或者直接在这个函数里循环逐字节喂给状态机。我选择逐字节喂,因为SBUS一帧才25字节,循环开销微不足道,而且天然处理了回绕问题,不用额外判断。
void sbus_uart_idle_handler(UART_HandleTypeDef *huart) { uint16_t cur = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t received = (last_counter >= cur) ? (last_counter - cur) : (SBUS_RX_BUF_SIZE - cur + last_counter); last_counter = cur; for (uint16_t i = 0; i < received; i++) { // 循环缓冲区的真实索引,处理回绕 sbus_rx_buffer[read_index] = ...; read_index = (read_index + 1) % SBUS_RX_BUF_SIZE; sbus_parse_feed_byte(...); } }因为这块代码在中断上下文里,所以处理逻辑必须短小精悍,不能做浮点运算,不能调用耗时函数,尽量在微秒级完成。SBUS一帧数据才25字节,逐字节喂状态机在100MHz的M4上也就是几个微秒的事,非常安全。
3.3 状态机核心逻辑设计
状态机的价值在于:对于一帧SBUS数据,解包时不能只依赖“固定字节位置”,因为一旦出现半帧错位或者丢字节,直接按固定位置解析会产生灾难性的错误数据。引入状态机按序推进,能够自动重新寻找帧头,容错性大大提高。
我们定义帧状态机的几个状态:
typedef enum { SBUS_STATE_WAIT_HEADER, // 等待帧头0x0F SBUS_STATE_RX_DATA, // 接收通道数据和标志位 SBUS_STATE_CHECK_END // 校验帧尾0x00 } sbus_state_t;逐字节喂给状态机的逻辑:
void sbus_parse_feed_byte(uint8_t data) { switch (sbus.state) { case SBUS_STATE_WAIT_HEADER: if (data == 0x0F) { sbus.buf[0] = data; sbus.index = 1; sbus.state = SBUS_STATE_RX_DATA; } break; case SBUS_STATE_RX_DATA: sbus.buf[sbus.index++] = data; // 帧头1字节 + 通道数据22字节 + 标志位1字节 = 24字节 if (sbus.index >= 24) { sbus.state = SBUS_STATE_CHECK_END; } break; case SBUS_STATE_CHECK_END: sbus.buf[24] = data; if (data == 0x00) { sbus.valid = 1; // 一帧有效数据解析完成 sbus_decode_channels(&sbus); } else { // 帧尾错误,重新开始找帧头,注意不能丢弃当前字节,因为当前字节可能是下一帧的帧头 sbus.state = SBUS_STATE_WAIT_HEADER; if (data == 0x0F) { sbus.buf[0] = data; sbus.index = 1; sbus.state = SBUS_STATE_RX_DATA; } } break; } }这里最巧妙的是CHECK_END状态里,如果帧尾校验失败,当前字节不能直接丢掉,要把它视作潜在的下一帧帧头再重新走一遍。这个细节很关键,因为SBUS没有像传统帧协议那样有明确的帧头校验和CRC,唯一的帧边界信号就是0x0F头和0x00尾,一旦中间出现误码导致错位,重新同步能力就靠这段逻辑。
实际运行中,只要接收机信号正常,状态机稳定在WAIT_HEADER→RX_DATA→CHECK_END→WAIT_HEADER的循环中。如果接线不良或干扰较大,会有偶发的帧尾错误,这时状态机会退回首字节重新同步,下一帧就能恢复,不会产生持续性的错乱。
3.4 16通道11bit数据提取
SBUS的16个通道每个11位,不是按字节对齐存储的,而是连续排列的位流。解析时需要按位偏移去提取。通道数据从帧的第2字节开始(索引1),连续176位。
提取公式可以这么写:
for (int ch = 0; ch < 16; ch++) { uint16_t bitpos = ch * 11; uint16_t bytepos = bitpos / 8; uint8_t shift = bitpos % 8; uint16_t value = (sbus.buf[1 + bytepos] >> shift) | ((uint16_t)sbus.buf[1 + bytepos + 1] << (8 - shift)); sbus.channels[ch] = value & 0x07FF; // 11位掩码 }这里的索引要小心:缓冲区中第1个字节(索引0)是帧头0x0F,通道数据从索引1开始,所以公式里加了1。bytepos最大是21,因为176位最后一组是通道15的位偏移165~175位,字节偏移20.625,需要访问buf[22]和buf[23],正好在DATA区域范围内,不会越界。
提取出来的值是0~2047的范围,对应接收机输出的PWM比例值。飞控领域常把SBUS通道值约化成1000~2000的PPM值,实际使用时在业务层做一次线性缩放即可。这里不做缩放,保持原始值,方便调试对比。
标志位解析也要跟上:
sbus.flag = sbus.buf[23]; sbus.channel17 = (sbus.flag >> 7) & 1; sbus.channel18 = (sbus.flag >> 6) & 1; sbus.frame_lost = (sbus.flag >> 5) & 1; sbus.failsafe = (sbus.flag >> 4) & 1;这些标志位在飞控应用里非常重要,frame_lost和failsafe直接决定要不要切换安全模式。我的模块里把这两个标志位单独剥出来,置成一个全局状态,业务逻辑直接读,不用每次解析时去翻原始帧。
4. 实测数据与问题排查实录
4.1 实测波形与帧间隔数据
把逻辑分析仪挂在SBUS串口线上,可以看到典型的帧结构:25字节密集输出,之后是一段约7ms的空闲,然后又是下一帧。在100000波特率下,每个字节耗时100us多一点,25字节密集传输约2.6ms,加上7ms空闲,总周期约10ms上下,基本符合接收机默认的14ms周期(不同品牌有差异,部分支持7ms高速模式)。
空闲期间正好是IDLE中断的触发点。因为SBUS空闲时间远大于单字节时间,IDLE中断不会在帧中间误触发。这一点和某些高密度协议不同,也是SBUS能放心用IDLE中断的原因。
用示波器测量了一下我们的系统响应:从IDLE中断触发到16个通道数据解析完成并放入全局变量,整个耗时约20us(M4 @ 100MHz,优化等级O2),对2.6ms的帧密集期来说占用不到1%,CPU基本是空闲的。这也是DMA方案最大的优势,传统逐字节中断在这个场景下至少占用个10%以上CPU时间。
4.2 常见故障速查表
整理一下我调试过程中遇到的典型问题,做成速查表,应该能帮大家节省很多排查时间。
| 故障现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 收不到任何数据 | 信号极性反了 | 检查SBUS输出电压逻辑,确认是否已经外部反相 |
| 收到全是0x00或0xFF | 线路空闲电平不对 | 示波器看空闲电平,应为高电平(反相后);检查非门电路供电 |
| 能收到数据但校验总是失败 | 波特率偏差太大 | 计算当前系统时钟下的实际波特率误差,看是否超过3% |
| IDLE中断一直触发 | 标志位没清干净 | 确认__HAL_UART_CLEAR_IDLEFLAG是否真正有效,必要时手动读SR再读DR |
| 数据偶尔错位一帧后恢复 | 帧同步丢失 | 确认状态机CHECK_END失败后有把当前字节继续喂入,而不是直接丢弃 |
| 程序调试器暂停后恢复,数据错乱 | 调试器暂停期间DMA还在跑,环形缓冲覆盖 | 这是正常现象,重新连接或复位接收机即可,不影响实际运行 |
| 与飞控通信时偶发丢包 | 解析完成标志位读写时序问题 | 解析标志在用完后就清零,避免业务层重复读取旧数据 |
4.3 调试技巧:串口打印不要直接进中断
调试SBUS解析时最忌讳的就是在IDLE中断回调里直接调用printf重定向的串口发送。串口发送使用同一个外设或者不同外设时,中断优先级和阻塞时间都可能干扰接收时序。我调试时用的是“解析完成标志位+主循环轮询打印”的方式:中断里只置位标志,把数据存到全局结构体,主循环检测到标志后再格式化打印。这样打印耗时再长也不影响接收侧。
另外一个实用的调试技巧是统计帧错误计数。我维护一个sbus_error_count变量,每次帧尾校验失败就加1。比起肉眼观察乱码,用这个计数器能很快判断信号质量。正常接线时这个计数应该基本不增长,如果每秒几十次增长,优先排查硬件极性、地线、接触电阻,而不是软件逻辑。还有,调试时尽量把SBUS接地点和STM32板子的地连好,航模接收机的地线和模拟电源地线有时候会带来一点压差,串口在这种高压差下容易出错。
还要提醒一下:DMA缓冲区定义在片内RAM,不要在中断回调里同时被多个地方修改。我的做法是解析完成后的通道数组放在一个独立结构体中,主循环读取这个结构体时先关中断再取数,取完立即开中断。对STM32这类单核MCU,关中断取数是最简单可靠的互斥手段,避免主循环读到一半,中断又更新了数据导致高低字节错位。
5. 中断优先级与其他工程细节
5.1 中断优先级设置的讲究
NVIC优先级设置上,USART1全局中断的抢占优先级我设成0,子优先级0,也就是最高优先级。为什么?因为SBUS接收是控制链路的一部分,丢一帧数据在飞控场景可能意味着几十毫秒的控制延迟,优先级给最高是值得的。但要注意同组内如果有定时器中断负责编码器采集,定时器优先级可以同级或稍低,不能出现互锁。
DMA中断我没有使能,因为循环模式下DMA传输完成中断只会无意义地频繁触发。如果有些项目需要DMA半满中断来做双缓冲处理,那DMA中断优先级应和串口中断保持一致,避免DMA半满还没处理完,IDLE又进来了。双缓冲方案适合数据量更大的协议,SBUS这里用不到,单DMA循环+IDLE已经足够。
5.2 与业务模块对接的工程做法
解析出的通道数据最终要对外输出,可能通过另一路串口、CAN、PWM或USB。对接时我建议把“协议解析”和“数据消费”解耦:解析模块只维护sbus_data结构体和valid标志,消费模块轮询valid标志然后取数据。
这样做的好处是,如果将来接收机换成了CRSF协议,只需要替换解析模块,对外接口保持不变。即使主循环里加了大运算任务,只要不超过IDLE中断间隔,解析也不会丢帧。我这个模块里valid标志用volatile声明,并在消费后清零,避免重复消费同一帧数据。对于需要更高实时性的场景,valid标志可以换成信号量或者事件标志,由消费任务阻塞等待,但核心解析流程是完全一样的。
最后再分享一个经验:DMA循环接收 + IDLE中断这套组合不止能用在小帧协议上,常见的Modbus RTU主从、DJI云台协议、各类自定义变长协议都可以用同样的骨架实现,只需要更换状态机的字节判断逻辑。把这个骨架吃透,以后项目里遇到任何串口协议解析,基本都能在半小时内适配出来。