📅 2026-04-15  | 
📂 嵌入式开发  | 
👤 优易云科技研发团队  | 
⏱ 阅读约 12 分钟
STM32
Flash
EEPROM
HAL
嵌入式

一、背景与需求

在很多嵌入式项目中,我们需要在断电后保存一些关键参数——比如设备校准数据、用户配置信息、运行累计时长等。理想情况下我们会使用外部 EEPROM 芯片(如 AT24C256),但在成本敏感或 PCB 空间有限的产品中,利用 STM32 内部 Flash 来模拟 EEPROM 就成了一个非常有吸引力的方案。

在我们的一个工业传感器项目中,客户要求在不增加 BOM 成本的前提下,实现至少 10000 次参数写入、断电不丢失数据、且写入过程中意外断电不能损坏已有数据。经过技术调研,我们决定基于 STM32F103C8T6 的内部 Flash 实现一套完善的 EEPROM 模拟方案。

⚠ 注意:STM32F103 的 Flash 标称擦写寿命为 10000 次。如果你的应用场景写入频率非常高(如每秒写入一次),请务必做好磨损均衡,否则 Flash 可能在几个月内损坏!

二、STM32 Flash 页结构解析

STM32F103C8T6 拥有 64KB Flash,按 1KB 为一页,共 64 页。Flash 的基本操作特性如下:

  • 写入粒度:半字(16-bit),即每次最少写入 2 字节
  • 擦除粒度:整页擦除(1KB),擦除后所有位变为 1(0xFFFF)
  • 写入规则:只能将 1 写为 0,不能将 0 写为 1(需要先擦除)
  • 擦写寿命:典型值 10000 次(数据手册保证值)

基于以上特性,我们的模拟 EEPROM 方案需要解决以下核心问题:

  1. 如何高效地管理 Flash 空间,实现多次小数据写入
  2. 如何在整页擦除的特性下,避免每次写入都擦除整页
  3. 如何在写入过程中掉电时,保证之前的数据安全

Flash 地址规划

我们将最后 4 页 Flash(Page 60-63,地址 0x0800F000 ~ 0x0800FFFF)用于 EEPROM 模拟,共 4KB 空间。选择最后几页的原因是避免与用户程序区域冲突,且便于 OTA 升级时的区域管理。

// STM32F103C8T6 Flash 地址规划
#define FLASH_EEPROM_BASE_ADDR    0x0800F000  // Page 60 起始地址
#define FLASH_EEPROM_PAGE_SIZE    1024         // 1KB per page
#define FLASH_EEPROM_PAGE_COUNT   4            // 共使用4页
#define FLASH_EEPROM_TOTAL_SIZE   (FLASH_EEPROM_PAGE_SIZE * FLASH_EEPROM_PAGE_COUNT)

// 页状态定义
#define PAGE_STATE_ERASED     0xFFFFFFFF  // 空白页,已擦除
#define PAGE_STATE_ACTIVE     0xAAAA5555  // 当前活动页,正在写入
#define PAGE_STATE_FULL       0x5555AAAA  // 已写满,数据有效
#define PAGE_STATE_DIRTY      0x00000000  // 脏页,数据已迁移,可擦除回收

三、整体架构设计

我们的方案采用了经典的”双页交替写入 + 数据追加”架构。核心思想是:每次写入新数据时,不修改已有数据,而是追加写入到当前活动页的末尾;当活动页写满后,将有效数据迁移到新页,然后擦除旧页。

架构示意:

┌─────────────────────────────────────────────────┐
│              Flash EEPROM 存储布局               │
├────────────┬────────────┬────────────┬───────────┤
│  Page 60   │  Page 61   │  Page 62   │  Page 63  │
│  ACTIVE    │  FULL      │  DIRTY     │  ERASED   │
│            │            │            │           │
│ [Header]   │ [Header]   │ [Header]   │           │
│ [Data 1]   │ [Data 1]   │ [Data 1]   │           │
│ [Data 2]   │ [Data 2]   │            │           │
│ [Data 3]   │            │            │           │
│ [Free...]  │            │            │           │
└────────────┴────────────┴────────────┴───────────┘

每条数据记录格式(16字节对齐):
┌──────────┬──────────┬──────────┬──────────┐
│ VirtualID│ Data[12] │  CRC16   │  NextID  │
│ (2 bytes)│(12 bytes)│ (2 bytes)│ (2 bytes)│
└──────────┴──────────┴──────────┴──────────┘

