Skip to content

文件与目录

  • 写作时间: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: 0

fork 场景中,子进程读了 5 字节后父进程的偏移量也变成了 5。独立 open 场景中,通过 fd1 读了 5 字节后 fd2 的偏移量仍然是 0。这就是三层结构在运行时产生的可观测效果。

打开文件描述的引用计数

一个打开文件描述可能被多个 fd 引用(来自 dup 或 fork)。内核为每个打开文件描述维护一个引用计数(reference count):dupfork 都会让引用计数加 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)之后还能看到。

虚拟文件系统

/proc/systmpfs 等虚拟文件系统没有后备磁盘,它们的数据完全由内核在内存中生成。/proc 导出进程信息和内核参数(比如 /proc/cpuinfo/proc/<pid>/maps),/sys 导出设备和驱动信息,tmpfs 提供一块临时的内存存储。这些虚拟文件系统和真实的磁盘文件系统实现了相同的接口,所以可以用相同的系统调用来操作。这种"一切皆文件"的统一接口正是 Unix 设计哲学的核心体现。

挂载也解释了前面硬链接"不能跨文件系统"这条限制的物理原因。每个文件系统有自己独立的 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 源码入口


  1. 命名容易引起混淆:POSIX 标准把这一层叫做 "open file description",而 Linux 内核源码中对应的结构体叫 struct filestruct file 不是"文件"本身(那是 struct inode),而是"一次 open 操作所产生的运行时状态"。本书统一使用 POSIX 术语"打开文件描述"来指代这一层,提到内核数据结构时使用 struct file↩︎