ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 DMA+IDLE中断+状态机实现SBUS解析,CPU占用率几乎为零

STM32 DMA+IDLE中断+状态机实现SBUS解析,CPU占用率几乎为零 1. 为什么SBUS解析值得单独拎出来讲SBUS这个协议在航模和机器人圈子里太常见了接收机、飞控、舵机控制器之间基本都用它通信。但很多刚上手STM32的朋友第一次接SBUS就懵了明明是串口为什么波特率要设成100000为什么数据是反相的为什么一帧25个字节里塞了16个通道更头疼的是SBUS帧与帧之间的间隔不固定用传统的“收完一帧再处理”的阻塞方式要么丢帧要么CPU被串口中断拖死。我自己最早做SBUS解析的时候用的是最笨的办法串口每收到一个字节进一次中断在中断里拼帧、判断帧头帧尾。结果主循环里稍微跑点别的逻辑遥控通道就开始抖严重的时候直接失控。后来换成DMA循环接收加IDLE中断再配一个状态机做帧同步CPU占用率从原来的百分之十几直接降到几乎为零通道数据也稳得一批。这套方案的核心思路其实不复杂DMA负责把串口数据无声无息地搬到内存缓冲区IDLE中断负责告诉我们“这一波数据收完了”状态机负责在缓冲区里找到完整的SBUS帧并解析出16个通道值。三个东西各司其职配合起来就是一套非常优雅的异步接收方案。这篇文章我会把整个实现过程拆开讲清楚包括CubeMX怎么配、DMA循环模式和普通模式到底选哪个、IDLE中断里为什么要做缓冲区切换、状态机怎么设计才能不丢帧不误判。如果你正在做航模接收机、机器人遥控、或者任何需要解析SBUS的项目这套代码可以直接拿去用。2. SBUS协议的几个关键细节绕不开也躲不掉2.1 电气特性和串口参数的反常识设定SBUS的电气层是反相UART也就是说它的空闲电平是低电平起始位是高电平。这就意味着你不能直接把接收机的信号线接到STM32的RX引脚上中间必须加一个反相电路。最常见的做法是用一个NPN三极管加两个电阻搭一个反相器或者直接用74HC14这类反相施密特触发器。我实测下来用三极管方案成本最低一个S8050加两个1K电阻就够了但要注意三极管的开关速度太慢的型号在100000波特率下波形会变形。串口参数方面SBUS的波特率是100000不是常见的9600或115200。数据位8位偶校验2位停止位。这里有个坑STM32的HAL库在配置串口时如果你选了偶校验HAL库默认会把数据位当成9位来处理因为校验位算在数据位里。所以CubeMX里要选“8位数据偶校验”生成的代码里实际上会配置成9位数据位加校验。这个细节如果不注意收上来的数据会莫名其妙多一位或者少一位。注意有些STM32型号的USART在偶校验模式下硬件会自动把校验位从数据中剥离但HAL库的接收函数行为可能不一致。建议在初始化完成后先用示波器或者逻辑分析仪抓一下波形确认波特率和数据格式都对得上。2.2 25字节帧结构里藏着什么SBUS一帧固定25个字节结构是这样的字节位置内容说明00x0F帧头1-22通道数据16个通道每个11位共176位正好22字节23标志位bit0通道17bit1通道18bit2帧丢失bit3失效保护240x00帧尾16个通道每个11位这个打包方式很紧凑。解析的时候需要把22个字节当成一个176位的位流然后每11位切一刀。具体做法是把22个字节拼成一个大整数然后从低位开始每11位取一次。这里要注意字节序SBUS是小端模式第一个字节的低位对应通道1的低位。我见过不少人在这里翻车直接用移位操作从字节数组里取11位结果通道值全是乱的。正确的做法是先定义一个32位的临时变量把相邻的几个字节拼进去再移位取值。下面这段代码是我用了很久的解析逻辑void sbus_decode(uint8_t *buf, uint16_t *channels) { channels[0] ((buf[1] | buf[2]8) 0x07FF); channels[1] ((buf[2]3 | buf[3]5) 0x07FF); channels[2] ((buf[3]6 | buf[4]2 | buf[5]10) 0x07FF); channels[3] ((buf[5]1 | buf[6]7) 0x07FF); channels[4] ((buf[6]4 | buf[7]4) 0x07FF); channels[5] ((buf[7]7 | buf[8]1 | buf[9]9) 0x07FF); channels[6] ((buf[9]2 | buf[10]6) 0x07FF); channels[7] ((buf[10]5| buf[11]3) 0x07FF); channels[8] ((buf[11]8| buf[12] | buf[13]8) 0x07FF); channels[9] ((buf[13]3| buf[14]5) 0x07FF); channels[10] ((buf[14]6| buf[15]2 | buf[16]10) 0x07FF); channels[11] ((buf[16]1| buf[17]7) 0x07FF); channels[12] ((buf[17]4| buf[18]4) 0x07FF); channels[13] ((buf[18]7| buf[19]1 | buf[20]9) 0x07FF); channels[14] ((buf[20]2| buf[21]6) 0x07FF); channels[15] ((buf[21]5| buf[22]3) 0x07FF); }这段代码看起来有点绕但逻辑是固定的每个通道的11位可能跨越2到3个字节用移位和或运算拼起来就行。实际项目中我会把它封装成一个函数输入是25字节的帧缓冲区输出是16个通道的数组。2.3 帧间隔不固定带来的接收难题SBUS接收机通常以14ms为周期发送数据但这个周期不是严格固定的会有几毫秒的抖动。更麻烦的是如果接收机没有信号它可能完全不发数据或者发一些无效帧。这就意味着你不能用“定时收25个字节”的方式来做必须用一种“数据来了就收收完就处理”的异步机制。传统的串口接收中断方式在这里有两个问题第一每个字节进一次中断25个字节就是25次中断如果主频不高中断开销很可观第二你无法知道一帧什么时候结束只能靠字节计数一旦丢了一个字节后面全乱。DMA加IDLE中断的组合正好解决这两个问题。DMA把串口数据自动搬到内存不需要CPU干预IDLE中断在串口总线空闲时触发告诉我们“这一波数据传输结束了”。两者结合CPU只需要在IDLE中断里处理一次数据效率极高。3. CubeMX配置里那几个容易配错的选项3.1 串口参数配置的细节在CubeMX里配置USART的时候有几个地方需要特别注意。首先是波特率直接填100000。然后是数据位和校验位数据位选8位校验位选偶校验停止位选2位。这里CubeMX会显示“8位数据偶校验”但生成的代码里实际上会配置成9位数据位因为校验位占了一位。这是正常的不用改。DMA配置方面在USART的DMA设置里添加一个接收通道模式选“Circular”循环模式数据宽度都选Byte。循环模式的意思是DMA搬完一圈之后自动回到开头继续搬不需要软件重新启动。这对于持续接收SBUS数据非常合适。IDLE中断的使能不在CubeMX的图形界面里需要生成代码后手动添加。在MX_USARTx_UART_Init()函数末尾加上__HAL_UART_ENABLE_IT(huartx, UART_IT_IDLE);这行代码的作用是打开串口的空闲线路检测中断。当串口RX线上连续一个字节时间没有新数据时硬件就会置位IDLE标志触发中断。3.2 DMA缓冲区大小的选择DMA接收缓冲区的大小需要仔细考虑。SBUS一帧25字节但接收机可能连续发送多帧或者因为干扰产生一些垃圾数据。如果缓冲区太小比如只开25字节DMA指针会很快绕回来覆盖未处理的数据。如果太大又会浪费内存而且IDLE中断处理时需要扫描整个缓冲区效率降低。我的经验是开两倍帧长也就是50字节。这样即使连续来两帧也不会覆盖。同时在IDLE中断里我会记录DMA当前剩余计数算出这一波接收了多少字节只处理这部分数据而不是扫描整个缓冲区。#define SBUS_BUF_SIZE 50 uint8_t sbus_rx_buf[SBUS_BUF_SIZE];3.3 中断优先级的安排DMA和USART的中断优先级需要合理安排。IDLE中断属于USART中断优先级应该比DMA中断高因为IDLE中断里要做缓冲区切换和状态机驱动实时性要求更高。但也不能太高否则会影响其他关键中断。我一般把USART中断设为优先级1DMA中断设为优先级2SysTick设为优先级0。注意如果系统里还有别的实时性要求高的中断比如电机控制PWM或者编码器捕获需要根据实际情况调整。原则是SBUS解析的实时性要求是毫秒级不需要太高的优先级但也不能被长时间阻塞。4. DMA循环接收加IDLE中断的代码骨架4.1 启动DMA接收的正确姿势在main函数初始化完成后启动DMA接收HAL_UART_Receive_DMA(huart1, sbus_rx_buf, SBUS_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);这两行代码的顺序很重要先启动DMA再使能IDLE中断。如果反过来可能在DMA还没准备好时就触发了IDLE中断导致读到无效数据。DMA循环模式下HAL_UART_Receive_DMA只需要调用一次之后DMA会自动循环搬运。但要注意HAL库的DMA接收函数在循环模式下不会自动重新装载计数器它依赖DMA硬件的循环特性。所以只要不调用HAL_UART_DMAStopDMA就会一直工作。4.2 IDLE中断处理函数的写法IDLE中断的处理是整个方案的核心。在stm32f1xx_it.c里找到USART1_IRQHandler添加IDLE检测void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); sbus_idle_callback(); } HAL_UART_IRQHandler(huart1); }sbus_idle_callback()函数里做三件事计算接收到的字节数、把数据拷贝到处理缓冲区、驱动状态机。void sbus_idle_callback(void) { static uint16_t last_pos 0; uint16_t curr_pos SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t len; if (curr_pos ! last_pos) { if (curr_pos last_pos) { len curr_pos - last_pos; sbus_feed_data(sbus_rx_buf[last_pos], len); } else { len SBUS_BUF_SIZE - last_pos; sbus_feed_data(sbus_rx_buf[last_pos], len); if (curr_pos 0) { sbus_feed_data(sbus_rx_buf, curr_pos); } } last_pos curr_pos; } }这里用__HAL_DMA_GET_COUNTER获取DMA剩余传输次数用缓冲区大小减去剩余次数就是当前写指针位置。last_pos记录上一次处理到的位置两者之差就是这一波新收到的数据长度。如果当前指针小于上次指针说明DMA绕了一圈需要分两段处理。4.3 状态机怎么在数据流里找帧状态机的作用是在连续的字节流里找到完整的SBUS帧。SBUS帧头是0x0F帧尾是0x00但数据区里也可能出现0x0F和0x00所以不能简单地靠帧头帧尾来切分。我的做法是维护一个25字节的帧缓冲区和一个索引每收到一个字节就判断当前索引位置应该是什么。typedef enum { STATE_HEADER, STATE_DATA, STATE_FOOTER } sbus_state_t; static sbus_state_t state STATE_HEADER; static uint8_t frame_buf[25]; static uint8_t frame_idx 0; void sbus_feed_data(uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { uint8_t byte data[i]; switch (state) { case STATE_HEADER: if (byte 0x0F) { frame_buf[0] byte; frame_idx 1; state STATE_DATA; } break; case STATE_DATA: frame_buf[frame_idx] byte; if (frame_idx 24) { state STATE_FOOTER; } break; case STATE_FOOTER: if (byte 0x00) { frame_buf[24] byte; sbus_process_frame(frame_buf); } frame_idx 0; state STATE_HEADER; break; } } }这个状态机很简单但有个问题如果数据区里出现了0x0F状态机会误判吗不会因为状态机只在STATE_HEADER状态下检测0x0F进入STATE_DATA后就不再检测帧头了而是老老实实数够23个字节。这样即使数据区里有0x0F也不会打断当前帧的接收。但还有一种情况如果帧头0x0F出现在数据区而当前帧因为干扰丢失了部分字节状态机可能会把数据区里的0x0F当成新帧的帧头。这种情况概率很低而且SBUS接收机通常会连续发送多帧丢一帧影响不大。如果要做更严格的校验可以在STATE_FOOTER里检查帧尾如果帧尾不对就丢弃整帧重新找帧头。5. 实测中遇到的几个坑和排查过程5.1 通道数据偶尔跳变到0或者2047这个问题困扰了我很久。现象是遥控器摇杆不动但解析出来的通道值偶尔会跳到0或者2047。一开始怀疑是DMA缓冲区溢出把缓冲区从50加大到100问题依旧。后来用逻辑分析仪抓串口波形发现接收机输出的信号本身就有毛刺偶尔会出现一个极窄的负脉冲。原因是接收机和STM32之间的连接线太长而且没有加屏蔽周围电机电调的干扰耦合到了信号线上。解决办法有两个一是缩短信号线最好控制在10厘米以内二是在接收机输出和STM32输入之间加一个100欧姆的电阻和一个100pF的电容做低通滤波。我两个都做了问题彻底消失。注意SBUS信号是反相的加滤波电容的时候要注意不要影响上升沿和下降沿的斜率。100pF以下一般没问题再大就可能影响100000波特率下的波形质量。5.2 IDLE中断触发太频繁导致CPU占用高理论上IDLE中断只在总线空闲时触发一次但实际调试中发现如果接收机没有信号串口线上有噪声IDLE中断会频繁触发。每次触发都进中断虽然处理逻辑很简单但累积起来CPU占用率也不低。解决办法是在IDLE回调里加一个简单的判断如果这一波收到的数据长度小于25字节直接丢弃不驱动状态机。因为SBUS一帧至少25字节小于25字节的数据不可能是完整帧。if (len 25) return;这一行代码就把无效数据的处理开销降到了最低。5.3 DMA循环模式下缓冲区覆盖的隐患DMA循环模式虽然方便但有一个隐患如果IDLE中断处理太慢DMA可能已经绕回来覆盖了未处理的数据。SBUS一帧25字节100000波特率下传输一帧需要2.5毫秒。如果IDLE中断处理时间超过2.5毫秒就有可能覆盖。我的处理函数里只做了数据拷贝和状态机驱动没有耗时操作实测处理一帧数据大约20微秒远远小于2.5毫秒。但如果你在回调里加了打印或者复杂计算就要小心了。建议把耗时的处理放到主循环里回调里只做数据搬运和标志位设置。6. 从原始通道值到实际控制量的映射6.1 通道值的范围和中位点SBUS的16个通道每个通道是11位理论范围是0到2047。但实际接收机输出的范围通常是172到1811中位点在992左右。不同品牌的接收机可能略有差异比如Futaba的接收机中位点可能是1024而某些国产接收机可能是992。我在代码里定义了两个宏#define SBUS_MIN 172 #define SBUS_MAX 1811 #define SBUS_MID 992解析出原始值后先做一次限幅把超出范围的值钳到边界然后根据应用需求做映射。比如控制电机转速可能需要把172-1811映射到1000-2000的PWM脉宽控制舵机可能需要映射到500-2500微秒。6.2 失效保护标志的处理SBUS帧的第23个字节包含失效保护标志。当接收机失去信号时这个标志会置位同时通道值会变成预设的失效保护值。在代码里要检测这个标志一旦置位就触发失控保护逻辑比如让电机停转或者让舵机回中。if (frame_buf[23] 0x04) { // 帧丢失标志 sbus_failsafe 1; } if (frame_buf[23] 0x08) { // 失效保护标志 sbus_failsafe 1; }这两个标志的区别是帧丢失表示接收机没有收到发射机的信号失效保护表示接收机进入了预设的失控保护状态。实际使用中只要任意一个置位就应该触发失控保护。6.3 通道数据的平滑处理原始通道值会有轻微抖动即使摇杆不动数值也可能在正负2之间波动。对于控制类应用这种抖动会导致电机或者舵机发出嗡嗡声。简单的解决办法是加一个一阶低通滤波channels_smooth[i] channels_smooth[i] * 0.8 channels_raw[i] * 0.2;这个系数可以根据实际需求调整。系数越小平滑效果越好但响应越慢。对于航模控制0.2到0.3的系数比较合适既能滤掉抖动又不会明显影响操控手感。7. 把这套方案移植到其他串口协议的思路这套DMA加IDLE中断加状态机的框架其实不只能用于SBUS。任何“帧长固定、帧间隔不固定”的串口协议都可以用类似的思路来处理。比如Modbus RTU、某些GPS模块的NMEA协议、甚至自定义的二进制协议。关键是把状态机部分抽象出来做成一个通用的帧解析器。帧头、帧长、帧尾这些参数做成可配置的状态机逻辑不变。这样换一个协议只需要改配置不用重写代码。我在另一个项目里用同样的框架解析一个自定义的传感器协议帧头是0xAA55帧长不固定帧尾是校验和。状态机稍微改了一下增加了长度字段的解析其他部分完全复用。从SBUS切换到自定义协议只花了不到一个小时。提示如果协议帧长不固定状态机里需要增加一个“长度字段”状态先解析出帧长再根据帧长决定什么时候进入帧尾状态。这个改动不大但很实用。8. 几个实际项目中的经验补充8.1 双缓冲区避免数据竞争如果主循环里要读取通道数据而IDLE中断里会更新通道数据就需要考虑数据竞争的问题。最简单的做法是使用双缓冲区中断里写缓冲区A主循环读缓冲区B通过一个标志位切换。或者更简单一点在中断里更新完数据后置一个标志位主循环检测到标志位再读取。我一般用后者因为SBUS的更新频率是14毫秒一次主循环读取频率远高于这个偶尔读到旧数据影响不大。但如果你的应用对数据一致性要求很高比如做数据记录或者精确控制建议用双缓冲区。8.2 调试时用LED或者串口打印辅助调试SBUS解析的时候最直接的办法是用一个LED指示帧接收状态。每收到一帧完整的SBUS数据翻转一次LED。如果LED以稳定的频率闪烁说明帧接收正常如果闪烁不均匀或者不闪说明有问题。另一个办法是把解析出的通道值通过另一个串口打印出来。但要注意打印会占用CPU时间可能影响IDLE中断的处理。建议只在调试阶段使用正式代码里去掉打印。8.3 电源和地线的处理SBUS接收机对电源噪声很敏感。如果接收机和STM32共用一组电源电机或者舵机的电流波动会通过电源线耦合到接收机导致信号异常。我的做法是给接收机单独加一个LDO稳压或者在电源线上加一个磁珠和电容做滤波。地线方面接收机和STM32的地要单点连接避免地环路。这些细节看起来和软件无关但实际项目中很多“软件问题”最后查下来都是硬件引起的。我在一个项目里遇到过通道偶尔跳变的问题查了两天代码没找到原因最后用示波器看电源纹波发现电机启动瞬间电源跌落了0.5V导致接收机复位。加了一个1000uF的电容之后问题解决。8.4 代码结构的组织建议最后说一下代码组织。我习惯把SBUS相关的代码放在一个独立的文件里比如sbus.c和sbus.h。对外只暴露三个接口初始化、获取通道值、获取失效保护状态。DMA缓冲区、状态机、解析函数都放在文件内部用static修饰。这样主程序里只需要调用sbus_get_channel(i)就能拿到通道值不需要关心底层实现。这种模块化的写法还有一个好处如果以后要换用别的接收机或者协议只需要替换sbus.c主程序不用改。我在一个项目里从SBUS切换到CRSF协议就是重新写了一个crsf.c主程序里只改了一个头文件引用。这套方案我在好几个项目里用过从STM32F103到F407再到G0系列HAL库的版本从1.7到1.12都跑通过。核心逻辑没有变过只是DMA和中断的寄存器操作略有差异。如果你在移植过程中遇到问题大概率是CubeMX的配置或者中断优先级的问题仔细检查这两处基本都能解决。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进