Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.3 Metadata Pair——两页互为备份的原子提交

两个块为什么就够了?

现在你面对一个问题:如何在一个只能整块擦除的NOR Flash上,实现一个能够原子提交的“小日志“?

日志的经典定义是一个环形缓冲区——你不断往后追加记录,当到达末端时回到开头。但在Flash上,“到达末端时擦除“意味着你要擦除一个整块——如果擦除操作在中途掉电,这个块就变成了半擦除的垃圾状态。所以你需要至少两个块:在一块活跃写入时,另一块要么是空的(刚被擦除)、要么保存着历史数据。

这就是Metadata Pair的根本原因:两块Flash,一块活跃,一块备用。 不是三块,不是四块——两块是维持“原子性+不丢数据“的最小配置。

Metadata Pair 的基本结构
=========================================

         pair[0]                     pair[1]
    ┌──────────────┐          ┌──────────────┐
    │  revision: 3  │          │  revision: 2  │
    │──────────────│          │──────────────│
    │   entry A'    │  ← 最新  │   entry A     │  ← 旧数据
    │──────────────│          │──────────────│
    │   entry B'    │          │   entry B     │
    │──────────────│          │──────────────│
    │   entry C     │          │   entry C     │
    │──────────────│          │──────────────│
    │   checksum    │          │   checksum    │
    └──────────────┘          └──────────────┘
         ↑ 活跃                      ↑ 非活跃
    revision更大                revision较小

每个metadata pair由两个block地址组成:pair[0]pair[1]。通过比较两个block中的revision计数(使用序列号算术来防止整数溢出导致的问题),LittleFS知道哪一个是“最新“的。revision本质上是这个metadata pair经历的compaction次数——每次compact时revision+1。

在LittleFS中,metadata pair的结构体是lfs_mdir_t

// lfs.h:377-386 — metadata directory 运行时状态
typedef struct lfs_mdir {
    lfs_block_t pair[2];    // 两个block的地址
    uint32_t rev;           // 当前revision(RAM中的值)
    lfs_off_t off;          // 当前读取/写入偏移
    uint32_t etag;          // end tag:最后一个有效tag
    uint16_t count;         // 分裂计数(目录分裂后的子块数)
    bool erased;            // 备用块是否已擦除
    bool split;             // 是否已分裂(有tail指向下一个metadata pair)
    lfs_block_t tail[2];    // 分裂后的尾部地址
} lfs_mdir_t;

而盘上,每个metadata block的格式极其简单:

Metadata Block 盘上格式
=========================================

偏移 0:   revision (4 bytes, little-endian)
         ┌──────────────────────────────┐
         │  entry 1: tag + data         │
         │  entry 2: tag + data         │
         │  entry 3: tag + data         │
         │  ...                         │
         │  entry N: tag + data         │
         │  padding                     │
         │  CRC (4 bytes, little-endian)│ ← 最后一个32位字
         └──────────────────────────────┘

注意:条目是从block开头向末尾增长的
      CRC总是在block的最末尾
      block中未被entry覆盖的区域是0xFF(擦除态)

每个entry由tag(4字节)和随后的data组成。tag编码了类型、ID和data大小。当LittleFS读到data末尾时检查CRC——如果CRC匹配,整个block的内容就是可信的。


原子提交三步曲

Metadata pair的“原子提交“在数学上是这样保证的:在任何时刻断电,两个block中至少有一个包含完整且CRC校验通过的数据。

这个保证通过三种操作模式的精心编排来实现:

模式一:追加(Append)——block未满时

这是最理想的情况。活跃block还有空间,你直接在已有entry后面追加新entry。追加过程中断电的后果是:CRC覆盖不到未写完的entry,所以旧CRC仍然有效。挂载时LittleFS读到旧CRC,忽略不完整的尾部,回到上一个完整状态。

追加操作中的掉电安全性
=========================================

commit A:
┌──────────────┬──────────────┐     ┌──────────────┬──────────────┐
│  rev=1       │  rev=0       │     │  rev=1       │  rev=0       │
│──────────────│              │     │──────────────│              │
│              │              │ ==> │     A        │              │
│              │              │     │──────────────│              │
│              │              │     │   CRC(ok)    │              │
└──────────────┴──────────────┘     └──────────────┴──────────────┘

commit B and A':
┌──────────────┬──────────────┐     ┌──────────────┬──────────────┐
│  rev=1       │  rev=0       │     │  rev=1       │  rev=0       │
│──────────────│              │     │──────────────│              │
│     A        │              │     │     A        │              │
│──────────────│              │     │──────────────│              │
│   CRC(ok)    │              │     │     B        │              │
│              │              │ ==> │──────────────│              │
│              │              │     │     A'       │              │
│              │              │     │──────────────│              │
│              │              │     │   CRC(ok)    │              │
└──────────────┴──────────────┘     └──────────────┴──────────────┘

