ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32L4xx内部FLASH读写实战:双Bank、ECC与掉电保护避坑指南

STM32L4xx内部FLASH读写实战:双Bank、ECC与掉电保护避坑指南

简介:这份资源面向使用STM32L4xx系列MCU进行底层开发的嵌入式工程师,聚焦内部FLASH的读写与擦除操作,采用LL库直接配置寄存器实现,已在STM32L452RET6芯片上实测调通;由于核心均为寄存器级操作,改用HAL库时只需调整相关定义即可复用,适合希望摆脱库函数封装、深入理解FLASH控制器时序与页管理机制的开发者参考。压缩包共2个文件,包含1个c源文件与1个h头文件,分别承载FLASH读写擦除的具体实现与接口声明,整体仅2KB,体量轻便、便于直接嵌入工程。资源已有1935人学习下载,具备一定的实践验证基础。作者还补充了在STM32L471VETx上的调试经验:该型号FLASH页码不连续,需修改擦页函数中的页码号才能成功擦除,并提示不适用于STM32L4x1系列,这一排错思路对跨型号移植尤具参考价值。

1. 拿到 STM32L4xx 内部 FLASH 读写包,先别急着烧录

上周有个做低功耗采集终端的同行找我,说设备掉电后校准参数全丢,外挂 EEPROM 又占板面又加 BOM,问能不能把参数塞进片内 FLASH。这几乎是 STM32L4 系列绕不开的诉求:内部 FLASH 当参数区用,省一颗芯片、省两行布线,还能靠双 Bank 做 A/B 备份。但真动手就会发现,L4 的 FLASH 控制器和 F1/F4 那套完全不是一回事——双 Bank 切换、页擦除粒度、ECC 报错、编程前必须对齐的 double-word,随便一个没处理就是 HardFault 或者读回全 0xFF。这个 rar 包给的就是一套基于 LL 库加寄存器操作的 STM32L4xx 内部 FLASH 读写实现,覆盖解锁、页擦除、double-word 编程、读校验的完整链路,适合正在做参数存储、固件升级标志位、掉电保护的中低端嵌入式开发者。下面我按自己拆包复现的顺序,把能抄的部分和会翻车的地方都摊开讲。

2. STM32L4xx FLASH 控制器:为什么不能照搬 F1 的写法

2.1 L4 的 FLASH 结构和 F1/F4 的关键差异

很多人从 F103 转过来,第一反应是FLASH->CR |= FLASH_CR_PER然后写地址,结果在 L4 上编译能过、跑起来直接卡死。原因在于 L4 的 FLASH 接口做了几处结构性改动,必须先搞清楚再写代码。

第一是双 Bank 架构。L4 系列按容量分,512KB 及以上的型号通常是双 Bank,每个 Bank 有独立的页擦除和编程状态机,FLASH->CR里的BKER位决定当前操作落在哪个 Bank。你算页地址时如果只按绝对地址偏移,很容易把 Bank2 的页当成 Bank1 的页去擦,擦完发现数据没动,或者更糟——把正在跑的代码区擦了。

第二是擦除粒度变成 2KB 页。F1 是 1KB 页,F4 有 sector 概念(16KB 到 128KB 不等),L4 是统一的 2KB 页(部分小容量型号是 1KB,看具体型号的 reference manual)。页号计算必须用(addr - FLASH_BASE) / FLASH_PAGE_SIZE,而且这个FLASH_PAGE_SIZE在不同子型号上不一样,不能写死。

第三是编程单位是 double-word(64 位)。L4 的 FLASH 编程必须一次写 8 字节,且地址要 8 字节对齐。你写一个uint32_t进去,硬件会等你补齐第二个 word 才真正落盘,中途如果复位,这一组 double-word 就是脏数据。这是 L4 上最容易踩的坑,后面避坑章节会细说。

第四是ECC 机制。L4 的 FLASH 带 ECC 校验,读一个从来没编程过的页(全 0xFF)不会报错,但如果你对一个已经编程过的 double-word 再编程一次(不复位的情况下),ECC 会直接触发 NMI。这意味着"先写后改"的朴素思路在 L4 上行不通,改数据必须先擦整页。

特性STM32F1STM32F4STM32L4xx
擦除粒度1KB 页16~128KB sector2KB 页(部分 1KB)
编程单位半字/字字节/半字/字double-word(64 位)
Bank 结构单 Bank单/双 Bank双 Bank(大容量)
ECC无无有,重复编程触发 NMI
解锁序列KEYR 两次KEYR 两次KEYR 两次 + OPTKEYR 可选

