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.5 Block Allocator——lookahead与磨损均衡

你如何在一个4MB的Flash上找一块空地?

想象你现在正在LittleFS的内部。CTZ跳表刚决定要追加一个数据块——它调用lfs_alloc(lfs, &new_block),说:“给我一个空闲块。”

你现在有1024个4KB的块。其中有些已经被目录和数据占用了,有些是空闲的。你必须在有界RAM的前提下(不能扫描1024个块并构建一个1024位的位图——等等,1024位是多少?128字节。但如果是8192个块呢?1024字节。如果是131072个块(512MB)呢?16KB。)——总之,LittleFS不能假设设备多小。它必须为任意大的设备提供O(1)内存的分配方案。

你的选择是什么?

选项A:在盘上维护一个空闲块位图。每次分配时读取它,每次释放时更新它。 → 问题:掉电时位图更新可能中断,导致“已分配的块被记录为空闲“(块泄漏)或“空闲块被记录为已分配“(永久丢失空间)。

选项B:在挂载时扫描一次全盘,构建一个内存中的位图。 → 问题:RAM占用与设备大小线性增长,违反有界RAM原则。而且扫描一次全盘在大型设备上可能要数秒。

选项C:完全不维护空闲块信息。每次需要空闲块时,遍历整个文件系统树,找到所有被引用的块,剩下的就是空闲的。每次从头开始选一个。 → 问题:O(n²)的分配时间,在大型设备上不可接受。

LittleFS选择了选项D——lookahead缓冲区。


Lookahead:一个在空闲块上滑动的窗口

Lookahead的核心思想是:用一个固定大小的位图——比如16字节(128位)——来缓存“当前区域“的空闲/占用状态。当这个区域里的空闲块用完后,扫描文件系统找到下一个区域,更新位图,继续分配。

Lookahead Buffer 工作示意
=========================================================

设备(128个块,用 0 表示空闲,1 表示占用):
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│1 │0 │1 │1 │1 │0 │0 │1 │0 │1 │0 │0 │1 │0 │1 │0 │0 │0 │1 │0 │ ...
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
 \____________________/ \___________________________________/
   lookahead 位图           还未扫描的区域
   (32位 = 跟踪32个块)
// lfs_t 中的 lookahead 结构体
struct lfs_lookahead {
    lfs_block_t start;       // 当前窗口的起始块号
    lfs_block_t size;        // 窗口中还有多少blocks要检查
    lfs_block_t next;        // 窗口内的下一个位置
    lfs_block_t ckpoint;     // 距上次checkpoint以来检查过的块数
    uint8_t *buffer;         // 指向位图缓冲区的指针
};

lookahead位图的每一位对应一个物理块。“0“表示该块是空闲的(未被文件系统引用),“1“表示该块被占用。当你需要分配一个空闲块时,从next位置开始扫描位图,找到第一个值为0的位。

以16字节(128位)的lookahead缓冲区在128块(512KB)的设备上运行示例:

boot...         lookahead:
                fs blocks: fffff9fffffffffeffffffffffff0000
scanning...     lookahead: fffff9ff          ← 扫描找到一批空闲块
                fs blocks: fffff9fffffffffeffffffffffff0000
alloc = 21      lookahead: fffffdff          ← 分配了block 21
                fs blocks: fffffdfffffffffeffffffffffff0000
alloc = 22      lookahead: ffffffff          ← 分配了block 22
                fs blocks: fffffffffffffffeffffffffffff0000
scanning...     lookahead:         fffffffe  ← 窗口前移,重新扫描
                fs blocks: fffffffffffffffeffffffffffff0000
alloc = 63      lookahead:         ffffffff  ← 分配了block 63
                fs blocks: ffffffffffffffffffffffffffff0000
scanning...     lookahead:         ffffffff  ← 扫描中……
scanning...     lookahead:                 ffffffff  ← 继续扫描
scanning...     lookahead:                         ffff0000  ← 最后一段
alloc = 112     lookahead:                         ffff8000
                fs blocks: ffffffffffffffffffffffffffff8000

