
简介STM32片内Flash读写是嵌入式开发中数据掉电保存的关键环节。这份资料围绕STM32F10x系列片内Flash的编程与擦除流程提供了一套完整可运行的测试工程适合嵌入式初学者及需要实现参数存储、日志记录等功能的开发者参考。压缩包共140个文件以HAL/标准库头文件35个h和源文件34个c为主辅以工程配置文件uvprojx、uvoptx、编译产物axf、hex、map及调试辅助文件整体仅2.96MB结构清晰、开箱即用。资源内包含Flash_test主程序可直接查看flash_write()、flash_read()等接口的底层实现覆盖解锁、擦除、编程、校验及错误处理等完整步骤并集成USART调试输出方便在开发板上验证读写一致性与掉电保护效果。目前已有1031人学习下载适合对照参考帮助避过Flash误擦写、地址越界等常见坑点。 最近在调一块STM32F103C8T6的样板遇到一个特别恼人的现象程序跑得好好的一掉电再上电所有配置参数全部回到出厂默认值。起初怀疑是代码逻辑有bug折腾了一上午最后把问题定位到STM32片内Flash读写测试的代码上——我在写入之前忘了按页擦除数据压根没写进去。这个坑其实很典型很多初学者以为片内Flash就是调用两个库函数一读一写的事真正动手跑起来扇区划分、擦除粒度、寿命约束、调试器连接处处都是雷。这篇文章就围绕STM32片内Flash读写测试把原理、代码、实测方法和排错经验完整过一遍适合正在做参数掉电保存、IAP升级或者写毕业设计时想验证片内Flash可靠性的读者。1. 为什么要盯上片内Flash一次掉电丢数据的排查经历先说清楚应用场景。我做的那块板子需要保存WiFi配置、校准参数、运行累计次数这类数据的特点是容量小、不常写、但绝对不能掉电丢失。当时第一反应是外挂一颗EEPROM或者SPI Flash比如常见的W25Q64接线简单、容量够大、掉电也不丢。但画PCB的时候发现板子空间紧张能省一颗芯片就省一颗于是我把目光落在了STM32自带的片内Flash上。片内Flash和外部存储方案的本质区别可以用下面这张表看得很清楚方案容量擦写寿命接线成本典型单位成本STM32片内Flash几十KB到1MB约1万次零片内直接访问无额外成本外部SPI FlashW25Q6464Mb起步约10万次需要CS、SCK、MOSI、MISO四根线几毛到几元外部EEPROMAT24C022Kb起步约100万次需要I2C两根线几毛到几元从成本上看片内Flash真的很香。从寿命上看1万次擦写其实很微妙如果每次上电都写一次一天开关10次大概两年半就顶到上限了。所以我的结论很明确低频写入、小容量的数据适合用片内Flash高频日志、频繁计数器这种东西还是老老实实外挂EEPROM别拿片内Flash硬扛。那次排查的现场也很有代表性。程序上电后从Flash读配置读出来一堆0xFF说明根本没写入成功。我打印了FLASH_Status返回值发现擦除那一句返回的就不是FLASH_COMPLETE。追到代码里一看因为我的写入地址落在代码段之后但目标页的地址算错了擦除根本没擦到我想用的那一页。这种错误靠肉眼盯代码很难看出来必须配合地址计算和回读结果一起排查。这也是我后来坚持做完整读写测试的原因不光要验证数据能写进去还要验证断电重启以后数据还在。2. 片内Flash的硬件脾气扇区、擦除和寿命约束2.1 Flash的最小操作粒度STM32片内Flash虽然在地址上从0x08000000开始可以直接用指针读取但它的写入和擦除规则跟RAM完全是两回事。对NOR Flash来说空闲状态是0xFF写操作的本质是把某些位从1变成0而要把0变回1唯一的手段就是擦除。擦除又是一个按页或者按扇区进行的块级操作不能单独擦某一个字节。不同型号的Flash页大小差异很大我列几个常见型号芯片型号Flash总容量页/扇区划分STM32F103C8T664KB每页1KB从0x08000000开始共64页STM32F103ZET6512KB每页2KB从0x08000000开始共256页STM32F407VET6512KB扇区0~3各16KB扇区4为64KB扇区5~7各128KBSTM32F407ZGT61MB扇区0~3各16KB扇区4为64KB扇区5~11各128KB这个差异直接决定了你选数据存储区的策略。F103C8T6的1KB页写一个小结构体绰绰有余但如果想在F407上存一个50KB的参数文件就必须跨多个扇区处理擦除一个16KB的小扇区也要几十毫秒。所以写代码之前第一件事是翻开对应芯片的数据手册把页大小和地址边界算清楚。2.2 写前擦除带来的连锁反应由于写操作只能把1变0想把一个字节从0x55改成0xAA直接在这个地址上调用写函数是做不到的。必须先擦除整页让这一页全部回到0xFF再把0xAA写进去。这就带来一个非常反直觉的约束只改一个字节整页的数据都会陪葬。实际项目里惯用的做法是先把整页读到RAM缓冲区在缓冲区里修改目标字节然后擦除整页最后把缓冲区整体写回Flash。但这里有个隐患如果擦除之后、写回之前突然掉电这一页的数据就全丢一个字节都保不住。所以有严格一致性要求的数据不能这样裸奔必须做双备份或者A/B区轮换这是后话。还有一个必须牢记的点程序本身就是放在Flash里的。如果你擦除的页正好是CPU正在执行的代码所在区域那Flash控制器忙起来之后CPU取指会直接被stall住表现就是程序卡死、看门狗超时复位严重的时候调试器都连不上。最典型的事故是把中断向量表所在的第一页给擦了等于把自己埋了。所以数据存储区规划一定要避开代码段、中断向量表和常量区最稳妥的是放在芯片Flash的最后几页。2.3 寿命和掉电风险片内Flash标称擦写寿命一般是1万次注意这是按页计算的。如果你每次都擦写同一页这个页很快会报废。做测试的时候也别没事就反复断电重启几百次看起来很快实际上在加速消耗寿命。掉电风险也是测试必须考虑的场景。Flash擦写不是一个瞬间动作擦一页通常需要十到几十毫秒写一个半字也需要微秒级时间。如果在擦除或者写入过程中掉电Flash可能停在某个中间状态轻则写入失败重则整页数据变成不可预知的乱值。这也是我后来坚持在测试程序里做标志字、做校验和的根本原因。3. 标准外设库读写实操从解锁到回读的四步闭环3.1 为什么要先解锁STM32为了防止程序跑飞后误改Flash给Flash控制器加了一把锁。正常复位后Flash控制寄存器是被锁住的任何擦写命令都不会执行。想操作Flash必须先按顺序向密钥寄存器写入两个固定值0x45670123和0xCDEF89AB。如果这两个值写错了顺序或者写错内容整把锁会死锁必须复位芯片才能重新尝试。标准库把这些细节都封装好了用起来是四步解锁、擦除、写入、上锁。上锁这一步很多人会忘虽然不会导致立即出错但会让Flash长期处于可写状态遇到野生指针乱跳时风险非常高。习惯是每次写完立刻FLASH_Lock()让代码处于平时锁死、用时解锁的安全状态。3.2 完整读写样例下面是一个基于STM32F103标准外设库的完整测试函数目标是往最后一页0x0800FC00写入一批半字数据然后回读校验#include stm32f10x_flash.h #define FLASH_TEST_BASE 0x0800FC00 /* F103C8T6最后一页第63页 */ #define PAGE_SIZE_BYTES 1024 uint8_t FlashPageWrite(uint16_t *buf, uint16_t len) { uint32_t addr FLASH_TEST_BASE; uint16_t i; if (len * 2 PAGE_SIZE_BYTES) { return 1; /* 数据超过一页容量 */ } FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); if (FLASH_ErasePage(FLASH_TEST_BASE) ! FLASH_COMPLETE) { FLASH_Lock(); return 2; /* 擦除失败 */ } for (i 0; i len; i) { if (FLASH_ProgramHalfWord(addr i * 2, buf[i]) ! FLASH_COMPLETE) { FLASH_Lock(); return 3; /* 写入失败 */ } } FLASH_Lock(); return 0; } uint8_t FlashPageVerify(uint16_t *buf, uint16_t len) { uint16_t i; for (i 0; i len; i) { if (*(volatile uint16_t *)(FLASH_TEST_BASE i * 2) ! buf[i]) { return 0; /* 校验不通过 */ } } return 1; }调用方式也很直接往缓冲区填数据写入回读看打印结果uint16_t write_buf[64], read_buf[64]; uint16_t i; for (i 0; i 64; i) { write_buf[i] 0xA000 i; } if (FlashPageWrite(write_buf, 64) 0) { if (FlashPageVerify(write_buf, 64)) { printf(flash write and verify OK\r\n); } else { printf(verify fail\r\n); } }3.3 三个容易被忽略的细节第一个细节是地址对齐。FLASH_ProgramHalfWord要求传入地址必须是2字节对齐也就是地址最低位为0。如果传的是奇数地址Flash控制器不会替你处理直接就是HardFault。同理读取的时候也不要随意拿一个奇数地址去强转uint16_t指针。第二个细节是FLASH_ProgramWord并不是一个原子操作。标准库里它内部其实拆成了两次FLASH_ProgramHalfWord先写低16位再写高16位。如果你在写Word的过程中掉电会出现低半字是新值、高半字是旧值这种撕裂状态。所以做数据一致性设计时宁可自己控制写入顺序也别迷信一个函数能一次搞定。第三个细节是回读一定要用volatile指针。直接从Flash地址读数据编译器可能以为目标值不会变化优化掉重复读取逻辑。加volatile不只是习惯是为了防止编译器自作聪明。做测试程序时这个细节会直接影响判断结果。4. HAL库迁移指南F1和F4的API与扇区差异4.1 两套库的API对照很多读者从标准库切到HAL库时都会懵一阵因为API名字变了参数含义也变了。我整理了一份简单的对照表操作标准外设库HAL库解锁FLASH_Unlock()HAL_FLASH_Unlock()擦除页/扇区FLASH_ErasePage(addr)HAL_FLASHEx_Erase(erase, err)写入FLASH_ProgramHalfWord(addr, data)HAL_FLASH_Program(type, addr, data)上锁FLASH_Lock()HAL_FLASH_Lock()等待操作完成库函数内部处理HAL_FLASH_Program内部处理HAL库最大的变化是擦除操作不再直接传地址而是要填一个FLASH_EraseInitTypeDef结构体指定扇区号、扇区数量和电压范围。这种设计看起来繁琐但好处是强制你弄清楚当前芯片的扇区边界而不是随便丢一个地址进去。4.2 F4扇区边界和地址换算F4系列的扇区不是均匀的前4个扇区只有16KB第5个扇区64KB后面全是128KB。所以不能简单用地址除以扇区大小来算扇区号必须按边界判断。下面是常用的辅助函数static uint32_t UserFlash_GetSector(uint32_t addr) { if (addr 0x08004000) return FLASH_SECTOR_0; if (addr 0x08008000) return FLASH_SECTOR_1; if (addr 0x0800C000) return FLASH_SECTOR_2; if (addr 0x08010000) return FLASH_SECTOR_3; if (addr 0x08020000) return FLASH_SECTOR_4; if (addr 0x08040000) return FLASH_SECTOR_5; if (addr 0x08060000) return FLASH_SECTOR_6; if (addr 0x08080000) return FLASH_SECTOR_7; return FLASH_SECTOR_7; }写数据时这样用void UserFlash_Program(uint32_t addr, uint32_t *buf, uint32_t len) { FLASH_EraseInitTypeDef erase; uint32_t sector_error 0; uint32_t i; HAL_FLASH_Unlock(); erase.TypeErase FLASH_TYPEERASE_SECTORS; erase.Sector UserFlash_GetSector(addr); erase.NbSectors 1; erase.VoltageRange FLASH_VOLTAGE_RANGE_3; if (HAL_FLASHEx_Erase(erase, sector_error) ! HAL_OK) { // 到这里说明擦除失败检查sector_error定位具体扇区 HAL_FLASH_Lock(); return; } for (i 0; i len; i) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr i * 4, buf[i]) ! HAL_OK) { HAL_FLASH_Lock(); return; } } HAL_FLASH_Lock(); }注意F4的HAL_FLASH_Program支持多种数据宽度FLASH_TYPEPROGRAM_BYTE、HALFWORD、WORD、DOUBLEWORD都有。选哪种决定了地址对齐要求WORD操作要求4字节对齐DOUBLEWORD要求8字节对齐。F1的标准库通常只有半字和字两种没有字节写入这也是很多人从F1代码移植到F4后莫名其妙出错的原因。4.3 阻塞时间、看门狗与中断调度HAL库的Flash擦写函数是阻塞式的擦一个扇区通常要几十毫秒在极端情况下可能接近百毫秒。这段时间里CPU没法从Flash取指实际上是被stall住的。如果系统里开了IWDG独立看门狗而喂狗超时设置得又很短这次擦写很可能直接把系统复位。处理办法有两种要么把喂狗周期放宽确保擦写期间不会超时要么把Flash擦写放在关中断的临界区里快速执行并在结束后立刻喂狗。第二种方式更安全但要注意关中断时间不能太长否则实时性要求高的任务会出问题。还有一点如果系统跑的是RTOS多个任务同时调用HAL_FLASH_Unlock会出现不可重入的问题最好用一个互斥锁把整个Flash操作包起来保证同一时刻只有一个任务在操作Flash。5. 掉电测试三板斧怎么才算真的数据不丢5.1 第一板斧上电计数器法最简单的验证办法是设计一个计数器每次上电加1存到Flash里。断电再上电如果数值是递增的说明数据真的跨掉电周期保留了。具体做法是把整页读到RAM缓冲区取出第一个半字加1然后擦整页、写回。代码如下#define PAGE_ADDR 0x0800FC00 #define PAGE_SIZE 1024 uint16_t page_buf[PAGE_SIZE / 2]; uint32_t boot_count 0; uint16_t i; /* 1. 整页读到RAM */ for (i 0; i PAGE_SIZE / 2; i) { page_buf[i] *(volatile uint16_t *)(PAGE_ADDR i * 2); } /* 2. 解析旧值全0xFF视为从未写过 */ if (page_buf[0] ! 0xFFFF) { boot_count page_buf[0]; } boot_count; /* 3. 把修改后的缓冲区写回Flash */ page_buf[0] (uint16_t)boot_count; FlashPageWrite(page_buf, PAGE_SIZE / 2); printf(boot count %lu\r\n, boot_count);串口输出如果能看到每次重启都加1说明读写链路是通的。这个测试成本低适合在开发阶段快速验证驱动是否正常。5.2 第二板斧结构体加魔法字计数器只能证明能写进去能读出来但真实产品里存的往往是一堆参数比如结构体、字符串、浮点数。这时候我推荐在数据区开头放一个固定的魔法字例如0xA5A5A5A5用来判断Flash里是不是有效数据。上电流程是读Flash首地址如果魔法字正确直接把后面的数据拷到RAM里使用如果不对说明是第一次上电或者数据损坏就用默认值初始化结构体然后写回Flash。这个设计同时解决了怎么判断数据有效和怎么给参数做默认值兜底两个问题是实际项目里最常用的模式。测试的时候可以故意把某个参数改成特殊值断电重启看它是否保持。连做10次、20次都稳定才说明基本可用。5.3 第三板斧批量数据加校验和只测一个计数器还不足以暴露跨页、地址错位的问题。我的习惯是再写一批随机数据长度尽量贴近页容量比如F103C8T6写满512字节然后回读并累加校验和uint16_t write_buf[256]; uint16_t read_buf[256]; uint32_t sum 0; uint16_t i; for (i 0; i 256; i) { write_buf[i] (uint16_t)(i * 7 0x1234); } FlashPageWrite(write_buf, 256); for (i 0; i 256; i) { read_buf[i] *(volatile uint16_t *)(PAGE_ADDR i * 2); sum (read_buf[i] ! write_buf[i]); } if (sum 0) { printf(ALL DATA MATCH\r\n); } else { printf(MISMATCH COUNT %lu\r\n, sum); }批量写入的好处是能逼出地址偏移问题尤其是当写入长度跨了页边界时很多隐藏bug立刻现形。如果写入数据跨页必须分成两次擦写分别处理两页千万别把连续地址当连续页直接撸过去。5.4 测试判据和边界情况我的经验是掉电测试至少要覆盖这几类场景正常上电读数据、正常断电重启、写入过程中断电、擦除过程中断电。正常读写测的是功能写一半断电测的是数据一致性后者才是真正拉开普通工程师和有经验工程师差距的地方。写入过程中断电后可能读到旧数据、新数据、或者Cortex-M3总线错误但绝不能容忍芯片彻底锁死。如果做了双备份区这种异常断电后还能自动回滚到上一份有效数据这才是产品级的表现。6. 读写测试中的经典翻车现场下载失败到写保护6.1 flash download failed这类报错的排查链路读写测试过程中我最常见到的报错就是Keil里弹出类似error: flash download failed - target dll has been cancelled或者error: no stm32 target found! if your product embeds debug authentication的提示。这类问题表面看是下载失败根源却五花八门。我习惯按下面这条链路来排查现象常见原因处理办法下载失败Target DLL cancelledSWD接线不稳定、USB供电不足换线、降SWD速度到4MHz以下、重新插拔调试器找不到目标芯片驱动异常、BOOT0被拉高重装驱动BOOT0拨回0再上电能连接但下载提示Flash算法失败Keil里Flash Download配置漏选或选错型号在Options for Target里重新选择对应容量Flash算法程序能跑但一写入就复位擦除的页覆盖了正在执行的代码检查数据区地址避开代码段和向量表下载后无法调试只能全片擦除芯片读保护被开启用STM32 CubeProgrammer执行全片擦除解除这里特别要强调如果板子BOOT0引脚被跳线拉高芯片上电后进的是系统存储器里的bootloader你的用户程序根本不会运行SWD连接也表现得很奇怪。排查这类诡异问题第一件事永远是量电压、查BOOT、查复位。6.2 写保护与读保护把芯片锁死的元凶Flash有一个选项字节区里面存着读保护级别和写保护策略。读保护分为Level 0、Level 1、Level 2默认是Level 0。如果你在测试过程中不小心设置了读保护Level 1芯片会禁止调试器通过SWD直接读Flash内容除非执行全片擦除。Level 2更狠设置后连恢复的路径都封死基本等于把芯片废掉。写保护则是针对特定Flash扇区的一旦开启往受保护扇区写数据会直接报WRPRTERR。测试时如果发现返回的错误标志永远是写保护错误不要只怀疑代码优先检查选项字节里是不是误开启了WRP。ST-LINK Utility里可以很直观地看到这些配置也能一键复位。6.3 连不上调试器时的自救工具当芯片被写保护、读保护折磨得连不上Keil时别慌先试试ST-LINK Utility或者STM32CubeProgrammer。这两个工具可以直接操作底层Flash选项字节在连接界面选择Connect under reset让芯片在复位状态下被拉进调试模式通常能救回来。救回来之后赶紧做一次Whole chip erase把选项字节恢复默认再回到Keil里重新下载程序。最后分享一个我自己踩过几次坑之后养成的习惯每次下载新的测试程序之前先让调试器执行一次全片擦除杜绝上次测试留下的脏数据干扰判断然后在数据区末尾放一个魔法字每次上电先检查它一旦发现不是预期值系统能自己恢复到安全默认状态而不是带着乱数据往下跑。这个习惯帮我挡掉了至少三次看起来是偶发bug其实是Flash数据残留的假象。片内Flash不是洪水猛兽但它比EEPROM更需要敬畏。搞清楚页大小、遵守擦除规则、做足回读和掉电验证这套流程走通了后面做IAP升级、做参数存储都会顺手很多。本文还有配套的精品资源点击获取