如果在写入B或A'的过程中断电:
→ 新的CRC不存在 → 旧CRC仍然有效 → 回到只有A的状态
→ 安全!

模式二:紧凑化(Compaction)——block满了但没有过期的entry

当活跃block满了的时候,LittleFS需要把仍然有效的entry复制到备用block上(擦除后变成干净的空白区),然后擦除旧活跃块。这个过程叫compaction(紧凑化)。

Compaction 中的掉电安全性
=========================================

commit B', need to compact:
┌──────────────┬──────────────┐     ┌──────────────┬──────────────┐
│  rev=1       │  rev=0       │     │  rev=1       │  rev=2       │
│──────────────│              │     │──────────────│──────────────│
│     A        │              │     │     A        │     A'       │
│──────────────│              │     │──────────────│──────────────│
│   CRC(ok)    │              │     │   CRC(ok)    │     B'       │
│──────────────│              │     │──────────────│──────────────│
│     B        │              │ ==> │     B        │   CRC(ok)    │
│──────────────│              │     │──────────────│              │
│     A'       │              │     │     A'       │              │
│──────────────│              │     │──────────────│              │
│   CRC(ok)    │              │     │   CRC(ok)    │              │
└──────────────┴──────────────┘     └──────────────┴──────────────┘

如果在compaction过程中断电:
→ pair[1]的新CRC还没写 → pair[0]的旧CRC仍然有效
→ 回到compaction之前的状态
→ 安全!

在代码层面,lfs_dir_compact(lfs.c:1952)精确实现了这个过程:

// lfs.c:1952-2064 — lfs_dir_compact 的核心流程(简化版)
static int lfs_dir_compact(lfs_t *lfs,
        lfs_mdir_t *dir, const struct lfs_mattr *attrs, int attrcount,
        lfs_mdir_t *source, uint16_t begin, uint16_t end) {

    dir->rev += 1;  // 递增revision

    while (true) {
        // 1. 初始化提交状态,目标是pair[1](备用块)
        struct lfs_commit commit = {
            .block = dir->pair[1],
            .off = 0,
            .crc = 0xffffffff,
        };

        // 2. 擦除备用块
        int err = lfs_bd_erase(lfs, dir->pair[1]);
        if (err) {
            if (err == LFS_ERR_CORRUPT) goto relocate;
            return err;
        }

        // 3. 写入新的revision
        err = lfs_dir_commitprog(lfs, &commit,
                &dir->rev, sizeof(dir->rev));

        // 4. 遍历旧数据,把仍然有效的entry复制过来
        err = lfs_dir_traverse(lfs, source, 0, 0xffffffff,
                attrs, attrcount,
                LFS_MKTAG(0x400, 0x3ff, 0),
                LFS_MKTAG(LFS_TYPE_NAME, 0, 0),
                begin, end, -begin,
                lfs_dir_commit_commit, &commit_data);

        // 5. 如果有tail,写入tail指针
        if (!lfs_pair_isnull(dir->tail)) {
            err = lfs_dir_commitattr(lfs, &commit,
                    LFS_MKTAG(LFS_TYPE_TAIL + dir->split, 0x3ff, 8),
                    dir->tail);
        }

        // 6. 写入global state的delta
        // ...

        // 7. 写入CRC(这是原子性的关键!)
        err = lfs_dir_commitcrc(lfs, &commit);
        if (err) {
            if (err == LFS_ERR_CORRUPT) goto relocate;
            return err;
        }

        // 8. CRC写入成功 → 翻转pair,旧活跃块降级为备用块
        dir->erased = false;
        lfs_pair_swap(dir->pair);  // pair[0] ↔ pair[1]
        dir->off = commit.off;
        dir->etag = commit.ptag;
        return 0;

    relocate:
        // 坏块处理:尝试用一个新的block替换pair[1]
        lfs_cache_drop(lfs, &lfs->pcache);
        lfs_block_t bad = dir->pair[1];
        int err = lfs_alloc(lfs, &dir->pair[1]);
        if (err) return err;
        // 标记坏块为"已使用",防止后续分配
        // 进入下一轮循环重试
    }
}

模式三:分裂(Split)——block满了且无法compact

如果block满了,但经过遍历发现没有可以清理的过期entry(所有entry都是活跃的),这意味着这个目录的元数据量需要超过一个block的容量。此时LittleFS的做法不是增大block——而是把metadata pair分拆成两个,用链表连接。

Split:一个metadata pair 分成两个
=========================================