当lookahead窗口中的空闲块用完时,lfs_alloc_scan被调用——它会遍历整个文件系统,在下一次扫描窗口内标记哪些块被占用。窗口在设备上不断向前滑动,形成一个环形扫描模式。


lfs_alloc:分配一个块的完整过程

// lfs.c:666-715 — lfs_alloc 的完整逻辑
static int lfs_alloc(lfs_t *lfs, lfs_block_t *block) {
    while (true) {
        // 1. 在当前lookahead窗口中找空闲块
        while (lfs->lookahead.next < lfs->lookahead.size) {
            if (!(lfs->lookahead.buffer[lfs->lookahead.next / 8]
                    & (1U << (lfs->lookahead.next % 8)))) {
                // 找到了!这个块是空闲的
                *block = (lfs->lookahead.start + lfs->lookahead.next)
                        % lfs->block_count;

                // 提前找到下一个空闲块位置(优化后续分配)
                while (true) {
                    lfs->lookahead.next += 1;
                    lfs->lookahead.ckpoint -= 1;

                    if (lfs->lookahead.next >= lfs->lookahead.size
                            || !(lfs->lookahead.buffer[lfs->lookahead.next / 8]
                                & (1U << (lfs->lookahead.next % 8)))) {
                        return 0;
                    }
                }
            }

            lfs->lookahead.next += 1;
            lfs->lookahead.ckpoint -= 1;
        }

        // 2. 检查是否整个设备都扫描过了
        if (lfs->lookahead.ckpoint <= 0) {
            LFS_ERROR("No more free space 0x%"PRIx32,
                    (lfs->lookahead.start + lfs->lookahead.next)
                        % lfs->block_count);
            return LFS_ERR_NOSPC;
        }

        // 3. 当前窗口用完了,需要扫描下一个窗口
        int err = lfs_alloc_scan(lfs);
        if(err) {
            return err;
        }
    }
}

lfs_alloc_scan的实现展示了lookahead窗口如何移动:

// lfs.c:641-662 — lfs_alloc_scan:填充lookahead缓冲区
static int lfs_alloc_scan(lfs_t *lfs) {
    // 移动lookahead窗口到下一个位置
    lfs->lookahead.start = (lfs->lookahead.start + lfs->lookahead.next)
            % lfs->block_count;
    lfs->lookahead.next = 0;
    lfs->lookahead.size = lfs_min(
            8*lfs->cfg->lookahead_size,     // 位图能追踪的最大块数
            lfs->lookahead.ckpoint);         // 但不能超过剩余块数

    // 初始化为全0(全空闲),然后标记已被占用的块
    memset(lfs->lookahead.buffer, 0, lfs->cfg->lookahead_size);

    // 遍历整个文件系统树,标记被占用的块
    int err = lfs_fs_traverse_(lfs, lfs_alloc_lookahead, lfs, true);
    if (err) {
        lfs_alloc_drop(lfs);
        return err;
    }

    return 0;
}

lfs_alloc_lookahead是一个回调函数,对每个被文件系统占用的块,它在lookahead位图中标记其为“1“:

// lfs.c:627-637 — 标记被占用的块
static int lfs_alloc_lookahead(void *p, lfs_block_t block) {
    lfs_t *lfs = (lfs_t*)p;
    lfs_block_t off = ((block - lfs->lookahead.start)
            + lfs->block_count) % lfs->block_count;

    if (off < lfs->lookahead.size) {
        lfs->lookahead.buffer[off / 8] |= 1U << (off % 8);
    }

    return 0;
}

遍历的开销: 每次lfs_alloc_scan会调用lfs_fs_traverse_遍历整个文件系统树——包括所有目录的metadata pair和所有文件的CTZ跳表。这个遍历本身的复杂度是O(已用块数)。但因为lookahead窗口一次性填充了多个空闲块,每次遍历的平摊成本是:O(已用块数 / lookahead可以追踪的块数)。

对于典型的配置(lookahead_size=16字节→128位,设备有1024块),一次扫描填充128个块的信息,每128次分配才需要一次全盘扫描。对于小设备的日常使用,这通常意味着只需要一到两次扫描。


