
先交代背景。去年我手里一个基于STM32F103的项目在老化测试阶段开始出现偶发通信错帧客户那边用Modbus RTU轮询总在运行几十个小时后突然读回一帧错数据。示波器挂上去观察电平波形干净得能当教科书示例可一旦把探头取下来错帧又偶尔出现。查到最后问题既不是DMA配置也不是协议解析而是USART的UART_FLAG_FE和UART_FLAG_NE这两个错误标志位以及我在HAL库错误回调里少处理的一件事。这篇文章想把整条排查链路和最终修复方案完整写出来。适合正在用STM32F103 HAL库做串口DMA收发、尤其是遇到偶发错帧接收一段时间后卡死DMA发送不能连续发送这类问题的开发者参考。文章里不会只贴结论我会把底层逻辑和排查方法一起讲清楚这样你下次遇到类似问题不用靠猜。1. FE和NE到底是什么F1串口采样机制决定了很多直觉是错的1.1 16倍过采样与三分投票STM32F103的USART在接收端对每一位数据做了16倍过采样也就是说接收一个bit的时间窗口内硬件会采样16次。解码时并不是16次全部拿来做判断而是取中间的第8、9、10三个采样点做多数投票三个点里两个以上采到高电平这一位就判为高。这个机制本来是为了抗干扰但它也是UART_FLAG_NE产生的根源。三个采样点结果不完全一致时硬件认为这次采样受到了噪声污染于是把NE位置1。注意NE置位不代表这一位一定解错了只代表采样结果不一致。FE则更严重。帧错误是在停止位检测阶段触发的停止位本来应该是稳定的高电平但如果在停止位的第8、9、10采样点上多数投票结果采到了低电平硬件就判定这不是一个合法停止位FE位置1。在参考手册里FE的触发条件还包括失步、过度噪声和break字符。所以我习惯把FE理解成整帧结构坏了NE理解成帧里某个bit采样不干净。两者可能同时置位也可能单独出现排查时要看谁的占比更高。1.2 FE、NE、ORE三个标志的差异很多初学者会把FE、NE、ORE混为一谈反正都是USART的SR寄存器标志位报错就清一下。但它们代表的物理含义完全不同标志触发条件数据是否有效最常见诱因NE一位数据的采样点结果不一致该位可能仍被解码但不可靠共模噪声、地电位差、干扰毛刺FE停止位采样点多数为低电平该帧数据不可靠但字节可能进入RDR波特率偏差、总线电平异常、失步ORERXNE为1时又收到新字节上一字节未取走丢失新字节DMA未及时搬运、中断处理太慢以我实际项目中的统计经验如果是两个系统之间偶发通信错误NE出现的频率通常远高于FE如果FE开始大规模出现优先怀疑波特率匹配和总线电平而不是环境噪声。这个区分能帮你少走很多弯路。1.3 为什么连着示波器就好了这是整个排查过程中最迷惑人的现象。我在老化测试时把示波器探头接到A、B线上测了一下午都是干净的波形但只要把探头拿下来跑一晚上还是会有错帧。原因其实很朴素示波器探头的地线夹本身就是一根低阻抗导线它夹在系统地上以后相当于给两个原本地电位不完全一致的设备提供了一个外部共地回路。很多所谓的噪声其实是两个系统之间的地电位差造成的共模干扰探头一夹地电位差被强制拉低FE和NE自然就消失了。所以排查FE/NE时不要轻易相信示波器看波形一切正常这个结论。它只能证明信号电平的绝对形状没问题不能证明两个系统之间的地参考是干净的。后面我会专门讲物理层的处理。2. HAL库DMA模式下错误标志被拿到后的真实动作2.1 SR寄存器清除规则与HAL库宏F103的USART_SR寄存器里PE、FE、NE、ORE这四个错误标志的清除方式比较特殊不是写1清除而是必须先读SR寄存器、再读USART_DR寄存器硬件才会把错误位清零。对应的HAL库宏是__HAL_UART_CLEAR_PEFLAG(huart1); __HAL_UART_CLEAR_FEFLAG(huart1); __HAL_UART_CLEAR_NEFLAG(huart1); __HAL_UART_CLEAR_OREFLAG(huart1);这些宏在F1的HAL库里本质就是读一次SR再读一次DR。需要注意DMA模式下DR寄存器是被DMA控制器读取的手动读DR并不会破坏DMA正在搬运的数据流但你的代码意图要清楚读取DR就是为了触发错误标志清除不是想从串口拿一字节数据。2.2 HAL_UART_IRQHandler里对错误标志的处理很多人不知道在使用HAL_UART_Receive_DMA启动接收后USART的全局中断里发生错误时HAL库内部的动作不只是清标志它会先做三件事把SR里的错误标志读出来存入huart-ErrorCode进入DMA模式下的错误处理分支调用UART_DMAAbortOnError中止当前DMA接收调用用户重写的HAL_UART_ErrorCallback回调。关键点在第2步DMA被中止了。如果回调函数里什么都不做接收状态就停在空闲或异常状态之后线上的数据虽然还在进USART但不会被DMA搬到缓冲区。表现出来的现象就是串口突然收不到数据了重新调用一次初始化又恢复正常过一段时间又卡死。我在帮朋友排查一个类似问题时他就是这种表现程序跑着跑着通信中断复位一下立刻好接着再跑几个小时又断。查来查去ErrorCallback里只放了一行printf(uart err\n)既不恢复DMA也不清状态。这不是个例而是HAL库DMA模式最容易踩的隐形坑。2.3 ErrorCallback里最常见的三个错误写法在明确了HAL库会自动中止DMA之后再回头看错误回调常见的错误写法就非常清晰了第一种只打印不做任何恢复。调试阶段无所谓产品阶段这是致命的因为接收一旦停摆整个链路就瘫了。第二种在回调里直接调用HAL_UART_Receive_DMA想重新开始接收。但此时DMA可能还处于BUSY状态API会返回HAL_BUSY你以为重启成功了实际什么也没发生。正确做法是先用HAL_UART_DMAStop强制停止DMA再重新启动接收。第三种在回调里做耗时操作比如写Flash、长时间打印日志、延时。错误回调本身运行在中断上下文做重活会阻塞整个系统还容易引发看门狗复位。还有一个细节错误发生时FE所在的那一帧数据其实已经被DMA搬进缓冲区了。F1在检测到帧错误时通常仍会把该字节写入RDR并触发DMA请求。也就是说坏数据已经混进接收缓冲光清标志还不够协议层需要靠帧头、长度、CRC把这些坏数据丢掉否则后续数据会错位。3. 定位FE/NE我的完整排查链路复盘3.1 第一步区分持续错误和偶发错误遇到FE/NE不要急着改代码。先做一件事在错误回调里把两种错误的次数分别统计出来通过串口或者测试报告接口读出来。volatile uint32_t frame_err_cnt 0; volatile uint32_t noise_err_cnt 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint32_t sr huart-Instance-SR; if (sr UART_FLAG_FE) frame_err_cnt; if (sr UART_FLAG_NE) noise_err_cnt; /* ... 后续恢复逻辑 */ } }根据统计结果可以快速分类FE和NE都极少每小时只出现一两次的基本是偶发干扰优先查地线和屏蔽NE很多、FE几乎为零的多半是共模噪声或者采样点靠近信号跳变沿FE持续增加、NE反而不多的优先怀疑波特率偏差下一步直接测实际波特率。3.2 第二步回环测试和波特率实测为了把物理层和逻辑层分开我有一个固定流程先把MCU的TX和RX用跳线短接跑一个自发自收的DMA循环测试。如果短接以后FE/NE消失说明问题出在外部线缆和对方设备如果短接以后还有错误那问题大概率在这块板子本身比如时钟配置或者TX/RX引脚复用错误。然后拿逻辑分析仪或者示波器测一下TX引脚上实际输出的波特率。不要相信配置实测最靠谱。举个实际例子某项目用外部8MHz晶振但CubeMX里时钟树配置成了12MHz导致系统时钟比预期高USART2实际波特率跑到9976配置是9600偏差约3.9%。这个偏差已经超过UART停止位采样容限了对端MCU在停止位第8、9、10采样点采到低电平FE频繁出现。把时钟树修正后问题消失。3.3 第三步物理层的三处嫌疑如果波特率没问题回环测试也正常那就要把目光放到外部物理链路。我这里遇到最多的三类情况第一是地电位差。两个设备各自用独立的开关电源供电电源负极之间没有真正共地USART电平虽然看起来是正常的TTL但两个系统之间的参考地差了好几伏。共模电压压到RX引脚上NE和FE会同时飙升。第二是线缆与强干扰源平行走线。比如RS485线跟电机驱动线捆在一起走电机PWM一开干扰脉冲直接耦合进总线采样点恰好落在跳变沿附近NE就开始产生。第三是长线缆没有终端匹配。RS485在超过几十米、或者分支较多的场合不接终端电阻会导致信号反射总线波形在停止位上形成振铃看起来就是FE。我常用的验证手段是人为制造干扰来复现问题把串口线拉长、靠近变频器输出线、或者故意让两个设备的地线不连看FE/NE计数是否显著增加。如果某个动作一操作错误计数立刻上升根因就锁定了。4. 彻底修复错误恢复代码与更稳的接收架构4.1 最小修复ErrorCallback里强制重置接收先把最直接的修复方案放出来。这套代码的思路是错误发生时清标志、强制停止DMA、重新启动DMA接收。之所以要强制停止是为了避免HAL_UART_Receive_DMA因为DMA还处于BUSY状态而返回HAL_BUSY。#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance ! USART1) return; /* 记录错误类型方便统计和现场排查 */ uint32_t sr huart-Instance-SR; if (sr UART_FLAG_FE) frame_err_cnt; if (sr UART_FLAG_NE) noise_err_cnt; if (sr UART_FLAG_ORE) overrun_err_cnt; /* 清理F103上必须通过读SR再读DR来清除的错误标志 */ __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_FE); __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_NE); __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_ORE); /* 强制停止当前DMA确保接收状态回到READY */ HAL_UART_DMAStop(huart); /* 重新启动DMA接收 */ if (HAL_UART_Receive_DMA(huart, rx_buf, RX_BUF_SIZE) ! HAL_OK) { /* 如果这里还失败说明状态机没恢复干净需要额外处理 */ __HAL_UART_ENABLE(huart); HAL_UART_Receive_DMA(huart, rx_buf, RX_BUF_SIZE); } }注意这套方案只是恢复没有处理坏数据混入缓冲区的问题。如果协议带CRC坏帧在协议层会被丢弃那没问题。如果你的应用是按固定长度拆包的就要在错误回调里把接收缓冲区头部指针重新指向当前帧起点或者干脆把缓冲区的有效标志清掉。4.2 更稳的方案空闲中断DMA帧边界自己掌握只靠DMA传输完成回调来接收数据有一个天然缺陷DMA缓冲区满了才触发完成回调而你的协议帧长度往往小于缓冲区。比如缓冲区256字节协议帧最多32字节怎么办你只能在缓冲区里攒好几帧再解析或者把缓冲区设成和帧一样长但帧长度不固定时就会很尴尬。所以我的习惯是DMA负责把数据搬进缓冲区空闲中断IDLE负责告诉你这一帧结束了。STM32的USART在RX线上检测到一段空闲时间后会置IDLE标志。配上DMA就能做到数据驱动收、空闲拼帧。void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); /* 当前这一帧有效长度 缓冲区长度 - DMA剩余计数 */ uint16_t rx_len RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(huart1.hdmarx); if (rx_len 0) { process_frame(rx_buf, rx_len); } /* 非循环DMA模式处理完一帧后重新启动接收 */ HAL_UART_DMAStop(huart1); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }这个架构有个好处每一帧的边界都在IDLE中断里被锁定FE/NE发生时当前帧的长度字段、CRC字段大概率都是坏的process_frame里校验失败直接丢弃即可。错误恢复时只需要像4.1那样重置DMA不需要再额外处理缓冲区错位问题。如果想让接收永远不停顿可以考虑DMA的Circular循环模式。但循环模式下DMA计数器是持续递减又自动重装的你要自己维护一个环形读指针逻辑复杂度会高一些。我的建议是协议简单、帧短用非循环DMA IDLE协议长、数据流持续不断再上Cirular 环形队列。4.3 从电气层面压制NE/FE代码再健壮也架不住物理层持续轰炸。如果测试环境里NE计数居高不下光靠代码恢复是不够的一定要动硬件。我这里按优先级排序第一解决地回路。两个设备短距离通信确保有可靠的低阻抗共地线。长距离或者跨电源系统用隔离方案电源用隔离DC-DC信号用光耦或者隔离RS485收发器。这一步能消掉绝大多数NE。第二线缆走线。串口线尽量用屏蔽双绞线屏蔽层单端接地。布线时远离电机线、PWM线、继电器线这些大电流变化率高的线路。如果避免不了交叉尽量垂直交叉不要平行长距离走线。第三降低波特率。这个看起来最不起眼但效果往往最直接。同一个链路115200波特率时NE每小时几十次降到9600可能一整天一次都没有。因为波特率降低后每一位的时间变长采样点离信号跳变沿更远噪声毛刺的影响被平均掉了。不是所有项目都需要115200必要时给波特率留一点余量。5. 顺带解决DMA发送不能连续发送这个高频坑5.1 锁机制HAL_BUSY从哪里来搜索词里很多人问到HAL库使用DMA发送数据不能连续发送我实际看过的代码里八成是连续调用HAL_UART_Transmit_DMA导致的问题。HAL库的发送状态机用一个gState字段来锁第一次调用时gState从READY变成BUSY_TXDMA开始搬运第二次调用如果来得太快DMA还没搬完gState还是BUSY_TXHAL_UART_Transmit_DMA就直接返回HAL_BUSY。所以你会在主循环里看到类似现象第一次发送成功第二次发不出去第三次又成功。这不是DMA坏了而是你发送得太快了。5.2 发送缓冲区的生命周期问题除了锁机制还有一个隐蔽问题DMA发送是异步的数据缓冲区必须在整个发送过程中保持有效。很多人图省事在函数里定义局部数组然后调用DMA发送函数一返回栈上的数据被覆盖DMA还在搬运旧数据发出去的内容自然就乱了。正确做法是用全局数组或静态数组或者用一个发送完成标志volatile uint8_t tx_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) tx_busy 0; } uint8_t UartSendDma(uint8_t *buf, uint16_t len) { if (tx_busy) return 0; /* 上一次还没发完本次丢弃或排队 */ tx_busy 1; if (HAL_UART_Transmit_DMA(huart1, buf, len) ! HAL_OK) { tx_busy 0; return 0; } return 1; }这个实现最简单业务上能接受忙则丢弃就用。如果一帧都不能丢就要在发送外挂一个队列TxCpltCallback里弹下一帧继续发。5.3 DMA通道冲突与CubeMX配置检查F103的各个串口DMA通道是固定的USART1_TX是DMA1_Channel4USART1_RX是DMA1_Channel5USART2_TX是DMA1_Channel7USART2_RX是DMA1_Channel6USART3_TX是DMA1_Channel2USART3_RX是DMA1_Channel3。你可能会同时用到SPI、ADC、定时器它们也会申请DMA通道。如果两个外设被分配到了同一个DMA通道后者配置会覆盖前者表现为某个外设的功能时好时坏。CubeMX里可以在DMA配置界面直接看到通道占用情况检查一下你的工程别让串口和SPI在同一个DMA通道上打架。6. 长期稳定运行压力测试与量产经验6.1 设计一轮有效的压力测试很多人的串口测试是发几个字节看看正确就完事这对定位偶发FE/NE来说几乎没有意义。我的测试标准是三段第一阶段长时间老化。让设备在目标波特率下持续收发24到72小时统计错误的次数和间隔。不要收到错误就重启要看错误是否能在代码层自愈。第二阶段边界电压测试。把供电电压往下调到接近欠压点同时跑通信看FE/NE是否增加。很多通信怪问题实际上是电源纹波在作怪。第三阶段全双工对打。两个MCU互发随机长度、随机内容的数据包每包带CRC统计CRC错误率和错误类型。单纯回环测试只能证明链路通不能证明双向同时数据传输时有没有互相干扰。6.2 诊断信息设计量产产品不可能每次出问题都插仿真器所以我在固件里专门留了一个错误信息结构体保存最近一次FE/NE/ORE发生的时间戳、DMA剩余计数值、错误计数总数通过某个调试命令或者上位机读取。现场如果复现了偶发问题直接看这几个数值就能判断大致方向。typedef struct { uint32_t fe_cnt; uint32_t ne_cnt; uint32_t ore_cnt; uint32_t last_error_tick; uint16_t last_rx_len; } uart_diag_t;这个思路不复杂但能省下大量现场排查时间。别等到出了bug才print平时就把诊断信息攒好。6.3 几个容易忽略的硬件细节最后说几个量产时容易被忽略的硬件细节地线宽度一定要够。PCB上串口地段的地线如果又细又长地电位会在PCB内部就产生差异直接把NE引出来。RS485长线传输终端电阻焊不焊、焊一端还是两端对波形影响很大。短距离可以不用超过几十米最好两端都接120欧姆终端电阻同时加上下拉偏置电阻防止悬空时误收。还有一点经验如果这个系统里既有开关电源、又有高速数字总线串口线尽量从电源输出端远离电源纹波大的时候NE的错误率会明显上升而这个问题从代码层面是查不出来的。我在实际项目中把上面这套方案完整落地后老化测试里的FE/NE从偶发必现变成了连续几天计数为零接收也再没有出现跑着跑着就卡死的情况。最后再分享一个小技巧如果你的产品以后还要在现场排查类似问题可以在出厂固件里常驻一个通信健康状态查询命令把FE/NE/ORE计数和最近一帧CRC错误次数上报给上位机。这个设计成本极低但现场工程师拿到数据后能少走至少一半弯路。