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.2 lfs.c全貌——单文件设计的工程智慧

一张6500行的名片

在GitHub上,LittleFS仓库的文件列表会让你困惑三秒钟:

littlefs/
├── lfs.h          (801 行)
├── lfs.c          (6549 行)
├── lfs_util.h     (工具宏/函数)
├── LICENSE.md
├── DESIGN.md      (2173 行——设计文档比代码还详细)
├── SPEC.md        (盘上格式规范)
├── Makefile
├── tests/         (测试文件)
└── bd/            (块设备驱动示例)

核心代码就两个文件:lfs.h(801行)和lfs.c(6549行)。 没有分模块编译,没有“把目录操作抽到lfs_dir.c、把文件操作抽到lfs_file.c“的划分——所有的块设备抽象、元数据对操作、CTZ跳表、块分配器、目录遍历、文件读写、孤儿处理、全局状态管理……全部在lfs.c里面。

这不是懒惰。你如果理解嵌入式编译的上下文,就会明白这个决策的工程智慧。


为什么是单文件?

想象你是一个嵌入式固件工程师。你的项目使用IAR EWARM编译器,.c文件是通过Makefile一个个加进去的。你每增加一个源文件,就意味着:

  • 多一次编译调用
  • 多一个目标文件要链接
  • 多一组符号要跨文件解析
  • 链接器要多处理一组重定位表

嵌入式项目的编译单元数量是一个线性成本。而且,许多嵌入式编译器对跨文件的内联(inline)支持很差——static inline函数只能在定义它的翻译单元内展开。如果你把lfs_bd_read放在单独的文件里,所有调用它的文件要么看到一次函数调用(牺牲性能),要么在每个调用处重复定义(违反ODR)。

单文件设计把所有这些成本归零。 编译器只编译一个lfs.c,所有内部函数都声明为static——链接器根本不需要管它们。整个文件系统的内部逻辑在编译期就被完全解析,传统的“模块接口“由文件内部的分节和函数命名约定来维护。

// lfs.c 的内部模块通过分节注释来划分,而不是分文件

/// Caching block device operations ///
static int lfs_bd_read(...)   { /* ... */ }
static int lfs_bd_prog(...)   { /* ... */ }
static int lfs_bd_erase(...)  { /* ... */ }

/// Small type-level utilities ///
static inline bool lfs_pair_isnull(...) { /* ... */ }
static inline void lfs_pair_swap(...)   { /* ... */ }

/// Block allocator ///
static int lfs_alloc(...)       { /* ... */ }
static int lfs_alloc_scan(...)  { /* ... */ }

/// Metadata pair and directory operations ///
static int lfs_dir_fetch(...)   { /* ... */ }
static int lfs_dir_commit(...)  { /* ... */ }
static int lfs_dir_compact(...) { /* ... */ }

/// File index list operations ///
static int lfs_ctz_index(...)   { /* ... */ }
static int lfs_ctz_find(...)    { /* ... */ }
static int lfs_ctz_extend(...)  { /* ... */ }

在LittleFS的设计哲学中,每个模块的“公开接口“不是导出函数,而是在lfs.h中暴露给用户的那些lfs_mountlfs_file_openlfs_dir_read函数——它们构成了稳定API。 内部模块之间通过static函数调用彼此,编译器做死代码消除(dead code elimination)后,未使用的内部函数不会出现在最终二进制中。


lfs.c的模块结构——分层虽在,不见文件

