跳到内容

13.1 路径、inode 与数据块

地心档案馆的门牌写着 /home/lin/report.txt,库房里却找不到一只刻着完整路径的盒子。那里只有目录项、带编号的文件对象和一组数据块。路径属于命名空间;存储设备并不理解“report”这个名字。

文件系统负责把名字、权限、字节范围和物理存储连接起来,同时让进程通过 file descriptor 继续访问已经打开的对象。这套抽象里最容易混淆的是:文件名不是文件本身,file descriptor 也不是 inode,文件大小更不等于实际占用块数。把三者拆开,后面的崩溃恢复才有可推理的对象。

1. 从路径到设备

一次普通文件访问大致穿过这些层次:

text
进程:文件描述符、当前目录

VFS:统一的 open/read/write/stat 接口

具体文件系统:目录、inode、块映射、空闲空间

页缓存与块 I/O

设备驱动、控制器、持久介质

VFS 让 ext4、XFS、tmpfs、网络文件系统等呈现相似接口,但这不表示它们有完全相同的磁盘结构。比如“inode”是 Unix 风格文件系统与 VFS 中的重要概念,某个具体文件系统在盘上的元数据组织可能不是一张简单 inode 表。

2. 名字存在目录里

目录保存从名字到文件系统对象的映射。路径解析会从根目录、进程当前目录或指定目录描述符出发,逐段查找:

text
/srv/app/config.json
 │   │        │
根   目录项   目录项 → 最终对象

最终对象的元数据通常包含:

  • 文件类型与权限;
  • 所有者和组;
  • 文件大小;
  • 时间戳;
  • 硬链接计数;
  • 数据块或 extent 的映射;
  • 文件系统特有的标志、扩展属性和校验信息。

inode 本身通常不保存“这个文件唯一的名字”,因为同一个对象可以有多个硬链接。删除一个目录项也不一定立刻销毁对象。

dentry 与缓存

Linux VFS 使用 dentry 表示一次名字解析关系,并缓存近期查找结果。dentry 是内核内存对象,不能简单等同于盘上的目录项。缓存命中能省去重复目录遍历;缓存失效与一致性则由 VFS 和具体文件系统共同处理。

因此,看到第二次 stat 更快,并不能证明磁盘目录结构本身发生了变化,可能只是路径与 inode 元数据仍在缓存中。

3. 硬链接与符号链接

硬链接在同一文件系统内为已有对象增加一个目录项。两个名字通常指向相同 inode:

text
notes.txt ─┐
           ├──> inode 418 ──> data
draft.txt ─┘

删除其中一个名字,只减少一条命名关系。普通文件在链接计数降为 0 且没有进程继续打开它后,文件系统才可以回收对象和数据块。

符号链接是另一种文件,它的内容是一段待解析路径。目标被移动或删除后,符号链接可能悬空;硬链接则不依赖原名字继续存在。

目录硬链接通常受到严格限制,以免目录树出现难以管理的环。跨文件系统也不能创建普通硬链接,因为 inode 编号只在相应文件系统的标识范围内有意义。

4. 文件描述符指向一次打开状态

open 返回的小整数是进程文件描述符表的索引。它通常经过一层“打开文件描述”再关联到文件系统对象:

text
进程 fd 3 ─┐
            ├──> open file description ──> inode/vnode
进程 fd 7 ─┘          │
                      ├─ 当前偏移
                      └─ 状态标志

dupfork 产生的描述符可以共享同一个打开文件描述,因此也共享文件偏移。两次独立 open 通常得到不同的打开状态,即使最终指向同一个 inode。

这解释了几个现象:

  • 路径被 unlink 后,已打开的描述符仍可读写;
  • lseek 改变的是打开状态中的偏移,不是 inode 里的全局游标;
  • 文件描述符数字可以复用,日志里只记“fd=5”不足以标识长期对象;
  • 文件锁的归属与释放语义取决于锁类型,不能只凭 inode 图推断。

5. 动手观察:拆下门牌,档案仍被打开

从目录删掉文件名,像从档案馆走廊拆下门牌;已经打开它的进程仍握着一份 open file description,可以继续访问同一个对象。这个实验把“名字”和“打开状态”在终端里分开。

下面的程序创建文件和硬链接,然后删除两个名字。只要描述符仍打开,内容仍可读取:

c
#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

static int write_all(int fd, const void *buffer, size_t length) {
    const char *cursor = buffer;
    while (length > 0) {
        ssize_t written = write(fd, cursor, length);
        if (written > 0) {
            cursor += written;
            length -= (size_t)written;
        } else if (written < 0 && errno == EINTR) {
            continue;
        } else {
            return -1;
        }
    }
    return 0;
}

