你在编辑器里按下保存,看到的动作只有一瞬间:文件名旁边的小圆点消失了。可这次写入到底落在笔记本的固态硬盘、刚插上的移动盘,还是公司的远程文件服务器上?应用通常不用知道。它只把路径和数据交给操作系统,剩下的工作由文件系统完成。
这层“看起来都像文件”的体验并不是存储设备天然具备的能力。操作系统要先把设备整理成卷,在卷上建立文件系统,再把不同文件系统接到路径树中;应用发起 open()、read() 或 write() 时,虚拟文件系统还要沿路径查找对象,把通用操作分派给真正的实现。路径若跨到远程服务器,后面又多出缓存、网络、重试和一致性问题。
我们就从一次保存动作出发,顺着路径向下走。每到一层,都问两个问题:它解决了什么矛盾?为此付出了什么代价?

本机磁盘、U 盘和远程文件系统挂载到不同目录后,应用仍通过一棵统一目录树访问它们。
一块新硬盘刚接上电脑时,只能按位置读写扇区或逻辑块。它还不知道什么叫“课程笔记”,也无法回答“这个目录里有哪些文件”。文件系统做的第一件事,是在一片存储空间上建立可解释的结构:哪些块空闲,哪些块属于某个文件,目录名如何对应到文件对象,文件大小、时间、所有者和权限放在哪里。
理解这一过程时,最容易混在一起的是设备、分区、卷和文件系统。可以把它们分成四层:
这几个概念并不总是一一对应。卷管理和存储池可以把多块盘拼成一个逻辑卷,也可以从一个存储池里切出多个卷。文件系统只需要看到自己管理的逻辑地址范围,不必把所有底层拓扑都暴露给应用。
“格式化”不是简单地把盘清空,而是在目标空间上写入文件系统需要的初始结构。文件系统通常要准备描述全局状态的元数据、空闲空间记录、根目录以及用于恢复或校验的信息。不同文件系统如何组织这些内容并不相同,所以挂载时必须先识别类型,再由对应实现解释。
有些存储空间故意不建立普通文件系统。例如交换空间更关心固定页块的换入换出,不需要目录名、所有者和文件时间。数据库也可能在特定场景直接管理裸设备,不过这样会失去文件系统带来的命名、权限、工具和通用缓存能力,运维复杂度随之上升。
开机早期还有一个顺序难题:操作系统内核尚未运行,完整的文件系统驱动自然也还没就位,但机器必须先找到并加载引导程序。现代 UEFI 固件通过自己的启动管理机制定位可执行的引导镜像,通常从约定的系统分区读取它;引导程序再继续定位内核和初始运行环境。
这解释了为什么“可启动”不是随便把内核文件复制到任意目录就够了。启动链条中的每一段代码,只能使用当时已经具备的设备和文件访问能力。引导分区格式、启动项和加载器之间必须彼此理解。
文件系统管理的是一段逻辑存储空间里的对象和名字;挂载解决的是这个文件系统从全局路径树的哪个位置被看见。两件事相关,但不是同一步。
假设系统已经有根目录 /,移动盘上也有一棵自己的目录树。应用若必须先记住设备编号,再用另一套接口读取移动盘,所有软件都会变得很别扭。挂载的思路是:选中现有路径树中的一个目录作为挂载点,把另一个文件系统的根接到这里。挂载完成后,/media/course/notes.md 只是一个普通路径,路径解析器会在走到挂载点时切换文件系统。
挂载不会把原挂载点目录里的内容删除。新文件系统覆盖在这个位置上时,旧内容暂时被遮住;卸载后,原内容会重新出现。这个细节很重要,因为管理员若误把文件写入尚未挂载的目录,之后挂载成功,那些文件会像“消失”了一样,实际上仍占着下层文件系统的空间。
一次典型挂载至少涉及三类信息:要挂载的来源、目标挂载点,以及文件系统类型和选项。内核会让对应文件系统实现读取并验证必要的元数据,建立内存中的文件系统实例和根对象,再把新的挂载关系记入当前挂载视图。验证失败可能来自类型判断错误、元数据损坏、设备不可读或挂载选项不兼容。
UNIX 类系统倾向于把各卷接入一棵目录树。Windows 常以驱动器号呈现独立卷,同时也支持把卷挂到目录。两者的界面习惯不同,底层要解决的仍是同一个问题:把路径中的名字映射到某个文件系统实例和具体对象。
容器带来了一个很有体感的例子。主机进程可能看得到 /srv/data,容器里的进程却只看到被允许的几个挂载。Linux 的挂载命名空间隔离的是“进程能看到哪些挂载关系”,同一块文件系统并没有因此复制一份。这样既能给不同进程组安排不同的文件视图,也引出了挂载传播的额外规则:某处新挂载是否要同步出现在另一个命名空间,需要明确控制。
下面的演示中,点击“挂载移动盘”或“挂载远程盘”,再选择路径。注意挂载前后相同路径会落到不同文件系统;卸载后,被遮住的目录内容又会恢复可见。

