简介:STM32L4xx系列超低功耗微控制器的闪存驱动源码包,面向嵌入式开发者,聚焦官方LL库对闪存编程、扇区擦除、选项字节配置、读写保护等底层操作的完整实现。压缩包共2个文件,由头文件和C源文件组成,体积约12KB;头文件声明接口与数据类型,源文件展示寄存器操作、时序控制和错误处理逻辑,适合希望绕开高级库、直接控制硬件的进阶开发者研读。研读时可结合片上闪存规格书逐行对照,提升底层驱动调试能力。
目前已有1610人学习下载。通过阅读代码,可以掌握闪存操作的关键函数实现,了解编程前解锁、等待忙标志、擦除后校验等常见流程,也能借助选项字节和读保护配置提升系统安全性。整体简洁紧凑,对理解STM32L4xx存储架构以及在实际项目中高效管理程序区具有直接参考价值。 前阵子帮客户排查一个基于STM32L431的故障,现象很典型:设备在OTA升级过程中偶尔会死机,而且不是每次都复现,重启后固件还能跑,但升级流程就是断在那里。查到最后,问题出在升级代码对Flash的擦写没有处理好“忙等待”和“缓存一致性”这两个细节。其实他用的就是ST官方的LL库,LL库本身没错,但对Flash的理解不够,代码写得不够严谨。
STM32L4系列在大批量项目里越来越常见,尤其是做电池供电的IoT设备、传感器节点、便携医疗设备。它的Flash编程和老的F1/F4有很大不同:双Bank、RWW、ICACHE、64位编程单位、页大小2KB——这些特性在HAL库和LL库下的处理方式也不一样。这段时间我把stm32l4xx_ll_flash相关的经验整理了一遍,这篇文章就围绕L4系列Flash的架构、LL库API的正确用法、实际踩过的坑,以及选项字节和读写保护这几个方向展开,希望能帮到正在做Bootloader、参数存储或者OTA功能的朋友。
1. L4的Flash架构:双Bank和RWW决定了你的擦写策略
1.1 存储布局和编程单位的差异
STM32L4系列的Flash不是传统意义上的一块整体。以最常见的L431、L476为例,Flash从0x08000000开始,容量从256KB到1MB不等。在1MB型号上,它被划分为两个Bank,每个Bank 512KB;在256KB的小容量型号上,两个Bank各128KB。页大小统一是2KB,除了少数几个L4+型号使用8KB页,绝大多数L4都是2KB一页。
这个架构直接带来了一个编程策略问题:你不能把Flash当成一个"可以随便按字节改写的存储卡"。L4的编程最小单位是双字(double word),也就是64位、8个字节。写入的时候必须一次性构造好8字节的数据,再调用LL_FLASH_Program写入。如果只改其中几个字节,你得先把整页读出来放到RAM里,在RAM里修改,再把整页擦除、整页写回——这就是EEPROM模拟方案的典型做法。
另外,擦除的最小单位是页,一页2KB。L4的标准擦除单位是不能单独擦除某个字节的,这是Flash的物理特性,跟哪家厂商无关。理解了这一点,你就知道为什么Flash驱动里最常见的组合就是"读页→改数据→擦页→写页"这个四步循环。
1.2 RWW实际用起来会踩什么坑
L4系列支持RWW(Read-While-Write),意思是一个Bank在擦写的时候,CPU仍然可以从另一个Bank读取代码执行。这个设计初衷很好,但如果你只有一个Bank在用,或者代码和要擦写的Flash在同一个Bank里,RWW帮不了你。
我见过最多的问题是这样的:Bootloader放在Bank1的低地址区,要升级的应用代码也放在Bank1的高地址区。在执行擦除Bank1高地址页时,CPU本身还在执行Bank1里的Bootloader代码。Flash接口在忙的时候,CPU取指令就要去读Flash,但Flash接口正忙着擦除,于是CPU被迫等待。如果此时恰巧来一个高优先级中断,中断向量表又放在同一个Bank,那整个系统就卡死了——复位按钮都救不了,只能看门狗复位。
解决办法有几个。最常用的方案是:把Flash擦写函数放在RAM里执行,同时把中断向量表也搬到RAM。STM32从寄存器层面是支持中断向量表重定位的,但是需要先使能FPU和I-Cache/D-Cache,再设置VTOR寄存器。另一个方案是直接用双Bank特性:把Bootloader放在Bank1,应用代码放Bank2,在Bank2上做擦写操作的时候,Bootloader还在Bank1跑着,互不干扰。这个方案对硬件设计没有额外要求,但在软件层面要处理好地址重映射和跳转逻辑。
1.3 等待周期与电压调节档位的联动
L4的Flash读取不是无条件的。Flash等待周期(Latency)必须和系统时钟频率、电源电压调节器档位(VOS)匹配。很多人在移植代码时,改了时钟树配置,却忘了重新设置Flash等待周期,结果程序跑在低电压调节档位下但配置了太高的时钟频率,Flash读出来的数据就乱了。
LL库对应的API是LL_FLASH_SetLatency。比如在VOS1档位、系统时钟80MHz时,Flash等待周期需要设置成4;在VOS3档位、时钟低到2MHz时,等待周期可以是0。这个表在STM32L4参考手册的"Flash program memory"章节里有明确列出。我强烈建议在时钟初始化代码里,每次改系统时钟后必须调用这个函数,不要只在启动时设置一次。
2. LL库的Flash API:为什么它的调用逻辑比HAL更"简朴"
2.1 核心API的语义
LL库全称Low Layer Library,设计思路是"轻封装"。它直接操作寄存器,函数命名很直观:LL_FLASH_Unlock、LL_FLASH_Lock、LL_FLASH_Program、LL_FLASH_Erase。这些函数本身不检查参数,也不做超时判断,它把控制权完全交给你。
实际使用中,最常见的调用顺序是:
LL_FLASH_Unlock(); /* 擦除指定页 */ LL_FLASH_Erase(LL_FLASH_TYPEERASE_PAGES, LL_FLASH_BANK_1, address); /* 等待操作完成 */ while (LL_FLASH_IsActivity() == LL_FLASH_ACTIVITY_BUSY); /* 写一个双字 */ LL_FLASH_Program(LL_FLASH_TYPEPROGRAM_DOUBLEWORD, address, data); /* 等待操作完成 */ while (LL_FLASH_IsActivity() == LL_FLASH_ACTIVITY_BUSY); LL_FLASH_Lock();注意LL_FLASH_Erase的第二个参数是Bank号,不是地址。很多刚上手的人容易忽略这一点,直接把地址传进去。LL_FLASH_Program的第三个参数是一个uint64_t类型的值,一次写8字节。
2.2 为什么LL库没有超时机制
HAL库的Flash操作函数自带超时,它内部用一个循环等待BSY标志位清零,超时会返回HAL_TIMEOUT。如果你在调试器里看HAL_FLASH_Program的源码,会发现它封装了一层复杂的状态机。而LL库的函数非常简单,它不维护任何内部状态,等待忙标志这件事要你自己做。
这算缺点吗?我反而觉得这是优点。在实时系统里,你本来就该自己控制等待策略:是轮询等待、还是进低功耗等待、还是去跑别的任务等Flash空闲了再回来补操作。HAL把超时写死,有时反而不知道它到底等了多久。LL库把控制权交还给工程师,代价是:你必须在擦写步骤里显式写出等待逻辑。很多人在这个环节少写了while循环,导致上一条擦除还没完成就开始写入,最后报PGAERR错误。
2.3 解锁和锁定的心智模型:LL_FLASH_Unlock不是可选的
L4的Flash控制器有一把"锁"。上电默认是锁住的,在给Flash控制寄存器写入配置之前,必须先执行LL_FLASH_Unlock()。这个解锁过程实际上是往FLASH_KEYR寄存器写入两个固定的Key值:0x45670123和0xCDEF89AB。LL_FLASH_Unlock()函数内部帮你写了这两个值,但它的副作用是,如果你重复调用两次,第二次是无效的,并不会导致错误。
操作完成后,一定要调用LL_FLASH_Lock()。这个不只是习惯问题。在OTA产品上,如果一直保持解锁状态,一旦程序跑飞,可能意外擦掉关键Flash区域,这是安全审计里常见的问题点。所以解锁和锁定要成对出现,而且要覆盖整个擦写临界区。
3. 手写一个参数存储模块:从擦除到写入的完整时序
3.1 准备:检查地址边界和对齐
先说一个常见误区:很多人拿到LL_FLASH_Program,直接传一个任意地址进去,结果写入返回的FLASH状态是PGAERR(Programming Alignment Error)。写L4的Flash,地址必须按双字对齐,也就是低3位必须是0。如果地址不满足对齐要求,Flash控制器直接拒绝操作。
页擦除同理。按页擦除时,传入的地址必须是该页的起始地址或者页内任意的地址都行吗?实测下来,LL_FLASH_Erase内部会对地址和页号进行处理,但如果地址落在两个Bank的交界区域,要特别注意Bank号的对应。更稳的做法是先计算出地址对应的页号,再确定Bank号。
下面这段代码是一个比较稳的擦除函数:
void flash_erase_page(uint32_t page_addr) { uint32_t bank = FLASH_BANK_1; if (page_addr >= FLASH_BANK2_BASE) { bank = FLASH_BANK_2; } LL_FLASH_Unlock(); LL_FLASH_Erase(LL_FLASH_TYPEERASE_PAGES, bank, page_addr); while (LL_FLASH_IsActivity() == LL_FLASH_ACTIVITY_BUSY); LL_FLASH_Lock(); }3.2 写入子程序:必须构造64位数据
接着看写入。LL_FLASH_Program一次只能写一个64位值。如果你要存一个结构体,结构体长度是24字节,最简单的方法是用memcpy把结构体拷到一个临时uint64_t数组里,再循环调用Program。但要注意结构体对齐问题——如果你结构体里既有uint8_t又有uint32_t,编译器可能会在内部塞padding,你直接按对象大小去算偏移可能会错。更推荐的做法是定义一个明确打包的结构体:
typedef struct __attribute__((packed)) { uint8_t magic; uint16_t version; uint32_t values[4]; } app_params_t;然后用下面的函数写入:
uint32_t flash_write_buffer(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t i; uint64_t data64; LL_FLASH_Unlock(); for (i = 0; i < len; i += 8) { memcpy(&data64, buf + i, 8); LL_FLASH_Program(LL_FLASH_TYPEPROGRAM_DOUBLEWORD, addr + i, data64); while (LL_FLASH_IsActivity() == LL_FLASH_ACTIVITY_BUSY); } LL_FLASH_Lock(); return addr; }注意这里memcpy的长度是8,因为L4的编程单位就是8字节。如果参数长度不是8的倍数,最后一个不足8字节的数据需要单独凑零,否则会报错。这个细节在调试时最容易忽略。
3.3 先擦后写是个铁律
Flash只能把1写成0,不能把0写成1。所以对同一个地址重复写入的时候,第二次写入大概率会失败,或者写入结果不是你预期的。在Flash操作里,必须先擦除再写入,而且擦除和写入之间要等擦除动作彻底完成。
完整参数存储逻辑通常是这样:
uint32_t flash_save_params(uint32_t page_addr, const app_params_t *params) { uint8_t page_buf[2048]; memcpy(page_buf, (void *)page_addr, sizeof(app_params_t)); /* 在RAM副本上修改 */ memcpy(page_buf, params, sizeof(app_params_t)); flash_erase_page(page_addr); return flash_write_buffer(page_addr, page_buf, sizeof(app_params_t)); }这样既保留了整页其他数据,又只修改了目标结构体。不过在操作前要确保page_buf足够大,并且页地址是2KB对齐的。
4. 实测中的两个大坑:同Bank死锁和缓存失效
4.1 复现一次HardFault
我最初在L431的Bootloader里写了一套Flash升级逻辑,最开始是直接在Flash里执行的升级函数,擦写目标页正好和代码不在同一Bank(目标应用在Bank2,Bootloader在Bank1)。那时一切正常。后来为了简化地址映射,把应用挪到了Bank1的高地址,Bootloader还留在Bank1低地址,问题就爆了:第一次擦除Bank1高地址页的时候,Flash控制器忙,CPU在Bank1里取指令,直接卡住。几分钟后看门狗复位,然后又进入升级模式,又卡住,反复循环。
这个问题的本质是:RWW只在两个Bank之间生效,同Bank内是不能同时读写Flash的。解决办法就是我上面说的两条路:要么把擦写函数放到RAM里执行,要么保持Bootloader和应用代码分处不同Bank。
4.2 把擦写函数放到RAM里执行
L4系列支持把函数放到指定RAM区编译。以GCC为例:
__attribute__((section(".ramfunc"))) void flash_erase_page_ram(uint32_t page_addr) { LL_FLASH_Unlock(); LL_FLASH_Erase(LL_FLASH_TYPEERASE_PAGES, FLASH_BANK_1, page_addr); while (LL_FLASH_IsActivity() == LL_FLASH_ACTIVITY_BUSY); LL_FLASH_Lock(); }然后在链接脚本里把.ramfunc段放到RAM中。在IAR环境下用__ramfunc关键字,在Keil环境下用__attribute__((section("RAMCODE")))。执行时,CPU从RAM取指令,擦写的是Flash,二者不冲突。
但还有个隐藏点:如果擦写函数内部调用了其他函数,被调用的函数也必须放到RAM里。LL库的LL_FLASH_Unlock本身是个小函数,编译器可能会内联,但保险起见,打开map文件确认一下所有被调用的函数都被放到了RAM区,或者把擦写相关函数统一放到一个.c文件里,整体控制。
4.3 ICACHE带来的脏读问题
L4内核带I-Cache,也就是指令缓存。CPU第一次从Flash读指令后,指令被缓存下来。如果代码在运行过程中对Flash做了擦写,缓存的指令可能还是旧的——虽然Flash擦写后数据已经变了,但CPU在下一次取指令时可能直接命中旧缓存。
这个问题在Bootloader跳转App时尤为明显。跳转前,最好执行一次缓存失效指令,例如:
SCB_InvalidateICache(); SCB_InvalidateDCache();同时还要确认ICACHE的使能状态,不要想当然。对于D-Cache也要同样处理,尤其是当你在做“DMA从Flash读取数据”这类操作时。D-Cache的脏数据会导致读出来的还是旧值。
4.4 用逻辑分析仪看BSY标志的时序
排查这类问题时,逻辑分析仪帮了大忙。把目标引脚在Flash操作前拉高,操作结束后拉低,配合上位机可以看到Flash擦写的实际耗时:L4擦除一页大概几个毫秒,写入一个双字大概几十微秒。重点观察是否有异常的长等待——如果BSY一直高,多半是对Flash的访问权限没配置好,或者是擦写时有其他总线主机在访问Flash。
也可以直接在调试器的Watch窗口监控FLASH->SR寄存器的BSY位,但轮询的频率够不够高、有没有因为断点而暂停,都会影响观察结果。最可靠的方法还是示波器拉引脚,实测最直观。
5. 选项字节编程:读写保护和BOOT引脚配置
5.1 选项字节到底改了什么
L4的Flash除了主存储区,还有一个独立的选项字节区域。它并不直接对应你的代码数据,而是控制Flash的行为:读保护等级(RDP)、写保护(WRP)、BOR复位电压等级、硬件BOOT0/BOOT1配置。很多产品在量产时要做读保护,防止固件被读出来直接抄板,那么就必须操作选项字节。
LL库提供了LL_FLASH_ProgramOptionBytes。这组API的底层逻辑比主Flash操作多一步:要先解锁选项字节区(往OPTKEYR写Key值),写完选项字节后还要触发一次选项字节加载(OBL_LAUNCH),新配置才能真正生效。
5.2 设置RDP等级和写保护
设置读取保护等级为1的代码大致是:
LL_FLASH_Unlock(); LL_FLASH_UnlockOptionBytes(); LL_FLASH_SetReadOutProtectionLevel(LL_FLASH_RDP_LEVEL_1); LL_FLASH_LockOptionBytes(); LL_FLASH_Lock();这里有个非常容易踩的坑:STM32L4的RDP等级一旦从Level 0直接升到Level 1,是单向过程,之后如果要回退到Level 0,Flash会被自动全片擦除。这就是ST刻意设计的防破解机制——防止你通过回退读保护来读取原固件。所以量产时设置RDP,一定要在产线固件烧写完、功能全部验证完的最后一步做,否则改一次就要重新回退一次,数据全没。
写保护(WRP)则是按页保护Bootloader区域。如果你的目标是防意外擦写,WRP比RDP更轻量。LL库设置WRP的API是LL_FLASH_EnableWriteProtection和LL_FLASH_LockOptionBytes,但要注意,写保护本身也可以通过回退选项字节来解除,它只是软件层面的防护,不涉及全片擦除,适合保护启动代码区。
5.3 操作选项字节时会执行全片擦除
这是最容易被忽略的重型副作用。当你调用选项字节编程接口时,ST的固件库在底层往往会自动执行一次Mass Erase(全片擦除)。所以绝对不能在设备运行过程中,随手调用一次选项字节编程,否则代码、参数、校准数据全部清光。
量产工具里设置RDP之前,通常会先把固件烧好、序列号写进去、MAC地址写进去,最后才设置读保护,这就是为了避免全片擦除导致工序返工。调试过程中,尽量不要频繁操作选项字节,每次操作完我都会用ST-Link Utility先读一下当前RDP等级,确认无误再烧录。
5.4 实际工程配置建议
在实际产品中,我一般建议这样划分:
| 区域 | 大小 | 保护方式 | 说明 |
|---|---|---|---|
| Bootloader | 32KB | 写保护 | 防止升级失败时被意外篡改 |
| 应用代码区 | 剩余Flash主体 | 无写保护 | 供OTA写入 |
| 参数存储区 | 末尾若干页 | 无写保护 | 按页擦写 |
| 选项字节 | - | RDP Level 1 | 量产最后一步开启 |
这样划分的好处是:即使应用区被写坏,Bootloader仍然完好,设备还能恢复;而参数区跟应用区分开,升级时不会把校准数据冲掉。
6. 从HAL切换到LL库:影响Flash操作最大的三个认知转变
6.1 锁是你自己管理的,不是库代管的
HAL库的FLASH_Program会在函数内部自动做Unlock/Lock,对使用者隐藏了这把"锁"。LL库则把这个流程完全透明化,你必须自己在操作序列里加Unlock和Lock。这算麻烦,但我更愿意把它看成"可控性提升"。
代价是什么呢?如果你在一个函数里解锁后,忘记在某个异常分支上锁,Flash就处于长期解锁状态。我在自己的代码里有一个不成文的规定:能在一个函数里完成的擦写动作,就只在一个函数里完成,不跨函数解锁。比如flash_save_params这个函数内部同时完成解锁、擦除、写入、锁定,外面再调用的代码不需要关心锁状态。
6.2 从"阻塞等待"到"显式等待"
HAL库的Flash编程函数会自己等待,LL库不会。这个差别看起来很小,实际影响很大。举个例子:你要擦除4页Flash,用HAL的HAL_FLASHEx_Erase,传入参数后它会一直等到全部擦完才返回。用LL库的话,你得写一个循环,逐页擦除,每擦一页就while等一次BSY,然后才能擦下一页。看起来代码多了,但好处是你可以灵活地在每页之间做其他处理,比如喂狗、检查串口缓冲区、查看是否收到取消指令。
在OTA升级这种耗时的过程中,能随时响应取消指令是很实用的能力。用户按一下取消键,整个升级过程能优雅地停止在“当前页擦除完成,下一页不擦”的状态,而不是卡在一整块Flash擦除中间无法中断。
6.3 Flash状态错误码的解读:PGAERR、WRPERR、SIZERR
LL库操作失败时通常不会返回错误码,它只在FLASH->SR寄存器里留一个标志位。所以出问题时的排查习惯要从"看函数返回值"变成"直接看状态寄存器"。
最常遇到的三个错误位:
- PGAERR(Programming Alignment Error):地址没有8字节对齐,或写入长度不足8字节又没凑零。
- WRPERR(Write Protection Error):写入的区域被写保护了,通常是因为WRP配置没解除。
- SIZERR(Size Error):写入字节数或操作类型不符合64位编程模式。
我自己的排查流程是:先查SR寄存器里有没有错误标志,有就复位相关错误位,再返回到代码检查地址对齐。不要一上来就怀疑LL库本身——LL库就是个寄存器封装,功能简单,逻辑出错率极低,问题大多在调用方。
6.4 一个收尾的小技巧
最后分享一个小技巧,用Flash存参数的时候,我习惯在参数前面加一个magic数和校验和。每次上电读参数时先核对magic,再算一遍CRC或累加和。这样即使Flash因为掉电导致写入半截数据,也能立刻发现参数无效,回到默认值。这个习惯帮我挡过好几次断电写入的现场事故。
LL库的Flash操作就是这样——API不多,逻辑不难,难点全在"你是不是真正理解了Flash的物理约束"。把架构、对齐、等待、缓存、保护这几件事逐一理清楚,STM32L4全系列的Flash功能用起来就不会再出幺蛾子了。
本文还有配套的精品资源,点击获取