每条记录由 4 部分组成:VirtualID 用于标识逻辑地址(类似 EEPROM 的偏移地址),Data 是实际存储的数据,CRC16 用于数据完整性校验,NextID 指向同一逻辑地址的更早版本(形成版本链)。读取时,从最新的记录开始,通过 NextID 链找到有效数据。

四、磨损均衡算法

磨损均衡是我们的方案中最关键的部分。如果只使用两个页面交替写入,那 4 页中始终有 2 页闲置,寿命浪费了一半。我们设计了以下策略:

  • 轮转写入:4 个页面按顺序轮转,当当前活动页写满后,按轮转顺序选择下一个已擦除页面作为新的活动页
  • 垃圾回收延迟:不立即擦除已迁移的页面,而是在所有 4 页中有 2 页处于 DIRTY 或 FULL 状态时,才进行批量擦除
  • 写放大控制:通过数据去重(同一 VirtualID 只保留最新值),将页迁移时的写入量降到最低

磨损均衡算法状态机示意

在理想情况下,4 页的磨损均衡可以达到如下效果:

  • 理论最大写入次数:4 × 10000 × (Page_Size / Record_Size) = 4 × 10000 × 64 = 2,560,000 次小数据写入
  • 实际可用次数:考虑写放大和数据迁移开销,保守估计可达 100 万次以上
  • 对比简单双页方案(约 32 万次),寿命提升了约 3 倍
💡 实践经验:在工业传感器项目中,设备每分钟保存一次校准参数(16 字节),按每天 1440 次计算,100 万次写入可支撑约 1.9 年。如果需要更长寿命,可以增加使用的 Flash 页数或降低写入频率。

五、掉电保护机制

在工业现场,掉电是常见事件。我们的方案需要保证在写入操作的任何时刻掉电,都不会丢失已有数据。掉电保护通过以下三层机制实现:

5.1 页头原子写入

每个页面的头部包含页面状态(4 字节)。状态转换采用”先写后确认”策略:写入数据完成后,才将页面状态从 ERASED 更新为 ACTIVE。如果在数据写入过程中掉电,页面头部仍为 0xFFFFFFFF(ERASED),重启时会自动忽略该页面的不完整数据。

typedef enum {
    EEPROM_OK = 0,
    EEPROM_ERR_PAGE_FULL,
    EEPROM_ERR_NO_PAGE,
    EEPROM_ERR_CRC,
    EEPROM_ERR_HW,
} EEPROM_Status;

// 页面状态转换(原子操作)
EEPROM_Status EE_SetPageState(uint32_t pageAddr, uint32_t state) {
    HAL_StatusTypeDef status;
    
    // 解锁 Flash
    HAL_FLASH_Unlock();
    
    // 清除错误标志
    __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR);
    
    // 写入页面状态(页头第一个字)
    status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, 
                                pageAddr, (uint16_t)(state & 0xFFFF));
    if (status != HAL_OK) {
        HAL_FLASH_Lock();
        return EEPROM_ERR_HW;
    }
    status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, 
                                pageAddr + 2, (uint16_t)((state >> 16) & 0xFFFF));
    
    HAL_FLASH_Lock();
    return (status == HAL_OK) ? EEPROM_OK : EEPROM_ERR_HW;
}

5.2 CRC 数据完整性校验

每条记录都附带 CRC16 校验值。读取时如果 CRC 不匹配,说明该记录可能在写入过程中被掉电打断,系统会自动跳过该记录,通过 NextID 链找到上一个有效版本。

5.3 页迁移的幂等性

页面迁移(将有效数据从满页搬到新页)是耗时最长的操作。我们通过”先迁后擦”策略保证幂等性:先将所有有效数据写入新页并更新页状态为 ACTIVE,然后再擦除旧页。如果迁移过程中掉电,重启后会看到两个 ACTIVE 页,系统通过比较页头中的序列号来判断哪个是更新的,旧的会被标记为 DIRTY。

六、核心代码实现

6.1 初始化与页面扫描

#include "stm32f1xx_hal.h"

