TheUnknownBlog

Back

前言

我们终于来到了 Linux 内核的”文件系统”部分了。这个章节非常重要,因为它是 Linux 内核里最庞大、最复杂、也是最核心的子系统之一。理解了 VFS,你就真正理解了 Linux 的”万物皆文件接口”是怎么实现的。

如果你还没有阅读上一章节,建议先阅读 Linux Kernel (Part 4) - Memory Management

Disclaimer

我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色,本文的 first draft 肯定是 AI 告诉我的内容。如果你对此感到无法接受,或者觉得 AI 讲得不够好,你可以随时退出阅读,或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系,而不是替代你自己去接触原始资料。

如果你能接受这个前提,那么我们就继续往下走了。

为什么 Linux 一定要有 VFS

VFS = Virtual Filesystem Switch。 它不是一个具体的文件系统,而是 Linux 为整个”文件世界”所构建的统一抽象层

Linux 上同时存在着大量截然不同的文件系统实现:ext4、xfs、btrfs、tmpfs、procfs、sysfs、NFS……它们的底层数据结构和存储方式各有不同。但用户态程序从来不需要为此操心——不论底层是哪种文件系统,你都可以统一地写:

open()
read()
write()
close()
mmap()
c

这件事之所以成立,就是因为有 VFS。 如果没有 VFS,会怎样?那每种文件系统都得自己定义一套完整的接口:打开文件的方法、查找路径的方法、读写的方法、权限语义、目录操作……用户态和内核其它子系统都会被文件系统的实现细节死死耦合住,整个系统将变得不可维护。

VFS 的解法非常清晰:先把”文件、目录、路径、打开实例”这些概念统一出来,形成一套标准的对象模型和接口;具体文件系统再去实现这些统一抽象背后的操作。

和调度器一样,VFS 大量依赖 ops table:

  • inode_operations
  • file_operations
  • address_space_operations
  • super_block_operations

你不需要把它们全记住,只需抓住核心思想:VFS 先定义统一的对象和操作接口,具体文件系统去实现这些接口。 这意味着 ext4、xfs、tmpfs 都可以挂在同一套 VFS 框架下,用户态不需要为每种文件系统学一套新的系统调用。这就是 Linux 的”万物皆文件接口”能够成立的关键工程基础。

VFS 统一的不只是名字解析,还统一了一部分”文件内容访问模型”。如果你仔细看过之前讲的内存管理,应该知道 read()mmap(file) 常常在 page cache 汇合。现在把它放回 VFS 的视角:

inode / file-backed object
  -> address_space
  -> address_space 管理 page cache
  -> VFS 和具体 fs 通过这层衔接数据访问
plaintext

所以文件系统这一章不会离开你刚学的 mm,反而会不断地把 inode、file、page cache、mmap、writeback 绑在一起。

文件系统主线最关键的三个对象

学习 VFS,第一步也是最重要的一步,就是把下面三个对象彻底分清:

  • inode:文件本体
  • dentry:名字和路径关系
  • file:一次打开实例

这三者是 VFS 的核心骨架。

inode

inode 表示”文件对象本身”。它关注的是文件最本质的属性:

  • 文件类型(普通文件、目录、设备、symlink……)
  • 权限
  • 大小
  • 时间戳
  • 数据块映射关系
  • inode 编号
  • 文件系统相关的元数据

关键点在于:inode 不关心你是通过哪个路径名找到它的。 一个文件可以有多个 hard link,也就是多个名字指向同一个 inode。这意味着”名字”不是文件本体——inode 才是文件的核心抽象

dentry

dentry 全称 directory entry,它表示的是”某个目录下面的某个名字”。所以 dentry 关心的主要是三件事:

  • 这个名字叫什么
  • 它在哪个父目录下面
  • 它对应的是哪个 inode

这就回答了路径名世界的问题。比如 /tmp/a.txt 不是一个单一对象,而是一层层目录项解析出来的:

/ -> tmp -> a.txt
plaintext

每走一步名字解析,都会碰到 dentry。因此,如果要用一句话区分两者:inode 是”文件是什么”,dentry 是”这个名字指向什么”。 这两个概念必须分清,否则后面所有关于路径解析和文件打开的理解都会出问题。

file

file 表示一次打开文件得到的”打开实例”。这是三个对象中最容易被误解的。很多人会觉得”文件就是文件”,open() 之后得到的 fd 就是文件对象了。其实不是。fd 事实上只是一个整数索引,指向当前进程文件描述符表里的一个 struct file 指针;而 struct file 是一个独立的内核对象,代表了这次打开的状态。

考虑下面的代码:

