你在编辑器里写完一份实验报告,按下保存,界面几乎立刻就恢复了可操作状态。看起来只是把一串字节交给硬盘,真正落到操作系统里,却要回答一连串问题:路径中的每一级目录怎样找到?同名文件对应哪份元数据?文件新增的几千个字节放在哪些块里?内存里的修改什么时候才算真的进入设备?如果此时突然断电,系统又凭什么知道哪些更新已经完成?
文件系统就是处理这组矛盾的中间层。应用希望看到的是一个有名字、能增长、能随机读写的连续文件;存储设备提供的却是固定大小的块,还带着容量有限、访问延迟、擦写约束和故障风险。文件系统一边维持简单的文件抽象,一边用目录、索引、空闲空间表、缓存和恢复协议,把上层操作翻译成可执行的块读写。
这一节我们不从定义表格开始,而是跟着 open → write → close 走一遍。走完后你会发现,所谓“文件系统实现”,本质上是在处理三张映射表:名字映射到文件对象,文件偏移映射到存储块,内存状态映射到持久状态。
假设程序要写入 /home/xiaohu/os/实验报告.docx。应用只提交路径、缓冲区和字节数,内核不能直接把这些参数塞给设备。它要先在统一的文件接口中解析路径,再请具体文件系统找到元数据和数据块,接着把逻辑块请求交给块层和设备驱动,最后由控制器完成数据传输。
这条链路可以粗略分成四层,但别把分层理解成四套完全隔离的代码。
最上面一层接住 open、read、write、stat、rename 这类系统调用。它提供统一的对象和操作约定,让同一个程序既能访问本地 ext4,也能访问网络文件系统或内存文件系统。具体文件系统只要实现约定的操作,就能挂到同一棵命名空间里。
统一接口还维护挂载关系。路径走到某个挂载点时,解析过程会从当前文件系统切换到另一个文件系统的根目录。对应用来说,/home 下面是不是另一块设备并不重要;对内核来说,这一步必须明确切换“由谁继续解释后面的名字”。
再往下,具体文件系统负责目录、权限、时间戳、文件大小和块映射。它知道一个目录项怎样指向 inode 或文件记录,也知道文件的第 37 个逻辑块实际落在哪个设备块。
这一层同时负责分配和回收。文件增长时要找空闲块,文件截短或删除时要把块还回去。它还得尽量把相关数据放得近一些,否则映射虽然正确,访问却可能慢得无法接受。
块层面对的是“从某个逻辑块地址开始读写若干扇区”的请求。它可以合并相邻请求、调整队列顺序,并把请求交给合适的设备驱动。驱动设置 DMA、通知控制器、处理中断和错误;控制器再把数据搬到设备介质上。
机械硬盘和 SSD 在这一层以下差异很大。机械硬盘要移动磁头并等待盘片转到合适位置,位置相邻通常意味着更少的机械等待。SSD 没有寻道动作,但闪存不能像内存那样随意原地覆盖:写入按页进行,回收通常牵涉更大的擦除块,控制器还要做地址转换、垃圾回收和磨损均衡。文件系统不必知道每个闪存单元的位置,却必须避免把“没有机械臂”误解成“布局完全不重要”。
块大小不是一个放之四海皆准的常数。设备有逻辑扇区,文件系统有逻辑块,内存管理有页,SSD 内部还有写入页和擦除块。它们常常都是 2 的幂,但大小和职责并不相同。讨论“第几个块”时,先弄清楚它属于哪一层。
文件系统卸载后,内存中的对象都会消失,所以足以重建整个文件系统的关键结构必须保存在设备上。不同文件系统布局不同,常见角色包括:
这些区域不是天然可靠的。超级块坏了,系统可能连其他结构在哪里都不知道;空闲空间表和文件索引不一致,同一个块就可能被错误地分给两个文件。因此,成熟的文件系统会保存备份、校验信息或恢复记录,并规定更新顺序。
我们继续打开 /home/xiaohu/os/实验报告.docx。内核不会在整块磁盘上搜索完整字符串,而是从起点开始逐段解析:先找 home,再在 home 对应的目录中找 xiaohu,然后找 os,最后处理 实验报告.docx。绝对路径从根开始,相对路径从进程的当前目录或调用者指定的目录文件描述符开始。
目录最关键的内容是“名字 → 文件标识”的映射。在类 Unix 文件系统里,目录项通常保存文件名和 inode 号;inode 保存文件的类型、权限、大小、链接计数、时间戳和数据块映射。文件名通常不在 inode 里,因为同一个 inode 可以被多个硬链接指向。
这一区分解释了一个容易误判的现象:改名通常只改变目录映射,不需要搬动文件数据;删除名字也不一定立刻销毁文件。只有指向该 inode 的目录链接都消失,并且没有打开引用继续持有它时,文件系统才具备回收 inode 和数据块的条件。
路径解析期间,每一级目录都要检查“搜索目录”的权限。最终对象能不能读写,还要按打开方式检查对象权限。路径中若遇到符号链接,解析器可能改走另一条路径;若遇到挂载点,解析器会跨入另一套文件系统。并发的改名和删除又会让路径在解析过程中变化,所以真实实现还需要引用计数、锁或无锁读机制来保证对象不会在半路被错误释放。
程序经常反复访问相同路径。如果每次都把每一级目录从设备读一遍,打开文件会被大量小 I/O 拖慢。内核会在内存中缓存“父目录、名字、目标对象”之间的关系。缓存命中时,很多路径分量无需访问设备;缓存未命中时,具体文件系统才去目录数据中查找,并把结果放入缓存。
不存在的名字也可以缓存一小段时间。这样,一个程序反复探测同一个缺失文件时,不必每次都扫描目录。不过网络文件系统上的名字可能被另一台机器修改,缓存就需要重新验证;本地文件系统和远程文件系统不能套用完全相同的可信期限。
在下面的实验里输入路径,再逐步观察解析器当前站在哪个目录、检查了什么,以及何时跨过挂载点。它没有访问网络,刷新页面也能重新运行。
open 返回的数字并不是 inode路径找到文件后,open 会返回一个小的非负整数,也就是文件描述符。它只是当前进程文件描述符表的下标。这个表项再指向一份“打开文件描述”,其中保存当前偏移、打开状态标志以及对文件对象的引用;文件对象最终关联到 inode 或其他文件记录。
把这几层拆开很重要:
open 打开同一个文件,通常得到两份打开文件描述,因此各自维护偏移。dup 复制的文件描述符指向同一份打开文件描述,所以共享偏移和状态标志。fork 后继承的描述符也可能与父进程共享同一份打开文件描述。因此,“文件描述符相同”“打开状态相同”“底层文件相同”是三件不同的事。并发读写是否互相影响,要看它们共享到了哪一层,还要看应用是否显式指定偏移、文件是否以追加方式打开,以及文件系统提供什么原子性保证。