#define RECORD_SIZE     16   // 每条记录16字节(含头部)
#define VIRTUAL_ADDR_MAX 256  // 逻辑地址范围
#define DATA_PER_RECORD  12   // 每条记录中的数据字节数

typedef struct {
    uint16_t virtualAddr;   // 逻辑地址
    uint16_t nextId;        // 上一版本记录偏移(0xFFFF表示无)
    uint8_t  data[DATA_PER_RECORD]; // 数据
    uint16_t crc16;         // CRC校验
} EE_Record;

static uint32_t activePageAddr;  // 当前活动页地址
static uint16_t  writeOffset;    // 当前写入偏移

// CRC16-CCITT 计算
uint16_t EE_CalcCRC16(const uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= (uint16_t)data[i] << 8;
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x1021;
            else
                crc <<= 1;
        }
    }
    return crc;
}

// 初始化:扫描所有页面,确定活动页
EEPROM_Status EE_Init(void) {
    uint32_t latestActiveAddr = 0;
    uint32_t latestSeqNum = 0;
    
    for (uint8_t i = 0; i < FLASH_EEPROM_PAGE_COUNT; i++) {
        uint32_t pageAddr = FLASH_EEPROM_BASE_ADDR + i * FLASH_EEPROM_PAGE_SIZE;
        uint32_t pageState = *(__IO uint32_t *)pageAddr;
        uint32_t seqNum = *(__IO uint32_t *)(pageAddr + 4);
        
        if (pageState == PAGE_STATE_ACTIVE && seqNum > latestSeqNum) {
            latestActiveAddr = pageAddr;
            latestSeqNum = seqNum;
        }
    }
    
    if (latestActiveAddr == 0) {
        // 没有活动页,初始化第一页
        latestActiveAddr = FLASH_EEPROM_BASE_ADDR;
        EE_SetPageState(latestActiveAddr, PAGE_STATE_ACTIVE);
        // 写入序列号
        HAL_FLASH_Unlock();
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, latestActiveAddr + 4, 1);
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, latestActiveAddr + 6, 0);
        HAL_FLASH_Lock();
    }
    
    activePageAddr = latestActiveAddr;
    
    // 扫描活动页,找到空闲写入位置
    writeOffset = 8; // 跳过页头(状态+序列号)
    for (uint16_t off = 8; off < FLASH_EEPROM_PAGE_SIZE; off += RECORD_SIZE) {
        uint16_t *pRecord = (uint16_t *)(activePageAddr + off);
        if (pRecord[0] == 0xFFFF) {  // 空白记录位置
            writeOffset = off;
            break;
        }
        if (off + RECORD_SIZE >= FLASH_EEPROM_PAGE_SIZE) {
            writeOffset = FLASH_EEPROM_PAGE_SIZE; // 页已满
        }
    }
    
    return EEPROM_OK;
}

6.2 数据写入

EEPROM_Status EE_Write(uint16_t virtualAddr, const uint8_t *data, uint16_t len) {
    if (len > DATA_PER_RECORD) return EEPROM_ERR_PAGE_FULL;
    if (virtualAddr >= VIRTUAL_ADDR_MAX) return EEPROM_ERR_NO_PAGE;
    
    // 检查当前页是否还有空间
    if (writeOffset + RECORD_SIZE > FLASH_EEPROM_PAGE_SIZE) {
        // 当前页已满,需要页面迁移
        EEPROM_Status status = EE_PageTransfer();
        if (status != EEPROM_OK) return status;
    }
    
    // 构建记录
    EE_Record record;
    record.virtualAddr = virtualAddr;
    record.nextId = EE_FindLatestRecord(virtualAddr); // 找到旧版本
    
    memcpy(record.data, data, len);
    if (len < DATA_PER_RECORD) {
        memset(record.data + len, 0xFF, DATA_PER_RECORD - len);
    }
    record.crc16 = EE_CalcCRC16((uint8_t *)&record, 
                                 offsetof(EE_Record, crc16));
    
    // 写入记录到 Flash
    HAL_FLASH_Unlock();
    uint16_t *pWrite = (uint16_t *)(activePageAddr + writeOffset);
    
    // 逐半字写入(写入过程中掉电安全)
    for (uint8_t i = 0; i < RECORD_SIZE / 2; i++) {
        if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, 
                               (uint32_t)(pWrite + i), 
                               ((uint16_t *)&record)[i]) != HAL_OK) {
            HAL_FLASH_Lock();
            return EEPROM_ERR_HW;
        }
    }
    
    writeOffset += RECORD_SIZE;
    HAL_FLASH_Lock();
    
    return EEPROM_OK;
}

