你在编辑器里按下“保存”,屏幕上只闪过一瞬间,文档就留在了某个文件夹里。第二天重新开机,它还在那里;把它改名、移动到别的目录,内容也没有跟着消失。这个体验太自然了,以至于我们很容易忘记:存储设备真正提供的只是按地址读写数据的能力,它并不知道什么叫“项目报告”,也不会主动维护文件名、目录、权限和修改时间。
文件系统就在这两种视角之间工作。向上,它给程序提供文件、目录、路径、文件描述符等稳定接口;向下,它要把逻辑字节落到存储介质,还得处理缓存、并发、权限和故障。它解决的核心矛盾是:应用想要简单、连续、可命名的对象,底层资源却是有限的块、异步的写入以及随时可能发生的竞争。
这一节先站在使用者和系统调用的视角看文件系统。至于空闲空间怎样管理、一个大文件的逻辑块怎样映射到物理块、崩溃后如何恢复一致性,会留到后续实现章节再拆开。

双击一张图片时,桌面环境会显示缩略图,应用会按图像格式解析像素;操作系统看到的东西要朴素得多:一个带名字、类型、长度、权限和时间戳的对象,以及一串可以读取或写入的字节。文件系统通常不负责理解“这是一份论文还是一首歌”,文件内容的语义主要由应用解释。
这种抽象很有用。文本编辑器、数据库和编译器处理的内容完全不同,却都可以使用打开、读取、写入、定位和关闭这一组接口。底层是固态硬盘、机械硬盘,还是通过网络挂载的远端存储,上层程序也不必为每一种设备重新设计一套保存逻辑。
文件的数据是应用真正写进去的内容。元数据描述文件本身,常见内容包括:
“文件长度”和“占用多少磁盘空间”并不总是相同。文件尾部可能只使用了最后一个块的一小部分,也可能存在没有实际分配存储块的空洞。读取空洞时,程序看到的是零字节;真正写入空洞区域后,文件系统才需要为相应范围分配空间。这类稀疏文件常见于虚拟磁盘镜像、科学计算结果和大型预分配文件。
扩展名只是名字的一部分。把 photo.jpg 改成 photo.txt,并不会自动改变内部字节。应用经常结合扩展名、文件开头的格式标记和实际内容来判断类型。操作系统更关心它是不是普通文件、目录或其他内核对象,而不是它属于哪一种办公文档格式。
你可以把文件理解成“稳定身份加可变内容”。名字帮助人找到它,文件系统内部标识帮助内核识别它,元数据约束谁能怎样使用它,数据区才保存正文。名字和文件对象不是同一个东西,这一点会在硬链接里变得非常关键。
程序每次读几个字节时,如果都从根目录重新解析完整路径、检查权限、寻找元数据,成本会很高。于是操作系统把“按名字找到对象”和“持续使用这个对象”拆成两步。
调用 open 后,内核先解析路径并进行权限检查,再创建或找到一个“已打开文件描述”。这个内核对象保存本次打开的状态,例如访问模式、状态标志和当前文件偏移量。进程得到的只是一个小整数,也就是文件描述符。后续 read、write、lseek、fstat 和 close 都拿这个整数说话。
这里有三层对象,第一次接触时最容易混:
同一个文件可以被打开多次。两次独立 open 通常得到两个独立的已打开文件描述,所以偏移量各走各的。dup 复制文件描述符,或者进程 fork 后继承描述符时,多个描述符可能指向同一个已打开文件描述,此时它们共享偏移量和部分状态标志。一个描述符读走 4 个字节,另一个描述符的下一次读取也会从新的位置开始。
下面的实验把内容固定为十个字符。切换“独立打开”和“复制描述符”,再分别点击两个读取按钮,你会直接看到偏移量是否互相影响。所有代码都封在页面中,不需要联网。
创建文件会建立目录项与元数据,必要时再分配数据空间。open 建立一次可持续使用的内核状态,read 把文件内容送入用户缓冲区,write 修改内容,lseek 改变当前偏移,close 解除这个描述符与已打开对象的关联。
删除也值得仔细看。unlink 删除的是路径中的一个名字,并让硬链接计数减少。只要还有其他硬链接,文件仍能从别的路径找到;即使最后一个名字已经删除,只要某个进程仍保持文件打开,内核通常也要让这个进程继续使用它,等最后一个打开句柄释放后再回收数据。于是“目录里看不见”和“存储空间已经释放”不是同一时刻。
rename 主要改变目录中的命名关系,不必把整个文件内容复制一遍。同一文件系统内的重命名可以作为一个原子的命名操作完成,其他进程不会看到半个名字。但跨文件系统移动往往要退化成复制新文件再删除旧文件,成本和失败方式都不同。
write 成功通常只说明数据已经交给内核处理,不必然意味着它已经安全落到非易失介质。页缓存、设备缓存和存储控制器都可能延后真正写入。需要明确持久化边界的程序会使用同步接口,并认真处理错误;数据库还会把写入顺序、日志和恢复协议一起设计,而不是只相信一次 write。
关闭文件是资源生命周期的一部分,不只是代码结尾的礼貌动作。描述符泄漏会逐渐耗尽进程或系统可用的打开项;缓冲输出也可能直到关闭或刷新时才暴露错误。程序应在成功与异常路径上都明确释放资源。
播放一个几小时的视频时,播放器通常从头向后取数据;数据库查一条索引记录时,却可能直接跳到文件中间。两类需求都在访问文件,但它们对“位置”的表达不同。
顺序访问把文件当作流。程序从当前偏移读取一段,偏移自动前进,再继续读下一段。日志扫描、压缩流、音视频解码和编译器逐字分析源文件都很适合这种方式。它的优势是访问模式可预测,缓存和预读容易发挥作用;代价是如果目标在文件末尾而格式又没有索引,程序可能必须处理前面的全部内容。
顺序访问描述的是程序观察到的读写顺序,并不等于“磁盘块一定用链表连接”。文件怎样分配存储块属于实现问题。即使文件底层采用范围映射或多级索引,程序依然可以顺序读取它。
普通文件通常支持按字节偏移定位。程序可以把偏移改到指定位置,再从那里读写。数据库数据页、可执行文件中的代码段和媒体容器的时间索引都会利用这点。直接访问给了程序灵活性,但“把偏移设到某处”不代表只发生一次物理 I/O:文件系统还要把逻辑偏移换算成文件逻辑块,再查询块映射、页缓存和存储设备状态。
如果数据格式自己维护索引,程序可以先从键找到记录位置,再访问目标范围。这里的“索引访问”更像建立在直接访问之上的数据组织策略。维护索引会占用空间,插入、删除时还要更新它;换来的好处是避免全文件扫描。