这张表建议贴在工位上,每次从别的系列移植 FLASH 代码前扫一眼,能省掉大半调试时间。

2.2 为什么这个包选 LL 库加寄存器,而不是 HAL

HAL 的HAL_FLASHEx_Erase和HAL_FLASH_Program当然能用,但在这个场景下有几个现实问题。一是 HAL 的 FLASH 驱动为了兼容全系列,带了一堆状态判断和超时轮询,代码体积不小,对于参数存储这种"偶尔写一次"的功能,占的 flash 空间不划算。二是 HAL 的擦除函数内部会做 Bank 自动判断,但判断逻辑依赖FLASH_BANK_1/2宏,跨型号移植时如果宏没配对,行为很隐蔽。三是 LL 库本身就是寄存器的一层薄封装,LL_FLASH_Unlock()展开就是写 KEYR,调试时看反汇编能直接对上 reference manual 的时序图,出问题好定位。

这个包的思路是:解锁、等待 BSY、清标志用 LL 的现成函数,页擦除和 double-word 编程直接操作FLASH->CR、FLASH->SR、FLASH->PECR这些寄存器。好处是每一步都能在手册里找到对应位,坏处是你得自己保证时序和标志清除顺序。我一般会在工程里保留一份 reference manual 的 FLASH 章节 PDF,改代码时对着看。

2.3 解锁与状态检查:第一步就决定后面顺不顺

L4 的 FLASH 上锁状态下,FLASH->CR的LOCK位为 1,任何擦写操作都会被忽略且置OPERR。解锁序列是往FLASH->KEYR连写两个魔数,中间不能有中断打断(严格说是不建议),写完读LOCK位确认清零。

/* 解锁 FLASH 并确认状态,返回 0 表示成功 */ int flash_unlock(void) { if (LL_FLASH_IsLocked()) { /* 先判断是否已锁 */ LL_FLASH_Unlock(); /* 内部写 KEYR: 0x45670123, 0xCDEF89AB */ } if (LL_FLASH_IsLocked()) { /* 再读一次 LOCK 位 */ return -1; /* 解锁失败,多半是时序被打断 */ } /* 清掉可能残留的错误标志,避免后续操作被误判 */ __IO uint32_t tmp = FLASH->SR; FLASH->SR = tmp; /* SR 的 EOP/OPERR/PROGERR/WRPERR 写 1 清除 */ return 0; }

这段的关键在最后两行:FLASH->SR的错误标志是写 1 清除(write-1-to-clear),不是写 0。很多人习惯FLASH->SR &= ~FLASH_SR_OPERR,在 L4 上这是无效操作,标志一直挂着,后面每次擦除都返回错误。我见过有项目因为这个,擦除函数永远返回 fail,最后靠"忽略返回值"硬扛过去,埋了个大雷。参数说明:LL_FLASH_IsLocked()读的是CR的 bit31,LL_FLASH_Unlock()是 LL 提供的封装,如果你用的是纯寄存器版本,就自己写两次FLASH->KEYR = ...。

3. 页擦除与 double-word 编程:把流程拆成可复现的步骤

3.1 页擦除:算对页号,等对 BSY

擦除一页的完整流程是:解锁 → 确认没有正在进行的操作(BSY=0)→ 配置CR的PER位和PNB页号 → 置STRT→ 轮询BSY直到清零 → 检查SR的EOP和错误位 → 清标志 → 上锁。看起来线性,但每一步都有细节。

