1. 从“掉电就丢”到“数据永驻”:为什么我们需要操作FLASH
做嵌入式开发,尤其是用STM32这类MCU做项目,你肯定遇到过这样的场景:设备需要记住一些关键参数,比如用户的校准数据、设备的运行次数、最后一次的报警记录,或者是一个简单的计数器。这些数据在设备重启、甚至断电后,都不能丢失。这时候,你第一时间想到的可能是外挂一个EEPROM芯片,比如AT24C02。这确实是个经典方案,I2C通信,读写也方便。但随之而来的是成本的增加(芯片本身、PCB面积、布线复杂度)和可靠性的潜在风险(多一个器件,就多一个故障点)。
其实,STM32内部自带了一个“宝藏”——内部FLASH。它本质上就是一块非易失性存储器,和EEPROM一样,掉电数据不丢失。对于很多非频繁修改、数据量不大的应用场景,直接使用内部FLASH来存储这些参数,是性价比最高、最简洁的方案。它省去了外部器件,简化了硬件设计,降低了BOM成本。我做过不少消费类和工业控制的小项目,但凡需要存储几个到几十K的配置数据,首选就是折腾内部FLASH,而不是去画一个EEPROM的电路。
但是,操作内部FLASH和操作内存(SRAM)完全是两码事。内存读写就像在纸上用铅笔写字,想写就写,想擦就擦,速度极快。而FLASH的读写,特别是“写”操作,更像是在雕刻石板:你必须先整块“擦除”(把石板磨平),然后才能“编程”(刻上新字),而且这个过程相对较慢,有严格的时序和寿命限制。很多新手第一次尝试直接往FLASH地址写数据,结果程序跑飞或者数据压根没写进去,根本原因就是没搞懂FLASH的这些“脾气”。
所以,这篇内容我们就来彻底搞懂在STM32标准库环境下,如何安全、正确、高效地读写内部FLASH。我会结合我踩过的坑和项目经验,把原理、步骤、注意事项掰开揉碎了讲清楚,让你不仅能“抄作业”跑通代码,更能明白背后的“所以然”,以后遇到任何FLASH相关的问题都能自己解决。
2. 庖丁解牛:深入理解STM32内部FLASH的物理结构
在动手写代码之前,我们必须像了解自己家房子结构一样,了解STM32内部FLASH的物理布局。这是所有操作的基础,很多诡异的问题都源于对这里的不熟悉。不同型号的STM32,其FLASH容量和组织结构可能不同,我们以最常见的STM32F103系列(中等容量)为例进行拆解。
2.1 FLASH的“楼层”与“房间”:主存储块与信息块
STM32F103的FLASH主要分为两大区域:主存储块(Main Flash Memory)和信息块(Information Block)。
主存储块就是我们存放用户程序代码和用户数据的主要区域。对于STM32F103C8T6(64KB FLASH)来说,它的主存储块被划分为64页(Page),每页1KB。注意,这里的“页”是擦除操作的最小单位。也就是说,你想修改某一个字节,不能只擦除那个字节,必须擦除它所在的整个1KB的页。这是FLASH操作中最核心、也最容易犯错的一个特性。
信息块又包括两部分:
- 系统存储器(System Memory):这里面存放的是STM32出厂时预置的Bootloader程序。我们通过串口下载程序(ISP),靠的就是它。这部分区域是只读的,用户无法擦写。
- 选项字节(Option Bytes):这是一个非常关键的区域。它控制着芯片的一些重要硬件特性,比如:
- 读写保护(RDP):相当于给FLASH上锁,防止程序被非法读取或修改。一旦设置,解锁过程比较麻烦,可能触发全片擦除。
- 用户配置:如看门狗是硬件使能还是软件使能、复位后某些引脚的状态等。
- 写保护(WRP):可以对主存储块的特定页(比如前几页存放关键代码的页)进行写保护,防止程序跑飞后误擦写。
重要提示:除非你非常清楚你在做什么,并且有完整的恢复方案(比如通过调试器连接),否则绝对不要轻易去修改选项字节!错误的配置可能导致芯片无法再次下载程序(俗称“锁死”),需要借助特殊的工具或方法才能恢复。在大多数应用数据存储场景中,我们只操作主存储块。
2.2 FLASH的“寿命”与“规矩”:擦写次数与操作时序
FLASH不是金刚不坏之身,它有物理寿命。STM32内部FLASH的典型擦写寿命是1万次(10k cycles)。这意味着同一个页,你反复擦写1万次后,就可能出现数据保存不可靠的情况。对于存储偶尔更新的配置参数,这个寿命绰绰有余。但如果你想用它来记录频繁变化的数据(比如每秒记录一次),就必须设计磨损均衡算法,让写操作均匀分布到不同的页上,避免“写死”某一页。
操作时序则是FLASH的“工作节奏”。FLASH控制器内部有严格的时序要求,比如擦除和编程操作都需要特定的延迟。在标准库中,这些时序控制由库函数FLASH_SetLatency来设置,它需要根据你的系统时钟(SYSCLK)来配置正确的等待周期(Latency),以确保CPU能稳定地读取FLASH中的指令和数据。如果设置不对,轻则程序运行不稳定,重则根本无法启动。
2.3 找到你的“数据仓库”:规划FLASH存储地址
我们的程序代码是从FLASH的起始地址(0x0800 0000)开始存放的。如果我们把数据也胡乱存,很可能会覆盖掉程序代码,导致系统崩溃。因此,我们必须为数据规划一个安全的“仓库”区域。
一个通用的做法是,在程序代码后面,找一个空闲的页作为数据存储页。如何知道代码用了多少空间呢?你可以查看编译后生成的.map文件,找到程序的末尾地址。更简单可靠的方法是,在链接脚本(.ld文件或分散加载文件)中直接定义一个特定的存储区域。不过,在标准库工程中,更常见的实践是手动指定一个绝对地址。
例如,对于STM32F103C8T6(64KB FLASH,地址范围0x0800 0000 ~ 0x0800 FFFF),我们可以把最后一页(第63页,地址0x0800 FC00 ~ 0x0800 FFFF)用作数据存储。这样,只要我们的程序代码不超过62KB(0x0800 F800),就绝对安全。
// 定义FLASH数据存储的起始地址 (STM32F103C8T6 最后一页) #define FLASH_DATA_BASE_ADDR ((uint32_t)0x0800FC00) // 定义页大小,对于F103中等容量是1K #define FLASH_PAGE_SIZE (1024)在定义地址时,务必确保该地址是页对齐的(即地址是FLASH_PAGE_SIZE的整数倍)。非对齐的地址在进行擦除操作时会导致硬件错误。
3. 实战演练:基于标准库的FLASH读写驱动实现
理论清楚了,现在我们来搭建一个完整、健壮的FLASH操作驱动。我会把每一步的代码和背后的意图都解释清楚。
3.1 环境准备与核心函数解读
首先,在你的工程中,确保包含了STM32标准库中FLASH操作相关的头文件:#include “stm32f10x_flash.h”。标准库提供了一系列函数来简化FLASH操作,核心函数如下:
FLASH_Unlock():解锁FLASH控制寄存器。在对FLASH进行擦除或编程前,必须先调用此函数,否则操作会被硬件拒绝。FLASH_Lock():操作完成后,重新锁上FLASH控制寄存器。这是一个好习惯,可以防止程序异常时误操作FLASH。FLASH_ErasePage(uint32_t Page_Address):擦除指定地址所在的整个页。参数是页内的任何一个地址(通常我们传入页的起始地址)。FLASH_ProgramHalfWord(uint32_t Address, uint16_t Data):向指定地址编程一个半字(16位数据)。这是STM32F1系列FLASH编程的最小单位!你不能单独编程一个字节(8位)。如果你想写一个8位数据,也需要以16位的形式操作,高字节通常填充0xFF或保留。FLASH_Status FLASH_GetStatus(void):获取FLASH操作的状态,用于判断擦除或编程是否完成、是否发生错误。
3.2 封装一个健壮的FLASH数据写入函数
直接使用库函数虽然可以,但不够安全和完善。我们需要封装一个更高级的函数,它应该能处理擦除、按16位写入、等待操作完成、检查状态等全套流程。
/** * @brief 向指定FLASH页写入数据 * @param WriteAddr: 写入的起始地址(必须页对齐) * @param pBuffer: 要写入的数据缓冲区指针 * @param NumToWrite:要写入的16位数据的个数 * @retval 状态:0-成功,其他-失败(错误代码可自定义) */ uint8_t FLASH_WriteData(uint32_t WriteAddr, uint16_t *pBuffer, uint16_t NumToWrite) { uint16_t i; FLASH_Status status = FLASH_COMPLETE; /* 1. 检查地址是否页对齐 */ if((WriteAddr % FLASH_PAGE_SIZE) != 0) { return 1; // 错误码:地址不对齐 } /* 2. 解锁FLASH */ FLASH_Unlock(); /* 3. 清除所有之前的错误标志(可选但推荐)*/ FLASH_ClearFlag(FLASH_FLAG_BSY | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); /* 4. 擦除目标页 */ status = FLASH_ErasePage(WriteAddr); if(status != FLASH_COMPLETE) { FLASH_Lock(); return 2; // 错误码:擦除失败 } /* 5. 逐个半字编程 */ for(i = 0; i < NumToWrite; i++) { status = FLASH_ProgramHalfWord(WriteAddr + (i * 2), pBuffer[i]); if(status != FLASH_COMPLETE) { break; // 编程出错,跳出循环 } } /* 6. 操作完成,重新上锁 */ FLASH_Lock(); /* 7. 返回最终状态 */ return (i == NumToWrite) ? 0 : 3; // 成功返回0,失败返回3(编程失败) }关键点解析:
- 地址对齐检查:这是第一道安全关卡,防止传入错误地址导致硬件错误。
- 解锁与上锁:这对函数必须成对出现,像互斥锁一样。
- 擦除在前:这是铁律!写之前必须先擦除整页。即使这页是空的(全0xFF),也建议执行擦除操作,以确保状态正确。
- 最小编程单位:
FLASH_ProgramHalfWord是F1系列的唯一选择。如果你要存储uint8_t数组,需要将其转换为uint16_t数组,或者两个字节合并成一个半字来写。 - 错误处理:每一步操作后检查
FLASH_GetStatus()或函数返回值是良好的编程习惯。在实际产品代码中,你可能需要更详细的错误日志。
3.3 实现FLASH数据读取函数
读取操作就简单多了,因为FLASH可以像只读存储器一样被随机访问。我们只需要进行指针操作即可。但要注意地址必须是有效的FLASH地址。
/** * @brief 从指定FLASH地址读取数据 * @param ReadAddr: 读取的起始地址 * @param pBuffer: 读取数据存放的缓冲区指针 * @param NumToRead:要读取的16位数据的个数 * @retval None */ void FLASH_ReadData(uint32_t ReadAddr, uint16_t *pBuffer, uint16_t NumToRead) { uint16_t i; for(i = 0; i < NumToRead; i++) { // 通过指针直接访问FLASH地址 pBuffer[i] = *(volatile uint16_t*)(ReadAddr + (i * 2)); } }这里使用volatile关键字非常重要,它告诉编译器不要优化对此地址的访问,因为FLASH的内容可能在编译器不知情的情况下被改变(例如由我们自己写的函数)。
3.4 应用示例:存储与读取一个系统配置结构体
光有底层读写函数还不够,我们需要一个应用层的例子。假设我们要存储一个系统配置SysConfig。
// 系统配置结构体(注意4字节对齐,方便FLASH操作) typedef struct __attribute__((packed)) { uint32_t systemUpTime; // 系统运行时间 uint16_t deviceID; // 设备ID uint8_t brightness; // 屏幕亮度 uint8_t volume; // 音量 uint32_t crc32; // CRC校验值,用于验证数据完整性 } SysConfig_t; SysConfig_t g_sys_config; // 1. 准备要保存的数据 g_sys_config.systemUpTime = 1234567; g_sys_config.deviceID = 0xAA55; g_sys_config.brightness = 80; g_sys_config.volume = 60; // 计算CRC(假设有CRC32函数),填充到结构体末尾 g_sys_config.crc32 = calculate_crc32((uint8_t*)&g_sys_config, sizeof(g_sys_config) - 4); // 2. 将结构体指针强制转换为uint16_t*,因为写入函数需要16位数据指针 // 计算需要写入多少个16位数据 uint16_t data_size_in_halfwords = (sizeof(SysConfig_t) + 1) / 2; // +1是为了向上取整 // 3. 调用写入函数 uint8_t write_status = FLASH_WriteData(FLASH_DATA_BASE_ADDR, (uint16_t*)&g_sys_config, data_size_in_halfwords); if(write_status == 0) { printf(“Configuration saved to FLASH successfully.\r\n”); } else { printf(“FLASH write failed with error code: %d\r\n”, write_status); } // 4. 读取验证 SysConfig_t read_back_config; FLASH_ReadData(FLASH_DATA_BASE_ADDR, (uint16_t*)&read_back_config, data_size_in_halfwords); // 5. 校验CRC uint32_t calculated_crc = calculate_crc32((uint8_t*)&read_back_config, sizeof(read_back_config) - 4); if(calculated_crc == read_back_config.crc32) { printf(“FLASH data read back and CRC check OK.\r\n”); // 将读取的数据赋值给全局变量 g_sys_config = read_back_config; } else { printf(“CRC error! FLASH data may be corrupted.\r\n”); // 使用默认配置 load_default_config(&g_sys_config); }这个例子展示了几个重要实践:
- 结构体封装:将相关参数打包,便于管理。
- CRC校验:这是必须的!FLASH有寿命,也可能受电磁干扰,数据可能损坏。写入时计算CRC并存储,读取时重新计算并比对,可以极大提高数据的可靠性。
- 类型转换:注意
sizeof计算的是字节数,而写入需要半字数。(size + 1) / 2是常用的字节数转半字数并向上取整的方法。 - 错误处理与默认值:读取校验失败后,应有加载默认配置的容错机制。
4. 避坑指南与高级话题:从能用到好用
把代码跑通只是第一步,要让它在产品中稳定可靠地工作,还需要注意下面这些我踩过坑总结出来的要点。
4.1 中断与擦写时序:致命的打断
问题场景:你在主循环里调用FLASH_WriteData函数,而这个函数执行擦除操作需要几十毫秒。此时,一个定时器中断或者外部中断发生了。如果中断服务程序(ISR)也位于FLASH中(绝大多数情况都是),CPU尝试从FLASH取指执行中断程序,但FLASH正忙(擦除中),无法响应读取请求。这会导致程序锁死或进入硬件错误(HardFault)。
解决方案:
- 方案A:关闭全局中断。在擦写FLASH的整个关键期间(从
FLASH_Unlock()到FLASH_Lock()),使用__disable_irq()和__enable_irq()指令关闭所有中断。这是最简单粗暴但有效的方法,适用于对实时性要求不高的场合。__disable_irq(); write_status = FLASH_WriteData(...); __enable_irq(); - 方案B:将中断向量表和中关键ISR搬到RAM中运行。这是一种高级优化,通过修改SCB->VTOR寄存器,将中断向量表重定位到RAM,并将高频或关键的中断服务程序也复制到RAM。这样,即使FLASH忙,CPU也能从RAM取指执行中断。这种方法复杂,但能保证实时性。对于大多数应用,方案A已经足够。
4.2 数据磨损均衡:让FLASH“活”得更久
如果你的数据需要频繁更新(比如每小时记录一次),直接反复擦写同一页,很快就会达到1万次的寿命极限。此时需要引入磨损均衡算法。
简单实现思路:
- 在FLASH中划分一个“存储区”,包含多个页(例如4页,每页1KB)。
- 每个存储单元(一条记录)包含:有效标志、序列号(或时间戳)、实际数据、CRC。
- 写入新数据时,总是找到下一个空闲的存储位置写入。如果当前页写满了,就擦除最早的一页,循环使用。
- 读取时,在所有页中查找序列号最大的有效数据。
这样,写操作被均匀分摊到多个页上,假设4个页循环,总擦写寿命就变成了 4页 * 1万次 = 4万次,显著延长了使用寿命。市面上一些开源的文件系统(如LittleFS)其核心思想之一就是磨损均衡。
4.3 仿真调试与FLASH断点:看不见的冲突
诡异现象:你在Keil或IAR中单步调试,程序运行到FLASH操作函数时,突然卡死,或者调试器断开连接。
原因分析:很多调试器(如ST-Link)是通过在FLASH中设置软件断点(通常是修改指令为BKPT)来实现的。当你进行FLASH擦写操作时,很可能会意外地擦除掉调试器设置的断点指令,导致调试会话混乱。
应对策略:
- 调试时避开:在调试阶段,如果不需要测试FLASH读写功能,可以先注释掉相关代码。
- 使用硬件断点:如果你的芯片支持且调试器性能足够,尽量使用数量有限的硬件断点(Hardware Breakpoint),它不修改FLASH内容。
- 在RAM中调试:将待测试的FLASH操作相关函数放到RAM中执行(通过链接脚本或属性声明
__attribute__((section(“.ram_code”)))),这样就不会干扰FLASH中的代码和断点。
4.4 电源稳定性:压垮骆驼的最后一根稻草
FLASH擦写操作对电源电压非常敏感。在电压不稳或过低时进行擦写,极易导致数据写入错误,甚至损坏FLASH存储单元。
设计建议:
- 硬件上:确保电源电路有足够的滤波电容,在MCU的VDD引脚附近放置一个0.1uF和一个10uF的电容是常见做法。
- 软件上:在启动FLASH操作前,可以读取芯片内部的电源电压监测标志(PVD),如果电压低于安全阈值,则推迟或放弃本次写操作。STM32标准库提供了PVD相关函数
FLASH_ProgramOptionByteData(用于配置)和中断,可以加以利用。 - 操作时机:避免在系统刚上电、电压尚未完全稳定时进行FLASH写操作。可以延时几百毫秒后再执行。
5. 不止于F1:其他系列STM32的FLASH操作差异
我们以上讨论主要基于STM32F1系列的标准库。对于其他系列,核心思想一致,但具体细节有差异,迁移时需要注意:
- STM32F4/F7/H7系列:这些系列的FLASH模块更复杂,扇区(Sector)大小不一(有16KB, 128KB等),擦除函数可能是
HAL_FLASHEx_Erase(),编程函数是HAL_FLASH_Program(),并且编程类型可以是字节(8位)、半字(16位)、字(32位)甚至双字(64位)。务必查阅对应系列的参考手册(Reference Manual)中的Flash programming manual章节。 - HAL库与LL库:如果使用STM32CubeMX生成的HAL库或LL库,API函数名和参数会有所不同,但流程依然是:解锁->清除标志->擦除->编程->上锁。HAL库通常提供了更完整的错误处理机制。
- 写保护与读保护:这些高级功能在不同系列中配置方式不同,F1主要通过选项字节,而F4等可能在FLASH控制寄存器中直接配置。启用前务必理解其后果。
最后,我个人最深刻的一个体会是:把FLASH当作一个“脾气倔强但可靠的仓库管理员”。你必须遵守他的规矩(先擦后写、按16位访问、别在他工作时打扰他),他才会帮你把数据保管好。在项目初期就规划好FLASH的存储布局,设计好带校验的数据结构,并充分考虑中断和电源的影响,这些前期工作会为项目后期的稳定性省去无数麻烦。当你看到设备断电重启后,那些关键的参数依然完好如初时,你会觉得这一切的细致都是值得的。