恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
STM32写入FLASH后读回数据不一致?擦除、Cache与写保护全解析
首页
资讯中心
/
STM32写入FLASH后读回数据不一致?擦除、Cache与写保护全解析
STM32写入FLASH后读回数据不一致?擦除、Cache与写保护全解析
发布时间:2026/10/19 18:00:04
前两天有人在嵌入式交流群里贴了一段代码项目标题就是“STM32向FLASH写入数据后重新读出的数据和原写入数据不一致”。他说自己照着教程抄了一套Flash读写函数往0x08020000写了128个字节然后读回来逐字节比对结果前面32字节是对的后面96字节全是0xFF。他反复确认过地址没错甚至换了颗芯片问题依旧。我让他把擦除函数和写入循环截图发出来看了一眼就基本锁定了问题——这类情况十个里有八九个是同一个原因写入之前根本没有擦除或者擦除的页跟你写入的地址没对齐。但“数据不一致”并不只有这一种表现。读回全0xFF、读回一半对一半错、读回的是上一次的数据、只有复位后才不正常……这些不同现象背后是完全不同的根因排查思路和心理预期也完全不一样。这篇文章我就把常见的几种情况挨个拆开讲附带一套完整可复用的读写流程以及我在实际项目里反复踩过的几个隐蔽坑。1. 问题现场先分清是哪种“对不上”1.1 典型表现不是只有一种很多人一上来会说“我写入的数据读出来不对”但“不对”这个词太笼统了。我建议你先自己问一句到底是怎么个不对法不同表现对应不同故障方向这一步分不清后面全是瞎猜。我在群里见过大量的同类问题归纳起来主要是这几种读回全是0xFF最常见。原因基本指向写入根本没成功或者目标区域从未被正确擦除过。读回全是0x00比上一类少见但也存在。通常指向擦除后编程电压或数据线异常或者你写入时地址/数据宽度选错了。前面一部分对后面一部分错典型是跨页/跨扇区边界的擦除逻辑出问题或者是写入长度超出了你擦除的范围。读回的是旧数据而不是新数据写入操作看似返回成功但实际数据没落盘。Cache未失效、写入保护、地址越界都可能。运行时读回不对但调试器单步看又是对的大概率是编译器优化导致你读的是寄存器缓存而不是Flash里的原始值也可能是中断干扰。写入后立刻读OK复位后读不对你写进去的数据在RAM里成立但Flash操作本身没完成或没真正提交。1.2 先把“芯片坏了”这个念头放一边很多朋友一旦连续排查一两天没有头绪就开始怀疑芯片有问题。我没说芯片永远不会坏但在Flash读写这件事上芯片本身出现随机数据位翻转的概率极低。Flash控制逻辑是纯数字电路要么整个寄存器配置挂了要么就是物理损坏。你连换两片芯片问题都复现说明问题大概率在你的代码或者配置上。我在前几年帮公司排查过一个批次的样机现象是产品断电重启后配置参数偶尔丢失。当时产线负责人一直说是Flash质量不行后来我们拿到样机抓log发现是业务代码里把擦除函数和写入函数的地址参数传反了导致每次都在擦除另一个扇区。所以说先查自己代码芯片真的很无辜。2. Flash写入的本质为什么“先擦除”是条铁律2.1 从浮栅晶体管说起NOR Flash的存储单元本质是一个浮栅晶体管。它跟普通MOS管不一样的地方在于栅极和沟道之间多了一层浮栅这个浮栅被绝缘层包裹电荷一旦放进去在没有外部电压干预的情况下可以保存很多年。当你向某个单元写入数据时本质上是在改变浮栅里的电荷量。电荷量的多少会改变晶体管的阈值电压而读操作就是给晶体管施加一个参考电压看它能不能导通从而判断这个单元代表的是“1”还是“0”。这套设计决定了两个非常关键的性质擦除操作是把浮栅里的电子抽走让单元恢复到导通阈值较低的状态对应读出为“1”。擦除总是把bit往“1”方向拉。编程操作是把电子注入浮栅让单元进入难以导通的状态对应读出为“0”。编程只能把bit往“0”方向拉。换句话说Flash的编程不能凭空造出“1”只能把原来的“1”改成“0”。如果你拿到一个全是0xFF也就是全“1”的干净区域往里写数据凡是需要变成0的位都能成功但如果一个位本来就是0你还想让它变成1编程操作是做不到的。2.2 生活类比刻字和涂改液的区别你可以把Flash理解成一张只能由浅变深、不能由深变浅的纸。擦除相当于换一张新纸编程相当于拿黑笔往上写字。你要是往一张已经写满深色字迹的纸上再写新内容新写的内容和旧字迹叠在一起最后读出来的自然不是你想要的东西。很多人犯的错就是对着一个已经用过、还残留着上一轮数据的Flash区域直接调用写入函数然后读回来发现数据是乱的第一反应是Flash坏了。实际上你是在一张旧纸上写字出现奇怪结果是物理规律决定的不是硬件故障。2.3 页/扇区边界这是“一半对一半错”的根源擦除操作的最小单位是页或扇区不同芯片型号差异很大。以常见的STM32F103为例中小容量型号的Flash页大小是1KB或2KB而F407的扇区大小是16KB起步。这意味着你无法只擦除一个字节或一个字你要把整个页/扇区全部擦掉。如果你的写入长度跨越了页边界但你只擦除了其中一页那么另一页很可能还是旧数据。写入完成后读回来就会出现前半段正常、后半段全是旧值或0xFF的情况。这一点在很多初学朋友的代码里反复出现因为大家习惯在RAM里连续排数据忽略了Flash的物理擦除边界。3. 读回数据“不一致”的几种特征与根因对照3.1 一张表帮你快速缩小范围做嵌入式排错最忌讳的就是漫无目的地试。下面这张对照表是我自己平时定位问题的第一张索引表你也可以直接拿去用。读回特征第一优先怀疑方向第二优先怀疑方向验证方法全部0xFF未正确擦除写入函数未执行/地址错误单独擦除后读回确认是否变FF全部0x00数据宽度/对齐错误写入错误地址改为按字写入再验证前段对后段错跨页/跨扇区擦除页数不足计算写入范围是否跨越边界读回旧数据Cache命中旧值真正数据写入失败关闭Cache/Invalidate后读单步正常运行异常编译器优化缓存变量中断打断写入时序用volatile修饰地址指针复位后丢失写保护/选项字节异常编程后未等EOP标志复位后进调试器读Flash区域这张表不是让你死记硬背而是帮你建立一种“现象到原因”的映射思维。你遇到问题时先描述现象再按表里的顺序验证通常十分钟内就能锁定方向。3.2 我常用的三组特征验证数据定位这类问题我喜欢用三组有辨识度的数据做实验而不用真实业务数据。真实数据随机性太强出错后你很难判断是“值不对”还是“字节顺序错位”。第一组是0x55AA55AA也就是二进制01010101 10101010交替第二组是0xA5A5A5A5第三组是0x0123 4567这样的递增非对称序列。为什么选它们因为这三组数据里“1”和“0”的位都有足够密度任何一个bit翻转都能被明显看到而且三组之间没有相似性如果你读出的是上一组数据一眼就能认出来。实际验证时先擦除目标页然后把这三组数据分别写到三个不同的页里再逐一读回。如果读回的数据根本不是这三组里的任何一段那说明地址错位了如果读出的是上一组数据说明写入可能压根没生效。这个小小的实验矩阵能帮你快速把“地址错误”“写入失败”“擦除不彻底”三种情况分开。4. 一次在F407上折腾两天的真实排查过程4.1 现象写进去读出来总是旧值有次朋友的项目用到F407VET6要在Flash里存一组校准参数每次上电时读出来做运算。他的代码很简单上电先读出参数然后写入新参数再读回校验。问题来了——校验有时候通过有时候不通过而且不通过的时候读回来的总是上一次断电前写入的旧参数。这个现象非常有意思因为不是全0xFF也不是前对后错而是“旧数据顽固不化”。他最开始怀疑是写入地址选择有问题于是把地址打出来反复核对没错又在不同扇区分别测试现象完全一样。折腾了一整天没进展。4.2 排查链路从最容易查的开始我让他按下面的顺序走了一遍每一步都记录结果不走弯路。第一步检查Flash解锁和上锁。他用的HAL库HAL_FLASH_Unlock和HAL_FLASH_Lock都调了没发现异常。第二步检查擦除调用。把擦除函数的返回值打出来结果是HAL_OK看上去也正常。但这里有个陷阱HAL_OK只能代表函数调用和标志位正常不能代表目标区域真的被擦除干净。我让他单独写一个测试函数只擦除目标扇区然后直接读整片区域结果读出来的居然还是旧数据——擦除根本没生效。到这里问题基本锁定了要么擦除地址不对要么擦除被某种机制拦住了。第三步检查选项字节和写保护。F407有一个Flash写保护寄存器区域如果对应扇区被写入保护擦除和编程都会被硬件拦下而且API不一定返回让你警觉的错误码。我们用调试器读了相关寄存器果然发现目标扇区处于写保护状态。4.3 元凶恢复出厂后残留的写保护标志为什么会有写保护这款芯片是朋友从某个方案上拆下来的二手料之前跑过别人的固件可能被设置过选项字节。而HAL库默认的初始化流程并不会主动清除写保护标志所以他每次擦除都是“假成功”数据自然一直保留旧值。最后处理办法很简单用调试器的选项字节编程功能把目标扇区对应的写保护去掉再重新上电擦除立刻生效读写恢复正常。这个案例给我的教训是遇到Flash读写异常一定要把写保护和选项字节这件事纳入排查第一步二手芯片尤甚。5. 一套可以直接抄的Flash读写完整流程5.1 地址规划先算好边界再动代码写Flash之前我建议你拿芯片手册把两件事查清楚一是页/扇区大小是多少二是你自己的应用固件占用到哪个地址。不要在手册没查的情况下随便挑一个地址就开始写。某常见型号本地应用固件往往只占前面一段Flash后面空闲区域足够大但如果你不小心写到固件所在的扇区轻则覆盖代码重则启动直接跑飞。以一张典型规划表为例项目建议值说明固件起始0x08000000由链接脚本决定通常从0x08000000开始参数存储区0x08040000避开固件区且首地址对齐到扇区边界扇区大小16KB确认目标芯片对应扇区大小最大参数长度不超过4KB留足余量单扇区存放参数区首地址必须对齐到扇区边界否则擦除函数的地址参数会被拒绝或者擦掉相邻扇区。计划存128字节也好4KB也好都要保证你擦除的扇区完整覆盖你要写入的地址范围。5.2 HAL库标准三段式代码下面这段代码基于STM32的HAL库适用于F1/F4/F7/H7等大多数系列你只需要根据自己芯片调整擦除类型和页地址。注意F1系列的页大小通常是1KB/2KBF4是16KB扇区H7又是另一套bank逻辑务必以手册为准。#include stm32f4xx_hal.h #define PARAM_SECTOR_ADDR 0x08040000U #define PARAM_DATA_LEN 128U static uint8_t s_write_buf[PARAM_DATA_LEN]; static uint8_t s_read_buf[PARAM_DATA_LEN]; static void Flash_Program_Test(void) { FLASH_EraseInitTypeDef erase_cfg; uint32_t sector_error 0; HAL_StatusTypeDef status; /* 1. 解锁Flash */ HAL_FLASH_Unlock(); /* 2. 擦除目标扇区 */ memset(erase_cfg, 0, sizeof(erase_cfg)); erase_cfg.TypeErase FLASH_TYPEERASE_SECTORS; erase_cfg.Sector FLASH_SECTOR_8; /* 0x08040000对应哪个扇区按芯片映射 */ erase_cfg.NbSectors 1; erase_cfg.VoltageRange FLASH_VOLTAGE_RANGE_3; status HAL_FLASHEx_Erase(erase_cfg, sector_error); if (status ! HAL_OK) { /* 擦除失败sector_error会指出出错的扇区 */ HAL_FLASH_Lock(); return; } /* 3. 按32位字写入地址必须4字节对齐 */ for (uint16_t i 0; i PARAM_DATA_LEN; i 4) { uint32_t word_data (uint32_t)s_write_buf[i] | ((uint32_t)s_write_buf[i 1] 8) | ((uint32_t)s_write_buf[i 2] 16) | ((uint32_t)s_write_buf[i 3] 24); status HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARAM_SECTOR_ADDR i, (uint64_t)word_data); if (status ! HAL_OK) { HAL_FLASH_Lock(); return; } } /* 4. 重新上锁 */ HAL_FLASH_Lock(); /* 5. 读回校验 */ memcpy(s_read_buf, (uint8_t *)PARAM_SECTOR_ADDR, PARAM_DATA_LEN); if (memcmp(s_read_buf, s_write_buf, PARAM_DATA_LEN) 0) { /* 读写成功 */ } else { /* 读写不一致 */ } }有几点要特别说明HAL_FLASH_Program的第三个参数在HAL库里是uint64_t你传32位数据时记得强转避免类型警告。F4系列的扇区编号不是线性的0x08040000通常对应扇区8但一定要以参考手册的扇区-地址映射表为准。擦除和写入之间不要插入长时间延时或高优先级中断虽然HAL库内部会等待BSY标志但外部环境越安静出错概率越低。5.3 写完后必须确认的几件事很多朋友写完这段代码看到memcmp通过就认为万事大吉。实际上代码跑到这里CPU读的可能是Cache里的值也可能是Flash控制器提前缓冲的数据并不完全等于物理存储介质里真实的值。所以我在量产项目里还会多干两件事第一校验通过后人为触发一次软件复位然后在启动代码早期就把参数区读回一遍。如果复位后读回的数据还能和写入时一致这才算真正落盘。这能避开所有和Cache、编译器优化相关的假阳性。第二看Flash控制器是否有报错标志。HAL库的底层函数虽然会返回HAL_OK但不排除编程过程中出现电压跌落等异常。更稳妥的做法是写完后读一下Flash状态寄存器确认没有编程错误位。我在量产固件里会加一句简单的排查但凡发现状态寄存器有异常立刻把数据重新写一遍。6. 容易被忽略的系统级因素时钟、缓存与写保护6.1 Flash等待周期读乱码的一大来源Flash在读操作时也有时序要求。CPU的主频越高Flash读取速度越跟不上所以芯片设计了等待周期用寄存器配置“读一次Flash要等几个周期”。如果这个值配得太小CPU访问Flash就会读到不稳定的数据表现出来就是代码偶尔跑飞、数据随机错误、调试器看内存一片乱码。标准库和HAL库一般会在系统时钟初始化时自动设置好等待周期但如果你自己写过时钟配置或者从旧工程移植代码很容易把这一步漏掉。以常见Cortex-M内核的STM32为例主频72MHz时通常需要2个等待周期主频168MHz时需要5个等待周期具体数值看参考手册的“Flash memory programming”章节。排查方法很简单把主频暂时降到最低频率比如8MHz等待周期设为0再测试Flash读写。如果问题消失说明就是等待周期配置不对。6.2 D-Cache和I-Cache读回旧数据的幕后黑手带Cache的高性能型号比如F4、F7、H7系列Cache会在你读Flash时命中缓存。如果你的代码之前读过这个地址Cache里保存了一份旧值之后你重新写入Flash再读同一个地址CPU拿到的可能还是Cache里的旧数据。我见过不止一个项目Flash写入逻辑单独测试是好的放到整个系统里就跑不对最后发现是D-Cache没有做Invalidate操作。解决办法有两类最简单粗暴访问Flash参数区之前关掉D-Cache或者用SCB_InvalidateDCache_by_Addr主动使对应地址范围的缓存失效。更规范把Flash参数区所在的地址范围配置成非Cacheable。但这里牵扯到MPU配置不少开发者不熟悉反而引入新问题。如果你只是存参数我建议直接走“写完之后Clean/Invalidate读之前Invalidate”这条路代码量最小。/* 读回之前以目标地址为起始使缓存行失效 */ SCB_InvalidateDCache_by_Addr((uint32_t *)PARAM_SECTOR_ADDR, PARAM_DATA_LEN);要注意SCB_InvalidateDCache_by_Addr的地址必须32字节对齐长度也最好按32对齐否则不在缓存行范围内的区域可能漏掉。实际上我一般直接把整页数据都Invalidate一遍多花几个周期但保证不翻车。6.3 写保护和选项字节擦除成功的假象前面F407案例里已经提到写保护。这里再补充一个容易踩的细节擦除函数返回成功不代表字节真的变成了0xFF。在写保护场景下Flash控制器的行为是“忽略你的擦除请求”但API的状态寄存器检查未必严格。所以我在排查时有个习惯每次擦除完之后直接读目标扇区的前几个字节确认是0xFF才继续写入。另外选项字节本身也可以被软件修改但需要先解锁选项字节区再调用专门的接口最后复位才生效。如果你在做Bootloader升级功能尤其要注意升级程序里的擦写逻辑要能兼容“扇区被写保护”的情况最理想的做法是在Bootloader里主动检查并解除所有业务扇区的写保护。7. 遇到这类问题先按这个顺序排查7.1 最小化复现实验把干扰全砍掉如果你已经被这类问题折腾了几个小时别再继续在业务代码里大海捞针。我建议你新建一个独立的测试工程只做三件事点亮一个LED、写Flash、读Flash。初始化里不开RTOS、不开中断、不开Cache、不开任何外设。在主循环里这样跑清零缓冲区、填充特征数据、擦除、写入、读回、比对结果通过串口打印或者用调试器观察。这个“裸奔”实验能帮你确认芯片本身和HAL库的读写接口是不是正常的。绝大多数情况下做到这一步问题就水落石出如果裸奔还是不对那才是真正需要往硬件层面深挖的时候。7.2 调试器里的三项核对比printf好用调试器看内存是我的首选方式尤其开System Viewer或者Memory窗口时能直接看到物理地址上的字节绕开软件层的所有干扰。排查时我固定核对三件事第一写入前目标的Flash区域是否全是0xFF。如果不是说明擦除没有真正生效。第二写入后立刻看同一地址的字节。如果此时正确但复位后不正确问题多半在Cache或选项字节如果此时就不正确问题多半在编程时序或地址对齐。第三核对变量指针的值。有时候你把地址参数传错了编译器不会报错但你调试器里看到的指针值会明显偏离预期一眼就能看出来。7.3 别急着写代码先想清楚“擦除和写入的关系”我在带新人时经常说一句话写Flash不是写数组你写的是一个带物理约束的介质。数组你什么时候想覆盖就覆盖但Flash每次覆盖前都要把整块擦干净。你把这句话刻在脑子里遇到“读回数据和原写入数据不一致”时先检查擦除边界再检查写保护然后检查Cache和等待周期90%的问题都能在半小时内定位。如果做了所有检查依然复现那最后一个建议把目标芯片的参考手册里“Flash programming”章节从头到尾翻一遍。很多细节比如最小编程时间、编程电压范围、擦除时的电源要求都是写在这份文档里的。看手册虽然不如调试来得爽但它是唯一能让你把所有可能性都覆盖到的途径。我自己现在的习惯是每次在Flash区存放参数之前都会先写一个自检函数上电时自动对参数区做一次“写-读-比对-复位-再读”的完整校验通过后才允许业务层使用这些参数。这个动作在量产产品里帮我提前拦截了不少批次的隐性故障成本也不高几十行代码而已。希望你也能在自己的项目里加一道这样的保险。