int fd1 = open("a.txt", O_RDONLY);
int fd2 = open("a.txt", O_RDONLY);
c

虽然路径名相同、底层 inode 也相同,但通常会产生两个不同的 struct file。因为每次打开都有自己独立的状态:

  • 当前文件偏移 f_pos
  • 打开标志
  • 指向哪套 file_operations
  • 一些运行期上下文

所以这三层的关系是:

  • inode:文件本体
  • file:这次打开所得到的句柄对象
  • fd:进程文件表中的整数索引,指向 file

我在之前(大概是 Part 1 还是 Part 3 中?)提到过 task_struct 的构成,其中 files_struct,现在就能把它接回来了——files_struct 中的 fd table 里存的就是指向 struct file 的指针。

拼成一张图

理解文件操作,应该始终用这张图:

pathname -> dentry -> inode
open()   -> file
fd table -> fd -> file
plaintext

更精确地说:路径解析主要在 dentry / inode 世界里发生; open() 成功后,才得到 file. 进程里真正拿着的是 fd, fd 再指向 file. 所以 pathname lookup、open file instance、file descriptor table——这是三层不同的概念。

在这里,我也要提一个非常容易混淆的点:fd 不是文件. 这一点必须特别清楚。fd 只是当前进程文件描述符表里的一个整数编号——比如 0 是 stdin、1 是 stdout、2 是 stderr。它本身不包含”文件内容”,只是一个查表入口。

task -> files_struct -> fd table -> struct file -> dentry/inode
plaintext

所以当你在用户态写下 read(fd, buf, n) 时,真正的底层含义是:“拿当前 task 的 fd table 里的这个打开实例,从它背后的文件对象读数据。” 这就把 task、files、VFS 三者连在了一起。

Pathname Lookup

路径是逐段解析

路径不是”一个字符串直接指向文件”,而是一个逐段解析的过程。 比如 /a/b/c,内核不会把它当成一个整体黑盒。它会像这样一步步走:

/ -> a -> b -> c
plaintext

每走一步,都会涉及:

  • 当前目录是谁
  • 当前名字是什么
  • 这个名字对应哪个 dentry / inode
  • 有没有 mount 点需要切换
  • 有没有符号链接需要重写路径
  • 权限是否允许

路径解析的起点

起点取决于路径类型:

如果是绝对路径(如 /etc/passwd):从当前进程看到的根目录开始。注意是”当前进程看到的根目录”——当我们后面讲到 mount namespace 时,你就会明白为什么这个限定词很重要。

如果是相对路径(如 tmp/a.txt):从当前工作目录 cwd 开始。这就和 fs_struct 接上了——fs_struct 里就包括 cwd 和 root 这些”路径视角状态”。

所以 pathname lookup 不是纯字符串解析,它还依赖当前 task 的路径视角上下文。

每一段到底查什么

假设要解析 /home/user/test.txt,流程大致是:

  1. 从起点目录开始
  2. 查找名字 home,得到对应的 dentry / inode
  3. 确认它是目录,进入
  4. 再查名字 user,重复相同过程
  5. 最后查 test.txt

每一步都像在做:

(当前目录 inode, 名字) -> 下一个 dentry -> 下一个 inode
plaintext

这就是 dentry 在 pathname lookup 中真正的意义:目录世界里,名字解析的中间对象。

为什么需要 dentry cache

如果每次解析路径都要去磁盘或底层文件系统查目录项,代价会非常高。所以 Linux 会缓存大量 pathname lookup 的结果——这就是 dentry cache

它缓存的是目录项的名字关系和名字到 inode 的解析结果。所以当你多次访问 /usr/lib/libc.so 时,后续的 lookup 不一定每次都完整地走底层文件系统,而是尽量命中 dentry cache。这使得路径解析可以非常快。

你可以把 dentry cache 理解成”路径世界的高速缓存”。 它和你已经学过的其他缓存形成了一个非常漂亮的平行:

缓存类型缓存什么
dcache名字解析结果
page cache文件内容页
TLB地址翻译条目

这三个缓存各自面向不同的层次,但设计思想是相通的。

dentry 和 inode 在 lookup 中怎么配合

我们需要区分 dentryinode 分别的作用:

  • dentry 解决的是:“这个目录下面有没有这个名字?”
  • inode 解决的是:“这个名字指向的对象本体是什么?”

所以 lookup 的逻辑是在父目录里找名字 -> 得到 dentry -> dentry 指向 inode -> inode 告诉你它是文件、目录、symlink 还是别的对象。 如果 inode 表示的是目录,那就可以继续向下走;如果最终 inode 表示的是普通文件,那 lookup 成功结束。

