ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32F103串口DMA的FE/NE错误排查与自动恢复方案

STM32F103串口DMA的FE/NE错误排查与自动恢复方案 干嵌入式这几年被STM32F103串口DMA坑过的次数真不算少。最典型的一个现象程序跑着跑着UART就像咽气了一样DMA明明配置好了、回调也老老实实写了但就是再也不进接收回调。打开调试器一看SR寄存器UART_FLAG_FE或者UART_FLAG_NE被置位HAL_UART_ErrorCallback触发过一次之后接收链路就彻底停了。这篇文章专门聊HAL库下STM32F103串口DMA收发时FE帧错误和NE噪声错误到底怎么产生、怎么排查、怎么用代码做自恢复。内容适合正在被串口偶发死机折磨的人也适合从标准库转HAL库的兄弟以及刚用CubeMX搭完DMA却发现系统一受干扰就废掉的新手。我会把硬件层面的诱因、HAL库处理错误标志的内部逻辑、以及可以直接抄的稳定代码方案一起讲清楚。1. 先搞懂FE和NE是什么别拿错误标志当地震很多朋友一看到HAL_UART_ErrorCallback被调用第一反应就是“串口坏了”然后把整个外设重新Init一遍。这种做法不是不行但属于乱枪打鸟。要彻底解决问题得先知道FE和NE在硬件层面到底发生了什么。1.1 帧错误FE停止位被拉低的一瞬间USART接收一帧数据有个基本规则空闲时RX线保持高电平数据帧以起始位低电平开始后面跟着8个数据位最后是停止位高电平。接收器内部有一个过采样时钟一般是波特率的16倍在每一位的时间中点附近采样。如果能正常采到停止位的高电平这一帧就收得干净如果在停止位的采样时刻RX线上依然是低电平硬件就会把USART_SR寄存器里的FE位置1。FE的含义很直接这一帧的时间关系已经乱套了。最常见的情形是对端设备波特率偏慢每个bit被拉长导致本端在采停止位时实际上还在最后一位数据位的时间窗内也有可能是RX线被外部因素拉低比如对端故障、电平冲突、或者共地不良。FE置位后DR寄存器里的那个数据字节大概率是错的不能直接当有效数据处理。我调试时遇到过一种很有迷惑性的情况用逻辑分析仪看波形停止位确实是高电平但程序却报FE。后来发现是STM32和前端RS485收发器之间地电位不一致导致接收端看到的电平被削低了。所以别看到一个低电平就认定是波特率问题线路压降和地偏移同样能制造出“假停止位”。1.2 噪声错误NE过采样失败数据被“投毒”NE和FE不同它更像是“采样点打架”的结果。USART接收每个bit时不是只采一次而是用过采样做多数表决。比如过采样16倍时会在一位的中间附近取多个采样点如果这些采样点不一致——比如采样到0、1、0——说明这一位上有毛刺或者跳变硬件就会把NE位置1。NE标志的常见来源是外部干扰RX线走得太长没做防护、靠近继电器或电机驱动线缆、插拔连接器瞬间的抖动、对端设备上下电时IO口短暂浮空这些都容易让采样点出现不一致。NE不一定立刻导致整帧错误但如果噪声持续存在多个bit被判错最终就会和数据位错位叠加成FE。所以在一个系统里看到NE和FE同时出现往往说明干扰源已经比较严重了。还有一个容易忽略的细节NE错误不等于数据一定错了。多数表决机制的设计初衷就是容错某一个采样点被噪声干扰只要多数采样点正确数据位仍然可能是对的。但HAL库不区分这种“轻微噪声”和“严重噪声”只要NE被置位它就会把整次接收视为失败并中止DMA链路这就引出了下一个小节的问题。1.3 为什么HAL库一碰到错误就“罢工”这是所有串口DMA问题里最核心、也最坑的一环。很多人困惑的点在于我只想收个数据为什么会“死机”其实MCU没死是HAL库主动把接收链路关掉了。HAL_UART_Receive_DMA启动接收后会通过USART_CR3寄存器的EIE位使能错误中断。一旦硬件检测到FE、NE或者ORE溢出就会触发UART全局中断。中断服务函数里HAL_UART_IRQHandler会检查到错误标志然后做三件事把错误码记录到huart-ErrorCode里通过读SR和DR的方式清除错误标志然后调用UART_EndRxTransfer这个内部函数。问题就出在UART_EndRxTransfer上。这个函数会关掉DMA搬运、禁用相关UART中断并把huart-State复位成READY。也就是说HAL库认为“这次接收已经废了”直接把DMA通道停掉。如果你只在HAL_UART_RxCpltCallback里做了处理而没有在ErrorCallback里重新调用HAL_UART_Receive_DMA那系统就永远停在那里看起来像死机一样。我见过不少同事在这个坑里卡一整天因为他们以为HAL库会像标准库那样错误之后自己恢复继续接收。HAL库没这个自觉它把错误处理的责任完全交给了开发者。所以设计串口DMA方案时必须把“错误恢复”当成一项和“初始化”“发送”“接收”同等重要的标准功能来做。2. 从根上排查这些原因是FE/NE频繁出现的元凶处理FE/NE错误正确的思路是“先防后恢复”。如果错误频率很高你就算写了再完美的恢复代码系统也会频繁丢数据。所以我先讲硬件和工程配置层面的几个主要诱因。2.1 波特率误差最容易被低估的凶手STM32F103的USART波特率由PCLK时钟和BRR寄存器决定计算公式是USARTDIV PCLK / (16 × BaudRate)然后把USARTDIV的整数部分写入BRR的高12位小数部分乘16取整后写入低4位。注意这里是有舍入的所以实际波特率和目标波特率之间一定有误差。参考手册里建议全双工通信时两端时钟误差的累计值最好不要超过2%如果长期稳定最好控制在1%以内。为什么是这个值因为接收端在每个bit的中间时刻采样如果一帧有10个bit1起始8数据1停止波特率误差会导致采样点逐位偏移偏移量累积到停止位时超过半个bit周期就必然采错FE就出现了。我整理过一个常见时钟配置下的误差估算表拿115200这个典型波特率来说场景实际USART时钟USARTDIV理论值BRR实际写入实际波特率误差USART1APB272MHz7200000039.06250x02711152000%USART2/3PCLK136MHz3600000019.531250x0138115384.6-0.16%USART1直接8MHz时钟80000004.34030x0045115942.00.64%从表中能看出误差最大的场景出现在“低主频”情况下。如果你用内部HSI 8MHz直接跑115200误差还有0.64%理论能忍但HSI本身温漂明显夏天和冬天可能就不一样了。更危险的是对端如果是某些USB转串口芯片内部晶振本身就不准两边误差叠起来很容易突破2%。排查波特率问题时别只信CubeMX生成的代码。CubeMX用的是理想PCLK值它不会告诉你晶振精度。实际调试建议用示波器或逻辑分析仪抓一下对端TX脚输出的波形精确测量起始位下降沿到停止位上升沿的时间折算成实际波特率两边都测一下对比误差是否在安全范围。另外要注意USART2和USART3挂在APB1总线上当APB1分频系数为2时PCLK1是36MHz而APB1上的定时器是72MHz这两者别搞混波特率算错往往就是从这里开始的。2.2 RX引脚浮空、驱动能力和外部干扰CubeMX配置USART_RX引脚时默认模式是复用推挽输出这个“推挽”只是说引脚能主动驱动输出而接收方向上的输入电平是由外部电路决定的。如果你的板子在设备未连接、或者对端拔线的一瞬间RX引脚处于悬空状态电平会随机漂移采样点打架NE就来了。这种问题在原理图阶段就要解决。RX引脚建议外部上拉到VCC阻值10kΩ左右比较常用。这样做有两个好处空闲状态被稳定在高电平符合UART协议插拔连接器或者对端断电时不会因为引脚悬空而产生乱七八糟的错误标志。如果应用环境干扰比较强可以在RX线上串联一个1kΩ电阻和引脚等效电容形成一个低通滤波能明显抑制高频毛刺但要注意串阻太大会影响信号边沿波特率超过115200时要谨慎。还有一个容易踩的坑是TTL电平设备的“共地”。两块板子各自供电只连了TX和RX没连GND信号线上全是电位差轻则乱码重则烧IO。串口调试第一件事就是确认地线是通的。对于长距离通信别硬用TTL电平老老实实上RS485或者RS422用A/B差分走线加终端电阻。RS485接收端的A、B之间要加偏置电阻保证空闲状态是确定的否则总线悬空时一样会产生FE/NE错误。2.3 DMA和UART中断配置不当造成的连锁反应HAL库的错误恢复流程依赖中断配合所以NVIC优先级配错会引发非常隐蔽的故障。我在错误回调里尝试重新启动DMA接收时遇到过返回HAL_BUSY的情况查了很久才发现是中断优先级的问题。这里要说清楚HAL库的一个内部动作UART_EndRxTransfer会调用HAL_DMA_Abort_IT来异步中止DMA传输DMA的Abort完成中断需要DMA中断能够被响应。如果你的UART中断优先级高于DMA中断UART中断服务函数还没退出时不断有错误中断进来而DMA中断被堵在后面Abort状态迟迟不完成你再去调用HAL_UART_Receive_DMA自然就被拒了。我的习惯配置是DMA通道中断优先级高于同串口的UART中断。比如中断优先级分组为NVIC_PRIORITYGROUP_2时DMA1_Channel4和DMA1_Channel5的中断抢占优先级设为1USART1_IRQn设为2这样既能保证DMA完成中断及时处理也给错误恢复留出了执行空间。另外中断服务函数里必须老老实实调用HAL_UART_IRQHandler和HAL_DMA_IRQHandler很多复现不了的随机问题最后发现是中断响应函数漏掉了。DMA工作模式也要想清楚。定长帧通信用Normal模式收到指定长度后触发RxCpltCallback处理完必须重新调用Receive_DMA启动下一轮否则只收一次就停。持续不间断的数据流用Circular循环模式DMA自己循环搬运但错误中断一旦触发UART_EndRxTransfer同样会把循环接收停掉所以循环模式不代表你不用写错误恢复。还有一种常见的做法是Normal模式加串口IDLE空闲中断用来判断不定长帧的结束这个我在后面会给出代码。2.4 485、自收发和时序问题如果你用的是RS485收发器比如MAX3485或者SP3485FE错误还有一个非常隐蔽的来源发送方向切换过早。很多同学用TXE标志来切断DE也就是认为“最后一个字节写进发送寄存器了”就该切方向了。但TXE只代表数据从内核搬到了发送移位寄存器移位寄存器还没有把最后一个bit送完此时切到接收模式总线上最后一个停止位可能没发完整对端设备就会报告帧错误。正确做法是等待TC标志发送完成也就是HAL库中UART_FLAG_TC。发送完成回调HAL_UART_TxCpltCallback触发的时机就是TC置位所以理想的方向切换点是在这个回调里处理。如果用了DMA发送DMA的传输完成中断比TC可能早一拍别在DMA传输完成回调里直接切DE先确认TC再操作。关于TC标志还有一个容易忽略的点TC在传输完成时置位但读取SR后再写DR会自动清掉TC所以如果写发送代码时不注意清标志第一次发送没问题后续的TC判断可能一直为真或者一直为假方向切换时序就全乱了。自收发测试时还有一个经典场景把TX和RX短接MCU发送什么就收到什么。但如果短接处电平驱动能力不足回环信号边沿变缓也可能产生NE错误。这种自测问题容易误导人以为程序有问题其实把短接断开就消失了。3. 一个可以直接抄的HAL库串口DMA收发方案前面分析了原因这一节给出我一贯使用的、稳定跑过多个量产项目的HAL库方案。它包含CubeMX配置、接收启动、错误恢复、连续发送四个部分。3.1 CubeMX配置要点用CubeMX生成工程时以下几步按这个思路来第一时钟树。外部晶振8MHz的话PLL源选HSE倍频到72MHzAPB1预分频设为2APB2设为1。这样USART1挂在APB2上时钟72MHz115200波特率下误差为0。如果你用的是HSI至少保证系统时钟算出来是整数频率尽量别用非标时钟跑串口。第二USART1模式选Asynchronous波特率115200字长8位无校验1个停止位。NVIC设置里打开USART1全局中断。第三DMA Settings。添加UART1_RX和UART1_TX两个DMA请求。RX选择DMA1 Channel5TX选择DMA1 Channel4。地址增量Increment Address打开数据宽度都设为Byte。RX的模式根据你的帧格式选Normal或Circular建议先从Normal开始。TX用Normal。第四NVIC中断优先级。按前面讲的DMA通道中断优先级高于USART中断。生成代码之后打开工程确认一下MX_DMA_Init在MX_USART1_UART_Init之前被调用。这是个隐藏的坑如果DMA初始化在串口初始化之后USART1_UART_Init内部配置GPIO和时钟没问题但稍后启动DMA接收时可能因为DMA时钟没使能而失败。3.2 初始化与启动接收主循环里启动接收代码可以写成这样#define UART_RX_BUF_SIZE 256 #define UART_TX_BUF_SIZE 128 static uint8_t uart_rx_buf[UART_RX_BUF_SIZE]; static uint8_t uart_tx_buf[UART_TX_BUF_SIZE]; static volatile uint8_t uart_rx_data_ready 0; static volatile uint8_t uart_tx_done 1; void UART_StartReceive(void) { __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_FE | UART_FLAG_NE | UART_FLAG_ORE); HAL_UART_Receive_DMA(huart1, uart_rx_buf, UART_RX_BUF_SIZE); }main函数里初始化外设后先清一次标志再启动接收。为什么要先清标志因为如果上电瞬间RX引脚电平不稳定SR寄存器里可能有残留的FE或者NE标志不清的话启动DMA接收后错误中断可能立刻触发一上来就进错误回调。定长接收的处理逻辑在接收完成回调里做void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart_rx_data_ready 1; // 不要在这里直接调用 HAL_UART_Receive_DMA // 把重启动作放在主循环避免回调嵌套过深 } }主循环里处理完一帧数据后再调用UART_StartReceive启动下一轮接收。这里有一个取舍如果主循环处理逻辑很慢而串口数据源源不断进来新一轮接收还没启动数据就会丢失甚至触发溢出错误。这种场景下建议双缓冲两个接收缓冲区一个被DMA填充时主循环处理另一个。双缓冲的实现比较长这里先不展开但思路就是回调里切换缓冲区和长度主循环里处理上一块数据。如果你要处理不定长帧可以在USART1_IRQHandler里加IDLE中断判断void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t len UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (len 0) { // 这一帧有效数据长度为 len可以扔到自己的环形队列里 } HAL_UART_DMAStop(huart1); UART_StartReceive(); } }IDLE标志的意思是RX线空闲了至少一个字节时间这在不定长协议里非常实用。注意读取DMA计数器要在停止或重启接收之前否则计数会变。中断里处理要快别做打印、别做大循环。3.3 错误自动恢复ErrorCallback的正确姿势错误恢复是整个方案的核心错误回调里最忌讳的就是做一堆耗时操作然后拍拍屁股走人。我的做法是把真正的恢复动作放在主循环回调里只置标志static volatile uint32_t uart_err_cnt 0; static volatile uint32_t uart_last_err_code 0; static volatile uint8_t uart_recover_request 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart_err_cnt; uart_last_err_code huart-ErrorCode; __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_FE | UART_FLAG_NE | UART_FLAG_ORE); uart_recover_request 1; } }主循环里处理恢复if (uart_recover_request) { uart_recover_request 0; // 强行停止DMA确保DMA通道是空闲的 HAL_UART_DMAStop(huart1); // 重新初始化一次串口彻底复位接收状态 HAL_UART_DeInit(huart1); HAL_UART_Init(huart1); // 清错误标志启动接收 __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_FE | UART_FLAG_NE | UART_FLAG_ORE); HAL_UART_Receive_DMA(huart1, uart_rx_buf, UART_RX_BUF_SIZE); }为什么把恢复动作放到主循环因为HAL_UART_ErrorCallback是在中断上下文里被调用的中断里做HAL_UART_DeInit再Init虽然很多情况下能跑但风险在于这个过程中还可能进来新的串口中断重入和时序问题让人头大。主循环里执行虽然会有几个毫秒的空窗期但换来的是稳定性和可预测性。还要处理错误风暴。如果对端设备掉线、RX线被强拉低错误中断会一直触发ErrorCallback会被疯狂调用即使主循环不断恢复也只是杯水车薪。这种场景下加一个退避机制比如1秒内错误超过10次就停止恢复30秒再试。代码上就是记录错误时间戳在恢复函数里做节流#define UART_ERR_MAX_PER_SEC 10 static uint32_t uart_err_timestamp[UART_ERR_MAX_PER_SEC]; static uint8_t uart_err_index 0; uint8_t UART_ErrorThrottle(void) { uint32_t now HAL_GetTick(); uart_err_timestamp[uart_err_index] now; if (uart_err_index UART_ERR_MAX_PER_SEC) { uart_err_index 0; } uint32_t oldest uart_err_timestamp[uart_err_index]; if ((now - oldest) 1000) { return 0; // 错误太频繁先不恢复 } uart_err_timestamp[uart_err_index] now; return 1; }这个节流逻辑的核心是兼顾“尽快恢复”和“防止反复重启”。实际项目里错误风暴期间如果一直做DeInit/Init可能会干扰系统里其他依赖USART中断的功能比如低功耗唤醒、BootLoader跳转节流能把这些副作用降到最低。3.4 连续DMA发送的坑与解决发送侧最常见的抱怨就是“用HAL库DMA发送数据不能连续发送”。绝大多数情况是忽略了HAL_UART_Transmit_DMA的返回值。HAL_UART_Transmit_DMA内部会检查huart-State如果上一次发送还没完成State不是READY函数会直接返回HAL_BUSY。很多人的代码收到HAL_BUSY后不处理数据自然就发不出去。解决思路很简单用一个“发送忙”标志串行化发送uint8_t UART_SendByDMA(uint8_t *data, uint16_t len) { if (uart_tx_done 0) { return 0; } uart_tx_done 0; if (HAL_UART_Transmit_DMA(huart1, data, len) ! HAL_OK) { uart_tx_done 1; return 0; } return 1; } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart_tx_done 1; } }有了这个“上一包发完才发下一包”的机制连续调用不会丢数据。但这还不够DMA发送是异步的函数返回时数据可能还在内存里被DMA读取。如果传入的是局部数组函数一出作用域缓冲区内容就变成未定义值了DMA搬运的全是垃圾。所以DMA发送缓冲区必须是全局变量或者静态变量生命周期至少覆盖到TxCplt回调。这个坑我见过太多次甚至有人最后查出来是编译器优化把栈区复用掉了。另外发送完最后一字节后DE方向切换要等TC标志while (!__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC)) { } SET_DE_ENABLE();别用TXE判断。TC置位说明移位寄存器确实空了总线上的停止位已经送完这时候切到接收模式才安全。DMA发送的数据即使DMA中断已经完成TC可能还在路上稳妥做法是在TxCplt回调里等TC或者直接把方向切换逻辑放在TxCplt之后。4. 排查实录与经验笔记这一节把实际项目中遇到过的几个有代表性的问题整理一下希望能帮你少走弯路。4.1 一次“偶发FE/NE错误”的现场排查有一个项目是用STM32F103C8T6和一个带串口的传感器模块通信波特率9600距离大概两米。现场反馈传感器数据每隔几分钟就断一次重启后又能恢复。我先在错误回调里把ErrorCode通过另一个调试串口打出来发现大多数是UART_ERROR_FE偶尔有UART_ERROR_NE。用示波器同时抓传感器的TX波形和MCU的RX引脚波形发现空闲时RX线上有明显的振铃上升沿不是干净的高电平而是先冲上去再回摆一下。这基本说明是信号完整性问题而不是波特率问题。接着查原理图传感器的TX输出到MCU的RX之间没有串阻MCU的RX也没有上拉传感器那边是开漏输出靠传感器板上的外部上拉。两根板子距离拉长后分布电容变大再加上地线是经过机壳搭接的地电位有一点浮动波形就变脏了。处理办法是在MCU的RX引脚添加上拉电阻到3.3V并串一个470Ω的电阻同时在靠近MCU引脚处放一个100pF的对地电容。改动之后跑了48小时错误计数为0问题解决。这个案例给我的教训是出现FE/NE先别急着改代码把示波器探头夹上去看一眼波形比什么都快。4.2 常见错误速查表现象可能原因排查方向程序跑一会串口彻底停错误回调后未重启DMA接收检查HAL_UART_ErrorCallback补恢复逻辑偶发乱码错误码含NERX线长、干扰强、引脚悬空示波器查波形RX加上拉缩短线距错误码含FE且频率较高波特率误差过大计算BRR测实际波特率检查晶振打开DMA接收立即进错误上电残留错误标志启动前清SR标志错误恢复后经常仍然失败DMA Abort未完成就重启恢复动作放主循环先DMAStop再Receive_DMADMA发送第二包开始丢失发送忙标志未处理用TxCplt回调串行化发送485总线偶发FEDE切换过早停止位被截断等TC标志再切DE这张表基本覆盖了串口DMA最常见的几类故障遇到问题时先对应一下现象。如果现象不在表里也要养成“先看SR寄存器、再抓波形”的习惯别凭感觉猜。4.3 一些小技巧与边界情况第一调试时可以把错误码和错误计数做成全局变量在调试器里实时查看比串口打印更轻量、更不容易影响时序。我习惯在HAL_UART_ErrorCallback里只更新变量然后通过调试器或者RTT方式观察。第二如果项目用了国产APM32F103这类引脚兼容MCUHAL库的错误处理流程基本一致这套方案可以直接迁移。迁移时唯一要注意的是DMA通道编号和中断向量名可能略有差异打开对应芯片的HAL头文件确认一下。第三空闲中断配合DMA接收是目前我觉得最灵活的不定长方案比单纯依赖RxCpltCallback健壮很多。但IDLE中断和USB虚拟串口一起使用时要小心USB转串口芯片在host端关闭串口时可能会在总线上产生一段空闲期误触发IDLE中断导致收到长度为0的“假帧”。处理方式是判断len等于0时直接忽略。第四某些低功耗场景下进入Stop模式前如果RX引脚没有外部上拉唤醒瞬间容易产生一次错误中断。处理方法是在进入低功耗前关闭USART中断唤醒后再重新初始化串口并启动接收别指望HAL库能帮你处理这种边界。最后说一个容易被忽略的细节HAL_UART_DeInit之后GPIO配置会被恢复成复位状态如果你在主循环恢复逻辑里调用了DeInit和Init一定要确认MX_USART1_UART_Init依赖的GPIO初始化已经执行过。我一般会在恢复逻辑里直接调用HAL_UART_MspInit确保GPIO和DMA句柄都重新绑定避免出现“串口寄存器配置好了但DMA通道还没接上”的尴尬情况。串口DMA这块说白了就是“硬件防患于未然软件自愈于已然”。FE和NE并不可怕可怕的是没有一套清晰的处理链路。希望这篇避坑指南能让你下次遇到UART_FLAG_FE和UART_FLAG_NE时不再一脸茫然而是能快速定位、快速修复、并且让程序在恶劣环境下自己站起来继续跑。
返回列表