UART主从通信多余字节排查与修复实战指南
做嵌入式这些年UART 应该是最常用也最容易被低估的接口。你大概率遇过这种怪事协议里明明只发了 5 个字节从机却收到了 6 个主机还没发数据从机一上电就收到一个 0x00把逻辑分析仪接上去波形又干净得像教科书一样。这类问题在 master 和 slave 两块 MCU 之间跑 UART 时尤其常见网上管它叫 unwanted bytes with UART in master and slave MCU翻译过来就是“主从 MCU 的 UART 通信里出现了不该出现的字节”。它不会让你系统直接崩但会污染帧结构、破坏校验、触发错误状态严重的能让整条主从链路完全对不上话。这篇文章我会用自己的真实排查经验把这类多余字节的产生原理、定位方法和最终能落地的修复方案全部讲清楚适合正在做单片机主从通信、调试串口协议、或者被各种诡异串口数据折磨的工程师参考。1. 先给“多余字节”分个类症状不同病因完全不同1.1 三种最常见的不该出现的字节先说结论unwanted bytes 不是一个病而是好几个病的共同症状。我把它按出现时机和表现分成三类每一类的排查方向完全不一样。第一类上电/复位瞬间的垃圾字节。最常见的是从机或者主机一上电UART 接收寄存器里就躺着一个 0x00 或 0xFF。系统正常启动之后通信一切正常但只要一复位这个垃圾字节就会准时出现。这类字节极具迷惑性因为你正常调试时往往不会去关注启动瞬间等后期联调、机器频繁复位时才暴露出来。第二类帧中间或帧尾多出来的旧字节。比如主机发送一帧 8 字节从机有时候收到 9 字节多出来的那个还恰好是上一帧的最后一个字节。或者收到一帧数据中间突然插进去一个重复字节导致后面的数据全部错位。这类问题往往不是随机的而是有固定节奏频率不高但一出现就破坏整帧。第三类没有数据发送时的毛刺字节。主机和从机都处于空闲状态从机突然收到 0xFF、0x00 或者随机数。用示波器看可能是线上一闪而过的窄脉冲或者地电位突变造成的低电平毛刺。这种情况下波形通常也很脏属于典型的物理层问题。我做主从采集系统时就撞上了第一类和第二类混合的情况主控 STM32F103从机用 STM32G030两块板子用 TTL 电平直连。从机一上电必然收到一个 0x00主机发一帧 8 字节从机偶尔收到 9 字节多出来的是上一帧最后一个字节。最初我以为是代码问题反复检查接收逻辑都没找到毛病后来才意识到问题不在“处理”上而在“源头”。症状典型表现形式首要怀疑方向上电/复位即收到单字节0x00 或 0xFF只在启动瞬间出现引脚浮空、初始化顺序、器件复位状态帧尾多出上一帧旧字节接收计数比实际多 1多出的字节有规律溢出错误 ORE、中断处理不及时空闲时收到毛刺字节0x00/0xFF波形上能看到窄脉冲共地不良、外部干扰、线上上拉缺失1.2 为什么主从两块 MCU 之间更容易出这种问题很多人想不明白我用 USB 转 TTL 接电脑连 Arduino、连 STM32从来没出过这种“多余字节”为什么换成两块 MCU 直连问题就来了这其实很好解释。USB 转 TTL 模块本身有完善的收发电路它和电脑之间的地线、电源通常是同一个来源引脚电平也被模块内部的收发器处理得比较干净。但两块 MCU 直连是另一个世界它们有各自的电源、各自的晶振、各自的复位时序甚至各自处于不同的程序执行阶段。先看地电位。如果主从板子各自用一个 USB 口供电两个 5V/3.3V 电源的地并不是绝对等电位两根地线之间的压差可能达到几百毫伏甚至更高。UART 信号是单端传输发送方输出高电平 3.3V接收方看到的实际电压是信号电压减去两地之间的电位差。地电位一旦不稳接收端的采样阈值被突破就会凭空产生边沿误认为是起始位。再看复位时序。主机和从机大概率不是同时上电也不是同时复位。当从机处于复位状态时它的 RX 引脚往往没有初始化可能处于高阻输入或者浮空状态。此时线上只要有一点噪声就会被从机内部已上电的 UART 外设捕捉到。更麻烦的是有些 MCU 在复位释放后、main 函数还没跑到 GPIO 初始化时外设寄存器的默认状态并不可控UART 可能在 GPIO 还没配好之前就开始接收数据了这样复位瞬间的一丁点毛刺就会变成 DR 里的第一个“合法”字节。最后是两套时钟。主机和从机各自用自己的晶振或内部 RC 振荡器产生波特率。两个时钟源的初始误差、温漂、电压特性都不一样。USB 转 TTL 模块和电脑之间的时钟由主机主动控制误差要小得多。两块 MCU 直连时如果两边都是内部 RC误差叠加起来很容易超过 UART 的容限导致接收端采样错位于是出现乱码、帧错误甚至把噪声当数据。2. 从串口原理看“字节是怎么被凭空取出来的”2.1 UART 接收器如何工作起始位、采样点和停止位要理解 unwanted bytes得先明白 UART 接收端是怎么“认”出一个字节的。UART 是异步全双工或半双工串行协议收发之间没有独立的时钟线接收端完全靠电平跳变来同步。空闲状态下RX 线必须保持高电平。发送端要发一个字节时先把线拉低一个位时间这叫起始位。接收端看到这个下降沿就知道后面跟着一个字节然后按约定的波特率在每个位的中间采样点读取电平依次取到 8 个数据位最后等待一个高电平的停止位。一帧结束线路恢复高电平。这里的关键是接收端永远在监视 RX 线上的下降沿。任何把线路从高拉到低的情况无论持续时间多短只要被 UART 采样到就会被当成一个潜在的起始位。如果这个低电平持续了一个位时间以上UART 就会开始接收一整帧数据。所以问题就变成什么情况下 RX 线会莫名其妙地出现一个“合法”的下降沿答案不外乎几类引脚浮空受噪声干扰、地电位突变、发送端 TX 引脚在上电/复位瞬间输出不确定电平、或者接收端波特率匹配不上导致采样错位。理解了这一点后面的排查逻辑就顺了。2.2 波特率误差怎么算为什么会导致帧尾多字节波特率误差是 UART 通信里最隐蔽的杀手。很多新手觉得 115200 就是 115200两块 MCU 都配成 115200 就完事了实际上晶体振荡器有误差内部 RC 更不靠谱两边的实际波特率可能相差不少。以最常用的 8 数据位无校验 1 停止位8N1为例一帧包含 1 个起始位 8 个数据位 1 个停止位总共 10 个位时间。如果在起始位下降沿之后接收端按照自己的时钟去采样每个位时间的采样点都会累积误差。假设发送端实际波特率比接收端高 3%那么接收端采样第 10 个位停止位时理论上已经偏移了大约 0.3 个位时间。这个偏移足以让采样点落到数据位的边缘或者让停止位被采成低电平最终导致帧错误Frame Error或者收到的数据整个错位。在 master 和 slave 两块 MCU 的通信场景里波特率误差还有叠加效应。主机时钟 1.5%从机时钟 -1.5%那实际误差就是 3%已经非常危险。有些 MCU 内部 RC 在全温度范围内能够达到 ±3% 甚至更高加上长帧数据、多字节传输误码率会显著上升。判断方法也很简单用逻辑分析仪抓主机的 TX 引脚实际测量一个字节的位宽算出实际波特率再抓从机的 TX 引脚算另一个两者之差不要超过 ±2%超过就要换晶振或者校准。2.3 RXNE 处理不及时会留下“假字节”溢出错误这类问题经常被误判为“多收了一个字节”实际上是“少读了一个字节”造成的。在 STM32 这类 MCU 的 UART 外设里接收到的数据会先放进数据寄存器 DR硬件同时把 RXNE读数据寄存器非空标志置 1。如果软件不及时读取 DR下一个字节又到了硬件就会置上溢出标志 ORE。在溢出情况下DR 里的旧数据不会被新数据覆盖具体行为取决于 MCU 实现但 RXNE 标志仍然为 1。如果软件的中断处理不及时或者中断被更高优先级抢占等到读取时拿到的还是旧数据于是下一帧里就混进了上一帧的最后一个字节。更麻烦的是如果你在中断里只读 DR 而不主动清 ORE接下来新到的字节会持续产生错误状态接收链路基本就废了。我遇到的主机发 8 字节从机收到 9 字节最后定位到就是这个问题。从机中断里做了太多事情比如把数据拷贝到结构体、更新标志位、甚至调用了打印函数导致 RXNE 中断处理时间超过了 1 个字节的传输时间。115200 波特率下 1 个字节大约 86.8 微秒看着挺充裕但如果你在中断里又做了其他耗时的操作很容易超时。解决办法是把中断服务函数压缩到最短只做数据入队解析和业务放到主循环里处理。2.4 复位、上电和 GPIO 浮空造成的非法帧最后也是最容易踩的一个坑引脚浮空。MCU 复位期间或者程序刚启动尚未配置 GPIO 时RX 引脚通常是高阻输入状态电平不被任何东西固定。此时如果线上有静电放电、电源突变、或者相邻信号线串扰RX 引脚上就会出现一个短暂的低电平毛刺。一旦 UART 外设已经上电工作这个毛刺就会被当成起始位。接收端开始按照波特率采样由于毛刺后面根本不是持续的低电平采样到的大概率是 1于是得到 0xFF如果毛刺刚好持续一段低电平时间可能采到多个 0 开头的字节比如 0x00。这就是为什么上电瞬间经常收到 0x00 或 0xFF 的原因。另外还有一种特殊情况从机复位过程中它的 TX 引脚可能输出不定电平。如果这个不确定电平恰好把主机 RX 拉低主机也会收到垃圾字节。所以主从双方都需要检查上电和复位瞬间的引脚状态不能只盯着一端。3. 实战排查一步一步把坏字节“按”在现场3.1 准备一个能稳定复现的测试环境排查这类问题第一件事不是改代码而是让问题变得可复现。如果问题只是偶发你很难验证修复是否有效。我建议搭一个最简单的测试环境两块 MCU 板子用尽量短的杜邦线直接连接 TX/RX/地三根线不接任何其他外设。如果问题在短线下也能复现说明是软件或固有硬件问题如果短线下问题消失长线下才出现那就重点查共地、长线干扰和电平驱动能力。然后准备一个逻辑分析仪或者示波器采样率至少 4 倍于波特率推荐 10 倍以上。比如 115200 波特率逻辑分析仪采样率最好设到 2M 以上这样才能看到毛刺。还需要一个能打印调试信息的串口通道把从机收到的字节和错误标志实时打出来。复现方法也很重要。针对上电垃圾字节给从机反复上电、断电每次上电后检查收到的第一个字节。针对帧尾多字节让主机循环发送固定长度的帧发几千次看从机是否偶发多收。针对毛刺字节把所有设备保持空闲观察从机是否在无数据传输时收到字节。每个问题都要给出明确的复现路径否则后面没法判断是修好了还是凑巧没出现。3.2 第一步先抓波形别急着改代码我习惯第一步永远是用逻辑分析仪抓异常瞬间的波形。这一步能快速区分问题出在物理层还是协议层。把逻辑分析仪的通道夹到从机的 RX 引脚上触发方式设为下降沿触发。然后给从机上电或者触发主机发帧。看波形时重点看两件事第一空闲电平是不是稳定在高位。正常的 UART 空闲电平应该是接近电源电压的高电平。如果看到空闲时电平有下坠、抖动、或者来回跳说明引脚浮空或者线路受扰这就是 unwanted bytes 的直接来源。第二看毛刺出现在什么位置。如果上电瞬间 RX 线上有一个 1 微秒级别的低脉冲随后 UART 把它解析成一个完整字节那问题就是毛刺。如果 TX 波形非常干净但接收端仍然多字节那大概率是接收端的波特率误差或者中断处理慢属于“内部问题”波形上看不出来。抓波形时还要顺手量一下实际波特率。从抓到的字节波形里量一个最窄的高电平或低电平宽度理论上 115200 下最窄位是 8.68 微秒。如果量出来是 9 微秒左右说明波特率偏慢如果只有 8 微秒说明偏快。主从双方都量一遍算出差值心里就有数了。3.3 第二步把错误标志暴露出来波形干净不代表内部没有问题。很多时候 UART 外设已经把错误标志置位了只是软件没有去读。为了定位我建议写一个最简的接收中断不去处理业务只记录收到的字节和 SR 寄存器里的错误标志。在 STM32F1 这种系列上标准外设库风格的写法可以这样volatile uint8_t rx_buf[64]; volatile uint8_t rx_cnt 0; volatile uint16_t error_flags_hist 0; void USART1_IRQHandler(void) { volatile uint16_t sr USART1-SR; if (sr (USART_FLAG_ORE | USART_FLAG_FE | USART_FLAG_NE)) { /* 错误标志置位记录并丢弃该字节 */ error_flags_hist | sr; (void)USART1-DR; return; } if (sr USART_FLAG_RXNE) { if (rx_cnt sizeof(rx_buf)) { rx_buf[rx_cnt] USART1-DR; } else { (void)USART1-DR; } } }这段代码的关键在于检测到 ORE、FE、NE 错误时先把标志记录下来然后读一次 DR 清除错误状态。不要只读 DR 不清标志那样下一次错误还会继续累积问题表现会变得更加混乱。如果用 HAL 库可以在错误回调里收集错误信息void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_OREFLAG(huart); __HAL_UART_CLEAR_FEFLAG(huart); __HAL_UART_CLEAR_NEFLAG(huart); } }然后通过调试串口把error_flags_hist的值打印出来。ORE 代表溢出FE 代表帧错误NE 代表噪声错误。哪个标志量多就直接指向对应的根因方向。我那次排查中从机收到的第 9 个字节就是 ORE 标志置位后读取到的旧数据问题定位到中断处理时间过长而不是线路问题。3.4 第三步脱开从机让 USB 转 TTL 当“替身”如果你想尽快分清是主机的问题还是从机的问题最有效的办法是找一个 USB 转 TTL 模块当“替身”把通信链路从“MCU 对 MCU”拆成“MCU 对 PC”。具体操作分两组实验。第一组主机的 TX 直接接 USB 转 TTL 的 RX模块插电脑串口助手显示接收数据。然后让主机按照同样的方式发送那 8 字节帧看电脑端是不是也会多字节。如果电脑端是干净的说明主机的发送逻辑和硬件没问题问题出在从机侧。如果电脑端也出现多字节那要继续检查主机 TX 的时序、波特率误差、以及是否误发了数据。第二组把从机的 RX 接到 USB 转 TTL 的 TX由电脑主动向从机发数据。看从机是否还会在上电时收到 0x00是否还会在正常帧后多出字节。如果电脑发就没问题从机自己接收才有问题那大概率是从机 RX 引脚初始状态、中断处理或者波特率配置的问题。这个“替身”法的逻辑很简单USB 转 TTL 模块自身有稳定的收发器时钟和电平都更可靠。它能帮你把责任边界画清楚避免两个 MCU 互相甩锅也能极大地缩小排查范围。3.5 第四步逐项做二分实验做完前几步基本能锁定到主机侧还是从机侧再往下就是针对具体原因的二分法实验。这类实验每次只改变一个变量记录问题是否消失通常不到半小时就能定位。我常用的实验清单如下降低波特率比如从 115200 降到 9600。如果问题消失说明很可能与波特率误差、线路高频特性有关。换短粗线或者双绞线如果不能换就把线尽量贴近地线走。问题消失说明长线耦合或串扰是主因。两块板子共用同一个电源或者用一根粗线把两块板子的地直接连起来。问题消失说明地电位差或电源纹波是主因。把从机 RX 引脚改为内部上拉输入再初始化 UART。上电 0x00 消失说明引脚浮空导致的毛刺被确认。在主机发送函数结尾加一个短暂延时再切换 RS485 方向。多字节消失说明半双工方向切换时序有问题。每次改动之后至少跑 1000 次复现测试确认问题不再出现再算通过。不要指望一次就能定位更不要凭感觉改代码UART 诡异问题尤其要讲证据。4. 修复方案落地从硬件到协议把坏字节挡在外面4.1 硬件层面的四个必改项先讲硬件。不管软件写得多完美物理层有隐患多余字节总有一天会换个姿势冒出来。在 master 和 slave 两块 MCU 的 UART 通信中我每次画板或者接线都会强制检查下面四个点。第一个是共地。所有 MCU 地必须可靠地连在一起地线尽量短、粗不要在电源线上“借地”。两块板子如果各自用 USB 供电USB 地之间往往有电阻线上压差不可忽视。最稳妥的做法是让所有板子从同一个电源端子取电地线通过单点接入。第二个是RX/TX 信号线上加上拉。很多 MCU 的 GPIO 内部有可编程上拉一定要在初始化时把 RX 引脚配成GPIO_PULLUP。如果 MCU 不支持内部上拉就在靠近引脚的位置外接一颗 4.7kΩ 到 10kΩ 的上拉电阻到电源。上拉的作用是把空闲电平钳制在高位即使对端 TX 引脚在复位期间处于高阻线路也不会浮空从根上杜绝毛刺被识别成起始位。第三个是适当阻尼。在 RX 线上串联一个 33Ω 到 100Ω 的电阻可以抑制信号边沿的过冲和振铃。尤其在两个板子之间走线较长时信号反射会产生振荡在接收端看起来就像多个下降沿。串联电阻虽然会轻微降低边沿速率但对 115200 以内的波特率完全够用。如果环境电磁干扰严重还可以在接收引脚对地并联一个几十皮法的小电容给高频噪声提供泄放路径。第四个是距离和电平选择。如果两台 MCU 之间的连线超过 1 米或者周围有电机、继电器等干扰源不要犹豫直接改成 RS485/RS422 差分传输。TTL 电平在长线和噪声环境下就是一副空壳再多的软件滤波也只是亡羊补牢。RS485 还要记得在总线两端加 120Ω 终端电阻并在 A/B 线上加偏置电阻防止总线空闲时电平不确定。4.2 UART 初始化务必先清残留标志硬件整改之后软件要跟上。最容易被忽略的细节是UART 初始化时数据寄存器和错误标志里可能已经有上电瞬间残留的垃圾数据。如果不先清掉一开接收中断就会立刻触发一次“伪接收”把垃圾字节当成第一帧数据。正确的初始化顺序应该是先配置 GPIO将 RX 引脚设置为带上拉的输入/复用功能再使能 UART 时钟配置波特率、字长、停止位。然后立即检查并清除 ORE、FE、NE 标志再读一次 DR 清空数据最后才使能接收中断。以 STM32 的 HAL 库为例可以参考下面这段void MX_USART1_Init(uint32_t baud) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); /* TX: PA9 复用推挽输出 */ gpio.Pin GPIO_PIN_9; gpio.Mode GPIO_MODE_AF_PP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio); /* RX: PA10 复用输入务必使能内部上拉 */ gpio.Pin GPIO_PIN_10; gpio.Mode GPIO_MODE_AF_INPUT; gpio.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, gpio); huart1.Instance USART1; huart1.Init.BaudRate baud; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1); /* 清残留错误标志再读一次 DR */ __HAL_UART_CLEAR_OREFLAG(huart1); __HAL_UART_CLEAR_FEFLAG(huart1); __HAL_UART_CLEAR_NEFLAG(huart1); volatile uint8_t tmp (volatile uint8_t)huart1.Instance-DR; (void)tmp; }有些工程师觉得HAL_UART_Init之后内部已经处理了这些实际上并没有。你必须在使能接收中断之前手动清一遍。这个步骤虽然只有几行代码但能省掉后面大量调试时间。4.3 接收侧不要裸奔状态机校验超时硬件和初始化都处理完如果还担心偶发的错误字节那就要在协议层设一道防火墙。很多人接收 UART 数据时习惯用HAL_UART_Receive_IT固定接收 N 个字节收到 N 个就算一帧。这种方案在数据长度固定、数据链路干净时没问题但在主从通信里一旦出现 unwanted bytes接收长度就会错位后面全乱。我强烈建议给接收侧加一个简单的状态机。帧格式可以定为帧头 长度 数据 校验。每收到一个字节就切换状态校验不通过就丢弃并重新回到等待帧头的状态。这样即使有垃圾字节混进来只要它不是完整的帧头长度数据校验结构就不会被当成有效帧。下面是一个简化的状态机实现C 语言裸机环境可以直接用#define FRAME_HEADER 0xAA #define MAX_FRAME_LEN 64 typedef enum { ST_WAIT_HEADER, ST_WAIT_LEN, ST_WAIT_DATA, ST_WAIT_CRC } rx_state_t; typedef struct { rx_state_t state; uint8_t buf[MAX_FRAME_LEN]; uint8_t len; uint8_t idx; uint8_t crc; } uart_frame_t; static uart_frame_t fr; void uart_rx_byte(uint8_t b) { switch (fr.state) { case ST_WAIT_HEADER: if (b FRAME_HEADER) { fr.state ST_WAIT_LEN; fr.len 0; fr.idx 0; fr.crc b; } break; case ST_WAIT_LEN: fr.len b; fr.crc ^ b; fr.idx 0; if (fr.len MAX_FRAME_LEN) { /* 长度非法回到等待帧头 */ fr.state ST_WAIT_HEADER; } else if (fr.len 0) { fr.state ST_WAIT_CRC; } else { fr.state ST_WAIT_DATA; } break; case ST_WAIT_DATA: fr.buf[fr.idx] b; fr.crc ^ b; if (fr.idx fr.len) { fr.state ST_WAIT_CRC; } break; case ST_WAIT_CRC: if (b fr.crc) { /* 一帧有效交给业务处理 */ frame_handler(fr.buf, fr.len); } else { /* 校验失败丢弃并重新同步 */ } fr.state ST_WAIT_HEADER; break; default: fr.state ST_WAIT_HEADER; break; } }这里用异或做简单校验实际项目里建议换成 CRC8/CRC16鲁棒性更好。状态机的核心价值在于单个垃圾字节如果不是帧头会被直接忽略如果恰好是帧头后面长度和校验也大概率过不去不会污染业务数据。另外一定要配合超时机制。如果接收过程中断了比如收到帧头但后续字节一直不来状态机就会一直停在某个中间状态下一帧有效数据进来时可能被误解析。解决办法是启动一个 tick 定时器每毫秒检查一次距离上次收到字节超过比如 10ms就把状态机强行重置回ST_WAIT_HEADER。这就是把“帧不完整”的情况也作为垃圾数据挡在门外。4.4 主从模式下的几个时序注意点在 master 和 slave MCU 通信里除了数据本身还有“方向”和“时序”的问题。如果用的是 TTL 直连主从 TX/RX 交叉连接不需要方向切换情况相对简单。但如果改用了 RS485 半双工或者多从机共享一条总线方向切换的时机就变得非常关键。最常见的一个坑是主机发送完最后一个字节后立刻把收发器的 DE/RE 从发送模式切回接收模式。这时候最后一个字节的停止位可能还没完全发完总线上的电平仍在跳变切回接收模式后收发器可能把残留在总线上的“尾巴”当作数据接收下来于是主机就多收到了一堆 unwanted bytes。正确的做法是等待发送移位寄存器完全变空然后再加一点延时。STM32 里发送完所有数据的标志是 TCTransmission Complete不是 TXE。TXE 只代表数据寄存器空了移位寄存器可能还在发最后一位。void uart_send_buf(uint8_t *buf, uint8_t len) { for (uint8_t i 0; i len; i) { while (!(USART1-SR USART_FLAG_TXE)); USART1-DR buf[i]; } /* 等最后一个字节完全发送完成包括停止位 */ while (!(USART1-SR USART_FLAG_TC)); /* 再延时半个到一个字节时间然后才能切方向 */ delay_us(10); RS485_DIR(RS485_RX_MODE); }从机侧的响应时序也要注意。从机收到完整一帧、校验通过之后应该先准备应答数据再使能发送方向。不要在还没处理完上一帧时就去抢总线主从碰撞一旦发生总线上的乱码字节会同时污染两个 MCU 的接收缓冲区。4.5 关于 DMA 和中断的选择如果你使用的是 DMA 空闲中断IDLE来接收不定长数据那要小心另一个坑DMA 缓冲区大小和帧长度不匹配。很多人把 DMA 缓冲区设为 64 字节但一帧实际只有 20 字节剩余 44 字节的空间不会自动“失效”。一次 DMA 传输完成时数据计数器也就是NDTR会告诉你还剩多少字节没填实际接收字节数应该是“缓冲区大小减去剩余计数”。如果只根据 DMA 传输完成中断来判断一帧结束你会把缓冲区里的旧数据也当成新数据一起处理看起来就像“多出来的字节”。对于不定长协议我更推荐“环形缓冲区 空闲中断”的方案DMA 循环接收每收到一个字节就自动递增硬件在总线空闲时产生 IDLE 中断然后从中断里提取这一段时间内收到的数据。这样做有两个好处不丢字节、不需要在每次接收前重新配置 DMA。坏处是代码复杂度高一些但对于 master/slave 主从通信这种长期运行的设备绝对是值得的。如果只有普通中断那处理原则就一条中断里越快越好。不要做解析、不要调用打印、不要 memset只把收到的字节塞进环形缓冲区错误标志立刻清掉。具体的帧解析、状态机、校验全部放到主循环里去做。5. 常见问题速查表与几条私藏经验5.1 快速定位表我把这么多年排查 UART 多余字节遇到的高频场景整理成一张表方便你遇到问题时直接对着查。它覆盖了 90% 以上的主从 MCU 通信场景剩下的 10% 需要靠测量和实验去确认。现象可能原因验证方式解决方案上电/复位瞬间收到 0x00 或 0xFFRX 引脚浮空毛刺被当成起始位逻辑分析仪抓 RX 空闲电平检查 GPIO 上拉配置RX 配置内部上拉外部加 4.7k~10k 上拉初始化后清错误标志主机发 N 字节从机收 N1多出上一帧末尾ORE 溢出旧数据未被及时读取打印 SR 中的 ORE 标志观察中断处理耗时压缩中断处理使用环形缓冲区清 ORE 标志优先用 DMA 或空闲中断空闲状态收到 0xFF/0x00 毛刺共地不良、电源纹波、长线串扰晃动手边线材用示波器看毛刺两板共地测试共地缩短走线加串联电阻改 RS485 差分低波特率正常高波特率多字节波特率误差过大用逻辑分析仪测量实际位宽计算两边误差换晶振校准内部 RC降低波特率偶发帧错误、乱码波特率误差、线路干扰、信号振铃查看 FE 标志抓波形看停止位是否被破坏加终端电阻串电阻降低波特率换差分传输RS485 切换方向后多字节DE/RE 切换过早尾巴被接收示波器观察 DE 和 A/B 电平时序等 TC 标志后再切方向增加方向切换延时DMA 接收多出旧数据DMA 缓冲区残留数据未清除检查 NDTR 计算实际长度打印缓冲区内容用环形 DMA 空闲中断接收前清空缓冲区5.2 我的四条避坑心得第一条永远先怀疑空闲电平其次怀疑波特率最后才是协议。很多“多余字节”看着像软件逻辑问题实际上就是 RX 引脚浮空或者地电位不稳。你把状态机写得再漂亮物理层毛刺照样会变成假数据。先看波形、量电平再动代码。第二条给接收加状态机和校验不要为了“省事”裸奔。一开始我也觉得加状态机麻烦后来发现只要出现一次串帧、错位排查成本就完全抵消了那几天的编码投入。CRC 校验还能帮你快速发现物理层问题而不是等业务数据错了才后知后觉。第三条排查时一定要抓“异常瞬间”的波形不要用正常波形安慰自己。我踩过最大的坑就是逻辑分析仪抓了几百帧波形全部正常于是认定是软件问题。后来加了触发条件专门抓帧尾那一片区域才发现是 DE 信号切换太早导致的尾巴。波形抓对了定位就是半小时的事。第四条两块 MCU 直连如果都用内部 RC 时钟长帧传输尤其要实测波特率。不要相信两个 MCU 的初始化代码都写了 115200 就是真的 115200。内部 RC 不仅初始误差大温度一变还会漂同一套固件今天调试没问题明天在机柜里跑一会儿就开始多字节。这种情况最有效的办法是在产品上使用外置晶振或者把从机的时钟配置成经过校准的精确时钟源。后来我把从机 RX 改成上拉输入初始化时先清错误标志主机发送完等 TC 再切换 RS485 方向这套奇怪的多字节问题就再也没出现过。UART 这个东西就是这样原理不复杂但坑全藏在细节里。希望这篇记录能让你少走几个弯路。