...

它们也是路径解析的一部分——. 表示当前目录,.. 表示父目录。只不过内核在 lookup 过程中对这些名字有专门的语义处理。这说明 pathname lookup 不是简单的哈希字符串,而是有路径语义规则的。lookup 是”有状态的路径遍历”,不是纯粹的字符串到 inode 的哈希表查询。

符号链接

符号链接(symlink)的本质是:一个文件,其内容是另一个路径字符串。 所以如果解析过程中遇到 symlink,内核可能需要:

  1. 停下当前的路径解析
  2. 取出 symlink 指向的新路径
  3. 把它拼回剩余路径
  4. 继续重新走 lookup

这意味着 pathname lookup 不总是”单纯在树上往下走”,还可能出现路径替换、重新起点、以及递归/循环保护。symlink 让路径解析从”目录树遍历”变成了”树遍历 + 路径重写”。

mount point

在路径解析过程中,某个目录位置不一定真的”继续在同一个文件系统里往下走”——它可能是一个 mount point。 举个具体例子。假设一开始 root 文件系统是 ext4,里面有:

/
└── mnt
    └── data
plaintext

此时 /mnt/data 只是 ext4 上的一个普通目录。然后你执行:

mount /dev/sdb1 /mnt/data
bash

这件事做完之后,路径世界发生了变化:

/mnt/data 这个”位置”还在。但这个位置下面显示出来的内容,不再是原 ext4 里那个 data 目录的原本内容,而是 /dev/sdb1 这个文件系统的根目录内容。

所以 /mnt/data/foo.txt 现在实际上是在访问 /dev/sdb1 文件系统根下的 foo.txt,而不是原来 ext4 里的 data/foo.txt

原来的 /mnt/data 目录并没有消失,只是被覆盖/遮住了。 只要 mount 在,你通过 /mnt/data/... 看到的就是新挂上来的那棵树,原来底下那棵树暂时”藏在后面”。这也是为什么 umount 之后一切又恢复原样——内核本来就没有修改过原始的 dentry 或磁盘数据,mount 本质上是在内存的 VFS 层做了一次路径解析的重定向。

所以 pathname lookup 里,每走一步除了查名字,还要检查当前位置是不是 mount point。如果是,就切到另一个 superblock 的根目录继续走。对于 mount,我们稍后还会提到,如果你对“底下那棵树暂时藏在后面”这个表述还感到困惑,可以先放一放。

小小总结

这张图非常重要。因为以后 openstatunlinkrenamemkdirexecve——全都要先经历某种形式的 pathname lookup。

task
  -> fs_struct (cwd/root)
  -> pathname string
  -> start from root or cwd
  -> for each path component:
       lookup dentry in current directory
       get inode
       if directory: continue
       if symlink: rewrite path / continue
       if mountpoint: switch filesystem tree
  -> final dentry/inode
plaintext

open() 到底干了什么

open() 不是”读文件”

先纠正一个常见的直觉误区:open() 做的不是”把文件内容读进来”,而是”根据一个路径,创建一个打开实例,并把它挂到当前进程的 fd 表里”。

很多人会下意识以为 open() = “把文件打开到内存里”。其实不是。open() 的核心工作是以下这四点:

  1. 路径解析
  2. 权限检查
  3. 创建 struct file
  4. 分配 fd

真正读内容,通常要等 read()mmap() 等后续操作。

第一步:pathname lookup

当你执行:

int fd = open("/tmp/a.txt", O_RDONLY);
c

内核第一件事不是”找个整数给你”,而是先去解析路径 /tmp/a.txt——从 root 开始,走 tmp,再走 a.txt,得到最终的 dentry / inode。open() 的前半段其实就是在走 pathname lookup。

第二步:权限与语义检查

路径找到了,不代表一定能成功 open。内核还要检查:

  • 目标对象是否存在
  • 是普通文件、目录、设备、symlink 还是其他类型
  • 当前进程有没有权限
  • 打开标志是否合法
  • 如果带了 O_CREAT,是否需要创建新 inode
  • 如果带了 O_TRUNC,是否允许截断
  • 如果带了 O_DIRECTORY,目标是不是真目录

open() 是路径语义 + 权限语义 + 打开语义,三者共同决定结果。

第三步:创建 struct file

这一步是 open() 真正最关键的动作。路径解析最终给你的是 dentry 和 inode,但进程真正要拿来做后续 read / write / lseek / mmap / close 的不是 inode 本身,而是 struct file——“打开实例”

这个 file 对象里会存放:

  • 打开标志
  • 当前文件偏移 f_pos
  • 指向目标 dentry / path / inode
  • 指向哪套 file_operations
  • 一些运行时状态

