把SBUS比作遥控器和你MCU之间的一套暗号那STM32要做的事情其实就三件听懂波特率、分清每一帧的边界、再把25个字节里的16个通道拆出来。最近群里好几个做无人机、RC、地面站的朋友都在SBUS解析上卡过壳尤其换到HAL库以后总觉得串口中断处理很繁琐DMA和IDLE中断又不知道怎么配合。我自己在飞控板和数据链项目里用HAL库 DMA循环接收 UART IDLE中断 状态机这套方案跑过很久CPU占用低、帧不丢、代码也容易移植。这篇文章就把完整思路、配置方法、代码细节和踩坑记录一次性讲清楚F103、F407、G070这些系列都能直接参考。1. SBUS协议没有你想的那么神秘1.1 SBUS到底是什么SBUS全称Serial Bus是FrSky等遥控器厂商推出来的串行总线协议用来替代传统的PPM现在已经成了航模、穿越机、机器人圈子里最常见的接收机输出协议之一。它的物理层本质上就是UART只不过波特率比较特殊100000bps8个数据位、偶校验、2个停止位。这里注意SBUS的TTL电平是反相的也就是说从接收机出来的信号空闲时是低电平跟普通串口的空闲高电平相反直接接STM32的RX引脚基本收不到正确数据硬件上必须做一级反相。协议帧是固定25字节开头固定0x0F中间22个字节用来存放16个比例通道每个通道11bit后面还有1个状态字节最后固定0x00。一帧总长度按100k波特率算大约是2.75ms到3ms遥控器一般每隔7ms或14ms发一帧两个帧之间有超过3个字节时间的总线空闲间隔。这个空闲间隔就是我们做帧边界检测的关键也是IDLE中断能派上用场的根本原因。1.2 为什么用DMA循环接收而不是串口中断很多初学者拿到SBUS第一反应是在串口接收中断里做逐字节处理每来一个字节进一次中断边收边解析。代码看起来简单实际有几个问题第一100k波特率下每个字节间隔约100us如果串口中断服务函数里有解码、拷贝、状态机切换等操作很容易在下一个字节到来前处理不完直接造成Overrun错误第二HAL库的UART接收中断回调里要维护一个环形缓冲区逻辑写多了一个地方出问题就会丢数据第三SBUS协议是定长帧但你并不能保证中断每次正好从帧头开始读。DMA循环接收的思路完全不同。UART每收到一个字节DMA自动把数据搬到内存缓冲区CPU完全不参与字节搬运。DMA缓冲区写满一圈后会继续从头写形成环形缓冲。你只需要在总线空闲时触发一次IDLE中断在中断里读一下DMA的当前写位置把新到的一段字节交给状态机解析就行。这样做有几个直接好处CPU几乎没有串口负担不会因为中断响应不及时丢字节而且数据始终连续存放在缓冲区里即使DMA写到了缓冲中间也不会影响下一帧。1.3 为什么一定要状态机有了DMA和IDLE中断很多人觉得直接在IDLE中断里把缓冲区整段拷贝出来解析就够了其实还差一步。DMA循环缓冲里可能残留上一帧的数据也可能这一帧恰好跨过了缓冲区尾部和头部单纯按“当前写位置往前取25字节”很容易取出半个帧。更稳的做法是把DMA新产生的一段字节逐字节喂给一个解析状态机由状态机负责找帧头、数长度、验帧尾最后才算出一个完整SBUS帧。这样无论数据来自哪里、缓冲有没有绕圈、前一帧是否残留都不影响解析。这里说的状态机不是操作系统的状态机就是几个简单状态加一个计数器轻量且可靠。2. HAL库下的方案设计与基础配置2.1 CubeMX工程配置要点我一般先用STM32CubeMX生成初始化代码再在用户代码区补处理逻辑。配置串口时直接选你要用的UART比如USART3Parameters页里波特率填100000字数选8Bit校验选Even停止位选2。SBUS这个100000波特率不是常规列表里的值你在CubeMX下拉框里看不到直接手动输入就行底层BRR计算HAL库会帮你处理好。DMA配置在USART3的RX通道上Mode选Circular这是整个方案的基石。Data Width记得把Peripheral和Memory都设成Byte地址自动增量中Memory侧勾上Increment Address。Peripheral地址不需要增量因为源是UART数据寄存器。建完工程后检查一下DMA_Init里的Mode确实是Circular有些旧版本CubeMX生成代码时容易意外生成Normal模式Normal模式下一旦DMA传完设定长度就停了SBUS后面来的数据全部进不了内存。2.2 信号反相与电平匹配SBUS反相信号这个问题必须放到配置前面解决。简单理解普通串口空闲是高电平开始位是低电平SBUS正好反过来空闲是低电平开始位是高电平。不反相的话UART完全不会认为有数据到来哪怕你波特率配得再对也没用。最常见的反相做法是用一个NPN三极管接收机SBUS信号经电阻接到三极管基极三极管集电极接STM32的RX引脚并上拉到3.3V发射极接地中间再加几百欧限流电阻。这样SBUS高电平让三极管导通RX被拉低正好把逻辑反了过来。如果你不想自己搭电路也可以买现成的SBUS转TTL模块或者有些支持SBUS的飞控板子板上已经带反相电路。判断方法很简单用示波器或者逻辑分析仪看RX脚空闲时应该是高电平3.3V来数据时出现一段连续倒置波形那就对了。还有一个容易忽略的点SBUS接收机通常用5V供电SBUS信号电平是5V的而STM32的RX是3.3V IO。5V电平直接进3.3V引脚有风险三极管反相器在这里同时起到了电平转换的作用所以千万别省掉这级电路直接连。2.3 DMA循环接收的关键设置初始化阶段在CubeMX生成代码之后要在串口初始化完成后启动一次DMA接收。普通的调用方式是HAL_UART_Receive_DMA(huart3, sbus_dma_buf, SBUS_DMA_BUF_SIZE);因为CubeMX里DMA已经是Circular模式这个调用会让DMA一直循环搬运数据。但这里有个HAL库的坑HAL_UART_Receive_DMA会注册DMA传输完成中断当DMA计数器从设定值减到0时会触发传输完成事件HAL库的UART回调可能会把你这个循环接收关掉。所以我启动完之后会手动关掉DMA的传输完成中断只保留IDLE中断来控制处理节奏HAL_UART_Receive_DMA(huart3, sbus_dma_buf, SBUS_DMA_BUF_SIZE); __HAL_DMA_DISABLE_IT(huart3.hdmarx, DMA_IT_TC); __HAL_UART_ENABLE_IT(huart3, UART_IT_IDLE);DMA_IT_TC会根据不同系列自动对应正确的中断标志位HAL库已经做了映射。关掉TC中断后DMA循环照常工作但不会再去触发HAL库那套“传输完成就停止接收”的逻辑。UART的IDLE中断在收到的字节流发生空闲、也就是总线上超过一个字节时间没有新数据时触发正好对应SBUS每帧末尾的间隔。3. 核心代码实现DMA循环 IDLE中断3.1 缓冲区与变量设计缓冲区大小我先说结论用128字节就足够了。SBUS一帧25字节DMA缓冲如果开太小比如正好25字节一帧到边界时DMA计数和IDLE事件几乎同时发生处理起来没有余量开太大又白白占用内存。128字节在100k波特率下约能存5帧数据RC链路不会那么短时间连发5帧处理压力很小我实测下来很稳。变量定义如下#define SBUS_UART huart3 #define SBUS_DMA_BUF_SIZE 128 #define SBUS_FRAME_SIZE 25 uint8_t sbus_dma_buf[SBUS_DMA_BUF_SIZE]; volatile uint16_t sbus_dma_last_pos 0;sbus_dma_last_pos记录上一次处理到DMA缓冲区的位置。每次IDLE中断时需要用当前DMA写位置和上一次记录位置作差得到一段新数据。这里的关键是DMA循环缓冲的写位置并不仅仅是“当前字节序号”它由DMA剩余计数逆向推出。3.2 开启DMA循环接收和IDLE中断在初始化函数里串口和DMA初始化完成后加入上面那段启动代码。注意顺序HAL_UART_Receive_DMA要放在MX_USART3_UART_Init之后因为UART外设必须先处于使能状态DMA通道才能正确响应UART请求。中断处理则放在串口IRQHandler里void USART3_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart3, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart3); sbus_idle_isr(); } }有朋友问要不要在最后调HAL_UART_IRQHandler(huart3)。我这里的做法是不调用因为IDLE中断不是标准HAL库UART例程会主动处理的类型而错误中断在正常工作的前提下几乎不会出现。如果你有其他必须要用HAL库UART中断处理的逻辑可以在检测完IDLE后补一句HAL_UART_IRQHandler(huart3)但要注意它可能会触发DMA传输完成回调反而干扰循环接收所以更建议自己单独写一个专用的SBUS UART中断入口。3.3 IDLE中断回调从DMA计数器中找回数据IDLE中断里要做的核心操作是算出DMA到底把数据写到了哪里。DMA有一个当前剩余计数字叫NDTR每搬运一个字节自动减1。初始化时设为SBUS_DMA_BUF_SIZE所以当前写位置就等于当前位置 SBUS_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart3.hdmarx)读取这个值有严格的时序要求在IDLE中断里UART已经因为空闲停止了DMA请求所以此刻的NDTR是稳定的。然后拿当前写位置和上次记录位置对比中间那段字节就是新增数据。由于是环形缓冲位置可能正向增长也可能绕过了缓冲区末尾回到开头所以要做分段处理static void sbus_idle_isr(void) { uint16_t cur_pos; cur_pos SBUS_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart3.hdmarx); if (cur_pos sbus_dma_last_pos) { sbus_feed_bytes(sbus_dma_buf[sbus_dma_last_pos], cur_pos - sbus_dma_last_pos); } else if (cur_pos sbus_dma_last_pos) { sbus_feed_bytes(sbus_dma_buf[sbus_dma_last_pos], SBUS_DMA_BUF_SIZE - sbus_dma_last_pos); sbus_feed_bytes(sbus_dma_buf[0], cur_pos); } sbus_dma_last_pos cur_pos; }如果两次IDLE之间恰好没有新数据cur_pos会等于last_pos什么都不做。这个情况一般不会出现因为SBUS帧间隙一定会有IDLE触发。每次处理完记得把last_pos更新成当前值下次才能正确计算增量。3.4 需要注意的HAL库坑HAL库的这套机制里最容易出问题的就是DMA传输完成中断。前面说过CubeMX把DMA配成Circular后NDTR减到0不会让DMA停止但会触发DMA的Transfer Complete事件。HAL库在HAL_UART_IRQHandler里看到DMA TC标志后可能调用UART的接收完成回调把UART的接收状态改成ready从而停掉后续接收。我的办法是启动后直接关掉DMA TC中断只保留IDLE中断作为唯一处理入口。这样DMA传输完成事件仍然会发生但不会产生中断UART的HAL状态也不会被搞乱。还有一个小坑__HAL_DMA_GET_COUNTER读到的值在DMA刚启动时等于缓冲区大小也就是写位置0。如果IDLE中断来得特别快第一次读取时可能cur_pos是0last_pos也是0这没关系。真正要小心的是在清IDLE标志之前先算位置先处理完新旧数据段再做清理避免清标志后UART立刻又接收到下一字节导致DMA位置变化后计算错位。4. 状态机解析SBUS帧4.1 帧同步状态机前面把DMA缓冲里的新增字节提取出来后不能直接当一帧用。SBUS是定长帧没错但你无法保证新增字节正好从帧头0x0F开始尤其在掉电、重启、拔插接收机等场景下DMA缓冲区里可能残留半个帧。状态机的作用就是在这段字节流里找回正确边界。我定义了这样一个状态机typedef enum { SBUS_STATE_WAIT_HEADER 0, SBUS_STATE_WAIT_BODY } sbus_rx_state_t; static uint8_t sbus_frame[SBUS_FRAME_SIZE]; static sbus_rx_state_t sbus_rx_state SBUS_STATE_WAIT_HEADER; static uint8_t sbus_frame_idx 0;WAIT_HEADER状态只干一件事找0x0F。每来一个字节匹配到0x0F就把它存进帧缓冲状态切到WAIT_BODY并把索引置1。WAIT_BODY状态则连续收集后续24个字节每收一字节索引加1。收满25字节后检查最后一字节是不是0x00如果是说明这是一个结构完整的SBUS帧交给解析函数无论是不是0x00状态机都要回到WAIT_HEADER重新找帧头。喂数据的代码这样写void sbus_feed_bytes(const uint8_t *data, uint16_t len) { while (len--) { switch (sbus_rx_state) { case SBUS_STATE_WAIT_HEADER: if (*data 0x0F) { sbus_frame[0] 0x0F; sbus_frame_idx 1; sbus_rx_state SBUS_STATE_WAIT_BODY; } break; case SBUS_STATE_WAIT_BODY: sbus_frame[sbus_frame_idx] *data; if (sbus_frame_idx SBUS_FRAME_SIZE) { if (sbus_frame[SBUS_FRAME_SIZE - 1] 0x00) { sbus_decode_frame(sbus_frame); } sbus_rx_state SBUS_STATE_WAIT_HEADER; } break; default: sbus_rx_state SBUS_STATE_WAIT_HEADER; break; } data; } }代码里没有在WAIT_BODY阶段判断0x0F是否重新出现这是刻意为之。因为SBUS的中间22个字节是按位存储的通道数据某个通道值完全有可能恰好等于0x0F如果你一看到0x0F就重置状态机会频繁把正常帧拆断。正确做法就是只认最初的帧头和满25字节后的帧尾0x00。4.2 16通道数据解包SBUS帧里16个通道每个占11bit总共176bit正好均匀塞进22个字节。解包时核心是每11个bit连续切一刀。最简单直观的写法是用位运算按通道序号算起始字节和下一位偏移static void sbus_decode_frame(const uint8_t *frame) { uint16_t value; for (uint8_t i 0; i 16; i) { uint16_t offset 1 i * 11 / 8; uint8_t shift i * 11 % 8; value (frame[offset] shift) | ((uint16_t)frame[offset 1] (8 - shift)); value 0x07FF; sbus_channel[i] value; } // 状态字节可根据需要解析 // frame[23] bit0/bit1 常被用于扩展通道bit2信号丢失bit3失控保护 sbus_frame_lost (frame[23] 0x04) ? 1 : 0; sbus_failsafe (frame[23] 0x08) ? 1 : 0; }这段公式我解释一下。通道0的11bit正好从第1个数据字节的bit0开始所以offset1shift0取byte1整个8bit再取byte2低3bit拼成11bit。通道1起始于bit11也就是byte2的bit3shift3所以把byte2右移3位得到高5位把byte3左移5位得到低6位。后面依次类推。最后和0x07FF相与把高于11bit的部分全部清零。这里务必留意类型转换frame[offset 1] (8 - shift)左移前要先转成uint16_t否则8位变量移位超过自身位数时行为会变成未定义或者得到预期之外的结果。我最早在F103平台上用标准库写这一段时没注意调试时通道1到通道3的值一直跳后来加个打印才发现是位宽问题。4.3 状态机喂数据IDLE中断里提取出来的新旧数据段无论是一段连续内存还是绕弯后的两段不连续内存只要逐字节调用sbus_feed_bytes就行。因为状态机内部有帧索引天然能处理跨缓冲区边界的帧。这也是为什么现场配置里不需要关心“一帧是不是正好落在DMA缓冲的连续区域里”状态机把所有边界问题都消化掉了。中断服务函数里不建议直接做通道值的比例变换、打印或者发送给上层控制任务这些工作放到主循环或者单独的低优先级任务里做。IDLE中断里只要完成喂状态机和必要的数据拷贝。我的实际处理是中断里只更新一个全局结构体主循环通过标志位轮询读取。5. 常见问题与排查经验5.1 收不到数据怎么办如果你把代码烧进去发现SBUS通道值始终是0第一步先量RX引脚的波形。用示波器看RX脚对地电压正常情况下空闲是高电平有数据时是一串脉冲而且波形的波特率在100k左右。如果示波器显示空闲是低电平说明你接到的还是SBUS原始反向信号反相电路没生效。排除硬件后看软件。第一确认CubeMX里DMA Mode是Circular第二确认启动代码里调用过HAL_UART_Receive_DMA第三确认IDLE中断确实进了可以在sbus_idle_isr开头置一个标志或者GPIO翻转配合示波器看是否有输出。还有一个很容易忽略的问题UART的RX引脚有没有被别的初始化代码复用成GPIO尤其是你自己写的外设初始化如果把这个引脚重新配成推挽输出DMA自然搬不到数据。5.2 通道值异常或乱跳通道值解析出来但明显不对一般分两种情况。第一种是帧同步失败表现为所有通道数值都没有规律而且你加大油门后波形乱跳。这种情况重点检查帧头帧尾校验逻辑确认状态机不是被数据里的0x0F干扰。第二种是位解包方向错误表现是部分通道数值正确、部分通道明显偏大或偏小或者通道序号错位。这时候用逻辑分析仪抓一帧原始字节打印出来对着公式慢慢核对最有效。还要检查串口配置SBUS是偶校验和2个停止位如果配成无校验、1个停止位UART会把校验位当作数据位的一部分导致每字节的位排列整体错位解析出来的通道值一定不对。我在调试时遇到过两次这种低级错误每次都是改完配置忘了在CubeMX里重新生成代码。5.3 DMA与IDLE中断冲突如果你发现SBUS跑着跑着突然完全没数据必须重新上电才能恢复大概率是DMA传输完成中断抢了控制权。HAL_UART_Receive_DMA启动后DMA循环一圈就会触发一次TC事件HAL库的回调可能在某个地方把UART接收停掉了。我前面给出的办法是启动后立刻__HAL_DMA_DISABLE_IT(huart3.hdmarx, DMA_IT_TC)这是改动最小、最稳定的做法。另一种更干净的做法是走HAL库新提供的HAL_UARTEx_ReceiveToIdle_DMA接口它本身就是为“接收一段数据到空闲”设计的可以在空闲事件回调里拿到实际长度。但要注意这个接口不是严格意义的循环接收每次回调之后需要重新调用启动函数重新接收。如果你想完全按照HAL库官方思路来做可以试试但我不建议新手用它替代循环DMA方案因为每次重启DMA之间可能出现短暂的数据盲区反而增加了调试难度。5.4 性能和实时性优化建议DMA循环接收对CPU的占用已经非常低在IDLE中断里喂状态机的工作量也就是每帧25次简单判断。真正影响性能的是你拿这些通道值去干什么。比如在主循环里做浮点PID计算、把通道值打印到屏幕这些操作不要放在中断里也不要让它们阻塞IDLE中断超过一个字节间隔。如果后续要做遥控器失联检测直接用第23字节的frame_lost标志比用“多久没收到新帧”判断更及时。如果要用SBUS通道值控制电机记得通道值范围是0到2047中位一般是1024很多遥控器在校准后输出范围是172到1811需要根据你的遥控器型号做一次量程映射。我习惯在sbus_decode_frame里只保留原始通道值映射和滤波放到上层任务去处理这样SBUS解析模块可以保证在任何项目里都是一份可以原样拖走的代码。最后分享一个调试技巧刚上手时不要一上来就接飞控、接电机先用一个USB转串口模块把解析出的16路通道值打印到电脑上看。这一步能帮你快速分开“协议解析问题”和“外部控制问题”。等一串通道值在电脑上稳定且平滑地跟着遥控器摇杆变化再接电机和飞控后面的调试会省心很多。我自己做过的几个项目里SBUS解析模块基本没再返工过靠的就是这套DMA循环、IDLE定边界、状态机保稳定的组合。
