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_mount、lfs_file_open、lfs_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 blocklfs_dir_traverse——遍历metadata pair中的所有条目lfs_dir_commit——原子提交一次目录更新(包括compaction和分裂逻辑)lfs_dir_compact——紧凑化一个metadata pairlfs_dir_split——满时分拆一个metadata pairlfs_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_mount、lfs_file_open、lfs_file_read、lfs_file_write、lfs_dir_open、lfs_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编译进你的固件时——
-
编译期: 编译器处理lfs.c这一个文件。所有
static函数在单个翻译单元内解析,linker不做任何LittleFS内部符号的重定位。最终二进制中只包含你实际调用的那些API路径(编译器进行死代码消除)。 -
初始化: 你调用
lfs_mount(&lfs, &cfg)。LittleFS调用lfs_init验证配置参数——块大小必须是cache_size的整数倍,cache_size必须是read_size/prog_size的整数倍,block_cycles不能为0。然后分配读缓存和编程缓存(如果用户提供了静态缓冲则用静态的)。接着开始扫描盘上的metadata对链表,寻找superblock,重建全局状态。 -
运行时: 每次文件操作(read、write、open、close……)都通过lfs.c中的内部函数链完成。块设备操作→缓存→目录查找/提交→CTZ跳表导航。所有操作都是同步的——调用返回时,数据已经写入(或至少在缓存中准备刷出)。
-
卸载:
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,中间用一条尾巴连起来。这个过程如果在中间掉电,会发生什么?