STM32实战:DMA循环接收+IDLE中断高效解析SBUS协议
做航模和飞控开发的人迟早会遇到SBUS协议。最近我在STM32F103上用HAL库调一套串口接收方案核心是DMA循环接收配合串口IDLE空闲中断再用一个状态机来解析SBUS协议帧。折腾完最明显的感觉是CPU占用极低一帧25字节的数据几乎不打扰主循环遥控器刷新率跑到100Hz以上也轻轻松松。这篇文章就把这套方案的完整思路、配置步骤、关键代码和踩过的坑都整理出来。如果你正在做SBUS转PWM、飞控通道采集、遥控接收机转接板或者单纯想搞明白DMA循环接收和IDLE中断到底怎么配合这篇应该能直接给你省下好几个晚上的调试时间。1. 方案选型为什么偏偏是这三个技术组合1.1 SBUS的数据特点决定了接收姿势SBUS最早是Futaba提出的串行总线遥控协议现在几乎成了航模接收机的标配输出方式。它的物理层比较特殊波特率100000bps8个数据位偶校验2个停止位也就是常说的8E2。而且接收机输出引脚上的电平是反相的TTL信号这一条后面调试时坑了很多人。SBUS的一帧数据一般是25字节0x0F起始字节接下来22字节是16个通道各自11bit的数据第24字节是标志位最后一字节是0x00。从串口波形上看一帧大约持续2.5ms帧与帧之间会留出几毫秒的空闲。这个空隙非常重要——它是IDLE中断能被稳定触发的前提。很多人第一反应是用“每收一个字节进一次中断”的方式去读SBUS这在低速遥控器上确实能跑但一旦接收机刷新率高或者主循环里有其他实时任务逐字节中断的CPU开销会非常难看。而且SBUS的数据流并不是每次都能对齐到帧头如果中断处理慢半拍丢一个字节后面一整帧全错。1.2 三种串口接收方案选DMA循环的真正理由市面上常见的串口接收方案有三类逐字节中断、定长DMA接收、DMA循环加空闲中断。逐字节中断的优点是实现直观来一个字节进一次回调代码很容易写。缺点是每25字节就要进25次中断再加上状态机解析逻辑中断里干太多活会导致其他任务卡顿。而且遥控信号在室内环境经常有毛刺干扰一个字节接收异常就可能让后续解析全乱。定长DMA接收是拿一个固定大小的数组DMA收满25字节后触发完成中断然后解析一帧。这个方案平时好用但SBUS有个麻烦不同厂家的接收机帧结构不完全一样。有的末尾是0x00有的会多出校验字节有的甚至不带完整帧尾。一旦遇到数据错位或者首字节丢失定长DMA会一直把后半截垃圾数据当成新帧开头如果没有额外重同步逻辑整个解析就废了。DMA循环接收的巧妙之处在于它让DMA永远在后台把串口数据写进一块环形缓冲区不需要知道帧从哪里开始、在哪里结束。MCU只需要在总线空闲时看一眼DMA写到了哪里把上次处理位置到当前写入位置之间的一整段数据交给状态机去啃。这就相当于把数据按“自然帧间隙”切片而不是按固定长度硬切。再加上循环模式不会自己停下来哪怕处理不及时缓冲区的旧数据被覆盖也不会出现DMA停止后彻底丢流的问题。1.3 状态机解决的是“错位再同步”问题DMA加IDLE中断解决了“怎么高效拿到数据”但没解决“怎么把数据流切成正确帧”。SBUS串口流本质上是一长串连续字节中间虽然有帧间隙但接收机刚上电时、信号受干扰时、接收机主动切换协议时都可能让数据流里出现半截帧、垃圾字节、重复字节。如果直接按位置硬切比如“每次取25字节就解析”一旦边界偏移后面全部解析失败。状态机的作用就是逐字节扫描从任何位置开始都能找到0x0F帧头并积累完整一帧。中途遇到异常就自动回到IDLE状态重新等帧头。这种设计把“解析协议”和“串口接收”彻底解耦DMA负责拿数据状态机负责从数据里挑出合法帧两边互不干扰。这也是我最终选这套组合的根本原因。2. 着手配置CubeMX里的关键设置与参数2.1 100000波特率与8E2在STM32里的正确打开方式SBUS是非标准波特率很多新手在这里直接卡住。拿STM32F103C8T6来说外部8MHz晶振系统主频72MHzUSART1挂在APB2上时钟也是72MHz。波特率100000时USARTDIV 72000000 / (16 * 100000) 45BRR寄存器写入720刚好能整除没有误差。这一点用标准库或HAL库都一样HAL库初始化时直接填100000波特率即可。麻烦的是数据格式。SBUS是8E2但STM32F1的USART寄存器里没有“8个数据位加2个停止位再额外加偶校验”这种直观选项。实际的做法是在CubeMX里把Word Length选为8 BitsParity选EvenStop Bits选2。这时候寄存器层面实际是9bit传输最低8位是数据第9位是偶校验位恰好对应SBUS物理层上“8位数据偶校验”的真实波形。很多人以为选8位就是只收8个bit结果怎么调都收不对。2.2 DMA循环模式与中断优先级的细节CubeMX里给USART1_RX添加DMA请求后有一个非常关键的选项Mode选Circular。循环模式的意思是DMA搬运完设定的长度后内部地址会自动回绕到起始地址继续接收不需要CPU重新配置。这相当于在内存里画了一个永动的环形水池串口数据一直往里灌。数据宽度方面外设和数据都选ByteMemory Increment打开Peripheral Increment不要开。优先级我一般给到High比普通定时器通道高一些。原因是串口数据是实时流如果DMA优先级太低被其他外设频繁抢占总线极端情况下会丢字节。F103这类Cortex-M3内核本身总线带宽有限多个DMA通道同时工作时要格外注意优先级分配。2.3 HAL库接收函数怎么选HAL库里和DMA接收相关的函数有好几个这个方案用的是HAL_UARTEx_ReceiveToIdle_DMA。这个函数做的事情比较“聪明”启动DMA循环接收同时使能USART的空闲中断IDLE Line Interrupt。之后每当串口总线上出现一段空闲硬件就置起IDLE标志HAL库会在中断里自动调用HAL_UARTEx_RxEventCallback。启动代码一般是这样的#define SBUS_DMA_BUF_SIZE 128 static uint8_t sbus_dma_buf[SBUS_DMA_BUF_SIZE]; HAL_UARTEx_ReceiveToIdle_DMA(huart1, sbus_dma_buf, SBUS_DMA_BUF_SIZE);注意这里要求工程使用的HAL库版本不要太老。老版本F1库里可能没有HAL_UARTEx_ReceiveToIdle_DMA只有HAL_UART_Receive_DMA加手动配置IDLE中断的旧套路。如果编译报函数未定义优先升级HAL库版本尤其是用CubeMX重新生成一次工程通常能解决问题。3. 缓冲区读取DMA循环接收的数据切片逻辑3.1 用CNDTR反推DMA当前写入位置DMA循环接收最核心的难题是我怎么知道DMA现在写到了缓冲区的哪个位置答案是看DMA通道的CNDTR寄存器。这个寄存器在F1系列里叫CNDTR在部分系列里叫NDTR作用是记录“当前还没搬运完成的字节数”。每次DMA搬运一个字节CNDTR就减一当减到0时如果处于循环模式就又重新装载初值。因此缓冲区总长度减去CNDTR当前值就是DMA接下来要写入的位置也就是当前最新数据的“尾部”。代码里可以用__HAL_DMA_GET_COUNTER这个宏拿到寄存器的值uint16_t pos SBUS_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx);有了当前写入位置pos再结合上次处理时保存的last_pos就能把两次回调之间新到达的数据干净地捞出来。数据流只有两种情况一种是pos last_pos说明没有发生回绕直接处理[last_pos, pos)这一段另一种是pos last_pos说明缓冲区已经转过一圈需要分两段处理last_pos到缓冲区末尾以及缓冲区开头到pos。3.2 回调里只搬运、不解析的实战写法我在实际项目里HAL_UARTEx_RxEventCallback里从来不做真正的SBUS解析只做数据搬运和标志位设置。原因是这个回调跑在串口中断上下文如果在这里面做逐位解算、浮点运算、打印日志会长时间占用中断后果就是下一帧数据来不及收DMA缓冲区被覆盖系统表现出偶发丢帧。推荐的写法是把数据从DMA缓冲区切片出来塞进一个临时数组然后设置一个“有新数据待处理”的标记主循环检测到标记后再调用状态机解析函数。这样做的好处非常明显中断处理时间缩短到微秒级解析逻辑放到主循环里跑即使解析函数写得再复杂也不会影响串口接收。static volatile uint16_t last_pos 0; static volatile uint8_t sbus_rx_flag 0; static uint8_t sbus_slice_buf[64]; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { uint16_t pos SBUS_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (pos last_pos) return; uint16_t len 0; if (pos last_pos) { len pos - last_pos; memcpy(sbus_slice_buf, sbus_dma_buf[last_pos], len); } else { uint16_t seg1 SBUS_DMA_BUF_SIZE - last_pos; memcpy(sbus_slice_buf, sbus_dma_buf[last_pos], seg1); memcpy(sbus_slice_buf seg1, sbus_dma_buf[0], pos); len seg1 pos; } last_pos pos; sbus_slice_len len; sbus_rx_flag 1; } }主循环里大致这样调用while (1) { if (sbus_rx_flag) { sbus_rx_flag 0; for (uint16_t i 0; i sbus_slice_len; i) { sbus_input_byte(sbus_slice_buf[i]); } } }这里memcpy可以直接用因为last_pos到pos之间不会跨页除非缓冲区本身就是跨RAM页的但普通SRAM连续区域不需要担心。3.3 缓冲区大小怎么定缓冲区大小的选择直接影响丢帧率。太小时比如32字节如果主循环被某个任务卡了几毫秒DMA在循环模式下会覆盖掉还没处理的数据太大则浪费RAM。SBUS一帧25字节每次IDLE中断到来时理论上新增数据量大约就是25字节左右所以缓冲区只要保证“主循环处理一次数据的时间内DMA不会把整个缓冲区覆盖完”就可以。我实测F103C8T6留128字节很稳即使在调试时频繁断点暂停也能恢复。如果板子RAM紧张64字节也能跑但余量比较小。缓冲区大小建议定义为宏方便后续移植时调整。4. SBUS解析状态机的设计4.1 帧格式标准25字节与厂牌差异SBUS标准帧各厂家的实现存在细微差异但主流的25字节格式基本一致字节位置内容00x0F 帧头1 ~ 2216个通道的11bit数据23标志字节ch17 / ch18 / 丢帧 / failsafe240x00 帧尾这里要注意部分接收机型号会在0x00后面再追加几个字节个别型号甚至不发送帧尾。如果代码死板地认为“必须收满25字节且最后是0x00”在部分设备上就会一直解析失败。所以我倾向于状态机按25字节收齐后首先检查帧头是0x0F然后检查尾字节是否为0x00如果尾字节不是0x00说明可能遇到了非标准帧或数据错位直接丢弃这一帧状态机回到IDLE重新等帧头。4.2 状态定义、跳转与超时自恢复状态机设计成三个状态就够了IDLE等帧头、RECEIVING积累数据、收满后解析。如果想让鲁棒性更好可以加一个“超时强制回IDLE”的机制防止在RECEIVING状态卡死。具体逻辑是在IDLE状态时只有遇到0x0F才进入RECEIVING其他字节一律忽略在RECEIVING状态时每收一个字节就计数计到25个字节就尝试解析然后不管成功失败都回到IDLE。为了让状态机能处理“帧中间突然断了”的情况可以在每次收到字节时记录时间戳如果距离上一次收到字节超过2ms到3ms就判定当前帧已经废了强制回到IDLE。一个简化但可用的实现#define SBUS_FRAME_SIZE 25 #define SBUS_STATE_IDLE 0 #define SBUS_STATE_RECEIVING 1 #define SBUS_FRAME_TIMEOUT_MS 3 static uint8_t sbus_rx_state SBUS_STATE_IDLE; static uint8_t sbus_frame_buf[SBUS_FRAME_SIZE]; static uint8_t sbus_rx_cnt 0; static uint32_t sbus_last_byte_time 0; void sbus_input_byte(uint8_t data) { uint32_t now HAL_GetTick(); if (sbus_rx_state SBUS_STATE_RECEIVING) { if (now - sbus_last_byte_time SBUS_FRAME_TIMEOUT_MS) { sbus_rx_cnt 0; sbus_rx_state SBUS_STATE_IDLE; } } if (sbus_rx_state SBUS_STATE_IDLE) { if (data 0x0F) { sbus_frame_buf[0] data; sbus_rx_cnt 1; sbus_rx_state SBUS_STATE_RECEIVING; } } else if (sbus_rx_state SBUS_STATE_RECEIVING) { sbus_frame_buf[sbus_rx_cnt] data; if (sbus_rx_cnt SBUS_FRAME_SIZE) { if ((sbus_frame_buf[0] 0x0F) (sbus_frame_buf[SBUS_FRAME_SIZE - 1] 0x00)) { sbus_parse_frame(sbus_frame_buf); } sbus_rx_cnt 0; sbus_rx_state SBUS_STATE_IDLE; } } sbus_last_byte_time now; }这里用HAL_GetTick()做超时判断毫秒精度对SBUS来说够用因为帧内字节间隔远小于1ms帧间空闲通常超过2ms。如果项目对实时性要求更高可以把时间戳换成DWT或定时器的微秒计数。4.3 11bit通道数据的位提取SBUS的16个通道值并不是每个占一个字节而是11bit一个紧密排列在22个字节里。这意味着第2个通道的起始位可能不在字节边界上必须做位拼接。简单来说每个通道值的起始位位置是8 通道号 * 11。因为帧头占了1个字节也就是8个bit。从帧数据里取出该位置之后的连续11个bit就是通道值。实现时用32位整数把起始位置的3个字节装进来再右移起始偏移量最后用0x07FF掩码取出低11位。void sbus_parse_frame(const uint8_t *frame, uint16_t *channels) { for (uint16_t ch 0; ch 16; ch) { uint16_t bit_pos 8 ch * 11; uint16_t byte_pos bit_pos 3; uint8_t bit_off bit_pos 0x07; uint32_t v frame[byte_pos] | ((uint32_t)frame[byte_pos 1] 8) | ((uint32_t)frame[byte_pos 2] 16); channels[ch] (v bit_off) 0x07FF; } uint8_t flags frame[23]; // bit0: ch17, bit1: ch18, bit2: frame lost, bit3: failsafe }这里有一个容易忽略的细节frame[byte_pos 2]最大访问到下标23刚好在25字节范围内不会越界。但当通道号接近15时如果缓冲区长度判定不严就有风险所以调用前必须确保传入的是完整25字节。拿到通道值后通常还要做一次映射。SBUS通道原始范围是0到2047中位值约1024。遥控器摇杆收到的典型范围往往是172到1811不同遥控器差异很大。实际使用中需要根据具体遥控器做校准把原始值映射到0到100%或者PWM脉宽范围。5. 调试实录从收不到数据到稳定跑起来5.1 硬件反相最容易被忽视的问题SBUS接收机输出的信号是反相电平。也就是说逻辑1在线上是低电平逻辑0是高电平。STM32的USART外设标准接收逻辑认的是正向电平直接接上去会收到一堆乱码甚至完全无数据。F103系列的USART寄存器没有提供RXINV反相位较新系列像F4的某些型号倒是支持但我建议不要依赖这个功能硬件上做一个反相电路最稳妥。我常用的做法是用一个NPN三极管做电平反相或者直接用74HC04非门芯片。很多飞控板上也会把SBUS信号分成正反两路焊盘如果手头有这种接收机直接飞线到反相输出点就行。调试时可以用逻辑分析仪先看SBUS引脚波形。如果看到的是正常UART波形只是高低电平跟预期相反那基本就是反相问题比怀疑代码效率高得多。5.2 常见问题排查速查表现象可能原因解决办法完全收不到数据信号没反相、波特率不对、没共地逻辑分析仪看波形补反相电路确认100000bps检查GND收到数据但全乱码8E2配置错误、时钟误差大确认Word Length8Bits、ParityEven、Stop2状态机永远不在RECEIVING帧头被干扰、缓冲区太小被覆盖增大DMA缓冲区检查IDLE回调耗时通道值偶尔跳动偶校验未开启、DMA优先级过低确认ParityEven提高DMA优先级IDLE回调触发频率异常高总线上有其他噪声导致虚假空闲检查信号质量增大超时加滤波编译报HAL_UARTEx_RxEventCallback未定义HAL库版本过旧升级HAL库重新用CubeMX生成工程5.3 逻辑分析仪与GPIO翻转验证法我调试这套方案时最常用的一招是在IDLE回调里翻转一个GPIO然后拿逻辑分析仪看翻转频率。正常情况下SBUS刷新率是多少这个GPIO就应该以同样的频率翻转。如果翻转频率忽高忽低说明IDLE中断触发不稳定要么是数据流本身有问题要么是DMA缓冲区处理逻辑有bug。另一种验证方法是在状态机每次成功解析出一帧后翻转另一个GPIO。两个GPIO同时看就能直观看到“串口空闲触发”和“完整帧解析成功”之间是否有对应关系。这套方法比单纯用串口打印调试高效得多因为printf本身会占用串口和CPU时间在中断里用还会干扰时序。6. 优化与扩展让这套框架更抗造6.1 丢帧、failsafe与通道异常处理SBUS协议里帧的flag字节包含failsafe和丢帧标志。如果接收机检测到遥控信号丢失它会持续输出带有failsafe标志的帧通道值也会被覆盖成预设的失败保护值。解析时如果只关心通道原始值不注意failsafe标志轻则出现舵面乱跳重则引发安全问题。我的做法是解析完帧后立刻检查frame[23]的第2位和第3位即丢帧位和failsafe位。一旦置位就标记“当前遥控数据不可信”。上层业务逻辑看到这个标记后才会决定是进入失效保护还是维持上一帧输出而不是直接拿解析结果去驱动舵机或电机。6.2 兼容24字节/带CRC等多种SBUS变体有些接收机的SBUS帧在标准25字节之外还有附加信息常见的是在末尾追加一个CRC字节。状态机如果死等25字节遇到这种帧会在解析时因为帧尾不是0x00而丢弃结果就是永远卡在“丢帧”状态。更稳妥的做法是在状态机收满一个完整帧后先校验帧头再看帧尾是否符合预期。如果不符不要立刻清空状态机而是尝试把缓冲区里的数据整体向后挪一个字节继续扫描。甚至可以加一个“帧长计数器”分别记录24、25、26字节这几种常见长度谁先满足头部和尾部条件就按谁解析。这样一台接收机即使输出格式略有差异代码也能自适应。6.3 移植到F4/G4/H7平台的小建议这套方案的核心逻辑跟具体芯片型号无关移植到F4、G4、H7时只需要注意几点DMA句柄名称不同__HAL_DMA_GET_COUNTER的参数要改成目标板实际生成的句柄部分系列的CNDTR寄存器名称叫NDTR但宏封装后用法一致H7系列的DMA库和中断回调命名略有差异需要参考对应HAL手册。另外新系列MCU的SRAM更大缓冲区可以适当加大到256字节进一步降低丢帧风险。如果项目里同时使用多个串口DMA的缓冲区、last_pos、状态机变量都要做成每个串口独立一份回调里根据huart-Instance区分数据来源。不要试图共用一套变量否则两个串口的数据会互相污染排查起来非常痛苦。最后再分享一个小技巧我在实际使用中把DMA缓冲区从32字节提到128字节之后偶发丢帧基本消失IDLE回调里除了拷贝数据和置标志位绝对不做解析和打印后来又把解析函数从回调挪到了主循环遥控器通道刷新终于稳定得像钟表一样。这套方案后续还能继续扩展比如把状态机单独拆成模块同时解析多路SBUS或者在一帧解析完成后通过DMA发送应答数据都属于水到渠成的事。