13.1 路径、inode 与数据块
地心档案馆的门牌写着 /home/lin/report.txt,库房里却找不到一只刻着完整路径的盒子。那里只有目录项、带编号的文件对象和一组数据块。路径属于命名空间;存储设备并不理解“report”这个名字。
文件系统负责把名字、权限、字节范围和物理存储连接起来,同时让进程通过 file descriptor 继续访问已经打开的对象。这套抽象里最容易混淆的是:文件名不是文件本身,file descriptor 也不是 inode,文件大小更不等于实际占用块数。把三者拆开,后面的崩溃恢复才有可推理的对象。
1. 从路径到设备
一次普通文件访问大致穿过这些层次:
进程:文件描述符、当前目录
↓
VFS:统一的 open/read/write/stat 接口
↓
具体文件系统:目录、inode、块映射、空闲空间
↓
页缓存与块 I/O
↓
设备驱动、控制器、持久介质VFS 让 ext4、XFS、tmpfs、网络文件系统等呈现相似接口,但这不表示它们有完全相同的磁盘结构。比如“inode”是 Unix 风格文件系统与 VFS 中的重要概念,某个具体文件系统在盘上的元数据组织可能不是一张简单 inode 表。
2. 名字存在目录里
目录保存从名字到文件系统对象的映射。路径解析会从根目录、进程当前目录或指定目录描述符出发,逐段查找:
/srv/app/config.json
│ │ │
根 目录项 目录项 → 最终对象最终对象的元数据通常包含:
- 文件类型与权限;
- 所有者和组;
- 文件大小;
- 时间戳;
- 硬链接计数;
- 数据块或 extent 的映射;
- 文件系统特有的标志、扩展属性和校验信息。
inode 本身通常不保存“这个文件唯一的名字”,因为同一个对象可以有多个硬链接。删除一个目录项也不一定立刻销毁对象。
dentry 与缓存
Linux VFS 使用 dentry 表示一次名字解析关系,并缓存近期查找结果。dentry 是内核内存对象,不能简单等同于盘上的目录项。缓存命中能省去重复目录遍历;缓存失效与一致性则由 VFS 和具体文件系统共同处理。
因此,看到第二次 stat 更快,并不能证明磁盘目录结构本身发生了变化,可能只是路径与 inode 元数据仍在缓存中。
3. 硬链接与符号链接
硬链接在同一文件系统内为已有对象增加一个目录项。两个名字通常指向相同 inode:
notes.txt ─┐
├──> inode 418 ──> data
draft.txt ─┘删除其中一个名字,只减少一条命名关系。普通文件在链接计数降为 0 且没有进程继续打开它后,文件系统才可以回收对象和数据块。
符号链接是另一种文件,它的内容是一段待解析路径。目标被移动或删除后,符号链接可能悬空;硬链接则不依赖原名字继续存在。
目录硬链接通常受到严格限制,以免目录树出现难以管理的环。跨文件系统也不能创建普通硬链接,因为 inode 编号只在相应文件系统的标识范围内有意义。
4. 文件描述符指向一次打开状态
open 返回的小整数是进程文件描述符表的索引。它通常经过一层“打开文件描述”再关联到文件系统对象:
进程 fd 3 ─┐
├──> open file description ──> inode/vnode
进程 fd 7 ─┘ │
├─ 当前偏移
└─ 状态标志dup 或 fork 产生的描述符可以共享同一个打开文件描述,因此也共享文件偏移。两次独立 open 通常得到不同的打开状态,即使最终指向同一个 inode。
这解释了几个现象:
- 路径被
unlink后,已打开的描述符仍可读写; lseek改变的是打开状态中的偏移,不是 inode 里的全局游标;- 文件描述符数字可以复用,日志里只记“fd=5”不足以标识长期对象;
- 文件锁的归属与释放语义取决于锁类型,不能只凭 inode 图推断。
5. 动手观察:拆下门牌,档案仍被打开
从目录删掉文件名,像从档案馆走廊拆下门牌;已经打开它的进程仍握着一份 open file description,可以继续访问同一个对象。这个实验把“名字”和“打开状态”在终端里分开。
下面的程序创建文件和硬链接,然后删除两个名字。只要描述符仍打开,内容仍可读取:
#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;
}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。于是:
逻辑大小:1 GiB
实际已分配:几个数据块stat 的大小、du 看到的已分配空间与设备剩余空间回答的是不同问题。复制工具如果不识别 hole,可能把稀疏文件展开成真正占用 1 GiB 的文件。
7. 目录操作与原子性
同一文件系统内的 rename 通常提供原子的命名切换:并发查找看到旧名字关系或新名字关系,而不是半个目录项。但它不让文件内容自动落盘,也不把跨越多个文件的修改合成事务。
跨文件系统移动通常无法直接完成一次 rename,工具会退化为复制再删除。此时中途失败可能留下源文件和不完整目标文件,应用必须把这种语义差异纳入错误处理。
创建、链接、重命名和删除都在改变命名空间。若程序要在断电后保证新名字仍存在,还需要遵循下一节讨论的文件与目录同步协议。
8. 缓存让“文件在磁盘上”变得含糊
读路径可能命中页缓存,完全不访问设备。写路径通常先更新页缓存,由内核稍后合并回写。内存映射文件也会经过相似的缓存与脏页机制,但使用 msync、fsync 等接口表达同步边界。
直接 I/O、同步打开标志和内存映射各有对齐、可见性与持久化规则。它们不是“绕过所有缓存就自然安全”的开关。选择之前应先明确:
- 是否需要绕开页缓存;
- 与普通 buffered I/O 是否会混用;
- 完成通知表示数据到达哪一层;
- 元数据和目录更新怎样同步;
- 崩溃后允许看到旧版、新版还是部分更新。
9. 容量耗尽时会发生什么
“设备还有空闲字节”不保证这次写入成功。失败可能来自:
- 数据块或元数据空间耗尽;
- inode/对象数量达到限制;
- 用户或项目配额;
- 只读重新挂载;
- 设备 I/O 错误;
- 稀疏文件在后续实际写入时才需要分配;
- COW 快照保留旧块,使可回收空间低于预期。
错误可能在 write、fsync 或 close 才暴露。可靠程序需要检查整个提交路径,而不是只检查 open。
10. 小结
文件系统把路径、打开状态与存储对象分成了不同层:
- 目录项把名字映射到对象,inode 通常不保存唯一文件名;
- 文件描述符指向一次打开状态,名字删除后它仍可能保持对象存活;
- 硬链接共享对象,符号链接保存待解析路径;
- 逻辑字节范围通过块指针或 extent 映射到存储;
- 稀疏文件让逻辑大小与实际分配量分离;
- 页缓存把系统调用完成与设备持久化分开。
下一节进入崩溃恢复,讨论日志、COW、校验和,以及应用如何安全替换一个文件。