深入剖析 EasyFlash ENV:嵌入式 Flash KV 存储的精妙设计
深入剖析 EasyFlash ENV:嵌入式 Flash KV 存储的精妙设计
嵌入式开发中,如何在有限的 Flash 上实现可靠、高效的键值存储?EasyFlash 给出了一个优雅的答案。
前言
在嵌入式开发中,我们经常需要保存一些配置参数——比如设备 ID、网络配置、校准数据等。传统的做法通常是:找一块 Flash 扇区,擦除后重写整个扇区。这种方式简单粗暴,但有两个致命问题:
- Flash 寿命有限:典型 NOR Flash 擦写次数只有 10 万次,每次修改都擦除整个扇区,寿命消耗极快。
- 掉电不安全:擦除和重写之间如果掉电,数据全部丢失。
EasyFlash 的 ENV 模块(V4.0 NG 模式)用一套精巧的设计解决了这些问题。本文将从源码层面深入分析它的三大核心机制:
- Append-only(追加写入)——常规配置修改参数操作不需要擦除 Flash
- 利用Flash 的物理特性实现状态的单向迁移,不需要擦除就能修改状态
- ** 利用"遍历迭代 + 回调匹配" 的方式查找参数,支持可选的缓存加速**
一、整体架构概览
EasyFlash 将 Flash 变为一个小型 NoSQL 键值数据库,核心设计理念是 Append-only(追加写入)——常规操作不需要擦除 Flash。
Flash 布局
+----------------------------+
| Environment variables area | ENV_AREA_SIZE(至少 2 个扇区)
+----------------------------+
| Saved log area | LOG_AREA_SIZE
+----------------------------+
| (IAP) Downloaded app |
+----------------------------+
ENV 区域按 Flash 最小擦除单位划分为多个扇区,每个扇区内部结构如下:
+========================+ <- 扇区起始地址
| Sector Header | <- 扇区头(状态 + 魔数)
+========================+
| KV Node 1 | <- 键值节点 1
+========================+
| KV Node 2 | <- 键值节点 2
+========================+
| 0xFF (空闲空间) |
+========================+ <- 扇区结束地址
二、扇区头结构:状态管理的基石
2.1 扇区头定义
每个扇区的起始位置存放一个扇区头:
struct sector_hdr_data {
struct {
uint8_t store[STORE_STATUS_TABLE_SIZE]; // 扇区存储状态
uint8_t dirty[DIRTY_STATUS_TABLE_SIZE]; // 扇区脏标记状态
} status_table;
uint32_t magic; // 魔数:0x30344645(ASCII "EF40")
uint32_t combined; // 合并扇区编号
uint32_t reserved; // 保留字段
};
扇区有 4 种存储状态:
| 状态值 | 名称 | 含义 |
|---|---|---|
| 0 | SECTOR_STORE_UNUSED |
未使用(初始状态) |
| 1 | SECTOR_STORE_EMPTY |
空(已格式化,无有效数据) |
| 2 | SECTOR_STORE_USING |
正在使用(有空间可写入新 KV) |
| 3 | SECTOR_STORE_FULL |
已满(无空间写入新 KV) |
以及 4 种脏状态:
| 状态值 | 名称 | 含义 |
|---|---|---|
| 0 | SECTOR_DIRTY_UNUSED |
未使用 |
| 1 | SECTOR_DIRTY_FALSE |
干净(无已删除的 KV) |
| 2 | SECTOR_DIRTY_TRUE |
脏(包含已删除的 KV,需要 GC) |
| 3 | SECTOR_DIRTY_GC |
正在执行 GC |
2.2 核心原理:利用 Flash 物理特性
这里有一个非常巧妙的设计。Flash 的物理特性是:
擦除后所有位为 1(0xFF),写入只能将 1 变为 0,不能将 0 变回 1。
EasyFlash 利用这一点实现状态的单向迁移,不需要擦除就能修改状态:
| 写入粒度 | status0 (初始) | status1 | status2 |
|---------------------------------------------------------------------------------
| 1 bit | 0xFF | 0x7F | 0x3F |
| 8 bit | 0xFFFF | 0x00FF | 0x0000 |
| 32 bit | 0xFFFFFFFF... | 0x00FFFFFF... | 0x00FFFFFF00... |
set_status() 的实现非常简洁:
static size_t set_status(uint8_t status_table[], size_t status_num,
size_t status_index) {
memset(status_table, 0xFF, STATUS_TABLE_SIZE(status_num));
if (status_index > 0) {
byte_index = (status_index - 1) * (EF_WRITE_GRAN / 8);
status_table[byte_index] = 0x00; // 只需将对应字节写为 0x00
}
return byte_index;
}
2.3 回答:更新扇区头需要擦除吗?
不需要。 日常操作中,扇区状态变更(如 EMPTY → USING、DIRTY_FALSE → DIRTY_TRUE)只需通过 write_status() 将状态表中的某些位从 1 写为 0,这是一次普通的 Flash 写操作。
只有两种场景需要擦除:
| 场景 | 是否擦除 | 说明 |
|---|---|---|
| 首次格式化 | ✅ 是 | 擦除整个扇区后写入头部 |
| 状态变更(EMPTY→USING 等) | ❌ 否 | 只写 0x00 修改状态表 |
| GC 回收扇区 | ✅ 是 | 搬运有效数据后擦除整个扇区 |
三、KV 节点格式与旧值删除标记
3.1 KV 节点结构
每个 KV 条目在 Flash 中的布局:
+----------+--------+------+--------+-------+----------+
| status | magic | len | crc32 | name | value |
| table | KV40 | | | | |
| (变长) | 4字节 | 4字节| 4字节 | 变长 | 变长 |
+----------+--------+------+--------+-------+----------+
对应的结构体定义:
struct env_hdr_data {
uint8_t status_table[ENV_STATUS_TABLE_SIZE]; // ENV 节点状态
uint32_t magic; // 魔数:0x3034564B(ASCII "KV40")
uint32_t len; // 节点总长度(header + name + value)
uint32_t crc32; // CRC32(name_len + value_len + name + value)
uint8_t name_len; // 名称长度
uint32_t value_len; // 值长度
};
ENV 节点有 6 种状态,其中 ENV_PRE_WRITE 和 ENV_PRE_DELETE 是掉电保护的中间状态:
| 状态值 | 名称 | 含义 |
|---|---|---|
| 0 | ENV_UNUSED |
未使用(全 0xFF) |
| 1 | ENV_PRE_WRITE |
准备写入(掉电保护中间状态) |
| 2 | ENV_WRITE |
已写入(有效数据) |
| 3 | ENV_PRE_DELETE |
准备删除(掉电保护中间状态) |
| 4 | ENV_DELETED |
已删除 |
| 5 | ENV_ERR_HDR |
头部错误 |
3.2 修改参数:追加写入 + 软删除
当修改一个参数时,EasyFlash 不会去擦除旧值,而是采用 "先删后加" 策略:
修改前:
+----------+ +----------+
| Sector 1 | | Sector 2 |
| USING | | EMPTY |
| KV1=a | | |
| KV2=b | | |
| KV3=c | | |
+----------+ +----------+
修改 KV2=b → KV2=x,删除 KV1,添加 KV4 后:
+----------+ +----------+
| Sector 1 | | Sector 2 |
| FULL | | USING |
| DIRTY | | |
| [d]KV1 | | KV3(new) |
| [d]KV2 | | KV4 |
| KV2(new) | | |
+----------+ +----------+
[d] = 已删除(状态为 ENV_DELETED)
3.3 ef_set_env() 完整流程
static EfErrCode set_env(const char *key, const void *value_buf, size_t buf_len) {
// 步骤 1:预检查,确保 Flash 有足够空间
new_env_by_kv(§or, strlen(key), buf_len);
// 步骤 2:查找旧的同名 ENV
env_is_found = find_env(key, &env);
// 步骤 3:将旧 ENV 标记为"预删除"(PRE_DELETE)—— 掉电保护
if (env_is_found) {
del_env(key, &env, false); // false = 只写 PRE_DELETE 状态
}
// 步骤 4:在新位置追加写入新的 KV 节点
create_env_blob(§or, key, value_buf, buf_len);
// 步骤 5:新值写入成功后,将旧值标记为"已删除"(DELETED)
if (env_is_found) {
del_env(key, &env, true); // true = 写 DELETED 状态
}
// 步骤 6:如果触发了 GC 请求,执行垃圾回收
if (gc_request) gc_collect();
}
3.4 两阶段删除:掉电安全的保障
为什么要分两步删除?这是为了 掉电保护:
| 阶段 | 操作 | 状态变化 | 掉电后处理 |
|---|---|---|---|
| 第一阶段 | 写入新值之前 | WRITE → PRE_DELETE |
视为有效(删除未完成) |
| 第二阶段 | 写入新值成功后 | PRE_DELETE → DELETED |
已完成删除 |
如果第一阶段后、第二阶段前掉电:
- 重启后检测到
PRE_DELETE状态,说明新值可能未写入成功 - 系统会将该 KV 迁移恢复,确保数据不丢失
del_env() 的核心实现:
static EfErrCode del_env(const char *key, env_node_obj_t old_env,
bool complete_del) {
if (!complete_del) {
// 第一阶段:标记为 ENV_PRE_DELETE
write_status(old_env->addr.start, status_table,
ENV_STATUS_NUM, ENV_PRE_DELETE);
} else {
// 第二阶段:标记为 ENV_DELETED
write_status(old_env->addr.start, status_table,
ENV_STATUS_NUM, ENV_DELETED);
}
// 同时将所在扇区标记为脏(DIRTY)
if (read_status(...) == SECTOR_DIRTY_FALSE) {
write_status(sector_addr + SECTOR_DIRTY_OFFSET,
status_table, SECTOR_DIRTY_STATUS_NUM,
SECTOR_DIRTY_TRUE);
}
}
3.5 回答:标记旧值为删除需要擦除吗?
不需要。 删除操作只是通过 write_status() 将 KV 节点的状态从 ENV_WRITE 改为 ENV_DELETED,本质是将状态表中的某些位从 1 写为 0,完全不需要擦除。
被删除的 KV 数据仍然占据 Flash 空间,但其状态已被标记为 DELETED,查找时会被跳过。这些"垃圾数据"会在 GC(垃圾回收)时被清理。
四、参数查找机制
4.1 查找策略
EasyFlash 采用 "遍历迭代 + 回调匹配" 的方式查找参数,支持可选的缓存加速。
调用链:
ef_get_env(key)
→ ef_get_env_blob(key, value_buf, buf_len)
→ get_env(key, env)
→ find_env(key, env)
→ [1] 先查缓存(如启用)
→ [2] 缓存未命中,调用 env_iterator() 遍历 Flash
→ [3] 找到后更新缓存
4.2 env_iterator() 遍历算法
采用两层循环——外层遍历扇区,内层遍历扇区内的 KV 节点:
static void env_iterator(env_node_obj_t env, void *arg1, void *arg2,
bool (*callback)(env_node_obj_t env, void *arg1, void *arg2)) {
// 外层循环:遍历所有扇区
while ((sec_addr = get_next_sector_addr(§or)) != FAILED_ADDR) {
read_sector_meta_data(sec_addr, §or, false);
// 只搜索 USING 或 FULL 状态的扇区
if (sector.status.store == SECTOR_STORE_USING ||
sector.status.store == SECTOR_STORE_FULL) {
// 内层循环:遍历扇区内所有 KV 节点
while ((env->addr.start = get_next_env_addr(§or, env))
!= FAILED_ADDR) {
read_env(env);
if (callback(env, arg1, arg2)) return; // 回调判断
}
}
}
}
4.3 匹配条件:三重校验
查找时的匹配条件非常严格,必须同时满足三个条件:
static bool find_env_cb(env_node_obj_t env, void *arg1, void *arg2) {
const char *key = arg1;
bool *find_ok = arg2;
if (key_len != env->name_len) return false;
// 三重校验:
// 1. CRC32 校验通过(数据完整性)
// 2. 状态为 ENV_WRITE(有效数据,跳过已删除的)
// 3. 名称匹配
if (env->crc_is_ok && env->status == ENV_WRITE
&& !strncmp(env->name, key, key_len)) {
*find_ok = true;
return true; // 找到,中断迭代
}
return false;
}
4.4 正确性保证
遍历顺序是从第一个扇区到最后一个扇区。由于修改 KV 时旧值被标记为 ENV_DELETED,新值追加在后面,所以:
- 旧值(
ENV_DELETED状态)会被回调函数自动跳过(因为要求status == ENV_WRITE) - 找到的第一个匹配项就是最新值(因为同名的旧值已被删除)
- 查找是纯读取操作,不需要擦除 Flash
4.5 缓存加速(可选)
当启用缓存时(EF_ENV_USING_CACHE),查找会先查缓存表:
struct env_cache_node {
uint16_t name_crc; // ENV 名称的 CRC32 高 16 位
uint16_t active; // 访问活跃度(类 LRU 算法)
uint32_t addr; // ENV 节点地址
};
- 默认缓存 16 个 ENV 节点
- 采用类 LRU(最近最少使用)替换算法
- 缓存命中时直接从缓存地址读取,避免全扇区遍历
- 扇区缓存(默认 4 项)缓存当前 USING 扇区的空地址,加速写入分配
五、垃圾回收(GC)机制
被删除的 KV 仍然占据 Flash 空间,当空扇区数量降到阈值(默认只剩 1 个空扇区)时,触发 GC:
GC 前:
+----------+ +----------+ +----------+ +----------+
| Sector 1 | | Sector 2 | | Sector 3 | | Sector 4 |
| FULL | | USING | | FULL | | EMPTY |
| DIRTY | | KV3(new) | | KV5 | | |
| [d]KV1 | | KV4 | | KV6 | | |
| [d]KV2 | | | | | | |
| KV2(new) | | | | | | |
+----------+ +----------+ +----------+ +----------+
GC 后(Sector 1 被回收):
+----------+ +----------+ +----------+ +----------+
| Sector 1 | | Sector 2 | | Sector 3 | | Sector 4 |
| EMPTY | | USING | | FULL | | EMPTY |
| (已擦除) | | KV3(new) | | KV5 | | |
| | | KV4 | | KV6 | | |
| | | KV2(moved)| | | | |
+----------+ +----------+ +----------+ +----------+
GC 流程:
- 找到
FULL + DIRTY的扇区 - 将其中有效 KV(
ENV_WRITE状态)搬运到其他有空间的扇区 - 擦除该扇区并重新格式化为
EMPTY
GC 是唯一需要擦除 Flash 的常规操作。
六、各操作是否需要擦除 Flash?总结
| 操作 | 是否需要擦除 | 说明 |
|---|---|---|
| 初始化/格式化 | ✅ | 首次使用时擦除所有扇区并写入头部 |
| 添加 KV | ❌ | 追加写入,只修改状态和写入数据 |
| 修改 KV | ❌ | 旧值标记删除(状态修改),新值追加写入 |
| 删除 KV | ❌ | 仅修改状态位(1→0) |
| 查找 KV | ❌ | 纯读取操作 |
| GC 垃圾回收 | ✅ | 搬运有效数据后擦除源扇区 |
七、设计亮点总结
EasyFlash ENV 的设计有几个值得学习的亮点:
- 追加写入(Append-only):常规增删改查都不需要擦除 Flash,极大延长 Flash 寿命
- 单向状态迁移:利用 Flash 只能 1→0 的物理特性,状态修改不需要擦除
- 两阶段掉电保护:通过
PRE_WRITE/PRE_DELETE中间状态,保证任何时刻掉电都可恢复 - 磨损平衡:KV 轮流写入不同扇区,避免集中在同一扇区擦写
- CRC32 校验:每个 KV 都有 CRC32,可以检测数据损坏
- 几乎零 RAM:NG 模式不需要在 RAM 中缓存所有 ENV,按需从 Flash 读取
这些设计思想不仅适用于嵌入式 Flash 存储,其中的 Append-only、软删除、两阶段提交等理念在数据库和分布式系统设计中也非常常见。理解这些基础设计模式,对工程师的技术成长大有裨益。
参考资料
- EasyFlash GitHub 仓库
- EasyFlash 设计文档
- 源码:
easyflash/src/ef_env.c、easyflash/inc/ef_def.h

浙公网安备 33010602011771号