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.6 Directory——目录即文件

你的文件夹和你想象的完全不一样

先做一个实验。打开你的终端,输入 ls -l /usr/bin。你看到了几百个文件条目——名字、大小、权限、时间戳——整齐地排列。你以为“目录“是一个特殊的结构,里面有格子,每个格子里装着一个文件。

这是一个便利的谎言。

在 LittleFS 里,目录不是“装着文件的容器“。目录就是一个文件。更精确地说,目录是一个被标记了 LFS_TYPE_DIR 类型的 metadata pair——和普通文件一样存储在某个块上,一样用两块的日志机制做原子更新,一样在数据块的底层没有特权。

你以为的目录:                  LittleFS 的真相:
┌─────────────────┐           ┌─────────────────┐
│   目录容器        │           │ metadata pair   │
│  ┌───┐ ┌───┐    │           │  [revision=5]   │
│  │ A │ │ B │    │           │  name="foo" REG │
│  └───┘ └───┘    │           │  name="bar" DIR │
│  ┌───┐          │           │  name="baz" REG │
│  │ C │          │           │  [checksum]     │
│  └───┘          │           └─────────────────┘
└─────────────────┘
                                           ↑
                              就是一个有特殊 tag 的文件

这个设计极其优雅。目录不需要独立的存储结构、分配器或掉电保护——处理文件的所有代码(日志追加、垃圾回收、CRC 校验)全部复用在目录上。Less code, less bugs。

那目录怎么知道“谁在这个文件夹里“呢?答案藏在 metadata pair 的 entry 里。


Metadata Pair 里的名字游戏

回忆一下数据块的格式。每个 metadata pair 是一个两块的日志结构,里面存储着若干 entries。每个 entry 是一个 tag + data 的组合。

当一个 entry 的 tag 类型是 LFS_TYPE_NAME(类型编号 0x0xx)时,它的 data 部分存储的是一个文件名。而 tag 里的 id 字段(10 bits)把这个名字和后续的结构信息关联起来。

一个目录条的完整构成:

tag: LFS_TYPE_CREATE, id=3
     → "创造者"标签,声明文件 id=3 的存在

tag: LFS_TYPE_NAME, id=3, type=REG, data="hello.txt"
     → 名字是 "hello.txt",这是一个普通文件

tag: LFS_TYPE_CTZSTRUCT, id=3, data={head=0x2A, size=4096}
     → CTZ skip-list 的头部块和文件大小

tag: LFS_TYPE_CRC
     → 提交结束,CRC 校验盖章

这就是 LittleFS 目录的全部秘密:目录就是一个 metadata pair,目录项就是 metadata pair 里的几个 entries

目录没有独立的“目录条目结构体“。文件名存在 name tag 里。文件类型存在 name tag 的 chunk 字段里。文件数据的位置存在 struct tag 里(CTZSTRUCT 或者 DIRSTRUCT 或者 INLINESTRUCT)。id 把这些散落的 tags 串在一起。

这就是“tag 系统“——LittleFS 里最天才的发明之一——的威力:32 位的 tag 既是类型,又是索引,又是长度。一个 LFS_TYPE_DIRSTRUCT tag 后面跟着 8 个字节的 metadata pair 指针(两个 32-bit block number),指向子目录在磁盘上的位置。

                    root directory (metadata pair at block 0,1)
                    ┌──────────────────────────┐
                    │ revision = 3              │
                    │ name="subdir", id=1, DIR  │ ← 声明 id=1 是目录
                    │ struct=DIRSTRUCT, id=1,   │ ← 指向子目录的 metadata pair
                    │   pair={block=14,block=27}│
                    │ name="readme.txt", id=2   │
                    │ struct=CTZSTRUCT, id=2,   │
                    │   head=40, size=1024      │
                    │ CRC                       │
                    └──────────────────────────┘
                              │
                              v
                    sub directory (metadata pair at block 14,27)
                    ┌──────────────────────────┐
                    │ revision = 1              │
                    │ name="config.json", id=1  │
                    │ struct=INLINESTRUCT, id=1,│
                    │   data="{...}"            │
                    │ CRC                       │
                    └──────────────────────────┘

lfs_dir_find:在 metadata pair 的海洋里找一条鱼

