STM32 内部 Flash 模拟 EEPROM 的磨损均衡实现:页交换 + 元数据双备份

1. 背景与问题

在许多嵌入式应用中,需要保存配置参数、校准数据等,且要求掉电不丢失、可频繁修改。外部 EEPROM 是理想选择,但会增加成本与 PCB 面积。STM32 内部 Flash 容量大,但擦写寿命仅约 10,000 次,且擦除操作以页(通常 1~2KB)为单位,无法像 EEPROM 那样按字节修改。若直接使用内部 Flash 模拟 EEPROM,频繁写入同一地址会迅速耗尽该页寿命。

磨损均衡(Wear Leveling)是解决此问题的核心技术。本文介绍一种基于页交换与元数据双备份的轻量级方案,仅使用两个 Flash 页,即可实现接近 EEPROM 的按字节读写体验,并大幅延长 Flash 寿命。

2. 原理讲解

2.1 基本思路

  • 将两个连续的 Flash 页(Page A 和 Page B)作为数据存储区。
  • 每个页划分为多个数据槽(Slot),每个槽包含:数据本身 + 状态标记(有效/无效)。
  • 写入时,不擦除原数据,而是将新数据追加到当前页的下一个空闲槽,并将旧槽标记为无效。
  • 当当前页写满后,将有效数据复制到另一页,擦除当前页,然后交换角色。
  • 元数据(记录当前活动页、写入位置等)采用双备份存储,防止掉电损坏。

2.2 磨损均衡效果

每次写入仅消耗一个槽的寿命,而不是整个页。假设每页有 N 个槽,则每擦除一次可支持 N 次写入。相比直接擦写,寿命提升 N 倍。若 N=64,则理论寿命达 64 万次,足以满足绝大多数应用。

2.3 元数据双备份

元数据包括:活动页标识、当前写入偏移、有效数据数量等。将其存储在 Flash 的固定位置(如两个页的末尾或独立区域),并保存两份。每次更新元数据时,交替写入两份,并附加 CRC 校验。读取时选择 CRC 正确的较新版本。

3. 配置步骤

以 STM32F103(中容量)为例,Flash 页大小为 1KB,选择 Page 62 和 Page 63(地址 0x0800F800 和 0x0800FC00)作为存储区。

  1. 定义 Flash 页地址与大小

    #define PAGE_SIZE      1024
    #define PAGE_A_ADDR    0x0800F800
    #define PAGE_B_ADDR    0x0800FC00
    #define META_ADDR      0x0800F000  // 元数据区,使用独立页
    
  2. 设计数据槽结构

    typedef struct {
        uint32_t key;        // 数据标识
        uint32_t value;      // 数据值
        uint8_t  status;     // 0xFF=空闲, 0x00=有效, 0xFE=无效
    } slot_t;
    

    每个槽占 12 字节,为对齐可填充至 16 字节。每页可容纳 64 个槽。

  3. 元数据结构

    typedef struct {
        uint32_t active_page;  // 0=PAGE_A, 1=PAGE_B
        uint32_t write_offset; // 当前写入位置(字节偏移)
        uint32_t crc;          // 校验和
    } meta_t;
    

    元数据双备份:META_ADDR 和 META_ADDR+16 各存一份。

  4. 初始化流程

    • 读取两份元数据,选择 CRC 正确的较新版本。
    • 若均无效,则格式化:擦除两页,设置活动页为 A,偏移为 0,写入元数据。

4. 完整代码示例

以下为关键函数实现(基于 HAL 库)。

#include "stm32f1xx_hal.h"
#include <string.h>

#define PAGE_SIZE      1024
#define PAGE_A_ADDR    0x0800F800
#define PAGE_B_ADDR    0x0800FC00
#define META_ADDR      0x0800F000
#define SLOT_SIZE      16
#define SLOT_PER_PAGE  (PAGE_SIZE / SLOT_SIZE)

typedef struct {
    uint32_t key;
    uint32_t value;
    uint8_t  status;
    uint8_t  pad[7];
} slot_t;

typedef struct {
    uint32_t active_page;
    uint32_t write_offset;
    uint32_t crc;
} meta_t;

static meta_t g_meta;

// CRC32 简化实现
static uint32_t crc32(const uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (uint32_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int j = 0; j < 8; j++)
            crc = (crc >> 1) ^ (0xEDB88320 & -(crc & 1));
    }
    return ~crc;
}

// 擦除页
static void flash_erase_page(uint32_t addr) {
    FLASH_EraseInitTypeDef erase;
    uint32_t page_error;
    erase.TypeErase = FLASH_TYPEERASE_PAGES;
    erase.PageAddress = addr;
    erase.NbPages = 1;
    HAL_FLASH_Unlock();
    HAL_FLASHEx_Erase(&erase, &page_error);
    HAL_FLASH_Lock();
}

