Linux 中的虚拟文件系统:我们为什么需要它们以及它们如何工作

虚拟文件系统是神奇的抽象层,使得 Linux 的“一切皆文件”哲学成为可能。
477 位读者喜欢这篇文章。
Filing papers and documents

什么是文件系统?早期 Linux 贡献者和作者 Robert Love 认为,“文件系统是遵循特定结构的数据的分层存储。” 然而,这种描述同样适用于 VFAT(虚拟文件分配表)、Git 和 Cassandra(一个 NoSQL 数据库)。那么,是什么区分了文件系统呢?

文件系统基础知识

Linux 内核要求,对于一个实体要成为文件系统,它还必须在具有关联名称的持久对象上实现 open()read()write() 方法。从 面向对象编程 的角度来看,内核将通用文件系统视为抽象接口,而这三个主要函数是“虚拟的”,没有默认定义。因此,内核的默认文件系统实现被称为虚拟文件系统 (VFS)。

If we can open(), close(), read() and write(), it is a file as this console session shows.
如果我们能 open()、read() 和 write(),那么它就是一个文件,如下面的控制台会话所示。

VFS 是著名的“在类 Unix 系统中,一切皆文件”这一论断的基础。想想看,以字符设备 /dev/console 为特色的上述微型演示实际上能够工作,这有多么奇怪。该图像显示了虚拟电传打字机 (tty) 上的交互式 Bash 会话。将字符串发送到虚拟控制台设备使其显示在虚拟屏幕上。VFS 还有其他更奇怪的属性。例如,可以在其中进行查找

像 ext4、NFS 和 /proc 这样的常见文件系统都以 C 语言数据结构(称为 file_operations)提供了这三个主要函数的定义。此外,特定的文件系统以熟悉的面向对象的方式扩展和覆盖了 VFS 函数。正如 Robert Love 指出的那样,VFS 的抽象使得 Linux 用户能够轻松地将文件复制到其他操作系统或管道之类的抽象实体,并从中复制文件,而无需担心它们的内部数据格式。代表用户空间,通过系统调用,进程可以使用一个文件系统的 read() 方法从文件中复制到内核的数据结构中,然后使用另一种文件系统的 write() 方法输出数据。

