STM32 HAL库实现SBUS协议解析:DMA循环接收、IDLE中断与状态机实战
2026/9/24 23:22:56 网站建设 项目流程

玩飞控的朋友对SBUS协议应该都不陌生。无论是Pixhawk、ArduPilot还是各类开源飞控,接收机输出的SBUS几乎是最常用的遥控输入来源。SBUS全称是Serial Bus(串行总线),由Futaba提出,本质是一路100000bps的异步串行数据,电气上做了反相处理,帧结构固定为25字节,刷新周期约14ms。这篇文章我准备完整展开HAL库下用DMA循环接收、IDLE中断、状态机三层配合解析SBUS的整套方案,包括原理、CubeMX配置、代码实现和几个非常容易踩的坑。不管你后续做的是飞控、遥控车、机器人还是地面站,只要涉及SBUS,这套框架基本都能直接拿去用。

1. 项目概述:SBUS协议与整体方案

1.1 SBUS 帧格式速览

SBUS帧格式相对固定,但刚接触时容易绕晕。第一个字节固定是0x0F,作为帧头;紧接着是22个字节的通道数据区,这里塞了16个遥控通道,每个通道占用11bit,16个通道共176bit,正好压缩成22字节;第23字节是状态标志位,高四位分别对应第17通道、第18通道、丢帧标志和FailSafe标志;最后一个字节固定是0x00,作为帧尾。整个帧长度就是25字节。

串口参数是100000bps、8位数据位、偶校验、2位停止位,也就是常说的8E2。很多人一开始容易把波特率当成115200,然后发现数据怎么都对不上,其实SBUS就是100000,这个数值在CubeMX里是可以直接手输的。

有一点特别容易埋坑:SBUS信号是反相的,逻辑0和逻辑1与标准TTL串口完全相反,空闲状态是低电平而不是高电平。如果直接把接收机的SBUS引脚接到STM32的RX引脚,大概率收到的全是0xFF或0x00。所以硬件上必须先做反相处理,这一点我在下面会单独展开。

1.2 为什么选择DMA循环接收 + IDLE中断 + 状态机

有人可能会问:SBUS一帧才25字节,14ms才一帧,数据量这么小,我直接串口接收中断逐字节收不行吗?

可以,但我强烈不建议。原因有三。第一,中断太频繁。100000bps波特率下,一个字节的时间大约80us,即使数据量不大,系统里若同时跑着定时器、I2C、其他DMA外设,频繁进出串口ISR不仅拉高CPU占用,还可能因为中断优先级或响应延迟导致丢字节。第二,帧边界不好找。如果只是把字节堆进数组,一帧从哪开始、到哪结束很难界定,靠0x0F去搜索虽然可以,但数据中间一旦出现噪声,很容易错位。第三,代码耦合太紧。逐字节中断要求每收到一个字节都通知CPU,业务逻辑被打断的次数太多,后面扩展很麻烦。

DMA循环接收就清爽得多。DMA硬件把串口收到的字节自动搬运到缓冲区,CPU不需要理会单字节中断。再配合IDLE空闲中断,当串口总线上出现一段空闲时间,说明一帧数据已经结束,硬件会主动通知CPU去处理。等于把接收这个苦力活交给了DMA,CPU只在一帧数据到位后去解析,负载极低。

那为什么还需要状态机?因为DMA只是搬砖的,它不知道哪些字节属于合法帧,也不知道缓冲区里积压了多少数据。状态机要负责从DMA缓冲区里搜索0x0F帧头、收集25字节、校验0x00帧尾,只有帧头帧尾都对上,才认为是一帧有效数据。IDLE中断负责告诉CPU“有空闲了,可以处理了”,状态机负责在字节流里找合法帧,两者配合才能真正稳定不漏帧。

2. 硬件准备与CubeMX配置

2.1 信号适配:反相器与电平匹配

先解决反相问题。SBUS信号在接收机端输出的是反向串行电平,空闲时为低电平,而STM32的USART期望空闲时为高电平,所以必须反相。STM32F103的USART没有硬件RXINV反相功能,只能外部处理。

