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

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

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

使用 eBPF 和 bcc 工具窥探 VFS

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

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

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

Console - vfsstat.py
vfsstat.py 是一个带有嵌入式 C 代码片段的 Python 脚本,它只是计算 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 容器 大量使用它们。 让我们通过运行 bcc 的 mountsnoop 工具来监视当我们使用 systemd-nspawn 启动容器时会发生什么

Console - system-nspawn invocation
system-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 贡献了上游代码,并在 Embedded Linux Conference、Usenix、linux.conf.au 和 Southern California Linux Expo 上发表了演讲。

6 条评论

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

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

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

谢谢!

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

回复 作者 Clark Henry (未验证)

© . All rights reserved.