虽然所有代码在一个文件中,但LittleFS的内部分层异常清晰。通过代码中的comment分隔(/// Caching block device operations ////// Metadata pair and directory operations ///……),作者标注了模块边界。让我们从下往上看:

lfs.c 内部分层结构
=========================================

┌─────────────────────────────────────────────┐
│  公开API封装 (lfs_mount, lfs_file_open, ...)  │ ← 调用内部 _ 后缀函数
├─────────────────────────────────────────────┤
│  文件系统级别操作                               │
│  lfs_fs_deorphan, lfs_fs_forceconsistency,   │
│  lfs_fs_gc, lfs_fs_grow, lfs_fs_traverse     │
├───────────────┬───────────────┬───────────────┤
│  文件操作      │  目录操作      │  CTZ跳表       │
│  lfs_file_*   │  lfs_dir_*    │  lfs_ctz_*     │
│  open/close/  │  fetch/commit │  index/find/   │
│  read/write/  │  /compact/    │  extend/       │
│  sync/truncate│  traverse/    │  traverse      │
│               │  split/alloc  │                │
├───────────────┴───────────────┴───────────────┤
│  块分配器 (lfs_alloc, lfs_alloc_scan)         │
├─────────────────────────────────────────────┤
│  块设备抽象层 (lfs_bd_read, lfs_bd_prog, ...)   │
│  + 缓存层 (lfs_cache_drop, lfs_cache_zero)      │
├─────────────────────────────────────────────┤
│  类型/工具层 (tag操作, pair操作, CRC, endian)   │
└─────────────────────────────────────────────┘

块设备抽象层(lfs_bd_*) ——这是LittleFS与物理存储的唯一接口。它封装了lfs_config中用户提供的read/prog/erase/sync回调,并在上面叠加了两层缓存:读缓存(rcache)和编程缓存(pcache)。

// lfs.c:44-126 — lfs_bd_read,块设备读取的核心逻辑
static int lfs_bd_read(lfs_t *lfs,
        const lfs_cache_t *pcache, lfs_cache_t *rcache, lfs_size_t hint,
        lfs_block_t block, lfs_off_t off,
        void *buffer, lfs_size_t size) {
    uint8_t *data = buffer;

    while (size > 0) {
        lfs_size_t diff = size;

        // 1. 先查编程缓存(pcache可能包含未刷新的新数据)
        if (pcache && block == pcache->block &&
                off < pcache->off + pcache->size) {
            if (off >= pcache->off) {
                diff = lfs_min(diff, pcache->size - (off-pcache->off));
                memcpy(data, &pcache->buffer[off-pcache->off], diff);
                data += diff; off += diff; size -= diff;
                continue;
            }
            diff = lfs_min(diff, pcache->off-off);
        }

        // 2. 再查读缓存
        if (block == rcache->block && off < rcache->off + rcache->size) {
            if (off >= rcache->off) {
                diff = lfs_min(diff, rcache->size - (off-rcache->off));
                memcpy(data, &rcache->buffer[off-rcache->off], diff);
                data += diff; off += diff; size -= diff;
                continue;
            }
            diff = lfs_min(diff, rcache->off-off);
        }

        // 3. 如果请求够大,绕过缓存直接读
        if (size >= hint && off % lfs->cfg->read_size == 0 &&
                size >= lfs->cfg->read_size) {
            diff = lfs_aligndown(diff, lfs->cfg->read_size);
            int err = lfs->cfg->read(lfs->cfg, block, off, data, diff);
            if (err) return err;
            data += diff; off += diff; size -= diff;
            continue;
        }

        // 4. 从设备加载到读缓存
        rcache->block = block;
        rcache->off = lfs_aligndown(off, lfs->cfg->read_size);
        rcache->size = lfs_min(/* ... */, lfs->cfg->cache_size);
        int err = lfs->cfg->read(lfs->cfg, rcache->block,
                rcache->off, rcache->buffer, rcache->size);
        if (err) return err;
    }
    return 0;
}

注意这个读取函数的精巧之处:pcache优先级最高——因为编程缓存中可能包含尚未写入Flash的最新数据。读缓存次之。如果请求的大小超过了读取阈值(hint),则绕过缓存直接读硬件——这是性能优化:大块数据不污染缓存。

类型/工具层 ——LittleFS的最底层是一组位操作和内联函数,用来操作它的核心数据结构。其中最重要的是tag系统。

LittleFS用32位的tag来编码目录条目的类型、ID和大小:

// lfs.c:342-343 — 32位tag的编码格式
#define LFS_MKTAG(type, id, size) \
    (((lfs_tag_t)(type) << 20) | ((lfs_tag_t)(id) << 10) | (lfs_tag_t)(size))

// tag的位域布局:
// [31]         = 有效位 (0=有效)
// [30:20]      = 类型 (11 bits)
// [19:10]      = ID   (10 bits)
// [9:0]        = 大小 (10 bits)

这个tag是LittleFS元数据通用的“数据头“——每个写入metadata pair的记录都以一个tag开始。tag告诉解析器这一条记录是什么类型(文件名、文件数据、CTZ结构体……)、属于哪个文件(ID)、数据内容有多长(size)。

Metadata pair和目录操作层(lfs_dir_*) ——这是LittleFS最复杂的模块,也是6500行中占比最大的部分。包含了:

  • lfs_dir_fetch——从Flash加载一个metadata block
  • lfs_dir_traverse——遍历metadata pair中的所有条目
  • lfs_dir_commit——原子提交一次目录更新(包括compaction和分裂逻辑)
  • lfs_dir_compact——紧凑化一个metadata pair
  • lfs_dir_split——满时分拆一个metadata pair
  • lfs_dir_alloc——分配一个新的metadata pair

CTZ跳表层(lfs_ctz_*) ——四个函数,不到200行代码,实现了完整的COW跳表:

  • lfs_ctz_index——从文件偏移算出block索引(O(1))
  • lfs_ctz_find——找到给定偏移所在的block(O(log n))
  • lfs_ctz_extend——追加一个新块(O(1))
  • lfs_ctz_traverse——遍历所有块(O(n),用于垃圾回收)

块分配器(lfs_alloc_*) ——三四个函数,约150行,实现了lookahead扫描和块分配。

顶层API ——lfs_mountlfs_file_openlfs_file_readlfs_file_writelfs_dir_openlfs_dir_read……这些公开接口都在文件末尾。它们基本上是对内部_后缀函数的薄封装:

// lfs.c 中公开API的实现模式:
// 公开函数调用内部函数,内部函数做实际工作

int lfs_mount(lfs_t *lfs, const struct lfs_config *config) {
    return lfs_mount_(lfs, config);     // 内部实现
}

int lfs_file_open(lfs_t *lfs, lfs_file_t *file,
        const char *path, int flags) {
    return lfs_file_open_(lfs, file, path, flags);  // 内部实现
}

这种“公开=薄壳“的模式暴露了LittleFS的一个有趣特征:LittleFS的API设计与内部实现是严格解耦的。 公开API承诺的是行为契约(“open一个文件,返回一个句柄”),内部实现可以自由重构——你可以优化lfs_dir_compact而不影响任何调用方。


lfs_t:一个结构体装下整个文件系统

LittleFS的全部运行时状态都封装在lfs_t结构体中。这个结构体的大小是固定的,不管你的文件系统有多大:

// lfs.h:435-470 — 文件系统运行时状态
typedef struct lfs {
    lfs_cache_t rcache;              // 读缓存 (~12 bytes + buffer)
    lfs_cache_t pcache;              // 编程缓存 (~12 bytes + buffer)

    lfs_block_t root[2];             // 根目录的metadata pair地址 (8 bytes)
    struct lfs_mlist {
        struct lfs_mlist *next;      // 打开的目录/文件链表
        uint16_t id;
        uint8_t type;
        lfs_mdir_t m;
    } *mlist;                        // (指针,8 bytes on 32-bit)

    uint32_t seed;                   // 随机数种子 (4 bytes)

    lfs_gstate_t gstate;             // 运行时全局状态
    lfs_gstate_t gdisk;              // 盘上全局状态
    lfs_gstate_t gdelta;             // 本轮修改的增量

    struct lfs_lookahead {
        lfs_block_t start;           // 预搜索窗口的起始块
        lfs_block_t size;            // 预搜索窗口的大小
        lfs_block_t next;            // 窗口内的下一个位置
        lfs_block_t ckpoint;         // 检查点计数器
        uint8_t *buffer;             // 预搜索位图缓冲区指针
    } lookahead;                     // (~20 bytes + buffer)

    const struct lfs_config *cfg;    // 配置指针 (8 bytes on 32-bit)
    lfs_size_t block_count;          // 总块数 (4 bytes)
    lfs_size_t name_max;             // 文件名最大长度 (4 bytes)
    lfs_size_t file_max;             // 文件最大大小 (4 bytes)
    lfs_size_t attr_max;             // 属性最大长度 (4 bytes)
    lfs_size_t inline_max;           // 内联文件最大大小 (4 bytes)
} lfs_t;

你能看到的RAM使用 = sizeof(lfs_t) + cache_size * 2(读缓存+编程缓存)+ lookahead_size(预搜索缓冲区)。这三个数字在编译时就完全确定。对于典型配置(cache_size=64, lookahead_size=16),总RAM大约200字节——加上缓冲区的128+16=144字节,一共大约350字节。这个数字不会随着文件系统变大而增长。


lfs_config:你如何让LittleFS认识你的Flash

LittleFS不假设你的Flash型号、不假设你用什么驱动、不假设你在哪个RTOS上运行。所有外部依赖都通过lfs_config注入:

// lfs.h:157-293 — 用户提供的配置结构体
struct lfs_config {
    void *context;               // 用户自定义上下文

    int (*read)(const struct lfs_config *c, lfs_block_t block,
            lfs_off_t off, void *buffer, lfs_size_t size);
    int (*prog)(const struct lfs_config *c, lfs_block_t block,
            lfs_off_t off, const void *buffer, lfs_size_t size);
    int (*erase)(const struct lfs_config *c, lfs_block_t block);
    int (*sync)(const struct lfs_config *c);

    lfs_size_t read_size;        // 最小读取粒度
    lfs_size_t prog_size;        // 最小编程粒度
    lfs_size_t block_size;       // 擦除块大小(LittleFS的"逻辑块")
    lfs_size_t block_count;      // 块总数(0=从盘上superblock读取)
    int32_t   block_cycles;      // 搬迁阈值(擦除多少次后搬迁元数据)
    lfs_size_t cache_size;       // 缓存大小
    lfs_size_t lookahead_size;   // 预搜索缓冲区大小
    lfs_size_t compact_thresh;   // GC压缩阈值

    void *read_buffer;           // 可选:静态分配的读缓存
    void *prog_buffer;           // 可选:静态分配的编程缓存
    void *lookahead_buffer;      // 可选:静态分配的预搜索缓冲区
    // ...
};

这非常像KnotFS的“块设备异步抽象层“——但LittleFS的更简洁。它不需要你实现“异步读“——所有I/O都是同步的。如果你需要异步,那是调用者的责任(比如在FreeRTOS任务中调用LittleFS API)。

四个回调函数(read/prog/erase/sync)就是你唯一需要实现的硬件适配代码。 例如对于一个SPI NOR Flash:

// 你实现的 read 回调
int my_flash_read(const struct lfs_config *c, lfs_block_t block,
        lfs_off_t off, void *buffer, lfs_size_t size) {
    uint32_t addr = block * c->block_size + off;
    spi_flash_read(addr, buffer, size);  // 调用你的SPI驱动
    return 0;
}

// 你实现的 prog 回调
int my_flash_prog(const struct lfs_config *c, lfs_block_t block,
        lfs_off_t off, const void *buffer, lfs_size_t size) {
    uint32_t addr = block * c->block_size + off;
    spi_flash_write(addr, buffer, size);  // 编程(只能把1变0)
    return 0;
}

// 你实现的 erase 回调
int my_flash_erase(const struct lfs_config *c, lfs_block_t block) {
    uint32_t addr = block * c->block_size;
    spi_flash_erase_4k(addr);   // 擦除4KB块(把所有位变1)
    return 0;
}

然后你把它们填入lfs_config,调用lfs_mount——

const struct lfs_config cfg = {
    .read  = my_flash_read,
    .prog  = my_flash_prog,
    .erase = my_flash_erase,
    .sync  = my_flash_sync,
    .read_size      = 16,       // SPI Flash的最小读取粒度
    .prog_size      = 16,       // SPI Flash的最小编程粒度(通常1-256字节)
    .block_size     = 4096,     // 擦除块大小
    .block_count    = 1024,     // 4MB / 4KB = 1024块
    .cache_size     = 64,       // 64字节缓存
    .lookahead_size = 16,       // 16字节预搜索缓冲区 = 最多跟踪128个块
};

lfs_t lfs;
int err = lfs_mount(&lfs, &cfg);
if (err) {
    // 挂载失败,可能需要格式化
    lfs_format(&lfs, &cfg);
    lfs_mount(&lfs, &cfg);
}

就是这三行——你就在一个4MB NOR Flash上有了一个完整的、掉电安全的文件系统。


代码中的一些工程细节

遍历LittleFS的6500行C代码,你会注意到一些质朴但扎实的工程实践:

无处不在的endian处理。 LittleFS的盘上数据是小端序,但为了兼容不同架构(ARM、MIPS、PowerPC),每个写入盘上的多字节数值都要通过lfs_tole32转换:

// lfs.c:497-500
static inline void lfs_superblock_tole32(lfs_superblock_t *superblock) {
    superblock->version     = lfs_tole32(superblock->version);
    superblock->block_size  = lfs_tole32(superblock->block_size);
    // ... 每个字段逐一转换
}

goto cleanup 模式。 在初始化/挂载等需要多层资源清理的路径中,LittleFS使用goto:

// lfs.c:4482-4560 — lfs_mount_ 的典型错误处理
static int lfs_mount_(lfs_t *lfs, const struct lfs_config *cfg) {
    int err = lfs_init(lfs, cfg);
    if (err) {
        return err;              // init失败,无需清理
    }

    // ... 挂载逻辑 ...

    if (tag < 0) {
        err = tag;
        goto cleanup;            // 跳转到统一的清理代码
    }

    // ...

cleanup:
    lfs_deinit(lfs);             // 释放lfs_init中分配的资源
    return err;
}

坏块重试循环。 在写入代码中广泛使用了relocate标签——当检测到写入校验失败时,不是返回错误,而是自动尝试分配一个新块重写:

// lfs.c:2921-3017 — lfs_ctz_extend 中的坏块重试
while (true) {
    lfs_block_t nblock;
    int err = lfs_alloc(lfs, &nblock);
    // ...
    err = lfs_bd_erase(lfs, nblock);
    if (err) {
        if (err == LFS_ERR_CORRUPT) {
            goto relocate;       // 擦除失败,换一个块
        }
        return err;
    }
    // ... 写入数据 ...

relocate:
    LFS_DEBUG("Bad block at 0x%"PRIx32, nblock);
    lfs_cache_drop(lfs, pcache);  // 清缓存,进入下一次循环
}

从编译到运行——一个完整的生命周期

当你把LittleFS编译进你的固件时——

  1. 编译期: 编译器处理lfs.c这一个文件。所有static函数在单个翻译单元内解析,linker不做任何LittleFS内部符号的重定位。最终二进制中只包含你实际调用的那些API路径(编译器进行死代码消除)。

  2. 初始化: 你调用lfs_mount(&lfs, &cfg)。LittleFS调用lfs_init验证配置参数——块大小必须是cache_size的整数倍,cache_size必须是read_size/prog_size的整数倍,block_cycles不能为0。然后分配读缓存和编程缓存(如果用户提供了静态缓冲则用静态的)。接着开始扫描盘上的metadata对链表,寻找superblock,重建全局状态。

  3. 运行时: 每次文件操作(read、write、open、close……)都通过lfs.c中的内部函数链完成。块设备操作→缓存→目录查找/提交→CTZ跳表导航。所有操作都是同步的——调用返回时,数据已经写入(或至少在缓存中准备刷出)。

  4. 卸载: lfs_unmount释放所有申请的内存,清空缓存。

KnotFS 同样把所有核心逻辑放在一个 knotfs.c 里,同样是 ~845 行单文件。但不同的是:KnotFS 没有 RTOS——它不能调用 lfs_file_write() 然后阻塞等待完成。LittleFS 的同步 API 是靠有操作系统的调度器来保证调用者不被饿死的;KnotFS 必须自己把每个操作拆成状态机的多个步骤,在 knotfs_run() 的每次调用中推进一小步。


下集预告

你看到了lfs.c的全貌——单文件不是懒惰,是嵌入式编译单元的理性选择。但LittleFS真正的黑魔法,藏在那些只有两块的metadata pair里。两块Flash怎么实现原子提交?为什么一定是两块而不是三块?revision计数器如何用“序列号算术“来避免整数溢出?下一节,我们钻进lfs_dir_commit和lfs_dir_compact的内部,看一个8字节的CRC是如何保证任何时刻目录都有合法状态的。

悬念留给:当一个metadata pair写满时,它不是报错——而是默默分叉成两个metadata pair,中间用一条尾巴连起来。这个过程如果在中间掉电,会发生什么?