文件、目录与 inode¶
文件系统把持久化字节组织成可命名对象。应用看到路径和文件描述符,内核内部则维护目录项、inode、打开文件对象、页缓存和底层块映射。理解这些对象的区别,可以解释共享偏移、删除后仍可读取和链接等看似反直觉的现象。
文件抽象解决什么问题¶
裸块设备只提供按块读写,没有“名称、所有者、长度、目录或并发打开”的概念。文件系统增加:
- 稳定命名和层次目录。
- 字节序列或记录式访问。
- 权限、时间戳和其他元数据。
- 空间分配与回收。
- 崩溃后可恢复的一致性规则。
这些抽象有成本:路径解析、元数据更新、缓存和日志都增加额外工作,但让应用不必理解具体扇区布局。
四类容易混淆的对象¶
文件描述符¶
文件描述符是进程局部的小整数,作为文件描述符表索引。它可以指向普通文件、管道、套接字、设备或事件对象。
打开文件对象¶
记录一次打开实例的当前偏移、打开标志和对底层对象的引用。多个文件描述符可以指向同一个打开文件对象,例如 dup 或 fork 继承后,因此共享文件偏移。
inode¶
inode 表示文件本体的元数据与数据块映射,通常包含类型、权限、所有者、大小、时间戳、链接计数和块位置。名称通常不直接存放在 inode 中。
目录项¶
目录把名字映射到 inode 标识。内核可缓存路径组件解析结果,常称 dentry/目录项缓存。
关系可以画成:
open 到底做了什么¶
概念路径:
- 从根目录或当前工作目录开始解析路径。
- 逐个查找目录项,并检查搜索权限与挂载点。
- 找到目标 inode,检查打开权限和标志。
- 创建打开文件对象。
- 在进程 fd 表中分配空闲索引。
- 返回这个整数索引。
返回 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 不是一次哈希命中,而是逐级解析:
每一级都可能涉及目录缓存、inode 缓存、权限检查、符号链接和挂载点跳转。缓存命中时无需设备 I/O;冷缓存或远程文件系统上,路径解析可能成为主要成本。
..、符号链接和挂载命名空间使解析更复杂。安全敏感程序若先检查路径再打开,攻击者可能在两步之间替换目录项,形成 TOCTOU。基于已打开目录 fd 的相对接口可缩小竞态窗口。
硬链接¶
硬链接新增一个“名字到同一 inode”的目录项,并增加链接计数。两个名字地位相同:
删除 name_a 只移除一个目录项并减少链接计数,不影响 name_b。
通常不允许普通用户为目录创建任意硬链接,以避免目录图出现环并简化一致性。硬链接一般不能跨文件系统,因为 inode 标识属于具体文件系统。
符号链接¶
符号链接是内容为目标路径的特殊文件。访问时,路径解析器读取该路径并继续解析。
优点:
- 可跨文件系统。
- 可指向目录。
- 即使目标暂不存在也能创建。
代价是目标移动后可能悬空,且解析需要防止符号链接环和过深递归。
删除一个打开文件¶
删除通常执行 unlink:移除名字并减少 inode 链接计数。若仍有打开文件对象引用 inode,数据不会立即释放。最后一个目录链接和最后一个打开引用都消失后,系统才能回收空间。
这允许:
- 程序打开临时文件后立即删除名字,仍通过 fd 使用。
- 日志轮转后,旧进程继续写入已取消链接的旧 inode。
第二种情况也解释了“文件看似删除但磁盘空间没有释放”:需要找到并关闭仍引用它的进程。
目录是什么¶
目录是保存名字到 inode 映射的特殊文件系统对象。应用不能把它当普通字节随意写,因为那会破坏命名不变量;必须通过创建、链接、重命名和删除等受控操作修改。
目录的执行/搜索权限表示能否穿过并查找其中名字,读取权限表示能否列出名字,写权限表示能否修改目录项。它们与普通文件的读写含义不同。
数据块布局¶
文件内容需要映射到设备块。常见方案包括:
- 直接/间接块指针。
- extent:用起始位置和长度描述一段连续块。
- B 树或其他索引结构管理大文件和稀疏映射。
连续 extent 减少元数据并利于顺序 I/O,但长期写入和删除会产生碎片。预分配可改善连续性,却可能占用暂时不用的空间。
稀疏文件¶
文件逻辑长度可以大于实际分配块。未分配洞读取为零,写入洞内区域时才分配块。
普通复制若逐字节读取再写出,可能把洞变成真实零块;支持稀疏感知的工具会保留洞信息。
VFS 与多种文件系统¶
虚拟文件系统层定义统一的打开、读取、查找和属性操作,把具体实现分派给本地磁盘文件系统、内存文件系统、网络文件系统或伪文件系统。
统一接口的好处是应用无需针对每种文件系统重写;代价是公共抽象无法完全隐藏语义差异,例如缓存一致性、原子重命名范围、大小写和持久化保证。
页缓存¶
普通文件数据通常缓存在内存页中:
- 读取命中页缓存时,无需访问设备。
- 写入常先修改缓存页并标脏,稍后回写。
- 文件映射和
read可以共享同一批缓存页。
缓存提高吞吐并合并写入,却把“系统调用返回”和“数据持久化”分开。崩溃一致性页会继续讨论何时需要 fsync 等接口。
文件权限与引用¶
权限检查通常在路径解析和打开时进行,但已打开 fd 是一种能力引用:进程即使随后看不到原路径,也可能继续通过已有 fd 访问。通过进程间通信传递 fd,也是在传递受控访问能力。
因此安全检查不能只搜索路径字符串,还要追踪打开引用、继承和传递。
自测¶
- 文件描述符、打开文件对象和 inode 分别保存什么?
dup后两个 fd 为什么可能共享偏移?- 硬链接与符号链接的目标和跨文件系统能力有何不同?
- 为什么删除打开文件后空间可能不释放?
- 页缓存命中时,
read是否一定产生设备 I/O?
参考思路
fd 是进程表索引,打开对象保存偏移和标志,inode 保存文件元数据与块映射。dup 指向同一打开对象。硬链接直接指向同一 inode,符号链接保存路径。打开引用维持 inode 生命周期。缓存命中无需设备读取。