int main(void) {
    char original[] = "/tmp/atlas-file-XXXXXX";
    char hard_link[128];
    static const char message[] = "still reachable\n";
    char buffer[sizeof(message)] = { 0 };

    int fd = mkstemp(original);
    if (fd < 0) {
        perror("mkstemp");
        return EXIT_FAILURE;
    }

    if (snprintf(hard_link, sizeof(hard_link), "%s.link", original)
            >= (int)sizeof(hard_link)
            || write_all(fd, message, sizeof(message) - 1) != 0
            || link(original, hard_link) != 0) {
        perror("setup");
        close(fd);
        unlink(original);
        return EXIT_FAILURE;
    }

    struct stat original_stat;
    struct stat link_stat;
    if (stat(original, &original_stat) != 0
            || stat(hard_link, &link_stat) != 0) {
        return EXIT_FAILURE;
    }
    printf("same inode: %s, links: %lu\n",
           original_stat.st_ino == link_stat.st_ino ? "yes" : "no",
           (unsigned long)original_stat.st_nlink);

    if (unlink(original) != 0 || unlink(hard_link) != 0
            || lseek(fd, 0, SEEK_SET) < 0
            || read(fd, buffer, sizeof(message) - 1)
                != (ssize_t)(sizeof(message) - 1)) {
        perror("read after unlink");
        close(fd);
        return EXIT_FAILURE;
    }

    printf("%s", buffer);
    return close(fd) == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
bash
cc -std=c17 -O2 -Wall -Wextra inode-links.c
./a.out
# same inode: yes, links: 2
# still reachable

这也是“删除大型日志后磁盘空间没有立刻回来”的一个常见原因:进程仍持有已删除文件。此时目录里找不到名字,但对象尚未到达可回收条件。

6. 文件大小、块与 extent

文件向应用呈现连续的字节序列,物理块却不必连续。文件系统需要把逻辑偏移映射到存储位置。

早期 Unix 风格设计常用直接、间接和多级间接块指针。现代文件系统也常用 extent,用“逻辑起点、物理起点、连续长度”描述一段范围。对连续大文件,extent 比逐块记录更紧凑。

具体布局属于文件系统实现细节。不能把某个旧版 ext 系列 inode 的“12 个直接指针”当作所有 ext4 文件当前都采用的固定映射;ext4 通常可以使用 extent 树,inode 中相同字段区域的解释会受标志和格式影响。

稀疏文件

若某段逻辑范围没有分配物理块,读取时可以得到零,这就是 hole。于是:

text
逻辑大小:1 GiB
实际已分配:几个数据块

stat 的大小、du 看到的已分配空间与设备剩余空间回答的是不同问题。复制工具如果不识别 hole,可能把稀疏文件展开成真正占用 1 GiB 的文件。

7. 目录操作与原子性

同一文件系统内的 rename 通常提供原子的命名切换:并发查找看到旧名字关系或新名字关系,而不是半个目录项。但它不让文件内容自动落盘,也不把跨越多个文件的修改合成事务。

跨文件系统移动通常无法直接完成一次 rename,工具会退化为复制再删除。此时中途失败可能留下源文件和不完整目标文件,应用必须把这种语义差异纳入错误处理。

创建、链接、重命名和删除都在改变命名空间。若程序要在断电后保证新名字仍存在,还需要遵循下一节讨论的文件与目录同步协议。

8. 缓存让“文件在磁盘上”变得含糊

读路径可能命中页缓存,完全不访问设备。写路径通常先更新页缓存,由内核稍后合并回写。内存映射文件也会经过相似的缓存与脏页机制,但使用 msyncfsync 等接口表达同步边界。

直接 I/O、同步打开标志和内存映射各有对齐、可见性与持久化规则。它们不是“绕过所有缓存就自然安全”的开关。选择之前应先明确:

  • 是否需要绕开页缓存;
  • 与普通 buffered I/O 是否会混用;
  • 完成通知表示数据到达哪一层;
  • 元数据和目录更新怎样同步;
  • 崩溃后允许看到旧版、新版还是部分更新。

9. 容量耗尽时会发生什么

“设备还有空闲字节”不保证这次写入成功。失败可能来自:

  • 数据块或元数据空间耗尽;
  • inode/对象数量达到限制;
  • 用户或项目配额;
  • 只读重新挂载;
  • 设备 I/O 错误;
  • 稀疏文件在后续实际写入时才需要分配;
  • COW 快照保留旧块,使可回收空间低于预期。

错误可能在 writefsyncclose 才暴露。可靠程序需要检查整个提交路径,而不是只检查 open

10. 小结

文件系统把路径、打开状态与存储对象分成了不同层:

  • 目录项把名字映射到对象,inode 通常不保存唯一文件名;
  • 文件描述符指向一次打开状态,名字删除后它仍可能保持对象存活;
  • 硬链接共享对象,符号链接保存待解析路径;
  • 逻辑字节范围通过块指针或 extent 映射到存储;
  • 稀疏文件让逻辑大小与实际分配量分离;
  • 页缓存把系统调用完成与设备持久化分开。

下一节进入崩溃恢复,讨论日志、COW、校验和,以及应用如何安全替换一个文件。

Built with VitePress | Software Systems Atlas