open() 成功,本质上是创建了一个新的 file 实例。 你可以把 inode 理解为文件本体,file 理解为”这次会话对象”。

为什么同一个文件多次 open() 会得到不同 file

因为每次打开都应该有自己的一份”打开状态”:

fd1 = open("a.txt", O_RDONLY);
fd2 = open("a.txt", O_RDONLY);
c

这通常意味着:同一个 inode、可能同一路径 dentry,但两个不同的 struct file。因为 fd1 的 offset 可以走到 100,fd2 仍然可以停在 0。file 必须是”打开实例”,而非文件本体。一旦想清楚这一点,很多 Unix API 的语义都会变得自然。

第四步:分配 fd

现在终于轮到 fd 了。当前 task 的 files_struct 里有一张 fd table,内核会找一个空闲 fd 编号,把 fd -> struct file * 的关系填进去,然后把整数 fd 返回给用户态。

所以 fd 本质上就是:当前进程文件描述符表里,对某个打开实例的索引。以后你看到 read(fd, ...),内核并不是”拿着这个数字直接找文件”,而是先从当前 task 的 fd table 里把 file * 找出来,再往后走。

dup() 为什么是一个很有代表性的例子

dup() 能非常好地帮你区分”fd != file”。当你调用 fd2 = dup(fd1) 时,通常不会新建一个 struct file,而是新建一个 fd table entry,让 fd2 也指向同一个 struct file

所以 fd1fd2 共享同一个打开实例——共享 offset、共享打开状态。多个 fd 可以指向同一个 file,这个区别非常重要。

fork() 为什么会把文件语义带进来

fork() 后,子进程通常继承父进程的 fd table 语义。很多情况下,父子会持有指向同一批 struct file 的引用,这意味着某些 fd 是共享打开实例的,offset 也可能联动。

这就是为什么 shell 重定向、pipe、父子进程文件共享语义会表现得那么自然——因为底层就是多个进程/线程指向同一个打开实例。

open() 和 create 的关系

如果 open() 带了 O_CREAT 标志,它可能还要额外做一件事:如果路径的最后一项不存在,就在父目录里创建一个新的目录项和 inode。所以 open() 实际上有两种主要模式:

仅打开已有对象: lookup → 检查 → 创建 file → 分配 fd

打开时创建: 先找到父目录 → 创建新 dentry / inode → 再创建 file → 再分配 fd

open() 常常不只是”打开”,而是路径语义的一大入口。

open() 成功不等于文件内容已在 page cache 里

这个点必须钉死。open() 完成时,意味着你已经有了 file 对象、可以后续操作。但它意味着文件数据已经读进内存、page cache 已经准备好、甚至不意味着将来的 read() 不会阻塞。

真正的内容访问通常要等 read()write()mmap()、readahead 或其他后续动作。open() 主要是名字和对象语义,不是内容搬运语义。

对于 open(),我们把途经的步骤画成一张图:

open(path, flags)
  -> pathname lookup
     -> dentry / inode
  -> permission + semantic checks
  -> maybe create / truncate / etc
  -> allocate struct file
  -> install into current task's fd table
  -> return integer fd
plaintext

Dentry Cache vs. Inode Cache

理解了 dentry cache 加速路径解析之后,一个自然的问题是:为什么还需要 inode cache?有 dentry cache 不就够了吗?我存这个 inode 到底有什么用?

答案是不够。因为这两层缓存解决的是完全不同的问题。

dentry cache 解决什么

它解决的是:“这个目录下面有没有这个名字?这个名字指向谁?”

比如解析 /usr/bin/python,你会反复查 / 下有没有 usr/usr 下有没有 bin/usr/bin 下有没有 pythondentry cache 加速的是 pathname lookup——名字解析。

inode cache 解决什么

inode cache 缓存的是”文件对象本体的 in-core 表示”——文件类型、权限、大小、时间戳、block mapping、i_op / f_opaddress_space、引用计数、锁、状态位等等。这些东西不是 dentry 能替代的。

即使你已经知道名字 a.txt 对应 inode number 12345,你后面仍然需要这个 inode 对象本身在内存里的结构体——文件大小是多少?权限是什么?它对应哪套 inode / file operations?page cache 挂在哪个 address_space 上?dentry 只是告诉你”去找哪个 inode”,并不能代替 inode 本体。

很多操作根本不经过 pathname lookup

这是一个非常重要的观察:

read(fd, buf, n);
fstat(fd, ...);
mmap(fd, ...);
write(fd, ...);
c