路径负责找到文件身份,inode 或 FCB 记录文件信息,打开文件表保存本次打开状态,进程最终拿到可操作的文件描述符。
创建新文件时,文件系统先解析父目录,检查同名项和目录权限,再分配一份文件元数据并把名字加入目录。一个刚创建的空文件可以只有元数据和目录项,还没有数据块。第一次写入时才真正分配数据空间,是很常见的做法。
close 的首要作用是释放当前描述符引用。当最后一个描述符离开某份打开文件描述时,内核可以回收这份内存状态。但 close 不等于“所有数据已经进入持久介质”。脏页可能仍在等待后台写回,设备内部也可能还有易失缓存。需要明确持久化边界时,应用要使用同步接口并正确处理返回值。
删除文件名时,目录项被移除,链接计数减少。若程序已经打开该文件,它通常还可以继续通过描述符读写;路径中已经找不到这个名字,但打开引用仍让对象活着。直到链接计数和打开引用都归零,数据块才会回到空闲空间结构。
“删掉名字”和“擦除每一个旧字节”不是同一件事。普通删除主要是解除目录映射并回收空间所有权,之后的物理擦除、设备内部回收或数据可恢复性取决于文件系统、设备和安全擦除机制。
应用把文件看成从偏移 0 开始的一条连续字节带。文件系统必须把这条字节带切成逻辑块,再为每个逻辑块找到设备位置。若块大小为 ,访问字节偏移 时,逻辑块号和块内偏移分别是:
这一步只回答“文件里的第几个块”,还没回答它在设备哪里。后一个问题由块映射结构解决。
若整个文件占据一段连续块,只需记录起始块和长度。文件逻辑块 的位置可以直接用“起始块 + ”算出。顺序访问和随机访问都很省事,对机械硬盘尤其友好。
麻烦发生在文件增长时。紧邻区域如果已被别的文件占用,就要搬家或另找一段空间。反复创建和删除还会留下许多彼此分离的空洞,明明总空闲量够用,却找不到足够长的一段,这就是外部碎片。
链式分配让每个数据块指出下一个块,文件可以使用任何空闲位置,追加也很灵活。但要访问第 个块,可能要从链头走过前面所有指针;指针若散落在设备块中,机械硬盘还会不断寻道。
FAT 把“下一个块”指针集中到文件分配表里,表能缓存到内存时,遍历不必每一步都读数据块。不过从文件头走到第 块的依赖仍然存在,表本身也会随卷容量增长。
索引分配把数据块地址集中放在索引块或树中。找到索引后,可以直接或经过少数几层间接块定位目标。小文件若也强制占用完整索引块会很浪费,所以许多设计会把少量直接指针放在 inode 内,再为更大文件启用一级、二级甚至更多层间接索引。
多级索引解决了文件大小跨度的问题,但访问大文件末尾可能要多读几层元数据。元数据缓存会缓解这项成本;索引损坏的影响也更集中,因此校验和恢复策略很重要。
现代文件系统常用 extent 描述“从某个逻辑块开始的一段连续映射”。一条记录可以表达逻辑块 20–99 对应物理块 8000–8079,而不用保存 80 个块号。它既保留连续布局的高吞吐,又允许一个文件由多段 extent 组成,增长比单一连续区灵活。
代价是 extent 可能分裂。文件中间写入、空间逐渐拥挤或写时复制都会让一段映射变成多段。extent 数量多到放不进 inode 时,还要组织成树。文件系统真正做的是控制分裂速度,而不是幻想永远没有碎片。
下面的沙盘有 32 个块。选择一种策略和文件长度,看看连续分配为何会失败、链式分配为何容易散开,以及 extent 怎样用少量区间描述结果。