选择目标逻辑块,再比较顺序扫描、已知偏移和先查索引三种策略。实验显示的是概念步骤,不把“逻辑步骤数”冒充真实磁盘 I/O 次数,因为缓存是否命中、索引是否已在内存、设备调度怎样工作都会改变实际成本。
大多数普通文件 I/O 会经过页缓存。第一次读取某段数据时,内核可能要从存储设备取回对应页面;稍后再次读取,如果页面仍在内存中,就能直接命中缓存。连续访问还可能触发预读,内核提前把后续页面带入内存。随机跳跃太频繁时,预读效果下降,缓存也更容易被大量分散页面挤占。
写入通常先修改内存中的缓存页,把它标记为脏页,再由后台回写。这样可以合并小写入、重排设备请求,提高吞吐量;代价是程序看到“写完”和数据真正持久化之间存在窗口。性能与持久性在这里是一笔明确的交换。
想象一个没有目录的系统:所有人的所有文件都挤在同一张长表里。重名会直接冲突,项目也无法按层次整理。目录解决的是命名空间问题。它不是一个装文件的物理盒子,更接近一张“名字到文件身份”的映射表。
在类 UNIX 文件系统中,目录项通常包含名字和 inode 编号。查找 /home/lin/report.txt 时,系统不会把整串文字当成一个全局键,而是从根目录开始逐段解析:先找 home,再进入它指向的目录;接着找 lin;最后在 lin 目录里查 report.txt。每走过一个目录,都要有搜索权限。
绝对路径从根开始,含义不依赖进程当前目录。相对路径从当前工作目录开始;“.”表示当前目录,“..”表示父目录。相对路径写起来短,却把含义绑定到运行环境。服务程序、安装脚本和安全敏感代码若对当前目录作了错误假设,就可能打开意料之外的文件。
单级目录实现简单,但所有名字共享一个空间。两级目录给每个用户一张独立表,缓解重名问题,却仍难以组织复杂项目。树形目录允许目录包含子目录,成为现代系统最常见的视图。
一旦加入链接,这棵树就不再完全是树。挂载还能把另一个文件系统的根接到某个目录点上,让多个存储来源出现在一个命名空间中。用户看到的是连贯路径,路径背后可能跨越设备、本地与网络文件系统。

