ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 DMA+IDLE+状态机:SBUS协议解析框架实战

STM32 DMA+IDLE+状态机:SBUS协议解析框架实战 1. 为什么SBUS协议值得单独写一套解析框架SBUS是遥控接收机领域事实上的标准协议Futaba、FrSky、乐迪等厂商的接收机几乎都支持它。它的物理层很特殊反相串口、100000波特率、8数据位、偶校验、2停止位一帧25字节包含16个通道的11位数据、2个数字通道标志位和一帧结束字节0x00。很多刚接触STM32的朋友第一次接SBUS用普通串口接收方式去读结果要么丢帧要么数据错位要么CPU被串口中断拖死。我在实际项目里踩过最典型的坑就是用HAL_UART_Receive_IT逐字节接收波特率100k下每帧25字节遥控器刷新周期通常7ms到14ms看起来不忙但一旦主循环里有其他耗时任务中断响应延迟就会导致帧内字节丢失表现为通道值偶尔跳变。后来换成DMA循环接收加IDLE中断CPU占用几乎为零帧完整性也稳了。这套方案的核心思路是三层配合DMA负责搬运数据不打扰CPUIDLE中断负责告诉CPU“一帧数据到齐了”状态机负责把25字节的原始流解析成16个通道值。三者各司其职缺一不可。下面我把整个设计思路、CubeMX配置、代码实现和调试经验完整拆开讲。2. 整体方案设计与选型考量2.1 为什么不用普通串口中断接收普通串口中断接收的方式是每收到一个字节触发一次中断CPU在中断里把数据存进缓冲区。SBUS一帧25字节按100k波特率算一个字节传输时间约100微秒25字节约2.5毫秒。如果每字节都进中断2.5毫秒内要进25次中断每次中断进出开销按1微秒算光中断开销就25微秒看起来不多但问题在于中断优先级冲突。实际项目中如果同时有定时器中断、ADC中断在跑串口中断被延迟几十微秒很正常。SBUS字节间隔只有100微秒延迟超过这个窗口就会丢字节。我实测过在主循环里加一个OLED刷新I2C软件模拟丢帧率能到5%以上。DMA方式则完全绕开这个问题数据由硬件直接搬到内存CPU只在整帧到齐后处理一次。2.2 DMA循环模式与IDLE中断的配合逻辑DMA循环模式Circular的意思是缓冲区满了之后自动回到开头继续写不需要CPU干预。配合IDLE中断逻辑是这样的串口空闲线检测到总线空闲一帧结束触发IDLE中断此时DMA已经把这帧数据搬到了缓冲区CPU在IDLE中断里读取DMA剩余计数算出这帧收了多少字节然后交给状态机解析。这里有个关键点DMA缓冲区要足够大至少能装下两帧以上。因为IDLE中断触发到CPU实际处理之间有时间差如果缓冲区太小新一帧数据可能覆盖还没处理的旧帧。我一般用64字节或128字节缓冲区SBUS一帧25字节足够容纳两帧还有余量。2.3 状态机解析的必要性SBUS帧有固定格式首字节0x0F25字节一帧末字节0x00。但实际接收中由于干扰或上电时序问题缓冲区里可能出现半帧、错位帧。如果不用状态机直接按固定偏移取数据一旦错位就全乱。状态机的作用是逐字节扫描找到帧头0x0F后开始计数收满25字节且末字节为0x00才认为是一帧有效数据否则丢弃重新找帧头。我用的状态机很简单三个状态等待帧头、接收数据、校验结束。每个字节进来根据当前状态决定下一步动作逻辑清晰不容易出错。3. CubeMX配置与硬件连接要点3.1 串口参数配置在CubeMX里选一个USART比如USART2模式选Asynchronous。参数设置如下参数值说明Baud Rate100000SBUS标准波特率Word Length8 Bits数据位8ParityEven偶校验Stop Bits2双停止位Data DirectionReceive Only只接收SBUS是单向的这里有个坑HAL库默认的串口初始化可能不支持偶校验加2停止位需要检查生成的代码里huart2.Init.Parity和huart2.Init.StopBits是否正确。我遇到过CubeMX版本问题导致StopBits被设成1结果收不到数据排查了半天。3.2 DMA配置在DMA Settings里添加USART2_RX模式选Circular数据宽度Byte优先级Medium或High。不要开FIFOSBUS数据量小开FIFO反而增加延迟。3.3 硬件反相电路SBUS信号是反相的普通串口RX引脚直接接会收到反码。两种解决方案一是用硬件反相电路一个NPN三极管加两个电阻就能搞定二是用软件反相但STM32的USART不支持硬件反相软件反相需要在每个字节上做位翻转DMA方式下不方便。我推荐硬件反相电路简单可靠。具体电路SBUS信号接三极管基极串1k电阻集电极接3.3V串10k上拉发射极接地集电极输出就是反相后的信号接STM32的RX引脚。实测这个电路在100k波特率下波形干净没有明显延迟。3.4 IDLE中断使能在USART2的NVIC Settings里勾选USART2全局中断。然后在代码里手动使能IDLE中断HAL库没有直接提供IDLE中断的使能函数需要操作寄存器__HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE);这行代码放在HAL_UART_Receive_DMA之后。4. 代码实现与关键细节拆解4.1 全局变量定义#define SBUS_RX_BUF_SIZE 64 #define SBUS_FRAME_SIZE 25 uint8_t sbus_rx_buf[SBUS_RX_BUF_SIZE]; uint8_t sbus_frame[SBUS_FRAME_SIZE]; volatile uint8_t sbus_frame_ready 0; volatile uint16_t sbus_channels[16]; volatile uint8_t sbus_failsafe 0; volatile uint8_t sbus_lost_frame 0;sbus_rx_buf是DMA目标缓冲区sbus_frame是解析后的有效帧sbus_frame_ready标志位告诉主循环有新数据。4.2 启动DMA接收和IDLE中断void SBUS_Init(void) { HAL_UART_Receive_DMA(huart2, sbus_rx_buf, SBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); }注意HAL_UART_Receive_DMA的第三个参数是缓冲区大小DMA循环模式下这个大小就是循环周期。我设64字节意味着DMA写到第64字节后自动回到第0字节。4.3 IDLE中断处理函数IDLE中断在HAL库的HAL_UART_IRQHandler里没有直接回调需要自己写中断服务函数void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart2); uint16_t rx_len SBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t start_pos (rx_len SBUS_RX_BUF_SIZE) ? (SBUS_RX_BUF_SIZE - rx_len) : 0; SBUS_Parse(sbus_rx_buf, SBUS_RX_BUF_SIZE, start_pos, rx_len); } HAL_UART_IRQHandler(huart2); }这里有个容易搞错的地方DMA循环模式下__HAL_DMA_GET_COUNTER返回的是剩余未传输的字节数不是已传输数。已传输数等于缓冲区大小减去剩余数。但循环模式下如果DMA已经绕了一圈这个计算会出错。我的处理方式是记录上一次的DMA位置计算增量。不过对于SBUS这种每帧25字节、缓冲区64字节的场景一帧数据不会跨越缓冲区边界两次简单计算就够用。更稳妥的做法是维护一个读指针每次IDLE中断时从上次读位置读到当前DMA写位置。我实际项目里用的是简化版因为SBUS帧短缓冲区大基本不会出现绕圈问题。4.4 状态机解析函数typedef enum { SBUS_STATE_HEADER, SBUS_STATE_DATA, SBUS_STATE_END } SBUS_State_t; void SBUS_Parse(uint8_t *buf, uint16_t buf_size, uint16_t start, uint16_t len) { static SBUS_State_t state SBUS_STATE_HEADER; static uint8_t frame_idx 0; for (uint16_t i 0; i len; i) { uint16_t pos (start i) % buf_size; uint8_t byte buf[pos]; switch (state) { case SBUS_STATE_HEADER: if (byte 0x0F) { sbus_frame[0] byte; frame_idx 1; state SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: sbus_frame[frame_idx] byte; if (frame_idx SBUS_FRAME_SIZE) { state SBUS_STATE_END; } break; case SBUS_STATE_END: if (byte 0x00) { SBUS_Decode(sbus_frame); sbus_frame_ready 1; } frame_idx 0; state SBUS_STATE_HEADER; break; } } }状态机的逻辑是找到0x0F后开始存数据存满25字节后检查下一字节是否为0x00是则解码否则丢弃重新找帧头。这里有个细节SBUS帧的第24字节索引24是帧结束标志0x00但有些接收机可能发0x04或0x14表示丢帧或失控。所以严格来说结束字节不一定是0x00需要根据具体接收机文档调整。我用的FrSky接收机结束字节是0x00但为了兼容性可以放宽为检查帧头0x0F和帧长25字节。4.5 通道数据解码SBUS的16个通道数据是11位打包的每两个字节包含一个完整通道和一个通道的高3位。解码代码如下void SBUS_Decode(uint8_t *frame) { sbus_channels[0] ((uint16_t)frame[1] | ((uint16_t)frame[2] 8)) 0x07FF; sbus_channels[1] ((uint16_t)frame[2] 3 | ((uint16_t)frame[3] 5)) 0x07FF; sbus_channels[2] ((uint16_t)frame[3] 6 | ((uint16_t)frame[4] 2) | ((uint16_t)frame[5] 10)) 0x07FF; sbus_channels[3] ((uint16_t)frame[5] 1 | ((uint16_t)frame[6] 7)) 0x07FF; sbus_channels[4] ((uint16_t)frame[6] 4 | ((uint16_t)frame[7] 4)) 0x07FF; sbus_channels[5] ((uint16_t)frame[7] 7 | ((uint16_t)frame[8] 1) | ((uint16_t)frame[9] 9)) 0x07FF; sbus_channels[6] ((uint16_t)frame[9] 2 | ((uint16_t)frame[10] 6)) 0x07FF; sbus_channels[7] ((uint16_t)frame[10] 5 | ((uint16_t)frame[11] 3)) 0x07FF; sbus_channels[8] ((uint16_t)frame[12] | ((uint16_t)frame[13] 8)) 0x07FF; sbus_channels[9] ((uint16_t)frame[13] 3 | ((uint16_t)frame[14] 5)) 0x07FF; sbus_channels[10] ((uint16_t)frame[14] 6 | ((uint16_t)frame[15] 2) | ((uint16_t)frame[16] 10)) 0x07FF; sbus_channels[11] ((uint16_t)frame[16] 1 | ((uint16_t)frame[17] 7)) 0x07FF; sbus_channels[12] ((uint16_t)frame[17] 4 | ((uint16_t)frame[18] 4)) 0x07FF; sbus_channels[13] ((uint16_t)frame[18] 7 | ((uint16_t)frame[19] 1) | ((uint16_t)frame[20] 9)) 0x07FF; sbus_channels[14] ((uint16_t)frame[20] 2 | ((uint16_t)frame[21] 6)) 0x07FF; sbus_channels[15] ((uint16_t)frame[21] 5 | ((uint16_t)frame[22] 3)) 0x07FF; sbus_failsafe (frame[23] 0x08) ? 1 : 0; sbus_lost_frame (frame[23] 0x04) ? 1 : 0; }这段代码看着复杂其实就是位操作。每个通道11位跨字节边界用移位和或运算拼起来。注意 0x07FF是必须的因为移位后可能带进高位脏数据。4.6 主循环处理while (1) { if (sbus_frame_ready) { sbus_frame_ready 0; if (sbus_failsafe) { // 失控保护处理 } else { // 正常使用sbus_channels数组 int16_t ch1 sbus_channels[0]; // ... } } // 其他任务 }主循环里只检查标志位不做耗时操作保证解析及时。5. 调试过程中踩过的坑与排查方法5.1 收不到数据先查硬件反相第一次接SBUS串口助手收到一堆乱码以为是波特率不对。后来用示波器看波形发现信号是反的。加上反相电路后正常。排查顺序示波器看RX引脚波形确认空闲电平是高还是低。SBUS空闲时应该是低电平反相后如果看到高电平说明反相电路没工作。5.2 数据偶尔跳变检查DMA缓冲区大小有次调试发现通道值每隔几秒跳一下用逻辑分析仪抓串口数据发现偶尔丢一帧。原因是DMA缓冲区设了32字节只够一帧多IDLE中断处理稍慢就被新数据覆盖。改成64字节后问题消失。经验值缓冲区至少是帧长的2.5倍。5.3 IDLE中断不触发检查标志位清除HAL库的IDLE标志清除比较特殊不能直接用__HAL_UART_CLEAR_FLAG要用__HAL_UART_CLEAR_IDLEFLAG。我一开始用错了宏中断只触发一次就不再触发。正确写法是先读SR寄存器再读DR寄存器或者直接用__HAL_UART_CLEAR_IDLEFLAG。5.4 通道值范围不对确认解码位宽SBUS通道值是11位范围172到1811中位992。如果解出来是0到2047说明没做 0x07FF。如果解出来是0到255说明只取了低8位。用遥控器打满舵看通道值是否在172和1811附近能快速判断解码是否正确。5.5 偶校验错误检查串口初始化偶校验加2停止位有些STM32型号的USART不支持2停止位或者CubeMX生成的代码里StopBits被设成1。检查huart2.Init.StopBits是否为UART_STOPBITS_2如果不是手动改。6. 常见问题速查表现象可能原因排查方法解决完全无数据硬件反相未做示波器看RX波形加反相电路数据乱码波特率/校验/停止位不对核对串口参数100000,8,E,2偶尔丢帧DMA缓冲区太小增大缓冲区至少64字节IDLE只触发一次标志位清除错误检查清除宏用__HAL_UART_CLEAR_IDLEFLAG通道值跳变帧错位加状态机校验检查帧头帧尾通道值范围错解码位宽错打满舵看极值加 0x07FFCPU占用高用了逐字节中断改DMA换DMA循环模式7. 性能实测与优化建议我用STM32F103C8T6跑这套方案主频72MHz实测CPU占用率不到1%。具体数据SBUS帧率约14ms一帧每帧25字节IDLE中断每14ms触发一次中断处理时间约20微秒含状态机解析主循环里解码16个通道约5微秒。加起来每14ms消耗25微秒CPU时间占用率0.18%。优化建议如果项目里SBUS只是辅助功能可以把解析放在IDLE中断里直接做完主循环只读结果。如果SBUS是核心控制输入建议在IDLE中断里只做帧提取解码放主循环避免中断里耗时过长。另外DMA缓冲区地址要32位对齐虽然STM32的DMA对字节传输不要求对齐但对齐后访问效率更高。用__attribute__((aligned(4)))修饰缓冲区数组即可。8. 这套框架还能怎么扩展这套DMA加IDLE加状态机的框架不只适用于SBUS。任何不定长、有固定帧头帧尾的串口协议都能套用比如Modbus RTU、自定义传感器协议、GPS NMEA语句。只需要改状态机里的帧头判断和帧长校验逻辑DMA和IDLE部分完全复用。我后来用同样的框架解析过某品牌激光雷达的串口数据帧长可变状态机里加了个长度字段解析照样跑得很稳。所以这套东西值得花时间吃透以后遇到串口协议直接套模板省事。最后分享一个小技巧调试阶段可以在IDLE中断里翻转一个GPIO用示波器看中断触发频率能快速判断帧率是否正常。正常SBUS应该是7ms或14ms触发一次如果频率不对说明接收有问题。这个法子比串口打印直观多了也不占用串口资源。
返回列表