STM32 片上 Flash 读写实战:把参数存进芯片,掉电不丢
我早年做个温控器,用户拿面板按键设个目标温度 26 度,再调两下 PID 的 P、I 参数。我图省事,这些全塞在一个全局结构体里,RAM 里跑得挺欢。结果客户打电话来:“我家地暖昨天设的 28 度,今早变回出厂默认 20 度了,大冬天给我冻醒。”
我一拍脑门——对哦,断电重启全局变量全清零,谁给你记得住。更坑的是 ADC 同步采样、低功耗唤醒这类场景的校准偏移值,每次上电重新算一遍又慢又漂,好不容易标定的零点偏移说没就没。
后来才明白:STM32 片内那块 Flash,本来就是拿来存固件的,你代码只占前面一小段,后面大把没用到的页,就是一块断电不丢、随你当 EEPROM 用的存储区。把它用起来,参数掉电就保留了。
一、片上 Flash 到底是个啥脾气
和 RAM 最大的区别在三个字:它嫌烦。
- 只能 1 写 0,不能 0 写 1。Flash 里每个 bit 出厂全是 1(0xFF)。你写数据是把某些 bit 清成 0。想把一个已经为 0 的 bit 改回 1?做不到,除非整页擦除。
- 写之前必须整页/整扇区擦除。擦除的操作就是把一整页恢复成全 1(0xFFFF…)。不擦直接写,原来 0 的位置永远改不回来,数据就错了。
- 有写保护,得先解锁。Flash 寄存器默认锁着,写之前往密钥寄存器塞两个魔数解锁,写完好锁回去,防止程序跑飞误改固件。
- 读随便,写很贵。读它跟读 RAM 差不多随意;写要按固定粒度(半字/字/双字)对齐编程,一页擦除动辄几十毫秒,而且写的时候这块 Flash 总线是锁死的。
低功耗节点把"采得省"解决了;这一篇解决"采完的配置存得住"——校准偏移、用户设定,掉电不丢,下次上电直接读。
二、CubeMX 怎么配
Flash 在 CubeMX 里没有专门的外设开关,它跟着 AHB 时钟走,默认就开着。你要确认的是三件事:
1. 系统时钟 → FLASH 等待周期(LATENCY)
CPU 主频越高,读 Flash 取指要等的周期越多。CubeMX 在SystemClock_Config()里会按你设的频率自动配FLASH_LATENCY(比如 F103 跑 72MHz 要 2 个 wait state)。你自己改时钟后得回头确认这个值对——配小了取指错,程序跑飞。
2. 确认参数页地址,避开你的代码
最稳的办法是看编译出来的.map文件,找到代码实际占到的末尾地址,把参数页放在 Flash最末尾且留足余量。F103C8T6 是 64KB Flash、每页 1KB,最后一页就是0x0800FC00;F407 是 1MB、扇区 11 在0x080E0000(128KB)。
3. 链接脚本留区域(进阶,可选)
不想手算地址的,在链接脚本里加一段_flash_param区域,让链接器保证你的代码不会长到那去。初学阶段直接用固定常数地址也行,只要留够余量。
三、最少的代码
读很简单,Flash 随意读,拿指针解引用就行。写要按"解锁→擦页→编程→锁"四步来。
typedefstruct{uint16_tversion;// 版本号, 区分不同固件结构的参数floattarget_temp;// 目标温度, 用户设的int16_tadc_offset;// ADC 校准偏移, 同步采样场景用得上uint16_tcrc;// 校验和, 防读坏}Param_t;#definePARAM_VERSION1#definePARAM_FLASH_ADDR((uint32_t)0x0800FC00)// F103 最后一页(1KB)Param_t g_param;// ---- 读: Flash 随意读, 直接指针解引用, 但必须校验 ----staticuint16_tcalc_crc(Param_t*p){uint16_tsum=0;uint8_t*b=(uint8_t*)p;for(inti=0;i<sizeof(Param_t)-2;i++)sum+=b[i];returnsum;}voidparam_load(void){Param_t*p=(Param_t*)PARAM_FLASH_ADDR;// 版本不对 或 校验不过 -> 说明没写过/写坏了, 用默认, 别拿垃圾当真值if(p->version==PARAM_VERSION&&calc_crc(p)==p->crc){g_param=*p;}else{g_param.version=PARAM_VERSION;g_param.target_temp=20.0f;// 出厂默认g_param.adc_offset=0;}}// ---- 写: 解锁 -> 擦页 -> 编程 -> 锁 ----HAL_StatusTypeDefparam_save(void){g_param.version=PARAM_VERSION;g_param.crc=calc_crc(&g_param);HAL_FLASH_Unlock();// 1. 解锁FLASH_EraseInitTypeDef ei={0};// 2. 擦整页(页内全变0xFF)ei.TypeErase=FLASH_TYPEERASE_PAGES;ei.PageAddress=PARAM_FLASH_ADDR;ei.NbPages=1;uint32_terr=0;HAL_FLASHEx_Erase(&ei,&err);// 3. 按半字(16位)编程, F1 系列特点; 地址必须 2 对齐uint16_t*src=(uint16_t*)&g_param;for(inti=0;i<sizeof(Param_t)/2;i++){HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD,PARAM_FLASH_ADDR+i*2,src[i]);}HAL_FLASH_Lock();// 4. 锁回returnHAL_OK;}这段在干嘛:上电param_load从末尾那页指读取结构体,版本号和校验和都过了才用,不然回落到出厂默认——这步救过我好几次"参数页写一半掉电变砖"的命。存的时候param_save先解锁,把整页擦干净,再一个半字一个半字把结构体写进去,最后锁回去。F1 系列是半字(16 位)编程;你要是 F4/F7,用的是字(32 位)或双字(64 位)编程,见后面坑 3。
★ 关键认知:Flash 不是"能改写的 RAM",是"断电不丢、但写起来很贵的 ROM"。读它随便,写它先擦、对齐、解锁、关中断。你把它当 EEPROM 随手param_save(),迟早吃瘪。
四、真正会坑你的几个点
1. 写之前没擦除。最高频的坑。Flash 只能 1 写 0,你跳过擦除直接HAL_FLASH_Program,原来为 0 的 bit 永远回不了 1,写进去的数据跟你想的完全两样。记住:擦除是写的前置动作,页内全变 0xFF 之后才能编程。
2. 擦除/编程前没解锁。HAL_FLASH_Unlock()不调,Flash 寄存器是锁的,所有写操作直接返回HAL_ERROR,你以为存进去了,其实啥也没动。忘记解锁是"静默失败"的典型——函数返回错,参数页还是旧的。
3. 编程粒度/地址对齐错。F1 只能半字(16 位)编程,地址必须 2 字节对齐;F4/L4 是字(32 位)或双字(64 位)对齐;H7 支持 256 位线但编程单位是双字。你用uint32_t指针按字节往里怼、或地址没对齐,直接 HardFault 或写失败。照着你芯片手册的"编程并行度"来选FLASH_TYPEPROGRAM_xxx。
4. 地址算错,擦到自己的代码。★ 最惨的坑。参数页地址没算准,落在你的固件身上,一调HAL_FLASHEx_Erase把自己的程序擦没了——板子变砖,得拿下载器重刷。务必看.map确认代码实际大小,参数页放 Flash 最末尾留足余量;或者链接脚本里划一块专属区域,让链接器保证代码长不过去。
5. 写 Flash 时来了个中断,直接 HardFault。★ 第二惨的坑。CPU 取指、中断向量表、常量全在 Flash 里。擦写 Flash 时那块 Bank 的总线是锁死的,期间任何"读 Flash"的操作会被 stall 甚至 HardFault。你正在param_save擦页(几十毫秒),突然来个定时器中断,CPU 去 Flash 取中断向量和 ISR 代码——取不到,HardFault 原地爆炸。写 Flash 期间要么__disable_irq()关中断,要么把关键代码/向量搬 RAM,要么接受这段时间里别跑别的 Flash 代码。
6. FLASH_LATENCY 没跟上频率。你把 CPU 超到 72MHz 但 LATENCY 还配成 0 wait state,读 Flash 取指时序不对,程序跑飞、进 HardFault。HAL 的SystemClock_Config一般自动配好,但你手动改时钟后一定回头核对等待周期。
7. 在中断里调 param_save,整机卡死。页擦除动辄二三十毫秒,在中断服务程序里调param_save,别的实时任务全被卡住;而且还会撞上坑 5 的 HardFault。写 Flash 放主循环、关中断或搬 RAM 里做,别在 ISR 里干。
8. 页大小因型号天差地别。F103C8 是 1KB/页,F407 是扇区(16/64/128KB 不等),F0 有 1KB/2KB 页,L4 又是 2KB 页。你照抄别人"擦第 63 页"的代码,型号一换页大小变了,地址全错,轻则写不进、重则擦到代码(见坑 4)。每次换芯片先翻 reference manual 的 Flash 组织表。
9. 频繁写,寿命耗尽。Flash 擦写寿命大约 1 万次(部分型号 10 万)。你每采一笔、每动一下按键就param_save,一天几万次,几小时写废。做法:只在参数真正变化时才存;或上磨损均衡——用两页乒乓,或在一页里顺序追加写,轮流用,把磨损摊开(见下图)。
10. 不校验就读,读出垃圾当真值。Flash 擦写中途掉电,参数页写一半,半残。读出来 version 不对、crc 不过,必须 fallback 默认并标记无效;不然读到一堆乱七八糟的数去控温、去标定偏移,出的是实打实的事故。我那个温控器一开始没做校验,偶尔冷启动会读到离谱的 target_temp,差点烧了加热棒。
五、完整例程骨架
把"读校验、写四步、磨损均衡"串起来:上电读校验,参数变了才存,用两页乒乓把擦写寿命摊开。
#definePARAM_PAGE_A0x0800FC00#definePARAM_PAGE_B0x0800F800// 上一页, 乒乓用voidparam_load(void){Param_t*p=(Param_t*)PARAM_PAGE_A;if(p->version==PARAM_VERSION&&calc_crc(p)==p->crc){g_param=*p;return;}p=(Param_t*)PARAM_PAGE_B;// A 坏了试 Bif(p->version==PARAM_VERSION&&calc_crc(p)==p->crc){g_param=*p;return;}g_param=default_param();// 都坏, 用出厂默认}HAL_StatusTypeDefparam_save(void){g_param.version=PARAM_VERSION;g_param.crc=calc_crc(&g_param);HAL_FLASH_Unlock();// 轮流擦 A / 擦 B, 把磨损摊开staticinttoggle=0;uint32_taddr=toggle++?PARAM_PAGE_B:PARAM_PAGE_A;FLASH_EraseInitTypeDef ei={0};ei.TypeErase=FLASH_TYPEERASE_PAGES;ei.PageAddress=addr;ei.NbPages=1;uint32_terr=0;HAL_FLASHEx_Erase(&ei,&err);uint16_t*src=(uint16_t*)&g_param;for(inti=0;i<sizeof(Param_t)/2;i++)HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD,addr+i*2,src[i]);HAL_FLASH_Lock();returnHAL_OK;}磨损均衡的本质:别总怼同一页,两页轮流擦写,寿命翻倍;要求更高就在一页里顺序追加多条记录、写满再换页。平均到每页的擦写次数降下来,十年也写不废。
Flash 读写这件事,难的不是那几行Unlock/Erase/Program/Lock,是搞清楚"它不能像 RAM 那样随便改"——读随便,写先擦、对齐、解锁、关中断,别把它当 EEPROM 随手怼。把参数页放对位置、写好校验、加上磨损均衡,它就是一块稳稳断电不丢的小仓库。
适合:需要掉电保留参数的任何场景(用户设定、校准偏移、配置标志、运行计数);正好给低功耗节点做"配置持久化"补完。不适合:高频连续记录(Flash 慢且寿命有限,那种该上外部 SPI Flash 或 FRAM)。铁律四条:写前必擦除、写前必解锁、写期间必关中断(或搬 RAM)防 HardFault、参数必带版本号加校验防读坏;参数页地址务必核对芯片手册的页/扇区大小和代码实际占用,千万别擦到自己的固件。