硬链接创建一个新的目录项,让它与已有名字指向同一个 inode。两个名字地位相同,共享内容、所有者、权限和时间信息。修改任一名字看到的内容,另一个名字也能看到;删除其中一个名字只让链接计数减一。
硬链接通常不能跨文件系统,因为 inode 编号只在所属文件系统中有意义。普通用户也通常不能随意给目录建立硬链接,否则很容易制造循环,让遍历和回收语义变得危险。
符号链接则是一个独立的小文件,内容保存目标路径。访问它时,路径解析器再去解析这段目标路径。它可以跨文件系统,也可以指向目录;代价是目标一旦被移动或删除,符号链接可能悬空。若符号链接保存相对路径,这个相对路径是从符号链接所在目录解释,而不是从使用者当前目录解释。
点击路径中的组成部分,观察系统怎样一层层找到最终对象;再切换链接类型,比较删除原名字后的结果。
先检查路径是否安全,再隔一段时间打开它,看似谨慎,实际存在检查与使用之间的时间窗口。另一个进程可能在两步之间替换目录项或符号链接。更稳妥的思路是让检查和打开尽量落在一个原子接口里,或先获得目标目录的稳定描述符,再相对它执行后续操作。
这说明文件名是共享状态。只要多个进程都能改同一个目录,路径解析就不是纯粹的字符串处理,而是一次可能与别人并发交错的命名空间访问。
多人共用服务器时,同一个存储设备上既有公共程序,也有个人密钥和服务配置。操作系统必须在每次访问时回答:当前进程以什么身份行动,它请求什么操作,目标对象允许吗?
类 UNIX 权限位把进程分到所有者、所属组、其他用户三类之一,再从对应的三位中判断读 r、写 w、执行 x。它不是把三类权限叠加起来:若进程匹配所有者,就使用所有者那一组三位;否则再看组,最后才用其他用户。

对普通文件:
对目录:
目录权限的组合常让人意外。一个目录可读但不可搜索时,可能列得出名字,却无法取得其中对象的详细信息;可搜索但不可读时,如果提前知道准确名字,仍可能访问目标。删除一个文件主要修改父目录的目录项,所以是否能删除通常取决于父目录权限,而不只是文件本身是否可写。
八进制写法只是三位二进制的紧凑表示:r 对应 4,w 对应 2,x 对应 1。rwx 是 7,rw- 是 6,r-x 是 5,r-- 是 4。于是 754 表示所有者 rwx、组 r-x、其他用户 r--。
新建对象的初始模式还会受到 umask 影响。umask 指出哪些权限位要从请求的模式中屏蔽掉,它不是直接写出最终权限。程序若不理解这一点,容易创建过度开放的配置文件或密钥文件。
三组权限位简洁高效,却不方便表达“只额外授权给某一个用户”。访问控制列表可以添加更细的用户或组条目。系统还可能叠加挂载策略、安全模块、只读属性和能力机制。最终访问结果是多层规则共同作用,而不是看 ls -l 的九个字符就能推断全部情况。
特权进程在一定条件下可以绕过普通权限检查,但这不是让程序长期以最高权限运行的理由。更安全的做法是只保留完成任务所需的最小权限,并把路径解析、权限下降和外部输入的边界设计清楚。
勾选三类主体的 r、w、x,实验会立即计算符号形式和八进制形式。再选择对象类型,观察 x 在普通文件与目录上的语义变化。
“先用 access 检查,再 open”并不能天然变安全,因为路径可能在两次调用之间变化。真正的访问仍应由打开或操作目标的系统调用完成权限判定;安全设计还要减少可变路径组件和检查—使用窗口。
数据库需要频繁访问一个几 GB 的数据文件。如果每次都自己计算缓冲区、调用 read、处理拷贝和淘汰,代码会很复杂。内存映射提供另一种接口:把文件的一段关联到进程虚拟地址空间,程序随后像访问数组一样读写对应地址。
这不是把整个文件立刻复制进内存。mmap 建立的是虚拟地址范围与文件偏移范围的关系。第一次访问某个尚未驻留的页面时,处理器触发缺页,内核在页缓存中查找;若没有,就从文件读取相应数据,建立页表映射,再让指令重新执行。访问多少页,才逐步带入多少页。

