☰
STM32L4内部FLASH读写实战:LL库流程与避坑指南
2026/10/17 10:55:10 网站建设 项目流程

简介:针对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 这类小容量型号单页只有 1KB,L4+(L4R/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 的编程位叫 PG(Programming),L4 它改叫 PROG;F4 扇区擦除位叫 SER,L4 叫 PER(Page 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 页的 L476,0x08008000 落在第 16 页。这里不要用地址先减 1 再除,边界上的地址会算错页。参数说明:FLASH_PAGE_SIZE 必须按实际型号改,L412 系列是 0x400(1KB),L4+ 按扇区走,不能沿用这个宏。

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 操作是特殊的:当 PROG=1 时,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 的当前值,没有执行选项字节的加载序列,所以重启后老配置又回来了。

解决:用寄存器改写保护时,要先解锁 FLASH(KEYR),再解锁选项字节(OPTKEYR),写完 OPTR 后必须等待 BSY 清空,然后调用 NVIC_SystemReset() 让新配置生效。注意:步骤不能省,尤其“解锁选项字节”不能漏。实际代码里建议直接抄 LL_FLASH_OB_EnableWriteProtection() 的实现,比自己拼寄存器可靠。

5. 把“能跑”变成“能用”:数据管理、版本校验与量产测试

5.1 用“双槽位 + 序列号”做有限磨损均衡

直接在一个固定地址反复擦写,寿命天花板就是页擦除次数——L4 内部 FLASH 典型寿命约 1 万次。假设设备每 10 分钟存一次参数,一天 144 次,70 天就逼近极限。要做磨损均衡,常见做法是开辟多个槽位,轮流写。下表的方案在工程里最常用:

槽位地址范围状态
Head页 N 起始存“当前活跃槽”索引
Slot0页 N+1有效或过期
Slot1页 N+2有效或过期

写入时先读 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 测试序列再继续往后写业务代码。希望这个习惯也能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询