1. 前言

本文以 512 KB 虚拟磁盘为例,说明文件系统的磁盘布局、元数据结构、路径解析和基本文件操作。

2. 什么是文件系统

文件系统通过数据结构和算法,把磁盘块组织成目录、文件和可读写的路径层级。
磁盘用于长期保存数据;与易失性内存不同,断电后仍能保留已经写入的数据。
可以把磁盘类比为一个仓库:

2.1 常见存储设备

常见的磁盘/存储设备包括:
不同设备使用不同的物理介质:
对操作系统而言,这些设备通常都可以抽象为块设备(block device)。操作系统不需要了解底层使用盘片还是闪存,只需要按编号读取或写入数据块。

2.2 文件的管理单位

如果按单个比特或字节管理整个磁盘,系统就必须额外跟踪每个文件的起止位置和所有空闲区间。
假设磁盘是这样:010101010111000101010101000111……
查找文件时,需要解决以下问题:
为了简化分配、定位和回收,操作系统与存储设备会把空间划分为固定大小的管理单位:
  • 扇区(sector):存储设备硬件层面的基本读写单位;
  • 块(block):操作系统或文件系统管理数据时使用的单位;
  • 簇(cluster):许多文件系统分配空间时使用的单位,通常由一个或多个扇区组成。
传统硬盘扇区常见大小是 512 字节,较新的 HDD 和 SSD 常见 4096 字节。文件系统通常不是按照单个扇区分配文件,而是按 block/cluster 这样的分配单位管理文件和目录。

2.3 文件的结构

本例把虚拟磁盘划分为 1024 个块,每块 512 字节,因此总容量为 1024 × 512 B = 512 KB
fileSystem.dat 本质上就是一个 512 KB 的普通文件,我们人为把它切成了 1024 块:
程序随后可以按块号执行读写:
按块管理比直接跟踪每个字节的位置更容易维护。

2.4 为什么需要按块读写

  • 简化空间分配和回收;
  • 便于记录物理磁盘或虚拟磁盘的使用情况;
  • 提高文件定位和读写效率。

3. 文件系统的功能

底层存储只提供按块读写能力。文件系统需要在此基础上提供目录、路径和文件操作,例如:
这些命令由文件系统转换为路径解析、元数据更新和数据块读写。

3.1 文件系统的目的

文件系统的目标是组织和存储数据,并且让数据在重启之后仍然存在。它需要用磁盘上的数据结构表示目录树,记录文件内容所在的数据块和哪些磁盘区域是空闲的。就像上面我们把文件系统比喻成了一个仓库管理系统,它可以分成 8 个核心部分:虚拟磁盘、SuperBlock、inode bitmap、block bitmap、inode table、data blocks、目录项 和 命令功能。

3.2 文件系统的组成

以下 C++ 代码均为用于说明数据结构和接口的局部片段,不构成可独立编译的完整实现。
3.2.1 fileSystem.dat 虚拟磁盘
构建一块模拟磁盘
3.2.2 磁盘布局
构建出一块模拟磁盘之后,我们需要规定:
为什么要固定布局?因为程序启动后必须知道:
3.2.3 SuperBlock 文件系统总信息
记录:
为什么要有 magic?因为程序启动时要判断:fileSystem.dat 是不是我这个文件系统创建的?
程序启动时逻辑是:
SuperBlock 的本质是:让程序知道整个文件系统的基本布局和状态。
3.2.4 bitmap 空闲空间管理
文件系统必须知道:
这就需要 bitmap。
bitmap 是一串 0 和 1:
例如:
意思是:
inode bitmap:管理 inode 是否空闲
block bitmap:管理数据块是否空闲
这部分的本质是:解决“空间从哪里来、删除后怎么回收”的问题。
3.2.5 inode 物品登记表
一个文件不等于文件名。
inode 保存文件的元数据:
为什么 inode 不直接保存文件名?
因为文件名是目录负责的,inode 只负责文件本体。
inode = 文件本体
目录项 = 文件名到 inode 的映射
例如:
这就是一个完整文件。
3.2.6 data blocks 保存内容的地方
数据块区从 block 19 开始,它可以保存两类东西:
普通文件和目录,本质上都占用 inode 和 data blocks,区别只是 data blocks 里面放的内容不同。
目录在内部很像文件:目录的 inode 类型是目录,目录的数据是一系列 directory entries,每个 entry 包含 name 和 inode number。
3.2.7 DirEntry 目录项
目录项负责解决:用户输入 a.txt,我怎么知道它对应哪个 inode?
比如根目录 / 里有一个 /docs
这表示:
当用户输入:
程序就读取根目录 inode 的数据块,扫描里面的 DirEntry。
目录项的本质是:把可读的名字,转换成系统内部使用的 inode 编号
3.2.8 路径解析
当用户输入:
路径解析过程是:
路径解析分两类:
为什么要有 resolveParent
因为创建文件时,目标文件还不存在。例如:
这时候 /docs/a.txt 还没有 inode,所以不能直接找 a.txt 的 inode。正确做法是:
路径解析是连接“用户命令”和“底层 inode”的桥。