应用看到的是逻辑偏移,文件系统则根据块组织方式,把它翻译成连续、链式、索引或 extent 描述的物理位置。
目录索引和空闲空间表看起来是两个主题,其实都在回答同类问题:面对大量候选项,怎样快速找到目标,同时又能承受频繁插入、删除和增长。
小目录用线性列表很自然。查找时逐项比较,插入时把新条目放到可用位置。它占用空间小、实现简单,几十个条目时往往已经够快。问题是目录扩大到几万项后,一次未命中查找也可能扫描完整目录。
哈希目录把文件名算成桶位置,平均查找接近常数时间。代价是要处理碰撞、扩容和恶意构造的冲突;它也不擅长按名字排序遍历。哈希值只负责缩小候选范围,最后仍要比较真实文件名,不能把“哈希相等”直接当成“文件名相等”。
B 树或 B+ 树把有序键和子节点组织进与存储块大小相配的节点。每层可以容纳很多分支,所以即使条目很多,树高仍然较低。它支持稳定的查找、插入、删除和有序遍历,但节点分裂、合并、并发更新与崩溃一致性都比线性表复杂。
现实文件系统经常采用混合策略:小目录把条目直接放在 inode 附近或少量块里,目录长大后再转换为哈希树或 B 树结构。这样,小目录不用为复杂索引支付固定成本,大目录又不会退化成漫长扫描。

