1. 为什么需要内部Flash存储
在嵌入式开发中,经常会遇到需要保存一些关键数据的需求,比如设备的配置参数、运行日志、校准数据等。这些数据需要在设备断电后仍然能够保留,下次上电时还能读取出来使用。如果只是简单地使用变量来存储这些数据,断电后就会丢失。
这时候就需要用到非易失性存储器。虽然有些单片机带有专门的EEPROM,但大多数STM32芯片并没有内置EEPROM。不过,所有STM32芯片都内置了Flash存储器,我们可以利用这部分空间来存储需要断电保存的数据。
Flash存储器有几个显著特点:
- 非易失性:断电后数据不会丢失
- 可擦写:可以多次修改存储的数据
- 访问速度快:比外部存储器快得多
- 集成度高:不需要额外硬件电路
2. 理解STM32内部Flash结构
2.1 Flash存储区划分
STM32的Flash存储器主要分为两个区域:
- 主存储区(Main Memory):用于存储程序代码
- 系统存储区(System Memory):存储Bootloader代码
我们主要关注主存储区,它的起始地址是0x08000000。这个区域又被划分为多个页(Page),不同型号的STM32页大小可能不同:
- 小容量产品(16-32KB):每页1KB
- 中容量产品(64-128KB):每页1KB
- 大容量产品(256KB以上):每页2KB
2.2 Flash操作特性
Flash存储有几个重要特性需要注意:
必须先擦除后写入:Flash只能将1写成0,不能将0写成1。所以写入前必须先擦除(将整页置为1)。
擦除最小单位是页:不能单独擦除某个地址,必须整页擦除。
写入最小单位:可以按字节、半字(16位)或字(32位)写入。
寿命限制:Flash有擦写次数限制,通常为10万次左右。
操作期间不能执行Flash中的代码:这意味着在操作Flash时需要特别注意中断处理。
3. 准备工作与地址规划
3.1 确定Flash参数
在开始编程前,我们需要确认几个关键参数:
芯片型号:这个非常重要!我曾经遇到过因为看错型号导致一天的工作白费的情况。可以通过查看芯片上的丝印确认。
Flash大小:在芯片数据手册中可以查到,也可以通过读取芯片ID获取。
页大小:同样在数据手册中查找,或者在HAL库头文件中搜索FLASH_PAGE_SIZE定义。
3.2 地址规划策略
为了避免擦写操作影响程序运行,我们通常选择Flash的最后几页来存储数据。具体步骤:
- 查看程序编译后的大小,确定程序占用的Flash空间
- 选择程序占用空间之后的页作为数据存储区
- 预留足够的空间,防止程序更新后覆盖数据
例如,对于STM32F103C8T6(64KB Flash,1KB/页),如果程序占用30KB,我们可以使用最后两页(0x0800F800-0x0800FFFF)来存储数据。
4. HAL库Flash操作实战
4.1 基本操作流程
使用HAL库操作Flash的标准流程如下:
- 解锁Flash
- 擦除目标页
- 写入数据
- 锁定Flash
4.2 关键代码实现
4.2.1 页擦除函数
void Flash_PageErase(uint32_t address) { __disable_irq(); // 关闭所有中断 // 解锁Flash while(HAL_FLASH_Unlock() != HAL_OK); // 配置擦除参数 FLASH_EraseInitTypeDef eraseConfig; eraseConfig.TypeErase = FLASH_TYPEERASE_PAGES; eraseConfig.PageAddress = address; eraseConfig.NbPages = 1; uint32_t pageError = 0; // 执行擦除 HAL_FLASHEx_Erase(&eraseConfig, &pageError); // 锁定Flash HAL_FLASH_Lock(); __enable_irq(); // 重新开启中断 }4.2.2 数据写入函数
void Flash_Write(uint32_t address, uint32_t data) { __disable_irq(); // 解锁Flash while(HAL_FLASH_Unlock() != HAL_OK); // 写入数据 HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, address, data); // 锁定Flash HAL_FLASH_Lock(); __enable_irq(); }4.2.3 数据读取函数
uint32_t Flash_Read(uint32_t address) { return *(__IO uint32_t *)address; }4.3 完整使用示例
下面是一个完整的使用示例,演示如何保存和读取一个配置参数:
#define CONFIG_ADDRESS 0x0800FC00 // 使用最后一页的中间位置 void SaveConfig(uint32_t configValue) { // 先擦除整页 Flash_PageErase(CONFIG_ADDRESS); // 写入配置值 Flash_Write(CONFIG_ADDRESS, configValue); } uint32_t LoadConfig() { return Flash_Read(CONFIG_ADDRESS); } int main() { // 初始化硬件... // 加载保存的配置 uint32_t config = LoadConfig(); // 如果没有配置,使用默认值 if(config == 0xFFFFFFFF) { // 擦除后的值是0xFFFFFFFF config = DEFAULT_CONFIG; } // 使用配置... // 需要保存新配置时 SaveConfig(newConfig); while(1) { // 主循环... } }5. 常见问题与优化技巧
5.1 常见问题排查
写入失败:
- 检查是否先进行了擦除
- 确认地址是否正确对齐(32位写入要对齐4字节边界)
- 检查是否忘记解锁Flash
数据损坏:
- 确保操作期间没有发生中断
- 检查电源稳定性,低电压可能导致写入错误
程序崩溃:
- 确认没有擦写正在执行的代码区域
- 检查堆栈是否足够大
5.2 优化技巧
磨损均衡:为了延长Flash寿命,可以实现简单的磨损均衡算法,轮流使用不同页存储数据。
数据校验:添加CRC校验或校验和来检测数据是否损坏。
批量写入:尽量减少擦写次数,可以积累一定量数据后一次性写入。
内存缓存:频繁访问的数据可以先读到RAM中,减少Flash读取次数。
错误恢复:实现数据备份机制,当主数据损坏时可以恢复备份数据。
6. 高级应用:实现键值存储
对于需要存储多个配置项的场景,我们可以实现一个简单的键值存储系统:
#define KV_STORE_START 0x0800F800 #define KV_STORE_END 0x0800FFFF #define KV_ITEM_SIZE 8 // 每个键值对占8字节(2个32位字) typedef struct { uint32_t key; uint32_t value; } KVItem; void KVStore_Write(uint32_t key, uint32_t value) { // 查找空闲位置或相同key的位置 uint32_t address = KV_STORE_START; while(address < KV_STORE_END) { KVItem item = *(KVItem*)address; if(item.key == 0xFFFFFFFF || item.key == key) { break; } address += KV_ITEM_SIZE; } // 如果找到位置,写入数据 if(address < KV_STORE_END) { Flash_Write(address, key); Flash_Write(address + 4, value); } } uint32_t KVStore_Read(uint32_t key) { uint32_t address = KV_STORE_START; while(address < KV_STORE_END) { KVItem item = *(KVItem*)address; if(item.key == key) { return item.value; } address += KV_ITEM_SIZE; } return 0xFFFFFFFF; // 未找到 } void KVStore_Init() { // 检查是否需要擦除 if(Flash_Read(KV_STORE_START) != 0xFFFFFFFF) { Flash_PageErase(KV_STORE_START); } }这个简单的键值存储系统可以管理多个配置项,每个配置项由一个32位key和32位value组成。当存储区满时,需要先擦除整页才能继续写入新数据。