简介:一套基于STM32F103芯片内部Flash读写的完整例程,适合嵌入式入门开发者及需要在项目中实现固件升级、参数存储、日志记录等功能的工程师。例程基于HAL库编写,完整涵盖了Flash解锁、扇区擦除、数据编程、读取校验、再次上锁的标准流程,并演示了通过地址映射直接读取Flash内容的实现方式。借助此例程,可快速掌握内部Flash的分区结构、各扇区容量差异及擦除前必须编程的注意事项,理解操作时序后便能灵活移植到实际产品中。资源包共72个文件,以29个H头文件与28个C源代码为主体,另有汇编启动文件、Keil工程文件、说明文档与芯片数据手册,整体压缩后仅948KB,文件划分清晰,便于在工程中直接复用或对照学习。例程中初始化代码和操作函数注释清晰,便于逐段理解Flash控制器的行为。目前已有10760人次学习浏览,是社区中较受关注的STM32Flash读写参考案例。除了可复现的读写代码,还提供数据手册等配套资料,能帮助使用者结合引脚定义和串口设计完成整体调试验证,减少因误擦除或地址越界导致的固件异常。1. STM32F103 内部 Flash 读写:掉电不丢参数的最后一公里做温控器的时候,要存一组校准系数。最开始用的外部 EEPROM,结果焊反一次、换上后地址漂移一次,项目差点砸在存储上。后来把参数写进 STM32F103 内部 Flash,一个通用接口就能搞定,断电十年数据还在。这份例程就是干这个事的:把内部 Flash 的解锁、擦除、写入、读取封装成几个简单函数,内置地址越界检查和掉电恢复演示,能直接抄进自己的工程。它适合所有用 F103 做小家电、传感器节点、工控板,又不想额外加存储芯片的开发者。本篇不会只讲概念,会带你从寄存器看到实战代码,最后给出一套能落地的二次改造方案。2. 硬件基础与寄存器视角:为什么 Flash 写入不能像 RAM 一样直接赋值2.1 存储结构:主存储块、系统存储区与选项字节STM32F103 的内部 Flash 被分成三个区域:主存储块、系统存储区、选项字节区。我们日常读写用的是主存储块,也就是存放程序代码的地方。主存储块按页划分,不同型号页大小不一样:小容量产品每页 1K 字节,中容量产品每页 1K 字节,大容量产品每页 2K 字节。例程默认按大容量 512K 系列来写,所以页擦除单位是 2K,如果你用的是中容量 64K 芯片,记得把页大小宏定义改成 1K。Flash 写入有个物理特性:只能把 1 写成 0,不能把 0 写成 1。想把某一位从 0 恢复成 1,唯一办法是对整页执行擦除。这决定了任何 Flash 写入操作都至少需要两步——先判断目标位置是否是 0xFF,如果不是就得先擦除整页,然后再写。很多新手在这翻车:直接往非空地址写数据,然后读出来的数据跟预期完全不对。这不是代码逻辑错,是没理解 Flash 的位映射关系。系统存储区是芯片出厂时固化 BootLoader 的区域,用户一般碰不到。选项字节区控制读保护、看门狗、启动模式等,例程里没有操作它,因为一旦写错可能锁死芯片。读写程序时只关心主存储块的末尾几页,绝对不要碰写有代码的前部区域,否则程序直接跑飞。例程默认把参数放在倒数第二页,也就是主存储块的末尾,避开代码空间。2.2 关键寄存器与状态标志:BSY、PGERR、WRPRTERRSTM32F103 在标准外设库里提供了 flash 操作函数,但为了排查问题还是得认识几个关键寄存器。控制寄存器 FLASH_CR 里的 PG 位表示编程(写入)模式,PER 位表示页擦除模式,STRT 位用来触发操作;状态寄存器 FLASH_SR 里有 BSY 忙标志、PGERR 编程错误标志、WRPRTERR 写保护错误标志。绝大多数异常都能从这三个标志里找到答案。典型流程是这样:先调用 FLASH_Unlock() 解锁 Flash,然后等待 FLASH_FLAG_BSY 清零,再配置 CR 寄存器选择页擦除,写入目标地址,置位 STRT 开始擦除,等待擦除完成。写入半字时同理,但要注意 CR 寄存器必须再配置为编程模式。标准库把这一串封装成了 FLASH_ErasePage 和 FLASH_ProgramHalfWord,你不用自己位操作,但要知道内部要等 BSY。实际操作中最重要的经验是:在操作 Flash 期间,任何中断、读写 Flash 的操作都会导致 CPU 停摆,因为 Flash 控制器正忙着擦写。这意味着不要在 Flash 擦写过程中响应中断,尤其是不要在中断服务函数里写 Flash。如果你用了实时操作系统,要先把调度器锁住,或者把擦写过程放到一个最低优先级任务里,并且用临界区保护。这属于血泪经验,后面避坑章节会展开讲。2.3 例程工程的文件分工:一眼就能抓重点例程的工程结构不复杂,核心就三个文件:flash_if.c 是功能封装层,flash_if.h 是对外接口,main.c 是演示逻辑。配套的还有一个参数结构体定义文件 param.h,用于把散落的写入数据组织成结构体批量存储。如果你打开工程,第一眼应该去找 flash_if.c 里的三个函数:Flash_Init、Flash_WritePage、Flash_ReadPage,其他都是辅助。整个系统的调用关系是:应用层只需要调用 Flash_WritePage(FIRST_USER_ADDR, paramBuff, len) 就能把一段数据写进指定页;需要读取时调用 Flash_ReadPage 从同一地址把数据搬回 RAM。擦除动作被藏在写入函数内部,先擦后写。这样的设计让调用方完全不用关心 Flash 控制器的细节,即使不懂寄存器也能跑通。工程里还单独放了一个 startup_time 文件,用来记录从复位到读取成功的时间戳,这个时间戳是给掉电恢复演示用的。实际项目中你可以把它换成生产批次号、设备序列号或者校准日期。文件结构如下表所示。文件名职责关键点flash_if.cFlash 擦、写、读封装地址对齐检查,页擦除,半字写入flash_if.h对外宏与函数声明定义 Flash 基地址、页大小、起始页param.h参数结构体定义定义存储字段及默认值main.c演示逻辑写后读回、断电恢复验证3. 核心代码拆解:从擦除到写入的完整流程3.1 解锁流程:先给 Flash 控制寄存器放权STM32F103 的 Flash 控制器默认是锁定的,直接写 CR 寄存器没有反应。必须先向 KEYR 寄存器依次写入 0x45670123 和 0xCDEF89AB 这两个钥匙值解锁。标准库把这一步封装在 FLASH_Unlock() 里,但我们要做的不是盲目调用,而是保证每次操作前都处于解锁状态,并在操作完成后重新上锁。下面这段代码展示了例程的初始化与解锁保护。注意它没有用 VDD 供电判断,因为内部 Flash 编程电压由内部调压器提供,写代码不需要关心。uint8_t Flash_Init(void) { if (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { return 1; // 如果正在忙,先不操作 } FLASH_Unlock(); // 解锁 Flash 控制器 return 0; }FLASH_GetFlagStatus 读取的是 FLASH_SR 寄存器中的 BSY 位。如果返回 SET,说明控制器正在执行擦除或写入,此时任何新的操作都会被忽略,甚至触发错误标志。这里的返回值 1 表示初始化失败,调用方可以在应用层重试。实际使用中我一般会再加一个超时计数,防止极端情况下 BSY 一直不清零导致程序卡死。3.2 页擦除函数:整页归零才是 Flash 的“写前准备”写入前必须先擦除。擦除以页为单位,例程的页擦除函数接收一个页起始地址,比如大容量系列的 2K 一页,地址必须是 2048 的整数倍。如果不是,函数会直接返回错误,这是防止越界和错位的第一道防线。static uint8_t Flash_ErasePage(uint32_t pageAddr) { uint16_t timeout = 0; /* 检查地址是否按页大小对齐,2K 页要求低 11 位为 0 */ if ((pageAddr % FLASH_PAGE_SIZE) != 0) { return 1; } /* 等待 Flash 空闲,最多等 5000 次循环 */ timeout = 5000; while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { if (timeout-- == 0) return 2; // 超时返回错误码 2 } FLASH_ErasePage(pageAddr); // 标准库页擦除 timeout = 5000; while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { if (timeout-- == 0) return 3; } return 0; }FLASH_ErasePage 是标准外设库函数,内部会完成 PER 位置 1、写地址到 AR 寄存器、STRT 置位、等待 EOP 等操作。这里在调用前后都加了超时等待,超时时间用循环次数做延时,而不是定时器,因为此时 CPU 可能还处于中断频繁的环境,定时器本身可能被干扰。实际中页擦除典型耗时在 20 毫秒到 40 毫秒之间,具体取决于芯片型号和时钟配置。3.3 写入半字与全字封装:地址对齐是硬门槛STM32F103 的 Flash 编程最小单位是半字,也就是 16 位。标准库的 FLASH_ProgramHalfWord 一次只能写 2 字节。实际业务数据通常是 uint8_t 数组或者结构体,所以例程在此基础上做了一层缓冲写入封装:先用临时数组把待写数据拼成半字,再调标准库接口逐半字写入。static uint8_t Flash_WriteWords(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; uint16_t halfWord; /* 地址必须半字对齐,否则无法编程 */ if ((addr % 2) != 0) return 1; /* 长度向上取偶,避免奇数长度丢尾巴 */ if (len % 2) len++; for (i = 0; i < len; i += 2) { halfWord = buf[i] | (buf[i + 1] << 8); // 低字节在前 FLASH_ProgramHalfWord(addr + i, halfWord); /* 等待完成,逐个半字校验写入结果 */ while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); if ((*(volatile uint16_t *)(addr + i)) != halfWord) { return 2; // 写入不回读一致,说明失败 } } return 0; }这段代码把 buf 里的字节按小端拼成半字,然后逐个写入。注意参数 len 是按字节数传入的,但因为半字是 2 字节,所以循环步长是 2。如果传入长度是奇数,这里会向上取偶,多写一个字节,对应的缓存缓冲区也要留足空间。代码中每次写完立即做回读校验,这是很多例程没有的,它能第一时间暴露写保护或者电压不稳问题。3.4 读取接口与参数恢复:没有擦除,只有读读 Flash 和读 RAM 一样简单——直接按指针访问地址即可。例程把读取封装成 Flash_ReadPage,纯粹是做了一个地址映射和长度拷贝,没有任何控制器操作。但要注意,读取时不能越界访问没映射的 Flash 地址,否则会 HardFault。void Flash_ReadPage(uint32_t addr, uint8_t *buf, uint16_t len) { const uint8_t *src = (const uint8_t *)addr; while (len--) { *buf++ = *src++; } }这里的读取没有加任何保护,因为它直接操作地址空间。如果 addr 落在未定义的存储区,比如大于芯片 Flash 总容量的地址,执行时即刻触发总线错误。所以在调用层,Flash_ReadPage 前面先用宏定义限制起始地址。例程把参数页起始地址定义在倒数第二页,最大写入长度为 2048 字节,实际业务只用其中一小段,剩余空间留给未来扩展。4. 避坑篇:这些 Flash 翻车现场,例程帮你提前挡掉了4.1 现象:写入后复位,读回来全是 0xFF一个很典型的案例:程序调用写入函数返回成功,也没有报错,但一复位再读,数据全是 0xFF。看起来像没写过。原因一般是两个:要么写入地址对错了页,擦除没真正执行;要么写入后 CPU 立刻进入低功耗模式,Flash 进入深度休眠前数据没稳住。实际上更隐蔽的是直接把地址写在了代码区,程序运行过程中又自改了向量表,造成复位后指令指针跑飞。解决方法是先做回读校验,见 3.3 的代码。写完半字后立即从同一地址读回比较。如果发现 0xFF,先确认地址是不是指向了代码段、是不是在末尾页之前。还有一点容易被忽视:如果使能了 Flash 预取缓冲区或者指令缓存,写完地址后,读回的数据可能被缓存污染。在这种情况下,可以关闭数据缓存,或者对同一个地址做两次读取——第一次丢弃,第二次作为真实值。4.2 现象:突然 HardFault,死在等待 BSY 清零的死循环里有些开发者把写入数据放在主循环里,每帧都写同一页,结果跑了半小时突然 HardFault。查调发现卡在 while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) 这一行,因为 BSY 永远不清零。原因是对 Flash 操作太频繁,或者擦写期间掉电,Flash 控制器进入异常状态。解决思路是给 BSY 等待加超时退出机制。不要用无条件的 while 循环,应该像 3.2 代码里那样,设定一个超时计数器,超时后返回错误码。同时检查 FLASH_SR 里的 WRPRTERR 位,如果写保护被意外打开,也会导致 BSY 挂死。例程的做法是在系统启动时调用一次 Flash_Init 做状态复位,如果发现异常就强制重新解锁并清除错误标志,避免带病运行。4.3 现象:程序跑飞,Flash 被写坏,连 J-Link 都连不上最严重的一个翻车:参数存到一半,看门狗复位,复位后又立刻往里写,结果 Flash 内容错乱,导致程序启动不了。这种情况多半是写入了向量表所在区域。比如楼主位没有把目标地址限定在 Flash 末尾,而是顺着数据缓冲区的地址偏移写,偏移越界直接写进了启动代码。例程把这个坑堵死的办法是在 Flash_WritePage 入口处检查目标地址范围。地址必须在 USER_FLASH_START_ADDR 和 USER_FLASH_END_ADDR 之间,超出范围直接返回错误,不执行擦写。如果你自己写代码,务必加上这个边界检查,并确保地址不是代码段或中断向量区。万一已经写坏了,可以把 BOOT0 拉高进入系统 BootLoader,用串口重新烧录全片擦除,这是后悔药,但一次能救回来。4.4 现象:在中断里写 Flash,主程序响应变慢,甚至丢串口数据有些场景需要在外部中断里快速保存参数——比如检测到掉电,立即在掉电中断里写 Flash。这个想法很自然,但忽略了掉电瞬间的电压已经不稳,Flash 擦写需要稳定电压,掉电过程中写 Flash 轻则写失败,重则把整页数据带崩。即便不是在掉电时,在中断里执行 20 多毫秒的擦除操作,会让其他中断长时间得不到响应。例程的做法是用“掉电检测 + 缓写”模式:掉电中断里只把待写数据放进一个备份 RAM 区,设置一个标志;主循环检测到标志后再完成 Flash 写入。如果掉电太快,备用电容要撑过擦写时间,选用小容量代码页减少擦除时间。如果你实在需要低延迟写入,F103 不支持读-修改-写,只能用双页交替策略,这属于二次开发范围。5. 运行与验证:用串口和调试器走一遍完整读写链路5.1 烧录与初始状态验证拿到例程,第一步是改芯片型号。打开工程后,在魔术棒对话框的 Device 选项卡里选择你的 F103 具体型号,比如大容量 C8T6 或 ZET6。然后确认宏定义里的 FLASH_PAGE_SIZE:大容量系列设为 2048,中容量设为 1024。设置不对的话,擦除会按错误页大小执行,轻则擦不干净,重则擦错区域。烧录例程后,打开串口调试助手,波特率 115200。上电复位后,例程先调用 Flash_ReadPage 读出参数页数据,并与预设的魔数(比如 0xA5 0x5A)比对。如果匹配则打印 “Param valid”,否则打印 “Param invalid, restore default”。第一次烧录时参数页是空的,所以你会看到 “Param invalid”,这是正常现象,不用慌。5.2 写数据、断电重启、读回校验在串口发送区输入要保存的数据,比如四个字节的校准值 0x12 0x34 0x56 0x78,例程收到后调用 Flash_WritePage 写入参数页。写入过程串口会打印 “Writing… done”。此时你可以直接断电,再重新上电。例程启动后会再次读取同一地址,并打印出读回的数据。如果显示与你发送的一致,说明整套流程走通。这里建议你做一次极端测试:连续写入同一地址 100 次。每次写入前例程都会先擦除整页再写入。你会发现写入第 2 次开始,之前的数据已经被清掉,这符合先擦后写的预期。同时观察 Flash 状态寄存器,没有出现 PGERR 标志。如果第 50 次时突然失败,大概率是电源纹波或者温度问题,而不是代码问题。5.3 性能参数观察:用逻辑分析仪或示波器看 BSY 时间把示例中的 HAL_GPIO 电平翻转代码加在页擦除前和后,用逻辑分析仪抓取高电平持续时间,就能测出页擦除实际耗时。不同主频下结果会有差异,但通常页擦除在几十毫秒量级。单半字写入测起来更短,通常在几十微秒量级。如果数据长度只有几十字节,你会发现对比 EEPROM 完全没有压力。操作典型耗时依赖条件页擦除(2K 页)20~40ms主频、内部电压半字写入40~80us等待 BSY 清零整页写入(2K 数据)主频及半字次数逐字回读校验6. 二次开发改造:把例程改造成你自己的参数存储模块6.1 修改用户 Flash 起始地址与容量现在例程把参数放在倒数第二页,也就是主存储块的末尾。你可以改成在程序代码之后紧跟的空闲页,只需改动两个宏。比如你的代码只占了前 100K,那可以把起始地址设为 100K 边界。但要注意:每页只能整体擦除,如果业务数据多于一页,就得设计跨页写入,例程的简单接口就不够用了。我一般会把结构体压缩到不超过 1K,单页足够存。#define USER_FLASH_START_ADDR (FLASH_BASE + 100 * 1024) #define USER_FLASH_END_ADDR (USER_FLASH_START_ADDR + FLASH_PAGE_SIZE)6.2 增加校验与磨损均衡内部 Flash 擦写寿命通常在 1 万次左右,频繁存储参数会老得快。例程没有做磨损均衡,但你可以把页拆成 4 个区块,每次写新块,写满后统一擦除整页重新轮转。配合 16 位 CRC 校验,每次读取先验 CRC,不通过则回退到上一个备份块。这种双备份机制在工控场景很有用,能解决断电写一半导致的数据丢失问题。6.3 封装成平台无关接口如果你打算把代码复用到 F4 或 G0 系列,例程里直接调用标准库函数的写法就不够优雅。建议把 flash_if.c 内部的读写操作再抽象一层,比如定义platform_flash_erase、platform_flash_write、platform_flash_read三个函数指针,底层根据芯片切换实现。换平台时只改抽象层,业务逻辑完全不动。从那以后我每次做带存储参数的功能,都会先在草稿纸上画出“擦除、写入、回读、断电恢复”四个阶段的时序图,再动手写代码。这套例程帮我省了不少事,但它最大的价值其实是把那些容易踩坑的边界条件用代码表达了出来。如果你正打算在 F103 上存参数,把这份例程的接口直接拿过去用,先跑通再改设计,会比自己从寄存器开始一个人啃快得多,希望帮到你。本文还有配套的精品资源,点击获取
简介:一套基于STM32F103芯片内部Flash读写的完整例程,适合嵌入式入门开发者及需要在项目中实现固件升级、参数存储、日志记录等功能的工程师。例程基于HAL库编写,完整涵盖了Flash解锁、扇区擦除、数据编程、读取校验、再次上锁的标准流程,并演示了通过地址映射直接读取Flash内容的实现方式。借助此例程,可快速掌握内部Flash的分区结构、各扇区容量差异及擦除前必须编程的注意事项,理解操作时序后便能灵活移植到实际产品中。资源包共72个文件,以29个H头文件与28个C源代码为主体,另有汇编启动文件、Keil工程文件、说明文档与芯片数据手册,整体压缩后仅948KB,文件划分清晰,便于在工程中直接复用或对照学习。例程中初始化代码和操作函数注释清晰,便于逐段理解Flash控制器的行为。目前已有10760人次学习浏览,是社区中较受关注的STM32Flash读写参考案例。除了可复现的读写代码,还提供数据手册等配套资料,能帮助使用者结合引脚定义和串口设计完成整体调试验证,减少因误擦除或地址越界导致的固件异常。1. STM32F103 内部 Flash 读写:掉电不丢参数的最后一公里做温控器的时候,要存一组校准系数。最开始用的外部 EEPROM,结果焊反一次、换上后地址漂移一次,项目差点砸在存储上。后来把参数写进 STM32F103 内部 Flash,一个通用接口就能搞定,断电十年数据还在。这份例程就是干这个事的:把内部 Flash 的解锁、擦除、写入、读取封装成几个简单函数,内置地址越界检查和掉电恢复演示,能直接抄进自己的工程。它适合所有用 F103 做小家电、传感器节点、工控板,又不想额外加存储芯片的开发者。本篇不会只讲概念,会带你从寄存器看到实战代码,最后给出一套能落地的二次改造方案。2. 硬件基础与寄存器视角:为什么 Flash 写入不能像 RAM 一样直接赋值2.1 存储结构:主存储块、系统存储区与选项字节STM32F103 的内部 Flash 被分成三个区域:主存储块、系统存储区、选项字节区。我们日常读写用的是主存储块,也就是存放程序代码的地方。主存储块按页划分,不同型号页大小不一样:小容量产品每页 1K 字节,中容量产品每页 1K 字节,大容量产品每页 2K 字节。例程默认按大容量 512K 系列来写,所以页擦除单位是 2K,如果你用的是中容量 64K 芯片,记得把页大小宏定义改成 1K。Flash 写入有个物理特性:只能把 1 写成 0,不能把 0 写成 1。想把某一位从 0 恢复成 1,唯一办法是对整页执行擦除。这决定了任何 Flash 写入操作都至少需要两步——先判断目标位置是否是 0xFF,如果不是就得先擦除整页,然后再写。很多新手在这翻车:直接往非空地址写数据,然后读出来的数据跟预期完全不对。这不是代码逻辑错,是没理解 Flash 的位映射关系。系统存储区是芯片出厂时固化 BootLoader 的区域,用户一般碰不到。选项字节区控制读保护、看门狗、启动模式等,例程里没有操作它,因为一旦写错可能锁死芯片。读写程序时只关心主存储块的末尾几页,绝对不要碰写有代码的前部区域,否则程序直接跑飞。例程默认把参数放在倒数第二页,也就是主存储块的末尾,避开代码空间。2.2 关键寄存器与状态标志:BSY、PGERR、WRPRTERRSTM32F103 在标准外设库里提供了 flash 操作函数,但为了排查问题还是得认识几个关键寄存器。控制寄存器 FLASH_CR 里的 PG 位表示编程(写入)模式,PER 位表示页擦除模式,STRT 位用来触发操作;状态寄存器 FLASH_SR 里有 BSY 忙标志、PGERR 编程错误标志、WRPRTERR 写保护错误标志。绝大多数异常都能从这三个标志里找到答案。典型流程是这样:先调用 FLASH_Unlock() 解锁 Flash,然后等待 FLASH_FLAG_BSY 清零,再配置 CR 寄存器选择页擦除,写入目标地址,置位 STRT 开始擦除,等待擦除完成。写入半字时同理,但要注意 CR 寄存器必须再配置为编程模式。标准库把这一串封装成了 FLASH_ErasePage 和 FLASH_ProgramHalfWord,你不用自己位操作,但要知道内部要等 BSY。实际操作中最重要的经验是:在操作 Flash 期间,任何中断、读写 Flash 的操作都会导致 CPU 停摆,因为 Flash 控制器正忙着擦写。这意味着不要在 Flash 擦写过程中响应中断,尤其是不要在中断服务函数里写 Flash。如果你用了实时操作系统,要先把调度器锁住,或者把擦写过程放到一个最低优先级任务里,并且用临界区保护。这属于血泪经验,后面避坑章节会展开讲。2.3 例程工程的文件分工:一眼就能抓重点例程的工程结构不复杂,核心就三个文件:flash_if.c 是功能封装层,flash_if.h 是对外接口,main.c 是演示逻辑。配套的还有一个参数结构体定义文件 param.h,用于把散落的写入数据组织成结构体批量存储。如果你打开工程,第一眼应该去找 flash_if.c 里的三个函数:Flash_Init、Flash_WritePage、Flash_ReadPage,其他都是辅助。整个系统的调用关系是:应用层只需要调用 Flash_WritePage(FIRST_USER_ADDR, paramBuff, len) 就能把一段数据写进指定页;需要读取时调用 Flash_ReadPage 从同一地址把数据搬回 RAM。擦除动作被藏在写入函数内部,先擦后写。这样的设计让调用方完全不用关心 Flash 控制器的细节,即使不懂寄存器也能跑通。工程里还单独放了一个 startup_time 文件,用来记录从复位到读取成功的时间戳,这个时间戳是给掉电恢复演示用的。实际项目中你可以把它换成生产批次号、设备序列号或者校准日期。文件结构如下表所示。文件名职责关键点flash_if.cFlash 擦、写、读封装地址对齐检查,页擦除,半字写入flash_if.h对外宏与函数声明定义 Flash 基地址、页大小、起始页param.h参数结构体定义定义存储字段及默认值main.c演示逻辑写后读回、断电恢复验证3. 核心代码拆解:从擦除到写入的完整流程3.1 解锁流程:先给 Flash 控制寄存器放权STM32F103 的 Flash 控制器默认是锁定的,直接写 CR 寄存器没有反应。必须先向 KEYR 寄存器依次写入 0x45670123 和 0xCDEF89AB 这两个钥匙值解锁。标准库把这一步封装在 FLASH_Unlock() 里,但我们要做的不是盲目调用,而是保证每次操作前都处于解锁状态,并在操作完成后重新上锁。下面这段代码展示了例程的初始化与解锁保护。注意它没有用 VDD 供电判断,因为内部 Flash 编程电压由内部调压器提供,写代码不需要关心。uint8_t Flash_Init(void) { if (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { return 1; // 如果正在忙,先不操作 } FLASH_Unlock(); // 解锁 Flash 控制器 return 0; }FLASH_GetFlagStatus 读取的是 FLASH_SR 寄存器中的 BSY 位。如果返回 SET,说明控制器正在执行擦除或写入,此时任何新的操作都会被忽略,甚至触发错误标志。这里的返回值 1 表示初始化失败,调用方可以在应用层重试。实际使用中我一般会再加一个超时计数,防止极端情况下 BSY 一直不清零导致程序卡死。3.2 页擦除函数:整页归零才是 Flash 的“写前准备”写入前必须先擦除。擦除以页为单位,例程的页擦除函数接收一个页起始地址,比如大容量系列的 2K 一页,地址必须是 2048 的整数倍。如果不是,函数会直接返回错误,这是防止越界和错位的第一道防线。static uint8_t Flash_ErasePage(uint32_t pageAddr) { uint16_t timeout = 0; /* 检查地址是否按页大小对齐,2K 页要求低 11 位为 0 */ if ((pageAddr % FLASH_PAGE_SIZE) != 0) { return 1; } /* 等待 Flash 空闲,最多等 5000 次循环 */ timeout = 5000; while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { if (timeout-- == 0) return 2; // 超时返回错误码 2 } FLASH_ErasePage(pageAddr); // 标准库页擦除 timeout = 5000; while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { if (timeout-- == 0) return 3; } return 0; }FLASH_ErasePage 是标准外设库函数,内部会完成 PER 位置 1、写地址到 AR 寄存器、STRT 置位、等待 EOP 等操作。这里在调用前后都加了超时等待,超时时间用循环次数做延时,而不是定时器,因为此时 CPU 可能还处于中断频繁的环境,定时器本身可能被干扰。实际中页擦除典型耗时在 20 毫秒到 40 毫秒之间,具体取决于芯片型号和时钟配置。3.3 写入半字与全字封装:地址对齐是硬门槛STM32F103 的 Flash 编程最小单位是半字,也就是 16 位。标准库的 FLASH_ProgramHalfWord 一次只能写 2 字节。实际业务数据通常是 uint8_t 数组或者结构体,所以例程在此基础上做了一层缓冲写入封装:先用临时数组把待写数据拼成半字,再调标准库接口逐半字写入。static uint8_t Flash_WriteWords(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; uint16_t halfWord; /* 地址必须半字对齐,否则无法编程 */ if ((addr % 2) != 0) return 1; /* 长度向上取偶,避免奇数长度丢尾巴 */ if (len % 2) len++; for (i = 0; i < len; i += 2) { halfWord = buf[i] | (buf[i + 1] << 8); // 低字节在前 FLASH_ProgramHalfWord(addr + i, halfWord); /* 等待完成,逐个半字校验写入结果 */ while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); if ((*(volatile uint16_t *)(addr + i)) != halfWord) { return 2; // 写入不回读一致,说明失败 } } return 0; }这段代码把 buf 里的字节按小端拼成半字,然后逐个写入。注意参数 len 是按字节数传入的,但因为半字是 2 字节,所以循环步长是 2。如果传入长度是奇数,这里会向上取偶,多写一个字节,对应的缓存缓冲区也要留足空间。代码中每次写完立即做回读校验,这是很多例程没有的,它能第一时间暴露写保护或者电压不稳问题。3.4 读取接口与参数恢复:没有擦除,只有读读 Flash 和读 RAM 一样简单——直接按指针访问地址即可。例程把读取封装成 Flash_ReadPage,纯粹是做了一个地址映射和长度拷贝,没有任何控制器操作。但要注意,读取时不能越界访问没映射的 Flash 地址,否则会 HardFault。void Flash_ReadPage(uint32_t addr, uint8_t *buf, uint16_t len) { const uint8_t *src = (const uint8_t *)addr; while (len--) { *buf++ = *src++; } }这里的读取没有加任何保护,因为它直接操作地址空间。如果 addr 落在未定义的存储区,比如大于芯片 Flash 总容量的地址,执行时即刻触发总线错误。所以在调用层,Flash_ReadPage 前面先用宏定义限制起始地址。例程把参数页起始地址定义在倒数第二页,最大写入长度为 2048 字节,实际业务只用其中一小段,剩余空间留给未来扩展。4. 避坑篇:这些 Flash 翻车现场,例程帮你提前挡掉了4.1 现象:写入后复位,读回来全是 0xFF一个很典型的案例:程序调用写入函数返回成功,也没有报错,但一复位再读,数据全是 0xFF。看起来像没写过。原因一般是两个:要么写入地址对错了页,擦除没真正执行;要么写入后 CPU 立刻进入低功耗模式,Flash 进入深度休眠前数据没稳住。实际上更隐蔽的是直接把地址写在了代码区,程序运行过程中又自改了向量表,造成复位后指令指针跑飞。解决方法是先做回读校验,见 3.3 的代码。写完半字后立即从同一地址读回比较。如果发现 0xFF,先确认地址是不是指向了代码段、是不是在末尾页之前。还有一点容易被忽视:如果使能了 Flash 预取缓冲区或者指令缓存,写完地址后,读回的数据可能被缓存污染。在这种情况下,可以关闭数据缓存,或者对同一个地址做两次读取——第一次丢弃,第二次作为真实值。4.2 现象:突然 HardFault,死在等待 BSY 清零的死循环里有些开发者把写入数据放在主循环里,每帧都写同一页,结果跑了半小时突然 HardFault。查调发现卡在 while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) 这一行,因为 BSY 永远不清零。原因是对 Flash 操作太频繁,或者擦写期间掉电,Flash 控制器进入异常状态。解决思路是给 BSY 等待加超时退出机制。不要用无条件的 while 循环,应该像 3.2 代码里那样,设定一个超时计数器,超时后返回错误码。同时检查 FLASH_SR 里的 WRPRTERR 位,如果写保护被意外打开,也会导致 BSY 挂死。例程的做法是在系统启动时调用一次 Flash_Init 做状态复位,如果发现异常就强制重新解锁并清除错误标志,避免带病运行。4.3 现象:程序跑飞,Flash 被写坏,连 J-Link 都连不上最严重的一个翻车:参数存到一半,看门狗复位,复位后又立刻往里写,结果 Flash 内容错乱,导致程序启动不了。这种情况多半是写入了向量表所在区域。比如楼主位没有把目标地址限定在 Flash 末尾,而是顺着数据缓冲区的地址偏移写,偏移越界直接写进了启动代码。例程把这个坑堵死的办法是在 Flash_WritePage 入口处检查目标地址范围。地址必须在 USER_FLASH_START_ADDR 和 USER_FLASH_END_ADDR 之间,超出范围直接返回错误,不执行擦写。如果你自己写代码,务必加上这个边界检查,并确保地址不是代码段或中断向量区。万一已经写坏了,可以把 BOOT0 拉高进入系统 BootLoader,用串口重新烧录全片擦除,这是后悔药,但一次能救回来。4.4 现象:在中断里写 Flash,主程序响应变慢,甚至丢串口数据有些场景需要在外部中断里快速保存参数——比如检测到掉电,立即在掉电中断里写 Flash。这个想法很自然,但忽略了掉电瞬间的电压已经不稳,Flash 擦写需要稳定电压,掉电过程中写 Flash 轻则写失败,重则把整页数据带崩。即便不是在掉电时,在中断里执行 20 多毫秒的擦除操作,会让其他中断长时间得不到响应。例程的做法是用“掉电检测 + 缓写”模式:掉电中断里只把待写数据放进一个备份 RAM 区,设置一个标志;主循环检测到标志后再完成 Flash 写入。如果掉电太快,备用电容要撑过擦写时间,选用小容量代码页减少擦除时间。如果你实在需要低延迟写入,F103 不支持读-修改-写,只能用双页交替策略,这属于二次开发范围。5. 运行与验证:用串口和调试器走一遍完整读写链路5.1 烧录与初始状态验证拿到例程,第一步是改芯片型号。打开工程后,在魔术棒对话框的 Device 选项卡里选择你的 F103 具体型号,比如大容量 C8T6 或 ZET6。然后确认宏定义里的 FLASH_PAGE_SIZE:大容量系列设为 2048,中容量设为 1024。设置不对的话,擦除会按错误页大小执行,轻则擦不干净,重则擦错区域。烧录例程后,打开串口调试助手,波特率 115200。上电复位后,例程先调用 Flash_ReadPage 读出参数页数据,并与预设的魔数(比如 0xA5 0x5A)比对。如果匹配则打印 “Param valid”,否则打印 “Param invalid, restore default”。第一次烧录时参数页是空的,所以你会看到 “Param invalid”,这是正常现象,不用慌。5.2 写数据、断电重启、读回校验在串口发送区输入要保存的数据,比如四个字节的校准值 0x12 0x34 0x56 0x78,例程收到后调用 Flash_WritePage 写入参数页。写入过程串口会打印 “Writing… done”。此时你可以直接断电,再重新上电。例程启动后会再次读取同一地址,并打印出读回的数据。如果显示与你发送的一致,说明整套流程走通。这里建议你做一次极端测试:连续写入同一地址 100 次。每次写入前例程都会先擦除整页再写入。你会发现写入第 2 次开始,之前的数据已经被清掉,这符合先擦后写的预期。同时观察 Flash 状态寄存器,没有出现 PGERR 标志。如果第 50 次时突然失败,大概率是电源纹波或者温度问题,而不是代码问题。5.3 性能参数观察:用逻辑分析仪或示波器看 BSY 时间把示例中的 HAL_GPIO 电平翻转代码加在页擦除前和后,用逻辑分析仪抓取高电平持续时间,就能测出页擦除实际耗时。不同主频下结果会有差异,但通常页擦除在几十毫秒量级。单半字写入测起来更短,通常在几十微秒量级。如果数据长度只有几十字节,你会发现对比 EEPROM 完全没有压力。操作典型耗时依赖条件页擦除(2K 页)20~40ms主频、内部电压半字写入40~80us等待 BSY 清零整页写入(2K 数据)主频及半字次数逐字回读校验6. 二次开发改造:把例程改造成你自己的参数存储模块6.1 修改用户 Flash 起始地址与容量现在例程把参数放在倒数第二页,也就是主存储块的末尾。你可以改成在程序代码之后紧跟的空闲页,只需改动两个宏。比如你的代码只占了前 100K,那可以把起始地址设为 100K 边界。但要注意:每页只能整体擦除,如果业务数据多于一页,就得设计跨页写入,例程的简单接口就不够用了。我一般会把结构体压缩到不超过 1K,单页足够存。#define USER_FLASH_START_ADDR (FLASH_BASE + 100 * 1024) #define USER_FLASH_END_ADDR (USER_FLASH_START_ADDR + FLASH_PAGE_SIZE)6.2 增加校验与磨损均衡内部 Flash 擦写寿命通常在 1 万次左右,频繁存储参数会老得快。例程没有做磨损均衡,但你可以把页拆成 4 个区块,每次写新块,写满后统一擦除整页重新轮转。配合 16 位 CRC 校验,每次读取先验 CRC,不通过则回退到上一个备份块。这种双备份机制在工控场景很有用,能解决断电写一半导致的数据丢失问题。6.3 封装成平台无关接口如果你打算把代码复用到 F4 或 G0 系列,例程里直接调用标准库函数的写法就不够优雅。建议把 flash_if.c 内部的读写操作再抽象一层,比如定义platform_flash_erase、platform_flash_write、platform_flash_read三个函数指针,底层根据芯片切换实现。换平台时只改抽象层,业务逻辑完全不动。从那以后我每次做带存储参数的功能,都会先在草稿纸上画出“擦除、写入、回读、断电恢复”四个阶段的时序图,再动手写代码。这套例程帮我省了不少事,但它最大的价值其实是把那些容易踩坑的边界条件用代码表达了出来。如果你正打算在 F103 上存参数,把这份例程的接口直接拿过去用,先跑通再改设计,会比自己从寄存器开始一个人啃快得多,希望帮到你。本文还有配套的精品资源,点击获取