嵌入式开发:Flash擦写导致USART丢数据,用DMA接收彻底解决
2026/9/6 13:29:07 网站建设 项目流程

做嵌入式这些年,我踩过不少坑,但最让我印象深刻的,是那次Flash写操作直接把我USART接收干丢了数据。当时产品已经进入联调阶段,串口助手一发数据就丢字节,而且丢得毫无规律,起初还以为是板子接触不良,排查了好几天,最后才发现元凶竟然是Flash擦写。这个问题的隐蔽性在于:它不是每次都复现,而是跟CPU执行时序强相关,一旦波特率往上提,Flash操作又频繁,丢数据就变成必然。今天就把整个排查过程和技术原理完整拆一遍,给同样被USART接收丢数据折磨的朋友一条捷径。

这个问题本质上是两个常见的嵌入式子系统——Flash控制器和USART外设——在同一颗MCU上争抢CPU资源导致的中断响应延迟问题。哪怕你用的是STM32F0、F1、F4,或者是其他Cortex-M内核的芯片,只要涉及"一边写Flash一边串口收发数据"的场景,就迟早会撞上。文章的核心内容覆盖了问题复现条件、底层根因、4种可行的解决方案、带DMA的完整代码示例,以及我在实际调试中总结的排查技巧,适合刚接触MCU开发的入门者,也适合正在被类似bug折磨的嵌入式工程师。

1. 问题现象与根因定位

1.1 丢数据时的典型表现

先说现象。我用的是STM32F030C8T6,主频48MHz,USART2配置为115200-8-N-1,接收采用传统的RXNE中断方式,每次进入中断就从USART2->RDR寄存器读走一个字节。测试场景很简单:上位机以每包64字节、间隔2ms的速度持续下发给MCU,MCU收到后回显。空载跑的时候一切正常,但一旦我在主循环里插入Flash写入操作,收到的数据就开始随机丢字节。

丢得最有规律的情况出现在擦除Flash扇区时。我用的是片内Flash,按页擦除,F030每页1KB,擦除时间典型值在20ms到40ms之间。我实测过,禁止优化、代码跑在Flash里,FLASH_ErasePage()这一句执行期间,串口那边大概会丢几十个字节。波特率越高丢得越多,115200下几乎可以稳定复现,9600下偶尔丢,握手协议反正收不满一包。

另外一个容易被忽略的现象是:如果你在USART中断里设置了ORE错误标志的检测,会发现在Flash操作之后,USART_ISR里的ORE位经常被置1。这是因为数据到了但CPU没来得及取走,新数据又来了,硬件只能覆盖旧数据。所以如果你看到ORE标志频繁置位,基本可以断定是"响应太慢"而不是"线路干扰"。

1.2 排查一线的第一印象:问题指向CPU响应延迟

我在最初排查时,先怀疑的是串口助手那边的发送间隔太短,还有杜邦线过长导致信号质量差。把这些因素排除之后,用示波器去抓USART的RX引脚波形,发现波形完全正常,起始位、数据位、停止位都标准,不存在毛刺或电平异常。这就把故障从物理层移到了软件层。

接着我在RXNE中断里加了一个GPIO翻转,用逻辑分析仪去测两次翻转之间的时间间隔。正常情况下,115200波特率下两次中断间隔约86.8us,逻辑分析仪测出来也差不多。但当Flash擦除发生时,我发现相邻两次中断的间隔会突然拉到几百微秒甚至毫秒级别,这说明中断确实被延后了。程序是在中断事件发生之后才进来执行的,而不是中断处理得太慢。这个细节很关键——它说明问题的本质不是USART驱动代码写得太烂,而是整个CPU在Flash操作期间"停摆"了。

1.3 从代码层面定位:问题出在Flash擦写函数

再往后,我用了一个最笨也最有效的办法:把Flash擦除函数临时注释掉,换成等时间的软件延时(比如delay_ms(30)),然后重新跑同样的测试。结果丢数据现象消失了。虽然同样阻塞了CPU约30ms,但软件延时不会触发Flash接口的忙等待状态,中断仍然可以正常响应。这一下就把注意力完全锁定到Flash操作本身。

到这一步,问题的方向已经很清晰:不是GPIO配置问题,不是串口波特率配置问题,而是Flash擦写期间CPU被硬件暂停,导致USART的RXNE中断无法及时响应。理解了这一点,后面的所有优化方案都围绕着"如何在Flash操作期间保住USART接收"来展开。