/* 擦除包含 addr 的那一页,addr 必须是 FLASH 区内的有效地址 */ int flash_erase_page(uint32_t addr) { uint32_t page; uint32_t bank; if (addr < FLASH_BASE || addr >= (FLASH_BASE + FLASH_SIZE)) { return -1; /* 地址越界,直接拒绝 */ } if (flash_unlock() != 0) { return -2; } while (LL_FLASH_IsActiveFlag_BSY()) { } /* 等上一次操作彻底结束 */ page = (addr - FLASH_BASE) / FLASH_PAGE_SIZE; /* 页号,注意 PAGE_SIZE 按型号取 */ bank = (page >= (FLASH_PAGE_NB / 2)) ? 2 : 1; /* 双 Bank 型号要判断落在哪个 Bank */ if (bank == 2) { LL_FLASH_EnableBank2Erase(); /* 置 CR 的 BKER */ page -= (FLASH_PAGE_NB / 2); /* Bank2 的页号从 0 重新计 */ } else { LL_FLASH_EnableBank1Erase(); } LL_FLASH_SetPageToErase(page); /* 写 CR 的 PNB[7:0] */ LL_FLASH_EnablePageErase(); /* 置 CR 的 PER */ LL_FLASH_StartErase(); /* 置 CR 的 STRT */ while (LL_FLASH_IsActiveFlag_BSY()) { } /* 擦除期间不能碰 FLASH */ if (LL_FLASH_IsActiveFlag_EOP()) { LL_FLASH_ClearFlag_EOP(); /* 写 1 清 EOP */ LL_FLASH_DisablePageErase(); /* 清 PER,恢复默认 */ LL_FLASH_Lock(); return 0; } /* 出错路径:清错误标志并上锁,返回负值供上层记录 */ LL_FLASH_ClearFlag_OPERR(); LL_FLASH_ClearFlag_PROGERR(); LL_FLASH_ClearFlag_WRPERR(); LL_FLASH_DisablePageErase(); LL_FLASH_Lock(); return -3; }

逻辑说明:先做地址合法性检查,避免把外设区地址传进来导致未定义行为;BSY轮询放在解锁之后、配置之前,是因为如果上一次操作还没结束,你改CR的位会被硬件忽略。Bank 判断是这段的核心——FLASH_PAGE_NB是总页数,双 Bank 型号下前一半属于 Bank1、后一半属于 Bank2,BKER置位后PNB的编号是相对当前 Bank 的,不减掉偏移就会擦错页。参数上,FLASH_PAGE_SIZE和FLASH_PAGE_NB这两个宏必须按你实际型号的 reference manual 填,别抄别人的工程。

提示:擦除期间 CPU 从 FLASH 取指会被 stall,如果你的中断服务程序也在 FLASH 里跑,擦除那几毫秒到几十毫秒内中断响应会延迟。对时序敏感的应用,要么把擦除放在低优先级任务里,要么把关键 ISR 搬到 RAM 执行。

3.2 double-word 编程:8 字节对齐是硬约束

编程比擦除更容易翻车,因为 L4 要求一次写满 64 位。FLASH->CR的PG位置 1 后,你往目标地址写第一个 32 位字,硬件不会立即编程,而是等你写第二个 32 位字,凑齐 double-word 才一起落盘。如果你只写了一个字就退出,硬件会一直等,直到你写第二个字或者复位——复位后这个半截操作可能留下 ECC 错误。

/* 从 src 编程 len 字节到 dst,dst 必须 8 字节对齐,len 向上取整到 8 的倍数 */ int flash_program(uint32_t dst, const uint8_t *src, uint32_t len) { uint32_t i; uint64_t dw; if ((dst % 8) != 0) { return -1; /* 目标地址未对齐,直接拒绝 */ } if (flash_unlock() != 0) { return -2; } while (LL_FLASH_IsActiveFlag_BSY()) { } LL_FLASH_EnableProgram(); /* 置 CR 的 PG */ for (i = 0; i < len; i += 8) { /* 拼一个 double-word,不足 8 字节的部分补 0xFF(擦除态) */ dw = 0xFFFFFFFFFFFFFFFFULL; memcpy(&dw, src + i, (len - i) >= 8 ? 8 : (len - i)); *(volatile uint32_t *)dst = (uint32_t)(dw & 0xFFFFFFFF); *(volatile uint32_t *)(dst + 4) = (uint32_t)(dw >> 32); while (LL_FLASH_IsActiveFlag_BSY()) { } if (!LL_FLASH_IsActiveFlag_EOP()) { LL_FLASH_DisableProgram(); LL_FLASH_ClearFlag_PROGERR(); LL_FLASH_Lock(); return -3; /* 编程失败,可能是目标未擦除 */ } LL_FLASH_ClearFlag_EOP(); dst += 8; } LL_FLASH_DisableProgram(); LL_FLASH_Lock(); return 0; }

逻辑说明:memcpy那段处理的是尾部不足 8 字节的情况,补0xFF而不是补 0,因为 FLASH 擦除态是 1,补 0 等于主动编程了一个 0 位,后续想改还得再擦。两次*(volatile uint32_t *)写入的顺序不能反,先低 32 位后高 32 位是硬件状态机的约定。每次写完等BSY清零再检查EOP,是因为EOP在BSY期间可能还没置起来,提前读会误判失败。参数上,len建议由调用方保证是 8 的倍数,函数内部虽然做了补齐,但补齐逻辑依赖src可读范围,传越界指针一样会崩。