解析 /home/lin/report.txt 时,VFS 在 /home 处切换到底层已挂载文件系统,随后继续逐段查找。
现在我们打开 /home/lin/notes.md。/home 也许是 ext4,/tmp 也许是 tmpfs,/team 也许来自 NFS。应用却可以对它们调用同一组系统调用。虚拟文件系统(VFS)就是内核中的适配层:上面承接统一的文件 API,下面通过操作表把请求交给具体文件系统。
VFS 不是把所有文件系统改造成同一种磁盘格式。它统一的是内核对象和操作约定。具体实现仍然决定目录怎样查找、块怎样分配、远程请求怎样发送。这样新增文件系统时,应用接口不用跟着扩张;代价是内核要维护一套抽象对象、缓存和生命周期规则。

VFS 用超级块、索引节点、目录项和文件对象抽象已挂载文件系统、文件元数据、名字解析与已打开访问。
Linux VFS 中经常一起出现四类对象,它们回答的问题不同:
dup() 或进程继承文件描述符时,则可能共享同一个打开实例和偏移。把这四者分开后,很多现象就不神秘了。重命名主要改变目录项关系,不等于重写文件数据;硬链接增加的是指向同一 inode 的名字;两个独立打开动作可以各自从不同偏移读取同一文件。
内核不会把 /team/os/outline.md 当成一个巨大的字符串直接丢给磁盘。它从起点开始,把路径拆成 team、os、outline.md,逐级在父目录中查找下一个名字。dentry 缓存命中时,这一步可能只需访问内存;未命中时,VFS 调用具体文件系统的目录查找操作。
途中还可能发生三种转向:遇到挂载点,路径行走切换到另一文件系统的根;遇到符号链接,解析器按规则转去目标路径;遇到 ..,它既要考虑目录父子关系,也要正确处理跨越挂载根的情况。路径查找因此既是性能热点,也是安全边界。限制符号链接或禁止跨挂载点的安全选项,本质上都在约束这段行走过程。
当 VFS 已经拿到所需对象,后续操作会沿着对象中的函数入口交给具体实现。同样是 read(),普通磁盘文件可能从页缓存取数据并在需要时发起块 I/O;tmpfs 从虚拟内存页取数据;NFS 客户端则可能先查本地缓存,未命中后构造远程过程调用。
这种设计解决的是“接口统一”和“实现差异”之间的矛盾。代价也很实在:每一层间接调用都要守住一致的语义,错误码、权限、并发和缓存失效不能各说各话。
选择文件系统和操作,观察 VFS 使用了哪些对象,以及请求最终落到哪里。
一提到文件系统,很多人脑中会自动出现磁盘。可文件系统更本质的价值是提供名字、层级、权限和文件操作接口,数据是否落在普通块设备上反而是实现选择。
tmpfs 把文件内容放在虚拟内存管理的页面里。它会随内容增长或缩小,卸载后内容消失;系统启用交换空间时,暂时不用的 tmpfs 页面还可能被换出。因此,把 tmpfs 简单说成“永远锁在物理内存里的高速盘”并不准确。它绕开了普通磁盘文件系统的数据布局,却没有绕开内存压力。
tmpfs 适合短生命周期数据、运行时目录和可重新生成的中间结果。速度是常见收益,但真正的边界是持久性:断电、重启或卸载后不能依赖内容继续存在。容量若不限制,进程还可能用大量临时文件挤压其他内存使用者。
另一类是伪文件系统。procfs、sysfs 之类的路径,看起来可以 open() 和 read(),返回的却是内核当时生成的进程或设备状态。这里的“文件”更像一份接口视图。统一文件 API 让 shell、监控程序和系统工具不必再发明一套查询协议,代价是读写这些路径可能触发真实的内核行为,不能把它们当成普通文本副本随意操作。
“存在文件名”不等于“数据持久化在磁盘”。判断一个路径能否承载重要数据,要先确认它属于哪个文件系统、该文件系统的生命周期是什么,以及是否允许换出或被系统重新创建。
多人共用一台机器时,目录树既要方便分享,也要阻止越权。POSIX 风格的基本权限模型把进程分到文件所有者、文件所属组和其他用户三类,再分别判断读、写、执行权限。内核检查的是进程凭据与文件元数据,不是应用界面里显示的昵称。
对普通文件,读权限控制读取内容,写权限控制修改内容,执行权限控制把它当程序执行。对目录,读权限更接近“列出目录项名字”,写权限允许增删或改名目录项,执行权限则是“穿过这个目录继续查找”。
这会产生一个初学时很反直觉的结果:删除文件主要修改父目录里的名字关系,因此能否删除往往取决于父目录权限,而不只看文件自身是否可写。权限判断还可能叠加访问控制列表、安全模块或只读挂载等约束,所以 rwx 是基础,不是所有系统策略的终点。
文件元数据通常记录数值形式的用户和组标识。移动硬盘带到另一台机器,或远程服务器只信任客户端传来的数值身份时,同一个编号可能对应不同的人。解决办法不是看到名字相同就自动相信,而是建立统一身份目录、在边界处做可靠映射,或使用带加密认证的机制把请求绑定到真实主体。
两个用户都有写权限,只说明他们都被允许修改,不代表同时写入会自动排好顺序。协作程序仍需使用文件锁、原子重命名、版本号或应用层事务来协调。把权限和锁分开理解,才能看清系统同时在处理两个问题:谁有资格进入,以及进入之后并发动作怎样不互相踩坏。
当 /team/report.md 实际存放在另一台机器上,本地 VFS 仍希望给应用一套熟悉接口。客户端文件系统把查找、读取和写入转换成网络请求,服务器在自己的文件系统上执行操作,再返回结果。使用者得到位置透明性,系统则必须面对本地磁盘没有的故障:网络可以丢包、延迟、分区,服务器可以重启,回复也可能在返回途中丢失。
NFS 是这类设计的经典例子。服务器先导出允许访问的目录,客户端把远程目录挂到本地路径。后续请求不会每次都发送完整绝对路径,而是大量依赖文件句柄:服务器给出一个不透明标识,客户端用它指明某个远程文件系统对象,再附上偏移、长度或目录项名字完成操作。