lfs_dir_find() 是 LittleFS 里被调用次数最多的函数之一。lfs_file_open() 调用它,lfs_mkdir() 调用它,lfs_remove() 调用它,lfs_stat() 调用它。几乎所有文件系统操作的第一步都是“找到目标文件的目录条目“。

它的核心逻辑非常简单:

  1. 从 root directory 开始,拿着路径 "/subdir/hello.txt"
  2. 找到第一段名字 "subdir",在 root 的 metadata pair 里遍历所有 name tag,做字符串比较。
  3. 找到后,从对应的 struct tag 里提取出子目录的 metadata pair 指针。
  4. lfs_dir_fetch() 把子目录的 metadata pair 从磁盘加载到 RAM。
  5. 重复第 2 步,直到路径最后一段。
    "/subdir/hello.txt"
     ↓
    root pair (block 0,1)
     │  遍历 name tags,找 "subdir"
     │  找到 → 提取 pair={14,27}
     ↓
    subdir pair (block 14,27)
     │  遍历 name tags,找 "hello.txt"
     │  找到 → 返回 tag(包含 id 和类型信息)
     ↓
    调用者拿着返回的 tag 继续操作

但 LittleFS 的实现比这个概述要精巧得多。看 lfs.c:1483 的源码:

static lfs_stag_t lfs_dir_find(lfs_t *lfs, lfs_mdir_t *dir,
        const char **path, uint16_t *id) {
    const char *name = *path;
    lfs_stag_t tag = LFS_MKTAG(LFS_TYPE_DIR, 0x3ff, 0);
    dir->tail[0] = lfs->root[0];
    dir->tail[1] = lfs->root[1];
    // ...

函数一开始就把 dir 指向了 root,设置 tag 为 DIR 类型。然后进入一个无限循环,逐级解析路径。在这个循环里,LittleFS 做了几件非常聪明的事:

第一件事:用 strspn + strcspn 逐段解析路径,但不是简单地 split。

它用 strspn(name, "/") 跳过斜杠,用 strcspn(name, "/") 找到名字的结束位置。然后它还会向前看路径的剩余部分,处理 ...。如果剩余路径里有对称的 .. 可以抵消当前路径段,它就直接跳过当前段——不需要真的读取父目录。

        // skip if matched by '..' in name
        const char *suffix = name + namelen;
        lfs_size_t sufflen;
        int depth = 1;
        while (true) {
            suffix += strspn(suffix, "/");
            sufflen = strcspn(suffix, "/");
            if (sufflen == 0) break;
            if (sufflen == 2 && memcmp(suffix, "..", 2) == 0) {
                depth -= 1;
                if (depth == 0) {
                    name = suffix + sufflen;
                    goto nextname;
                }
            } // ...

这段代码的含义是:如果路径是 "a/../b",LittleFS 在解析 "a" 时就会发现后面的 ".." 可以抵消 "a",于是直接把 name 指针跳到 "b" 的位置——一次磁盘读取都不需要。

这是极致的优化思维。在一个 32 MHz 的 Cortex-M0 上,少一次 Flash 读取就是少几百微秒的延迟。

第二件事:lfs_dir_fetchmatch() —— 带着回调函数去匹配。

lfs_dir_fetchmatch() 是 LittleFS 里又一个精妙的设计。它加载 metadata pair,遍历里面的所有 tags,然后调用一个用户提供的回调函数(这里是 lfs_dir_find_match)来比较当前 tag 的数据和目标名字。

            tag = lfs_dir_fetchmatch(lfs, dir, dir->tail,
                    LFS_MKTAG(0x780, 0, 0),
                    LFS_MKTAG(LFS_TYPE_NAME, 0, namelen),
                    id,
                    lfs_dir_find_match, &(struct lfs_dir_find_match){
                        lfs, name, namelen});

这个方法之所以精妙,是因为 lfs_dir_fetchmatch 不只是在一个 metadata pair 里查找——如果当前 pair 有 split(尾部指针指向下一个 pair),它会自动跨 pair 追踪。开发者不用关心“文件可能在链表上的第几个 pair“,一个函数调用全部搞定。

第三件事:当 tag 的 id 是 0x3ff 时表示全局标签。

0x3ff(10 bits 全 1)是一个特殊 id,表示这个 entry 不关联任何具体文件。Root directory 本身的 tag 就是 LFS_MKTAG(LFS_TYPE_DIR, 0x3ff, 0)——类型是 DIR,id 是 0x3ff。这意味着 root 并不是一个“文件“,它只是系统的起点标记。

lfs_dir_find 遍历到一个非 0x3ff 的 id 时,意味着它找到了一个真实的子目录。这时候它调用 lfs_dir_get() 从 struct tag 里提取出 metadata pair 指针,然后继续递归。

如果遍历完所有的 split pair 都没有找到匹配的名字,函数返回 LFS_ERR_NOENT —— “No Entry”。这不是错误,这是“文件不存在“的正常语义。


lfs_dir_read:遍历目录就像翻书

lfs_dir_read 是目录的“翻页“操作。每次调用返回一个 lfs_info 结构,包含文件名、类型、大小。返回 0 表示目录到底了。

static int lfs_dir_read_(lfs_t *lfs, lfs_dir_t *dir, struct lfs_info *info) {
    memset(info, 0, sizeof(*info));
    if (dir->pos == 0) {
        info->type = LFS_TYPE_DIR;
        strcpy(info->name, ".");
        dir->pos += 1;
        return true;
    } else if (dir->pos == 1) {
        info->type = LFS_TYPE_DIR;
        strcpy(info->name, "..");
        dir->pos += 1;
        return true;
    }
    while (true) {
        if (dir->id == dir->m.count) {
            if (!dir->m.split) return false;
            int err = lfs_dir_fetch(lfs, &dir->m, dir->m.tail);
            if (err) return err;
            dir->id = 0;
        }
        int err = lfs_dir_getinfo(lfs, &dir->m, dir->id, info);
        dir->id += 1;
        if (err != LFS_ERR_NOENT) break;
    }
    dir->pos += 1;
    return true;
}

这种设计有几个值得注意的地方:

虚拟的 ...。这两个条目不是存在磁盘上的。它们是 Read 的第一个位置(pos=0)和第二个位置(pos=1)由代码直接生成的。这样做省掉了磁盘上的两个 entry,也省掉了每次目录更新时维护这两个条目的麻烦。

跨越 split pair。当 dir->id 走到当前 metadata pair 的边界(dir->m.count)时,函数检查 dir->m.split。如果 split 为真,说明这个目录的数据跨了多个 metadata pair(因为当前 pair 满了),函数自动加载下一个 pair 并重置 id 为 0。

跳过已删除条目lfs_dir_getinfo 对已删除的 entry 返回 LFS_ERR_NOENTlfs_dir_read_ 的 while 循环会跳过这些条目,继续寻找有效条目。这就是为什么用户永远看不到已删除的文件:它们在遍历时被自动过滤。


lfs_mkdir:创建目录就是创建文件

lfs_mkdir 的实现进一步证明了“目录即文件“的理念。

static int lfs_mkdir_(lfs_t *lfs, const char *path) {
    int err = lfs_fs_forceconsistency(lfs);  // 先做孤儿清理
    struct lfs_mlist cwd;
    uint16_t id;
    err = lfs_dir_find(lfs, &cwd.m, &path, &id);
    if (!(err == LFS_ERR_NOENT && lfs_path_islast(path))) {
        return (err < 0) ? err : LFS_ERR_EXIST;
    }
    // ...分配新 directory pair...
    err = lfs_dir_alloc(lfs, &dir);
    // ...在父目录中提交新条目...
    err = lfs_dir_commit(lfs, &cwd.m, LFS_MKATTRS(
            {LFS_MKTAG(LFS_TYPE_CREATE, id, 0), NULL},
            {LFS_MKTAG(LFS_TYPE_DIR, id, nlen), path},
            {LFS_MKTAG(LFS_TYPE_DIRSTRUCT, id, 8), dir.pair},
            {LFS_MKTAG_IF(!cwd.m.split,
                LFS_TYPE_SOFTTAIL, 0x3ff, 8), dir.pair}));
    return 0;
}

这段代码做了四件事:

  1. 孤儿清理lfs_fs_forceconsistency()。这是每次写操作前的安全检查——如果上次掉电留下了半成品,这次先把烂摊子收拾干净。

  2. 检查名字不冲突:调用 lfs_dir_find 找目标名字。如果找到了(不是 LFS_ERR_NOENT),说明文件已存在,返回 LFS_ERR_EXIST

  3. 分配新的 metadata pairlfs_dir_alloc 分配一个空的 metadata pair。这个 pair 以后就是这个新目录的“肉身“。

  4. 在父目录里 commit:一次原子提交三个 tags:

    • LFS_TYPE_CREATE:标记文件 id 的存在
    • LFS_TYPE_DIR:名字 + 类型(目录)
    • LFS_TYPE_DIRSTRUCT:子目录的 metadata pair 指针

注意 LFS_MKATTRS 宏——它把这三个 tags 打包成一次原子提交。这就是 metadata pair 的核心承诺:要么三个 tags 全部落地,要么一个都不写。掉电不会留下“只有名字没有结构“的半残目录。


lfs_remove:删除目录也是删除文件

static int lfs_remove_(lfs_t *lfs, const char *path) {
    int err = lfs_fs_forceconsistency(lfs);
    lfs_mdir_t cwd;
    lfs_stag_t tag = lfs_dir_find(lfs, &cwd, &path, NULL);
    // ...目录必须为空...
    if (lfs_tag_type3(tag) == LFS_TYPE_DIR) {
        lfs_block_t pair[2];
        lfs_stag_t res = lfs_dir_get(lfs, &cwd, ...);
        err = lfs_dir_fetch(lfs, &dir.m, pair);
        if (dir.m.count > 0 || dir.m.split) {
            return LFS_ERR_NOTEMPTY;
        }
        err = lfs_fs_preporphans(lfs, +1);  // 标记孤儿状态
    }
    // 删除条目
    err = lfs_dir_commit(lfs, &cwd, LFS_MKATTRS(
            {LFS_MKTAG(LFS_TYPE_DELETE, lfs_tag_id(tag), 0), NULL}));

删除目录的过程和删除普通文件几乎一样,唯一的区别是目录必须为空(dir.m.count > 0 || dir.m.split)。LittleFS 通过 LFS_ERR_NOTEMPTY 拒绝删除非空目录——这个经典的 POSIX 语义在 32 KiB Flash 的文件系统里也被忠实地实现了。

另一个细节是 lfs_fs_preporphans(lfs, +1)。当删除一个目录时,如果父目录的 metadata pair 在提交时发生了 split,被删除的子目录可能暂时留在 threaded linked-list 里成为“孤儿“。preporphans 增加了一个全局计数器,这样 mount 时文件系统就知道需要做孤儿清理。


和 KnotFS 的对比:一棵树和一张白纸

LittleFS 有完整的目录树。KnotFS 没有目录——它是平面文件系统

LittleFS 的目录树:              KnotFS 的平面结构:
/                                ┌──────────────────────┐
├── config/                      │  hello.txt     4KB   │
│   ├── can.json                 │  can_config    512B  │
│   └── lidar.json               │  firmware.bin  128KB │
├── logs/                        │  diag.log      64KB  │
│   └── diag.log                 └──────────────────────┘
└── hello.txt

KnotFS 没有 mkdir。所有文件都在一个扁平的命名空间里,通过 32 字节的名字直接索引。文件表(File Table)是一个固定 8 槽的数组,每个槽要么存一个文件条目,要么是 0xFFFFFFFF(已删除)。

这种设计源于 KnotFS 的目标场景:车载MCU上跑的功能不需要多层目录。几个配置文件、一个日志文件、一个固件升级包——8 个槽位绰绰有余。平面的文件表结构消除了目录遍历的所有复杂性——没有递归解析路径,没有 split pair 的链表追踪,没有 ... 的语义处理。

但平面结构也牺牲了组织性。如果你有 30 个不同类型的配置文件(CAN 矩阵、LIDAR 参数、传感器校准……),你只能在文件名里加前缀——can_matrix_v2.bin, lidar_calib_v1.bin——没有真正的方式把它们分组。

LittleFS 的目录设计还有一个 KnotFS 根本不涉及的问题:名字排序。在 LittleFS 的目录 metadata pair 里,文件条目是按字母顺序排列的。这得益于 LFS_TYPE_CREATELFS_TYPE_DELETE 两个 splice tag——它们允许在有序列表中插入和删除条目而不需要重写整个列表。

目录条目在 metadata pair 中的存储(按名字排序):

[name="apple", id=1]  [name="banana", id=2]  [name="cherry", id=3]
                                                    ↑
                   插入 "blueberry" 时,CREATE tag 把 id=2.5 插入到
                   banana 和 cherry 之间,其他条目的 id 相对位置移动

这种插入机制非常巧妙。id 不是固定的,而是可以在 10-bit 空间内任意分配。插入一个新文件时,只需要给新文件分配一个虚拟的 id(在相邻两个文件 id 的中间值),然后在提交时附上 LFS_TYPE_CREATE tag 来告诉扫描器“这个 id 在这里“。

KnotFS 的文件表不需要排序——8 个槽的顺序就是文件的物理存储顺序,find 操作简单地遍历数组做字符串比较。


目录遍历的秘密:Threaded Linked-List

LittleFS 的目录树有一个附加结构:threaded linked-list。这个链表穿过文件系统里的每一个 metadata pair,让遍历整个文件系统变为可能。

            .--------.
           .| root   |-.
           ||        | |        ← soft tail
    .------||        |-'
    |      |'--------'
    |      '---|--|-'
    |       .-'    '-------------------------.
    |      v                                  v
    |  .--------.        .--------.        .--------.
    '->| dir A  |------->| dir A  |------->| dir B  |
      ||        |       ||        |       ||        |
      ||        |       ||        |       ||        |
      |'--------'       |'--------'       |'--------'
      '---|--|-'        '----|---'        '---|--|-'
       .-'    '-.            |             .-'    '-.
      v          v           v            v          v
 .--------.  .--------.  .--------.  .--------.  .--------.
 | file C |  | file D |  | file E |  | file F |  | file G |
 |        |  |        |  |        |  |        |  |        |
 '--------'  '--------'  '--------'  '--------'  '--------'

每个 metadata pair 都有一个 soft tail 指针,指向链表上的下一个 pair。这个链表的存在有两个原因:

  1. Bounded RAM 遍历:LittleFS 承诺 O(1) 的内存使用。你不可能在 RAM 里维护一个目录树。threaded linked-list 让遍历可以在只记住“当前位置“的状态下一步步进行。

  2. 孤儿检测:mount 时遍历这个链表,检查每个 pair 是否在目录树中有父节点。没有父节点的就是孤儿——可能是上次掉电留下的。


下集预告

六节走完,LittleFS 的核心数据结构你已经看全了。Metadata Pair 的原子元数据更新、CTZ Skip-List 的 O(log n) 文件数据管理、lookahead 分配器在 4MB Flash 上的飞驰——这些都是工业级代码经过十年打磨的成果。

但你读 LittleFS 不是为了照抄它。你读它是为了明白一件事:当设计约束不同时,同样的理论可以长出完全不同的实现。 LittleFS 选择同步 API 是因为它面向有 RTOS 的 MCU——调用者可以接受阻塞。而我们在第三章做的思想实验告诉你:在裸机/RTOS 场景下,文件系统不能“阻塞等待 Flash 擦除完毕“。200ms 的擦除延迟里,CPU 有更重要的事要做。

所以下一章我们不照搬 LittleFS。我们拿它验证过的核心理念——Metadata Pair 的原子提交、日志结构化的元数据、Copy-on-Write 的掉电安全——装进一个完全不同的外壳:异步状态机 + 协作式事件循环。这就是 KnotFS。

没有操作系统,没有动态内存分配,只有 ~845 行 C 代码和 64KB 的“纸上 Flash“。看看第三章的理论、第四章的工业实践,能不能在 16 个块的棋盘上全部兑现。

悬念留给:LittleFS 的所有操作都是同步的——lfs_file_write() 调用会阻塞到 Flash 擦除完成。而在一个没有操作系统的裸机循环里,你不能“等着“——你得把 100ms 的擦除拆成几十个 tick 的协作式状态机。KnotFS 怎么做到“不阻塞“?