最简单的做法是搭一个三极管反相器。NPN三极管用9013就行,基极串1k电阻接接收机SBUS输出,发射极接地,集电极用4.7k电阻上拉到3.3V并接到STM32的RX引脚。工作逻辑是这样:SBUS输出低电平时三极管截止,RX被上拉到高;SBUS输出高电平时三极管饱和导通,RX被拉低,正好把信号翻过来。这个电路成本不到几毛钱,实测非常稳定。

还有一种方案是用STM32H7或者部分F4/G4系列,某些型号USART支持RXINV,在CubeMX里勾选Reverse RX polarity就能硬件反相,不用外部电路。另外有些接收机模块,比如ELRS接收机或者部分地面的串口转发模块,本身就能输出正逻辑SBUS信号,接线前最好看一眼说明书,省得白搭反相器。

电平匹配同样要注意。很多接收机SBUS输出高电平是3.3V,可以直接接STM32,但老款接收机存在5V电平,直接接可能损伤MCU引脚。不确定的情况下,加个分压电阻最稳,我用FS-iA6B测试时它输出的是3.3V电平,直接接没问题,但这不代表所有接收机都这样。接之前先用万用表或示波器测一下实际高电平电压,别偷懒。

2.2 CubeMX工程配置:USART、DMA与NVIC

我以最常见的STM32F103C8T6为例,工程用STM32CubeMX生成,HAL库版本建议1.8.0以上。这个芯片USART1的接收DMA请求映射到DMA1的通道5,发送是DMA1通道4,我们这里只用接收。

时钟树直接用外部8MHz晶振,PLL倍频到72MHz,然后确认USART1波特率能否精确到100000。CubeMX里在USART1配置界面把Baud Rate手输为100000,Word Length选8 Bits,开启偶校验后实际是8数据位加1校验位,Parity选Even,Stop Bits选2。这几个配置不能错,任何一个不对,帧就对不上。

DMA配置需要认真看。把USART1_RX添加到DMA后,Mode一定要选Circular,也就是循环模式,否则DMA搬运完缓冲区大小就停了。Peripheral Address Increment和Memory Address Increment这两项,外设地址不用增加,内存地址要增加,所以Memory那块选Increment,Data Width两边都是Byte。DMA请求方式选Normal就好,不需要Peripheral Flow Controller。循环模式的意义在于,DMA会在缓冲区写满后自动回到开头继续写,配合环形缓冲区的指针管理,才能长期运行不出错。

NVIC设置里,把USART1全局中断勾上,这是IDLE中断所在的中断入口。DMA1通道5的中断也建议勾上,用于处理传输完成或错误状态。这里容易产生的误解是:IDLE中断不挂在DMA中断上,而是挂在UART全局中断上,所以UART中断必须开,DMA中断属于辅助性质。

2.3 硬件连接与引脚分配

我的接线是这样的:接收机SBUS输出脚经反相器接到STM32F103C8T6的PA10,也就是USART1_RX,两块板子共地。PA9也就是USART1_TX暂时空着,或者接一个USB-TTL调试工具做数据转发。如果想在调试时看解析结果,可以再用USART2接一个串口,把通道值打出来。

这里有个经验:接收机尽量用独立电源,或者和STM32共用一个电源但余量要足。舵机或接收机启动瞬间电流很大,如果从STM32板子的LDO取电,芯片很容易复位。我实际做的时候是用外接BEC给接收机供电,只把GND连在一起,信号线再进反相电路,这样最干净,也不会引入电源噪声。

3. 接收底层:DMA循环 + IDLE中断的实现

3.1 启动DMA循环接收

在HAL库的新版本里,启动串口DMA接收最方便的是这个函数:

#define SBUS_FRAME_SIZE 25 #define SBUS_RX_BUFFER_SIZE 256 uint8_t sbus_rx_buffer[SBUS_RX_BUFFER_SIZE]; // main中初始化UART之后调用 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, sbus_rx_buffer, SBUS_RX_BUFFER_SIZE);