目录较小时逐项查找足够直接;规模扩大后,哈希可以快速落桶,B 树则通过多级索引不断缩小查找范围。
若每个块对应一个比特,位图就能直接表示空闲或占用。分配一个块时寻找满足条件的位并翻转,释放时再改回来。位图大小由总块数决定,与当前空闲多少无关。
例如,一个 1 TiB 的卷采用 4 KiB 文件系统块,共有 个块。每块用 1 bit,完整位图大小为 字节,也就是 32 MiB。相对 1 TiB 容量,这项固定开销很小,但要在整张位图中找很长的连续空闲区仍可能昂贵。
因此实现通常会分组,并给每组保存空闲计数或摘要。分配器先挑一个有足够空间的组,再扫描局部位图。把新 inode、目录数据和文件数据尽量放在同组,还能减少机械硬盘寻道,也能缩小元数据更新范围。
空闲链表把空闲块串起来,拿走链头和归还链头都简单,但寻找连续区域很慢。分组方法让一个空闲块保存一批后续空闲块号,减少读取链表节点的次数,仍不擅长表达大段连续空间。
区间计数记录“起始块 + 连续长度”,大片空闲区域只需一条记录。分配一段后缩短或拆分区间,释放相邻区域时再合并。它对 extent 分配很合适,但磁盘越碎,区间条目越多,维护树结构的成本也越高。
文件系统还会区分“保留”“已分配”“延迟分配”“正在释放”等状态。只用一个肉眼可见的 0/1 模型能帮助入门,却不足以描述并发写入、日志事务和写时复制系统里的全部中间状态。

位图逐块记录占用状态,空闲区间则把连续空间压缩成“起始位置加长度”,两者提供不同粒度的分配视角。
程序写入 8 KiB 时,如果每次都等待设备完成,交互会很迟钝。内核通常先把数据复制到页面缓存,把相关页标记为脏,然后就让 write 返回。后台写回线程稍后把多份小修改聚合成更大的 I/O。
这套设计解决了延迟与吞吐的矛盾,却制造了新的状态:应用已经收到成功,数据仍可能只在易失内存中。理解文件系统可靠性,关键不是背“有缓存”,而是分清数据此刻在哪一层,以及崩溃会失去哪几层。
读取文件时,内核先按“文件对象 + 页索引”查页面缓存。命中就直接复制给应用或映射进进程地址空间;未命中才发起设备读取。连续读取时,系统会观察访问模式并预读后续页,让设备 I/O 与应用处理重叠。
写入缓存页后,页进入脏状态。写回开始时,系统标记它正在写回;设备确认完成后,才清除相应状态。内存紧张、脏页累计过多、定时触发或应用显式同步,都可能推动写回。
直接 I/O 可以绕过常规页面缓存,把数据在应用缓冲区与设备之间传输。它减少重复缓存并让数据库更容易控制 I/O,但对对齐、并发和缓存一致性提出更高要求。绕过页面缓存也不自动等于绕过设备缓存,更不自动获得持久性保证。
扩展文件时,至少会改数据块、块分配信息、inode 大小和时间戳,有时还会改目录。若这些写入任意乱序落盘,断电后可能出现“inode 指向未分配块”或“块已占用但没有文件引用”的状态。
日志文件系统会把一组元数据更新组织成事务。常见思路是先把恢复所需的记录写入日志,确认事务提交,再把更新逐步写到正式位置。重启后只重放已提交事务,丢弃不完整事务。具体系统可能只记录元数据,也可能记录数据;即使同叫“日志模式”,数据与元数据的顺序保证也可能不同。
写时复制走另一条路:不覆盖当前可达的旧结构,把修改写到新位置,逐级建立新的父节点,最后以受控方式切换根指针。崩溃时要么仍看到旧树,要么看到完整的新树。它天然适合快照,但会增加分配、引用计数和碎片整理的复杂度。
close、fsync 与“保存成功”close 主要结束描述符生命周期;后台写回可能还没完成。fsync 一类同步操作用于把文件数据和恢复该文件所需的元数据推进到持久存储边界,并把错误反馈给应用。若应用的事务还涉及目录项,例如创建临时文件后原子改名,仅同步文件内容不一定覆盖目录变更,通常还要考虑父目录的持久化。
即使应用正确调用同步接口,整条链路仍依赖设备遵守刷新和写入顺序协议。设备若在断电时丢失已声称完成的数据,文件系统无法只靠上层算法补救。企业存储会使用断电保护、电池或非易失写缓存,就是为了缩短这条不确定链路。
点击操作,观察同一份内容在“应用缓冲区、页面缓存、日志/文件系统、设备”之间怎样移动。你可以随时模拟断电,看看哪些状态会留下。