commit C and D, need to split:
┌──────────────┬──────────────┐     ┌──────────────┬──────────────┐
│  rev=1       │  rev=2       │     │  rev=3       │  rev=2       │
│──────────────│──────────────│     │──────────────│──────────────│
│     A        │     A'       │     │     A'       │     A'       │
│──────────────│──────────────│     │──────────────│──────────────│
│   CRC(ok)    │     B'       │     │     B'       │     B'       │
│──────────────│──────────────│     │──────────────│──────────────│
│     B        │   CRC(ok)    │ ==> │   tail    ───┐              │
│──────────────│              │     │──────────────│──────────────│
│     A'       │              │     │   CRC(ok)    │              │
│──────────────│              │     │──────────────│              │
│   CRC(ok)    │              │     │              │              │
└──────────────┴──────────────┘     └────────┬─────┴──────────────┘
                                             │
                               ┌─────────────┘
                               v
                      ┌──────────────┬──────────────┐
                      │  rev=1       │  rev=0       │
                      │──────────────│              │
                      │     C        │              │
                      │──────────────│              │
                      │     D        │              │
                      │──────────────│              │
                      │   CRC(ok)    │              │
                      └──────────────┴──────────────┘

注意split过程的两步原子性:

  1. 先准备新metadata pair——分配两个新block,写入一半的entry,写入CRC。
  2. 再在原metadata pair中插入tail指针——指向新分配的那个pair。这一步本身是一次原子的目录提交(追加模式)。

如果在第1步后第2步前断电:新metadata pair是孤儿——它在链表里没有parent引用。但LittleFS的孤儿恢复机制会在下次挂载时发现它,并把它正确插入目录树。

这就是“有界日志的链表“——每个metadata pair有界(最多容纳约半个block的有效数据),但通过链表可以无限扩展。 这保证了目录可以包含任意数量的文件,同时每次操作的时间复杂度仍然是O(1)。


50%分裂阈值——为什么不在100%时才分裂?

这里有一个容易混淆的地方:LittleFS 的 compact 触发条件并不是 50%。compact 的真正触发条件是 block 写满(没有空间容纳下一次 commit 了)或者 block_cycles 到期强制搬迁。50% 是 compact 过程中的分裂决策阈值

当 compact 发生时,LittleFS 检查当前 metadata pair 中有效数据(静态条目)的占比:

  • 如果有效数据 ≤ 50% → 原地 compact,不分裂
  • 如果有效数据 > 50% → 分裂成两个 metadata pair,用 tail 指针串联

为什么要 50%?从数学上看:

假设每个 entry 是 100 字节,一个 block 是 4KB(4096 字节):

  • 当有效数据占 25% 时,每次 compact 成本 ≈ 1.3n
  • 当有效数据占 50% 时,每次 compact 成本 ≈ 2n
  • 当有效数据占 75% 时,每次 compact 成本 ≈ 4n
  • 当有效数据占 90% 时,成本 ≈ 10n

成本曲线是指数增长的。在 DESIGN.md 中,LittleFS 的作者用平摊分析证明了:在 50% 时分裂,能把 compact 的平摊成本限制在 2x 以内,保证 O(1) 的操作复杂度。

所以 LittleFS 的做法是:平时不管,block 写满才 compact。compact 时如果发现有效条目超过一半,就分裂。“50%“是判别条件,不是触发条件。


与KnotFS的SB Log对比

如果你已经读过第三章(KnotFS的设计),你可能会发现这里有一个有趣的对照:

Metadata Pair  vs  KnotFS的SB双槽位设计
=======================================================

特性              LittleFS Metadata Pair      KnotFS SB双槽位
───────────────────────────────────────────────────────
块数量            2个(pair[0] + pair[1])     SB: 2块(SB0/SB1)
                                              文件表嵌入在SB Header中
原子提交          追加+compact+split           追加+compact(写入备用槽位)
活跃识别          比较revision计数             比较sequence number
Compact触发         block写满 或 block_cycles     Log区达到50%容量
分裂阈值             有效数据超过50%时分裂            不分裂(固定8文件槽位)
分裂机制          是(tail指针链表)            否(文件表固定8槽位)
CRC保护           每次commit一个CRC            每个log record一个CRC
───────────────────────────────────────────────────────

两种设计的核心思想相似——都是“两块交替+序列号仲裁+日志追加“。但细节差异反映了两者不同的设计目标:

  • KnotFS是“知道上限的“: 最多8个文件,最多8个块的文件——所以superblock(含文件表)的大小是固定的,compact只是把内存中的完整状态写入备用SB槽位,不需要分裂。
  • LittleFS是“不知道上限的“: 文件数量、目录深度、文件大小——都不受限制(除了文件大小有2GB上限)。所以它需要tail链表来支持metadata pair的无界扩展。

revision计数与序列号算术

Metadata pair依赖revision计数来判断哪一个block是“最新的“。但revision是一个uint32_t——它最终会溢出。如果revision从0xFFFFFFFF翻转到0x00000000,简单的大小比较就会出错。

