STM32 DMA循环接收与IDLE中断实现SBUS协议解析

发布时间:2026/9/26 2:38:30
STM32 DMA循环接收与IDLE中断实现SBUS协议解析 1. 为什么SBUS解析值得单独拎出来讲SBUS这玩意儿在航模和机器人圈子里太常见了一根线就能传16个通道接线简单、抗干扰还不错很多接收机、飞控、舵机控制器都在用。但你要是真拿STM32的HAL库去接它会发现一个很尴尬的事它既不是标准UART也不是SPI更不是I2C而是一个反相、100kbps、8E2、25字节一帧的串行协议。你直接用HAL_UART_Receive去收要么收不满要么收多了要么帧头对不上最后数据全是乱的。我最早做SBUS解析的时候用的是最笨的办法开一个25字节的缓冲区在UART中断里一个字节一个字节往里塞塞满25个再置个标志位让主循环处理。这个方法能跑但问题很明显——波特率100k、帧率通常14ms一帧意味着每14ms就有25个字节要处理中断频率不算高但也不低而且一旦主循环稍微卡一下缓冲区就可能被下一帧覆盖。后来换成DMA循环接收IDLE中断CPU占用直接降到几乎为零帧同步也稳了很多。这篇文章就是把我这套DMA循环接收 IDLE中断 状态机的方案完整拆开讲一遍。不是那种“复制粘贴就能跑”的流水账而是把每个环节为什么这么设计、参数怎么算、坑在哪里都说清楚。适合已经会用CubeMX配UART、但对DMA和IDLE中断配合还不太熟的嵌入式开发者也适合正在做航模接收机、机器人遥控、云台控制这类项目的朋友。提示SBUS是Futaba的专有协议但协议格式是公开的本文只讨论协议解析本身不涉及任何射频或遥控设备的具体实现。2. SBUS协议的帧格式与电气特性拆解2.1 一帧25字节到底长什么样SBUS一帧固定25个字节结构非常规整字节位置内容说明00x0F帧头固定值1-22通道数据16个通道每个11位共22字节23标志位bit0信号丢失bit1失控保护bit2故障保护240x00帧尾固定值16个通道×11位176位22字节刚好填满第1到第22字节。每个通道的值范围是0-2047对应舵机行程的0-100%。这个11位的打包方式有点绕后面讲状态机的时候会详细说怎么解。帧头0x0F和帧尾0x00是判断帧边界最直接的依据但不能只靠帧头帧尾做同步因为通道数据里完全可能出现0x0F或0x00。真正可靠的同步方式是靠IDLE中断判断帧间隔这个后面会展开。2.2 为什么必须反相SBUS的电气特性是反相UART也就是说空闲时是低电平起始位是高电平和标准UART完全相反。波特率100kbps数据格式是8位数据、偶校验、2位停止位8E2。这里有个坑STM32的UART硬件不支持反相所以你必须在外部加一个反相电路。最简单的方案是用一个NPN三极管加两个电阻做反相或者用74HC14这类施密特反相器。我实测下来三极管方案成本最低但要注意基极电阻和上拉电阻的取值否则边沿会变缓100k波特率下容易误码。如果你用的是STM32G4或者H7系列有些型号的UART支持TX/RX引脚交换和反相配置可以直接在寄存器里搞定不需要外部电路。但F1和F4系列就别想了老老实实加反相器。注意反相之后UART的RX引脚接的是反相器的输出不是直接接SBUS信号线。如果你发现收不到数据先用示波器看一下RX引脚上的波形是不是标准UART的空闲高电平。2.3 100k波特率下的时序余量100kbps意味着每个位宽10微秒一个字节8E2共12位120微秒25字节一帧3毫秒。帧率通常14ms一帧也就是说3ms传输、11ms空闲。这个空闲间隔就是IDLE中断能派上用场的地方。STM32的UART在接收完一个字节后如果下一个字节没有在预期时间内到达就会触发IDLE中断。IDLE中断的触发条件是RX线上出现一个字节时间的空闲电平。在100k波特率下一个字节时间是120微秒所以只要帧间空闲超过120微秒IDLE就会触发。14ms的帧间隔远远大于这个值所以每帧结束后必然会触发一次IDLE中断。这个特性太关键了。它意味着你不需要在软件里数25个字节来判断帧结束硬件会告诉你“这一帧收完了”。配合DMA循环接收你只需要在IDLE中断里把当前DMA写指针的位置读出来就能知道这一帧收了多少个字节、从哪里开始、到哪里结束。3. CubeMX配置里那些容易配错的地方3.1 UART参数8E2不是随便选的在CubeMX里配置UART的时候很多人会习惯性地选8N1然后发现收不到正确数据。SBUS必须配成8位数据、偶校验、2位停止位。具体在CubeMX里的操作是Baud Rate: 100000Word Length: 8 Bits (with Parity)Parity: EvenStop Bits: 2Data Direction: Receive Only如果你只收不发这里有个细节Word Length选“8 Bits (with Parity)”之后实际的数据位是8位校验位是1位加起来9位。如果你选成“9 Bits”那就错了STM32会把它当成9位数据处理校验位就没了。另外Over Sampling建议保持默认的16倍采样。100k波特率下16倍采样意味着采样时钟1.6MHzSTM32的UART时钟通常挂APB1或APB2完全带得动。如果你改成8倍采样虽然也能跑但抗噪能力会下降没必要。3.2 DMA配置循环模式是核心DMA的配置是这套方案的关键。在CubeMX里添加UART_RX的DMA请求模式选Circular数据宽度选Byte内存地址自增外设地址不自增。为什么必须用循环模式因为SBUS是连续不断的帧流你不能每收一帧就重新配置一次DMA。循环模式下DMA会在缓冲区末尾自动回绕到开头永远不需要软件干预。你只需要在IDLE中断里读一下当前写指针的位置就能知道这一帧的数据范围。缓冲区大小建议设为50字节也就是两帧的容量。为什么是两帧因为IDLE中断的处理可能会有延迟如果缓冲区刚好只够一帧下一帧的头几个字节可能会覆盖上一帧的尾部。50字节给了一定的余量即使IDLE中断晚处理几微秒也不会丢数据。提示DMA缓冲区的内存地址最好对齐到4字节边界虽然Byte宽度的DMA不强制要求对齐但对齐之后访问效率更高也避免一些编译器优化带来的奇怪问题。3.3 NVIC配置IDLE中断的优先级IDLE中断的优先级要设得合理。它不像串口接收中断那样每个字节都触发而是每帧只触发一次所以优先级不需要太高但也不能太低否则主循环里如果有长时间阻塞的操作IDLE中断迟迟得不到响应DMA写指针可能已经绕了一圈你就分不清哪段数据是哪一帧了。我的建议是IDLE中断优先级设为中等偏上比如抢占优先级2、子优先级0。比SysTick低一点比一般的定时器中断高一点。这样既能保证及时响应又不会打断关键的系统节拍。另外别忘了在NVIC里使能UART的全局中断。CubeMX里勾选“UART global interrupt”之后IDLE中断才能被CPU响应。很多人只勾了DMA中断忘了UART中断结果IDLE死活不进查半天查不出来。4. DMA循环接收与IDLE中断的配合逻辑4.1 循环模式下怎么知道一帧从哪开始这是整套方案里最核心的问题。DMA循环接收意味着写指针会不断回绕你没法简单地用“缓冲区开头就是帧头”来判断。正确的做法是在IDLE中断里用当前DMA写指针的位置反推这一帧的起始位置。具体来说假设DMA缓冲区大小是50字节当前写指针位置是pos上一帧结束时的写指针位置是last_pos。那么这一帧的数据范围就是从last_pos到pos如果pos last_pos或者从last_pos到缓冲区末尾再加上缓冲区开头到pos如果pos last_pos。但这里有个问题SBUS一帧固定25字节如果你在IDLE中断里发现这一帧的长度不是25那说明要么是噪声干扰要么是DMA配置有问题。正常情况下每帧长度应该严格等于25。我通常会在IDLE中断里做三件事读取当前DMA写指针位置用__HAL_DMA_GET_COUNTER或者直接读NDTR寄存器计算这一帧的起始位置和长度如果长度等于25把数据拷贝到解析缓冲区置一个标志位让主循环处理4.2 用NDTR寄存器反推写指针STM32的DMA有一个NDTRNumber of Data to Transfer寄存器它记录的是剩余待传输的数据量。在循环模式下NDTR会从缓冲区大小递减到0然后自动重载为缓冲区大小继续递减。所以当前写指针的位置等于pos BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart_rx);这个公式在循环模式下永远成立。__HAL_DMA_GET_COUNTER宏返回的就是NDTR的值。注意这个宏在不同系列的HAL库里名字可能略有不同F1/F4系列通常是__HAL_DMA_GET_COUNTERG0/G4系列可能是__HAL_DMA_GET_COUNTER或者直接读寄存器。注意NDTR在DMA传输过程中是实时变化的你在IDLE中断里读到的值就是那一刻的剩余量。因为IDLE中断触发时DMA已经停止传输帧结束了所以读到的值就是这一帧结束时的位置非常准确。4.3 IDLE中断里到底该做多少事我见过有人在IDLE中断里直接做完整的SBUS解析把16个通道全部解出来。这样做不是不行但中断服务函数应该尽量短解析这种耗时操作最好放到主循环里。我的做法是IDLE中断只做数据搬运和标志位置位。具体来说就是把DMA缓冲区里这一帧的25个字节拷贝到一个独立的frame_buffer里然后置一个frame_ready标志。主循环检测到这个标志后再调用解析函数处理。这样做的好处是中断响应时间极短通常只有几微秒不会影响其他中断的实时性。而且拷贝25个字节的开销很小用memcpy或者一个简单的for循环都能搞定。void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t pos SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t last sbus_last_pos; if (pos ! last) { uint16_t len; if (pos last) { len pos - last; memcpy(sbus_frame, sbus_buf[last], len); } else { len SBUS_BUF_SIZE - last pos; memcpy(sbus_frame, sbus_buf[last], SBUS_BUF_SIZE - last); memcpy(sbus_frame[SBUS_BUF_SIZE - last], sbus_buf, pos); } if (len 25) { sbus_frame_ready 1; } sbus_last_pos pos; } } }这段代码里有个细节__HAL_UART_CLEAR_IDLEFLAG的调用时机。必须在读取NDTR之前清除IDLE标志否则如果清除晚了新的IDLE中断可能会丢失。但也不能太早清除否则如果DMA还没完全停止读到的NDTR可能不准确。实测下来先清标志再读NDTR是安全的因为IDLE中断触发时DMA已经完成了当前帧的传输。5. 状态机解析把22字节拆成16个通道5.1 11位打包的位操作逻辑SBUS的16个通道每个11位打包在22个字节里。打包方式是这样的通道1字节1的bit0-7 字节2的bit0-2通道2字节2的bit3-7 字节3的bit0-5通道3字节3的bit6-7 字节4的bit0-7 字节5的bit0...以此类推这种非字节对齐的打包方式用纯位操作来解比较繁琐但有一个很巧妙的办法把22个字节当成一个176位的位流然后用移位的方式依次取出11位。具体做法是维护一个32位的累加器和一个位计数器。每次从字节流里取一个字节塞进累加器当累加器里的有效位数达到11位时取出低11位作为一个通道值然后右移11位继续取下一个字节。void sbus_parse(uint8_t *frame, uint16_t *channels) { uint32_t accumulator 0; uint8_t bits 0; uint8_t byte_idx 1; uint8_t ch_idx 0; while (ch_idx 16) { while (bits 11) { accumulator | (uint32_t)frame[byte_idx] bits; bits 8; } channels[ch_idx] accumulator 0x7FF; accumulator 11; bits - 11; } }这段代码的逻辑很清晰不断往累加器里塞字节直到有效位数够11位就取出一个通道。取完之后右移11位剩下的位继续参与下一个通道的组装。22个字节刚好够16个通道不多不少。5.2 标志位和失控保护的判断第23个字节是标志位bit0表示信号丢失Failsafebit1表示失控保护Frame Lostbit2表示故障保护Failsafe Activated。这三个标志位的含义在不同接收机上可能略有差异但通常bit0置位接收机没有收到发射机的信号bit1置位接收机收到了信号但帧丢失bit2置位接收机进入了失控保护模式在实际项目中我通常只关心bit0和bit1。如果bit0置位说明信号丢了这时候通道数据不可信应该保持上一次的有效值或者进入预设的失控保护状态。如果bit1置位说明信号质量不好但数据可能还能用可以做一个简单的滤波。提示有些接收机的标志位定义和标准SBUS不完全一样最好先用逻辑分析仪抓几帧实际数据确认标志位的含义再写代码。5.3 状态机在解析中的角色严格来说上面那个位操作解析函数本身就是一个简单的状态机状态是“累加器里有多少位”转移条件是“是否够11位”。但在更复杂的场景下比如你需要处理帧同步、错误恢复、多帧缓存就需要一个更完整的状态机。我通常会把整个SBUS处理流程分成三个状态状态含义转移条件IDLE等待帧头收到0x0FRECEIVING接收25字节收满25字节COMPLETE帧接收完成解析成功或失败但在DMAIDLE的方案里这个状态机其实被硬件和中断替代了。IDLE中断负责判断帧边界DMA负责搬运数据软件状态机只需要处理“帧就绪”和“解析完成”两个状态。这也是这套方案比纯中断方案优雅的地方——硬件做了大部分脏活累活。6. 实测中遇到的坑与排查过程6.1 第一帧数据总是错的这个问题我遇到过好几次。现象是上电后第一帧解析出来的通道值全是乱的从第二帧开始就正常了。排查了很久才发现原因是DMA在初始化完成后就已经开始接收数据了但IDLE中断的第一次触发可能发生在帧中间。具体来说DMA启动后SBUS信号可能正好处于某一帧的中间位置DMA从那里开始写入缓冲区。第一次IDLE中断触发时缓冲区里的数据是从帧中间开始的不是完整的25字节。如果你不做判断直接解析第一帧肯定是错的。解决办法很简单在IDLE中断里判断帧长度只有长度等于25才置位frame_ready。第一帧长度不对直接丢弃从第二帧开始就是完整的了。这个逻辑我在上面的代码里已经体现了。6.2 IDLE中断不进或者进得太频繁IDLE中断不进最常见的原因是NVIC里没有使能UART全局中断。CubeMX里勾了“UART global interrupt”之后生成的代码里会有HAL_NVIC_EnableIRQ(USART1_IRQn)但如果你手动改了中断服务函数的名字或者用了自己的IRQHandler就可能覆盖掉HAL库的默认实现。另一个原因是IDLE标志没有正确清除。STM32的IDLE标志清除方式比较特殊需要先读SR寄存器再读DR寄存器。HAL库提供了__HAL_UART_CLEAR_IDLEFLAG宏但如果你用的是标准库或者寄存器操作就要手动执行这个序列。IDLE中断进得太频繁通常是因为波特率配置不对。如果实际波特率和配置的100k偏差太大IDLE的触发条件就会变得很奇怪。用示波器量一下RX引脚上的位宽确认是不是10微秒。如果偏差超过5%就要检查UART时钟源和分频系数。6.3 通道值跳动很大通道值跳动大一般有三个原因电源噪声SBUS接收机对电源比较敏感如果和电机、电调共用电源纹波会耦合到信号线上。解决办法是给接收机单独供电或者在信号线上加一个低通滤波。地线环路如果接收机和STM32的地线不是单点接地地电流会在信号线上产生压降。用粗一点的地线或者加一个共模电感。解析错误如果位操作有bug某些通道的值会周期性跳变。用已知的固定通道值比如油门通道推到中位测试看解析出来的值是否稳定在1024附近。我遇到过一次通道值跳动查了半天发现是DMA缓冲区的内存地址没有对齐。虽然Byte宽度的DMA不强制对齐但某些编译器会把缓冲区分配到奇地址导致DMA传输时出现字节错位。把缓冲区声明为__attribute__((aligned(4)))之后问题就消失了。6.4 帧率不稳定或者丢帧帧率不稳定首先要确认SBUS接收机本身的帧率是不是稳定的。有些接收机在信号弱的时候会自动降低帧率这是正常行为。如果接收机帧率稳定但STM32这边丢帧就要检查IDLE中断的处理时间。如果IDLE中断里做了太多事情比如直接解析16个通道中断处理时间可能超过14ms导致下一帧的IDLE中断被延迟甚至丢失。把解析放到主循环里中断里只做拷贝就能解决这个问题。另一个可能的原因是DMA缓冲区太小。如果缓冲区只有25字节DMA写指针在IDLE中断处理期间可能已经绕了一圈覆盖了上一帧的数据。用50字节缓冲区给中断处理留出足够的时间余量。7. 性能优化与进阶玩法7.1 用双缓冲进一步降低CPU占用单缓冲方案已经能把CPU占用降到很低了但如果你追求极致可以用双缓冲。具体做法是DMA配置为循环模式缓冲区大小设为50字节两帧IDLE中断里判断当前写指针落在哪个半区然后把那个半区的数据交给主循环处理同时DMA继续往另一个半区写。双缓冲的好处是完全避免了数据拷贝。主循环直接读DMA缓冲区里的数据不需要在中断里memcpy。但缺点是逻辑稍微复杂一点需要处理半区边界和写指针回绕的情况。我实测下来单缓冲方案在100k波特率、14ms帧率下CPU占用不到1%完全够用。双缓冲更适合波特率更高、帧率更快的场景比如某些高速SBUS变种。7.2 把解析结果映射到PWM输出SBUS解析出来的是0-2047的通道值如果你要驱动舵机或者电调需要把它映射到PWM的占空比。标准舵机的PWM是50Hz、1ms-2ms脉宽对应0-180度。映射公式是pwm_pulse 1000 (channel_value * 1000) / 2047;这样0对应1000微秒2047对应2000微秒中间值1024对应1500微秒。如果你用的是电调脉宽范围可能是1000-2000微秒公式一样。注意有些舵机的实际行程不是标准的1ms-2ms可能需要微调。我通常会在代码里留一个校准系数方便现场调整。7.3 失控保护逻辑怎么做才安全失控保护是SBUS应用里必须考虑的问题。当接收机报告信号丢失时你不能让舵机保持最后的位置也不能让它乱动。正确的做法是检测到bit0或bit1置位时启动一个超时计数器如果超时超过预设值比如500ms把所有通道切换到预设的安全值安全值通常是油门收到底、舵面回中如果信号恢复平滑过渡回正常控制这个逻辑最好放在主循环里用一个简单的状态机实现。不要在中断里做因为失控保护涉及多个通道的协调中断里做容易出问题。7.4 用逻辑分析仪验证解析结果调试SBUS的时候逻辑分析仪是必备工具。我用的是Saleae Logic 8配合SBUS解码插件可以直接看到16个通道的实时值。把STM32解析出来的值和逻辑分析仪的值对比就能快速定位是硬件问题还是软件问题。如果没有逻辑分析仪也可以用STM32的另一个UART把解析结果打印出来但要注意打印不能影响SBUS的接收。最好用DMA方式的UART发送或者把打印频率降到1Hz以下。8. 代码结构建议与移植注意事项8.1 文件划分和接口设计我通常会把SBUS相关的代码分成三个文件sbus.h结构体定义、函数声明、宏定义sbus.c初始化、IDLE中断处理、解析函数main.c主循环里的帧就绪检测和通道映射对外接口只暴露三个函数void SBUS_Init(UART_HandleTypeDef *huart); uint8_t SBUS_IsFrameReady(void); void SBUS_GetChannels(uint16_t *channels);这样主循环里只需要轮询SBUS_IsFrameReady有帧就调SBUS_GetChannels拿数据逻辑非常清晰。中断服务函数放在sbus.c里通过extern引用huart句柄。8.2 从F1移植到F4或G4要注意什么F1和F4的DMA架构基本一样移植起来问题不大。主要注意两点DMA中断标志的清除方式F1和F4的DMA中断标志清除寄存器不同HAL库已经封装好了但如果你直接操作寄存器就要注意。UART的IDLE标志清除序列F1和F4都是先读SR再读DR但G4系列可能需要先读ISR再读RDR名字变了但逻辑一样。从F4移植到G4最大的变化是DMA的请求映射。G4系列的DMA请求和通道的对应关系和F4不一样CubeMX会自动处理但如果你手动配置就要查参考手册。8.3 中断优先级的实际配置建议最后说一下中断优先级的实际配置。我的项目里通常是这样安排的中断抢占优先级子优先级说明SysTick00系统节拍最高DMA10DMA传输完成次高UART IDLE20帧边界检测中等定时器30一般定时任务较低这个配置在STM32F4上跑得很稳IDLE中断不会因为其他中断的阻塞而丢失帧边界。如果你用的是G4或H7中断优先级分组可能不同需要根据实际情况调整。提示如果你的项目里还有USB、以太网等高优先级中断IDLE中断的优先级要相应提高否则可能被长时间阻塞。但也不要设得比SysTick还高否则会影响系统节拍。整套方案我从F1用到F4再到G4跑了不下十个项目稳定性非常好。核心思想就是让硬件做它擅长的事DMA负责搬运数据IDLE中断负责判断帧边界软件只做解析和业务逻辑。这样各司其职CPU占用低实时性也有保障。如果你正在做SBUS相关的项目可以直接把这套框架拿去用根据自己的硬件平台微调一下参数就行。