这些操作拿到的链条是 fd -> file -> inode,它们根本不会重新走路径解析。也就是说,这些场景下 dentry cache 的作用已经很弱了,但 inode 仍然是核心对象。如果没有 inode cache,每次 read()write()mmap() 都可能需要重新把 inode 元数据从底层文件系统或磁盘读一遍——这显然不可接受。

多个 dentry 可以指向同一个 inode

Hard link 的存在意味着同一个 inode 可以有多个目录项名字。如果只有 dentry cache 没有 inode cache,名字层的缓存可能大量重复,而文件本体的状态却没有统一的对象来承载。inode cache 正好解决了这个问题:把”同一个文件本体”统一成一个内存对象。

三层缓存体系

把所有缓存放在一起看,VFS 的缓存体系是这样的:

缓存缓存什么偏向
dentry cache名字解析结果名字
inode cache文件本体元数据对象对象
page cache文件内容页内容

名字、对象、内容——三层分离,各司其职。 Linux VFS 的缓存的优美设计非常值得学习。

在理解了 inode 和 dentry 的区别之后,hard link 和 soft link 的差异就变得非常清晰了。

ln a.txt b.txt
bash

这意味着 a.txtb.txt 是两个不同的目录项名字(不同 dentry),但它们指向同一个 inode。文件内容是同一份,inode 编号相同,link count 增加。修改 a.txt 就等于修改 b.txt,因为从内核视角看,它们根本就是同一个文件本体,只是有两个名字。

所以 hard link 更像是:给同一个 inode 多挂一个名字。

但是他也有约束条件:一般不能跨文件系统(因为 inode number 是 per-filesystem 的);通常不能对目录做 hard link(为了避免目录图变成难以处理的一般图)。

ln -s a.txt b.txt
bash

这时 b.txt 本身是一个新的 inode,但这个 inode 的类型是 symlink,它的数据内容不是普通文件数据,而是 "a.txt" 这条路径字符串。

所以访问 b.txt 时,pathname lookup 会看到这是个 symlink,取出里面的路径字符串,再按那个路径继续解析。soft link 更像一个”路径跳转器”文件。

soft link 不共享 inode,可以跨文件系统,可以指向目录,但可以变成 dangling link(目标路径被删除后,symlink 自己还在,但解析失败)。而 hard link 永远不会”悬空”——因为它根本不是”指向另一个名字”,而是直接就是那个 inode 的另一个名字。

read() / write() / mmap()

共同的起点

这三种最核心的文件内容访问方式,都从同一个起点出发:

当前 task -> files_struct -> fd table -> struct file
plaintext

用户态传进来的 read(fd, ...)write(fd, ...)mmap(fd, ...) 第一步都不是”直接操作磁盘”,而是先查 fd table、找到 struct file、再沿着 file -> dentry -> inode 走到对象本体,然后进入 VFS 和具体 fs 的内容访问路径。

三者的共同起点是”一个打开实例 struct file”。

read():copy model

read(fd, buf, n) 的典型路径:

  1. fd -> file
  2. VFS 层调用该对象的 read 逻辑
  3. 先看 page cache——如果命中,直接从缓存读
  4. 如果 page cache miss,发 I/O,把文件页装入 page cache
  5. 从 page cache 把数据 copy_to_user 到用户 buffer
  6. 更新 file->f_pos

read() 的本质是:“从文件对象中取数据,并复制到用户提供的缓冲区。” 关键特点:经过 page cache,经过一次 copy_to_user,与 file->f_pos 强相关。

write():先缓存,后落盘

write(fd, buf, n) 看起来像”把数据写进文件”,但从内核视角更精确地说:先把用户数据写进内核管理的文件页/缓存层,再由 writeback 机制决定何时真正落到磁盘。

典型主线:

  1. fd -> file
  2. VFS / fs 路径
  3. 把用户 buffer 数据 copy_from_user 进对应文件页(通常就是 page cache 页)
  4. 把页标成 dirty
  5. 更新 inode 大小/时间戳等元数据
  6. 更新 file->f_pos
  7. 将来某个时机再 writeback 到磁盘

所以 write() 返回成功,常常只表示”数据已经进入内核可控的缓存/页层”,不一定表示”磁盘此刻已经写完”。 这就是为什么脏页、writeback、fsync() 这些概念会变得重要。

mmap():mapping model

mmap(fd, ...) 的语义和 read / write 完全不同——它不直接搬内容:

  1. fd -> file
  2. 在当前 mm 里创建 file-backed VMA
  3. 记录映射关系:这段虚拟地址对应哪个文件、从哪个文件 offset 开始、映射权限是什么
  4. 通常并不立刻把每页都建立好 PTE
  5. 后续访问时,通过 page fault 把 page cache 页映射进来

