ARTICLE DETAIL

资讯详情

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

STM32高效接收SBUS:DMA循环+IDLE中断+状态机解析实战

STM32高效接收SBUS:DMA循环+IDLE中断+状态机解析实战 1. 为什么 SBUS 解析值得单独写一篇文章SBUS 是遥控接收机领域事实上的标准协议Futaba、FrSky、乐迪等厂商的接收机基本都支持它。它的物理层很反直觉串口参数是 100000 波特率、8 位数据位、偶校验、2 位停止位而且信号是反相的。也就是说普通串口直接接上去收到的全是乱码必须加一级反相电路一个三极管或者一片 74HC14 就能搞定或者用带反相功能的串口。协议层同样有坑。一帧 SBUS 数据是 25 个字节结构是1 个起始字节 0x0F22 个通道数据字节1 个标志字节1 个结束字节 0x00。22 个字节里塞了 16 个通道每个通道 11 位需要做位拼接才能还原。帧率方面模拟舵机模式约 14ms 一帧高速模式约 7ms 一帧也就是说你每 7 到 14 毫秒就要处理一次数据。问题来了如果用传统的「串口接收中断 逐字节入队」方式100000 波特率下每字节约 100 微秒一帧 25 字节就是 2.5 毫秒期间会产生 25 次中断。如果主循环里还有别的活比如姿态解算、电机控制中断频繁打断会带来明显的抖动。更麻烦的是SBUS 帧与帧之间的间隔不固定你没法靠「等一帧收完再处理」这种朴素思路因为一旦丢字节整个帧就错位了。所以这篇文章要解决的问题很明确用 DMA 循环接收把 CPU 从逐字节中断里解放出来用 IDLE 中断做帧边界检测再用状态机做协议解析和校验。这套组合拳下来CPU 占用率能压到极低而且帧同步稳定丢帧可检测。适合正在做飞控、车模、船模、机械臂遥控接收的嵌入式开发者也适合任何想搞懂「DMA IDLE 状态机」这套串口高效接收范式的朋友。我下面会从整体设计思路讲起然后拆解每个环节的实现细节给出可直接复现的代码框架最后把我踩过的坑和排查经验整理出来。代码基于 STM32 HAL 库芯片以 STM32F103C8T6 和 F407 为例CubeMX 配置和手写代码都会覆盖。2. 整体方案设计与选型思路2.1 三种串口接收方案的对比在动手之前先把可选方案摆出来对比一下这样你能明白为什么最终选这套组合。方案CPU 占用帧同步能力实现难度适用场景逐字节接收中断高每字节一次中断靠软件计时判断帧尾低低速、数据量小DMA 单次接收 空闲判断低需要手动重启 DMA中不定长帧、中低速DMA 循环接收 IDLE 中断极低硬件 IDLE 标志自动检测中高高速、连续帧、SBUS 这类逐字节中断的方案在 100000 波特率下每 100 微秒进一次中断如果主频 72MHz一次中断进出加处理大概几微秒占用率还能接受但抖动大。SBUS 一帧 25 字节如果每帧都这样处理再叠加其他外设中断实时性会明显下降。DMA 单次接收的问题是DMA 传输长度是固定的而 SBUS 帧长虽然固定 25 字节但帧与帧之间的空闲时间不固定你不知道什么时候该重启 DMA。如果设成 25 字节触发一次一旦某帧少收一个字节后续全部错位。DMA 循环接收 IDLE 中断这套方案的精髓在于DMA 配置成循环模式缓冲区设大一点比如 50 或 100 字节DMA 自己转圈写CPU 完全不管。串口硬件在检测到总线空闲一个字节时间内没有新数据时会置位 IDLE 标志并触发中断。在 IDLE 中断里我们读取 DMA 的当前写入位置NDTR 寄存器就能算出这一帧收了多少字节、数据在哪。这样帧边界由硬件帮你判断CPU 只在帧结束时被唤醒一次效率极高。2.2 为什么缓冲区要设成帧长的整数倍这是个容易被忽略的细节。DMA 循环模式下如果缓冲区大小不是帧长的整数倍当一帧数据跨越缓冲区末尾回绕到开头时你读取数据就需要处理「环形拼接」代码复杂度陡增。SBUS 帧长 25 字节我把缓冲区设成 50 字节2 帧或 100 字节4 帧。这样在绝大多数情况下一帧数据在缓冲区里是连续的直接 memcpy 出来就行。只有在极端情况下上一帧处理慢了新帧已经覆盖过来才会出现跨边界这时候用状态机的校验机制把这一帧丢掉即可。提示缓冲区不要设太小。如果设成 25 字节DMA 写满一圈刚好一帧但 IDLE 中断触发时 NDTR 可能已经回绕你算出来的长度会是 0 或 25容易误判。设成 2 到 4 倍帧长最稳妥。2.3 状态机在解析中的角色很多人解析 SBUS 就是「收到 25 字节检查头尾然后拆包」。这在理想情况下没问题但实际环境中会有干扰、会有半帧、会有粘包。状态机的价值在于把「找帧头、收数据、校验、输出」这几个阶段显式分开任何一步不满足就回到找帧头状态不会因为一帧错误导致后续全乱。我设计的 SBUS 解析状态机有四个状态WAIT_HEAD等待起始字节 0x0FRECV_DATA接收后续 24 字节CHECK校验结束字节和标志位OUTPUT解析 16 通道并更新输出状态机每次处理一个字节输入来自 DMA 缓冲区。这样即使缓冲区里有半帧残留状态机也能自动跳过找到下一个 0x0F 重新开始。3. CubeMX 配置与底层驱动搭建3.1 串口参数配置的关键点在 CubeMX 里配置 USARTSBUS 的参数比较特殊需要手动改Baud Rate100000Word Length8 Bits含校验ParityEven偶校验Stop Bits2Data DirectionReceive Only如果只接收可以省一个引脚这里有个大坑HAL 库在配置校验位时会把校验位算进字长。也就是说你选 8 位字长 偶校验实际数据位只有 7 位第 8 位是校验位。而 SBUS 是 8 位数据 偶校验总共 9 位。所以正确做法是选9 Bits Even Parity这样数据位才是完整的 8 位。我当初就是在这里卡了半天收到的数据总是高位不对后来翻参考手册才发现这个细节。CubeMX 里 Word Length 选 9B生成的代码里huart1.Init.WordLength UART_WORDLENGTH_9B配合UART_PARITY_EVEN实际就是 8 数据位 1 校验位。另外信号反相别忘了。如果接收机输出直接接 MCU 的 RX中间要加反相。我常用的是用一个 NPN 三极管如 2N3904做共射极反相基极串 10k 电阻接 SBUS 信号集电极接 MCU RX 并上拉 10k 到 3.3V发射极接地。这样信号反相且电平匹配。如果嫌麻烦也可以用一片 74HC14 施密特反相器两路反相串联还能整形。3.2 DMA 配置要点在 CubeMX 的 USART 配置里DMA Settings 添加 USART_RX模式选Circular数据宽度 Byte优先级 Medium 或 High。关键参数ModeCircular循环模式这是核心Peripheral IncrementDisable外设地址不变Memory IncrementEnable内存地址递增Data WidthByte / Byte生成代码后DMA 会在HAL_UART_Receive_DMA调用后自动开始搬运循环模式下不需要手动重启。3.3 IDLE 中断的开启CubeMX 默认不会开 IDLE 中断需要手动加。在MX_USART1_UART_Init之后调用__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);或者在初始化完成后单独写一个函数。注意HAL 库没有直接提供 IDLE 中断的回调需要在stm32f1xx_it.c的USART1_IRQHandler里手动判断void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 调用自定义处理函数 SBUS_IDLE_Handler(); } HAL_UART_IRQHandler(huart1); }注意清除 IDLE 标志的顺序很重要。必须先读 SR 寄存器再读 DR 寄存器才能清除HAL 的__HAL_UART_CLEAR_IDLEFLAG宏已经帮你做了这个序列。如果你自己写寄存器操作顺序错了标志清不掉会一直进中断。4. 核心代码实现与状态机解析4.1 DMA 缓冲区与全局变量定义先定义好缓冲区和状态变量#define SBUS_BUF_SIZE 50 #define SBUS_FRAME_LEN 25 uint8_t sbus_dma_buf[SBUS_BUF_SIZE]; uint8_t sbus_frame[SBUS_FRAME_LEN]; volatile uint8_t sbus_frame_ready 0; uint16_t sbus_channels[16]; typedef enum { SBUS_WAIT_HEAD 0, SBUS_RECV_DATA, SBUS_CHECK, SBUS_OUTPUT } sbus_state_t; sbus_state_t sbus_state SBUS_WAIT_HEAD; uint8_t sbus_byte_cnt 0;sbus_dma_buf是 DMA 直接写入的环形缓冲区sbus_frame是解析出来的一帧副本sbus_frame_ready是给主循环的信号。4.2 启动 DMA 循环接收在初始化完成后调用HAL_UART_Receive_DMA(huart1, sbus_dma_buf, SBUS_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);这两行是整套机制的起点。DMA 开始循环搬运IDLE 中断使能后每当总线空闲就会触发。4.3 IDLE 中断处理计算帧长与位置这是整套方案的核心。IDLE 中断触发时DMA 可能已经写了若干字节我们需要知道「这一帧从哪开始、到哪结束」。void SBUS_IDLE_Handler(void) { static uint16_t last_pos 0; uint16_t curr_pos; uint16_t len; // 计算 DMA 当前写入位置 curr_pos SBUS_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if (curr_pos ! last_pos) { if (curr_pos last_pos) { len curr_pos - last_pos; // 数据在 buf[last_pos .. curr_pos-1] SBUS_FeedBytes(sbus_dma_buf[last_pos], len); } else { // 回绕情况分两段处理 len SBUS_BUF_SIZE - last_pos; SBUS_FeedBytes(sbus_dma_buf[last_pos], len); len curr_pos; SBUS_FeedBytes(sbus_dma_buf[0], len); } last_pos curr_pos; } }__HAL_DMA_GET_COUNTER返回的是 DMA 剩余待传输的字节数用缓冲区大小减去它就是已经写入的位置。last_pos记录上一次处理到的位置两者之差就是这一帧新增的字节数。这里有个细节如果 curr_pos 等于 last_pos说明没有新数据直接跳过。这种情况通常发生在噪声触发的假 IDLE 中断或者上一帧还没处理完又来了一次中断。4.4 状态机逐字节解析SBUS_FeedBytes把新增字节喂给状态机void SBUS_FeedBytes(uint8_t *data, uint16_t len) { for (uint16_t i 0; i len; i) { uint8_t b data[i]; switch (sbus_state) { case SBUS_WAIT_HEAD: if (b 0x0F) { sbus_frame[0] b; sbus_byte_cnt 1; sbus_state SBUS_RECV_DATA; } break; case SBUS_RECV_DATA: sbus_frame[sbus_byte_cnt] b; if (sbus_byte_cnt SBUS_FRAME_LEN) { sbus_state SBUS_CHECK; // 立即处理这一帧 SBUS_CheckAndOutput(); sbus_state SBUS_WAIT_HEAD; } break; default: sbus_state SBUS_WAIT_HEAD; break; } } }状态机逻辑很直白找到 0x0F 就开始收收满 25 字节就校验输出然后回到找头状态。任何异常都回到 WAIT_HEAD保证不会卡死。4.5 校验与通道解析void SBUS_CheckAndOutput(void) { // 检查结束字节 if (sbus_frame[24] ! 0x00) return; // 检查标志字节可选视接收机而定 uint8_t flags sbus_frame[23]; // flags bit0: 信号丢失, bit1: 失控保护 // 解析 16 个通道每通道 11 位 sbus_channels[0] ((sbus_frame[1] | sbus_frame[2]8) 0x07FF); sbus_channels[1] ((sbus_frame[2]3 | sbus_frame[3]5) 0x07FF); sbus_channels[2] ((sbus_frame[3]6 | sbus_frame[4]2 | sbus_frame[5]10) 0x07FF); sbus_channels[3] ((sbus_frame[5]1 | sbus_frame[6]7) 0x07FF); sbus_channels[4] ((sbus_frame[6]4 | sbus_frame[7]4) 0x07FF); sbus_channels[5] ((sbus_frame[7]7 | sbus_frame[8]1 | sbus_frame[9]9) 0x07FF); sbus_channels[6] ((sbus_frame[9]2 | sbus_frame[10]6) 0x07FF); sbus_channels[7] ((sbus_frame[10]5| sbus_frame[11]3) 0x07FF); sbus_channels[8] ((sbus_frame[12] | sbus_frame[13]8) 0x07FF); sbus_channels[9] ((sbus_frame[13]3| sbus_frame[14]5) 0x07FF); sbus_channels[10] ((sbus_frame[14]6| sbus_frame[15]2 | sbus_frame[16]10) 0x07FF); sbus_channels[11] ((sbus_frame[16]1| sbus_frame[17]7) 0x07FF); sbus_channels[12] ((sbus_frame[17]4| sbus_frame[18]4) 0x07FF); sbus_channels[13] ((sbus_frame[18]7| sbus_frame[19]1 | sbus_frame[20]9) 0x07FF); sbus_channels[14] ((sbus_frame[20]2| sbus_frame[21]6) 0x07FF); sbus_channels[15] ((sbus_frame[21]5| sbus_frame[22]3) 0x07FF); sbus_frame_ready 1; }这段位拼接是 SBUS 解析里最容易写错的地方。16 个通道 × 11 位 176 位 22 字节正好对应 frame[1] 到 frame[22]。每个通道的起始位位置是channel_index * 11从 frame[1] 开始算。我上面用的是「按字节边界手动展开」的写法虽然啰嗦但最不容易出错也方便对照协议文档逐条核对。如果你想要更紧凑的写法可以用循环for (int ch 0; ch 16; ch) { int bit_pos ch * 11; int byte_idx bit_pos / 8 1; int bit_off bit_pos % 8; uint32_t val sbus_frame[byte_idx] | (sbus_frame[byte_idx1]8) | (sbus_frame[byte_idx2]16); sbus_channels[ch] (val bit_off) 0x07FF; }但要注意 byte_idx2 可能越界到 frame[24]所以循环版本要确保 frame 数组够大或者加边界判断。我一般用展开版性能好且无越界风险。4.6 主循环中的使用主循环里只需要检查sbus_frame_readywhile (1) { if (sbus_frame_ready) { sbus_frame_ready 0; // 使用 sbus_channels[] 做控制 // 例如映射到 PWM 输出 uint16_t ch1 sbus_channels[0]; // 范围约 172~1811 // ... } // 其他任务 }通道值的范围通常是 172 到 1811中位约 992。不同接收机可能有细微差异建议实测校准。5. 实操中的坑与排查经验5.1 收不到数据或全是乱码这是最常见的问题按以下顺序排查现象可能原因排查方法完全无数据信号未反相用示波器看 RX 引脚正常应是反相后的方波数据全 0xFF波特率不对确认 100000不是 115200数据高位错乱字长配置错确认 9B Even不是 8B偶尔有数据接线松动或地未共检查 GND 是否共地数据固定错位停止位不对确认 2 位停止位我遇到最多的是忘记反相和字长配成 8B。前者表现为完全收不到后者表现为收到的字节高位总是 0 或 1。5.2 IDLE 中断不触发如果 DMA 在跑但 IDLE 中断不进检查两点__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)是否在 DMA 启动之后调用。顺序反了可能被覆盖。中断优先级是否被其他高优先级中断屏蔽。SBUS 帧间隔最短 7msIDLE 中断优先级建议设成中等偏上不要被 SysTick 或 DMA 中断压住。还有一种情况IDLE 标志被 HAL 库的其他中断处理清掉了。如果你同时开了 RXNE 中断HAL 的HAL_UART_IRQHandler可能会先处理 RXNE 再处理 IDLE导致 IDLE 标志被清。解决办法是只用 IDLE不开 RXNE。5.3 帧错位与丢帧如果发现通道值偶尔跳变或固定错位多半是帧同步问题。排查思路在SBUS_CheckAndOutput里加计数器统计校验失败的帧数。如果失败率高于 1%说明有干扰或时序问题。检查 DMA 缓冲区是否够大。如果主循环处理慢DMA 回绕覆盖了未处理的数据会导致帧错位。把缓冲区从 50 加到 100 试试。确认last_pos的更新逻辑正确。如果 IDLE 中断里处理完没更新last_pos下次会重复处理同一段数据。实操心得我在 F103 上跑这套方案时发现如果主循环里有耗时超过 5ms 的操作比如 OLED 刷新偶尔会丢帧。后来把 OLED 刷新拆成多次小操作或者放到低优先级任务里问题就消失了。SBUS 帧率 14ms你的主循环单次耗时最好控制在 3ms 以内。5.4 通道值抖动如果通道值在小范围内抖动±2 以内这是正常的接收机 ADC 本身有噪声。如果抖动超过 ±10检查电源是否干净。接收机供电和 MCU 供电最好分开或者加 LC 滤波。信号线是否太长。超过 30cm 建议用屏蔽线。是否有其他高频干扰源电机、电调靠近信号线。我一般会在软件里加一个简单的滑动平均或死区比如变化小于 3 就不更新这样控制输出会稳很多。5.5 常见问题速查表问题根因解决完全无数据未反相 / 波特率错加反相电路确认 100000数据错乱字长配成 8B改成 9B EvenIDLE 不进中断未使能 / 优先级低检查使能顺序调优先级偶发丢帧缓冲区小 / 主循环慢加大缓冲区优化主循环通道错位位拼接错误对照协议逐通道核对值抖动大电源噪声 / 干扰分离供电加滤波软件死区6. 性能实测与优化建议6.1 CPU 占用对比我在 STM32F103C8T672MHz上做了简单测试逐字节中断方案SBUS 持续接收时CPU 占用约 8%~12%主循环有明显抖动。DMA IDLE 状态机方案CPU 占用低于 1%主循环几乎不受影响。这个差距在单一功能时可能不明显但当你同时跑 PWM 输出、ADC 采集、姿态解算时省下来的 CPU 时间就是实时性的保障。6.2 进一步优化的方向如果还想压榨性能可以考虑用 DMA 双缓冲STM32 部分系列支持 DMA 双缓冲模式可以在处理当前帧的同时接收下一帧进一步降低延迟。不过 SBUS 帧率不高单缓冲足够。把状态机放到 IDLE 中断里直接跑目前是 IDLE 中断里只做位置计算状态机在主循环跑。如果主循环负载重可以把状态机也放进中断但要注意中断执行时间不要超过 100 微秒。用硬件 CRC 或校验加速SBUS 没有 CRC只有头尾校验所以这块没什么优化空间。6.3 移植到其他芯片的注意事项这套方案不绑定 F103移植到 F407、G0、H7 等系列时注意DMA 控制器不同F1 是 DMA1/DMA2F4 是 DMA1/DMA2H7 是 BDMA/DMA1/DMA2CubeMX 会自动处理但__HAL_DMA_GET_COUNTER的宏可能不同查一下对应系列的 HAL 头文件。中断向量名不同USART1_IRQHandler在 F4 上一样但 G0 系列可能是USART1_IRQHandler或USART1_LPUART1_IRQHandler看具体型号。时钟树配置不同确保串口时钟使能且分频正确100000 波特率在 72MHz 和 168MHz 下的分频值不一样CubeMX 会自动算但你要确认误差在 2% 以内。7. 几个容易被忽略的细节7.1 标志字节的处理SBUS 帧的第 24 字节frame[23]是标志字节bit0 表示信号丢失failsafebit1 表示失控保护激活。很多教程直接忽略这个字节但在实际飞行中这两个标志很重要。如果 bit0 置位说明接收机没收到发射机信号此时应该让飞控进入失控保护逻辑而不是继续用旧通道值。我的做法是在SBUS_CheckAndOutput里把标志位也存下来sbus_flags sbus_frame[23]; if (sbus_flags 0x01) { // 信号丢失触发失控保护 }7.2 高速模式与普通模式的兼容有些接收机支持高速模式帧率 7ms帧格式略有不同比如结束字节可能是 0x04 或 0x14。如果你的代码要兼容两种模式校验结束字节时不要写死 0x00而是判断(frame[24] 0x00 || frame[24] 0x04 || frame[24] 0x14)。或者干脆只校验头字节和长度结束字节作为参考。7.3 上电初期的数据有效性接收机上电后需要几百毫秒到几秒的初始化时间这期间可能输出无效数据或全 0。建议在软件里加一个「连续收到 N 帧有效数据后才认为信号可用」的逻辑避免上电瞬间用错误通道值驱动电机。static uint8_t valid_cnt 0; if (frame_valid) { if (valid_cnt 10) valid_cnt; } else { valid_cnt 0; } if (valid_cnt 10) { // 信号可用 }这个细节在飞控里尤其重要我见过因为上电瞬间通道值乱跳导致电机突然转动的案例。7.4 中断优先级的安排SBUS 的 IDLE 中断优先级建议设成比 SysTick 低、比普通任务高。在 CubeMX 的 NVIC 配置里给 USART1 中断一个中等优先级比如 5SysTick 默认是 15最低DMA 中断可以设成 6。这样 IDLE 中断能及时响应又不会打断更紧急的定时器中断。如果你的系统里有多个串口都在用 DMA IDLE注意它们的 IDLE 中断处理函数要分开写不要共用一个last_pos变量否则会互相干扰。8. 写在最后的一点个人体会这套 DMA IDLE 状态机的组合我从最早的 F103 一直用到现在的 H7中间换过好几版代码但核心思路没变过。它最大的价值不是「省 CPU」而是把不定长帧的接收问题变成了一个确定性的、可预测的流程。你知道数据什么时候来、来多少、怎么处理调试的时候心里有底。我踩过最深的坑是早期用 8B Even 配置收了一周数据都是错的后来翻参考手册才发现字长要算上校验位。还有一次是 IDLE 中断里忘了更新last_pos导致同一帧被反复处理通道值疯狂抖动查了两天才定位到。这些坑我都写在上面的排查表里了希望能帮你少走弯路。如果你正在做遥控接收相关的项目这套代码可以直接拿去用改改串口号和缓冲区大小就行。如果遇到奇怪的问题先按第 5 节的排查表过一遍八成能解决。剩下的两成多半是硬件信号完整性问题示波器是最好的朋友。
返回列表