Appearance
文件与目录
- 写作时间:
2026-03-04 首次提交,2026-04-08 最近修改 - 当前字符:
13041
上一章从 I/O 硬件出发,一路走到了块层和 I/O 调度器:数据怎样从应用程序经过内核一层层传到磁盘驱动,又怎样从磁盘回到内存。但那些讨论把数据看成不带结构的字节块——读写的单位是扇区和页,没有名字,没有权限,没有层次。应用程序需要的不是"往磁盘偏移量 4096 处写 512 字节",而是"打开 /etc/hostname 读取主机名"。从原始的块读写到有名字、有权限、有层次的文件操作,中间需要一套管理层。这就是文件系统。
这一章从进程和文件系统的接口出发,一步步深入到文件系统内部。本课先看进程怎样操作文件,以及一个 fd 背后到底有几层结构。内核不可能为每种文件系统都写一套 open/read/write,所以它需要一层统一的抽象,这就引出下一课要讲的 VFS。有了统一抽象之后,文件系统的写入路径要经过内存缓存再落盘,中间任何一步断电都可能破坏数据的一致性,于是第三课讨论日志和写时复制两种保护策略。最后一课把视角从文件系统本身转向应用程序,看开发者怎样用 fsync 和 rename 确保自己的数据在崩溃后完好无损。
回到本课。进程生命周期那一课已经建立了文件描述符的基本认知:fd 是一个非负整数,进程通过它访问文件、设备和管道;fork 复制 fd 表后,父子进程的 fd 指向同一个"内核文件对象",共享文件偏移量和状态。但那时并没有展开"内核文件对象"到底是什么。这里有一个看似矛盾的现象:fork 后父子进程共享偏移量,但如果两个不相关的进程各自 open 同一个文件,它们的偏移量却是独立的。同样是"打开了同一个文件",行为却不一样。要解释这个差异,我们需要把 fd 背后的结构从一层展开到三层。在那之前,先从文件本身说起。
文件
文件(file)是操作系统提供的对持久存储数据的抽象:一段有名字、有属性、可以通过系统调用操作的字节序列。
对操作系统来说,文件由两部分组成:数据和元数据。数据就是文件的内容,一段可以按字节寻址的序列。元数据(metadata)则是操作系统维护的关于这个文件的附加信息。用 stat 命令可以看到一个文件的完整元数据:
bash
$ stat /etc/hostname
File: /etc/hostname
Size: 12 Blocks: 8 IO Block: 4096 regular file
Device: 254,1 Inode: 298919 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2026-04-07 06:46:36.892178002 +0000
Modify: 2026-04-07 06:46:36.892178002 +0000
Change: 2026-04-07 06:46:36.892178002 +0000
Birth: 2026-04-07 06:46:36.892178002 +0000几个关键字段值得注意。文件类型(file type) 区分普通文件(regular file)、目录(directory)、符号链接(symbolic link)、字符设备(character device)、块设备(block device)、管道(FIFO)和套接字(socket)。Linux 用同一套 fd 接口管理所有这些类型,但它们的底层实现完全不同。权限(permissions) 以 rwxr-xr-x 格式呈现,分为所有者(owner)、所属组(group)和其他用户(others)三组,决定谁能读、写或执行这个文件。链接计数(link count) 记录有多少个目录项指向这个文件,后文"链接"一节会展开这个概念。
ctime 为什么不是"创建时间"?
stat 输出中有三个时间戳:atime(最后访问时间)、mtime(最后修改数据的时间)和 ctime(最后修改元数据的时间)。很多人第一反应是把 ctime 读成 "creation time",但在传统 Unix 语义中,ctime 是 "change time",记录的是元数据最后被修改的时间。改权限、改所有者、创建硬链接,都会更新 ctime,但不会更新 mtime。传统 Unix 根本不记录文件的创建时间。
Linux 后来在 statx() 系统调用中加入了 btime(birth time),也就是真正的创建时间。上面 stat 输出中的 Birth 字段就是它。但并非所有文件系统都支持 btime:ext4 从 Linux 4.11 起支持,而某些文件系统则不记录。
进程对文件的操作遵循一个固定的生命周期:打开 → 读写 → 关闭。
open(path, flags) 是起点。它告诉内核"我要访问这个路径指向的文件,用这些模式"。内核在进程的 fd 表中分配一个空闲槽位,建立一系列内部数据结构(下一节详述),然后返回这个槽位的编号,也就是 fd。如果文件不存在且 flags 中包含 O_CREAT,内核会先在文件系统中创建它。
read(fd, buf, count) 和 write(fd, buf, count) 是读写操作。每次读或写都会推进文件的当前偏移量(offset),下一次操作从新的偏移量继续。read() 返回实际读到的字节数,返回 0 表示已到达文件末尾。
lseek(fd, offset, whence) 改变文件偏移量。whence 可以是 SEEK_SET(从文件头算)、SEEK_CUR(从当前位置算)或 SEEK_END(从文件尾算)。有了 lseek,程序就可以随机访问文件的任意位置,而不只是从头到尾顺序读写。
close(fd) 释放 fd 槽位和相关的内核资源。
这个生命周期暗示了一个重要区分:文件本身和**"打开了文件"这个动作**是两件不同的事。文件在磁盘上持久存在,不管有没有进程打开它。open() 在文件和进程之间建立一条运行时的连接,close() 断开这条连接。同一个文件可以被多个进程同时打开,每次 open() 都建立一条独立的连接。这种"文件 vs 打开的文件"的区分,正是下一节三层结构的核心。
打开文件的三层结构
打开文件的三层结构是 Unix/Linux 内核中连接进程和文件的数据结构链:进程的 fd 表 → 系统级的打开文件描述(open file description) → 文件系统级的 inode。
进程生命周期那一课把 fd 表画成一个数组,每个槽位"指向一个内核文件对象"。为了聚焦 fork/exec/pipe,那时我们故意把内核内部压缩成了一个黑盒。现在把这个黑盒打开。
第一层:fd 表。 每个进程有一张自己的 fd 表,由内核中的 files_struct 维护。它本质上就是一个指针数组,每个元素指向一个打开文件描述。fd 本身只是这个数组的下标,一个纯粹的整数,不携带任何语义。dup2(old_fd, new_fd) 做的事情就是让 new_fd 这个槽位也指向 old_fd 所指的同一个打开文件描述。
第二层:打开文件描述(open file description)。 每次调用 open() 时,内核都会创建一个新的打开文件描述。在 Linux 内核源码中,这个结构叫 struct file1。它记录三样东西:
- 当前偏移量(offset):下一次 read/write 的起始位置
- 访问模式(access mode):只读、只写还是读写,由
open()的 flags 决定 - 指向 inode 的指针:标明这个打开操作对应磁盘上的哪个文件
打开文件描述不属于任何单个进程,它是系统级的对象。多个 fd 可以指向同一个打开文件描述——无论这些 fd 来自同一个进程还是不同进程。
第三层:inode。 inode 是文件在文件系统中的唯一标识,存放前面 stat 命令看到的那些元数据(类型、权限、大小、时间戳),以及指向文件数据块在磁盘上位置的信息。一个文件在同一个文件系统中只有一个 inode,不管它被打开了多少次、被多少个名字引用。内核中对应 struct inode。
三层之间的关系:
进程 A 的 fd 表 打开文件描述 inode
(system-wide) (per-filesystem)
┌────┬────────┐
│ 3 │ ───────────────→ ┌───────────────┐
└────┴────────┘ │ offset: 1024 │
│ mode: O_RDONLY│ ────→ inode #42
进程 B 的 fd 表 │ │ (/etc/hostname)
┌────┬────────┐ └───────────────┘
│ 3 │ ───────────┐
└────┴────────┘ │ ┌───────────────┐
└───→ │ offset: 0 │ ────→ inode #99
│ mode: O_RDWR │ (/tmp/data.txt)
└───────────────┘这幅图还看不出三层结构的意义。它的真正价值要放到具体场景中才能体会。我们来看三种"多个 fd 操作同一个文件"的情况,它们的行为各不相同,而三层结构恰好解释了每一种差异。
场景一:dup2()——同进程内两个 fd 共享偏移量。
fd 表 打开文件描述 inode
┌────┬────────┐
│ 3 │ ──────────┐
├────┼────────┤ ├───→ offset: 1024 ────→ inode #42
│ 5 │ ──────────┘ mode: O_RDONLY
└────┴────────┘dup2(3, 5) 让 fd 3 和 fd 5 指向同一个打开文件描述。它们共享 offset:通过 fd 3 读取 10 字节后,fd 5 的偏移量也会前进 10 字节。进程生命周期那一课用 dup2 实现重定向,正是利用了这一点。
场景二:fork()——父子进程共享偏移量。
父进程 fd 表 打开文件描述 inode
┌────┬────────┐
│ 3 │ ──────────┐
└────┴────────┘ ├───→ offset: 1024 ────→ inode #42
子进程 fd 表 │ mode: O_RDONLY
┌────┬────────┐ │
│ 3 │ ──────────┘
└────┴────────┘fork 复制了 fd 表,但没有创建新的打开文件描述。父子两个 fd 3 仍然指向同一个打开文件描述。子进程读取了 5 字节后,父进程的偏移量也会前进 5 字节。
场景三:两次独立 open()——各自独立的偏移量。
进程 A fd 表 打开文件描述 X inode
┌────┬────────┐
│ 3 │ ───────────────→ offset: 1024 ──┐
└────┴────────┘ mode: O_RDONLY ├──→ inode #42
│
进程 B fd 表 打开文件描述 Y │
┌────┬────────┐ │
│ 3 │ ───────────────→ offset: 0 ──┘
└────┴────────┘ mode: O_RDWR两次 open() 创建了两个独立的打开文件描述,它们各自有自己的 offset。进程 A 读取不影响进程 B 的偏移量,因为中间层是两个不同的对象。但两个打开文件描述最终指向同一个 inode,所以它们操作的是磁盘上的同一份数据。
现在开篇提出的问题有了答案。fork 共享偏移量是因为父子进程指向同一个打开文件描述;独立 open 偏移量独立是因为每次 open 都创建了新的打开文件描述。关键在于中间层:共不共享偏移量,取决于 fd 指向的是同一个打开文件描述还是不同的。
下面这段程序可以直接观察到这两种行为的差异:
c
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
/* prepare a test file */
int fd = open("/tmp/test_fd.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
write(fd, "hello, world!\n", 14);
lseek(fd, 0, SEEK_SET);
/* fork: parent and child share the same open file description */
pid_t pid = fork();
if (pid == 0) {
char buf[6] = {0};
read(fd, buf, 5);
printf("[fork] child read: \"%s\"\n", buf);
fflush(stdout);
close(fd);
_exit(0);
}
waitpid(pid, NULL, 0);
printf("[fork] parent offset after child read: %lld\n",
(long long)lseek(fd, 0, SEEK_CUR));
close(fd);
/* two independent open() calls: separate open file descriptions */
int fd1 = open("/tmp/test_fd.txt", O_RDONLY);
int fd2 = open("/tmp/test_fd.txt", O_RDONLY);
char buf[6] = {0};
read(fd1, buf, 5);
printf("[open] fd1 read: \"%s\"\n", buf);
printf("[open] fd1 offset: %lld, fd2 offset: %lld\n",
(long long)lseek(fd1, 0, SEEK_CUR),
(long long)lseek(fd2, 0, SEEK_CUR));
close(fd1);
close(fd2);
unlink("/tmp/test_fd.txt");
return 0;
}bash
$ gcc -o /tmp/fd_sharing fd_sharing.c && /tmp/fd_sharing
[fork] child read: "hello"
[fork] parent offset after child read: 5
[open] fd1 read: "hello"
[open] fd1 offset: 5, fd2 offset: 0fork 场景中,子进程读了 5 字节后父进程的偏移量也变成了 5。独立 open 场景中,通过 fd1 读了 5 字节后 fd2 的偏移量仍然是 0。这就是三层结构在运行时产生的可观测效果。
打开文件描述的引用计数
一个打开文件描述可能被多个 fd 引用(来自 dup 或 fork)。内核为每个打开文件描述维护一个引用计数(reference count):dup 和 fork 都会让引用计数加 1,close 让引用计数减 1。只有当引用计数降到 0 时,内核才会真正销毁这个打开文件描述。
inode 也有类似的引用计数。当最后一个指向某个 inode 的打开文件描述被关闭,并且 inode 的链接计数也为 0(没有目录项指向它)时,内核才会释放 inode 和对应的磁盘空间。这就是为什么一个正在被读写的文件即使被 rm 删除了,进程仍然可以继续操作——目录项没了,但 inode 的引用计数不为 0,数据还在。
目录
目录(directory)是一种特殊的文件,它的内容不是普通数据,而是一组将名字映射到 inode 编号的条目。
前面说文件"有名字",但细看 inode 的结构会发现一个事实:inode 里没有文件名。inode 只存类型、权限、大小、时间戳和数据块位置,唯独不存名字。那文件名存在哪里?答案就是目录。
目录的每一个条目(directory entry)记录了一对映射:(名字, inode 编号)。当我们说"目录下有一个叫 hostname 的文件",实际含义是:这个目录文件中有一条记录,把名字 hostname 映射到某个 inode 编号。用 ls -lai 可以直接看到这层映射关系:
bash
$ ls -lai /etc/
total 484
298609 drwxr-xr-x 1 root root 4096 Apr 7 06:46 .
298877 drwxr-xr-x 1 root root 4096 Apr 7 06:46 ..
...
298919 -rw-r--r-- 1 root root 12 Apr 7 06:46 hostname
298914 -rw-r--r-- 1 root root 171 Apr 7 06:46 hosts
...
243956 lrwxrwxrwx 1 root root 27 Mar 16 00:00 localtime -> /usr/share/zoneinfo/Etc/UTC
...最左边的数字就是 inode 编号。hostname 对应 inode 298919,hosts 对应 inode 298914。目录 /etc/ 自身也是一个 inode(编号 298609),它的文件类型是 d(directory),而不是 -(regular file)。
每个目录中都有两个特殊条目:.(当前目录)指向自己的 inode,..(父目录)指向上一级目录的 inode。根目录 / 的 .. 指向它自己。
目录以树形结构组织。根目录 / 是整棵树的根节点,所有文件和子目录都挂在这棵树上。当内核收到一个路径 /etc/hostname 时,它会从根目录开始,逐个查找路径中的每一个分量:先在 / 的目录条目中找到 etc 对应的 inode 编号,读取那个 inode 确认它是目录,再在 /etc/ 的目录条目中找到 hostname 对应的 inode 编号。这个逐级查找的过程叫做路径名解析(pathname resolution)。
路径 /etc/hostname 的解析过程:
/ (inode 298877)
└── 查目录条目 "etc" → inode 298609
└── 查目录条目 "hostname" → inode 298919
└── 读取 inode 298919 的数据路径名解析是文件系统中最频繁的操作之一。每次 open()、stat()、chdir() 都要执行路径名解析。下一课讲 VFS 时,我们会看到内核怎样用缓存加速这个过程。
链接
硬链接(hard link)是一个指向已有 inode 的新目录条目。
上一节说过,目录条目是 (名字, inode 编号) 的映射。如果两个目录条目映射到同一个 inode 编号,那么这两个名字就指向同一个文件。这就是硬链接的全部含义:不是复制文件,而是给同一个 inode 多加一个名字。
bash
$ echo "hello" > /tmp/original.txt
$ ln /tmp/original.txt /tmp/hardlink.txt
$ ls -lai /tmp/original.txt /tmp/hardlink.txt
298918 -rw-r--r-- 2 root root 6 Apr 7 06:46 /tmp/hardlink.txt
298918 -rw-r--r-- 2 root root 6 Apr 7 06:46 /tmp/original.txt两个名字的 inode 编号完全相同(298918),链接计数从 1 变成了 2。通过任何一个名字修改文件内容,另一个名字看到的也是修改后的结果,因为它们操作的是磁盘上同一份数据。rm 一个名字只是删除一个目录条目并将链接计数减 1,只要链接计数不归零,inode 和数据就不会被回收。
硬链接有两个限制。
不能跨文件系统。 inode 编号是每个文件系统独立分配的,文件系统 A 的 inode 42 和文件系统 B 的 inode 42 是完全不同的文件。如果允许跨文件系统的硬链接,一个 inode 编号就会在不同的文件系统中产生歧义。
不能链接目录。 如果允许目录之间互相硬链接,目录树就可能出现环路。路径名解析在遇到环路时会无限循环下去,内核的遍历算法也会失效。Linux 明确禁止普通用户创建指向目录的硬链接。
为什么 . 和 .. 不会造成环路问题?
. 和 .. 也是指向目录 inode 的硬链接,但它们是文件系统在创建目录时自动维护的,语义固定且行为可预测。路径名解析的代码对这两个名字做了特殊处理:遇到 . 就停在当前目录,遇到 .. 就回到父目录。用户创建的任意目录硬链接则没有这种固定语义,内核无法保证它们不会引入真正的环路。
符号链接(symbolic link, symlink)是一种特殊的文件,它的内容是一个路径名字符串。
符号链接和硬链接的工作方式截然不同。硬链接在目录条目这一层操作,直接映射到 inode 编号。符号链接则是一个独立的文件,有自己的 inode,它的数据只是一段文本——目标文件的路径。当内核在路径名解析过程中遇到一个符号链接时,它会读取这段路径文本,然后用它替换当前路径继续解析。
bash
$ ln -s /tmp/original.txt /tmp/symlink.txt
$ ls -lai /tmp/original.txt /tmp/symlink.txt
298918 -rw-r--r-- 2 root root 6 Apr 7 06:46 /tmp/original.txt
298926 lrwxrwxrwx 1 root root 17 Apr 7 06:46 /tmp/symlink.txt -> /tmp/original.txt符号链接有自己的 inode(298926),和目标文件的 inode(298918)不同。文件类型是 l,大小是 17——刚好是路径字符串 /tmp/original.txt 的长度。目标文件的链接计数仍然是 2(来自之前创建的硬链接),符号链接不会增加目标文件的链接计数。
符号链接没有硬链接的那两个限制:它可以跨文件系统(因为它存的是路径字符串,不是 inode 编号),也可以指向目录。但它有自己的弱点:如果目标文件被删除或移动了,符号链接指向的路径就不再有效,变成一个悬空链接(dangling symlink)。硬链接不会出现这个问题,因为只要链接计数不归零,inode 和数据就一直存在。
| 硬链接 | 符号链接 | |
|---|---|---|
| 本质 | 指向同一个 inode 的目录条目 | 内容为路径名的特殊文件 |
| 跨文件系统 | 不可以 | 可以 |
| 指向目录 | 不可以(. 和 .. 除外) | 可以 |
| 目标被删除 | 其他链接仍可正常访问 | 变成悬空链接 |
| 增加链接计数 | 是 | 否 |
挂载点
挂载(mount)是将一个文件系统的根目录覆盖到已有目录树中某个目录上的操作,使得访问那个目录时实际进入的是新文件系统的内容。
一台 Linux 机器上通常同时存在多个文件系统。根分区可能是 ext4,/boot 可能是单独的分区,/tmp 可能是内存文件系统 tmpfs,/proc 和 /sys 是内核导出信息的虚拟文件系统。这些文件系统各自独立,有各自的 inode 编号空间和磁盘布局。但从用户的视角看,它们全部出现在同一棵以 / 为根的目录树里。把多个独立的文件系统拼接成一棵统一的目录树,靠的就是挂载。
findmnt 可以展示当前系统的挂载层次:
bash
$ findmnt --real
TARGET SOURCE FSTYPE OPTIONS
/ /dev/sda2 ext4 rw,relatime
├─/boot /dev/sda1 ext4 rw,relatime
└─/home /dev/sda3 ext4 rw,relatime
$ findmnt --pseudo | head -4
TARGET SOURCE FSTYPE OPTIONS
/proc proc proc rw,nosuid,nodev,noexec,relatime
/sys sysfs sysfs rw,nosuid,nodev,noexec,relatime
/dev udev devtmpfs rw,nosuid,relatime--real 显示的是真实块设备上的文件系统,--pseudo 显示的是没有后备块设备的虚拟文件系统。它们在目录树中的地位完全平等:/proc/cpuinfo 和 /etc/hostname 用同一套 open/read/close 操作,应用程序不需要关心背后是真实磁盘还是内核动态生成的数据。
挂载操作具体做了什么?假设 /dev/sda3 是一个 ext4 分区,执行 mount /dev/sda3 /home 后,原来 /home 这个目录条目不再指向根文件系统中 /home 目录的内容,而是指向 /dev/sda3 上 ext4 文件系统的根目录。路径名解析走到 /home 时会发现这里有一个挂载点,于是跨入新文件系统继续向下查找。原来 /home 目录中的内容并没有被删除,只是被"遮住"了——卸载(umount)之后还能看到。
挂载也解释了前面硬链接"不能跨文件系统"这条限制的物理原因。每个文件系统有自己独立的 inode 编号空间,而挂载只是把多棵独立的树拼接到一棵目录树上,并没有合并它们的 inode 编号。inode 42 在 / 分区和 /home 分区上是完全不同的文件。一个跨文件系统的硬链接——比如 / 上的目录条目指向 /home 分区的 inode 编号——在路径名解析时会被错误地解释为本分区的 inode,指向一个完全无关的文件,甚至指向一个不存在的 inode。
小结
| 概念 | 说明 |
|---|---|
| 文件(file) | 操作系统对持久存储数据的抽象,由数据和元数据组成 |
| 元数据(metadata) | 文件的类型、权限、大小、时间戳、链接计数等属性 |
| 打开文件的三层结构 | fd 表 → 打开文件描述 → inode,三层解耦使 dup/fork/独立 open 各自获得正确的共享语义 |
| 打开文件描述(open file description) | 每次 open() 创建一个,记录偏移量和访问模式,内核中为 struct file |
| inode | 文件在文件系统中的唯一标识,存放元数据和数据块位置信息,一个文件只有一个 inode |
| 目录(directory) | 存储 (名字, inode 编号) 映射的特殊文件 |
| 路径名解析(pathname resolution) | 从根目录开始逐级查找目录条目,将路径转换为最终 inode |
| 硬链接(hard link) | 指向同一个 inode 的另一个目录条目,不能跨文件系统,不能链接目录 |
| 符号链接(symbolic link) | 内容为路径名的特殊文件,可跨文件系统,可指向目录,但可能悬空 |
| 挂载(mount) | 将一个文件系统的根目录覆盖到目录树的某个节点上 |
fd 只是一个整数下标,真正决定行为的是它背后的三层结构。打开文件描述记录了"这次打开"的运行时状态,inode 记录了"这个文件"的持久身份。理解了这三层,dup 共享偏移量、fork 共享偏移量、独立 open 偏移量独立这三种行为就不再是需要死记的规则,而是同一套数据结构的自然推论。
Linux 源码入口:
fs/open.c—do_sys_open(),open 系统调用的核心实现fs/read_write.c—vfs_read()/vfs_write(),读写路径include/linux/fs.h—struct file(打开文件描述)和struct inode的定义fs/namei.c—path_lookupat(),路径名解析fs/namespace.c—do_mount(),挂载操作
命名容易引起混淆:POSIX 标准把这一层叫做 "open file description",而 Linux 内核源码中对应的结构体叫
struct file。struct file不是"文件"本身(那是struct inode),而是"一次 open 操作所产生的运行时状态"。本书统一使用 POSIX 术语"打开文件描述"来指代这一层,提到内核数据结构时使用struct file。 ↩︎