3.3 文件系统的操作

本节从「ls:列出目录」「create:创建空文件」和「write:写入文件」等方面说明文件系统的操作。
3.3.1 ls:列出目录
3.3.2 create:创建空文件
实现过程:
注意:create 创建的是空文件,所以此时:a.txt 有 inode 但还没有数据块,因为还没写内容。
3.3.3 write:写入文件
实现过程:
最终结构变成:
所以,文件内容不是存在文件名里,也不是存在目录项里,而是在 data block 里,inode 只是记录内容在哪些 block 里。
3.3.4 read:读取文件
过程:
也就是:
3.3.5 delete:删除文件
过程:
删除不是只删除文件名,删除文件必须按顺序处理三件事:
如果只删除目录项而不释放 inode 和数据块,就会造成空间泄漏;如果只释放 inode 而不删除目录项,目录中会留下悬空引用。
文件系统必须维护元数据的一致性。
3.3.6 openclose:管理打开文件表
open 不直接读取文件内容,而是把文件登记到打开文件表并返回文件描述符 fdclose 负责释放对应的打开文件表项。
这表示打开文件表的第 0 项正在记录 /docs/a.txt。打开文件表一般保存:
openclose 只负责维护打开文件表。真实操作系统通常采用以下调用形式:
3.3.7 mkdir:创建目录
过程:
目录同样由 inode 和数据块表示;目录的数据块保存文件名与 inode 编号的映射。
3.3.8 rmdir:删除空目录
过程:

4. 当前实现的边界与改进方向

这是教学用简易文件系统,不应直接等同于生产文件系统。实现时至少需要明确以下边界:
  • rmdir 默认只应删除空目录;如果支持递归删除,必须显式设计遍历、失败回滚和中途崩溃处理;
  • write 不宜先释放旧数据块再尝试分配新块,否则空间不足或写入失败时可能丢失旧内容。更安全的思路是先确认新资源,再提交元数据更新,失败时回滚;
  • 目录项名称、路径长度、块号、inode 号和文件大小都需要做边界检查;
  • 从磁盘读取的结构不能无条件信任,应验证 magic、版本、布局范围和 bitmap 一致性;
  • 直接把 C++ struct 的内存字节写入文件会受到对齐、填充、大小端和版本变化影响,稳定格式应明确序列化每个字段;
  • 当前模型没有日志、写时复制或事务,程序在元数据写到一半时崩溃,可能出现目录项、inode 与 bitmap 不一致;
  • 打开文件表还需要考虑 fd 重用、偏移更新、重复打开、删除后仍被打开等语义;
  • 权限、并发、缓存、硬链接、符号链接和大文件间接索引均未覆盖。
因此本文的核心目标是理解“路径 → 目录项 → inode → 数据块”的主线和基本分配流程。

5. 总结

用 shell 命令进行一次文件操作的具体流程:
文件系统就是把“路径名”一步步翻译成“磁盘块号”,然后对这些块进行读写。