这个函数做了两件事:启动DMA搬运,同时使能IDLE空闲中断。缓冲区设为256字节,远大于SBUS一帧25字节,这样即使CPU处理不及时,DMA也不会很快把缓冲区写满导致覆盖。缓冲区大小选256还有个好处,它是2的幂,后面用位运算取环形位置会很方便。

有个细节:这个函数在旧版HAL库里可能没有,如果遇到HAL_UARTEx_ReceiveToIdle_DMA未定义的编译错误,可以退一步用HAL_UART_Receive_DMA,然后再手动使能IDLE中断。代码是这样:

HAL_UART_Receive_DMA(&huart1, sbus_rx_buffer, SBUS_RX_BUFFER_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

这个老办法也可以,但需要自己在UART中断回调里判断IDLE标志,代码稍微麻烦一点。新库能直接用HALUARTEx_ReceiveToIdle_DMA最好。

3.2 IDLE事件回调:拿到一帧边界

IDLE中断触发时,HAL库会调用这个回调函数:

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart == &huart1) { sbus_on_idle(); } }

注意,在F103的HAL库里,HAL_UARTEx_ReceiveToIdle_DMA对应的回调是HAL_UARTEx_RxEventCallback,不是普通的HAL_UART_RxCpltCallback。这两个千万别搞混,很多人在这里踩坑:串口明明能收数据,但自己的回调就是不执行,大概率是回调函数名写错了。

进入这个回调时,说明UART总线上出现了空闲,也就是一帧数据已经接收完毕。此时DMA还在循环模式里继续转,缓冲区里的数据仍然保留。我需要知道DMA当前写到了哪个位置,才能把刚收到的数据从缓冲区里抠出来。

DMA当前写位置可以通过读取DMA的NDTR计数器得到:

uint16_t sbus_get_dma_write_index(void) { uint16_t ndtr = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); return (uint16_t)(SBUS_RX_BUFFER_SIZE - ndtr); }

因为DMA从缓冲区起始地址开始写,写完256字节会绕回0,NDTR的值表示还剩多少字节没写,所以当前写位置就是256减去NDTR。比如NDTR等于231,说明已经写了25字节,当前写位置就是25,正好一帧。

3.3 缓冲区的双指针与环形管理

我维护两个变量,一个记录上次消费到缓冲区的什么位置,一个通过NDTR计算当前DMA写位置。每次IDLE中断里,从上次消费位置到当前写位置之间的数据,就是新收到的字节流。

volatile uint16_t sbus_rx_last_index = 0; void sbus_on_idle(void) { uint16_t wr_idx = sbus_get_dma_write_index(); uint16_t len; if (wr_idx >= sbus_rx_last_index) { len = wr_idx - sbus_rx_last_index; } else { len = SBUS_RX_BUFFER_SIZE - sbus_rx_last_index + wr_idx; } if (len > 0) { sbus_parse_stream(sbus_rx_buffer, sbus_rx_last_index, len); } sbus_rx_last_index = wr_idx; }

这里有几个细节必须说清楚。第一,IDLE中断处理期间,总线是空闲的,按理说不会有新数据覆盖缓冲区,所以不用关中断。但如果下一帧很快到来而解析时间过长,DMA会继续写,有可能覆盖还没处理完的数据。好在SBUS帧周期14ms,状态机解析不过几微秒,实际不会发生。如果实在不放心,可以在解析前关闭IDLE中断,解析完再打开,代价是中间的空闲事件可能漏掉。

第二,len理论上应该等于25,也就是一帧长度。但实测中偶尔会出现len大于25或小于25的情况,比如信号受干扰导致IDLE提前触发,或者上次处理不及时导致缓冲区中积压多帧。所以不能假定len一定等于25,必须交给状态机去搜索帧头帧尾,而不是直接按25字节截断。这也是状态机存在的核心原因。

4. 状态机解析:从DMA字节流到16通道数据

4.1 状态机设计思路

缓冲区里是一长串连续的字节流,状态机的任务是在里面找0x0F开头、25字节长度、0x00结尾的合法帧。这里只做一个简单的两态状态机:SYNC状态搜索帧头,DATA状态收数据。代码很直接:

typedef enum { SBUS_STATE_SYNC = 0, SBUS_STATE_DATA } sbus_state_t; static sbus_state_t sbus_state = SBUS_STATE_SYNC; static uint8_t sbus_frame_buf[SBUS_FRAME_SIZE]; static uint8_t sbus_frame_idx = 0; void sbus_parse_stream(uint8_t *buf, uint16_t start, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint8_t byte = buf[(start + i) % SBUS_RX_BUFFER_SIZE]; switch (sbus_state) { case SBUS_STATE_SYNC: if (byte == 0x0F) { sbus_frame_buf[0] = byte; sbus_frame_idx = 1; sbus_state = SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: sbus_frame_buf[sbus_frame_idx++] = byte; if (sbus_frame_idx >= SBUS_FRAME_SIZE) { if (sbus_frame_buf[SBUS_FRAME_SIZE - 1] == 0x00) { sbus_decode_frame(sbus_frame_buf); } sbus_state = SBUS_STATE_SYNC; } break; default: sbus_state = SBUS_STATE_SYNC; break; } } }

这个状态机对外部表现是:只认合法帧,出现噪声、乱码、错位时它会重新等待0x0F,不会把错误数据硬当成一帧。相比按固定长度截取,鲁棒性强很多。搜索帧头时遇到一个假0x0F,最坏情况是丢掉25字节、在下一帧重新同步,对SBUS这种周期帧来说,影响可忽略。

4.2 16通道11位数据解码

SBUS的16个通道每个占11bit,而且是LSB在前,也就是先发低位,所以解码时不能简单按字节读。要先算每个通道的起始bit位置,再跨字节拼出11位数据。

typedef struct { uint16_t ch[16]; uint8_t ch17; uint8_t ch18; uint8_t frame_lost; uint8_t failsafe; } sbus_data_t; sbus_data_t sbus_data; void sbus_decode_frame(uint8_t *frame) { for (int ch = 0; ch < 16; ch++) { uint16_t bitpos = ch * 11; uint16_t bytepos = bitpos / 8; uint16_t bitoff = bitpos % 8; uint16_t val = (uint16_t)(frame[1 + bytepos] >> bitoff) | ((uint16_t)frame[1 + bytepos + 1] << (8 - bitoff)); sbus_data.ch[ch] = val & 0x07FF; } sbus_data.ch17 = (frame[23] >> 7) & 0x01; sbus_data.ch18 = (frame[23] >> 6) & 0x01; sbus_data.frame_lost = (frame[23] >> 5) & 0x01; sbus_data.failsafe = (frame[23] >> 4) & 0x01; }

这里有个容易错的点:frame[0]是0x0F帧头,真正的通道数据从frame[1]开始,所以代码里用frame[1 + bytepos]定位。当通道号较大时,bytepos最大是20,读取frame[21]和frame[22],正好还在通道数据区内,不会越界到flags字节,这个边界我确认过。

SBUS通道值范围是0到2047,11bit。不同遥控器、接收机的中位和行程可能不同,常见模拟舵机信号1000到2000us对应到SBUS值大概是170到1811,中位约992或1024。调试时先看原始值,再做通道映射和校准,不要把某个固定值当中位。

4.3 帧标志位、FailSafe和状态校验

除了16个模拟通道,SBUS还定义了4个标志位,存放在第23字节的高四位。第17和第18是数字通道,不是模拟量,只表示开关状态。frame_lost表示接收机是否丢失遥控信号,failsafe表示是否进入失控保护模式。

这四个位建议都解出来,用途很大。比如在飞控里,frame_lost置位就不能继续用该通道数据做控制;failsafe置位要做安全保护动作,比如切入自稳、回中或关闭电机。只解析16个通道会丢失这些状态信息,排查问题时很被动。

我实际遇到过一种情况:遥控器关机后,接收机持续输出failsafe标志位置1的帧,通道值保持最后时刻的值。如果逻辑只判断数据是否更新,很容易误判成遥控器还在线。正确的做法是三者结合:帧头帧尾校验保证帧合法,frame_lost判断链路状态,failsafe判断接收机是否处于保护模式。这样才能做到逻辑闭环。