3.3 读回校验:别信"写成功"就等于"写对了"

编程返回成功只代表硬件接受了这次操作,不代表你读回来的数据和写进去的一致。L4 的 ECC 在单 bit 错误时可以纠正,但双 bit 错误会报 NMI,而且这种错误在写入时不一定暴露。稳妥做法是写完立刻读回比对,尤其是参数区这种"写一次用很久"的数据。

/* 读回校验,返回 0 表示一致 */ int flash_verify(uint32_t addr, const uint8_t *src, uint32_t len) { uint32_t i; const volatile uint8_t *p = (const volatile uint8_t *)addr; for (i = 0; i < len; i++) { if (p[i] != src[i]) { return -1; /* 第一个不一致的偏移可以打日志 */ } } return 0; }

这段没什么花哨的,但volatile不能省——编译器优化可能把读操作提到编程之前,或者缓存到寄存器里,导致校验永远通过。我一般会在校验失败时把addr、偏移、期望值、实际值四个信息打到串口,方便判断是擦除没干净还是编程时序问题。

4. 参数区设计:把 FLASH 当 EEPROM 用的几个现实约束

4.1 磨损与寿命:10K 次擦写怎么摊

L4 的 FLASH 擦写寿命典型值是 10K 次(数据手册标的是 minimum,实际个体有差异)。如果你的参数每秒写一次,一页 2KB 撑不了多久。常见做法是日志式写入:把一页分成若干个 slot,每次写新数据追加到下一个空 slot,写满整页再擦除。这样擦除次数从"每次写"降到"每 N 次写一次",寿命直接乘 N。

这个包里没有现成的日志层,但读写原语已经够你搭。我一般会定义一个 slot 结构:头部放 magic + 序号 + CRC,后面放参数体。上电时从页尾往前扫,找到序号最大且 CRC 正确的 slot 作为当前有效数据。CRC 用软件算,别指望硬件 CRC 外设——它和 FLASH 读时序没关系,但多一个外设初始化就多一处出错点。

4.2 掉电保护:写一半断电怎么办

日志式写入天然带一点掉电保护:新 slot 写坏了,旧 slot 还在,上电扫描时 CRC 不过就跳过。但有两个细节要注意。一是写 slot 的顺序:先写参数体,再写头部(含 CRC),最后写 magic。这样如果断电发生在写参数体阶段,头部 magic 还没写,这个 slot 会被识别为无效。二是擦除页的时机:整页写满后擦除,擦除过程中断电,整页数据全丢。所以至少保留两个页做乒乓,一页擦的时候另一页还有有效数据。

注意:L4 的页擦除是整页 2KB 一起擦,没有"擦半个页"的选项。如果你的参数体很小,日志式写入的 slot 数量会很多,扫描时间变长。我见过有项目上电扫描花了 30ms,拖慢了启动,最后改成在 RAM 里缓存当前 slot 位置、只在首次上电全扫。

4.3 和选项字节、OTP 区的边界

有些参数(比如设备唯一 ID、出厂校准)适合放 OTP 区,但 OTP 只能写一次,写错就废。选项字节(option bytes)能改,但改选项字节会触发系统复位,而且操作序列比普通 FLASH 多一步OPTKEYR解锁。这个包聚焦的是主 FLASH 区的读写,选项字节和 OTP 不在范围内——如果你的需求是"存一个几乎不变的配置",选项字节可以;如果是"运行中频繁更新的参数",老老实实用主 FLASH 加日志层。

5. 避坑与排查:五个让我熬夜的现场问题

5.1 现象:擦除函数返回成功,但读回还是旧数据

原因:页号算错,擦的是另一个 Bank 的页。双 Bank 型号下PNB是相对当前 Bank 的编号,如果BKER没置或者置错,PNB指向的物理页和你以为的不是同一个。

解决:在擦除前后各读一次目标页的首字节,打印出来对比。如果擦除后不是0xFF,先查BKER和PNB的计算。我一般会在flash_erase_page里加一个可选的调试打印,把addr、page、bank三个值打出来,对着 reference manual 的地址映射表核一遍。

5.2 现象:编程时进 HardFault 或者 NMI

原因:对已经编程过的 double-word 重复编程,触发 ECC 双 bit 错误。L4 的 ECC 在检测到不可纠正错误时会产生 NMI,如果你的 NMI handler 没处理,就表现为死机。

