Flash
EEPROM
HAL
嵌入式
一、背景与需求
在很多嵌入式项目中,我们需要在断电后保存一些关键参数——比如设备校准数据、用户配置信息、运行累计时长等。理想情况下我们会使用外部 EEPROM 芯片(如 AT24C256),但在成本敏感或 PCB 空间有限的产品中,利用 STM32 内部 Flash 来模拟 EEPROM 就成了一个非常有吸引力的方案。
在我们的一个工业传感器项目中,客户要求在不增加 BOM 成本的前提下,实现至少 10000 次参数写入、断电不丢失数据、且写入过程中意外断电不能损坏已有数据。经过技术调研,我们决定基于 STM32F103C8T6 的内部 Flash 实现一套完善的 EEPROM 模拟方案。
二、STM32 Flash 页结构解析
STM32F103C8T6 拥有 64KB Flash,按 1KB 为一页,共 64 页。Flash 的基本操作特性如下:
- 写入粒度:半字(16-bit),即每次最少写入 2 字节
- 擦除粒度:整页擦除(1KB),擦除后所有位变为 1(0xFFFF)
- 写入规则:只能将 1 写为 0,不能将 0 写为 1(需要先擦除)
- 擦写寿命:典型值 10000 次(数据手册保证值)
基于以上特性,我们的模拟 EEPROM 方案需要解决以下核心问题:
- 如何高效地管理 Flash 空间,实现多次小数据写入
- 如何在整页擦除的特性下,避免每次写入都擦除整页
- 如何在写入过程中掉电时,保证之前的数据安全
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 倍
五、掉电保护机制
在工业现场,掉电是常见事件。我们的方案需要保证在写入操作的任何时刻掉电,都不会丢失已有数据。掉电保护通过以下三层机制实现:
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 存储需求,欢迎联系我们团队获取技术支持。