checkpoint机制:防止无限循环

考虑一个边界情况:如果文件系统快满了,每次lfs_alloc_scan都找不到空闲块,lfs_alloc会不会一直绕圈扫描直到地老天荒?

答案是不会——因为有ckpoint(检查点)计数器。

// lfs.c:614-616 — 设置检查点
static void lfs_alloc_ckpoint(lfs_t *lfs) {
    lfs->lookahead.ckpoint = lfs->block_count;
}

lfs_alloc_ckpoint在一次完整的、无等待的分配操作序列开始前被调用。它把ckpoint设置为总块数。每次扫描过一个块,ckpoint减1。如果ckpoint降到0,说明lookahead已经转了一圈,没有空闲块了——返回LFS_ERR_NOSPC(空间不足)。

这保证了分配器不会无限自旋。即使磁盘满了,它最多扫描一轮就会明确报告错误。


磨损均衡:藏在分配器里的隐形福利

LittleFS的磨损均衡是统计式的动态磨损均衡——它不主动追踪每个块的擦除次数(那需要按块存储擦除计数,代价是O(n)的内存),而是利用分配模式的自然随机性来近似均匀分布。

具体来说:

1. 线性循环分配(设备上电期间)。

lookahead窗口在设备上以线性循环的方式移动。每次lfs_alloc_scan都把窗口向前推进一段距离。这意味着在一次运行中,块按物理顺序被分配——块0、块1、块2……直到绕回。这已经提供了一层磨损分布:热点不会集中在某一块。

2. 随机起始偏移(每次挂载时)。

如果每次上电都从块0开始分配,那块0会比块1023经历更多次擦除。LittleFS通过在挂载时确定一个随机起始偏移来解决这个问题:

// 每次挂载时的随机种子生成(来自 DESIGN.md)
//
//             ┌────────┐ \                         probably random
//            ┌│metadata│ |                                ^
//            ││        │ +→ crc ───────────────────────→ xor
//            ││        │ |                                ^
//            │└────────┘ /                                │
//            └───│──│──┘                                   │
//             ┌─┘    └─────────────────────────┐         │
//            │                                  │         │
//            │        ┌──────────────→ xor ────────────→ xor
//            │        │                 ^       │         ^
//            v       crc               crc      v        crc
//       ┌────────┐ \  ^   ┌────────┐ \  ^   ┌────────┐ \  ^
//      ┌│metadata│─│──│→│metadata│ │  │  ┌│metadata│ │  │
//      ││        │ └──┘  ││        │ └──┘  ││        │ └──┘
//      ││        │ │     ││        │ │     ││        │ │
//      │└────────┘ /     │└────────┘ /     │└────────┘ /
//      └──│──│──┘        └────│───┘        └──│──│──┘

随机数不是来自硬件RNG——而是整个文件系统所有metadata pair的CRC值依次异或的结果。 这非常巧妙:文件系统上的数据本身就是熵源——文件内容、创建时间、元数据排列……都在CRC中体现。只要文件系统在上次运行中被修改过,这次挂载时的CRC异或结果就会与前次不同,从而产生不同的分配起始偏移。

挂载时的seedlfs_mount_设置为所有遍历过的metadata block的CRC的异或值。随后,lfs_alloc在第一次分配时用这个seed确定lookahead的起始位置。

3. 基于block_cycles的静态磨损级别。

除了动态磨损均衡,LittleFS还提供了一种基于擦除计数的“弱静态磨损均衡“:block_cycles参数。

当metadata pair的revision计数达到block_cycles的整数倍时,LittleFS会强制搬迁(relocate)这个metadata pair到两个全新的块上。这防止了一个频繁更新的目录把它的metadata block擦写穿。

// lfs.c:1940-1947 — 检查是否需要搬迁
static bool lfs_dir_needsrelocation(lfs_t *lfs, lfs_mdir_t *dir) {
    // 如果 dir->rev+1 是 (block_cycles+1)|1 的倍数,触发搬迁
    return (lfs->cfg->block_cycles > 0
            && ((dir->rev + 1) % ((lfs->cfg->block_cycles+1)|1) == 0));
}

