拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32 HAL库实现SBUS协议解析:DMA循环接收、IDLE中断与状态机实战

玩飞控的朋友对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个遥控通道每个通道占用11bit16个通道共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去处理。等于把接收这个苦力活交给了DMACPU只在一帧数据到位后去解析负载极低。那为什么还需要状态机因为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手输为100000Word Length选8 Bits开启偶校验后实际是8数据位加1校验位Parity选EvenStop Bits选2。这几个配置不能错任何一个不对帧就对不上。DMA配置需要认真看。把USART1_RX添加到DMA后Mode一定要选Circular也就是循环模式否则DMA搬运完缓冲区大小就停了。Peripheral Address Increment和Memory Address Increment这两项外设地址不用增加内存地址要增加所以Memory那块选IncrementData 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字节会绕回0NDTR的值表示还剩多少字节没写所以当前写位置就是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到204711bit。不同遥控器、接收机的中位和行程可能不同常见模拟舵机信号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。如果不小心选成了NormalDMA搬运完缓冲区大小后就停止数据只进缓冲一次后面的全部丢掉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希望这篇文章能帮你少走些弯路。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门