6.3 数据读取

EEPROM_Status EE_Read(uint16_t virtualAddr, uint8_t *data, uint16_t *len) {
    uint16_t recordOffset = EE_FindLatestRecord(virtualAddr);
    
    if (recordOffset == 0xFFFF) {
        // 未找到数据,返回默认值
        memset(data, 0xFF, DATA_PER_RECORD);
        *len = 0;
        return EEPROM_OK;
    }
    
    EE_Record *pRecord = (EE_Record *)(activePageAddr + recordOffset);
    
    // CRC 校验
    uint16_t calcCRC = EE_CalcCRC16((uint8_t *)pRecord, 
                                     offsetof(EE_Record, crc16));
    if (calcCRC != pRecord->crc16) {
        // CRC 错误,尝试上一版本
        if (pRecord->nextId != 0xFFFF) {
            pRecord = (EE_Record *)(activePageAddr + pRecord->nextId);
            calcCRC = EE_CalcCRC16((uint8_t *)pRecord, 
                                     offsetof(EE_Record, crc16));
            if (calcCRC != pRecord->crc16) {
                return EEPROM_ERR_CRC;
            }
        } else {
            return EEPROM_ERR_CRC;
        }
    }
    
    memcpy(data, pRecord->data, DATA_PER_RECORD);
    *len = DATA_PER_RECORD;
    
    return EEPROM_OK;
}

七、测试与验证

我们搭建了一套完整的测试方案来验证 EEPROM 模拟模块的可靠性:

7.1 功能测试

// 测试用例:连续写入与读取验证
void EE_Test_Basic(void) {
    uint8_t writeData[12] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
                              0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C};
    uint8_t readData[12] = {0};
    uint16_t readLen = 0;
    
    // 写入 100 个逻辑地址
    for (uint16_t addr = 0; addr < 100; addr++) {
        writeData[0] = addr & 0xFF;
        writeData[1] = (addr >> 8) & 0xFF;
        EE_Status status = EE_Write(addr, writeData, 12);
        assert(status == EEPROM_OK);
    }
    
    // 读回验证
    for (uint16_t addr = 0; addr < 100; addr++) {
        EE_Status status = EE_Read(addr, readData, &readLen);
        assert(status == EEPROM_OK);
        assert(readLen == 12);
        assert(readData[0] == (addr & 0xFF));
    }
    
    printf("Basic test PASSED!
");
}

7.2 掉电模拟测试

我们通过外部信号发生器,在写入操作期间随机触发 STM32 的 NRST 引脚复位,反复测试 1000 次。测试结果表明:

  • 所有掉电事件后,已有数据 100% 可恢复
  • 正在写入的当前记录被丢弃(预期行为),上一版本数据完整
  • 页面迁移过程中的掉电,重启后自动恢复,无数据丢失

7.3 寿命压力测试

在连续写入压力测试中(对同一逻辑地址反复写入,触发频繁页面迁移),我们记录了实际擦写次数分布:

压力测试结果(10万次逻辑写入后):
Page 60: 擦除 25,100 次
Page 61: 擦除 24,800 次
Page 62: 擦除 25,200 次
Page 63: 擦除 24,900 次
最大偏差: 0.12% —— 磨损均衡效果优秀

八、总结与展望

通过这个项目,我们积累了一些宝贵的实践经验:

  • Flash 模拟 EEPROM 是完全可行的,但需要仔细处理磨损均衡和掉电保护
  • CRC 校验必不可少,它是数据完整性的最后一道防线
  • 页面迁移的原子性通过"状态标记 + 序列号"机制可以很好地保证
  • 4 页轮转相比传统的 2 页交替方案,在寿命和空间利用率上都有显著提升

后续优化方向包括:支持变长数据记录(当前固定 12 字节数据区)、添加 AES 加密支持以保护敏感参数、以及适配 STM32G0/F4 等新型号的双 Bank Flash 架构。

完整的源代码已开源在我们的 GitHub 仓库中,欢迎交流讨论。如果你在实际项目中有类似的 Flash 存储需求,欢迎联系我们团队获取技术支持。