属于 VFS 基类型本身的功能定义可以在内核源代码的 fs/*.c 文件 中找到,而 fs/ 的子目录包含特定的文件系统。内核还包含类似文件系统的实体,例如 cgroups、/dev 和 tmpfs,这些实体在引导过程的早期是必需的,因此在内核的 init/ 子目录中定义。请注意,cgroups、/dev 和 tmpfs 不调用 file_operations 的三个主要函数,而是直接从内存读取和写入内存。

下图大致说明了用户空间如何访问通常挂载在 Linux 系统上的各种类型的文件系统。未显示的是管道、dmesg 和 POSIX 时钟等结构,它们也实现了 struct file_operations,因此它们的访问会通过 VFS 层。

How userspace accesses various types of filesystems
VFS 是系统调用和特定 file_operations(如 ext4 和procfs)的实现者之间的“垫片层”。file_operations 函数随后可以与特定于设备的驱动程序或内存访问器通信。tmpfs, devtmpfscgroups不使用 file_operations,而是直接访问内存。

VFS 的存在促进了代码重用,因为与文件系统关联的基本方法无需由每种文件系统类型重新实现。代码重用是广为接受的软件工程最佳实践!唉,如果重用的代码 引入了严重的错误,那么所有继承通用方法的实现都会受到影响。

/tmp:一个简单技巧

查找系统上存在哪些 VFS 的一种简单方法是键入 mount | grep -v sd | grep -v :/,这将在大多数计算机上列出所有未驻留在磁盘上且不是 NFS 的已挂载文件系统。列出的 VFS 挂载之一肯定是 /tmp,对吧?

Man with shocked expression
每个人都知道将 /tmp 保留在物理存储设备上是疯狂的!图片来源:https://tinyurl.com/ybomxyfo

为什么不建议将 /tmp 保留在存储设备上?因为 /tmp 中的文件是临时的(!),并且存储设备比创建 tmpfs 的内存慢。此外,物理设备比内存更容易因频繁写入而磨损。最后,/tmp 中的文件可能包含敏感信息,因此每次重启时都消失是一个优点。

不幸的是,某些 Linux 发行版的安装脚本仍然默认在存储设备上创建 /tmp。如果您的系统是这种情况,请不要绝望。请按照始终优秀的 Arch Wiki 上的简单说明来解决此问题,请记住,分配给 tmpfs 的内存不可用于其他用途。换句话说,具有巨大 tmpfs 且其中包含大文件的系统可能会耗尽内存并崩溃。另一个提示:编辑 /etc/fstab 文件时,请务必以换行符结尾,否则您的系统将无法启动。(猜猜我是怎么知道的。)

/proc 和 /sys

除了 /tmp 之外,大多数 Linux 用户最熟悉的 VFS 是 /proc 和 /sys。(/dev 依赖于共享内存,没有 file_operations)。为什么有两种风格?让我们更详细地了解一下。

procfs 为用户空间提供了内核及其控制的进程的瞬时状态快照。在 /proc 中,内核发布有关其提供的设施的信息,例如中断、虚拟内存和调度程序。此外,/proc/sys 是可以通过 sysctl 命令 配置的设置供用户空间访问的地方。有关各个进程的状态和统计信息在 /proc/<PID> 目录中报告。

Console
/proc/meminfo 是一个空文件,但仍然包含有价值的信息。

/proc 文件的行为说明了 VFS 与磁盘文件系统有多么不同。一方面,/proc/meminfo 包含 free 命令呈现的信息。另一方面,它也是空的!这怎么可能呢?这种情况让人想起康奈尔大学物理学家 N. David Mermin 于 1985 年撰写的一篇著名文章,题为“当无人观看时,月亮还在那里吗?现实与量子理论”。事实是,当进程从 /proc 请求时,内核会收集有关内存的统计信息,并且实际上,当无人观看时,/proc 中的文件中什么都没有。正如 Mermin 所说,“一个基本的量子原则是,测量通常不会揭示被测属性的预先存在的值。”(关于月亮的问题的答案留作练习。)

Full moon
/proc 中的文件在没有进程访问它们时是空的。(来源

procfs 的表面空虚是有道理的,因为那里可用的信息是动态的。sysfs 的情况则不同。让我们比较一下 /proc 与 /sys 中至少一个字节大小的文件数量。

Console

Procfs 恰好有一个,即导出的内核配置,这是一个例外,因为它每个引导过程只需要生成一次。另一方面,/sys 有许多更大的文件,其中大多数包含一页内存。通常,sysfs 文件仅包含一个数字或字符串,这与读取 /proc/meminfo 等文件生成的信息表形成对比。

sysfs 的目的是向用户空间公开内核所谓的“kobject”的可读和可写属性。kobject 的唯一目的是引用计数:当删除对 kobject 的最后一个引用时,系统将回收与其关联的资源。然而,/sys 构成了内核著名的“用户空间稳定 ABI”的大部分,任何人不得在任何情况下“破坏”它。这并不意味着 sysfs 中的文件是静态的,这将与易失性对象的引用计数相悖。

内核的稳定 ABI 而是约束了 可以 出现在 /sys 中的内容,而不是任何给定时刻实际存在的内容。列出 sysfs 中文件的权限可以了解如何设置或读取设备、模块、文件系统等的配置参数和可调参数。逻辑迫使人们得出结论,即 procfs 也是内核稳定 ABI 的一部分,尽管内核的 文档 没有明确说明。

Console
sysfs 中的文件描述了每个实体的恰好一个属性,并且可能是可读、可写或两者兼而有之。文件中的“0”表示 SSD 不可移动。

使用 eBPF 和 bcc 工具监控 VFS

了解内核如何管理 sysfs 文件的最简单方法是观察其运行,而在 ARM64 或 x86_64 上进行观察的最简单方法是使用 eBPF。eBPF(扩展伯克利包过滤器)由一个 在内核内部运行的虚拟机 组成,特权用户可以从命令行查询它。内核源代码告诉读者内核可以做什么;在启动的系统上运行 eBPF 工具则显示内核实际在做什么

幸运的是,通过 bcc 工具可以非常容易地开始使用 eBPF,这些工具可以作为 主要 Linux 发行版的软件包 提供,并且 Brendan Gregg 对它们进行了 充分的文档记录。bcc 工具是带有少量嵌入式 C 代码片段的 Python 脚本,这意味着任何熟悉这两种语言的人都可以轻松地修改它们。据统计,bcc/tools 中有 80 个 Python 脚本,这使得系统管理员或开发人员很有可能找到一个与其需求相关的现有脚本。

要大致了解 VFS 在运行系统上执行的工作,请尝试简单的 vfscountvfsstat,它们显示每秒都会发生数十次对 vfs_open() 及其朋友的调用。

Console - vfsstat.py
vfsstat.py 是一个 Python 脚本,其中包含一个嵌入式 C 代码片段,该代码片段仅计算 VFS 函数调用。

对于一个不太简单的示例,让我们观察一下在运行系统上插入 USB 闪存驱动器时 sysfs 中会发生什么。

Console when USB is inserted
使用 eBPF 观察插入 USB 闪存驱动器时 /sys 中发生的情况,包括简单示例和复杂示例。

在上面的第一个简单示例中,trace.py bcc 工具脚本会在 sysfs_create_files() 命令运行时打印一条消息。我们看到 sysfs_create_files() 是由 kworker 线程响应 USB 闪存驱动器的插入而启动的,但是创建了什么文件呢?第二个示例说明了 eBPF 的全部威力。在这里,trace.py 正在打印内核回溯(-K 选项)以及 sysfs_create_files() 创建的文件名。单引号内的代码片段是一些 C 源代码,包括一个容易识别的格式字符串,提供的 Python 脚本 诱导 LLVM 即时编译器 在内核内虚拟机中编译和执行。必须在第二个命令中重现完整的 sysfs_create_files() 函数签名,以便格式字符串可以引用其中一个参数。在此 C 代码片段中犯错会导致可识别的 C 编译器错误。例如,如果省略 -I 参数,则结果为“Failed to compile BPF text”。熟悉 C 或 Python 的开发人员会发现 bcc 工具易于扩展和修改。

插入 USB 闪存驱动器时,内核回溯显示 PID 7711 是一个 kworker 线程,它在 sysfs 中创建了一个名为“events”的文件。对 sysfs_remove_files() 的相应调用表明,移除 USB 闪存驱动器会导致移除 events 文件,这与引用计数的概念一致。在使用 eBPF 观察 USB 闪存驱动器插入期间的 sysfs_create_link()(未显示)时,发现创建了不少于 48 个符号链接。

无论如何,events 文件的目的是什么?使用 cscope 查找函数 __device_add_disk() 表明它调用了 disk_add_events(),并且可以将“media_change”或“eject_request”写入 events 文件。在这里,内核的块层正在向用户空间通知“磁盘”的出现和消失。想想看,与仅仅从源代码中尝试弄清楚这个过程相比,这种调查 USB 闪存驱动器插入工作原理的方法有多么快速。

只读根文件系统使嵌入式设备成为可能

可以肯定的是,没有人会通过拔掉电源插头来关闭服务器或桌面系统。为什么?因为物理存储设备上挂载的文件系统可能具有待处理的写入,并且记录其状态的数据结构可能与存储设备上写入的内容不同步。发生这种情况时,系统所有者将不得不在下次启动时等待 fsck 文件系统恢复工具 运行,并且在最坏的情况下,实际上会丢失数据。

然而,爱好者们会听说许多物联网和嵌入式设备(如路由器、恒温器和汽车)现在都运行 Linux。这些设备中的许多几乎完全缺乏用户界面,并且无法“干净地卸载”它们。考虑一下使用没电的电池启动汽车的情况,其中 运行 Linux 的抬头显示器 的电源会反复上升和下降。当发动机最终启动运行时,系统如何在没有长时间 fsck 的情况下启动?答案是嵌入式设备依赖于 只读根文件系统(简称 ro-rootfs)。

Photograph of a console
ro-rootfs 是嵌入式系统不需要频繁 fsck 的原因。(经许可)图片来源:https://tinyurl.com/yxoauoub

ro-rootfs 提供了许多不太明显的优势,例如不可破坏性。其中之一是,如果没有 Linux 进程可以在 /usr 或 /lib 中写入,则恶意软件无法写入。另一个是,对于远程设备的现场支持而言,基本上不可变的文件系统至关重要,因为支持人员拥有的本地系统在名义上与现场系统相同。也许最重要(但也最微妙)的优势是 ro-rootfs 迫使开发人员在项目的设计阶段决定哪些系统对象将是不可变的。与 ro-rootfs 打交道通常可能不方便甚至痛苦,就像编程语言中的 const 变量 通常那样,但好处很容易弥补额外的开销。

创建只读根文件系统确实需要嵌入式开发人员付出一些额外的努力,而这正是 VFS 发挥作用的地方。Linux 需要 /var 中的文件是可写的,此外,嵌入式系统运行的许多流行的应用程序会尝试在 $HOME 中创建配置文件点文件。主目录中配置文件的一个解决方案通常是预先生成它们并将它们构建到 rootfs 中。对于 /var,一种方法是在单独的可写分区上挂载它,而 / 本身挂载为只读。使用绑定或覆盖挂载是另一种流行的替代方案。

绑定和覆盖挂载及其在容器中的使用

运行 man mount 是了解绑定和覆盖挂载的最佳场所,它们使嵌入式开发人员和系统管理员能够在一个路径位置创建文件系统,然后在第二个位置将其提供给应用程序。对于嵌入式系统,这意味着可以将 /var 中的文件存储在不可写的闪存设备上,但在启动时将 tmpfs 中的路径覆盖挂载或绑定挂载到 /var 路径上,以便应用程序可以在那里尽情涂写。在下次通电时,/var 中的更改将消失。覆盖挂载提供了 tmpfs 和底层文件系统之间的联合,并允许对 ro-rootfs 中现有文件进行表面修改,而绑定挂载可以使新的空 tmpfs 目录在 ro-rootfs 路径中显示为可写。虽然 overlayfs 是一种适当的文件系统类型,但绑定挂载由 VFS 命名空间设施 实现。

基于对覆盖和绑定挂载的描述,没有人会感到惊讶,Linux 容器 大量使用了它们。让我们监视一下当我们使用 systemd-nspawn 通过运行 bcc 的 mountsnoop 工具来启动容器时会发生什么

Console - system-nspawn invocation
systemd-nspawn 调用启动容器,同时 mountsnoop.py 运行。

让我们看看发生了什么

Console - Running mountsnoop
在容器“启动”期间运行 mountsnoop 表明容器运行时严重依赖绑定挂载。(仅显示了冗长输出的开头)

在这里,systemd-nspawn 在容器 rootfs 中的路径处向容器提供主机 procfs 和 sysfs 中的选定文件。除了设置绑定挂载的 MS_BIND 标志之外,“mount”系统调用调用的其他一些标志确定了主机命名空间和容器中更改之间的关系。例如,绑定挂载可以根据调用将 /proc 和 /sys 中的更改传播到容器,也可以隐藏它们。

总结

理解 Linux 内核内部原理似乎是一项不可能完成的任务,因为内核本身包含大量的代码,更不用说 Linux 用户空间应用程序和 C 库(如 glibc)中的系统调用接口了。取得进展的一种方法是阅读一个内核子系统的源代码,重点是理解面向用户的系统调用和标头,以及主要的内核内部接口,这里以 file_operations 表为例。文件操作使“一切皆文件”真正起作用,因此掌握它们特别令人满意。顶层 fs/ 目录中的内核 C 源文件构成了其虚拟文件系统的实现,虚拟文件系统是垫片层,可实现流行的文件系统和存储设备的广泛且相对简单的互操作性。通过 Linux 命名空间的绑定和覆盖挂载是使容器和只读根文件系统成为可能的 VFS 魔法。结合源代码研究,eBPF 内核工具及其 bcc 接口使探测内核比以往任何时候都更简单。

非常感谢 Akkana PeckMichael Eager 的评论和更正。


Alison Chaiken 将在 3 月 7 日至 10 日于加利福尼亚州帕萨迪纳举行的第 17 届年度南加州 Linux 展 (SCaLE 17x) 上展示 虚拟文件系统:我们为什么需要它们以及它们如何工作

标签
User profile image.
Alison Chaiken 是一位软件开发人员,在加利福尼亚州山景城骑自行车。她的日常工作是在 Aurora Innovation 维护 Linux 内核并用 C++ 编写操作系统监控应用程序。Alison 为 u-boot、kernel、bazel 和 systemd 上游做出了贡献,并在嵌入式 Linux 大会、Usenix、linux.conf.au 和南加州 Linux 展上发表演讲。

6 条评论

多年前,当我还是 Wind*ws 用户时,我尝试了同样的事情。那完全失败了,所以我放弃了这个想法。现在阅读你的文章,我的第一个想法是“这不可能……”然后我意识到它确实会起作用!我真是个笨蛋。谢谢你的文章!

Alison,在 SCaLE 17x 上的演讲是我在所有 4 天和 15 场会议中最喜欢的!精彩的讲座和现场演示。即使作为一个 Linux 内核新手,我也学到了很多东西。非常感谢 bcc/eBPF 演示 - 看到入门如此容易真是令人鼓舞。

非常感谢您还创建了这篇博文……SCaLE 的录音完全搞砸了!哎。

谢谢!

嘿,Clark 你能提供讲座和现场演示的链接吗?我真的很想看看。

回复 作者 Clark Henry (未验证)

知识共享许可协议本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.