共享映射允许修改反映到同一文件的共享页面,并可以被映射同一区域的其他进程观察到。页面变脏后,内核会安排回写;程序若有明确持久化要求,仍需使用同步手段并处理错误。
私有映射采用写时复制。读取时可以共享文件页,一旦进程写入,修改落在该进程自己的私有页面中,不回写到底层文件,其他进程也看不到这份修改。可执行程序的代码与数据装载经常利用文件映射和写时复制来节省内存。
mmap 可以减少显式系统调用与用户缓冲区管理,让随机访问写得自然,并让多个进程共享页缓存。但它不是“永远比 read 快”的按钮:
多个进程共享映射同一文件时,能看到共同页面,不等于自动拥有正确的并发协议。读写顺序、内存可见性、数据结构一致性仍需要锁、原子操作或更高层事务来保证。
页缓存常把 read/write 与 mmap 连接到同一批文件页面。二者差别主要在程序怎样发起访问、怎样接收错误和管理地址空间,而不是一个“走内存”、另一个“完全绕过内存”。
两个程序同时更新同一份余额文件。它们都先读到 100,一个加 20,一个减 10,然后分别写回 120 和 90。最后结果取决于谁覆盖谁,正确的 110 反而没有出现。文件系统保证单次系统调用有明确边界,却不会猜出一组“读取—计算—写回”应当构成一个业务事务。
文件锁让协作进程建立访问协议。共享锁允许多个读者同时持有;独占锁与其他共享锁、独占锁冲突,适合写者修改期间排除其他参与者。有的接口锁整个文件,有的接口锁某个字节范围。

类 UNIX 系统里常见的文件锁是建议性锁。内核记录锁并协调也使用锁接口的进程,但另一个拥有权限、却完全不检查锁的程序,仍可能直接读写文件。因此锁能否保护数据,取决于所有参与者是否遵守同一协议。
不同锁接口对“锁属于谁”的定义可能不同:有的与进程关联,有的与已打开文件描述关联;复制描述符、fork、再次独立 open 或关闭任意描述符时,释放规则也可能变化。写跨平台程序时,不能只记住“close 会解锁”这样模糊的一句话,必须确认所用接口的精确语义。
两个程序若以相反顺序锁住文件 A 和文件 B,就可能互相等待。解决办法包括规定全局加锁顺序、缩小锁范围、设置非阻塞或超时策略,并确保异常路径释放锁。
锁得太粗,正确性容易理解,但并发度低;锁得太细,读写可以并行,却会增加锁记录、协议和死锁分析的复杂度。共享锁也可能让持续到来的读者拖延写者,具体是否发生取决于实现的唤醒和公平策略。
文件锁还不等于崩溃一致性。进程崩溃后,内核通常能释放与其生命周期绑定的锁,但已经写到一半的数据不会因此自动回滚。需要原子替换小文件时,常见做法是写入同目录临时文件、同步必要内容,再用原子重命名替换目标;需要复杂多步更新时,则要用日志或事务协议。
不要把“拿到独占锁”理解成“数据已经安全”。锁解决活着的参与者之间谁先操作;同步写、写入顺序、日志与恢复解决突然断电或进程崩溃后留下什么状态。这是两组不同的问题。
现在回到最开始的编辑器。用户点击保存后,一个稳妥的实现可能经历下面这些步骤:
编辑器先把目标路径拆成目录组成部分。内核从根目录或当前工作目录出发逐段解析,检查每层目录的搜索权限,最终定位父目录与目标目录项。
打开操作进行访问检查,建立已打开文件描述,并把文件描述符交给编辑器。之后的写入通过描述符进行,不必每次重新查完整路径。
为避免半份新内容覆盖完整旧文件,编辑器可以在同一目录创建临时文件,把新内容写完并检查错误。若有并发编辑协议,还要在约定范围内取得锁。
写入先进入页缓存,脏页随后回写。若应用承诺“保存完成后即使掉电也尽量保留”,就要在合适位置请求同步,并处理设备返回的失败。
临时文件准备完成后,通过同一文件系统内的原子重命名替换旧名字。其他进程看到的是旧版本或新版本,而不是重命名进行到一半的目录状态。
这一串动作展示了文件系统接口真正解决的矛盾:路径让人能组织对象,描述符让进程稳定持有对象,缓存提高吞吐,权限隔离用户,锁协调并发,原子命名与同步接口帮助程序划定一致性边界。每层机制都提供便利,也把新的状态和代价带进系统。
假设程序先用 fd 3 打开文件,然后通过 dup 得到 fd 4。fd 3 读取 100 字节后,fd 4 再读取 20 字节。若中间没有 lseek,第二次读取会从哪里开始?
读完这一节,最值得保留的不是一串系统调用名字,而是一条对象链:路径通过目录项找到文件身份,open 把它变成进程可持续使用的描述符,偏移量说明当前访问位置,权限与锁约束谁能怎样操作,页缓存和映射决定数据怎样进入内存,持久化协议再回答故障后能留下什么。