点击保存通常只是先修改页面缓存;在脏页越过持久化边界之前断电,数据仍可能丢失。
文件抽象相同,不代表底层策略相同。一个面向机械硬盘的“好布局”,搬到 SSD 上未必仍有相同收益;一个对 SSD 友好的写入模式,也可能把机械盘磁头折腾得来回奔跑。
机械硬盘随机访问慢,主要成本来自寻道和旋转等待。文件系统会尽量把同一文件的块、目录和相关元数据放在相近区域;块层也可能合并相邻请求,调整并发请求顺序。大块顺序读写通常更容易达到高吞吐。
预读能把顺序访问变成更大的连续 I/O,延迟分配能等待更多写入到齐后再决定最终位置,聚集写能减少细碎请求。但延迟越久,未持久数据窗口也越大;过度预读会占用带宽和缓存,把真正有用的页挤出去。
SSD 通过闪存转换层把操作系统看到的逻辑块地址映射到内部物理页。更新一个逻辑块时,控制器常把新数据写到别处,再让映射指向新位置。旧页变成无效页,垃圾回收把仍有效的数据搬走,再擦除较大的块。
因此,主机写入 1 份数据,设备内部可能搬动更多数据,这叫写放大。可用空闲空间越少、写入越零碎,垃圾回收越难安排。磨损均衡又要避免少数擦除块被反复使用。文件系统看不到全部内部细节,但可以通过对齐、批量写入和合理保留空间,减少不必要的压力。
删除文件后,文件系统知道哪些逻辑块空闲,SSD 控制器却可能仍把它们当作有效数据保存。TRIM 或 discard 把“不再需要这些逻辑块”的范围通知给下层设备,控制器以后做垃圾回收时便不必搬运其中的旧内容。
TRIM 不是“立刻把指定单元擦成零”,也不是一条同步安全擦除承诺。设备可以延后处理提示,读取被丢弃范围后的返回内容也不应被当作通用安全保证。系统可以实时下发 discard,也可以定期批量处理;实时通知回收更及时,批量方式则更容易控制请求开销。
SSD 没有机械寻道,不代表碎片完全免费。过多 extent 会增加元数据和查找成本,随机小写会加重控制器回收压力,空间接近写满时性能也可能明显波动。只是“物理相邻”的收益结构与机械硬盘不同了。

