第25讲 · 文件系统 API(2)

2026 南京大学《操作系统原理》第二十五讲 · 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV12gVs6SExQ/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 https://jyywiki.cn/OS/2026/lect25.md
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第25讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(lect25.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子

(参考时间: 00:00)

上节课讲了文件系统增删改查的 API(mkdir、link、unlink、symlink)、软/硬链接、以及文件的属性(mode、metadata、extended attributes)。

今天在 CRUD 的基础上,设计真正的「文件系统级」API。 思路回到根本:文件系统是一个数据结构(Abstract Data Type)——既然是数据结构,就可以定义各种更「花哨」的操作。本次课讲四件事:

  1. 监控(inotify,→ eBPF)
  2. 快照(persistent data structure,→ git、btrfs)
  3. 覆盖/联合(overlay、union fs,→ Docker)
  4. 终极虚拟化(既然文件系统是数据结构,凭什么不能自己写一个 → FUSE)

编者拓展|数据结构里的「全局操作」trick:讲师举了个例子——如果数据结构要支持「对所有元素加 10」,你既可以遍历一遍逐个加,也可以把「+10」悄悄记在数据结构旁边(惰性标记):取元素时再加上,插入 9 时插 −1。「数据结构里可以干很多 fancy 的事情,凭什么文件系统不能干?」


一、监控文件系统的变化

1.1 需求:文件变了通知我

(参考时间: 00:04)

典型场景:Web Server 的 debug/reload——代码改了要重新加载(比如 Next.js:你一保存 CSS,网页瞬间就变了);让 coding agent 一步一步改前端,你能看界面一点点变化。「这是一个非常实用的需求。」

原则上不用操作系统帮助也能实现——轮询 + diff:

# 用 stat 打印所有文件的修改时间,睡眠两秒后再打印,然后 diff
stat --format='%n %Y' *    # 立即得到所有文件的 mtime
sleep 2
# 修改 a.txt,再 stat 一次
diff name1 name2           # 没改就什么也不返回
轮询 + diff:一行脚本就能实现「哪些文件改了」

📷 轮询 + diff:一行脚本就能实现「哪些文件改了」
回到原视频 (00:06:30)

但问题:如果目录里有几百万个文件,每次遍历就是几百万次元数据查询——效率太低。

1.2 inotify:让文件系统主动通知你

(参考时间: 00:09)

现代操作系统提供了机制——inotify_init 和 inotify_add_watch:

int inotify_init1(int flags);        // 返回一个文件描述符
int inotify_add_watch(int fd, const char *pathname, uint32_t mask);
int inotify_rm_watch(int fd, int wd);
// 配合 read() 读出 struct inotify_event

使用场景:文件管理器的自动刷新、编辑器(VSCode)的自动重载、systemd 监控单元文件、tail -f……

inotify:返回 fd,可以用 poll/epoll 监听文件系统事件

📷 inotify:返回 fd,可以用 poll/epoll 监听文件系统事件
回到原视频 (00:09:30)

Watchdog 演示(一个 Python 脚本,封装了 inotify):

# web-code 出来的 watchdog:递归 watch 一个目录
# 创建目录、touch a.txt 时,能看到 created / modify 事件
watchdog 演示:touch 一个文件时,收到 created,并且它的父目录 mtime 也变了

📷 watchdog 演示:touch 一个文件时,收到 created,并且它的父目录 mtime 也变了
回到原视频 (00:12:00)

编者说明:一个你可能没留意过的细节——你改一个文件的 mtime,它上一级目录的 mtime 也会变。但不会一层层向上传递:「t/1/2 里 touch 一个文件,只有 t/1 和 t/1/2 变了。」(因为只有直接父目录被 open/修改。)

1.3 从 inotify 到 eBPF

(参考时间: 00:13)

回忆上节课的 block I/O trace:你能看到系统里所有进程执行块设备 I/O 的操作(读、写、同步、落盘)。「这很有启发性」——我们真正想监控的不只是文件变化,而是整个操作系统这个「程序」运行时的各种状态变化。「本质上操作系统也是代码。」

自然的想法:既然想要 trace 操作系统的代码——能不能写一段代码,插到内核里,让内核每个函数执行时都跑一下它?(类似 LD_PRELOAD 的 hooking,但这次是在内核里。)「恭喜你,你发明了 eBPF(extended Berkeley Packet Filter)。」

eBPF 的动机:把 strace 搬进内核;网络设备不能 read/write(速度太快,用 ring buffer + 中断)

📷 eBPF 的动机:把 strace 搬进内核;网络设备不能 read/write(速度太快,用 ring buffer + 中断)
回到原视频 (00:19:30)

编者拓展|eBPF 是什么:「你听它的名字就知道——它最早用在网络子系统上。」因为网络是世界上最快的设备之一——「数据中心的网卡,你看它的包计数,甚至会觉得 64 位整数都快溢出了」,所有 CPU 一起响应中断才跟得上。「最终所有 I/O 设备都活成了网卡的样子。」

eBPF 的关键特性: - 是一个虚拟机(RISC-like 指令集,11 个寄存器,R0 是返回值;每次调用入口 R1 指向一个 context 结构体,包含函数参数、function name 等); - 有一条特殊指令 bpf_call helper(如 bpf_get_current_pid_tgid),还能访问 BPF map(预实现好的数据结构); - 有非常严格的验证器:只允许 bounded execution(不能有 unbounded 循环),所以无法随意修改内核内存,也无法自己实现数据结构(要用 map); - 通过 bpf() 系统调用把字节码注入内核,verify + JIT 成本地指令,勾住关键的 hook point / trace point。

编者拓展|eBPF 在生产中的一个真实 bug:讲师讲了自己学生写的 BPF program——「里面有个 printf,我在前面加一个循环,那个 printf 就不执行了。但根据语义它百分百是要执行的——编译器不知道为什么把它优化掉了(分析有错,认为那是死代码,但它显然不是)。」

eBPF 能做什么?答案是任何事。 比如一个 Y 字开头的 eBPF 程序捕获你所有键盘按键和鼠标移动;再结合 execve 等系统调用的 trace,就能完整复原你在 Linux 电脑上干的所有事的轨迹。

用 eBPF 捕获键盘/鼠标事件,可以做「学习轨迹复原」(甚至让大模型点评你 Vim 的操作)

📷 用 eBPF 捕获键盘/鼠标事件,可以做「学习轨迹复原」(甚至让大模型点评你 Vim 的操作)
回到原视频 (00:23:00)

编者拓展|「Specification is all you need」:讲师说,以前开发 web 后端需要懂很多库和机制,今天你只需要一个 AI——把功能描述清楚,「哪怕你写一个性能奇烂无比但正确的 prototype,让 AI 尽可能借用操作系统机制做高效实现就行」。他甚至引用了 Ion Stoica 的论文 The Time is Here for Just-in-Time Systems:「未来系统可以 synthesize from scratch,specialized to environment、workload and required system properties。」——「如果你对现在的操作系统不满意,就直接说『帮我把内核改一下吧』,它 on the fly 就改好了。我真的觉得这件事很快会发生。」


二、快照:persistent data structure

2.1 从二叉树到 git

(参考时间: 00:31)

Random read + append-only write = 任何持久化数据结构。 比如在一棵二叉树上插入节点:只把从根到叶的那条路径复制一份($O(\log n)$),旧版本和新版本都保存下来了。

你从第一节课就在用的工具就是这么干的——git。

编者拓展|「什么是 git?」:讲师说,研究生复试时问「什么是 git」,有人摇头不知道,有人说是「从网上下载代码的」,没有一个能答出 rebase 是干什么的。「我当时看到就雷倒了。」

git 就是一个 persistent data structure——所有东西都在 .git 里:

git 的三大对象:blob / tree / commit,全部压缩存储在 .git/objects

📷 git 的三大对象:blob / tree / commit,全部压缩存储在 .git/objects
回到原视频 (00:37:15)

$ git cat-file ... # 现场用 AI 写的工具解出对象内容
hello            # 旧版本
hello world      # 新版本

git 剩下的一切都是「文本形式的指针」:

.git/HEAD      -> refs/heads/master
refs/heads/master -> <commit hash>

所以:一个分支就是一个指向某个 commit object 的指针;commit 时指针往后移;HEAD 也是(往后退的)指针。你可以依葫芦画瓢造一个 tail 指向旧的提交,然后 git diff HEAD tail。

git 的 head/tail 与 refs:分支、HEAD 都是指向 commit 对象的文本指针

📷 git 的 head/tail 与 refs:分支、HEAD 都是指向 commit 对象的文本指针
回到原视频 (00:39:00)

编者说明:讲师现场让 AI 把某个项目的 commit history 对象图画出来(Mermaid / SVG)——「你会得到一个很不错的结构图。」

用 AI 从 git 对象生成流程/结构图,帮助理解项目到底发生了什么

📷 用 AI 从 git 对象生成流程/结构图,帮助理解项目到底发生了什么
回到原视频 (00:44:00)

2.2 merge / rebase / stash / worktree

(参考时间: 00:45)

分布式版本管理:一个 commit 对应一个 tree,你在一台机器上发展出 A、B,别人发展出 C、D——不再是线性历史,git 会做合并:

编者拓展|慎用 rebase:「一旦 cherry-pick 发生冲突,基本上就要放弃 rebase,还是得 merge。因为你 resolve 了这个冲突,下一个又冲突了,两条线时间拉得越长就越痛苦。而且即便 auto rebase 成功了,也可能隐藏逻辑冲突——你最好回过头来 review 这三个 diff。」

stash 也是一个 commit object(有两个 parent:HEAD 和 next stash),所以它是一个栈,可以 push/pop。

git 的三种合并:merge(新节点 E)/ fast-forward / cherry-pick 与 rebase

📷 git 的三种合并:merge(新节点 E)/ fast-forward / cherry-pick 与 rebase
回到原视频 (00:47:00)

编者拓展|历史总在重演:git 是为单线程设计的(假设开发者一次做一件事)——「就像早期的 UNIX 只有进程、没有线程。」当你开始 multitasking(老板不断打来电话让你切换 feature 分支、hot fix),stash 是个栈,设计之初就没让你多线程工作。

于是 2015 年 git 增加了 work tree:「既然这是一个 persistent data structure,我们为什么不能把树直接复制一份呢?因为复制是没有代价的——我永远不修改已有对象,只要一个指针指着某个位置就可以了。」

$ git worktree add ../hot-fix   # 创建一棵独立的工作树(共享 .git 对象池)

worktree 里的 .git 不是一个目录,而是一个文件——里面是一个指针(指向真实的 .git 与 HEAD)。「这是 persistent data structure 带来的一个免费的 feature。」——而今天,worktree 成了 Agent 时代非常标准的特性(可以同时放一堆 agent 在同一个项目上)。

git worktree:因为 persistent data structure,复制一棵树只需要一个指针

📷 git worktree:因为 persistent data structure,复制一棵树只需要一个指针
回到原视频 (00:56:30)

2.3 btrfs:把整个文件系统做成 persistent 结构

(参考时间: 00:58)

既然 git 是 persistent data structure,能不能把文件系统整个变成 persistent?「没有道理不能。」

btrfs(B-tree FS,十几年前就可以在生产用了)——「Everything is a b-tree」:

btrfs:Everything is a b-tree,copy-on-write,一个 ioctl 创建免费快照

📷 btrfs:Everything is a b-tree,copy-on-write,一个 ioctl 创建免费快照
回到原视频 (00:59:30)


三、Overlay / Union FS

3.1 把两个目录「叠」起来

(参考时间: 01:01)

编者说明:这一切最早追溯到 UnionFS 的论文。文件系统是目录树——除了增删改查和监听,还能设计什么不一样的机制?

Union FS 的想法:一个挂载点里有 upper 和 lower 两个目录。创建一个虚拟目录 merge:

「upper 拼在上面,lower 拼在下面。」于是 lower 可以是一个「系统盘」,upper 里对 lower 做修改,而 lower 本身保持不变。

Union FS:upper 拼在 lower 上面;读时优先 upper,写只落到 upper

📷 Union FS:upper 拼在 lower 上面;读时优先 upper,写只落到 upper
回到原视频 (01:01:30)

两个 Killer Application:

  1. 刻盘:lower 放 600MB 相同的文件,upper 里放每张光盘不同的 CD-Key(1MB)——省下大量空间,可以并行刻 16 张盘;
  2. 防「成砖」:把整个根文件系统当作 lower,在另一块盘上做一个空的 upper,然后 apt dist-upgrade——所有写入都落到 upper(apt 的 trace 本质就是写文件、写系统状态)。「成砖了就回到 initramfs 模式,把 overlay 拿掉,你的系统又回来了。」

3.2 overlayfs 与 Docker

(参考时间: 01:08)

Linux 的机制叫 overlayfs,而且做得更彻底:可以挂载不止一个 lower(这正是 Docker 的关键):

overlayfs 演示:修改反映到 upper,删除产生 whiteout 字符设备

📷 overlayfs 演示:修改反映到 upper,删除产生 whiteout 字符设备
回到原视频 (01:09:30)

编者拓展|「网吧管理器」与考试竞赛管理:「网吧里用户会任意破坏你的电脑,你甚至愿意给他高权限——不要紧,把系统放在 lower,给他一个 upper,下机了直接把 upper 删掉。」讲师说自己在做大规模考试/竞赛管理时也用这套:「把 /home 做成 overlay,把实际数据放在用户访问不到的地方,上午场在 upper1 工作,下午场挂另一个 upper——下午场看不到上午的工作目录,但最终回收 U 盘时你还能看到所有人的东西。」

Docker 就是一个又一个 layer:Dockerfile 里每个 RUN 都会启动一个容器、创建一个 upper,底层是上一层的 lower;命令跑完,upper 就「固化」成一个新的 layer。一层层叠上去(又是一个 persistent data structure:底层是 append-only,只有最上面可变)——「你改了一条 RUN,就必须从上一条命令对应的 layer 重新 replay。」

Docker:每个 RUN 产生一个 layer;多个 upper 共享一个 lower = 容器

📷 Docker:每个 RUN 产生一个 layer;多个 upper 共享一个 lower = 容器
回到原视频 (01:13:00)


四、终极虚拟化:自己写一个文件系统

(参考时间: 01:15)

前面不论 inotify、快照、还是 CRUD,都可以用增删改查实现;但 overlay 改变了文件系统的语义。再往后想一步:文件系统就是一个数据结构,数据结构可以随便实现——「为什么我不能直接写一个 fs.c 来实现一个文件系统?」

编者说明:/proc 就是内核里的一个文件系统——它的 getdents64 由它自己提供,所以有些目录你能 cd 进去,但 ls 看不到。「这个行为完全由文件系统的实现决定。」

FUSE(Filesystem in USEr space):内核把 lookup/read/write 转发到 /dev/fuse,你只要实现一个 fuse_operations 结构体(open、read、readdir、甚至 set extended attribute……)。「从此文件系统再也不是 CRUD。」

FUSE:实现一个 `fuse_operations` 结构体,在用户态「虚构」出任意目录树

📷 FUSE:实现一个 `fuse_operations` 结构体,在用户态「虚构」出任意目录树
回到原视频 (01:17:30)

GGFS(Galgame FS):还记得那个用符号链接做的 Galgame 吗?这次用 FUSE 实现一个文件系统:

编者拓展|FUSE 能做的疯狂事情:把远程 SSH 目录映射成本地目录(sshfs);把远程 git 仓库变成目录;用数据库存文件系统(db-fs,用 SQLite 引擎实现,靠 append-only 保证一致性);反过来把一个不是文件系统的东西挂载成文件系统——把一个 JSON / key-value 变成目录树(key 变目录、value 变 value.txt,写入即更新 JSON)……

「你可以把一份『学习资料』真正隐藏起来——让 getdents64/readdir 看不到它,但你可以 cd 进去。只有你知道名字才能进去,没有任何人能进去。」


总结

编者拓展|Everything is a file 的终极实现:这一讲的四个层次层层递进——监控(改变「谁通知你」)、快照(改变「时间维度」)、overlay(改变「目录树的语义」)、FUSE(改变「文件系统本身是什么」)。「你从增删改查开始,你只要有想象力,借助操作系统机制,就可以做任何事情。」


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
eBPF 官网 https://ebpf.io/
BPF 手册(bpf(2)、bpf-helpers(7)) https://man7.org/linux/man-pages/man2/bpf.2.html
git 对象模型(Pro Git: Git Internals) https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
git worktree https://git-scm.com/docs/git-worktree
btrfs https://btrfs.readthedocs.io/
overlayfs(内核文档) https://docs.kernel.org/filesystems/overlayfs.html
Docker(容器与 layer) https://docs.docker.com/get-started/docker-concepts/building-images/
FUSE https://github.com/libfuse/libfuse
GGFS / Galgame FS(persistence 演示) https://jyywiki.cn/OS/demos/persistence/
Ion Stoica, The Time is Here for Just-in-Time Systems https://www.cs.utexas.edu/~eunsol/
OSTEP(第 37 章 Files and Directories) https://pages.cs.wisc.edu/~remzi/OSTEP/

阅读材料:读 git 的 Pro Git「Git Internals」一章,理解三大对象;在自己的机器上跑一次 git worktree、overlayfs、sshfs。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。