STM32F4 片上Flash模拟EEPROM:磨损均衡与掉电保护实现

为什么需要模拟EEPROM?

STM32F4系列(如STM32F407)内部Flash容量大(512KB~1MB),但擦写寿命有限(典型10000次)。而EEPROM虽可字节擦写且寿命长(100万次),但多数F4型号未集成。因此,当需要频繁保存配置参数(如校准值、运行状态)时,直接使用Flash会导致寿命迅速耗尽。解决方案是:用软件算法将Flash模拟成EEPROM,通过磨损均衡分散擦写,并利用掉电保护确保数据不丢失

核心原理

1. Flash特性与限制

  • 最小擦除单位:扇区(Sector),F4中通常16KB或64KB。
  • 写入单位:半字(16位)或字(32位),且只能将1写为0(编程),擦除后恢复为0xFF。
  • 寿命:每个扇区约10,000次擦写。

2. 磨损均衡策略

将Flash划分为两个或多个逻辑区,每次写入新数据到下一个空闲位置,而不是原地更新。当整个区写满后,擦除旧区并切换。典型方案:双区交替(Ping-Pong)

  • 区A和区B各存一份最新数据副本。
  • 写入时,先写区A的下一位置,若区A满则擦除区B并写入区B。
  • 读取时,比较两区的版本号或时间戳,取最新。

3. 掉电保护设计

掉电可能发生在写入过程中,导致数据半更新。解决:

  • 原子写入:先写入数据到临时区,再更新标志位(如CRC校验值)。
  • 双备份:每个数据项存两份,读取时校验CRC,若主备份损坏则用备用。
  • 状态机:定义写入状态(如:空闲、写入中、完成),掉电后重启根据状态恢复。

实现步骤

步骤1:定义存储结构

// 数据项结构(带CRC和状态)
typedef struct {
    uint32_t magic;      // 魔数,用于识别有效数据
    uint16_t version;    // 版本号,越大越新
    uint16_t crc;        // CRC16校验
    uint8_t data[64];    // 实际数据(示例64字节)
} EepromItem_t;

// 存储区管理结构
#define SECTOR_SIZE    (16 * 1024)  // 假设扇区16KB
#define ITEM_SIZE      (sizeof(EepromItem_t))  // 约76字节
#define ITEMS_PER_SECTOR (SECTOR_SIZE / ITEM_SIZE)  // 约215个

步骤2:Flash驱动函数

#include "stm32f4xx_hal.h"

// 擦除扇区
void Flash_EraseSector(uint32_t sector) {
    FLASH_EraseInitTypeDef erase = {0};
    uint32_t pageError = 0;
    erase.TypeErase = FLASH_TYPEERASE_SECTORS;
    erase.Sector = sector;
    erase.NbSectors = 1;
    erase.VoltageRange = FLASH_VOLTAGE_RANGE_3;
    HAL_FLASH_Unlock();
    HAL_FLASHEx_Erase(&erase, &pageError);
    HAL_FLASH_Lock();
}

// 写入半字(16位)
void Flash_WriteHalfWord(uint32_t addr, uint16_t data) {
    HAL_FLASH_Unlock();
    HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, data);
    HAL_FLASH_Lock();
}

// 写入整个数据项(逐半字写入)
void Flash_WriteItem(uint32_t baseAddr, uint16_t index, EepromItem_t *item) {
    uint32_t addr = baseAddr + index * ITEM_SIZE;
    uint16_t *p = (uint16_t *)item;
    for (int i = 0; i < ITEM_SIZE / 2; i++) {
        Flash_WriteHalfWord(addr + i * 2, p[i]);
    }
}

步骤3:磨损均衡逻辑

#define SECTOR_A  0  // 扇区编号(根据实际映射)
#define SECTOR_B  1

// 查找最新有效项(返回所在扇区和索引)
int FindLatestItem(uint32_t *sector, uint16_t *index) {
    uint32_t sectors[2] = {SECTOR_A, SECTOR_B};
    uint16_t maxVersion = 0;
    int found = 0;
    for (int s = 0; s < 2; s++) {
        uint32_t base = GetSectorAddr(sectors[s]);
        for (int i = 0; i < ITEMS_PER_SECTOR; i++) {
            EepromItem_t item;
            ReadItem(base, i, &item);
            if (item.magic == MAGIC_NUM && item.crc == CalcCRC(&item)) {
                if (item.version > maxVersion) {
                    maxVersion = item.version;
                    *sector = sectors[s];
                    *index = i;
                    found = 1;
                }
            }
        }
    }
    return found;
}