mmap() 的本质不是”帮你读文件内容”,而是”把文件对象的一段内容,变成你地址空间中的一段映射规则”。

统一图

现在用一张图来统一理解:

fd -> struct file -> inode / address_space / page cache

read():
    page cache -> copy_to_user(buf)

write():
    copy_from_user(buf) -> page cache (dirty) -> later writeback

mmap():
    file-backed VMA -> page fault -> page cache page mapped into user page table
plaintext

这张图是 VFS、mm、page cache 三者交汇处的总图。

f_pos 为什么重要

f_posstruct file 作为”打开实例”的最好例子之一。read() / write() 常常依赖 file->f_pos——当前读写偏移是打开实例状态的一部分,不是 inode 属性,也不是路径属性。

这就解释了为什么两次独立 open() 得到两个 file、它们偏移互不影响;而 dup() 出来的两个 fd 指向同一个 file,偏移会联动。

mmap() 则不同——映射一旦建立,访问发生在用户虚拟地址 + 页表 + page fault 的世界里,不需要每次都拿 f_pos 去推进。read / write 更像”流式 I/O”,mmap 更像”把文件内容投影进地址空间”。

mmap() 这么好,为什么不直接用 mmap()?

既然 mmap() 能做到零拷贝(免去了内核态到用户态的复制数据)并且消除了系统调用开销,在处理大数据时甚至自带原生的随机访问能力,那为什么我们不干脆抛弃传统的 read()write()

答案是:mmap 不是在所有场景下都碾压常规 I/O,它是一把双刃剑。

如果你无脑使用 mmap,可能会遇到下面这些问题:

  1. mmap 刚调用成功时只是分配了虚拟地址,并没有真正把文件读进内存。只有当你真正访问它时,CPU 才会触发 Page Fault 陷入内核去拉数据。如果你的访问极其离散,频繁的缺页中断开销可能会抹平零拷贝带来的收益。而传统的 read() 配合操作系统的预读(Read-ahead)机制,在严格顺序读取时,顺滑程度往往比不断触发 Page Fault 的 mmap 还要快。
  2. 如果你 mmap 了一个文件,在读取过程中另一个进程把这个文件截断(Truncate)变小了,当你访问超出新文件结尾的内存时,内核不会像 read 那样优雅地返回 EOF 或 -1,而是直接给你发一个 SIGBUS 信号,导致程序瞬间崩溃。
  3. mmap 分配的地址空间大小是固定的,它不能自动扩容。如果你想往文件末尾追加数据,你不能直接接着内存地址往下写。

那如果我用 write() 来扩容文件,用 mmap() 来随机读,是不是就完美了?

直觉上很美,但实际上这套“组合拳”会引入极大的复杂度和“心智负担”:

  • 因为映射的地址空间是固定的,write 虽然扩大了底层物理文件的大小,但它无法自动扩大你已经建立好的“内存窗口”。试图越界访问就会 Segmentation Fault。
  • 要想让 mmap 窗口读到新数据,你必须先把旧地址 munmap 掉,再重新 mmap 或者使用 mremap。这涉及修改内核页表,是一个非常昂贵的系统调用。
  • 更致命的是,重新映射可能会导致文件的起始地址改变。这意味着你代码里所有指向这块内存的 C++ 指针、std::string_view 或迭代器会瞬间全部失效。在多线程环境下,这简直是灾难。

如果你追求极致的随机读性能(比如 RocksDB 读索引文件),mmap 确实是神器。但在需要频繁更新和引发扩容的场景下,成熟的系统通常有这几种解法:

  1. 预分配:不要写几百字节就扩容一次。通常的做法是,文件不够用时,用 ftruncate 一次性给文件增加大块空间(如 128MB 或 1GB),然后 mremap,以此大幅减少重映射的次数。
  2. 只读 MMap + O_DIRECT Write:一些数据库(如 LMDB)读操作全部通过 mmap 指针进行,极度高效;而写操作则绕过 Page Cache 自己控制落盘。这需要应用层维护一个非常复杂的“元数据页面”来告诉读线程当前的有效边界在哪里。
  3. 老老实实手写 Buffer Pool:这也是为什么大多数成熟的关系型数据库(如 PostgreSQL, MySQL/InnoDB)不迷信 mmap 的主要原因。虽然自己管理内存缓冲区很累,但彻底避开了内核页表映射的坑和 SIGBUS 风险,同时也获得了绝对的话语权:可以精确控制脏页什么时候落盘(fsync),而不是看 OS 的心情。

Mount、Superblock 与 Filesystem Type

