在嵌入式开发中,经常需要存储一些关键数据,比如设备序列号、运行参数、校准数据等。这些数据通常需要频繁修改,但又不能因为断电而丢失。传统做法是外接一片EEPROM芯片,但这会增加硬件成本和PCB面积。其实,很多MCU内部已经集成了Flash存储器,如果能合理利用这部分资源,完全可以替代外置EEPROM。
我做过不少项目,发现AT32F403A和STM32F103这类MCU的内部Flash性能相当不错。它们的Flash容量从几十KB到几百KB不等,完全够存储一些关键参数。不过Flash和EEPROM在物理特性上有本质区别,直接使用会遇到不少坑。比如Flash必须先擦除才能写入,而且擦除单位是整个扇区(通常是1KB或2KB),不像EEPROM可以按字节操作。
先看个实际案例:我在一个温控器项目中使用STM32F103,需要记录10个温度校准点。如果直接用内部Flash,每次修改一个校准点都要擦除整个扇区,不仅麻烦还会影响Flash寿命。这就是典型的EEPROM使用场景。
Flash和EEPROM的主要区别体现在:
要实现可靠的Flash模拟EEPROM,必须解决三个核心问题:
我在早期项目中就踩过坑:没有做磨损均衡,结果设备运行半年后配置数据就丢失了。后来通过引入地址轮换和备份机制才解决这个问题。
雅特力官方库已经提供了完善的Flash操作接口,我们只需要关注应用层实现。先看看基本的读写流程:
c复制// Flash解锁函数
void flash_unlock(void)
{
FLASH_KEY = FLASH_KEY1;
FLASH_KEY = FLASH_KEY2;
}
// 扇区擦除函数
void flash_sector_erase(uint32_t sector_addr)
{
while(FLASH_STS & FLASH_STS_OBF);
FLASH_CTRL |= FLASH_CTRL_SER;
FLASH_ADDR = sector_addr;
FLASH_CTRL |= FLASH_CTRL_STRT;
while(FLASH_STS & FLASH_STS_OBF);
}
这里有个细节要注意:AT32F403A的Flash操作需要先解锁,操作完成后最好再上锁,防止意外修改。
直接写入未擦除的区域会导致失败,我总结出一个可靠的写入流程:
对应的代码实现:
c复制void flash_safe_write(uint32_t addr, uint16_t *data, uint16_t len)
{
uint32_t sector_start = addr & ~(SECTOR_SIZE-1);
uint16_t sector_buf[SECTOR_SIZE/2];
// 读取整个扇区
flash_read(sector_start, sector_buf, SECTOR_SIZE/2);
// 检查是否需要擦除
uint32_t offset = (addr - sector_start)/2;
for(int i=0; i<len; i++){
if((sector_buf[offset+i] & data[i]) != data[i]){
// 需要擦除
flash_sector_erase(sector_start);
// 更新缓冲区
for(int j=0; j<len; j++){
sector_buf[offset+j] = data[j];
}
// 写回整个扇区
flash_write_nocheck(sector_start, sector_buf, SECTOR_SIZE/2);
return;
}
}
// 直接写入
flash_write_nocheck(addr, data, len);
}
这个方案虽然效率不是最高,但保证了数据可靠性,适合存储关键参数。
Flash的寿命有限,需要均衡各扇区的擦写次数。我常用的方案是建立虚拟地址映射:
具体实现可以参考这个结构体:
c复制typedef struct {
uint16_t virtual_addr;
uint16_t data;
uint32_t timestamp;
uint16_t crc;
} flash_item_t;
#define POOL_SIZE 4 // 4个扇区作为存储池
uint32_t wear_count[POOL_SIZE]; // 记录每个扇区的擦写次数
void wear_leveling_write(uint16_t addr, uint16_t val)
{
// 找出使用次数最少的扇区
uint32_t min_wear = 0xFFFFFFFF;
int target_sector = 0;
for(int i=0; i<POOL_SIZE; i++){
if(wear_count[i] < min_wear){
min_wear = wear_count[i];
target_sector = i;
}
}
// 构造新的数据项
flash_item_t item;
item.virtual_addr = addr;
item.data = val;
item.timestamp = get_timestamp();
item.crc = calc_crc(&item, sizeof(item)-2);
// 写入新扇区
uint32_t sector_addr = FLASH_BASE + (SECTOR_SIZE * (RESERVED_SECTORS + target_sector));
flash_sector_erase(sector_addr);
flash_write_nocheck(sector_addr, (uint16_t*)&item, sizeof(item)/2);
// 更新磨损计数
wear_count[target_sector]++;
}
为了防止意外断电导致数据损坏,我通常会实现双备份机制:
这个方案虽然占用更多空间,但在工业级应用中非常必要。我曾经遇到过一个案例:设备在写入Flash时突然断电,导致配置全部丢失。引入双备份后,即使遇到断电也能恢复到最后一次正确的状态。
结合以上技术点,我整理出一个可直接移植的Flash模拟EEPROM框架:
c复制// flash_eeprom.h
#ifndef __FLASH_EEPROM_H__
#define __FLASH_EEPROM_H__
#include <stdint.h>
#define VIRTUAL_SIZE 1024 // 虚拟EEPROM大小
#define SECTOR_SIZE 2048 // Flash扇区大小
#define POOL_SIZE 4 // 存储池扇区数
void flash_eeprom_init(void);
int flash_eeprom_read(uint16_t addr, uint16_t *data);
int flash_eeprom_write(uint16_t addr, uint16_t data);
#endif
c复制// flash_eeprom.c
#include "flash_eeprom.h"
typedef struct {
uint16_t addr;
uint16_t data;
uint32_t timestamp;
uint16_t crc;
} eeprom_item_t;
static uint32_t wear_count[POOL_SIZE];
static uint32_t current_sector;
static uint16_t calc_crc(const void *data, size_t len)
{
// 实现CRC16计算
}
void flash_eeprom_init(void)
{
// 初始化磨损计数
// 查找最后写入的扇区
// 恢复映射关系
}
int flash_eeprom_read(uint16_t addr, uint16_t *data)
{
// 在所有扇区中搜索指定地址的最新有效数据
// 校验CRC和时间戳
// 返回找到的数据
}
int flash_eeprom_write(uint16_t addr, uint16_t data)
{
// 实现磨损均衡写入
// 更新磨损计数
// 必要时触发垃圾回收
}
这个框架已经应用在我多个量产项目中,包括智能家居设备和工业控制器,稳定性得到了充分验证。使用时只需要关注虚拟地址空间的规划,底层细节都已经封装好。
在实际项目中,还需要考虑一些性能优化点:
比如在物联网设备中,我通常会设计这样的写入策略:
c复制#define CACHE_SIZE 8
typedef struct {
uint16_t addr;
uint16_t data;
} write_cache_t;
static write_cache_t cache[CACHE_SIZE];
static int cache_count = 0;
void flash_eeprom_cache_write(uint16_t addr, uint16_t data)
{
// 先更新缓存
for(int i=0; i<cache_count; i++){
if(cache[i].addr == addr){
cache[i].data = data;
return;
}
}
if(cache_count < CACHE_SIZE){
cache[cache_count].addr = addr;
cache[cache_count].data = data;
cache_count++;
}else{
// 缓存满,触发实际写入
flash_eeprom_flush();
// 写入新数据
cache[0].addr = addr;
cache[0].data = data;
cache_count = 1;
}
}
void flash_eeprom_flush(void)
{
if(cache_count == 0) return;
// 合并缓存中的数据项
eeprom_item_t item;
uint32_t min_wear = 0xFFFFFFFF;
int target_sector = 0;
// 选择目标扇区
for(int i=0; i<POOL_SIZE; i++){
if(wear_count[i] < min_wear){
min_wear = wear_count[i];
target_sector = i;
}
}
// 构造完整数据项并写入
// ...
cache_count = 0;
}
这种延迟写入策略可以显著减少Flash操作次数,特别是在需要频繁更新数据的场景下。
在开发过程中,我遇到过不少典型问题,这里分享几个排查经验:
写入失败:首先检查地址是否对齐,AT32F403A要求半字写入时地址必须2字节对齐。其次确认目标区域已经擦除,可以用读取函数检查是否为全0xFF。
数据异常:建议在存储数据结构中加入CRC校验字段。每次读取时验证CRC,发现错误可以尝试从备份恢复。
性能瓶颈:如果系统对写入延迟敏感,可以考虑使用双Bank Flash的架构。在一个Bank执行擦写时,CPU可以继续访问另一个Bank。
寿命问题:定期监测各扇区的擦写次数,当接近最大额定值时提前预警。我在一个项目中实现了这样的监控代码:
c复制void flash_eeprom_check_wear(void)
{
uint32_t max_wear = 0;
uint32_t total_wear = 0;
for(int i=0; i<POOL_SIZE; i++){
if(wear_count[i] > max_wear){
max_wear = wear_count[i];
}
total_wear += wear_count[i];
}
printf("Max wear count: %lu/%lu\n", max_wear, FLASH_MAX_ERASE);
printf("Avg wear count: %lu/%lu\n", total_wear/POOL_SIZE, FLASH_MAX_ERASE);
if(max_wear > FLASH_MAX_ERASE * 0.9){
printf("WARNING: Flash nearing end of life!\n");
}
}
虽然AT32F403A和STM32F103在Flash控制器上有差异,但整体架构相似。移植时主要注意以下几点:
这里给出STM32的扇区擦除示例:
c复制void stm32_flash_erase_page(uint32_t page_addr)
{
while(FLASH->SR & FLASH_SR_BSY);
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB;
FLASH->CR |= FLASH_CR_PER;
FLASH->AR = page_addr;
FLASH->CR |= FLASH_CR_STRT;
while(FLASH->SR & FLASH_SR_BSY);
FLASH->CR &= ~FLASH_CR_PER;
}
其他高层逻辑,如磨损均衡、数据备份等策略都可以直接复用AT32的代码。我在多个项目中将这套框架在AT32和STM32之间来回移植,只需要修改底层驱动就能正常工作。