2. 为什么Flash操作会把USART接收拖垮

2.1 Flash编程/擦除的工作原理:CPU怎么就被冻住了

要彻底理解这个问题,必须回到STM32的Flash控制器和CPU取指机制。以STM32F0系列为例,Cortex-M0内核本身从Flash取指执行,Flash接口有一个状态寄存器(FLASH_CR里的PG位和PER位),当你执行页擦除时,硬件会把整个页的存储单元置为0xFF。这个过程不是瞬间完成的,需要一定的高压脉冲时间,期间Flash阵列不可访问。

Flash控制器在擦写期间会拉高内部的busy信号,此时如果CPU试图从Flash取指令或者读数据,总线就会进入等待状态,也就是俗称的stall。如果你的代码运行在Flash里,那问题就大了——CPU下一条指令还没取出来,整个流水线就堵住了。即便你把中断向量表放在SRAM,中断服务程序也拷到RAM里运行,只要中断服务程序里访问了Flash(比如查表、读取常量),一样会被卡住。这是硬件层面无法绕过的一个限制,除非你的代码和数据全部在SRAM中,并且不访问Flash地址空间。

我记得ST有一个应用笔记专门讲这个,说在Flash编程/擦除期间,任何对Flash的读访问都会让总线保持等待状态直到操作完成。我用逻辑分析仪抓到的长中断间隔,正是这个"总线等待"的直接体现。

2.2 USART接收缓冲的单字节结构:为什么延迟就等于丢数据

再来看看USART接收端的硬件结构。大多数MCU的USART接收路径是:移位寄存器(Shift Register)→ 接收数据寄存器(RDR)→ 软件读取。移位寄存器负责把每个起始位后的数据位逐位移入,当一个字节完整接收后,硬件自动把数据搬到RDR,同时置位RXNE标志。

关键问题在于,RDR只有一个字节的深度。也就是说,如果RXNE已经置位,但软件(也就是中断服务程序)还没有把RDR里的数据读走,此时一个新的字节又接收完成了,会发生什么?硬件会直接把这个新字节写入RDR,把旧数据覆盖掉,同时置位ORE(Overrun Error)标志。如果你的代码没有在中断里处理ORE标志,那这次旧数据就算是凭空消失了。

所以对USART接收来说,"中断响应延迟"和"数据丢失"之间几乎是等号关系。中断服务程序晚到的时间只要超过一个字节的传输时间,这个字节就危险了。我们来算一笔账:115200波特率下,一个字节包含起始位1位、数据8位、停止位1位,总共10位,传输一个字节需要约86.8us。也就是说,如果你让RXNE中断的响应时间超过86.8us,就一定会出现丢失。而一次Flash页擦除的耗时是20ms以上,这个时间足够让USART丢两百多个字节。

2.3 不同波特率、不同Flash操作类型下的丢失模型

为了把问题看得更透,我整理了一下不同条件下的丢失估算。这个表完全基于理论计算,实际值会因为代码位置、编译器优化、缓存策略等因素有出入,但可以作为设计时的参考:

场景Flash操作耗时波特率每字节耗时理论丢失字节数
页擦除(1KB)约20ms9600约1.04ms约19字节
页擦除(1KB)约20ms115200约86.8us约230字节
双字编程约30us115200约86.8us小于1字节(临界)
扇区擦除(较大容量芯片)约40ms115200约86.8us约460字节

这个表格的结论非常直观:Flash擦除操作在高速串口下几乎是灾难级的,即使是在慢速的9600波特率下也有可能丢数据。所以"丢数据"不是偶然事件,只要你满足两个条件——一是有Flash擦写操作,二是USART接收使能——在波特率足够高的时候,它一定会发生。只是有时候你上位机协议有重发机制,丢一帧没察觉,等到产品量产了才集中爆发。

3. 解决方案:从硬件机制到软件策略

3.1 方案一:DMA接收,把数据搬运交给硬件

既然问题根源是"CPU延迟响应",那最直接的思路就是让CPU不参与数据搬运。USART+DMA的接收方式,就是让DMA控制器直接把RDR里的数据搬到内存缓冲区里,整个过程中CPU不需要响应任何中断,只在DMA传输完成或收到特定数量的字节后才介入。

