
简介面向STM32嵌入式初学者的内部Flash读写测试工程基于STM32F107芯片围绕Flash存储器的扇区划分、擦除与编程特性、HAL库封装接口展开适合正在学习固件升级、参数存储与数据持久化的开发者参考也适用于电子设计和智能控制项目中的存储需求。压缩包共262个文件除C/H源程序外还包含汇编启动文件、链接脚本以及Keil、IAR等多个IDE的工程配置文件并附带了编译生成的hex、axf、map等输出文件整体约7MB工程结构清晰可直接修改调试。源码中完整演示了先扇区擦除、再按地址写入、最后读取回验确认数据正确性的操作流程同时给出了相关API调用与状态标志检查方法并封装了测试框架帮助读者理解STM32内部Flash的硬件机制和软件设计要点。目前已有262人学习下载适合有一定单片机基础、希望深入掌握存储管理的开发者作为实践素材。1. STM32F107 内部Flash读写测试为什么值得单独开一篇基础篇一个 0x55 写进没擦过的 Flash读回来仍然是 0x55看起来一切正常。等下次你想把同一个地址更新成 0xA0读回来的结果却不是你预期中的数据这个现象会花掉一个下午去排查。这正是 STM32F107 内部Flash读写测试和 RAM 测试最大的区别Flash 写入只能把 1 变成 0擦除才能把整页恢复成 0xFF而擦除的最小单位是一整页。标题里的“内部flash读写测试”听起来像点灯之后的下一个练习实际做的时候才会发现地址选错、页没擦、读写保护位被误开都会让测试结果变得非常有迷惑性。这篇基础篇按 F107 的 Flash 控制器模型、最小实现、排错路径和应用落地的顺序把读写测试里真正影响结果的细节一次说清楚。2. STM32F107 内部Flash 的扇区划分与擦写约束动手前先理解三个硬限制2.1 从地址映射看 STM32F107 内部Flash 的可用区间STM32F107 的内部 Flash 起始地址固定为 0x08000000上电后内核就是从该地址取指启动整个 Flash 空间既放代码也放数据。通过总线映射CPU 访问这段地址时有两条路径I-Bus 负责取指D-Bus 负责读写数据。应用层感知不到这个区分但当 Flash 控制器正在擦写时CPU 的取指和数据访问都会被暂停表现就是代码“卡”在写函数里迟迟不返回。F107 系列不同型号的 Flash 总容量从 64KB 到 256KB 不等地址范围分别为 0x08000000 至 0x0800FFFF或者至 0x0803FFFF。做读写测试选地址时应该尽量靠近地址末段比如 256KB 版本选用 0x0803F800 所属的最后一页。如果选 0x08000000 开头的区域做实验即使擦写逻辑完全正确也可能因为编译器的代码段先占用了这段空间把正在执行中的程序覆盖掉直接跑飞。Flash区域地址范围用途说明向量表0x08000000 起的低地址段中断向量表和启动代码代码区与常量区由链接脚本决定函数体、只读数据建议的测试数据区0x0803F800 起最后一页读写测试首选避开启动代码增长方向2.2 页擦除、半字写入与“只能1变0”的位特性内部Flash 的擦除操作必须以页为单位页大小因密度而异。中密度 F107 常见为 1KB/页高密度常见为 2KB/页精确值要以对应芯片型号的 Datasheet 里 Memory Map 一节为准。擦除完成后整页所有位都变为 1即读出 0xFF。写入操作则只能把 1 变成 0不能把 0 变回 1所以同一地址想更新数据必须先对整个页做一次擦除。写入的最小单位是半字即 16 位。标准外设库里的FLASH_ProgramHalfWord一次写 16 位如果业务数据是 32 位字就要拆成两个半字分别写到连续地址上。这比很多人的直觉更麻烦但也是 F1 系列 Flash 控制器的硬件事实。FLASH_ProgramWord这个库函数底层也是拆成两次半字操作所以在时序要求严格的场合直接调半字写更容易估算总耗时。页擦除耗时在 20 到 40 ms 量级半字编程耗时在几十微秒量级两者都是等待型操作必须等 Flash 控制器的 BSY 位清零后再进行下一步。2.3 三个决定测试结果可靠性的参数擦除时间、寿命、电压下限做测试前先把三个手册参数记下来页擦除时间、擦写寿命、编程电压下限。页擦除时间决定你的超时阈值该设多大擦写寿命决定测试脚本能不能对同一页反复跑编程电压下限决定硬件供电不稳时测试结果还有没有参考价值。F107 的 Flash 擦写寿命典型值是 10,000 次这和 EEPROM 动辄十万百万次的标称寿命差一个数量级。持续往同一页做读写测试时这一页会在自动化脚本跑几天后提前报废表现为擦除时间异常拉长或写入后校验失败。所以测试代码里应该让扇区地址可配置隔一段时间换一页而不是把最后一页焊死在编译期常量里。供电方面如果目标板用调试器供电3.3V 稳定问题不大。但用锂电池直接供电电压跌到 3.0V 以下时擦写结果可能不可靠而读取仍然正常这个现象很容易被误判成“Flash 坏了”。测试程序里最好在擦写前读一次 VDD 对应的 ADC 通道低于阈值就直接报错不做擦写。2.4 解锁/锁定寄存器与读写保护位F107 的 Flash 控制器上电后处于锁定状态直接写控制寄存器是无效的。解锁必须按顺序向FLASH-KEYR写入两个固定密钥0x45670123 和 0xCDEF89AB。两条密钥都写入且校验正确后FLASH-CR的 LOCK 位自动清 0控制器才允许执行擦除和编程命令。密钥写错一次硬件会把 LOCK 置为不可解锁状态只能复位芯片后再试这也是读写测试第一次跑就卡住的常见原因之一。if (FLASH-CR FLASH_CR_LOCK) { FLASH-KEYR 0x45670123; /* 第一个密钥见参考手册的 Flash programming 章节 */ FLASH-KEYR 0xCDEF89AB; /* 第二个密钥两条写完后 LOCK 位自动清零 */ } FLASH-CR | FLASH_CR_PG; /* 置 PG 位进入编程模式 */这段代码展示了最底层的解锁动作。FLASH_CR_PER对应页擦除模式FLASH_CR_MER对应整片擦除模式测试代码里不应该用 MER整片擦除会把启动代码一起抹掉。另外两个容易踩的状态位是FLASH_SR_WRPRTERR和FLASH_SR_PGERR前者是写保护错误后者是编程错误。一旦擦除或写入后这两个位中有任意一个为 1后续所有校验都失去了意义应该在状态位检查通过后再进入数据比对流程。3. 用标准外设库在本地跑通 STM32F107 内部Flash 读写的最小代码3.1 准备一个最小 Flash 驱动层我一般会把 Flash 操作封装成三层地址宏层、擦写函数层、业务调用层。地址宏层只暴露扇区基地址和页大小擦写函数层负责解锁、等待、清标志业务调用层只关心“往哪个地址写什么数据”。这样后续把测试扇区从末尾页换成别的页时只需要改一处宏不用在业务代码里翻找裸地址。工程里建议打开标准外设库的stm32f10x_flash.h确认库版本里FLASH_ProgramHalfWord是否存在。旧版本固件库可能只提供FLASH_ProgramWord两个函数的参数和数据宽度不同混用会导致编译类型警告。3.2 解锁、擦除、写入三步走的参考代码#include stm32f10x_flash.h #define TEST_BASE_ADDR 0x0803F800UL /* 256KB型号的最后一页页地址按2KB对齐 */ #define TEST_WORD_CNT 16 static uint32_t test_buf[TEST_WORD_CNT]; static void flash_write_test_area(uint32_t *src, uint32_t len) { uint32_t i; uint32_t addr TEST_BASE_ADDR; FLASH_Unlock(); /* 库函数封装了两次KEYR写入等价于2.4节的手写解锁 */ FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); if (FLASH_ErasePage(addr) ! FLASH_COMPLETE) { /* 擦除失败这里接错误处理不要在测试代码里用空循环 */ while (1); } for (i 0; i len; i) { FLASH_ProgramHalfWord(addr, (uint16_t)(src[i] 0xFFFFUL)); addr 2; FLASH_ProgramHalfWord(addr, (uint16_t)(src[i] 16)); addr 2; } FLASH_Lock(); /* 重新置 LOCK 位防止误操作改写 Flash */ }先说明流程解锁后立即清三个标志位然后FLASH_ErasePage会等待 BSY 清零并返回FLASH_COMPLETE。写入循环里把 32 位数据拆成两次 16 位写入地址每次自增 2最后一次写完后锁定控制器。参数上需要留意TEST_BASE_ADDR必须按页对齐如果传入的是页中间地址擦除行为以硬件页边界为准数据写入的位置和预期会不一致。FLASH_ProgramHalfWord内部会等 BSY 清零才返回所以不需要额外加延时循环。3.3 回读校验与测试模式设计写入之后不能只看调试器里某个变量对不对应当把回读和比对也放到代码里。回读不走 Flash 控制器的命令寄存器直接用总线读取地址因此读操作不需要解锁也不会消耗擦写寿命。static uint32_t flash_verify_test_area(uint32_t *src, uint32_t len) { uint32_t i; const volatile uint32_t *read_addr; for (i 0; i len; i) { read_addr (const volatile uint32_t *)(TEST_BASE_ADDR i * 4UL); if (*read_addr ! src[i]) { return i; /* 返回第一个比对失败的字下标0xFFFFFFFF 表示全部通过 */ } } return 0xFFFFFFFFUL; }volatile关键字是必要的否则编译器在 -O2 优化下可能把连续读取缓存到寄存器导致回读结果永远是上一次的值。测试数据本身不要只用 0x00 或 0xFF那样的数据写进擦除后的 Flash 看不出位变化。可以用 0xA5A50000 加递增值的组合让高低 16 位都覆盖到非全 0、非全 F 的形态例如0xA5A50001、0xA5A50002这样递增下去。int main(void) { uint32_t i; uint32_t ret; for (i 0; i TEST_WORD_CNT; i) { test_buf[i] 0xA5A50000UL | ((i 1) 16) | (i 1); } flash_write_test_area(test_buf, TEST_WORD_CNT); ret flash_verify_test_area(test_buf, TEST_WORD_CNT); if (ret 0xFFFFFFFFUL) { /* 校验通过测试数据回读一致 */ } else { /* 校验失败去 FLASH-SR 里读 PGERR 和 WRPRTERR */ } while (1); }这段 main 函数就是完整的读写测试框架。校验失败时先看是全部数据不对还是单个字不对。全部不对通常是页没擦掉或者地址越界单个字不对则要查写入循环里的地址增量是否和数据类型匹配。读取FLASH-SR寄存器时注意PGERR和WRPRTERR是粘性位需要软件清零后才能继续下一次测试。3.4 和 GD32F450 内部Flash 读写 API 的差异点搜索引擎里经常看到 “gd32f450 内部flash读写” 相关的实现很多人会把 F107 的代码直接改个头文件就丢给 GD32F450。两边确实可以跑同一套 STM32F107 例程因为 GD32F450 在设计上兼容了 STM32F10x 的外设地址总线但具体到 Flash 控制器操作差异很明显。GD32F450 固件库对外接口是fmc_page_erase、fmc_word_program操作对象是FMC而不是FLASH等待状态通过fmc_wscnt_set单独配置。GD32F450 的扇区大小和 F107 也不一样按整个 Flash 分多个大扇区页擦写的最小单位通常远大于 F107 的 2KB。把 F107 的FLASH_ProgramHalfWord改成fmc_halfword_program也不够还需要同步修改页地址宏、等待状态配置和时钟频率参数。反过来讲STM32F107 的这套测试代码的价值在于把“解锁、擦除、编程、校验”的流程讲清楚了而 GD32F450 项目里重点是重新确认扇区边界和擦除粒度。把手上的实际型号当成一台新设备去读手册而不是默认代码能无缝移植。4. 读写测试跑飞时的排查路径与常见误区4.1 卡在 BSY 等待循环时先查什么写测试代码最常见的版本是while (FLASH-SR FLASH_SR_BSY);一行等到天荒地老。遇到卡住先区分三种情况。第一种是擦除确实在进行中页擦除要 20 到 40 ms调试器单步时会觉得像卡死等几秒看 BSY 是否自动清除。第二种是擦写过程中触发了中断中断服务程序也放在同一片 Flash 里CPU 取指被 Flash 控制器占用整个系统表现出无法响应的状态这种情况下单看 BSY 没意义。第三种是进入了 HardFault调试器实际停在 HardFault_Handler 里Call Stack 回溯后才看到 Flash 操作函数。解决办法是给等待加上超时。用 SysTick 或者 DWT 计数器累加一个毫秒值超过 200 ms 直接返回错误不要用无条件的死循环。生产代码里尤其要注意看门狗复位发生在擦写过程中会导致状态机错乱超时返回后要把FLASH-SR的错误标志全部清掉再重新初始化。4.2 读回全 0xFF最常见的原因是页没擦掉或根本没写入读回全 0xFF 说明这块区域处于擦除后的初始状态写入操作没有生效或者生效之后又被后续代码擦了一遍。优先检查FLASH_ErasePage的返回值如果擦除已经失败后面写进去的数据全是空操作。其次检查FLASH_ProgramHalfWord函数的调用次数F107 里写半个字需要一次完整调用别在循环里把地址增量写错导致始终写到同一地址。还有一个很容易被忽略的原因编译器优化。局部变量在 -O2 下如果没加 volatile连续调用FLASH_ProgramHalfWord时库函数内部的地址计算可能被优化掉实际没有生成对应地址的写指令。遇到这种怀疑时把整个写入函数改成test_buf从全局数组取数再观察汇编窗口是否生成了strh指令。4.3 读回全 0x00写入保护、电压下限和选项字节读回全 0x00 和全 0xFF 的诊断方向完全不同。全 0x00 说明总线读取正常但擦除没有把页变成 0xFF常见原因包括写保护位WRPi被置位、FLASH_Unlock没有执行成功、或者 VDD 电压低于 Flash 擦写电压下限。检查时先读FLASH-SR的WRPRTERR这个位为 1 就是写保护问题。症状可能原因确认方法处理方式读回全 0xFF擦除后没写入 / 擦除失败擦除后立即读首地址检查擦除返回值与写循环读回全 0x00写保护 / 电压过低读取 SR 的 WRPRTERR检查选项字节与供电个别字不对地址未对齐 / 半字拆分错比对返回的下标核对地址增量与字节序写入卡死中断抢占 / 总线暂停看 BSY 与中断响应关中断或将 ISR 放 RAM选项字节里的读保护如果设置成了 Level 1调试器在连接后读取 Flash 数据就会产生异常这类问题不算少见。处理办法是把读保护临时降级到 Level 0重新擦除选项字节区域再进行测试。烧录器软件和调试器版本不同选项字节操作界面差异较大但核心流程都是先解除 RDP再整片擦除。4.4 中断、RTOS 与 Flash 擦写抢总线的处理策略在 FreeRTOS 等 RTOS 里做 Flash 操作最不建议的是在低优先级任务里自旋等待 BSY 清零。页擦除几十毫秒内高优先级任务无法正常调度中断响应时间被显著拉长。常见做法是把 Flash 擦写放到一个专门的高优先级任务或者用taskENTER_CRITICAL包住整段操作。临界区过大对实时性的影响也要评估如果系统里有关键的中断源更稳妥的方案是把擦写函数放到 RAM 里执行。F1 系列的库函数默认放在 Flash 代码区擦写时 CPU 取指需要等待 Flash 控制器空闲。用 GCC 工具链时可以为擦写函数单独标记__attribute__((section(.ramfunc)))并在链接脚本里把这个段放到 RAM。这样擦写函数本身执行不依赖 Flash 总线擦写过程中的中断响应也能恢复正常。移植到 Keil MDK 时对应方案是把函数放到IRAM1区域并在分散加载文件中保留足够的 RAM 空间。4.5 链接脚本把测试区占了看 .map 文件而不是猜当测试地址选得不够靠后编译链接出来的代码段可能已经覆盖了测试区域。Flash 读写操作本身不会报错但等你擦除时程序代码前半截被擦掉直接跑飞。判断方法不是猜而是打开工程生成的 .map 文件找到 Execution Region 对应的最高地址确认它小于测试扇区起始地址。如果工程使用 IAP 方式启动用户程序从 0x08002000 或者 0x08010000 开始那么 Flash 空间的高地址段虽然是空的也可能被后续固件升级逻辑占用。常规做法是链接脚本里单独声明一个只读数据段把地址固定到最后一个扇区并在业务代码里引用该段的起始地址而不是手动写死 0x0803F800。这样链接器在校验地址重叠时会直接报错把冲突暴露在编译阶段。5. 从读写测试到应用双扇区交替保存与掉电保护的最小实现5.1 用双扇区做 A/B 交替保存读写测试通过之后的典型应用是掉电保存系统参数。单扇区方案有一个致命问题每次更新数据都要先擦除整页擦除过程中断电旧数据和新数据一起丢失。常见的规避方案是准备两个扇区交替写入上电后选择保存时间更新的那一个。#define PAGE_A 0x0803F800UL #define PAGE_B 0x0803F000UL static uint8_t page_selector; static void page_write(uint32_t addr, uint32_t *data, uint16_t len) { FLASH_Unlock(); FLASH_ErasePage(addr); /* 按半字循环写入data 按 uint32_t 拆成两个半字 */ FLASH_Lock(); } void save_params_to_flash(uint32_t *data, uint16_t len) { if (page_selector 0) { page_write(PAGE_A, data, len); } else { page_write(PAGE_B, data, len); } page_selector ^ 1; /* 下次换另一页写降低单页磨损 */ }这段代码把页选择器做成静态变量每次写入后翻转。A/B 方案带来的第二个好处是磨损均衡两页交替擦写寿命比单页方案长一倍。上电恢复时分别读两页的版本号或时间戳取最新的那页作为有效数据。5.2 掉电保护写完提交标志才算保存完成只有完整写完一页数据并不算保存完成因为写最后一个半字时断电数据是不完整的。最小实现是在页末尾预留 4 字节作为 commit 标志数据全部写完后再把标志写成固定值 0xA5A5A5A5。上电读取时只有 commit 标志等于预期值的页才能被选中。#define COMMIT_MARK_ADDR 0x0803F8FCUL /* 页末 4 字节按半字拆写 */确认逻辑分三步先读两页的 commit 标志标志正确的那页有效两页都有效则比较版本号两页都无有效标志则使用默认参数。这个流程在工业设备里非常常见它不要求掉电瞬间做复杂处理只依赖“最后写入的字节决定数据有效性”这一个简单规则。5.3 验证测试是否达标用 CRC32 代替逐字比对工程上的读写测试如果只做一次逐字比对很难发现偶发的位错误。第一次写入后回读比对只能证明“这次写对了”。要评估长期可靠性我会在测试数据末尾附带一个 CRC32 校验值每次回读时重新计算并与存储值比对。逐字比对适合开发阶段定位问题CRC32 适合老化测试和产线自检两者并行不冲突。uint32_t crc32_calc(uint32_t *data, uint16_t len);老化测试时让脚本对同一扇区循环执行“擦除、写测试数据、读回、CRC 校验”四步记录每次擦写耗时和校验结果。如果擦除时间从 20 ms 逐渐涨到 50 ms或者某次 CRC 校验失败说明所选扇区已经接近寿命终点需要立即停止测试并更换测试扇区。校验数据建议用伪随机序列填充模拟真实参数的变化幅度不要让测试数据全是固定的递增模式。上线前的验收标准其实只有一条重复擦写同一区块超过总寿命的三倍校验仍然全对掉电重启后数据还在——达到这个标准STM32F107 内部Flash 读写测试才算真正通过而不是仅仅在调试器里看了十次变量值。本文还有配套的精品资源点击获取