跳转至

文件、目录与 inode

文件系统把持久化字节组织成可命名对象。应用看到路径和文件描述符,内核内部则维护目录项、inode、打开文件对象、页缓存和底层块映射。理解这些对象的区别,可以解释共享偏移、删除后仍可读取和链接等看似反直觉的现象。

文件抽象解决什么问题

裸块设备只提供按块读写,没有“名称、所有者、长度、目录或并发打开”的概念。文件系统增加:

  • 稳定命名和层次目录。
  • 字节序列或记录式访问。
  • 权限、时间戳和其他元数据。
  • 空间分配与回收。
  • 崩溃后可恢复的一致性规则。

这些抽象有成本:路径解析、元数据更新、缓存和日志都增加额外工作,但让应用不必理解具体扇区布局。

四类容易混淆的对象

文件描述符

文件描述符是进程局部的小整数,作为文件描述符表索引。它可以指向普通文件、管道、套接字、设备或事件对象。

打开文件对象

记录一次打开实例的当前偏移、打开标志和对底层对象的引用。多个文件描述符可以指向同一个打开文件对象,例如 dupfork 继承后,因此共享文件偏移。

inode

inode 表示文件本体的元数据与数据块映射,通常包含类型、权限、所有者、大小、时间戳、链接计数和块位置。名称通常不直接存放在 inode 中。

目录项

目录把名字映射到 inode 标识。内核可缓存路径组件解析结果,常称 dentry/目录项缓存。

关系可以画成:

进程 fd 表
  fd 3 ─┐
  fd 7 ─┴─> 打开文件对象(偏移、标志)
                -> inode(元数据、块映射)
                       <- 目录项 name_a
                       <- 目录项 name_b

open 到底做了什么

概念路径:

  1. 从根目录或当前工作目录开始解析路径。
  2. 逐个查找目录项,并检查搜索权限与挂载点。
  3. 找到目标 inode,检查打开权限和标志。
  4. 创建打开文件对象。
  5. 在进程 fd 表中分配空闲索引。
  6. 返回这个整数索引。

返回 3 不代表“磁盘上的第三个文件”,只表示当前进程 fd 表中的槽位。另一个进程的 fd 3 可以指向完全不同对象。

最小例子:共享偏移

int fd1 = open("data.txt", O_RDONLY);
int fd2 = dup(fd1);

char a[4], b[4];
read(fd1, a, sizeof(a));
read(fd2, b, sizeof(b));

dup 后两个描述符指向同一打开文件对象,因此第二次读取通常从第一次读取后的偏移继续。若分别调用两次 open,通常得到两个打开文件对象,各自维护偏移。

并发共享偏移的原子语义依具体接口和系统保证。需要明确位置的并发读取可使用带显式偏移的接口,避免依赖共享游标。

路径解析

路径 /home/alice/report.txt 不是一次哈希命中,而是逐级解析:

root inode
  -> 查找 home
  -> 查找 alice
  -> 查找 report.txt

每一级都可能涉及目录缓存、inode 缓存、权限检查、符号链接和挂载点跳转。缓存命中时无需设备 I/O;冷缓存或远程文件系统上,路径解析可能成为主要成本。

..、符号链接和挂载命名空间使解析更复杂。安全敏感程序若先检查路径再打开,攻击者可能在两步之间替换目录项,形成 TOCTOU。基于已打开目录 fd 的相对接口可缩小竞态窗口。

硬链接

硬链接新增一个“名字到同一 inode”的目录项,并增加链接计数。两个名字地位相同:

name_a -> inode 42 <- name_b

删除 name_a 只移除一个目录项并减少链接计数,不影响 name_b

通常不允许普通用户为目录创建任意硬链接,以避免目录图出现环并简化一致性。硬链接一般不能跨文件系统,因为 inode 标识属于具体文件系统。

符号链接

符号链接是内容为目标路径的特殊文件。访问时,路径解析器读取该路径并继续解析。

优点:

  • 可跨文件系统。
  • 可指向目录。
  • 即使目标暂不存在也能创建。

代价是目标移动后可能悬空,且解析需要防止符号链接环和过深递归。

删除一个打开文件

删除通常执行 unlink:移除名字并减少 inode 链接计数。若仍有打开文件对象引用 inode,数据不会立即释放。最后一个目录链接和最后一个打开引用都消失后,系统才能回收空间。

这允许:

  • 程序打开临时文件后立即删除名字,仍通过 fd 使用。
  • 日志轮转后,旧进程继续写入已取消链接的旧 inode。

第二种情况也解释了“文件看似删除但磁盘空间没有释放”:需要找到并关闭仍引用它的进程。

目录是什么

目录是保存名字到 inode 映射的特殊文件系统对象。应用不能把它当普通字节随意写,因为那会破坏命名不变量;必须通过创建、链接、重命名和删除等受控操作修改。

目录的执行/搜索权限表示能否穿过并查找其中名字,读取权限表示能否列出名字,写权限表示能否修改目录项。它们与普通文件的读写含义不同。

数据块布局

文件内容需要映射到设备块。常见方案包括:

  • 直接/间接块指针。
  • extent:用起始位置和长度描述一段连续块。
  • B 树或其他索引结构管理大文件和稀疏映射。

连续 extent 减少元数据并利于顺序 I/O,但长期写入和删除会产生碎片。预分配可改善连续性,却可能占用暂时不用的空间。

稀疏文件

文件逻辑长度可以大于实际分配块。未分配洞读取为零,写入洞内区域时才分配块。

logical file: [data][hole........][data]
physical:     [data]               [data]

普通复制若逐字节读取再写出,可能把洞变成真实零块;支持稀疏感知的工具会保留洞信息。

VFS 与多种文件系统

虚拟文件系统层定义统一的打开、读取、查找和属性操作,把具体实现分派给本地磁盘文件系统、内存文件系统、网络文件系统或伪文件系统。

统一接口的好处是应用无需针对每种文件系统重写;代价是公共抽象无法完全隐藏语义差异,例如缓存一致性、原子重命名范围、大小写和持久化保证。

页缓存

普通文件数据通常缓存在内存页中:

  • 读取命中页缓存时,无需访问设备。
  • 写入常先修改缓存页并标脏,稍后回写。
  • 文件映射和 read 可以共享同一批缓存页。

缓存提高吞吐并合并写入,却把“系统调用返回”和“数据持久化”分开。崩溃一致性页会继续讨论何时需要 fsync 等接口。

文件权限与引用

权限检查通常在路径解析和打开时进行,但已打开 fd 是一种能力引用:进程即使随后看不到原路径,也可能继续通过已有 fd 访问。通过进程间通信传递 fd,也是在传递受控访问能力。

因此安全检查不能只搜索路径字符串,还要追踪打开引用、继承和传递。

自测

  1. 文件描述符、打开文件对象和 inode 分别保存什么?
  2. dup 后两个 fd 为什么可能共享偏移?
  3. 硬链接与符号链接的目标和跨文件系统能力有何不同?
  4. 为什么删除打开文件后空间可能不释放?
  5. 页缓存命中时,read 是否一定产生设备 I/O?
参考思路

fd 是进程表索引,打开对象保存偏移和标志,inode 保存文件元数据与块映射。dup 指向同一打开对象。硬链接直接指向同一 inode,符号链接保存路径。打开引用维持 inode 生命周期。缓存命中无需设备读取。