简介针对STM32L4xx系列微控制器的内部FLASH读写需求这份代码资源基于实际项目调通使用LL库并以寄存器操作方式实现适用于STM32L452等型号通过简单修改定义也可用于HAL库工程适合需要保存参数、固件升级或日志记录的嵌入式开发者参考。压缩包包含一个C源文件和一个头文件共2个文件大小仅2KB代码精简集中便于直接嵌入项目或作为底层驱动移植。作者在调试STM32L471时曾遇到擦页失败最终定位为FLASH页码不连号只需调整擦页函数中的页码映射即可解决需要留意该方案不直接适用于STM32L4x1系列移植时应核对芯片的页号定义。目前已有1935人学习下载。这份资源提供了寄存器级的FLASH读写与擦除实现并包含了作者实测中的排错经验能帮助开发者快速上手内部FLASH操作减少踩坑与调试时间。1. 读写 STM32L4xx 内部 FLASH为什么这块“片上硬盘”值得认真对待很多做 STM32L4 开发的人第一次接触内部 FLASH 读写都是在“程序跑起来之后发现参数没地方存”的那一刻。外部 EEPROM 要加芯片、走 I2C还要考虑掉电时序而 MCU 内部那块 FLASH既能存代码也能存数据擦写寿命虽然只有一万次左右但存设备序列号、校准参数、OTA 标志位完全够用。问题是 L4 的 FLASH 和 F1/F4 不一样编程粒度是 64 位双字页大小因型号而变等待周期配错直接 HardFault。这份资源把 L4 的 LL 库流程和寄存器操作拆成了可直接抄的工程适合两类人一是想给固件加参数存储但不想上外部存储的嵌入式开发者二是被“擦除后读回 0x00”之类怪现象折磨过的调试者。读完你会明白它能不能解决你的问题以及坑在哪。2. L4 的 FLASH 结构与时序先搞清“能写什么、要等多久”2.1 页大小、双 Bank 与等待周期不同型号差别很大L4 系列内部 FLASH 不是统一规格。常见的 L476/L433 单页是 2KB而 L412/L422 这类小容量型号单页只有 1KBL4L4R/L4S又变成了按扇区管理。这意味着同样一个“擦除第 16 页”的操作在不同型号上对应的地址完全不同。我一般拿到工程第一件事就是查芯片选型手册里的 Flash organization 表而不是凭经验猜页大小。L4 的 FLASH 还分 Bank1 和 Bank2两个 Bank 可以独立擦写也可以合并成大容量模式。默认上电是单 Bank 模式地址连续编排这对用户来说是无感的但如果你要用“双 Bank 并行擦除”来提速就必须改选项字节。多数应用根本不需要这个功能知道有这回事就够了。等待周期Wait States是另一个容易翻车的点。L4 的内核跑到 80MHz 时FLASH 读取需要至少 2 个等待周期否则取指乱掉表现就是程序随机 HardFault。这个配置不在 FLASH 驱动代码里而是在系统时钟初始化里很多人写 FLASH 读写程序没问题但把系统时钟从默认的 4MHz 超频到 80MHz 之后整个工程开始抽风排查方向却全错。2.2 LL 库操作的本质寄存器序列的封装LL 库是 Cube 体系里偏底层的库它不像 HAL 那样做了一堆状态机管理而是把寄存器的操作序列直接映射成函数。读写内部 FLASH 用 LL 库是最舒服的因为你既不需要像裸寄存器那样查每个位的含义也不会被 HAL 的 __HAL_FLASH_GET_FLAG 宏绕晕。L4 的 FLASH 操作基本时序可以归纳为四条解锁、等待空闲、执行操作、重新上锁。解锁要往 KEYR 寄存器依次写 0x45670123 和 0xCDEF89AB 两个密钥等待空闲是轮询 SR 寄存器的 BSY 位执行操作根据你要做的事设置 CR 寄存器对应位并触发完成后写上锁值或者干脆调用 LL_FLASH_Lock()。LL 库把这些封装成了 LL_FLASH_Unlock()、LL_FLASH_Erase()、LL_FLASH_Program()、LL_FLASH_WaitForLastOperation() 这样几个函数。资源里给的就是这套流程直接照抄能跑但要想明白每个函数背后发生了什么出了错才知道看哪里。2.3 L4 与 F1/F4 的关键差异寄存器位名都不同有 F1 或 F4 经验的人写 L4 的 FLASH 程序最容易栽在寄存器位名的惯性上。F4 的编程位叫 PGProgrammingL4 它改叫 PROGF4 扇区擦除位叫 SERL4 叫 PERPage Erase。位名变了操作序列也变了——L4 在写数据之前必须先设置 PROG 位再执行写入指令顺序反了会报 PGSERR编程序列错误。L4 还给每个错误类型配了独立的状态位PGAERR 表示地址没对齐SIZEERR 表示数据宽度不对WRPERR 表示写保护MISERR 表示擦除序列错误。这些标志位是排障的金矿很多人遇到 FLASH 写不进去只盯着 BSY 位看完全忽略了 SR 寄存器里已经躺着一个明确的错误码。资源里的例程在每次操作后都会读这些标志这习惯值得保留。3. 最小可运行工程从解锁到回读校验的完整代码3.1 工程准备时钟、FLASH 基础地址与页计算拿到这个资源包先别急着往自己工程里塞文件。先把时钟配好尤其注意上一步说的等待周期。L4 在 80MHz 主频下FLASH 延迟必须是 2。如果你用的是 CubeMX 生成工程时钟树配置正确的话会自动算好手动移植时最容易漏。FLASH 的起始地址是 0x08000000这是固定的。要操作某块区域先算出页号。下面的代码演示 2KB 一页的型号怎么计算#define FLASH_BASE_ADDR 0x08000000u #define FLASH_PAGE_SIZE 0x800u /* 2KB per page, L476/L433 */ #define TARGET_ADDR 0x08008000u /* 我们要操作的绝对地址 */ /* 计算目标地址所在的页号从0开始 */ uint32_t page_number (TARGET_ADDR - FLASH_BASE_ADDR) / FLASH_PAGE_SIZE; /* 计算页起始地址页号乘以页大小加上基地址 */ uint32_t page_start FLASH_BASE_ADDR page_number * FLASH_PAGE_SIZE;逻辑说明页号从 0 开始所以地址偏移量直接除以页大小就能得到页号。对于 2KB 页的 L4760x08008000 落在第 16 页。这里不要用地址先减 1 再除边界上的地址会算错页。参数说明FLASH_PAGE_SIZE 必须按实际型号改L412 系列是 0x4001KBL4 按扇区走不能沿用这个宏。3.2 擦除整页LL 库的标准流程擦除是 FLASH 操作里最耗时的一步L4 擦一页大约要几十毫秒期间 CPU 取指会被阻塞。如果你在刷 OTA 固件这个时间片要考虑任务调度能不能忍。下面是擦除一页的 LL 库代码/* 1. 解锁 */ LL_FLASH_Unlock(); /* 2. 等待上一次操作完成如果有的话 */ while (LL_FLASH_GetStatus() ! LL_FLASH_STATUS_SUCCESS); /* 3. 擦除目标页 */ if (LL_FLASH_Erase(LL_FLASH_TYPEERASE_PAGES, FLASH_BANK_1, page_number, 1) ! LL_FLASH_STATUS_SUCCESS) { /* 擦除失败看看错误标志 */ uint32_t err LL_FLASH_GetError(); /* 处理错误MISERR 表示序列错WRPERR 表示写保护 */ } /* 4. 重新上锁 */ LL_FLASH_Lock();逻辑说明LL_FLASH_Erase 的第一个参数指定擦除类型页擦除传 LL_FLASH_TYPEERASE_PAGES第二个参数选 Bank单 Bank 模式下所有地址都在 FLASH_BANK_1第三个参数是页号第四个参数是连续擦除的页数。擦除完成后 FLASH 内部自动把这几页的数据恢复成全 1不需要手动写数据。参数说明页数参数支持一次擦多页但如果你只是更新 4 字节参数擦一页就够了——擦多了浪费时间而且会把不需要动的数据一起抹掉。3.3 写入双字地址对齐与 64 位数据L4 的编程粒度是双字也就是 8 字节。一次 LL_FLASH_Program 调用必须写满一个 64 位数据起始地址必须 8 字节对齐。这是 L4 和 F1 最大的区别——F1 支持半字编程F4 支持字编程L4 直接双字起步。/* 以 8 字节对齐的地址写入一个双字 */ uint64_t data 0x0123456789ABCDEFULL; uint32_t write_addr 0x08008000u; /* 注意必须能被8整除 */ if ((write_addr % 8) ! 0) { /* 地址未对齐直接返回不要继续 */ return -1; } LL_FLASH_Unlock(); /* 等待 FLASH 空闲 */ while (LL_FLASH_WaitForLastOperation(1000) ! LL_FLASH_STATUS_SUCCESS); /* 执行双字编程 */ LL_FLASH_Program(LL_FLASH_TYPEPROGRAM_DOUBLEWORD, write_addr, data); /* 等待本次编程完成 */ while (LL_FLASH_WaitForLastOperation(1000) ! LL_FLASH_STATUS_SUCCESS); LL_FLASH_Lock();逻辑说明LL_FLASH_Program 第三个参数是 uint64_t你把两个 32 位整数拼进去的时候要注意小端模式下低地址存低 32 位。这个顺序反了读回来的数据就是交换过的现象很隐蔽。参数说明WaitForLastOperation 的超时时间给了 1000 毫秒实际擦写远用不了这么久但宁可给足不要用 while(1) 死等——FLASH 硬件卡死时死等会把整个系统挂住。3.4 读回与校验读出数据不经过任何缓存写完之后读回校验是必须的步骤不是可选项。FLASH 读出不走 Cache 的话直接对地址取值就是物理数据/* 直接从 FLASH 地址读出 64 位数据 */ uint64_t read_back *(volatile uint64_t *)write_addr; if (read_back data) { /* 校验通过 */ } else { /* 校验失败检查是否未擦除、地址越界、或写保护 */ }逻辑说明这里用 volatile 修饰指针防止编译器把读操作优化掉——你写了一个全局变量标记写入完成但优化后的代码可能直接复用之前的值不再真正访问 FLASH。参数说明只校验双字够用但严谨的做法是整页擦除后全页回读确认没有残留脏数据。这个在量产测试里很有必要开发调试阶段只读关键地址就行。3.5 寄存器操作视角同一个流程去掉库的遮罩如果你习惯直接操作寄存器或者怀疑 LL 库在某版本有 bug可以用寄存器把流程重写一遍。这段代码展示的是 F4 工程师最容易搞混的 PROG 位顺序/* 寄存器方式解锁 */ FLASH-KEYR 0x45670123; FLASH-KEYR 0xCDEF89AB; /* 设置页擦除模式选中页号 */ FLASH-CR | FLASH_CR_PER; FLASH-CR | (page_number FLASH_CR_PNB_Pos); /* 触发擦除 */ FLASH-CR | FLASH_CR_START; /* 等待 BSY 清空 */ while (FLASH-SR FLASH_SR_BSY); /* 清除 PER 位切回编程模式 */ FLASH-CR ~FLASH_CR_PER; /* 写入双字地址数据同时准备好再置 PROG 位 */ *(volatile uint64_t *)write_addr data; FLASH-CR | FLASH_CR_PROG;逻辑说明这段代码最容易被骂的地方是最后两步——先写地址再置 PROG 位。实际上 L4 的写法是 Cortex-M 的写 FLASH 操作是特殊的当 PROG1 时CPU 对 FLASH 地址执行一次 64 位写入硬件自动完成编程。所以有的参考代码是先置 PROG 再写地址也能触发但先准备数据再置位的写法避免了在置位后、写入前这段时间窗口里被中断打断导致的序列错误。参数说明page_number 要移位到 PNB_Pos 对应位置不同 L4 型号这个位的偏移可能不同查参考手册的 FLASH_CR 寄存器位定义。4. 避坑与常见问题四个最容易翻车的现场4.1 擦除之后读回 0x00不是 0xFF现象擦除整页后用调试器读 FLASH看到的数据是 0x00并不是预期的 0xFF。有人因此怀疑擦除没生效反复调用擦除函数。原因擦除后的空态确实应该是 0xFF。出现 0x00 只有两种可能一是你在擦除之前往这个地址写过数据而 Flash 的编程操作只能把 1 改成 0不能反向二是擦除根本没执行成功而你读的是上一次写入的旧数据。要排除第二种用读出值做判断旧数据会有部分位是 0空页则是全 1。解决擦除后不要急着写先读全页确认 0xFF再执行写入。这个“先验证空态再编程”的步骤在量产烧录流程里能拦住很多奇奇怪怪的不稳定问题。4.2 写 4 字节数据程序报 PGAERR 错误现象想存一个 uint32_t 的校准参数调用带 LL_FLASH_TYPEPROGRAM_WORD 参数的函数结果错误标志位显示 PGAERR 或者直接不进中断。原因L4 不支持单字编程LL_FLASH_Program 的 TypeProgram 参数只有 LL_FLASH_TYPEPROGRAM_DOUBLEWORD 一个选项。想把 4 字节存进 FLASH必须凑成 8 字节的双字一起写数据高位随便填 0 或填充标记。解决把要存的变量声明成 uint64_t低 32 位放业务数据高 32 位放 CRC 或版本号一次写入。这样既绕开粒度限制又顺手做了数据完整性保护。这是我个人比较推荐的做法。4.3 写 FLASH 过程中掉电再上电程序直接跑飞现象产品在写参数时被人拔了电源重新上电后程序跳进 HardFault或者 FLASH 里读到一半 0xFF 一半乱码。原因FLASH 编程过程中掉电页内数据处于不确定状态。L4 虽然有 ECC 校验但遇到编程中断的页读出的数据可能触发 ECC 错误标志。程序跑飞往往是因为代码区被误擦除——如果有人把代码区和数据区放在同一页写参数时整页擦除把代码也抹了。解决第一数据区单独划分一页或多页跟代码区物理隔离第二写入流程设计成“先备份、再擦除、写入失败可回滚”的事务模式第三上电复位后先检查数据区关键位置的校验值非法就启用默认参数别让程序带病运行。4.4 关闭写保护后重新上电写保护自动恢复现象在调试器里把 OPTR 寄存器改成关闭写保护当时写入成功但断电重启后写保护又回来了。原因写保护的修改要永久生效必须同时操作选项字节区域并且触发一次系统复位加载新选项。很多人只改了 FLASH-OPTR 的当前值没有执行选项字节的加载序列所以重启后老配置又回来了。解决用寄存器改写保护时要先解锁 FLASHKEYR再解锁选项字节OPTKEYR写完 OPTR 后必须等待 BSY 清空然后调用 NVIC_SystemReset() 让新配置生效。注意步骤不能省尤其“解锁选项字节”不能漏。实际代码里建议直接抄 LL_FLASH_OB_EnableWriteProtection() 的实现比自己拼寄存器可靠。5. 把“能跑”变成“能用”数据管理、版本校验与量产测试5.1 用“双槽位 序列号”做有限磨损均衡直接在一个固定地址反复擦写寿命天花板就是页擦除次数——L4 内部 FLASH 典型寿命约 1 万次。假设设备每 10 分钟存一次参数一天 144 次70 天就逼近极限。要做磨损均衡常见做法是开辟多个槽位轮流写。下表的方案在工程里最常用槽位地址范围状态Head页 N 起始存“当前活跃槽”索引Slot0页 N1有效或过期Slot1页 N2有效或过期写入时先读 Head把数据写到非活跃的槽位然后更新 Head 指向新槽位。这样擦写次数被均摊到多个页上寿命翻倍只是起步。5.2 数据版本与无效标记的设计数据区里我习惯在头部放一个 32 位魔数、一个 16 位版本号、一个 16 位长度和 32 位 CRC。魔数用来区分“空页”和“有效数据”——空页全 0xFF魔数不可能是 0xFFFFFFFF所以一读便知。版本号解决的是“槽位里数据可能是上一次的旧数据”的问题。头部的定义长这样typedef struct { uint32_t magic; /* 固定值 0xA5A5A5A5用来识别有效记录 */ uint16_t version; /* 版本号每次写入递增 */ uint16_t len; /* 数据长度单位字节 */ uint32_t crc; /* 对整个记录算 CRC32 */ uint32_t payload[64];/* 实际业务数据 */ } flash_record_t;写入流程是擦除一个槽位填充结构体按双字逐个写入读回校验 CRC。读取流程是遍历所有槽位找 magic 匹配、CRC 通过、version 最大的那条当作有效数据。这套逻辑花半小时实现却能省掉一整年的“偶尔丢参数”排查时间。5.3 验证脚本把读写流程做成可重复的自动化测试开发阶段的读写调试人人都会但“改一处代码、验证一次功能”不是好习惯。我会把读写测试做成独立工程用一个按键触发完整测试序列全页擦除、全页写 0x5A5A5A5A、全页回读比对、局部擦写验证页隔离性。输出直接打到串口失败就停在那里打印错误标志。这套做法救过我一次某次改完时钟树后FLASH 写入偶发失败肉眼调了两天没头绪跑了一遍自动化测试发现错误总是出现在同一个地址附近顺藤摸瓜查到是等待周期在低温下不稳定虚汗当场就下来了。从那以后我每次改工程配置都强制先跑一遍这套 FLASH 测试序列再继续往后写业务代码。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取