24.文件系统 API(1) - Katyusha's blog
mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6mobile wallpaper 7
4050 字
10 分钟
24.文件系统 API(1)
2026-07-26

文件系统 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 BlockFlash 内部编程与擦除的物理粒度

这些粒度可能相等,也可能完全不同。块设备抽象统一了寻址接口,但没有消除底层介质的性能、寿命和掉电语义。

分区与设备文件#

分区不是新的物理设备,而是整盘逻辑块地址空间中的一段区间。假设一个分区从整盘的 start_lba 开始,则访问分区内第 k 个逻辑块,近似转换为:

whole_disk_lba = start_lba + k

Linux 把整盘和分区都暴露成块设备对象:

/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 = root
current = 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 b

ab 的 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 对象;用户环境再通过符号链接组合出当前可见的 binlib 等入口。

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 课程讲义:资源 R085slides/aigc/lecture-24-fs-api-1-video-derived/notes.pdf,第 2-21 页;用于课堂展开、演示语境和机制串联。
  • 官方课程讲义:资源 R048slides/official/lecture-24-fs-api-1.md;用于核对第 24 讲的主线、API、示例与术语。
  • 讲义中的设备编号、文件描述符、容量、进程数和“三天前 generation”等均是课堂示例或运行快照,不应当作跨系统不变量。
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

24.文件系统 API(1)
https://katyusha-blog.com/posts/nju-os/os/persistence/file_api/
作者
katyusha
发布于
2026-07-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录