前两天有人在嵌入式交流群里贴了一段代码,项目标题就是“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/2KB,F4是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, §or_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区存放参数之前,都会先写一个自检函数,上电时自动对参数区做一次“写-读-比对-复位-再读”的完整校验,通过后才允许业务层使用这些参数。这个动作在量产产品里帮我提前拦截了不少批次的隐性故障,成本也不高,几十行代码而已。希望你也能在自己的项目里加一道这样的保险。