“文件系统类型”告诉你这是什么品种;“superblock”表示一次具体挂载出来的文件系统实例;“mount”表示把这个实例接到路径树的某个位置上。有了这个大局观,我们接下来看具体的结构。

file_system_type

这是”类型”层。ext4、xfs、btrfs、tmpfs、proc、sysfs——它回答的是”这是什么文件系统类型?“你可以把它理解成一种”驱动/实现类”。

如果你一直只接触 ext4 或 Windows 的 NTFS,可能觉得文件系统等同于“用于给硬盘存文件格式”。但在万物皆文件的 Unix 哲学下,文件系统早已演化出了三头六臂:

  1. 物理磁盘文件系统 比如 xfs 被设计为一种极度成熟、高性能的 64 位日志文件系统。它尤其擅长应对极大容量(如 PB 级)系统,对高并发和大规模并行 I/O 极其友好,以至于现在许多企业级发行版都默认选择了 xfs
  2. 内存虚拟文件系统 tmpfs 不写入硬盘,只占用 RAM 或 Swap,读取速度奇快。但它具有易失性,一断电东西全灰飞烟灭。很多 Linux 系统会把 /tmp 或进程共享内存挂载为 tmpfs 来追求极致临时数据访问性能。
  3. 内核监视器/接口伪文件系统 它们挂载在 /proc/sys,本质上它们占用大小是 0 字节,在磁盘上并不存在。每当有进程去读取里面的文件,内核就实时捕获当前状态吐出来。proc 面向进程与系统指标信息,而 sysfs 则为了解决 /proc 过度膨胀,被专门整理出来展现内核极其复杂的“硬件设备树和驱动参数”。
  4. 路由器上的联合挂载 (OverlayFS) 在嵌入式系统或 Docker 中常常大放异彩的 OverlayFS,被称为 Union Mount(联合挂载)。 它犹如把底下一张不能涂改的白纸(Lowerdir,只读固件压缩文件系统)和上面一张透明塑料纸(Upperdir,可写区域)叠压在一起让你看。如果是读取,看到哪一层最新就展示什么;如果是修改文件,内核把白纸上的旧文件先 Copy 一份到透明薄膜层上给你改(Copy-up);要删除底层文件,就建立一个白点(Whiteout)来把它遮盖掉。如此既保证了底层骨架的不可破坏(一键恢复出厂设置的原理),又实现了上层高度的写自由。

这就凸显出 VFS 模型抽象层最精妙的能力:制定协议,让全世界万物,甚至内存、设备和硬件信号,都可以披上“文件”的外衣被我们优雅访问。

super_block

这是”实例”层。同样都是 ext4,你可以同时挂两个分区:/dev/sda1//dev/sdb1/data。它们类型都叫 ext4,但显然不是同一个文件系统实例。内核里,每个挂起来的文件系统实例,都会有一个 struct super_block,它大致包括:

  • 这个文件系统实例的全局元数据
  • 根 inode
  • block size
  • superblock ops
  • 设备/后端信息
  • 与这个实例关联的全局状态

历史上磁盘文件系统里就有”superblock”这个概念——存储整个文件系统的全局元数据。但在 VFS 中,不是所有文件系统都必须有真实的磁盘 superblock:procfs、tmpfs、sysfs 也都有 VFS 的 super_block 对象,因为它们也需要一个”文件系统实例级”的总管对象。所以更准确地理解,super_block 就是 VFS 中的 filesystem-instance object,而不只是”磁盘 superblock 的镜像”。

mount

即使你已经有了一个文件系统实例(superblock),它还得被接到当前路径树的某个点上,用户才能通过路径走到它。

mount /dev/sdb1 /mnt/data
bash

这件事的本质是:有一个文件系统实例(superblock),把它的根目录接到当前路径树中的 /mnt/data 这个位置。mount 回答的是:“这个文件系统实例现在挂在路径树的哪里?“

一个具体例子

假设你有一个 root ext4(在 /dev/sda1)、一个 data ext4(在 /dev/sdb1)、一个 procfs(在 /proc)。内核看到的大致是:

file_system_type:
  ext4
  proc

superblock instances:
  ext4 instance for /dev/sda1
  ext4 instance for /dev/sdb1
  proc instance

mount tree:
  [ext4:/dev/sda1 root]  mounted at /
  [ext4:/dev/sdb1 root]  mounted at /mnt/data
  [proc root]            mounted at /proc
plaintext

ext4 不是 mount,/dev/sdb1 不是 mount point,/mnt/data 也不是文件系统实例本身——它们分别属于类型、实例、接入点。

bind mount

mount --bind /var/log /tmp/x
bash