|1是为了确保除数奇数,避免边界条件。如果block_cycles设为100,那么每100次compaction就会把metadata搬迁到新块上。对于root目录这种高频更新的metadata pair,这个机制非常重要。


坏块处理:检测+驱逐

LittleFS的坏块处理策略是“检测即驱逐“:

写入时检测: 每次写入后,lfs_bd_prog(带validate=true参数)会立即回读数据并比较。如果不一致,返回LFS_ERR_CORRUPT

// lfs.c:228-272 — lfs_bd_prog 中的 validation
static int lfs_bd_prog(lfs_t *lfs,
        lfs_cache_t *pcache, lfs_cache_t *rcache, bool validate,
        lfs_block_t block, lfs_off_t off,
        const void *buffer, lfs_size_t size) {
    // ...
    if (validate) {
        // 写入后立即回读校验
        lfs_cache_drop(lfs, rcache);
        int res = lfs_bd_cmp(lfs, NULL, rcache, diff,
                pcache->block, pcache->off, pcache->buffer, diff);
        if (res != LFS_CMP_EQ) {
            return LFS_ERR_CORRUPT;  // 数据不匹配 → 坏块
        }
    }
    // ...
}

驱逐: 在上层代码中,看到LFS_ERR_CORRUPT后跳转到relocate标签。在这个标签里,分配一个新块、把数据写到新块、旧块被遗弃——不再被文件系统引用,自然就变成了空闲块。

坏块检测与驱逐流程
=========================================================

      ┌──────┐
      │ root │
      └──────┘
     v─┘    └──────────v
  ┌──────┐          ┌──────┐
  │  A   │          │  B   │
  └──────┘          └──────┘
   .    .          v─┘  .
   .    .       ┌──────┐.
   .    .       │  C   │.
   .    .       │      │.
   .    .       └──────┘.
   .    .       .    .   .

写入C时发现坏块:
      ┌──────┐
      │ root │
      └──────┘
     v─┘    └──────────v
  ┌──────┐          ┌──────┐
  │  A   │          │  B   │
  └──────┘          └──────┘
   .    .          v─┘  .
   .    .       ┌──────┐.    ┌──────┐
   .    .       │ bad  │.    │  C'  │  ← 把数据重写到新块
   .    .       │ blck │.    │      │
   .    .       └──────┘.    └──────┘

更新B的指针指向C':
      ┌──────┐
      │ root │
      └──────┘
     v─┘    └──────────v
  ┌──────┐    ┌──────┐
  │  A   │    │  B'  │  ← B也需要COW
  └──────┘    └──────┘
   .    .    .  │ .   └──────────v
   .    .    .  └──────┐    ┌──────┐
   .    .    .    .    │    │  C'  │
   .    .    .    .    │    │      │
                  └──────┘    └──────┘

更新root指向B':
      ┌──────┐
      │ root │  ← root也需要COW
      └──────┘
     v─┘    └──v
  ┌──────┐    ┌──────┐
  │  A   │    │  B'  │
  └──────┘    └──────┘
   .    .    .   └───────────v
   .    .    .    .        ┌──────┐
   .    .    .    .        │  C'  │
                        └──────┘

坏块成为垃圾,等待compaction回收

这个过程展示了COW结构的另一优势:坏块驱逐和COW搬迁可以用同一套机制处理——都只是“分配新块+写数据+更新父节点指针“的三步曲。


与KnotFS的块管理对比

KnotFS 块管理        vs        LittleFS 块管理
===============================================================
块数量              16个(固定)              理论上不限(实际受设备大小限制)
空闲块查找          扫描free_bitmap(O(n))   lookahead位图(O(1)平摊)
                    只有16个块,开销很低      对于大设备是必要的
磨损均衡            显式wear_count数组        统计式动态磨损均衡
                    每块1个uint32(64 bytes)  不需要每块计数器
块回收              compact时释放              垃圾块不主动回收
                    通过free_bitmap标记        通过"drop on floor"策略