NFS 客户端把文件操作连同文件句柄发送到服务器,服务器据此定位对象并返回数据与状态。
目录是逐层查找的,客户端拿着父目录句柄和下一级名字请求服务器,得到子对象句柄。没有缓存时,深路径可能带来多次往返。客户端因此会缓存目录项、属性和文件数据;较新的协议也能把多个相关操作组合进一次请求。性能提升来自减少网络往返,代价是客户端保存的副本可能过时。
不要机械地把“路径有四段”换算成“必定四次 RPC”。起点句柄、缓存命中、协议版本、复合操作和符号链接都会改变请求数量。更可靠的分析方式是:逐段判断本地是否已有可信结果,只有缺失或需要重新验证的步骤才跨网络。
NFSv3 的核心文件操作协议按无状态服务器来设计:每个请求携带足够的信息,服务器不必依赖“这个客户端之前打开过什么”才能正确处理。服务器重启后,客户端可以重试请求。这降低了恢复状态的负担,却没有让所有问题消失。服务器仍有持久文件数据,挂载服务也可能记录管理信息;“无状态”描述的是协议处理正确性不依赖每客户端打开状态,不是服务器脑中一片空白。
写入也不是只有“每次都同步落盘”一个模式。客户端可以要求稳定写入,也可以先进行不稳定写,由服务器暂存在缓存中,之后再用 COMMIT 确认指定范围进入稳定存储。若服务器在两者之间重启,客户端根据校验信息判断是否需要重传。这里的权衡很清楚:每次都等稳定存储,确认简单但延迟高;允许批量提交能提高吞吐,却要求客户端保留可重传数据并处理恢复。
文件锁、共享保留、委托和更完整的安全机制,需要服务器知道客户端持有什么权利。NFSv4 因此管理打开与锁定状态,并为重启后的恢复安排租约和状态重建流程。状态能提供更多协调能力,也意味着客户端和服务器必须识别彼此是否重启、过期状态如何回收、网络恢复后怎样重新取得权利。
这不是“有状态一定先进,无状态一定落后”。无状态让故障恢复路径短,有状态让锁和缓存协作更强,二者选择的是不同复杂度分配。
网络往返通常比内存访问慢得多。如果每次 read() 都询问服务器,远程文件系统的体验会非常差。客户端缓存把常用目录项、属性和数据留在本地,让大量读取不再过网。但一旦另一台客户端修改同一文件,本地副本什么时候失效,就成了必须回答的问题。
本机文件系统中,进程通常共享同一内核页缓存,一个进程的写入可以很快被另一个进程观察到。不过“可见”不等于“整个业务操作原子”。两个进程交错写入、先读后改,仍然需要锁或原子操作协调。
远程环境很难免费复制这种体验。常见的 close-to-open 做法是:写入者在关闭前把修改推向服务器,另一个客户端下次打开时重新验证缓存。这样适合“编辑、保存、再由别人打开”的工作流,却不能保证两个已经同时打开文件的客户端在每一时刻看到完全相同的内容。
历史上的会话式语义把一次打开到关闭看作会话,修改在关闭后才对之后开始的会话可见。不可变共享文件则走得更彻底:对象发布后不再修改,更新通过产生新对象或新版本完成。限制写入自由换来了极易缓存和验证的读取路径。