解决:编程前确认目标区域已擦除。可以在flash_program开头加一段检查:读目标地址的 8 字节,如果全是0xFF才继续,否则返回错误让上层先擦。另外 NMI handler 里至少要把FLASH->ECCR读出来清掉,否则会反复进 NMI。

5.3 现象:擦除后EOP一直不置位,轮询死循环

原因:BSY轮询写成了while (!LL_FLASH_IsActiveFlag_BSY()),逻辑反了;或者STRT位没置起来,因为PER配置和STRT之间被中断打断,CR被别的代码改了。

解决:给轮询加超时,别裸while。超时值按最坏擦除时间乘 3 取,比如 2KB 页擦除典型 20ms 左右,超时设 100ms。超时后读FLASH->SR和FLASH->CR打日志,看是BSY卡住还是STRT没置。

5.4 现象:参数写进去,重启后读出来是乱码

原因:结构体直接memcpy到 FLASH,编译器做了字节对齐填充,读的时候结构体定义变了(比如加了个字段),偏移全乱。

解决:参数区不要直接存结构体,存序列化后的字节流,每个字段按固定偏移写。或者用#pragma pack(1)强制紧凑,但这样跨编译器移植有风险。我一般写一对param_pack/param_unpack函数,显式处理每个字段,虽然啰嗦但不会因为改结构体而崩。

5.5 现象:低功耗模式下写 FLASH 失败

原因:L4 进 Stop 模式后 FLASH 时钟可能被关,或者电压调节器切到低功耗档,编程时序不满足。另外从低功耗唤醒后,FLASH->ACR的等待周期可能还没恢复。

解决:写 FLASH 前确保系统时钟和电压调节器在正常范围,别在 Stop 模式下直接写。如果必须在低功耗场景写,先唤醒、等时钟稳定、再解锁写,写完再进低功耗。这个包的代码默认在 Run 模式下跑,低功耗场景需要你在调用前自己加时钟保障。

6. 进阶:把读写原语封装成可测试的参数模块

拆完这个包,我做的第一件事不是直接往项目里塞,而是先搭一个能在 RAM 里跑的测试桩。思路是把flash_erase_page和flash_program抽成函数指针,测试时指向一个模拟 FLASH 行为的 RAM 缓冲区——擦除就是把一段填0xFF,编程就是检查目标是否全0xFF再写入。这样日志层的 slot 扫描、CRC 校验、掉电模拟都能在 PC 上跑,不用每次烧板子。

/* 可替换的 FLASH 操作接口,便于单元测试 */ typedef struct { int (*erase)(uint32_t addr); int (*program)(uint32_t dst, const uint8_t *src, uint32_t len); int (*verify)(uint32_t addr, const uint8_t *src, uint32_t len); } flash_ops_t; /* 测试桩:用一块 RAM 模拟 FLASH 行为 */ static uint8_t sim_flash[4096]; static int sim_erase(uint32_t addr) { uint32_t off = addr - SIM_FLASH_BASE; if (off + 2048 > sizeof(sim_flash)) return -1; memset(&sim_flash[off], 0xFF, 2048); /* 模拟页擦除 */ return 0; } static int sim_program(uint32_t dst, const uint8_t *src, uint32_t len) { uint32_t off = dst - SIM_FLASH_BASE; uint32_t i; for (i = 0; i < len; i++) { if (sim_flash[off + i] != 0xFF) return -1; /* 模拟"未擦除不能编程" */ sim_flash[off + i] = src[i]; } return 0; }

这套桩子帮我抓出过两个 bug:一个是 slot 扫描在页边界处越界读,另一个是 CRC 计算把头部 magic 也算进去了导致永远校验失败。这两个在真板上都表现为"偶发读不到参数",很难复现,但在 RAM 桩里跑一千次写入循环就暴露了。

验证方法上,我习惯做三组测试:连续写 1000 次参数,看 slot 轮转和擦除次数是否符合预期;在写入过程中随机"断电"(直接丢弃缓冲区后半段),看下次上电能否回退到上一个有效 slot;把整页填满后触发擦除,确认擦除后第一个 slot 能正常写入。三组都过,才敢往产品里放。

从那以后我每次碰 FLASH 相关代码,都强制先在 RAM 桩上跑一遍掉电模拟,再上板。这个习惯救过我至少两次——真板上掉电测试要拔电、要示波器抓时序,成本高得多,而 RAM 桩几秒钟就能跑一轮。希望这套拆包思路和避坑记录能帮到你,少走几个我走过的弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进