坏块处理            未实现                     写入校验+自动重分配
===============================================================

KnotFS的块管理非常简单——因为它只有16个块。维护一个16位的free_bitmap和一个16×4=64字节的wear_count数组在RAM中是微不足道的。每次分配时扫描16个位,找到第一个空闲块,然后选其中wear_count最小的。这是把问题简化到极致。

LittleFS面对的块数量可能是KnotFS的100倍、1000倍。它必须在保持O(1)内存的前提下提供可接受的分配性能。lookahead方案是一个精妙的折中——它不是最快的,但它在任何设备大小下都能工作,且平摊性能在大多数场景下是O(1)。


垃圾回收的哲学:Drop it on the floor

LittleFS的块回收策略可以用一句话概括:不主动回收垃圾块。

当一个文件被删除时,它的CTZ跳表块和metadata pair块不会立即被“标记为空闲“。它们只是不再被任何metadata pair引用——文件系统树上没有路径能到达它们了。在COW操作的日常过程中,compaction自然会把不再被引用的块排除在外。下一次lfs_alloc_scan扫描时,这些块因为没有被文件系统树引用,会被视为空闲块。

这被称为“drop it on the floor“(丢在地上不理)策略。它的好处是:

  • 不需要维护free list(免去掉电安全问题)
  • 释放操作是O(1)(什么都不做)
  • 坏块自然被遗忘(只要没人引用它)

代价是:已删除的空间不会立即可用——必须等到下一轮lookahead扫描经过那个区域。


一段关于熵的沉思

LittleFS的熵源——用CRC异或生成随机种子——是一个让人会心一笑的设计。

随机数在任何计算机系统中都是一个难题。在嵌入式系统上尤其如此:没有硬件RNG,没有操作系统提供的熵池,没有网络时间戳。上电后CPU寄存器的状态虽然是随机的,但你没法保证CRC在编译后不依赖那些初始化状态。

LittleFS的解决方案是:用文件系统自身的数据作为熵源。 每个metadata pair在挂载时被遍历,它的CRC被读取并异或到一个累加器中。这个累加器的最终值取决于所有metadata pair的内容——而metadata pair的内容取决于文件系统的历史修改序列。只要在设备的上一次运行中有过任何写操作,这次挂载时的CRC异或结果就几乎不可能与上一次相同。

这个设计的哲学含义是:文件系统本身就是它最可靠的随机源——因为它“记住了“自己的历史。 一个文件系统之所以需要磨损均衡,恰恰是因为它被使用过;而一个被使用过的文件系统,恰好携带着能使磨损均衡工作所需的“使用痕迹“。

这是一种自举式的优雅:问题创造了它自己的答案。

KnotFS 的块分配器比 lookahead 简单得多:只有 16 个块,一个 free_map 位图就够,不需要滑动窗口。KnotFS 的磨损均衡也比 LittleFS 更朴素——pick_lowest_wear 直接从所有空闲块中遍历选 wear 最小的,没有 block_cycles 的强制搬迁机制。LittleFS 的 lookahead 是为“块数可能上千、RAM 只有几 KB“的极端约束设计的;KnotFS 受益于教学场景的小规模——块数少到可以全量扫描,磨损跟踪不需要随机化的起始偏移。这两种设计的差异再一次说明:同一个问题,同一个理论基础,不同的约束会产生完全不同的实现。


下集预告

Metadata Pair 存元数据,CTZ Skip-List 存文件数据,Block Allocator 管空间分配——LittleFS 的三根支柱已经立起来了。但还有一个问题:文件是怎么组织的?目录树、文件名、路径解析——这些用户最直观感受到的东西,LittleFS 是怎么实现的?

下一节,我们进入 LittleFS 的目录系统。你会发现一个反直觉的事实:目录就是文件。 lfs_mkdirlfs_file_open 在底层调的是同一个函数——区别只是一个 tag 类型。以及:threaded linked-list 如何让目录遍历在掉电后还能继续?

悬念留给:如果你的目录里有一万个文件,遍历到第 5000 个时突然断电——重启后遍历指针应该指向哪里?