缓存减少网络往返,但其他客户端更新文件后,旧缓存可能在过期或重新验证前继续可见。
点击步骤模拟两台客户端协作。你可以切换为“每次读取都询问服务器”来观察一致性与网络开销的交换。
本地设备故障时,内核通常能较快得到明确错误。远程请求超时却有歧义:服务器可能根本没收到,也可能已经完成操作,只是回复丢了。客户端若直接重试读取通常没有副作用;重试创建、改名或追加写入时,则必须依靠协议的重复请求识别、幂等设计或操作校验,避免同一个动作执行两遍。
远程挂载面对故障时常见两类策略。一类持续重试,让应用等待服务器恢复,适合不能轻易把暂时断网解释成永久 I/O 失败的场景;另一类在超时后向应用返回错误,让调用方尽快继续,但应用必须真的能处理部分失败。选择哪种策略,不是单纯的“可靠”对“快速”,而要看工作负载能否容忍阻塞、是否存在本地恢复路径,以及错误返回后数据处于什么状态。

超时不能证明服务器未执行;重试保留同一请求编号,并在持续重试与有限重试之间权衡等待和失败处理。
缓存让断网时还能读到部分旧数据,却也让“当前版本”更难判断;服务器保存更多打开和锁状态,可以提供更强协调,却增加了重连后的状态恢复工作。分布式文件系统的设计重点,因此不是假装网络永不失败,而是让每一种失败都有可辨认、可重试或可上报的结果。
现在回到开头。编辑器保存 /team/os/outline.md 时,内核先从进程看到的根目录出发逐级解析路径;走到 /team,发现这里连接着远程文件系统;VFS 用 dentry 和 inode 表示查找结果,为这次打开建立 file 对象,再把写入交给 NFS 客户端。客户端可以先更新本地缓存,随后把数据和文件句柄发送给服务器,并按协议把修改提交到稳定存储。另一台电脑何时看到新内容,取决于它的缓存状态和重新验证时机。
这条链路没有哪一层是纯粹包装:卷和文件系统把块变成对象,挂载把对象接入命名空间,VFS 统一接口,权限守住访问边界,远程协议跨越网络,缓存拿一致性窗口换速度。理解文件系统内部机制,就是能在“路径打不开、文件像消失、远程盘卡住、两台机器内容不同”时,判断问题发生在哪一层,而不是把所有现象都归结为“硬盘坏了”。
服务器维护窗口结束后,管理员发现 /data 下原先写入的 20 GB 文件不见了,而新挂载的卷几乎是空的。卸载该卷后,文件重新出现。请判断数据真正写到了哪里,并给出避免再次发生的方法。
甲、乙两台客户端同时打开远程文件。甲保存并关闭后,乙仍在原来的打开会话中继续读取。乙没有立刻看到新内容,这是否必然说明服务器丢失了甲的写入?