5. 常见问题与排查技巧

5.1 收不到数据,或收到全是0xFF/0x00

先别急着调代码,用示波器或逻辑分析仪看接收机SBUS引脚的波形。如果空闲电平是低,说明信号是反相的,必须加反相器或者确认接收机是否有正逻辑输出。反相信号的特征很明显,总线空闲在低电平,数据位的电平跟常规UART完全相反,一看便知。

排除反相问题后,再确认波特率。SBUS是100000bps,不是115200或9600。CubeMX里直接填100000,时钟树锁在72MHz,误差基本可忽略。如果你用的是内部RC振荡器,误差可能超过1%,偶发误码就很正常了。所以这类通信对时钟精度还是有要求的,尽量上外部晶振。

再检查DMA配置,Mode必须是Circular。如果不小心选成了Normal,DMA搬运完缓冲区大小后就停止,数据只进缓冲一次,后面的全部丢掉,IDLE中断也会异常。最后确认回调函数名,新版HAL对应HAL_UARTEx_RxEventCallback,旧版手动使能IDLE时可能需要自己在HAL_UART_IRQHandler里处理,写错名字回调自然不执行。

5.2 解析偶发错位、丢帧

如果代码逻辑看着没问题,但通道值偶尔跳变,大概率是状态机没有完全同步。一种情况是缓冲区里出现了一个假0x0F,比如通道数据中间恰好有某个字节是0x0F,如果帧尾0x00也碰巧满足,这帧就会通过校验,但通道数据是错的。

要降低这种概率,可以加一个更严格的确认机制。正常情况下一帧结束后触发IDLE,所以可以在sbus_on_idle里先检查len是否等于25,如果等于25,基本可以确定是完整帧,状态机只需搜索确认一下帧头帧尾即可。如果len不等于25,再走状态机慢慢搜索。我实测下来,len等于25的情况占99%以上,这样既保留状态机的鲁棒性,又避免误同步。

另外,如果你的接收机输出帧间隔不规律,或者DMA缓冲区太小导致读写指针追尾,也会出现丢帧。解决办法就是把缓冲区加大,256字节足够。再不行,降低状态机里的无效工作,保证每次IDLE回调都能在1ms内处理完。

5.3 调试与验证技巧

调试SBUS解析最直观的方法就是把16个通道值通过另一个串口发到上位机看曲线。我用过VOFA+的串口协议,把F1到F16通道值以文本形式发出去,拖动遥控器摇杆,数值和曲线会跟着动,说明解析正确。如果没有遥控器,也可以用PC串口助手手动发一帧25字节的SBUS数据来验证状态机。构造测试帧时注意:0x0F开头,22字节通道数据随便填,最后两个字节分别是0x00和0x00。如果状态机设计正确,收到这种帧后能正确解出通道值。

这里贴一个最基础的测试帧,方便没有接收机的朋友快速验证:

uint8_t test_frame[25] = { 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };

想验证某个特定通道值,可以按11bit编码规则自己算。比如想让第1通道输出1024,第1通道的bitpos是0,所以第1字节的低8位是0,第2字节的低3位是4,也就是0x20F,组合起来就是1024。这种手工编码测试非常方便。

另一个经验:如果有条件,把SBUS原始字节也打印出来看。比如加一个调试开关,长按按键进入原始数据监视模式,直接以十六进制打印收到的字节流,判断是否出现丢字节、错位等问题。出错时看原始数据比看解析结果更直接。

个人实际体会是,这一套“串口DMA循环接收 + IDLE中断 + 状态机”的组合并不只适用于SBUS。任何有固定帧结构、低数据率、实时性要求不高的串口协议,都可以套这个模板。只要理解了DMA环形缓冲区的指针管理、IDLE事件的含义、状态机同步这三个核心点,后面换什么协议都只是改帧格式解析那一段而已。跑在F103这种入门MCU上,这套方案的CPU负载几乎可以忽略不计,只在一帧结束那一刻打断一下,日常和电机控制、无线数传等任务同时跑,完全无压力。如果你也在调SBUS,希望这篇文章能帮你少走些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询