文件系统 API:从块设备到目录树
存储设备向操作系统提供的是按逻辑块编号读写的持久空间,应用真正需要的却是文件、目录、稳定名字、权限和并发访问规则。文件系统位于两者之间:向下把文件偏移和元数据更新转换为块 I/O,向上把原始块数组虚拟化为可组合的对象与名字空间。
从块设备到文件系统
块设备的接口模型
块设备可以抽象为固定大小逻辑块组成的数组:
read_block(block_id, buffer)write_block(block_id, buffer)block_id 通常对应逻辑块地址(LBA)。文件系统只依赖这套接口,不需要知道下层是机械硬盘、SSD、eMMC、VHDX 还是由普通文件模拟的 loop 设备。设备控制器可以继续在内部处理磁头调度、坏块重映射、FTL、磨损均衡与垃圾回收。
这里的“块”需要按层次区分:
| 层次 | 含义 |
|---|---|
| 设备逻辑块 | 块设备对主机暴露的寻址单位,如 512 B 或 4 KiB |
| 文件系统块 | 文件系统分配数据和元数据的单位,常见为 4 KiB |
| NAND Page / Erase Block | Flash 内部编程与擦除的物理粒度 |
这些粒度可能相等,也可能完全不同。块设备抽象统一了寻址接口,但没有消除底层介质的性能、寿命和掉电语义。
分区与设备文件
分区不是新的物理设备,而是整盘逻辑块地址空间中的一段区间。假设一个分区从整盘的 start_lba 开始,则访问分区内第 k 个逻辑块,近似转换为:
whole_disk_lba = start_lba + kLinux 把整盘和分区都暴露成块设备对象:
/dev/mmcblk0 整块 eMMC/SD 设备├── /dev/mmcblk0p1 第一个逻辑分区└── /dev/mmcblk0p2 第二个逻辑分区/dev/mmcblk0p1 是设备节点,不是保存设备数据的普通文件。它记录的设备号使 VFS 能把 open/read/write/ioctl 路由到相应块设备驱动。ls -l 输出开头的 b 表示 block special file,主设备号选择驱动或设备类别,次设备号区分具体设备与分区。
虚拟机同样可以制造块设备。例如 WSL2 把 Windows 上的动态扩展 VHDX 暴露为 /dev/sdX;Linux 看到的是可读写逻辑块数组,不需要知道它在宿主机上只是一个文件。设备显示的 1 TiB 往往是虚拟容量上限,不等于 VHDX 已经占用 1 TiB 物理空间。
Windows 的盘符也不是物理磁盘的同义词。C: 通常是某个卷的访问名字,内部可经 DOS 设备名字空间解析到类似 \Device\HarddiskVolumeN 的设备对象。\?? 可近似理解为当前登录会话使用的 DOS 设备名字空间入口;网络映射盘等名字可以因登录会话而异。
文件系统作为统一协调层
如果多个进程直接修改原始块,它们必须共同维护“块属于谁、哪些块空闲、目录项指向什么、缓存是否一致”等全局不变量。任何一个进程写错地址或在复合更新中途崩溃,都可能破坏其他进程的数据。
文件系统把原本分散的设备写入收敛为受控操作:
进程的文件操作 → VFS / 具体文件系统 → 对象与权限检查 → 元数据锁、日志、缓存和请求组织 → 块设备例如创建文件至少可能涉及目录项、文件元数据和空闲空间分配。文件系统负责让并发操作遵守统一的锁与更新协议,避免不同进程各自解释磁盘格式并互相覆盖。
这不意味着文件系统自动消除所有应用层竞争。两个进程同时写同一文件的同一区域仍可能发生覆盖或交错;应用仍需要根据语义使用 O_APPEND、文件锁、原子替换或事务。文件系统保证的是结构不变量和规定的 API 语义,而不是替应用决定业务级写入顺序。
文件与目录树抽象
文件是可伸缩的字节序列
内存可以从物理地址空间逐层抽象出虚拟内存、动态分配器和各种数据结构;磁盘同样可以从块数组抽象出文件:
块设备:固定大小逻辑块数组文件:可随机访问、可伸缩的字节序列文件可建模为:
F = (bytes, size)典型操作包括:
read / write 按当前偏移访问内容lseek 修改文件偏移ftruncate 改变逻辑长度mmap 把文件内容映射进虚拟地址空间文件系统负责把“文件偏移”翻译到具体数据块。一次小写入可能跨块,也可能要求先读入旧块再修改;稀疏文件还可能让一段逻辑地址暂时没有物理块。
路径、文件描述符和文件对象不是同一概念:
路径:查找对象的名字文件描述符:进程中已经打开对象的句柄文件对象/inode:文件系统内部身份与元数据路径会被重命名、替换或经过符号链接重新解析;已经打开的文件描述符通常继续引用原对象。可靠程序不能把路径字符串当作永远稳定的对象 ID。
目录是名字到对象的映射
目录树解决的是“大量文件如何命名和组织”。目录可理解为由目录项组成的特殊对象:
目录项:(name → file object)在典型 Unix 文件系统中,目录项保存名字和 inode 号,inode 保存类型、权限、链接计数、大小、时间及数据块索引。因此普通文件不是“复制进目录的内容”,而是由目录中的名字定位到文件对象。
层级目录利用信息局部性:
/├── home│ └── user├── etc└── var路径解析逐个处理组件:
current = rootcurrent = lookup(current, "home")current = lookup(current, "user")current = lookup(current, "notes.md")以 . 开头的文件并没有特殊的内核隐藏属性。readdir/getdents 仍会返回它们;ls 默认不显示只是用户态工具策略。
目录 API 与用户态 globbing
第 24 讲给出的目录原语包括:
int mkdirat(int dirfd, const char *pathname, mode_t mode);int unlinkat(int dirfd, const char *pathname, int flags);ssize_t getdents64(int fd, void *dirp, size_t count);带 at 的接口允许程序以已经打开的目录描述符为相对路径锚点,减少对进程当前工作目录的隐式依赖。它仍不自动消除“先检查路径、后使用路径”产生的 TOCTOU 竞争;更稳健的程序应尽可能持有已打开对象或目录的句柄。
Shell 中的:
echo /etc/**/*.conf通常不是由内核或 echo 搜索目录,而是 shell 先枚举目录、执行 glob 匹配,再把匹配结果作为参数传给 echo。不同 shell 对 **、隐藏文件和排序的规则可能不同。
多文件系统与挂载
挂载改变路径解析
一个块设备或分区可以包含一个文件系统数据结构,但二者不是同一概念:
块设备/分区 → 按 ext4、FAT、ISO9660 等规则解释 → 得到该文件系统的根目录对象mount 把这个根目录对象接入当前 mount namespace 的某个目录位置:
mount -t iso9660 /dev/cdrom /mnt/cdrom挂载不会复制光盘文件。它只是改变路径解析:
解析到 /mnt/cdrom → 跨过挂载边界 → 继续从光盘文件系统的根目录解析因此 Linux 可以把多个本地分区、U 盘、网络文件系统和伪文件系统组合为一棵统一目录树。不同进程还可以处于不同 mount namespace,从而看到不同的组合结果。
镜像文件与 loop 设备
.img 只是普通文件扩展名;镜像内容可能是一个文件系统,也可能是带分区表的整盘字节镜像。文件系统驱动要求块设备接口时,loop 驱动可以把普通文件包装成虚拟块设备:
disk.img → /dev/loop0 → 文件系统驱动 → 挂载点loop 设备把块访问翻译为对承载文件相应偏移的读写:
read_block(k) ≈ pread(image_fd, block_size, k * block_size)如果镜像直接包含 ext4 等文件系统,可以使用:
mount -o loop rootfs.img /mnt/image如果镜像包含 GPT/MBR 和多个分区,则需要先扫描分区,再挂载 /dev/loop0p1、/dev/loop0p2 等子设备。
loop 的真实 I/O 路径可能是:
镜像内文件系统 → loop 块设备 → 宿主普通文件 → 宿主文件系统 → 真实块设备每层都可能有缓存和刷新时机,因此上层 write 返回成功不等于数据已经越过所有缓存并稳定落盘。
文件名字与链接
硬链接共享文件对象
硬链接是“同一个文件对象的另一个目录名字”:
a ─┐ ├──→ inode → 数据b ─┘创建:
ln a ba 和 b 的 inode 号相同,对任一名字写入都会改变同一份内容。unlink("a") 删除的是目录项并减少链接计数,不是无条件擦除数据:
可回收条件:硬链接计数为 0,并且没有打开引用因此一个进程打开文件后,另一个进程删除它的最后一个名字,前者仍然可以通过文件描述符继续访问,直到最后一个打开引用关闭。
硬链接通常有两项限制:
- 不能跨文件系统,因为 inode 等内部对象编号只在所属文件系统内有意义;
- 普通用户不能为目录任意创建硬链接,否则目录会从树变成可能成环的图,
..的唯一父目录、遍历和回收都会失去简单不变量。
目录中的 . 和 .. 是由文件系统维护的特殊关系,不能据此认为普通程序也应获准创建目录硬链接。
符号链接执行路径级间接
符号链接自身是一个独立文件对象,内容是目标路径文本:
ln -s ../target link解析 link/rest 时,可以近似理解为路径替换与拼接:
相对目标:parent(link) + "../target" + "/rest"
绝对目标:"/absolute/target" + "/rest"相对目标相对于符号链接所在目录解析,而不是相对于调用进程的当前工作目录。绝对目标则从本次路径解析的根重新开始。
符号链接具有硬链接没有的能力:
- 可以跨文件系统;
- 可以指向目录;
- 创建时目标可以不存在;
- 可以组合出图结构甚至形成循环。
循环解析必须受到展开次数限制,最终返回类似 ELOOP 的错误。符号链接还会引入检查与使用之间的竞态;安全敏感代码需要结合目录描述符、openat 家族和禁止跟随链接的选项控制解析边界。
| 对比 | 硬链接 | 符号链接 |
|---|---|---|
| 保存的关系 | 名字直接引用同一对象 | 独立对象中保存目标路径 |
| inode | 与其他名字相同 | 有自己的 inode |
| 跨文件系统 | 通常不能 | 可以 |
| 指向目录 | 普通用户通常不能 | 可以 |
| 删除原名字 | 其他硬链接仍有效 | 可能成为悬空链接 |
| 核心风险 | 生命周期与链接计数 | 路径循环、悬空和解析竞态 |
Nix 的不可变对象与代际环境
Nix 把软件包的不同构建结果保存到类似下面的路径:
/nix/store/<hash>-firefox-<version>/nix/store/<hash>-python3-<version>store 路径的身份与构建输入、依赖关系等共同决定,不应简单理解为“只对最终文件内容做一次 hash”。新版本不会原地覆盖旧版本,而是产生新的 store 对象;用户环境再通过符号链接组合出当前可见的 bin、lib 等入口。
generation 1 ──→ Python 3.11 的 store 路径generation 2 ──→ Python 3.12 的 store 路径current ──→ 当前选择的 generation切换环境的核心是改变“根链接指向哪一代”,旧对象和旧 generation 仍可被引用,因此能够快速回滚。这里的 persistent data structure 是数据结构意义上的“更新后旧版本仍可访问”,不是单纯指断电后数据不丢失。
课堂用下面的模型概括它:
append-only write:增加新的不可变 store 对象random read:通过链接选择并访问任意旧版本这和传统包管理器原地覆盖 /usr/bin、/usr/lib 的模型形成对比。nix-shell -p python3 nodejs 还能临时组合一个包含指定软件的环境,而不必永久改写全局目录。
课堂为突出结构,把 /nix/store 近似称为“只增不减”。真实 Nix 仍支持垃圾回收:当某个 store 路径不再被任何 GC root 或 generation 引用时,它可以被删除。因此更准确的说法是“正常更新不原地改写对象,回收由可达性统一决定”。
文件元数据与访问控制
ls -l 是元数据视图
ls -l 把多种元数据压缩成一行:
-rwxr-xr-x 2 user group 4096 ... file其中包括:
- 文件类型:普通文件、目录、符号链接、块设备、字符设备等;
- user/group/other 三组权限位;
- 硬链接计数;
- 所有者和所属组;
- 大小、时间与名字。
它是工具生成的视图,不是 inode 的原始内存或磁盘布局。
权限位必须结合对象类型解释。对普通文件,读、写、执行对应内容访问;对目录:
| 目录权限 | 作用 |
|---|---|
r | 枚举目录项名字 |
w | 创建、删除或重命名目录项 |
x | 穿越目录并按名字查找对象 |
最终授权还可能受到 ACL、挂载选项、Linux capabilities 和安全模块影响,因此只看九位 mode 不一定能解释所有 EACCES。
动态目录项、扩展属性与 ACL
procfs 说明目录项不必全部预先存储在磁盘上。访问 /proc/<pid> 时,内核可以根据当前进程状态动态生成对象;按名字 lookup 能成功,不代表该名字此前存在于一个静态目录表中。tree /proc 的数量只是某一时刻的运行快照。
扩展属性(xattr)把额外键值附着到文件对象:
ssize_t fgetxattr(int fd, const char *name, void *value, size_t size);int fsetxattr(int fd, const char *name, const void *value, size_t size, int flags);下载来源、安全标签、应用索引等信息可能位于 xattr。ACL 则在传统 user/group/other 九位权限之外,为更多用户或组表达细粒度授权。
因此“复制一个文件”有不同的完整性契约:
只复制内容字节复制基本 stat 元数据保留硬链接关系保留 xattr 与 ACL普通复制或归档成功不代表所有属性都被保留。若任务要求完整迁移文件对象,应显式规定需要保真的集合,并在目标端验证。
文件系统抽象的贯通模型
整讲可以压缩为下面的分层关系:
| 层次 | 核心职责 | 常见误解 |
|---|---|---|
| 块设备 | 提供按逻辑块编号读写的空间 | 把块写完成等同于全链路持久化 |
| 分区 | 截取整盘的一段逻辑 LBA 区间 | 把分区当成独立物理设备 |
| 文件 | 提供可随机访问、可伸缩的字节序列 | 把路径或 fd 当作永久对象身份 |
| 目录 | 保存名字到文件对象的映射 | 把目录树理解成文件内容的复制 |
| 挂载 | 把文件系统根接入路径解析视图 | 认为挂载复制了数据 |
| 硬链接 | 让多个名字共享同一对象 | 认为其中一个名字是“原文件” |
| 符号链接 | 在解析过程中引入目标路径 | 忽略相对锚点、循环和 TOCTOU |
| 元数据 | 描述类型、权限和附加语义 | 只复制内容却声称完整迁移 |
目录树不是唯一可能的数据组织方式,但它具有局部可枚举、权限与挂载边界较直观、路径能被文本工具传递、生态兼容性强等优点。第 24 讲的核心不是记住一组命令,而是建立一个稳定模型:
设备提供块→ 文件系统建立对象→ 目录项给对象命名→ mount 组合多个文件系统→ 链接改变对象共享或路径解析→ 元数据决定对象如何被解释和访问编写文件系统相关程序时,应先明确自己操作的是路径名字还是已打开对象,再检查解析根、目录锚点、符号链接策略、并发更新、权限来源以及复制保真范围。
来源与证据边界
- AIGC 课程讲义:资源
R085,slides/aigc/lecture-24-fs-api-1-video-derived/notes.pdf,第 2-21 页;用于课堂展开、演示语境和机制串联。 - 官方课程讲义:资源
R048,slides/official/lecture-24-fs-api-1.md;用于核对第 24 讲的主线、API、示例与术语。 - 讲义中的设备编号、文件描述符、容量、进程数和“三天前 generation”等均是课堂示例或运行快照,不应当作跨系统不变量。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时






