1. 为什么我最终选了 DMA 循环接收 + IDLE 中断这套组合
做 STM32 项目做到串口接收这一步,几乎每个人都会经历这样的过程:一开始用阻塞接收,发现 CPU 全被占死了;后来换串口中断逐字节接收,问题缓解了,但一旦波特率提上来、数据帧变密,中断频率就高得吓人;再往后才接触到 DMA,而 DMA 一旦跟 IDLE 中断配合起来,才算真正把串口接收这件事想明白了。
我最初接到这个 SBUS 解析任务的时候,第一反应也是“这不就是串口收数据嘛”。SBUS 是航模遥控器常用的数字串行协议,100000 波特率,8 个数据位,偶校验,2 个停止位。一组完整的数据帧是 25 个字节,一帧接着一帧发,典型频率在 100Hz 左右。这意味着每秒钟大约有 2500 个字节从串口涌进来。如果用传统中断逐字节接收,每个字节都会触发一次中断,每秒 2500 次中断,每次还得进中断服务函数做标志位判断、数据搬运,长期跑下来对系统实时性肯定有影响。更要命的是,如果主循环里还有别的任务,比如 PID 计算、电机控制、OLED 刷新,逐字节中断就会频繁打断这些任务,很容易出现控制周期抖动的现象。
换成 DMA 之后,情况就完全反过来了。DMA 搬运数据不占用 CPU,CPU 只负责在合适的时间点处理一整批数据。真正的问题反而变成了“我怎么知道一帧收完了”“我怎么拿到这一帧数据的起始位置”“我连续接收的时候,DMA 缓冲区被覆盖了怎么办”。这三个问题,恰好对应 DMA 循环模式、IDLE 中断、状态机三个关键设计点。这套方案的本质是:用 DMA 兜底收字节,用 IDLE 中断判断帧边界,用状态机做数据帧对齐和校验。三者缺一不可。
网上很多帖子只讲其中某一环,比如“STM32 串口 DMA 接收”,然后给一个固定长度的 DMA 接收例程。但 SBUS 这种不定长、有固定帧边界、帧间隔相对稳定的协议,用普通 DMA Normal 模式根本收不干净,因为帧长度虽然是 25 字节,但你不知道当前这一帧从哪个位置开始,也不知道 DMA 搬运完一帧之后会不会把下一帧的开头也搬进来了。只有“循环接收 + IDLE 定位边界”才能做到连续、可靠、不丢帧。这也是我写这篇文章的原因,把整套方案的每个关键决策都摊开来讲清楚,而不是只贴一段能跑通的代码。
先说结论:如果你也在做 STM32 下的串口协议解析,尤其是 SBUS、DSM、PPM 这类周期性串行协议,这套“DMA 循环接收 + IDLE 中断 + 状态机”的组合基本是当前性价比最高的解法。它能让 CPU 占用率降到极低,同时保证数据帧不漏收、不错位。下面我就按我的实际开发顺序,把整个方案从协议分析到代码实现再到调试经验完整过一遍。
2. SBUS 协议格式:解析方案设计的绝对前提
很多人在写解析代码之前没认真看协议格式,上来就按固定 25 字节去收,结果发现明明每个字节都收对了,解出来的通道数据却乱七八糟,要么通道对不上,要么数值跳变。这里面可能不是解析代码的问题,而是帧边界没有对齐。所以我想先花一节把 SBUS 的帧格式完整梳理一遍,因为它直接决定你后面 DMA 缓冲区怎么设计、状态机怎么跳转。
2.1 帧结构逐字节拆解
SBUS 一帧固定 25 字节,具体分布是:
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0 | 0x0F | 起始字节,固定值 |
| 1~22 | 通道数据 | 16 个通道,每通道 11bit,共 176bit,即 22 字节 |
| 23 | 标志字节 | 低 2 位是 channel 17 和 channel 18 的开关状态,高 6 位用于 FailSafe 等状态标志 |
| 24 | 0x00 | 结束字节,固定值 |
这个结构要特别注意的是,通道数据不是按“通道 1 一个字节、通道 2 一个字节”这样边界对齐排列的,而是 16 个通道共 176 位连续排列,一个通道占 11 位。打个比方,如果把一帧的 22 个数据字节看成一整串二进制的连续数据流,那么第 0 到第 10 位是通道 1,第 11 到第 21 位是通道 2,依此类推。这意味着解析的时候必须做跨字节的位拼接,而不能简单对字节赋值。
2.2 11 位通道数据的位拼接逻辑
举例来说,我需要在 22 个数据字节里取出第 3 个通道的数据。假设这段数据起始字节在 frameData[1](也就是起始字节 0x0F 之后的那一个字节),通道 n 的数据其实从第 (n-1)*11 位开始,到第 n*11 - 1 位结束。我把这段位流看成一个大整数连续块,先用移位把相关字节凑齐,再按位偏移提取 11 位。
我实际用的解析思路是这样的:
for (int ch = 0; ch < 16; ch++) { int bitPos = ch * 11; int bytePos = bitPos / 8; int bitOffset = bitPos % 8; uint32_t tmp = (frameData[1 + bytePos] << 8) | frameData[1 + bytePos + 1]; tmp = (tmp << 8) | frameData[1 + bytePos + 2]; tmp >>= (16 - bitOffset); sbusChannels[ch] = tmp & 0x07FF; }这里最关键的一点是,为了安全,我会多取一个字节。因为一个通道的 11 位大概率跨越两个字节,极端情况下会跨越三个字节(比如 bitOffset 大于等于 6 的时候,11 位会跨到第三个字节)。如果你只取两个字节做拼接,解析出来的数值在某些位置会错位。所以我统一取三个字节到 uint32_t,再右移对齐,最后用 0x07FF 掩码取低 11 位。这样写虽然每次解析多读一个字节,但代码逻辑简单、不易出错,实测下来解析 100Hz 数据完全没压力。
2.3 标志字节、FailSafe 和帧间隔
标志字节 bit0 对应通道 17 的数字开关量,bit1 对应通道 18,bit2 是 FailSafe 的帧丢失标志,bit3 是 FailSafe 的已激活标志,剩下几个 bit 一般不用。解析的时候可以直接用位运算提取。比如判断 FailSafe 是否激活,就用frameData[23] & 0x08这个位来判断。
最后强调一个所有 SBUS 解析器都会遇到的隐性参数——帧间隔。SBUS 协议典型帧间隔是 14 毫秒左右(对应 100Hz 刷新率),但实际上不同的遥控器发射机设置的刷新率不完全一样,有的甚至到 200Hz。帧间隔这个参数对“IDLE 中断什么时候会触发”影响极大:IDLE 中断是在串口总线空闲一段时间后触发的,STM32 的 IDLE 时序判定是“收到完整的一个字节后,总线持续为空闲状态的时间达到一个字节的传输时间”。在 100000 波特率下,一个字节大约 0.1 毫秒(8E2 格式,含校验位和 2 个停止位共 12bit),所以帧内字节与字节之间的间隔通常很小,不会触发 IDLE;而帧与帧之间那几毫秒的空闲时间足够触发 IDLE 中断了。也就是说,一帧数据到达后,串口硬件会自动产生一次 IDLE 中断,这正是我们判断“一帧结束了”的天然信号。
3. DMA 循环接收与环形缓冲区:让数据自己转起来
理解了协议之外,接下来要解决的就是底层接收架构。我选的是 DMA Circular 循环模式,配合一个固定大小的缓冲区。为什么不用 Normal 模式?因为 Normal 模式搬运完配置的长度后会自动停止,下一次收数据还得手动重启 DMA,这在连续数据流场景下很容易丢字节,而且重启 DMA 的瞬间如果数据正好进来,起始位置会很乱。循环模式的好处是,DMA 搬完整个缓冲区长度后自动回到起点继续搬运,始终不让串口数据无处可去,CPU 完全不用管字节层面的接收。
3.1 用 CubeMX 配置 DMA 循环接收
我用的是 STM32CubeMX 生成初始化代码,串口配置为 100000 波特率、8 位数据、偶校验、2 位停止位,DMA 选择 USARTx_RX,Mode 为 Circular,数据宽度为 Byte。生成代码后,HAL_UART_Receive_DMA 函数会在底层配置好 DMA 通道并启动接收。需要注意,这个函数只能调用一次,之后一直处于循环接收状态,不需要每次进 IDLE 中断都重新调用。
缓冲区大小我建议设成 64 字节或者 128 字节。SBUS 一帧只有 25 字节,64 字节正好可以缓存两帧多,留出足够的处理余量。如果缓冲区设得太小,比如 32 字节,在连续接收时可能会出现“DMA 还没来得及被 IDLE 中断处理,下一帧又开始覆盖”的风险;设得太大则浪费内存,对资源紧张的芯片不划算。
3.2 从 CNDTR 反推当前帧的结束位置
DMA 循环接收要解决的问题是“数据一直在写缓冲区,我怎么知道当前帧写到了哪”。这里的关键是 DMA 的 CNDTR 寄存器。CNDTR 保存的是 DMA 通道剩余待传输的字节数。循环模式下,DMA 每收一个字节,CNDTR 就减一;减到 0 之后会重新加载初值(也就是缓冲区长度),然后继续递减。所以某一时刻,DMA 正在写入的位置(相对缓冲区起点)就是“缓冲区总长度 - CNDTR”。
举个具体例子。假设缓冲区长度为 64,CNDTR 当前值是 40,说明 DMA 已经往缓冲区写入了 24 个字节。这时候触发一次 IDLE 中断,说明 24 这个位置就是当前这一帧最后一个字节写入后的位置。用这个办法能精确拿到帧结束位置,不需要去翻别的寄存器。
CNDTR 读取的代码我现在直接写成宏:
#define DMA_GET_REMAIN_DATA_LEN(dmaHandle) ((uint16_t)(dmaHandle)->Instance->CNDTR)用 HAL 库可以直接访问 DMA 通道结构体里的 Instance。但注意一点:读取 CNDTR 的时刻越早越好,最好在 IDLE 中断一进来就读,因为 DMA 可能还在后台继续搬运下一帧的数据,读晚了位置就不对了。
3.3 环形缓冲区结构设计
我在工程里定义了这样一个接收缓冲区结构:
#define SBUS_RX_BUF_SIZE 64 typedef struct { uint8_t buf[SBUS_RX_BUF_SIZE]; volatile uint16_t lastIndex; // IDLE 中断时记录的帧结束位置 } SbusRxBuf_t;lastIndex 在 IDLE 中断里更新,表示最后接收的一帧数据在缓冲区中的写入末尾位置。用 volatile 修饰,是为了防止编译器优化把读操作缓存起来。因为 IDLE 中断和主循环代码可能在不同优先级下访问这个变量,不加 volatile 有可能读到旧值。
有一点容易被忽略的是,帧结束位置只是“这一帧最后一个字节的下一个位置”,帧起始位置并不在这个变量里,需要通过状态机去识别。也就是 DMA 只负责告诉 CPU“数据到哪了”,至于哪一段是完整的一帧,交给解析层判断。这也是为什么我安排了状态机来做边界对齐,而不是单纯靠 DMA 索引来计算帧起点。
4. IDLE 中断处理:最容易被忽略的几个细节
IDLE 中断是这套方案里承上启下的一环。很多人 DMA 配好了,缓冲区也建好了,但是 IDLE 中断触发后要么不知道清标志,要么在中断里做了一堆耗时操作,导致整个方案跑起来偶尔丢帧。这里我把自己调试中踩过的坑和总结出来的做法完整讲一遍。
4.1 HAL 库下 IDLE 中断的开启方式
CubeMX 生成的代码默认是开启串口接收中断的,DMA 中断也是使能的,但 IDLE 中断默认没有打开。需要自己加几行代码。我习惯在 MX_USARTx_UART_Init 函数末尾追加:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);这样串口的 IDLE 中断就被打开了。注意,IDLE 中断触发后需要清除标志,HAL 库提供的是__HAL_UART_CLEAR_IDLEFLAG(&huart1)。但因为我是在中断服务函数里自己处理,不经过 HAL 的HAL_UART_IRQHandler,所以清除标志时要先读状态寄存器再写清除,具体见下文。
4.2 IDLE 中断里到底应该干什么
我的 USART1_IRQHandler 是这样写的:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); sbusRx.lastIndex = SBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); } }这个中断函数只做两件事:清标志位、记录帧结束位置。没有数据拷贝,没有解析动作,没有打印输出。这非常关键——IDLE 中断的实时性要求较高,如果在里面做解析或拷贝,下一帧数据很快又会覆盖缓冲区,产生难以追踪的丢帧问题。
__HAL_DMA_GET_COUNTER(&hdma_usart1_rx)对应 DMA 通道的 CNDTR 值。用缓冲区总长度 - CNDTR得到的就是这一帧最后一个字节的下一个缓冲区位置,也就是 lastIndex。
4.3 为什么不用 DMA 传输完成中断来定位帧结束
有些教程会在 DMA 传输完成中断里拷贝数据。但循环模式下 DMA 的传输完成中断指的是“整个缓冲区搬完一圈”的事件,它不代表一帧数据的结束。SBUS 的 25 字节帧和 64 字节缓冲区没有任何对齐关系,DMA 传输完成中断触发时,缓冲区里可能已经混了好几帧。用它定位帧边界,需要额外做大量判断,远不如 IDLE 来得直接。我建议在循环接收场景下,DMA 传输完成中断都不要开,开了反而容易误导。
4.4 中断优先级该怎么配
IDLE 中断的优先级,我习惯配置为高于主循环中可能触发阻塞的任务,但低于系统滴答定时器。在 CubeMX 的 NVIC 设置里,可以把 USART1 全局中断优先级设成 2(数值越小优先级越高)。如果你的工程里还有别的外设中断,不要让它们长时间关闭,更不要在 IDLE 中断里做延时。SBUS 帧间隔在 14ms 左右,看起来充裕,但解析代码一旦写得太啰嗦,累积起来照样影响控制周期。
5. 状态机解析:从“一坨字节”到“16 路通道数据”
DMA 和 IDLE 保证了数据帧的“物理边界”,但缓冲区里存的并不总是规整的一整帧。因为 IDLE 中断触发的时机虽然接近帧结束时刻,但 DMA 写入位置和帧起始位置没有绝对对齐。缓冲区开头可能是某一帧的中间部分,也可能是刚好一帧的起始。所以拿到 lastIndex 之后,不能简单地认为“从 0 到 lastIndex 就是一帧”,必须靠状态机对字节流做边界重同步。
5.1 状态机状态定义
我定义的解析状态机非常简单,只有四个状态:
typedef enum { SBUS_STATE_SYNC, // 等待起始字节 0x0F SBUS_STATE_DATA, // 接收 22 字节通道数据 SBUS_STATE_FLAG, // 接收标志字节 SBUS_STATE_END // 接收结束字节 0x00 } SbusParseState_t;因为 SBUS 帧结构固定、长度固定,状态机不需要太复杂,但从缓冲区的任意位置都能通过状态跳转收敛到正确帧边界。
5.2 为什么必须做边界重同步
举个例子,缓冲区里 DMA 写入位置落在某个帧的第 8 个字节处,那 lastIndex 附近的数据并不是从 0x0F 开始的。如果我们不做重同步,直接从缓冲区起点解析,第一帧数据必然是乱的。状态机的做法是:逐字节扫描,先等一个 0x0F,然后再按 25 字节长度去消费,最后检查结束字节是不是 0x00。如果检查失败,就继续往后扫描,重新等待 0x0F。这个方法相当于给数据流加了“滑动窗口”,确保我们解析的每一帧都是真实对齐的完整帧。
5.3 主循环里的完整解析流程
我是在主循环里轮询解析,而不是在中断里解析。主循环每轮做一次这样的判断:如果新的 lastIndex 跟上次处理的索引不相等,说明又有新的一帧到达了,就把缓冲区的数据按环形方式复制到局部数组,然后交给状态机解析。
复制的逻辑需要认真处理环形回绕。假设上次处理位置是 40,这次 lastIndex 是 56,那么在环形缓冲区里从 40 到 56 的地址可能跨越缓冲区尾部回绕。比如 40 到 63 存了一段,0 到 56 又存了一段。我在代码里统一做两次 memcpy,先把尾部复制,再把头部复制:
uint16_t len = (sbusRx.lastIndex + SBUS_RX_BUF_SIZE - lastProcessedIndex) % SBUS_RX_BUF_SIZE; if (len > 0) { if (lastProcessedIndex + len <= SBUS_RX_BUF_SIZE) { memcpy(frameBuffer, &sbusRx.buf[lastProcessedIndex], len); } else { uint16_t firstPart = SBUS_RX_BUF_SIZE - lastProcessedIndex; memcpy(frameBuffer, &sbusRx.buf[lastProcessedIndex], firstPart); memcpy(&frameBuffer[firstPart], sbusRx.buf, len - firstPart); } lastProcessedIndex = sbusRx.lastIndex; }这里变量 lastProcessedIndex 记录的是上一次已经复制到的位置,每次复制完立即更新。用环形取模的方式保证无论 DMA 怎么回绕,都不会把同一段数据重复解析,也不会漏掉中间的数据。
5.4 字节级状态机如何从任意位置收敛到正确帧
如果字节流中有干扰噪声或者起始字节正好出现在数据中间,状态机需要依靠“起始字节 + 校验长度 + 结束字节”三重条件来确定一个合法帧。我的实现逻辑:
uint8_t sbusFrame[25]; uint16_t rxIndex = 0; while (len > 0) { uint8_t byte = *pRead++; len--; switch (state) { case SBUS_STATE_SYNC: if (byte == 0x0F) { sbusFrame[0] = byte; rxIndex = 1; state = SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: sbusFrame[rxIndex++] = byte; if (rxIndex == 23) { state = SBUS_STATE_FLAG; } break; case SBUS_STATE_FLAG: sbusFrame[23] = byte; rxIndex = 24; state = SBUS_STATE_END; break; case SBUS_STATE_END: if (byte == 0x00) { // 收到了一个看起来完整、边界正确的帧 sbusFrame[24] = byte; parseSbusFrame(sbusFrame); } // 无论成功失败,都重新回到同步状态 state = SBUS_STATE_SYNC; break; } }这个方法本质是“滑动窗口 + 帧尾校验”。状态机只在确认结尾是 0x00 时才认为这是一帧,否则继续找下一个 0x0F。因为 0x0F 出现在 22 字节数据内部的概率极低,即便出现,也没法通过结尾 0x00 校验,因此误判概率可以忽略。
5.5 帧数据解析函数 parseSbusFrame
确定了一帧完整的 25 个字节后,就进入实际的通道解析。这步就是前面 2.2 节讲的位拼接逻辑,再加上标志字节的提取。我用一个结构体保存解析结果:
typedef struct { uint16_t channels[16]; uint8_t ch17; uint8_t ch18; uint8_t failsafeFrameLost; uint8_t failsafeActivated; } SbusChannels_t;解析完成之后,就可以直接把这些通道值送给后续的舵机控制或 PID 运算了。这里我建议不要在主循环里直接拿解析结果去控电机,可以先做一层滤波或者限幅处理,以免某个通道出现瞬间跳变导致执行机构剧烈抖动。
6. 实测数据、参数调整与避坑总结
前面几节讲的都是“应该怎么做”,但这套方案真正落地的时候,还有很多细节决定了它到底稳不稳定、能不能长时间运行。我把自己实测过程中比较典型的数据和几个容易出问题的点集中放在这一节。
6.1 实际接收效果:CPU 占用和帧间隔表现
我用的芯片是 STM32F103 系列,主频 72MHz,串口 1 接收 SBUS,同时三个 PWM 定时器输出、一个 OLED 刷新,外加一个简单的 PID 速度环。整机跑下来,SBUS 接收 + 解析这部分的开销,按主循环轮询计算大概只占 CPU 的百分之几。主要原因是中断路径很轻:每帧只触发一次 IDLE 中断,中断里只记录一个索引,不做复制不解析;解析是攒完一个完整的 25 字节后在主循环里一次性完成,不会反过来影响中断实时性。
帧间隔上,我的发射机设置的是 100Hz,用逻辑分析仪抓串口波形,测量相邻两帧起始字节 0x0F 之间的时间差,稳定在 14.0ms 左右,偶尔有 0.1ms 的抖动,跟遥控器的时基晶振精度有关系,这并不影响解析结果。
我还测过将缓冲区改为 32 字节的情况,问题很快暴露:主循环在 OLED 刷新的那段时间耗时超过 5ms,如果 SBUS 帧刚好在这时候到达两次,DMA 缓冲区就可能被写满并回绕,等我回来复制数据的时候,新数据已经覆盖了一部分旧数据,从 lastIndex 反推的长度也不对了。这也印证了缓冲区不能盲目求小,64 字节是比较稳妥的做法。
6.2 缓冲区大小、帧长度、处理延迟三者的关系
有人可能会问,缓冲区越大越稳吗?理论上是的,但大缓冲区意味着 RAM 占用上升,而且 DMA 回绕周期变长,某些情况下 lastIndex 的差值判断要小心溢出。实际上对 SBUS 来说,64 字节已经足够容纳“两帧 + 头部偏移”的最坏情况。给一个建议:
缓冲区长度 >= 单帧字节数 * 2 + 4预留 4 字节是为了容忍 IDLE 中断延迟和主循环复制延迟。如果主循环最坏响应时间较长,可以把系数从 2 加到 3。我个人不推荐为单帧 25 字节的协议开 256 字节的缓冲区,纯属浪费。
6.3 高频出现的坑:清不掉的 IDLE 标志
我用 HAL 库的时候做过一个错误示范:在 IDLE 中断里先调用HAL_UART_IRQHandler(&huart1),再去判断 IDLE 标志。结果发现 IDLE 标志永远清不掉,进入死循环。原因是 HAL 库内部对于 IDLE 这类特殊中断,并不会主动清除标志位,需要手动调用__HAL_UART_CLEAR_IDLEFLAG。而某些芯片的清标志逻辑是“先读 SR,再写 DR”才能完成清除,如果在中断里加了额外操作,可能因为读顺序问题导致清除无效。稳妥的做法是保持中断服务函数精简:只读标志、清标志、记录索引,其他事情一律不干。这个顺序我强烈建议新手照抄。
6.4 偶校验和波特率的小坑
SBUS 用的 100000 波特率不是标准常用值,有些 USB 转串口芯片或者逻辑分析仪,在 100000 波特率下会有 1%~3% 的偏差。我遇到过用某款逻辑分析仪抓波形时,显示波特率是 99980,感觉没问题,但换成另一款软件后显示 100150。这类偏差不会导致 STM32 内部接收失败,因为芯片串口容错范围一般在 ±2% 以内,但对于手工抄波形确认时序的人来说很容易产生疑惑。建议调试时直接把串口配置成 100000bps,8E2,不要参照 9600 或者 115200 那种习惯。
偶校验也要记住:SBUS 是偶数校验。如果你在 CubeMX 里配成无校验或者奇数校验,接收到的 25 字节会完全错乱,而且 STM32 的 USART 在偶校验不匹配时会置 PE 标志。这种故障在网上经常被描述成“数据对但是解析不对”,其实本质是校验位不对。
6.5 如果要扩展成通用接收框架,可以从哪里改
这套架构不止能解 SBUS,PPM、DSM、甚至自定义串行协议,只要底层还是“串口字节流 + 周期帧结构”,都能套用。扩展点主要是状态机:起始字节、帧长、校验方式、结束字节不同,状态机的判定条件改一改即可。DMA 和 IDLE 这部分完全不用动,可以做成一个通用的 DMA 串口接收模块,把“帧边界判定”抽象成回调函数,逻辑就能复用。
我在后续项目里就用同样的底层,解析过一款自定义遥测协议,区别只是帧长变成了 32 字节、起始字节变成了 0xA5,校验方式是 CRC16。改起来确实很快,这也说明当初把“字节搬运”和“协议解析”分开设计是对的,值得长期保留这种分层习惯。
最后多说一句个人体会:这套方案调试时最容易出现问题的往往不是代码逻辑,而是“你以为 IDLE 触发就一定是完整帧到了”这个假设。实际项目里串口线上会有干扰、上电瞬间有毛刺,状态机的容错能力才是长稳运行的关键。我的做法是始终保留“帧尾 0x00 校验 + 状态机重同步”,哪怕偶尔遇到坏帧,也只是丢弃这一帧,绝不会因为坏数据把后续所有解析带偏。用这套方案跑了几个月,从没出现过一次解析彻底卡死的故障。如果你也打算在 STM32 上做串口协议解析,我建议从这一套基础架构开始搭,个人体验非常顺。