这个方案的具体做法是:配置USART接收使能DMA请求,当RXNE置位时,DMA自动执行从USART_RDR到内存的搬运。你可以设置DMA传输长度,比如每次接收64字节触发一次完成中断。更常用的组合是"USART空闲中断(IDLE) + DMA循环模式",这样可以在一次连续传输结束后,在空闲中断里根据DMA计数器判断实际收到了多少数据,然后进行处理。

我在项目里最终采用的就是"DMA循环模式+IDLE中断"的方案,后面第4节会给完整代码。DMA方案还有额外的好处:它不仅能解决Flash操作期间的丢数据,还能显著降低CPU在高波特率下的中断负载。实测下来,同样115200波特率持续接收,传统RXNE中断方式下CPU占用率接近30%,DMA方式下几乎可以忽略不计。

3.2 方案二:提高中断优先级——治标不治本

很多人的第一反应是调NVIC优先级,把USART中断设为最高优先级,让它在Flash操作期间也能抢占CPU。这里要澄清一个误解:中断优先级只决定"多个中断同时发生时谁先执行",它不能让CPU在总线stall期间继续运行指令。

对于Cortex-M0内核,尤其是STM32F0这种没有指令缓存(Flash预取缓冲也有限)的型号,Flash擦写期间的bus stall是强制性的,高优先级中断只是排在等待队列的第一个,它一样要等到Flash操作完成才能执行。也就是说,USART中断响应延迟主要取决于Flash操作剩余时间,而优先级对这个时间几乎没有影响。

不过这个方案在特定条件下仍然有点用:如果你的Flash操作是短编程(比如写一个双字,耗时几十us),而在同一时间内没有其他中断竞争,那设置高优先级确实可以保证响应尽可能快。但如果你靠这个方案去解决20ms的页擦除,那基本是白费功夫。

3.3 方案三:错峰操作,优化业务时序

还有一种思路,不是去跟硬件抢时间,而是从业务逻辑上避开冲突。既然Flash擦写会导致无法响应中断,那我们就在确保USART没有数据到达的时候再去做Flash操作,或者把一次长时间的擦除拆成多次小操作,中间穿插USB/UART接收处理。

举例来说,如果你的通信协议是主从问答式的,MCU只有收到上位机的写Flash指令后才启动擦写,那可以先把整帧数据接收完整,确认校验无误后,再回复一个"准备开始写"的握手包,然后才开始擦除。因为上位机必须等收到握手包之后才会发下一个指令,这个间隙就是天然的时间窗口。

如果协议没法改,可以利用RTOS的时间片调度,把Flash擦除放到低优先级任务里,只要高优先级的UART接收任务有数据就立即抢占。但在STM32F0这种不带MPU和缓存的中小资源芯片上,我建议直接采用DMA方案更省心。

3.4 方案四:数据冗余与重传机制兜底

退一步讲,即便上面所有方案都做了,串口通信在工业现场仍然可能受到干扰,完全依赖硬件接收不出错是不现实的。所以我在实际项目中还会在协议层做一层兜底:给每帧数据加帧头、帧尾、CRC校验,接收方校验失败就让发送方重传。

这个方案的意义在于,它不是用来"避免"丢数据的,而是用来"容忍"丢数据的。DMA能把丢数据的概率降到极低,重传机制则能保证即便偶尔丢了,应用层也不会出错。两个方案叠加之后,系统的可靠性才有保障。

4. 实操:DMA接收+IDLE中断的完整实现

4.1 硬件环境与需求

这块代码我在STM32F030C8T6上验证过,使用的HAL库版本是1.9.0,但其实用标准库或寄存器写的话逻辑完全一样,只是API不同。硬件准备:MCU一颗、USB转TTL串口模块一个、逻辑分析仪(用于验证时序)。

需求很单纯:USART1以115200波特率接收上位机不定长数据,接收完成后通过标志位通知主循环处理;同时主循环会执行Flash数据记录操作,要求Flash擦写期间USART接收不丢字节、不丢帧。

4.2 外设配置:USART、DMA、NVIC一个都不能少

首先开启USART1的RX DMA和全局中断。使用CubeMX配置时,需要注意把USART1的DMA接收设置为Circular模式,数据宽度为Byte。在代码里初始化如下:

#include "stm32f0xx_hal.h" #define RX_BUF_SIZE 256 UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; uint8_t rx_buf[RX_BUF_SIZE]; volatile uint8_t rx_frame_ready = 0; volatile uint16_t rx_frame_len = 0;