// 写入新数据(自动选择位置)
void Eeprom_Write(uint8_t *data, uint16_t len) {
    uint32_t curSector, baseAddr;
    uint16_t curIndex;
    // 找到当前最新项的位置
    if (FindLatestItem(&curSector, &curIndex)) {
        // 若当前扇区还有空间,则写下一个位置
        if (curIndex < ITEMS_PER_SECTOR - 1) {
            baseAddr = GetSectorAddr(curSector);
            EepromItem_t newItem;
            FillItem(&newItem, data, len);
            Flash_WriteItem(baseAddr, curIndex + 1, &newItem);
        } else {
            // 当前扇区已满,擦除另一个扇区并写入
            uint32_t otherSector = (curSector == SECTOR_A) ? SECTOR_B : SECTOR_A;
            Flash_EraseSector(otherSector);
            baseAddr = GetSectorAddr(otherSector);
            EepromItem_t newItem;
            FillItem(&newItem, data, len);
            Flash_WriteItem(baseAddr, 0, &newItem);
        }
    } else {
        // 首次写入,擦除A扇区并写入
        Flash_EraseSector(SECTOR_A);
        baseAddr = GetSectorAddr(SECTOR_A);
        EepromItem_t newItem;
        FillItem(&newItem, data, len);
        Flash_WriteItem(baseAddr, 0, &newItem);
    }
}

步骤4:掉电保护增强

  • 写入前备份:在写入新项前,先将旧项复制到临时扇区(若存在),但为简化,我们采用双副本策略:每个数据项在写入时,同时写入两个位置(如当前扇区和另一扇区),读取时取CRC有效且版本高的。
  • 状态标志:在数据项末尾添加一个状态字节(如0xAA表示完成,0x55表示写入中),掉电后重启时,若发现状态为0x55,则丢弃该项。
// 增强的写入函数(带状态)
void Eeprom_WriteSafe(uint8_t *data, uint16_t len) {
    // 先写主副本
    EepromItem_t item;
    FillItem(&item, data, len);
    item.status = 0x55; // 写入中
    uint32_t sector, base;
    uint16_t idx;
    // 选择写入位置(略,同前)
    Flash_WriteItem(base, idx, &item);
    // 写备用副本(另一扇区)
    // ...
    // 最后更新状态为完成
    item.status = 0xAA;
    Flash_WriteItem(base, idx, &item);
}

完整代码示例(简化版)

// 初始化:检查并恢复
void Eeprom_Init(void) {
    uint32_t sector;
    uint16_t idx;
    if (!FindLatestItem(&sector, &idx)) {
        // 无有效数据,格式化A扇区
        Flash_EraseSector(SECTOR_A);
        EepromItem_t initItem;
        FillItem(&initItem, (uint8_t*)"default", 8);
        Flash_WriteItem(GetSectorAddr(SECTOR_A), 0, &initItem);
    }
}

// 读取最新数据
int Eeprom_Read(uint8_t *buf, uint16_t len) {
    uint32_t sector;
    uint16_t idx;
    if (FindLatestItem(&sector, &idx)) {
        EepromItem_t item;
        ReadItem(GetSectorAddr(sector), idx, &item);
        if (item.status == 0xAA && item.crc == CalcCRC(&item)) {
            memcpy(buf, item.data, len);
            return 0;
        }
    }
    return -1;
}

注意事项

  • 扇区大小:根据实际芯片调整,F4的扇区大小可能不同(如F407的扇区0为16KB,扇区1-3为16KB,扇区4-7为128KB)。
  • 写入对齐:Flash写入必须半字对齐,且地址不能跨扇区。
  • 擦除时间:擦除一个扇区约需1~2秒(取决于芯片),期间不能掉电,否则数据丢失。建议在关键操作前关闭中断。
  • 磨损均衡效率:双区方案只能将寿命延长2倍,若需更长,可多区轮换(如4区)。
  • CRC计算:可使用硬件CRC单元(如STM32F4的CRC模块)加速。

总结

通过双区交替和状态机设计,我们成功用STM32F4的片上Flash模拟了EEPROM,实现了磨损均衡和掉电保护。该方法在工业设备中广泛使用,既节省了外部EEPROM成本,又保证了数据可靠性。开发者可根据实际需求调整数据大小、扇区数量和均衡策略,以达到最佳性能。