这里没有新磁盘、没有新 ext4 实例被创建。它做的是把现有路径树中的某个子树再挂到另一个位置。这说明 mount 的本质真的不是”打开磁盘分区”,而是”把一个文件系统树里的某个入口,接到当前命名空间的路径树某处”。

mount 是 Dentry 上的任意门

回忆前面的路径解析,如果在 /mnt/data 挂载了一个新磁盘(新文件系统),发生了什么?

原始的 /mnt/data 目录的数据和 dentry 并没有被删除。当你在它上面挂载新系统时,内核实例化了新的文件系统并生成了一个 vfsmount 对象。内核悄悄在这个原本的 /mnt/data dentry 上打了一个特殊标记:DCACHE_MOUNTED

Traverse(路径遍历)的大变法: 之后,当路径检索(Path Walk)顺藤摸瓜来到 /mnt/data 时,内核看到这个 DCACHE_MOUNTED 标记。它立刻停止读取原先的 inode!取而代之的是,它去查全局 vfsmount 表,然后就像把当前路径指针推进了一个“任意门”,瞬间跳转到了新文件系统 super_block 的根 dentry 继续往下走。 你以为你还在原来的房间探索,其实 VFS 早已通过这扇任意门,不露声色地把你传送到另外一个世界了。

当我们执行 umount 时,内核就把这扇门(和挂载标记)拆除,你再次访问 /mnt/data,便又能看到原来的数据了。

这种设计通过 mount namespace 还能做到隔离——它可以限制“任意进程只能看到某一套挂载关系”。mount namespace 可以理解成”每个进程看到的挂载树视图”。不同进程可以拥有不同的根目录、不同的 /proc 挂载、不同的 /mnt/data 内容。

所以同一个路径字符串 /etc/passwd,在不同 mount namespace 里可能走到不同的 inode。这也是容器技术非常关键的一层能力——路径字符串可以一样,但看到的挂载世界完全不同。

mount namespace 决定”路径解析过程中,哪些 mount 会发生、会切到哪棵树”。 lookup 穿越 mount point 时,会查当前 namespace 的挂载关系,这不是抽象意义上的影响,而是非常直接的。

path = (vfsmount, dentry)

在内核中,一个路径位置经常不是单独一个 dentry 就能完整表达的。因为 mount crossing 会改变你所处的文件系统实例,所以内核里经常用这种组合来表示一个路径位置:

path = (mount context, dentry)
plaintext

一个路径位置不只是某个 dentry,还要知道它属于哪次挂载视图/哪棵挂载子树。这也是 pathname lookup 天然和 mount namespace 绑在一起的原因。

层次总图

file_system_type
  -> ext4 / xfs / tmpfs / proc ...

super_block
  -> 某个具体文件系统实例

mount
  -> 把这个实例的根接到路径树某个位置

namespace
  -> 决定一个进程看到哪棵挂载树

within a mounted filesystem:
  -> dentry / inode / file
plaintext

类型、实例、接入点、视图、对象模型——五层分得清清楚楚。

VFS 回顾

我们把所有关键认知汇总一下:

  • VFS 不是具体文件系统,而是统一抽象层。
  • inode 是文件本体,关心元数据和文件对象身份。
  • dentry 是目录项,解决”这个目录下这个名字指向谁”。
  • file 是一次打开实例,带 f_pos 和打开状态。
  • fd 只是当前进程 fd table 的整数索引,不是文件本身。
  • pathname lookup 是逐段解析,不是把整个字符串一次性映射成对象。
  • 三层缓存各司其职:dentry cache 缓存名字解析结果,inode cache 缓存文件本体对象,page cache 缓存文件内容页。
  • open() 的核心不是读内容,而是路径解析 → 检查 → 创建 struct file → 安装进 fd table。
  • read() / write() / mmap() 都从 fd -> file 出发,但内容访问模型不同:read 是 copy model,write 先写入页缓存再写回,mmap 是 mapping model。
  • file-backed VMA 和 page cache 把 mm 和 VFS 接到了一起。
  • mount point 是 pathname lookup 中的”切树点”。
  • file_system_type 是类型,super_block 是实例,mount 是接入路径树的动作。
  • mount namespace 决定一个进程看到的挂载树视图。

配合着前面所讲的,“进程 + 内存 + 文件”——Linux 内核三大核心骨架就基本齐了。

Learning Linux Kernel (Part 5) - Virtual Filesystem
https://20051110.xyz/blog/linux-kernel-5
Author TheUnknownThing
Published at March 31, 2026
Comment seems to stuck. Try to refresh?✨
浙ICP备2025146421号-1 浙公网安备33010502012185号