然后初始化USART和DMA:

void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; 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; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); } void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_usart1_rx.Instance = DMA1_Channel3; hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_rx); __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); }

这里有两个容易踩的坑。第一个是DMA模式必须用DMA_CIRCULAR,如果误配成Normal模式,DMA搬运完设定长度的数据后就会停止,后面再来数据概不负责。第二个是PeriphInc必须置为DMA_PINC_DISABLE,因为外设地址是固定的USART1->RDR,如果配置成地址自增,DMA会去读一串乱七八糟的寄存器地址。我在调试时吃过这个亏,配置错误后DMA不断触发卡死中断,排查了半天。

4.3 启动接收与IDLE中断处理

初始化完成后,在主循环开始前启动DMA接收:

HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);

接着开启USART的IDLE中断检测。HAL库里没有直接封装,需要用寄存器操作:

__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

然后在USART1的中断服务函数里做空闲判断:

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t current_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (current_len > 0) { rx_frame_len = current_len; rx_frame_ready = 1; } } }

这段代码的逻辑是:当USART在空闲状态(总线上没有新的起始位)时,说明一帧数据已经接收完毕。这时通过DMA剩余的未传输计数,反推出本次DMA实际搬运了多少字节。用RX_BUF_SIZE减去当前计数值,就是新收到的数据长度。

这里有一个细节必须注意:__HAL_DMA_GET_COUNTER返回的是DMA剩余传输次数,在循环模式下,计数器会从配置值不断减到0再重新加载。所以在判断帧长度时必须取模,也就是用配置的总长度减去当前计数。如果当前计数恰好为0,说明缓冲区正好被填满,此时长度就是RX_BUF_SIZE。

主循环里这样处理接收帧:

while (1) { if (rx_frame_ready) { uint16_t len = rx_frame_len; rx_frame_ready = 0; process_frame(rx_buf, len); } // 这里可以放心进行Flash写操作 flash_write_log_data(); }

process_frame处理完后,DMA仍然在循环接收下一帧数据,不需要重新启动,这是Circular模式最大的便利。

4.4 Flash操作的代码封装与注意事项

Flash操作本身没什么神奇之处,但我建议把擦除和写入封装成独立函数,并且在函数开头加一个临界区保护,防止Flash操作期间产生的中断同时访问Flash接口,造成未知错误:

#include "stm32f0xx_hal.h" void flash_erase_page_safe(uint32_t page_address) { uint32_t primask = __get_PRIMASK(); __disable_irq(); FLASH_EraseProgramPage(FLASH, page_address); __set_PRIMASK(primask); }

我在这里关中断的目的,不是为了阻止USART数据进来(因为DMA根本不依赖CPU),而是为了防止Flash操作期间有个别中断服务程序去读取Flash常量表或字符串,从而在总线上增加额外的等待和冲突。这个细节是ST在勘误手册里提到的:Flash操作期间,总线上的任何Flash访问都会导致不确定的等待行为,严重时甚至会造成总线错误。

在F030上,FLASH_EraseProgramPage同时兼容页擦除和写操作,但建议先把FLASH_CR里的PERPGSTRT位按顺序配置好,避免误操作。另外,不同型号的Flash擦除时间差别很大,如果你把代码移植到F103或F407,务必查阅对应的数据手册,确认Flash操作期间是否允许中断取指、是否支持预取缓冲等特性。

4.5 实测结果:丢数据问题彻底解决

改完DMA方案后,我重新跑之前的复现测试:115200波特率,上位机每2ms发64字节,主循环持续执行Flash页擦除。连续跑了一整晚,回显的每一帧数据都完整,逻辑分析仪没有再捕捉到ORE置位。

更让我欣慰的是,这次修改把CPU的负载降低了很多。在DMA方案下,接收一帧64字节的数据,CPU只需要在IDLE中断里做一次长度计算,大概只需要几十条指令。而传统RXNE中断方式下,每接收一个字节都要进入一次中断,光是进出中断的开销就非常大。在高波特率、高数据量的场景下,DMA方案几乎是唯一正确的选择。

5. 常见问题与排查技巧实录

5.1 排查顺序:别一上来就改代码

很多朋友遇到USART丢数据,第一反应是把波特率调慢,或者把RX引脚的外部上拉加上,这些当然可以试,但效率很低。我建议按下面的顺序排查,可以少走很多弯路:

  1. 先用示波器或逻辑分析仪看RX引脚波形,排除物理层问题。
  2. 在USB转串口模块和MCU之间单独测试,排除供电和地线噪声。
  3. 在RXNE中断里加GPIO翻转,量中断响应间隔,确认是"响应慢"还是"处理慢"。这一步能直接区分问题是出在硬件总线等待还是软件逻辑阻塞。
  4. 检查是否有Flash、EEPROM、I2C等外设操作与USART共用同一总线或抢占CPU。逐个注释掉可疑代码段,二分法定位。
  5. 确认ORE标志的状态。如果ORE频繁置位,几乎可以断定是响应延迟在作怪。

我遇到过一个更隐蔽的变种:代码里有两个中断,一个是USART接收,另一个是定时器中断,定时器中断服务程序里调用了EEPROM模拟函数,而EEPROM模拟数据正好存在Flash。结果每当时钟中断触发,USART就丢数据。排查时因为问题不总是复现,花了不少时间才定位到。

5.2 现场快速避坑清单

下面这张表是我在多个项目中沉淀下来的实战经验,每一条都对应过真实故障:

问题场景直接原因推荐处理方式
Flash擦写期间USART丢字节CPU总线stall,RXNE中断延迟改用DMA接收
使用RXNE中断时偶尔丢字节中断优先级配置不当提高USART中断优先级,同时缩短ISR耗时
DMA接收在Normal模式下丢末尾数据DMA传输完成后停止接收改用Circular模式
DMA接收后数据错位缓冲区长度与协议不匹配使用IDLE中断+计数器判断实际长度
Flash操作期间调用HAL_DelayDelay依赖SysTick中断,Flash操作阻塞取指避免在Flash擦写期间调用HAL_Delay

每次踩完坑,我都会把这些零散的排查经验记录到开发日志里。时间久了,你会发现大部分嵌入式问题都不是"代码写不出来",而是"现象定位太难"。DMA+IDLE这套组合我现在基本上是作为默认方案使用的,哪怕项目里暂时没有Flash操作需求,我也倾向于用DMA接收,因为它带来的收益(低中断负载、防突发延迟)远大于那一点配置上的繁琐。

5.3 补充:Flash写操作本身也值得优化

既然问题的触发点是Flash操作,那顺便把Flash操作的效率也优化一下是有好处的。我在项目中用到了日志记录功能,每次要写几条记录,最初设计是每次主循环写一次Flash,这在日志频繁时不仅耗时间,还会成倍放大丢数据风险。

后来我把日志数据先攒在RAM缓冲区,攒够一个页的量(1KB)再一次性擦除并写入。这样做有两个好处:一是Flash擦除次数大幅减少,延长了Flash寿命(F030的擦写寿命典型值是1万次,如果每秒写一次,不到3小时就报废了);二是Flash操作频率降低,给系统留出了大量空闲时间处理串口数据。数据累积和Flash操作完全可以做成异步的:RAM缓冲为生产者,Flash落盘为消费者,两者通过标志位解耦。

以F030的1KB页为例,写满一个页大概需要攒将近16条64字节日志,在正常业务逻辑下可能需要几分钟甚至更久才触发一次Flash擦写。这样的话,即便Flash操作期间无法响应中断,由于频率极低,对通信的影响也能控制在一个很小的窗口内。如果你在协议层再加个重试机制,这个窗口基本可以忽略不计。

6. 写在最后的一点体会

有一次在客户现场调试,对方工程师看到我把串口接收换成了DMA,第一反应是"小题大做"。直到我把逻辑分析仪抓到的中断延迟波形放给他看,他才意识到原来MCU的CPU并不是随时都在"待命"的。做嵌入式越久,我越觉得很多问题的根源不在于单个外设怎么配,而在于多个外设共享同一个CPU时,如何在硬件机制的限制下设计出能跑得稳的软件架构。

就我个人经验来说,USART接收数据丢失这个问题,如果能绕过CPU直接使用DMA,就不要依赖中断;如果业务逻辑允许错峰执行Flash操作,就一定要从时序上错开;如果系统里有多个外设争抢总线,在设计阶段就要把外设的访问频率和阻塞时间都估算进去。这套思路不仅适用于Flash写操作和USART接收,也适用于I2C、SPI、ADC等多外设协同场景。你在这个项目里养成的习惯,会在下一个更复杂的系统里帮你省下大把的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询