LittleFS使用序列号算术(Sequence Number Arithmetic) 来解决这个问题。核心思路是:我们不是比较“哪个数字大“,而是比较“从A到B需要加多少次“——这个“距离“在二进制补码下是不受溢出影响的。

用代码来理解更直观:

// 判断 rev_a 是否比 rev_b 更新
// 不是简单的 a > b,而是检查顺时针距离
bool is_newer(uint32_t rev_a, uint32_t rev_b) {
    return (int32_t)(rev_a - rev_b) > 0;
}

为什么这样可以?因为uint32_t的加减在计算机中本身就是模2³²的运算。(int32_t)(a - b) > 0等价于“从b顺时针走到a的距离小于2³¹“——这恰好是我们需要的“a比b更新“的条件。

前提条件是你不会连续执行超过2³¹次compaction而不检查revision——在Flash的物理寿命内(10万次擦除 × 两个block = 20万次),这个条件永远满足。


lfs_dir_commit:一次提交的完整路径

我们从顶层看一次目录提交的完整调用链:

// lfs.c:2601 — lfs_dir_commit 的顶层逻辑
static int lfs_dir_commit(lfs_t *lfs, lfs_mdir_t *dir,
        const struct lfs_mattr *attrs, int attrcount) {

    // 第一步:尝试追加
    int orphans = lfs_dir_orphaningcommit(lfs, dir, attrs, attrcount);

    // 第二步:如果有孤儿产生,清理孤儿
    if (orphans) {
        int err = lfs_fs_deorphan(lfs, false);
        if (err) return err;
    }

    return 0;
}

lfs_dir_orphaningcommit内部会调用lfs_dir_relocatingcommit,后者调用lfs_dir_splittingcompact

lfs_dir_commit
  └─ lfs_dir_orphaningcommit      ← 处理孤儿逻辑
       └─ lfs_dir_relocatingcommit ← 如果需要搬迁block
            └─ lfs_dir_splittingcompact ← 处理compaction/split
                 ├─ 尝试追加(如果当前block有空间)
                 ├─ 尝试compact(如果当前block满了)
                 └─ 尝试split(如果compact后仍然超50%)

这个调用链展示了metadata pair设计的全面性:无论当前处于什么状态,总有一条路径可以让提交成功——或者因为空间不足而触发更高级的处理。


掉电保证的最终验证

让我们做一个思想实验。你在一个metadata pair处于任意状态时拔掉电源。可能的情况:

情况1:正在追加entry,追加未完成。 → 旧CRC仍然有效 → 挂载时读到旧CRC → 忽略不完整的尾部 → 回到追加前状态。

情况2:正在compact,旧数据已复制到pair[1],但CRC未写入。 → pair[1]没有有效CRC → pair[0]的旧CRC仍然有效 → 回到compact前状态。

情况3:正在compact,CRC已写入,但pair尚未swap。 → pair[1]有新的有效CRC → pair[0]有旧的有效CRC → 比较revision → 发现pair[1]的revision更新 → 使用pair[1]作为数据源 → compact完成!

情况4:正在split,新pair已准备,但tail指针未在原pair中写入。 → 两个pair独立存在 → 新pair是孤儿 → 孤儿恢复机制会处理。

在所有可能的状态下,文件系统要么回到前一个一致状态,要么前进到下一个一致状态。不存在“部分写入“的中间态。

这就是LittleFS的Strong Guarantee在metadata pair层面的具体实现。

KnotFS 的双 SB 槽位(SB0/SB1)借鉴了 Metadata Pair 的双块交替思想——SB Log 往活跃槽位追加,Compact 往非活跃槽位覆盖,sequence 奇偶性决定谁是当前有效副本。但关键区别在于:LittleFS 的 Metadata Pair 存储的是目录条目(任意多),可以动态分裂成 tail 链;KnotFS 的 SB 存储的是文件表(固定 8 个槽位),Compact 就是整体重写——更简单,但也更“费 Flash“。这就是教学版刻意砍掉的复杂度:当你只需要管理 8 个文件时,不需要 metadata pair 的泛型框架。


下集预告

Metadata pair解决了“元数据如何安全落地“的问题——但它只存了目录结构。文件的真正数据呢?4KB的metadata block存不下一张照片。LittleFS用了一个你可能没见过的数据结构——CTZ跳表——让文件的每个数据块既是一个COW链表节点,又是一个跳表节点。追加在O(1)时间内完成,随机访问在O(log n)时间内完成。而这背后的数学,涉及二进制的count trailing zeros指令和OEIS数列大全中的一个惊人巧合。

悬念留给:为什么一个文件只需要两个数字(head指针 + size)就能描述?你是怎么从一个文件大小反推出它在跳表中的精确位置的?