深入剖析 EasyFlash ENV:嵌入式 Flash KV 存储的精妙设计

深入剖析 EasyFlash ENV:嵌入式 Flash KV 存储的精妙设计

嵌入式开发中,如何在有限的 Flash 上实现可靠、高效的键值存储?EasyFlash 给出了一个优雅的答案。

前言

在嵌入式开发中,我们经常需要保存一些配置参数——比如设备 ID、网络配置、校准数据等。传统的做法通常是:找一块 Flash 扇区,擦除后重写整个扇区。这种方式简单粗暴,但有两个致命问题:

  1. Flash 寿命有限:典型 NOR Flash 擦写次数只有 10 万次,每次修改都擦除整个扇区,寿命消耗极快。
  2. 掉电不安全:擦除和重写之间如果掉电,数据全部丢失。

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_WRITEENV_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(&sector, 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(&sector, 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 两阶段删除:掉电安全的保障

为什么要分两步删除?这是为了 掉电保护

阶段 操作 状态变化 掉电后处理
第一阶段 写入新值之前 WRITEPRE_DELETE 视为有效(删除未完成)
第二阶段 写入新值成功后 PRE_DELETEDELETED 已完成删除

如果第一阶段后、第二阶段前掉电:

  • 重启后检测到 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(&sector)) != FAILED_ADDR) {
        read_sector_meta_data(sec_addr, &sector, 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(&sector, 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 流程:

  1. 找到 FULL + DIRTY 的扇区
  2. 将其中有效 KV(ENV_WRITE 状态)搬运到其他有空间的扇区
  3. 擦除该扇区并重新格式化为 EMPTY

GC 是唯一需要擦除 Flash 的常规操作。


六、各操作是否需要擦除 Flash?总结

操作 是否需要擦除 说明
初始化/格式化 首次使用时擦除所有扇区并写入头部
添加 KV 追加写入,只修改状态和写入数据
修改 KV 旧值标记删除(状态修改),新值追加写入
删除 KV 仅修改状态位(1→0)
查找 KV 纯读取操作
GC 垃圾回收 搬运有效数据后擦除源扇区

七、设计亮点总结

EasyFlash ENV 的设计有几个值得学习的亮点:

  1. 追加写入(Append-only):常规增删改查都不需要擦除 Flash,极大延长 Flash 寿命
  2. 单向状态迁移:利用 Flash 只能 1→0 的物理特性,状态修改不需要擦除
  3. 两阶段掉电保护:通过 PRE_WRITE / PRE_DELETE 中间状态,保证任何时刻掉电都可恢复
  4. 磨损平衡:KV 轮流写入不同扇区,避免集中在同一扇区擦写
  5. CRC32 校验:每个 KV 都有 CRC32,可以检测数据损坏
  6. 几乎零 RAM:NG 模式不需要在 RAM 中缓存所有 ENV,按需从 Flash 读取

这些设计思想不仅适用于嵌入式 Flash 存储,其中的 Append-only、软删除、两阶段提交等理念在数据库和分布式系统设计中也非常常见。理解这些基础设计模式,对工程师的技术成长大有裨益。


参考资料

posted @ 2026-04-12 15:02  莫蘅  阅读(11)  评论(0)    收藏  举报