ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式开发:Flash擦写导致USART丢数据,用DMA接收彻底解决

嵌入式开发:Flash擦写导致USART丢数据,用DMA接收彻底解决 做嵌入式这些年我踩过不少坑但最让我印象深刻的是那次Flash写操作直接把我USART接收干丢了数据。当时产品已经进入联调阶段串口助手一发数据就丢字节而且丢得毫无规律起初还以为是板子接触不良排查了好几天最后才发现元凶竟然是Flash擦写。这个问题的隐蔽性在于它不是每次都复现而是跟CPU执行时序强相关一旦波特率往上提Flash操作又频繁丢数据就变成必然。今天就把整个排查过程和技术原理完整拆一遍给同样被USART接收丢数据折磨的朋友一条捷径。这个问题本质上是两个常见的嵌入式子系统——Flash控制器和USART外设——在同一颗MCU上争抢CPU资源导致的中断响应延迟问题。哪怕你用的是STM32F0、F1、F4或者是其他Cortex-M内核的芯片只要涉及一边写Flash一边串口收发数据的场景就迟早会撞上。文章的核心内容覆盖了问题复现条件、底层根因、4种可行的解决方案、带DMA的完整代码示例以及我在实际调试中总结的排查技巧适合刚接触MCU开发的入门者也适合正在被类似bug折磨的嵌入式工程师。1. 问题现象与根因定位1.1 丢数据时的典型表现先说现象。我用的是STM32F030C8T6主频48MHzUSART2配置为115200-8-N-1接收采用传统的RXNE中断方式每次进入中断就从USART2-RDR寄存器读走一个字节。测试场景很简单上位机以每包64字节、间隔2ms的速度持续下发给MCUMCU收到后回显。空载跑的时候一切正常但一旦我在主循环里插入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把旧数据覆盖掉同时置位OREOverrun 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不参与数据搬运。USARTDMA的接收方式就是让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和DMAvoid 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里的PER、PG、STRT位按顺序配置好避免误操作。另外不同型号的Flash擦除时间差别很大如果你把代码移植到F103或F407务必查阅对应的数据手册确认Flash操作期间是否允许中断取指、是否支持预取缓冲等特性。4.5 实测结果丢数据问题彻底解决改完DMA方案后我重新跑之前的复现测试115200波特率上位机每2ms发64字节主循环持续执行Flash页擦除。连续跑了一整晚回显的每一帧数据都完整逻辑分析仪没有再捕捉到ORE置位。更让我欣慰的是这次修改把CPU的负载降低了很多。在DMA方案下接收一帧64字节的数据CPU只需要在IDLE中断里做一次长度计算大概只需要几十条指令。而传统RXNE中断方式下每接收一个字节都要进入一次中断光是进出中断的开销就非常大。在高波特率、高数据量的场景下DMA方案几乎是唯一正确的选择。5. 常见问题与排查技巧实录5.1 排查顺序别一上来就改代码很多朋友遇到USART丢数据第一反应是把波特率调慢或者把RX引脚的外部上拉加上这些当然可以试但效率很低。我建议按下面的顺序排查可以少走很多弯路先用示波器或逻辑分析仪看RX引脚波形排除物理层问题。在USB转串口模块和MCU之间单独测试排除供电和地线噪声。在RXNE中断里加GPIO翻转量中断响应间隔确认是响应慢还是处理慢。这一步能直接区分问题是出在硬件总线等待还是软件逻辑阻塞。检查是否有Flash、EEPROM、I2C等外设操作与USART共用同一总线或抢占CPU。逐个注释掉可疑代码段二分法定位。确认ORE标志的状态。如果ORE频繁置位几乎可以断定是响应延迟在作怪。我遇到过一个更隐蔽的变种代码里有两个中断一个是USART接收另一个是定时器中断定时器中断服务程序里调用了EEPROM模拟函数而EEPROM模拟数据正好存在Flash。结果每当时钟中断触发USART就丢数据。排查时因为问题不总是复现花了不少时间才定位到。5.2 现场快速避坑清单下面这张表是我在多个项目中沉淀下来的实战经验每一条都对应过真实故障问题场景直接原因推荐处理方式Flash擦写期间USART丢字节CPU总线stallRXNE中断延迟改用DMA接收使用RXNE中断时偶尔丢字节中断优先级配置不当提高USART中断优先级同时缩短ISR耗时DMA接收在Normal模式下丢末尾数据DMA传输完成后停止接收改用Circular模式DMA接收后数据错位缓冲区长度与协议不匹配使用IDLE中断计数器判断实际长度Flash操作期间调用HAL_DelayDelay依赖SysTick中断Flash操作阻塞取指避免在Flash擦写期间调用HAL_Delay每次踩完坑我都会把这些零散的排查经验记录到开发日志里。时间久了你会发现大部分嵌入式问题都不是代码写不出来而是现象定位太难。DMAIDLE这套组合我现在基本上是作为默认方案使用的哪怕项目里暂时没有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等多外设协同场景。你在这个项目里养成的习惯会在下一个更复杂的系统里帮你省下大把的调试时间。
返回列表