简介:面向STM32L4系列微控制器开发者的内部闪存读写示例,特别适合使用LL库或HAL库进行底层寄存器配置的嵌入式工程师。作者基于STM32L452芯片完成调试验证,所有核心操作均以寄存器方式编写,因此迁移到HAL库环境时只需修改少量外设定义即可复用,极大降低重复开发成本。内容覆盖闪存读取、写入与擦除的完整流程,并针对STM32L471型号中出现的闪存页不连号问题给出具体解决方案,同时说明L4x1系列需要单独调整擦页函数中的页码,帮助读者避开同类芯片的兼容性陷阱。压缩包内共包含两个文件,一个C源文件与一个头文件,整体大小仅2KB,代码结构精简,便于阅读、移植和二次扩展。作者还在描述中记录了实际调试经历与排错要点,这些一手经验对入门者尤其有用。目前该资源已有1935人学习下载,正在调试STM32L4内部闪存模块的开发者可以将其作为简洁实用的参考代码包。
1. 读写内部 FLASH 没你想的那么简单:一个 STM32L4xx LL 库工程能解决什么
先说结论:在 STM32L4xx 上读写内部 FLASH,比在 F1 上最容易让人翻车的地方是“写进去了,复位后数据没了”。L4 的编程单元是 64 位双字,不是 F1 那种半字,你用 8 位或 16 位方式去写,表面上看数据写进去了,回读也正常,但只要一断电,那片区域很可能变成 0xFF。这个 rar 工程就是干这个的:用 LL 库加寄存器操作,把解锁、页擦除、双字编程、回读校验这四步串成一套完整的最小可用例程,并且把 L4 上特有的坑提前堵住。适合三类人:想在固件里保存校准参数或运行日志的、准备写 Bootloader 的、从 F1 迁到 L4 想快速对齐 Flash 差异的。项目不算大,但读完这套代码,你对 L4 的 Flash 时序、错误标志、页边界和缓存行为会有一个完整的认识。
2. 工程结构与初始化:LL 库封装下的一条 Flash 操作链路
2.1 目录结构:例程、驱动和文档怎么分工
这个工程的目录结构是典型的 CubeIDE 风格,核心代码集中在 Core 下面,驱动库放在 Drivers 里。我拆包后第一件事是看文件分工,找到哪些文件是拿来即用的,哪些是要改的。
flash_rw_test/ ├── Core/ │ ├── Inc/ │ │ ├── main.h // 串口、调试引脚宏定义 │ │ ├── flash_if.h // 外部接口声明:解锁/擦除/编程/回读 │ │ └── usart.h // 串口重定向声明 │ └── Src/ │ ├── main.c // 系统时钟、GPIO、串口初始化 + 命令入口 │ ├── flash_if.c // Flash 操作的四个核心函数 │ └── stm32l4xx_it.c // 中断向量(本例程只用轮询) ├── Drivers/ │ ├── CMSIS/ │ └── STM32L4xx_HAL_Driver/ // LL 库源码 └── flash_rw_test.ioc // CubeMX 工程配置实际要动手改的只有两个文件:flash_if.c里的 Flash 操作实现,和main.c里的测试命令调度。flash_if.h暴露了四个接口:解锁、页擦除、双字写入、回读校验,这个头文件设计得很干净,二次开发时可以把它当成一个独立的 Flash 驱动模块直接搬进新工程。
值得多说一句的是库的选择。这个工程用的是 LL 库而不是 HAL 库,原因是 LL 库的封装更薄,函数和参考手册里的寄存器描述基本一一对应。L4 的 Flash 操作本来就不复杂,用 HAL 库会发现HAL_FLASH_Program把很多细节藏住了,出错时看状态寄存器还要再翻一层。用 LL 库配合直接操作FLASH->CR和FLASH->SR,每次擦写失败都能当场定位到是序列错误、写保护错误还是地址对齐问题。
2.2 LL 库与外设初始化:先把时钟和 Flash 等待周期配对
在碰 Flash 之前,时钟树必须先配好。这里有个 L4 特有的注意点:Flash 的等待周期必须和系统主频匹配。主频跑 80MHz 时,Flash 等待周期必须是 4 个周期,配成 3 个,程序会在 Flash 取指时随机出错,表现出来就是“代码跑一会儿就 HardFault”,而且极难复现。
void SystemClock_Config(void) { /* 主频 80MHz 时,Flash 需要 4 个等待周期 */ LL_FLASH_SetLatency(LL_FLASH_LATENCY_4); /* HSE 作为 PLL 时钟源:HSE=8MHz -> PLL N=20 -> VCO=160MHz -> R/2=80MHz */ LL_RCC_HSE_Enable(); while (!LL_RCC_HSE_IsReady()) {} LL_RCC_PLL_ConfigDomain_SYS(LL_RCC_PLLSOURCE_HSE, LL_RCC_PLLM_DIV_1, 20, LL_RCC_PLLR_DIV_2); LL_RCC_PLL_Enable(); while (!LL_RCC_PLL_IsReady()) {} LL_RCC_SetSysClkSource(LL_RCC_SYS_CLKSOURCE_PLL); while (LL_RCC_GetSysClkSource() != LL_RCC_SYS_CLKSOURCE_PLL) {} /* 打开 Flash 预取,减少指令取指等待 */ LL_FLASH_EnablePrefetch(); }LL_FLASH_SetLatency和后边的 PLL 配置是配套的:主频升到 80MHz 而等待周期不跟着涨,Flash 阵列根本来不及把数据送到 CPU,等于拿一颗 10ns 的存储颗粒去喂一个 12.5ns 周期的 CPU,必然翻车。调试时如果遇到“烧录完第一次运行正常、复位后就挂”这类玄学问题,先回来查等待周期,别急着怀疑代码逻辑。
LL_FLASH_EnablePrefetch打开预取后,顺序执行的指令可以少等几个周期,但这和后面避坑章节里“写完回读旧值”的现象也有关系——缓存把旧指令缓存下来了,写 Flash 时不会自动失效。所以后面我还会再提到它。
2.3 操作时序总览:解锁 → 擦除 → 编程 → 锁回
L4 的 Flash 控制器把整个操作流程卡得非常死,顺序错了直接报错。先用一张表把整条链路铺开,后面每个函数都按这个时序来写。
| 阶段 | 关键寄存器/操作 | 对应 LL 库 APL | 出错时的标志位 |
|---|---|---|---|
| 解锁 | FLASH->KEYR 写入 KEY1、KEY2 | LL_FLASH_Unlock | 无(写错 KEY 则 LOCK 不清) |
| 擦除 | CR.PER=1,CR.PNB 写页号,CR.STRT=1 | LL_FLASH_PageErase | PGSERR / WRPERR |
| 编程 | CR.PG=1,向目标地址写 uint64 | LL_FLASH_Program | PGAERR / PGSERR |
| 等待 | SR.BSY 清零 | LL_FLASH_IsActiveFlag_BSY | 超时需复位 |
| 加锁 | CR.LOCK=1 | LL_FLASH_Lock | 无 |
| 清标志 | SR 写 0 清除 | LL_FLASH_ClearFlags | 无 |
LL 库把解锁和等待 BSY 都封装好了,但有一点值得注意:LL_FLASH_Unlock只负责写 KEYR,它不会帮你清错误标志。如果上一次操作在错误状态下退出,下一次直接擦除极大概率报 PGSERR。所以我在工程的flash_if.c里写了一个统一的准备函数,每个操作都先LL_FLASH_IsActiveFlag_BSY确认空闲,再LL_FLASH_ClearFlags清掉所有残留标志,最后才走解锁。这一步看着多余,实际在连续擦写几千次做压力测试时,能省掉一半的排查时间。
整个流程的本质是:先把 Flash 控制器从“锁定”状态释放,然后告诉它我要擦哪个页或者往哪个地址写,启动后轮询 BSY 位直到操作结束。这四步里最容易被忽略的是“擦除和编程是互斥模式”——CR 寄存器里的 PG 位和 PER 位同时只能有一个为 1,如果上一步是擦除,下一步直接编程而不把 PER 清掉,写操作会静默失败。
3. 核心代码拆解:从解锁到回读校验的完整实现
3.1 解锁与加锁:两个 KEY 打开寄存器大门
L4 的 Flash 控制寄存器 CR 默认是锁定状态,直接改 CR 会被硬件忽略。解锁的方式固定:往 KEYR 依次写入两个 32 位密钥,顺序不能反,数值也不能错,否则控制器会一直锁着。这套机制和 F1 差不多,但 L4 的密钥更严格,必须两条都写入后才能解锁。
#define FLASH_KEY1 0x45670123UL #define FLASH_KEY2 0xCDEF89ABUL void Flash_Unlock(void) { /* 写密钥前先等 BSY 清空,避免和上一次操作打架 */ while (LL_FLASH_IsActiveFlag_BSY()) {} WRITE_REG(FLASH->KEYR, FLASH_KEY1); WRITE_REG(FLASH->KEYR, FLASH_KEY2); /* 解锁成功的判断标准:LOCK 位被硬件清 0 */ if (READ_BIT(FLASH->CR, FLASH_CR_LOCK) != 0) { Error_Handler(); /* 密钥错误或时序异常 */ } }两个 KEY 必须分两次写入,不能合并成一个 64 位写。我第一次写 F1 的解锁函数时偷懒,想用一条指令把两个数“一起塞进去”,结果 LOCK 位纹丝不动。查看参考手册会发现 KEYR 是一个 32 位寄存器,硬件逻辑就是在两次写入的边界上判断“接下来是否进入解锁状态”,一次 64 位写反而打断了这个状态机。
加锁就简单了,CR 寄存器的 LOCK 位置 1 即可。日常习惯是:每个完整操作结束后立刻加锁,哪怕马上又要做下一次操作。这样即使程序跑飞,误操作 CR 也不会触发意外擦写。
void Flash_Lock(void) { SET_BIT(FLASH->CR, FLASH_CR_LOCK); }3.2 页擦除:PNB 选页、STRT 点火、BSY 等待
页擦除是 L4 上最容易出错的环节,因为它的页号不是直接写在地址寄存器里,而是要计算成“页索引”填到 CR 的 PNB 段。L4 的页大小一般是 2KB,页号的算法是(地址 - 0x08000000) / 0x800。
void Flash_ErasePage(uint32_t PageAddr) { uint32_t pnb = 0; Flash_Unlock(); while (LL_FLASH_IsActiveFlag_BSY()) {} LL_FLASH_ClearFlags(); /* 清掉残留错误标志,防止 PGSERR */ /* 地址合法性检查:不能超过 Flash 末尾 */ if ((PageAddr < FLASH_BASE) || (PageAddr >= FLASH_BASE + FLASH_SIZE)) { return; } pnb = (PageAddr - FLASH_BASE) / FLASH_PAGE_SIZE; /* 关闭编程模式,否则擦除命令无效 */ CLEAR_BIT(FLASH->CR, FLASH_CR_PG); SET_BIT(FLASH->CR, FLASH_CR_PER); MODIFY_REG(FLASH->CR, FLASH_CR_PNB, pnb); /* 置 STRT 启动擦除,硬件会自动清除 STRT 位 */ SET_BIT(FLASH->CR, FLASH_CR_STRT); /* 等待 BSY 清空,擦除 2KB 页一般需要几十毫秒 */ while (LL_FLASH_IsActiveFlag_BSY()) {} if (LL_FLASH_IsActiveFlag_ProgrammingError()) { Error_Handler(); /* 典型的 PGSERR:模式位配置错误 */ } LL_FLASH_ClearFlags(); }MODIFY_REG(FLASH->CR, FLASH_CR_PNB, pnb)是做“读-改-写”的推荐做法,只动 PNB 段,不碰其他位。这里有个反直觉的点:PNB 段在 L4 的参考手册里叫“页号”,不是“页地址”。有人把PageAddr直接塞进 PNB,结果擦除的不是目标页,而是地址值对应的那几页,数据就丢得莫名其妙。
擦除命令真正“点火”的是 STRT 位。这个位是自清零的,擦除结束后硬件自动把它拉低,所以不用手动清。等待 BSY 的时间不能省,擦除过程中如果去读 Flash,会拿到不确定数据,严重的会导致 CPU 从擦除中的页取指失败。
还有一点,LL_FLASH_ClearFlags要放在擦除启动之后、等待 BSY 之前还是之后,不同版本的库行为有差异。我在这个工程里统一放在启动前和结束后各一次,保证无论上次处于什么状态,这次操作都从干净状态开始。
3.3 双字编程:64 位对齐的 LL_FLASH_Program
L4 的编程单位是 64 位双字,这意味着每次写入必须是 8 字节对齐,数据也要凑成 8 个字节的整数倍。这是 L4 和 F1 最大的区别,也是新手在 L4 上最常踩的坑。
void Flash_WriteDoubleWord(uint32_t addr, uint64_t data) { Flash_Unlock(); while (LL_FLASH_IsActiveFlag_BSY()) {} LL_FLASH_ClearFlags(); /* 先关擦除模式,再开编程模式,两者互斥 */ CLEAR_BIT(FLASH->CR, FLASH_CR_PER); SET_BIT(FLASH->CR, FLASH_CR_PG); /* 关键:直接向 Flash 地址写 64 位数据 */ *(volatile uint64_t *)addr = data; /* 等待写入完成 */ while (LL_FLASH_IsActiveFlag_BSY()) {} if (LL_FLASH_IsActiveFlag_ProgrammingError()) { Error_Handler(); /* 地址不对齐或 PNB 越界会触发 PGAERR */ } LL_FLASH_ClearFlags(); }有人会问:为什么不用LL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr, data)?LL 库确实封装了这个函数,内部做的也是“写 CR.PG 位 + 向目标地址写双字”。我在工程里保留寄存器直写版本,是为了让你看清LL_FLASH_Program背后就这两步动作。当你调试时发现写入次数多了一倍,基本可以断定是 LL 库里把双字拆成了两次 32 位写,直接对照寄存器操作能少踩一层封装。
批量写一个缓冲区的逻辑也不复杂,核心是把两个 32 位字拼成一个 64 位双字:
void Flash_WriteBuffer(uint32_t addr, uint32_t *buf, uint32_t len) { uint32_t i; /* 8 字节对齐是硬性要求,不满足直接返回 */ if ((addr & 0x07) != 0) { return; } if ((len & 0x01) != 0) { /* 长度必须是偶数个 uint32 */ len = (len + 1) & ~1U; /* 补一个字的 0xFF 再对齐 */ } for (i = 0; i < len; i += 2) { uint64_t d = ((uint64_t)buf[i + 1] << 32) | buf[i]; Flash_WriteDoubleWord(addr + i * 4, d); } }这里len是uint32_t元素的个数,必须是偶数。如果你的数据是结构体,记得把结构体大小对齐到 8 字节的倍数,或者手动补字节。我一般会在调用前加断言(sizeof(struct) % 8 == 0),编译期就能发现问题。
写入的数据本身没有特殊要求,但0xFF和0x00有讲究:擦除后的 Flash 是全 1,也就是0xFF。往已经擦除的地址写0xFF是“无效写”,不会真的改变电荷状态,但也不会报错。所以校验回读时不要拿“和源缓冲区完全相同”当唯一标准,要允许擦除后空白的区域保持0xFF。
3.4 回读与校验:直接寻址,别忘了 volatile
读 Flash 不需要解锁,它和普通内存访问一样,直接拿指针读就行。但有一个细节不能省:用volatile限定指针。编译器优化时,如果它认为“这个地址上次写过了,内容应该还在寄存器里”,会悄悄把你的读操作优化掉,让你永远读到旧值。
void Flash_ReadBuffer(uint32_t addr, uint32_t *buf, uint32_t len) { for (uint32_t i = 0; i < len; i++) { buf[i] = *(volatile uint32_t *)(addr + i * 4); } }回读校验是验证写入是否成功的唯一可靠手段。我习惯写一个比对函数,逐字节比较,出错了能定位到第几个字:
int Flash_VerifyBuffer(uint32_t addr, uint32_t *buf, uint32_t len) { for (uint32_t i = 0; i < len; i++) { if (*(volatile uint32_t *)(addr + i * 4) != buf[i]) { return -1; /* 返回第一个出错的字偏移 */ } } return 0; }校验失败分两种:第一种是数据完全不同,多半是擦除没成功,或者写到了错误地址;第二种是低字节全对、高字节全是0xFF,这个现象指向“只写了 32 位,高 32 位没有编程”。L4 的双字编程要求一次性写满 8 字节,如果只写低 4 字节,硬件会认为这是一个未完成的双字,复位后整个 8 字节区域可能被丢弃。
读操作还有一个隐藏风险:L4 的 Flash 接口带有 ART 自适应实时加速器,会缓存最近访问的指令和数据。如果写入前代码刚从这个 Flash 区域读到过指令,写入后立刻回读,可能命中缓存读回旧值。规避办法见避坑章第 4 条,工程里我把擦写函数放在了 RAM 执行,从根上绕开了这个缓存问题。
4. 避坑笔记:L4 上擦写 Flash 最容易翻车的 5 个现场
4.1 擦除偶发失败,PGSERR 标志被置位
现象:连续对同一个页做“擦除 → 写入 → 再擦除”循环,几十次之后第二次擦除偶尔失败,进入 Error_Handler,查状态寄存器发现 PGSERR 为 1。
原因:上一次编程结束时,PGAERR 或 EOP 标志还残留在 SR 里。下次进入擦除流程时,如果只做了 WRITE_REG 解锁没清标志,硬件会认为当前是“错误状态”,拒绝接受新的擦除命令并把 PGSERR 置位。
解决:每个操作开始前清一次标志,结束后再清一次。我在Flash_Unlock()之后、配置 CR 之前加入LL_FLASH_ClearFlags(),并在擦除和编程函数末尾追加同样的清理。顺序上要“先清标志再置位模式”,反了的话这次操作依然会失败。
这个问题的难排查之处在于它不是必现的,和上次操作是否产生过标志位有关。第一次操作从复位后的干净状态开始,永远正常,只有走到第二次才暴露。
4.2 只写 4 字节,回读正常,复位后数据蒸发
现象:调用Flash_WriteDoubleWord往某个地址写了一个uint32_t,回读正确。断电重启,该地址变回0xFFFFFFFF,之前的数据像从来没写进去一样。
原因:L4 的编程物理单位是双字,也就是 8 字节。只写低 4 字节时,Flash 控制器可能只是把数据暂存在缓冲器里,等待第二个 4 字节凑齐后一起编程。复位发生时,未完成的双字被丢弃,整行恢复成全 1。
解决:所有写入一律按 8 字节对齐、按双字写满。写单字数据时,把目标地址的前后各 4 字节读出来拼成一个uint64_t,再整块写回。这个“读-改-写”策略牺牲一点效率,但保证每个双字都是完整编程。
4.3 擦除地址指向自己正在执行的页,程序卡死
现象:调用擦除函数后,程序像被抽走了地板,停在某个中断或轮询里再也不动。在线调试发现 PC 指针停在 Flash 擦写等待循环里,但 BSY 一直不消。
原因:这个工程的测试地址如果配成0x08000000,而当前代码恰好也在0x08000000附近运行,擦除动作把正在执行的指令物理清掉了,CPU 下一步取指自然取不到内容。
解决:第一,测试地址选在代码段之外,比如0x0803F000;第二,把整个flash_if.c中的擦除、编程函数放到 RAM 执行。在函数定义前加__attribute__((section(".ramfunc"))),并在链接脚本里增加一个ramfunc段,编译后这些函数的代码会从 Flash 拷贝到 RAM,函数运行时取指走 RAM,避开 Flash 擦除的总线冲突。
__attribute__((section(".ramfunc"))) void Flash_ErasePage(uint32_t PageAddr) { /* 函数体在 RAM 中执行,擦除 Flash 时不会取指失败 */ }从 F1 过来的人容易忽略这个差异。F1 的 Flash 接口同样存在这个问题,但 F1 的擦除页小、时序快,再加上大多数测试地址都避开代码段,很少踩雷。L4 的擦除耗时几十毫秒,只要地址选错,卡死的概率比 F1 高一个量级。
4.4 回读旧值:ART 缓存和编译器双重欺骗
现象:写完了双字,回读函数读到的还是写入前的旧数据,但用调试器 Memory View 看 Flash 区域,数据明明已经更新。
原因:两个因素叠加。一是编译器没有volatile限定,回读被优化成从寄存器拿值;二是 L4 的 ART 加速器会缓存 Flash 内容,写操作绕过了缓存直接落到 Flash 阵列,但读操作命中了缓存旧行。
解决:回读指针必须volatile,这是第一道保险。第二道保险是库里的SCB_InvalidateICache()和SCB_InvalidateDCache(),在写入完成后调用失效缓存再回读。工程中我把擦写函数放在 RAM 后,实际上减少了“代码区缓存干扰”的路径,但没有彻底消除数据缓存的可能,所以在Flash_WriteDoubleWord尾部保留了缓存失效调用。
这个问题最坑的地方是:你无法用普通的回显逻辑判断“回读旧值”还是“真没写进去”,必须看调试器的 Memory View 才能区分。
4.5 IDE 每次烧录都全片擦除,参数区被清空
现象:程序调试正常,但每次重新烧录固件,之前保存在 Flash 参数区的数据就丢了。不是代码问题,是工具链问题。
原因:许多 IDE 的默认下载算法是“Full Chip Erase”,把整个 Flash 全部擦掉再写。你的程序如果只在启动时读取参数区而不主动初始化它,烧录后读到的自然全是0xFF。
解决:在调试器配置里把擦除模式改成“Sectors Only”或者自定义算法区域,只擦应用代码占用的扇区,保留参数区。Keil MDK 的 Flash Download 配置里选“Erase Sectors”,CubeIDE 的 Debug Configuration 里把 Flash 地址范围从0x08000000和0x0803FFFF改为实际代码段范围,都不要包含参数页。
我这个工程特意把参数区设计在 Flash 尾部,就是为了把“代码区”和“数据区”物理隔开,这样既可以在 IDE 里配置只擦代码区,也可以在 Bootloader 里跳转时不用挪数据。
5. 跑通验证:烧录、串口命令、Memory View 确认数据
5.1 环境准备:CubeIDE、LL 库和烧录器
这个工程用 STM32CubeIDE 打开.ioc文件即可,它会自动关联 LL 库源码,不需要手动添加 HAL 库或寄存器定义文件。如果你平时用 Keil MDK,也能直接把Core目录下的源文件丢进新工程,驱动库路径指到Drivers/STM32L4xx_HAL_Driver/src里的stm32l4xx_ll_flash.c和stm32l4xx_ll_utils.c即可。
我开始跑这个工程用的是一块 L4 系列的核心板,板上 ST-Link 直接连 SWD,串口 PA9/PA10 通过 USB 转 TTL 接电脑。烧录前确认目标芯片型号和 Flash 容量,默认工程配置的是 512KB Flash 的型号。如果你用的是 256KB 或 1MB 的芯片,需要把FLASH_SIZE宏改对,否则擦除地址会越界。
串口重定向用的是最传统的方案:在main.c里重写fputc,底层输出到 USART1。这样printf就能直接打日志。默认波特率 115200、8N1,测试命令格式设计得很简单,方便用任意串口工具输入。
5.2 串口命令入口:一个简单的字符命令调度
工程在main.c里放了一个极简的串口命令解析,不依赖 RTOS 和第三方 CLI 库。命令分四个:擦除、写入、回读、校验。我把测试地址定义成宏,默认0x0803F000,这是 512KB Flash 芯片 Bank1 的最后一页,远离代码段,也不会被 IDE 的 Sector 擦除误伤。
#define TEST_ADDR 0x0803F000UL static const uint32_t test_data[8] = { 0x11111111, 0x22222222, 0x33333333, 0x44444444, 0x55555555, 0x66666666, 0x77777777, 0x88888888 }; void FlashCmd_Handler(char *cmd) { if (strcmp(cmd, "erase") == 0) { Flash_ErasePage(TEST_ADDR); printf("ERASE @0x%08X DONE\r\n", TEST_ADDR); } else if (strcmp(cmd, "write") == 0) { Flash_WriteBuffer(TEST_ADDR, (uint32_t *)test_data, 8); printf("WRITE 8 WORDS DONE\r\n"); } else if (strcmp(cmd, "read") == 0) { uint32_t buf[8] = {0}; Flash_ReadBuffer(TEST_ADDR, buf, 8); for (int i = 0; i < 8; i++) { printf("0x%08X ", buf[i]); } printf("\r\n"); } else if (strcmp(cmd, "verify") == 0) { if (Flash_VerifyBuffer(TEST_ADDR, (uint32_t *)test_data, 8) == 0) { printf("VERIFY PASS\r\n"); } else { printf("VERIFY FAIL\r\n"); } } else { printf("CMD NOT SUPPORT\r\n"); } }串口接收用最简单的轮询加换行判断,一次读取一个字节存入缓冲,遇到\r\n就调用FlashCmd_Handler。命令不区分大小写,测试时输入erase回车、write回车、read回车,一套完整的擦写读链路就通了。
这里有一个执行顺序的讲究:必须先擦除再写入,不能在未擦除的页上直接写。Flash 只能把1写成0,不能把0写成1。向已经写过数据的地址再次写入,除非地址本来是全 1,否则结果不是你期望的新数据,而是旧数据和写入数据按位与之后的“乱码”。所以write命令内部不自动擦除,而是把擦除作为独立操作暴露给用户,让你强制记住这个顺序。
5.3 预期输出与 Memory View 验证
烧录后打开串口,输入erase、write、read、verify,正常输出如下:
ERASE @0x0803F000 DONE WRITE 8 WORDS DONE 0x11111111 0x22222222 0x33333333 0x44444444 0x55555555 0x66666666 0x77777777 0x88888888 VERIFY PASS如果把read放在write之前执行,输出应该是 8 个0xFFFFFFFF,这正好验证了擦除操作是生效的。
用调试器确认硬件层面数据也没问题:进入 Debug 模式,打开 Memory 窗口,地址栏输入0x0803F000,你应该看到十六进制数据区里 8 个字依次排列。注意字节序:0x11111111在内存里显示为11 11 11 11,而0x22334455会显示为55 44 33 22,这是小端字节序的正常表现,别误判成写反了。
最后一个实验是把开发板断电再上电,再输入read,数据还在。这一步测试的是 Flash 的非易失性,是整个项目存在的前提。如果断电后数据丢失,回到避坑章第 4.2 节查“单字编程未完成”的场景。
6. 二次开发:从测试例程改造成参数存储与 Bootloader
6.1 存结构体参数:把数组换成可落地的业务数据
测试代码里用的是固定数组,实际项目里要存的往往是一个结构体:传感器校准值、设备序列号、运行计数器。改造思路是把结构体首地址转成uint32_t*传给Flash_WriteBuffer。这里有两个要点。
第一,结构体大小必须对齐到 8 字节。C 语言的结构体有自然对齐规则,编译器会在字段间插入 padding,所以编译期用sizeof检查就行,assert里判断sizeof(MyParam) % 8 == 0,不满足就手动加一个uint32_t reserved字段补位。
typedef struct { uint32_t seq; /* 运行次数 */ float kp; /* PID 参数 */ float ki; uint16_t crc; /* 数据校验 */ uint16_t reserved; /* 手动补齐 8 字节对齐 */ } AppParams;第二,写参数前先读旧值、和要写的新值比较,内容相同就跳过擦写。Flash 的擦写寿命约十万次,参数区如果每次开机都写一遍,一年下来就超了。我一般维护一个“脏标记”,只有参数真正变化时才触发擦写流程。
6.2 跳转 App 的检查清单:关中断、复位外设、重新设栈
把这套 Flash 读写能力往前推一步就是 Bootloader。Bootloader 把接收到的固件写入 Flash 参数区或 App 区,之后跳转到 App。跳转不是简简单单的函数指针调用,必须按固定步骤来,否则十个里面九个 HardFault。
typedef void (*AppEntry)(void); void JumpToApp(uint32_t app_addr) { uint32_t msp_value = *(volatile uint32_t *)app_addr; /* 检查栈顶地址是否合法:应在 RAM 地址范围内 */ if ((msp_value & 0xFFFF0000) != 0x20000000) { return; /* 非法固件,拒绝跳转 */ } __disable_irq(); /* 第一步:关全局中断 */ SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; /* 复位外设:USART、DMA、定时器等都要 DeInit */ USART1->CR1 = 0; DMA1_Channel1->CCR = 0; __DSB(); __ISB(); /* 设置主栈指针,指向 App 向量表首字 */ __set_MSP(msp_value); /* 跳转:取向量表第二个字作为复位入口地址 */ AppEntry app = (AppEntry)(*(volatile uint32_t *)(app_addr + 4)); app(); /* 如果执行到这里,说明跳转失败 */ while (1) {} }跳转前最容易漏的是关闭自己用过的外设中断。如果 Bootloader 里开了串口接收中断,跳转时没关,App 启动后第一次串口中断会把向量表跳到 Bootloader 的中断处理函数,程序当场跑飞。我在 Bootloader 项目里吃过这个亏,从那以后每次跳转前都强制关中断、清中断挂起位、把用过的外设寄存器手动清零,一条条过。这套检查清单现在也贴在我的工程模板注释里。
这个 rar 工程本身不含完整的 OTA 传输逻辑,但把擦写和跳转两块地基打好之后,加上一个串口或无线收包模块就是完整的升级方案。希望这套代码能帮你少踩几个坑。
本文还有配套的精品资源,点击获取