HDD 更在意磁头移动与连续布局;SSD 没有机械寻道,但删除后的页仍需通知控制器进入可回收状态。
设想一台专门提供网络文件服务的设备。客户端发来同步写请求,它希望服务器确认时数据已经安全;底层又是带校验信息的磁盘阵列,修改少量块可能触发额外读写。若每个请求都立刻原地改数据和元数据,延迟会很难看。
WAFL 的设计就是从这组工作负载出发。它仍然是基于块和 inode 的文件系统,但把文件系统组织成从根向下可达的一棵块树,普通文件、inode 文件、块映射和 inode 映射都能纳入这棵树。元数据也以文件形式组织,使它们能够像普通数据一样写到新的位置。
修改数据时,WAFL 可以把新数据和受影响的元数据写到新位置,再让活动文件系统的树指向新块。它会成批安排写入,尽量让同一 RAID 条带的更新一起发生,降低零散小写的校验开销。“写任意位置”强调的是不被旧块原位置绑住,并不意味着所有写入永远只在磁盘末尾形成一条纯顺序日志。
整棵树最终需要一个稳定入口。根 inode 的位置是写任意规则中的特殊点:系统启动必须先在约定位置找到它。只要根仍指向上一棵一致的树,新一批未完成写入就不会让旧视图失去解释能力。
创建快照时,可以先复制活动树的根引用。此刻快照和活动文件系统共享下面绝大多数块,所以创建动作很轻。以后某个数据块被修改,活动树改指向新块,快照继续指向旧块;旧块只要仍被任一快照引用,就不能回收到空闲池。
这解释了快照的两面性。它创建得快,也能让用户看到过去的只读视图;但快照保留期间,变化越多,旧块占用越多。删除快照还要更新引用关系并释放不再可达的块,空间回收不是无成本的。
为了尽快确认网络同步写,系统可以先把请求记录到非易失内存。内存中的文件系统修改积累到一定程度后,系统把一批脏数据和元数据写成新的一致状态,再更新根入口,这个批次边界就是一致性点。
若一致性点完成,新的根能找到完整的新树;若机器在中途故障,旧根仍描述上一致状态,重启后再利用非易失记录重放尚未进入一致性点的请求。这样,前台请求不必逐个等待完整的 RAID 落盘流程,恢复也不必扫描整个大文件系统来猜测状态。
这套方案支付的代价同样具体:需要可靠的非易失写缓存和重放逻辑;写时复制会带来额外块分配与碎片;快照让旧块不能立即回收;空间越紧张,找到合适写入位置和重新整理布局越困难。WAFL 的价值不在于消灭取舍,而在于把快照、批量写和快速恢复设计成同一棵树上的结果。

写时复制只重写从变化数据到根的相关路径;一致性点切换当前根后,旧根和未变化的块仍可组成快照。
现在再看一次“保存实验报告”:
程序用路径打开文件。内核逐段解析目录名,查询目录项缓存和挂载关系,最终得到文件对象并检查权限。
内核在进程描述符表中分配一个位置,让它指向打开文件描述;打开文件描述再引用 inode,并保存当前偏移和状态标志。
write 根据文件偏移找到对应的页面缓存页。若文件增长,分配器还要从位图或空闲区间中挑选块,并更新 extent 或索引树。
修改先成为脏页。后台写回、日志事务或写时复制协议把数据与元数据按可恢复的顺序送往设备。
文件系统实现之所以看起来复杂,是因为它始终同时维护速度、空间和可恢复性。缓存快了,但会拉大持久化窗口;连续布局快了,但增长和回收更难;索引查找快了,但需要更多元数据;写时复制便于快照和恢复,却会制造额外写入与碎片。理解这些机制时,最有用的问题始终是:它在解决哪一对冲突,又把成本转移到了哪里?
某编辑器保存文档时采用下面的流程:把新内容写入临时文件,调用同步接口,随后用原子改名替换旧文件。请回答:为什么“同步临时文件”与“同步包含它的目录”关注的是不同对象?若系统在改名后、目录更新持久化前断电,可能出现什么现象?
应用若需要明确的持久化保证,要调用同步接口并检查结果;close 只负责结束描述符生命周期,不能替代持久化协议。
文件删除后,目录名先消失;链接和打开引用都归零时,块才进入可回收状态。对 SSD,文件系统还可以向设备发送 discard 提示。