// 写入数据(半字编程)
static void flash_write(uint32_t addr, uint8_t *data, uint32_t len) {
    HAL_FLASH_Unlock();
    for (uint32_t i = 0; i < len; i += 2) {
        uint16_t half = data[i] | (data[i+1] << 8);
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr + i, half);
    }
    HAL_FLASH_Lock();
}

// 保存元数据(双备份交替)
static void save_meta(void) {
    g_meta.crc = crc32((uint8_t*)&g_meta, 8);
    static uint8_t idx = 0;
    uint32_t addr = META_ADDR + idx * 16;
    flash_erase_page(META_ADDR);  // 简化:擦除整页后写两份
    flash_write(META_ADDR, (uint8_t*)&g_meta, sizeof(meta_t));
    flash_write(META_ADDR + 16, (uint8_t*)&g_meta, sizeof(meta_t));
    idx ^= 1;
}

// 读取元数据
static void load_meta(void) {
    meta_t m1, m2;
    memcpy(&m1, (void*)META_ADDR, sizeof(meta_t));
    memcpy(&m2, (void*)META_ADDR + 16, sizeof(meta_t));
    uint32_t crc1 = crc32((uint8_t*)&m1, 8);
    uint32_t crc2 = crc32((uint8_t*)&m2, 8);
    if (crc1 == m1.crc && crc2 == m2.crc) {
        g_meta = (m1.write_offset >= m2.write_offset) ? m1 : m2;
    } else if (crc1 == m1.crc) {
        g_meta = m1;
    } else if (crc2 == m2.crc) {
        g_meta = m2;
    } else {
        // 格式化
        g_meta.active_page = 0;
        g_meta.write_offset = 0;
        flash_erase_page(PAGE_A_ADDR);
        flash_erase_page(PAGE_B_ADDR);
        save_meta();
    }
}

// 写入键值对
void ee_write(uint32_t key, uint32_t value) {
    uint32_t page_addr = (g_meta.active_page == 0) ? PAGE_A_ADDR : PAGE_B_ADDR;
    if (g_meta.write_offset >= PAGE_SIZE) {
        // 页满,交换
        uint32_t old_page = page_addr;
        uint32_t new_page = (g_meta.active_page == 0) ? PAGE_B_ADDR : PAGE_A_ADDR;
        flash_erase_page(new_page);
        // 复制有效数据(简化:仅复制最新值)
        // 实际需遍历旧页,提取每个 key 的最新值
        // 此处省略遍历代码,假设已实现
        g_meta.active_page ^= 1;
        g_meta.write_offset = 0;
        flash_erase_page(old_page);
        save_meta();
        page_addr = new_page;
    }
    slot_t slot = { .key = key, .value = value, .status = 0x00 };
    flash_write(page_addr + g_meta.write_offset, (uint8_t*)&slot, sizeof(slot_t));
    g_meta.write_offset += SLOT_SIZE;
    save_meta();
}

// 读取键值对(返回最新值)
uint32_t ee_read(uint32_t key) {
    uint32_t page_addr = (g_meta.active_page == 0) ? PAGE_A_ADDR : PAGE_B_ADDR;
    uint32_t latest_value = 0xFFFFFFFF;
    for (uint32_t off = 0; off < g_meta.write_offset; off += SLOT_SIZE) {
        slot_t slot;
        memcpy(&slot, (void*)(page_addr + off), sizeof(slot_t));
        if (slot.status == 0x00 && slot.key == key) {
            latest_value = slot.value;
        }
    }
    return latest_value;
}

5. 注意事项

  • 掉电保护:每次写入后立即更新元数据,确保掉电后能恢复。元数据双备份 + CRC 可防止写坏。
  • 页交换开销:页满时需复制有效数据,耗时较长,应避免在实时性要求高的中断中调用。
  • Flash 对齐:STM32 编程需按半字(16 位)或字(32 位)对齐,结构体需填充至对齐边界。
  • 磨损均衡粒度:槽越小,均衡效果越好,但元数据更新越频繁。建议槽大小 16~32 字节。
  • 寿命估算:假设每页 64 槽,擦写 1 万次,则总写入次数 = 64 * 10000 = 64 万次。若每天写 100 次,可用约 17 年。
  • 中断安全:Flash 操作期间 CPU 可能停顿,建议在写操作前关闭中断或确保中断向量不在同一 Flash 页。
  • 多变量支持:通过 key 区分不同变量,读取时遍历查找最新值。可增加索引加速。

本方案以极小的代码量实现了高效的磨损均衡,适用于大多数 STM32 内部 Flash 模拟 EEPROM 的场景。