关于实验
上周我们发布了这个学期的实验。不过不用太担心,在 AI 的辅助下你们应该很快就能完成。我今年试着让我的 coding agent 来完成实验——我跟它说"帮我完成它吧",然后它就搞定了。
第一个实验的大部分代码其实可以用 AI 生成,因为那些可能和我们的操作系统没有直接关系的部分可以交给 AI。但是 main 函数你需要自己来实现。另外,我的代码有个 bug,感谢有同学发现了——3 月 22 号中午的时候我更新了 main 分支。
因为我用了新版本的框架,但在复制的时候复制错了目录,把旧版本的 TesterKit 复制进去了。所以大家如果第一时间拉下来的代码,可能过不了 System Test。不过不要紧。同时我也理解,有的同学可能在做实验的时候会遇到一些困难,比如实验须知里一上来就说了很多不太清楚的内容。
现在你不需要再像以前那样了。我们只要打开一个 coding agent,然后把实验的要求粘贴进去。毫无疑问,今天的 AI 是可以把这些事情搞定的。我们看一下它的思考过程:它知道要在命令行里面执行正确的命令。如果我允许执行的话,它不仅会把该做的事情做了,而且会真的去查看目录里面的文件,看看是不是和实验框架手册上写的一样。
思考完成后,它说框架代码已经克隆到了 OS2026 的目录,还给出了一些额外的提示。它确实检查了目录结构,确认是正确的,甚至还问你"需要查看具体的实验指南来获取实验代码吗?"你可以不停地再问,让它帮你解决问题、完成整个作业。但我不是很建议大家这样做。
今天要讲的比较有意思的一点是:比如我在 3 月 22 号中午更新了 main 分支,你们可能对 Git 这些概念不太熟悉。但当我们有一个 coding agent 的时候,你随时随地都可以问它。
比如我现在假装自己知道一点——我可以看到 OS Lab 里面的目录,可以查看到我推送的几次代码:发布前 21 号有几个提交,22 号中午有一个 "update test kit" 的提交。尤其在这个 AI 的时代,更多的是你能提出一个好的问题,或者知道你下一步可以做什么。
比如我现在就可以做一件不太常规的事情:我有一个叫 main 的分支,我能不能让 main 往前回退一个 commit?即便你们有一些使用 Git 的经验,你也不一定完全知道精确的命令到底是哪一个。
这时候大语言模型的能力就非常重要。你只要知道这件事情是可以做的,它就会帮你。它先用 git log 查看当前的提交历史,看完以后确认有一个分支是需要回退的,然后给出了正确的命令——git reset --hard。当然这个会把当前的东西给摧毁掉,但没关系,因为是新克隆下来的 repo,不会伤害到自己的代码。
执行完以后,它说 main 分支已经回退一个 commit,甚至告诉你如果要恢复那个 commit,可以用 git reflog 找到它,然后 git cherry-pick 或者 git reset 来恢复。
现在我就得到了一个相当于你们在第一天把代码 clone 下来的状态。然后我可以模拟这样的场景:老师说在某个时间点更新了代码,但你对此毫无概念,你只知道要把那个更新同步到本地。你只要知道这件事情应该能做到,你就可以把它做到。
当我问 agent "how can I do" 的时候,它会耐心地向我解释该做什么。让它直接做了,确实看到了 test kit 的更新——同步完成,远程更新已经合并到本地仓库。
以前操作系统课一直是一门体验很差的课:你是一个新手小白,但要完成很多事情,被迫看很多文档。现在不需要了——在任何时候你需要的文档会自动出现在你面前,而且是由 AI 消化过的。你看,就这么短短的一点时间,就学会了好几个 Git 命令:git reset、git fetch、git log、git merge。你脑袋里有了一个概念,知道能做什么,以后慢慢用得越来越多就能更好地使用现在的工具了。
所以我觉得你们更需要拥抱世界的变化,让 AI 放大你们的 self-motivation。你愿意往前走一小步,就真的可以做很多事情。
Everything is a File
这是实验的一个小更新。大家如果有疑问可以发邮件给我或者助教。我当时看到了邮件,本来已经准备离开办公室了,吓得赶快把那个 bug 给修复了。
上节课我们讲了程序和进程。我们讲了一组系统调用,包括 fork、execv 和 exit。一个程序运行起来以后,它就是一个状态——有内存和寄存器,可以通过系统调用去请求操作系统。如果程序希望创建进程,可以用 fork 做一份状态的完整复制,也可以用 execv 重置到另一个二进制文件所指定的进程状态上,或者用 exit 摧毁一个进程。
在进程内部,当我们说状态有内存和寄存器的时候,一个很基础的问题是:如果一个指针扫过一整个进程的地址空间——从零开始——到底什么东西是可读的、什么是可写的?我们甚至用一个游戏修改器来告诉大家:我真的可以读写别的进程的内存。
对于一个进程来说,可以用 mmap 和 mprotect 等系统调用来管理内存。还有一个 msync,它可以把你一段内存里写进去的数据同步到那个映射的文件上。比较重要的是 mmap——你可以通过它来分配内存。我可以 mmap 一个非常大的、十几个 GB 的内存,然后马上就可以用了,只需要非常短的时间。而且 mmap 还可以映射一个文件,比如我可以把一整个磁盘或者 GPIO 的端口都映射到我的地址空间里。
其实你已经可以创建一整个应用程序的世界了:用 fork 可以创建出一整棵进程树,用 mmap 可以调动系统里各种各样的资源。
那么下一个问题:如果你要真正写出一个应用程序——比如终端模拟器、浏览器——你们可能自己就有点跃跃欲试,真的可以马上就用 coding agent 去写一个自己的浏览器或自己的 coding agent。在这个时候,你免不了要和操作系统里面更多的对象打交道,比如文件。我们已经很熟悉文件了,但直到今天才正式开始讲。
另外,我们用 fork、execv 创建出来的两个进程是完全分离的。上课讲过一个很有意思的例子:它可以打印出很多的 hello,父子进程完全分离、各管各的。那如果它们之间想要交换数据呢?比如我现在一个科学计算的程序,为了利用好 CPU 上面 128 个核心,创建了 128 个进程来算。每一个进程算出来一个数字,你想把这些数字加起来——怎么办?你发现光用 fork、execv 做不到这一点。
所以你需要的是在操作系统里创建一个新的对象,让父子进程都能访问它,这样就能实现共享了。所以今天的主题就是操作系统中的对象和管理它们的 API。
我们再回到应用程序开发者的角度。操作系统其实是给开发者提供服务的。你们写过很多大作业,比如在高级程序设计课上写一个游戏之类的东西。除了写算法——把一个数据结构变成另一个数据结构、做一个状态空间搜索——之外,你还希望调用库函数来完成一些不是计算类型的任务。而这些任务大部分背后都有一个操作系统:发出网络请求、读取文件、和其他进程通信。这些事情的本质都是去访问操作系统里面的对象。
在程序员的脑袋里,你想的可能是用 Python 的 pathlib 把 /proc 下的某个进程的 maps 当文本读出来,甚至想要一个更舒服方便的接口,比如叫 getMemoryMap(pid)——返回一个数据结构,告诉我每一段从哪开始到哪结束、访问权限是什么。你还想要 readProcessMemory,指定一个 PID、一个地址、一个 buffer、一个 size,把那一段进程内存给读出来。
而操作系统设计者脑袋里想的就是:应用程序开发者想做的事情是合理的。操作系统是一个通用的技术,为了让应用生态能够繁荣发展,操作系统设计者会一点一点把这些 API 加到系统当中。
操作系统设计者脑袋里想的就是要提供一套简单、稳定、通用的 API——一个抽象层。这个 API 可以完全迎合开发者——比如真的提供一条 getMemoryMap;也可以是另外一套不太一样的机制——比如 UNIX/Linux 世界里,有一个 /proc,把进程相关的信息以文件的形式放在里面。这样我就不需要提供一个额外的系统调用 API,可以复用文件和目录的 API——open、read、write——以及用 open 打开目录、用 readdir 扫描目录。复用这些已经实现好的 API,让应用开发者可以访问操作系统对象。
实际上在 Linux 里,如果你要做这些事情用的是一套 API。但这不代表在其他操作系统上也要这样实现。比如 Windows 是一个在软件工程意义上设计得非常好的产品,它的 API 就不像 Linux 那样。UNIX/Linux 有一种"我是 hacker 所以我就要用特别干净又舒服的 hack 去解决问题"的风格——比如把所有东西塞在 /proc 底下。但这其实带来一些麻烦。
比如你想知道进程的地址映射,可以用 pmap 7583 打印出进程的地址空间映射。但这个程序背后会以文本文件的形式去打开 /proc/7583/maps,做一次文本解析,再加工成输出的样子。这其实是很浪费的——这些信息在操作系统内核里本来就是一个数据结构。为了实现成 procfs,要把数据结构变成文本(一次序列化),然后 pmap 读出来后又要再做一次文本解析。如果能直接把内核里那个数据结构搬出来,就可以用更少的时间、更少的计算。
Windows 就是这样设计的。Windows 的理念是"开发者要什么我就给什么"。比如创建文件,Linux(UNIX)是把各种 flag 都放在 open 里面——可以以 append 方式打开、以 asynchronous 方式打开等等。如果你在文件不存在的时候想要创建文件,还需要用 O_CREAT。这其实是 UNIX 的历史遗留问题——这是一个 typo。当年 Ken Thompson 在设计的时候想省一个字节,后来大家发现字节不值钱了以后,他自己都觉得犯了一个很大的错误。但因为最早的 UNIX 是这样的,后面的系统为了兼容就一直沿用了这个错误的拼写。
而 Windows 不一样,它提供了如 CreateFile、WriteFile、SetFilePointer 这样的 API。如果要访问另一个进程,Windows 也有 OpenProcess 这样的 API,参数直接传递给 Windows 的内核,可以非常高效地实现。VirtualQueryEx 可以查询进程的地址空间,比从 procfs 里以文本形式读出来要高效一些。
但与此同时,Linux 选了另外一个设计以后,又会带来今天我要讲的一个很有意思的化学反应——叫 UNIX Philosophy。不过在今天大语言模型的时代,到底谁好谁坏其实又不好说了。世界一直在变化,我们需要理解的是不同的人在设计背后的理念是什么。操作系统设计者设计什么样的 API,就是为了满足程序员脑袋里想要做的事情。大家要记得,这是一个非常重要的 first principle。
好,那我们就来看看 UNIX 是怎么做的。UNIX 做了一件事,大家知道的——叫 Everything is a File(一切皆文件)。
再想一想,什么是文件?文件是你们上小学电脑课时就学过的概念——老师会告诉你桌面上有一个东西,双击鼠标打开它。回过头来想,你已经接触过很多文件了。你能接受的一个定义是:一个有名字的数据对象。比如 .markdown、readme.md、lab.c、.png——它们都是一个有名字的数据对象。你可以用 fopen 打开它,用 fwrite、fread 或者 cin、cout、fin、fout 去读写这个文件。
所以它的模型就是一个字节序列。而字节序列在计算机世界里是一个非常朴实的抽象,因为计算机无非就是代码和数据——冯·诺伊曼结构里面最重要的两个东西。一个字节序列几乎可以表示任何东西。
它可以表示一个数据流——比如我每按下一个键的时候都生成了一个新的数据,这就变成了一个 append-only 的列表。你也可以想象成有一个数据的数组——比如图片、或者一个 JSON 数据结构。甚至在浏览器里下载任何一个页面的时候,它也会以一个字节一个字节的形式把页面的 HTML 发给你。浏览器会把头部信息藏起来,但头部信息会告诉你 URL 是什么、内容有多长,空行以后开始发送文本——这也完全符合字节序列的抽象。
再比如连接到系统的打印机、你的终端、你的显卡。大家可能有一点惊讶——为什么打印机、终端、显卡也是一个字节序列?这个我会到设备驱动的时候再回答。甚至一把锁、一个管道——一个可以互相传递数据的管道——它们都可以假装自己是文件。
所以这就是 Everything is a File 的基本想法。因为如果所有东西都是文件,那我们就可以用同一套工具来处理它们——比如 cat、wc 这些工具。只要有一套工具能处理文件,我就可以处理这个世界上任何的对象。
这就是 UNIX 设计的一步。它希望把操作系统里面的对象直接暴露出来,然后给你一组工具,让你通过工具组合的方式去管理操作系统里面的对象。而 Windows 的设计不是给黑客用的,是给软件开发者用的。Windows 的用户不直接操作系统里各种各样的对象——他不会打开一个 terminal 然后说"我要把某个东西删掉"或者"我要看一下我的文件有多少行"。Windows 所有的用户都是程序员,于是它提供了一套非常符合软件工程规范的接口,而且高效。但 UNIX 的用户是黑客,所以黑客会写命令行。
因此 UNIX 需要一个好办法把操作系统对象以一个简单舒服的方式暴露给所有用户,并且能复用那些小的命令行工具。UNIX 走了另外一条路。最后,UNIX 再把文件放到目录里——一层一层一层的,每一个操作系统的对象都有一个名字。
本质上,每一个文件就是一个带名字的对象。Windows 可能选择你能看到的带名字的对象基本上就只有你实际看到的那些,但 UNIX 选择把所有操作系统内部的信息也以某种文件的形式暴露给你——仅此而已,这是一个小的区别。
文件系统——也就是 Everything is a File 以及文件的抽象——是一个非常好用的抽象。我可以说,文件系统可以用于构建任何的信息系统。
比如你们的课程网站,现在提交还没有开。如果你们 make submit 的话会看到一个 "not open yet"。有 hacking 精神的同学可能会看那个脚本,然后发现我直接复用了去年的脚本——用 echo 打印一个 "not open yet",然后 exit。实际的提交代码还在下面。理论上说如果你直接执行下面的代码是可以提交上来的。
我给大家看一下文件系统是多么通用——我可以走到课程网站的后台,有一个叫 file_receive 的目录,里面有 os2025,每一个同学的提交就直接以目录的形式放在正确的目录里。提交的工作方式是:你的代码在本地打包成一个压缩包,送到网页后端,后端代码就往某个目录里写一个文件。文件名是日期——比如 2025 年 4 月 24 号提交的,就放一个对应的文件。
我假设你一秒钟内不会交两次——实际上我有一个检查。如果真的一秒钟交两次成功了,也只是把上一次覆盖掉。我的后台 online judge 会不断 watch 这个文件系统的目录,直到评测完了以后生成一个 result 文件。
你们肯定会有问题:为什么不用数据库?数据库是一个标准的做法。我不用数据库的原因是:当你使用了数据库以后,想要 hack 它就变得前所未有的麻烦——你每一个 hack 都要么实现一个工具,比如在网页上实现一个管理端。你们的教务系统是怎么膨胀的?就是因为它把数据强行做了一层封装,你必须用它封装好的接口才能访问。
而对我来说,如果我想删除一份提交,我直接把它删掉就行了——一秒钟。如果我想要 rejudge 所有的 M1,我可以用 UNIX 里所有的工具,因为这是文件。我不需要写一个 online judge 后台的 rejudge 功能。我只要用 find 把这些文件全部删掉——只剩提交了,没有 result 了。后台扫描到一个没有 result 的提交,就会拿过去重新评测,过一段时间就会写上新的 result。
所以文件系统其实是一个非常好的抽象,不仅可以用来管理个人数据,还可以用于构建任何的信息系统。只要我的用户量没有大到服务器扛不住,用文件系统就是一个完全 OK 的选项,因为我随时可以 hack 它。我甚至可以让 AI agent 去帮每一个同学的提交写一个 AI 点评。如果这些东西都存在数据库里,我又需要一个额外的中间层;但如果用文件系统,对我来说就太容易了——我只要让 Claude Code 自己把每个提交的 AI 点评都写完就行了。
教务系统也许应该用这种方式来开发——就是一个文件系统。如果你需要高性能的时候你总有办法,可以缓存一些视图到更高性能的存储上,慢慢和底层维持同步。
文件系统可以实现任何的信息系统,但它有一个问题:它没有很强的一致性保证。比如数据损坏了、你要写三个文件、写到第二个的时候程序 crash 了,你的系统就进入了一个不一致的状态。文件系统本身没有这个保证。我现在是靠我自己的人力——赌百分之九十九点几在服务器运行过程中不会出现这种事,就算出现了我可能也就用一两个命令把那个提交删掉——换取的是我的方便。
但现在有了大模型、有了 coding agent,我可以用文件系统来开发这个 online judge,再让大模型帮我翻译成用数据库的实现——这样我就什么都有了。我本地用文件系统实现一个参考的实现,自动翻译成数据库实现。我还可以不断测试——有两个版本,可以互相交叉检查,就像你们在 PA 里做 differential testing 一样。我可以用弱智的实现和一个非常好的实现之间做 cross-checking,然后无缝地从几千行的小信息系统直接做到 planet scale。
在今天的时代,很多有意思的东西值得大家去学。在文件系统里,虽然数据库、serverless 是很 fancy 的技术,但当你回头思考要解决什么问题的时候,回到文件系统上、回到 UNIX,会给你很多启发。
好,我已经说了文件是一个非常有用的抽象。接下来就可以看文件系统里到底有什么。这是有标准规定的,叫 FHS(Filesystem Hierarchy Standard),有兴趣的同学可以让 AI 帮你读一读。
每一个 Linux 系统里,根目录下面有 home、dev、boot、usr、sys 等等。几乎每一个 Linux 都遵循这样的标准——当然也不是全部。比如 macOS 就不遵循这个 hierarchy standard。这样的好处是你可以在不同的系统上有同样的知识——一个系统上的经验可以在另一个系统上也能找到文件。
以前我们刚开始教计算机系统基础课的时候,你们第一次接触 Linux 文件系统结构,教起来很头疼,因为很细碎。说"你去看手册吧"不太好,说"你去读一本书吧"又太厚。但现在 AI 能够精准地从海量知识里隔离出那一点你需要的——以前隔离知识是你们自己要做的事情,你只能从图书馆借书、在搜索引擎里找博客。现在 AI 帮你隔离知识以后,比如你不知道 /proc 是什么——它是进程和内核的信息;/srv 是服务数据;/sys 也是一个虚拟文件系统;/usr 是用户程序和数据。你对哪个感兴趣就问就行了。
在这样一个很大的文件系统上,Everything is a File。我们可以在文件系统里面构建任何东西,用户程序的真正力量就体现出来了。它的 UNIX Philosophy 叫 Keep It Simple, Stupid(KISS Principle),有三个主要的要点:
- 每一个工具只做一件事,而且把它做好——像
grep、sed、awk 这些命令行工具。 - 把这些命令行工具组合在一起 work together。
- 再把它做成一个 UNIX stream——文本流。
这其实是一个非常具有前瞻性的设计。文本接口(textual interface)有什么好处?人类可以理解,机器也可以理解。比如 /proc/10718/maps 这样的文本接口,不像 Windows API 那样——Windows API 返回一个数据结构,里面有些是字符串、有些是指针、有些是十六进制数,你还需要另外一个工具再去解读。
文本接口意味着它既是机器可读的也是人类可读的。所以它付出的是性能,换来的是通用性。我觉得在 1970 年代敢于做出这样的 trade-off 是一个非常勇敢的决定。
你可以在 UNIX 里以文本的接口访问所有工具的输出,以及几乎所有的文件。这是一个非常有前瞻性的设计——甚至在那个时候当然没想到有一天会有大语言模型。文本是一个很适合大语言模型处理的东西。于是像 Claude Code 这样的 coding agent,在自然语言基础上预训练、再微调成为一个 coding agent,它能够理解命令行工具的输出,能写出很多命令行工具。
你看到 Claude Code 在运行的时候不断在用 bash,每一个 bash 都会组合好几个命令行小工具。比如可以用一个通配符在 shell 里找到很多文件——/proc 下所有进程的 status,然后在里面寻找一个叫 VmRSS 的字段。我就可以以一个非常 quick and dirty 的方式找到进程占用的物理内存。甚至可以管道给另外一个命令,去求出所有进程 VmRSS 的总和。
UNIX 的命令和文本接口的设计,使得我们可以写出非常像自然语言的命令,对于 power user 和 hacker 来说可以写得很快。执行这个命令以后,找到了系统所有进程物理内存的总和,大概是 2.369 GB。
我甚至也可以让 AI 去和其他命令组合——比如我们知道有一个 free 可以查看系统剩余内存。以前我们都会花很多时间去讲解 free 的格式是什么,但现在不需要了,AI 能帮你理解。AI 调用了 free,甚至还调用了一个我有点出乎意料的——它直接读了 /proc/meminfo。这就是文本接口的好处——原来 free 就是从这个地方得到的。
总结一下:我们的系统里有很多对象——进程的状态、进程的内存、整个系统的状态——它们都变成了文件。当然你们平时接触的 .c、.markdown 也都是文件。于是 Everything is a File,你就可以通过一套统一的命令行工具来处理它们。最神奇的是管道——今天就会讲管道在操作系统应用层是怎么实现的:操作系统提供了什么样的接口,来实现把一个程序的输出粘贴给另一个程序作为输入。
这样的设计实现了命令行工具和数据的组合和复用——不仅是命令行工具,还有对象和数据的组合和复用。它找到了一个非常好的自然语言和编程语言之间的平衡点。
这里有一本小册子很有意思,叫 UNIX Haters Handbook——讨厌 UNIX 的人吐槽 UNIX 的地方。它的吐槽都是真的。因为 Windows 给你提供了一套非常结构化、非常规整的接口——在软件工程意义上设计得好。而 UNIX 给你提供的是文本接口,文本接口就意味着你需要解析它,会带来各种问题。
比如在 Windows 里文件名有空格不是一个很大的问题,但凡使用过 UNIX/Linux 的人,都有一种非常强烈的倾向——不在文件名里引入任何空格。因为空格会给很多命令行工具带来麻烦——一个字符串里有空格,你必须用引号把它括起来,否则在 shell 里就会变成两个参数。像 Makefile 到至今为止 GNU 的 legacy make 都不能很好地处理空格。
这是一个自然语言和编程语言之间的平衡点。你可以快速写出一个 dirty 的、大概正确的命令,帮助 hacker 在工作流里完成想做的事情。但我觉得它今天也多多少少完成了它的历史使命——它成了大语言模型的语料。GitHub 上面写的那么多代码也都成了大语言模型的语料。在 Agentic AI 时代,脚本语言肯定会成为历史的。如果大语言模型可以写 strongly typed 的 Python 或者 Rust 代码,完全不需要用这种方式。它生成 token 如果够快、够好、够准,完全可以写一个真正好的 parser,甚至可以直接调用一个 API 从内核里得到绝对准确的数据。
这就是 UNIX Philosophy。工具的组合和数据的复用对我们来说是很有启发性的。甚至在今天大语言模型流行的时代,Everything is a Text、Everything is a File 对大语言模型来说是更友好的。
文件描述符
好,接下来我们就可以讲操作系统到底给我们提供了什么样的 API 来访问操作系统里面的对象。
先来看一眼我的 Crazy OS——一个操作系统,有两个进程。进程可以有一个叫 putchar 的系统调用,在操作系统里面的实现就是一段很平常的代码:它会把字符放到每一个进程的一个 buffer 里。这个进程有一个缓冲区,缓冲区有一个长度。
这不就是一个打开的文件吗?如果我们的 putchar 像是向一个 default file 里写一个字符,那这就是操作系统内部对文件的实现。文件可能有一个分配了一段内存的 buffer,有一个长度,甚至有一个指针指向这个 buffer 现在写到哪儿了。每次把指针 buf_len++,把字符写进去——这就是 Crazy OS 内部实现文件的方式。
好比是把 libc 里面的 struct FILE 直接嵌入到进程的 struct proc 里,你就实现了文件。
根据 Everything is a File,在文件系统里从用户的角度来看,无非就是一些文件:比如 /home/xxx/a.md、/proc/1234/maps 等等。所有这些东西都是一个一个的对象,映射到一个字节序列或者一个可以访问的东西。这些对象都在操作系统里,都是操作系统里面的文件。
如果一个进程想要访问一个文件,在 C 里面你是用一个 struct FILE,用 fopen 打开它。一样的,我可以在一个进程里面放一个类似 struct FILE 的东西,里面有一个指针指向这个文件。就跟我在 Crazy OS 里面的实现完全类似。
在这样的简单实现基础上,操作系统可以提供一系列的 API,类似于 C 里面的 fopen、fread。实际上 fopen、fread 就是对 UNIX 最早那个设计的一层封装——先有了系统调用,然后才有了 C 的那套 API。
所以操作系统会提供一个叫 open 的系统调用。open 相当于你要新分配一个 FILE 结构体——在操作系统内部是需要内存的。你 open 哪一个文件,就会有一个指针指向那个文件。
close 就是把这个结构体不要了——我不再需要指向这个文件了。
read 和 write 就是我在 Crazy OS 里展示的那个 putchar——有一个 offset 指向我现在写到的文件的哪个位置,把 offset 稍微往后写一个再写一个,和 putchar 完全一样的实现。
还有一个叫 lseek——它叫 reposition read/write file offset。这里面有一个 offset 代表了指向字节序列的位置,你可以把它设成零、设成 -1、加减等等。在 C 里面有三个常量:SEEK_SET、SEEK_CUR、SEEK_END,你可以选择把一个已经打开的文件的 offset 从头挪、从当前位置挪、或者从尾巴挪。
还有一个叫 dup(duplicate),它可以创建一个新的 FILE 结构,指向同一个文件。
但这些都是发生在操作系统内部的。我们在应用程序里——在 C 语言甚至汇编语言的世界里——怎么访问操作系统内核内部的这个数据结构呢?在操作系统代码里我可以直接用指针访问,但在应用程序里是获得不了这个指针的。
所以这就有了 UNIX 里面的一个设计,叫文件描述符(File Descriptor)。
一个程序——一个 C 程序——只有内存和寄存器,也就是 int main 里面能干的事情。而 UNIX/Linux 操作系统给了你另一个地址空间。这个地址空间不是通过指针去访问的——你的内存里 int x、int g 这些变量直接通过 load/store 去访问,甚至可以取 main 的地址读出来。但它还有另外一个地址空间,在操作系统里面,编号 0、1、2……指向了一些打开的文件。这个地址空间必须通过系统调用才能访问。
这就是文件描述符的由来。大家其实多多少少用过文件描述符了。比如我们最早在讲 minimal 程序的时候,用了一个 write 系统调用向终端输出字符——write(1, buf, size)。这个 1 实际上就是这个地址空间里编号为 1 的位置。有点像 x86 里那个 I/O 指令——你要指选一个 I/O 端口,往一个端口里写数据,才能把数据从计算机送到外部设备上。
对于进程来说,它不能直接 dereference 这个编号,但可以通过系统调用指定它。比如 write(1, buf, size) 操作系统就知道你访问的是编号为 1 的文件;read(0, ...) 就知道你是从 0 号对应的文件去读取。
这个整数就是应用程序用来访问操作系统对象的编号。因为 UNIX 的设计是 Everything is a File,所以文件描述符(File Descriptor)就是 Everything Descriptor——File 等于 Everything。
文件描述符是一个指向操作系统对象的指针,只不过它是以 0、1、2 开始编号的间接指针,由操作系统来负责解读 0 到底指向了哪一个对象、1 指向哪一个对象。如果你用 open 打开一个文件,比如现在 0、1、2 都有了,再打开一个 a.txt,open 就会从 3 开始——从一个最小的没有分配过的编号,指向你打开的那个文件。改变的是地址空间里 3 号指向的对象。你在应用程序世界里都必须通过这一套系统调用来访问它。
当然我觉得"文件描述符"是一个有一点 misleading 的 term,但没办法,这是历史原因。大家记得 Everything is a File,所以它是 Everything Descriptor——一个指向操作系统对象的指针就可以了。
比如你有好奇过这个地址空间有多大?因为大部分时候用的程序可能就 0、1、2,你甚至都不会再打开一个文件。你只要能提出这样的问题,AI 都可以解答。
在 Windows 里面这个东西有另外一个名字叫 Handle。Handle 是什么呢?门上的把手就是 handle。Handle 是用来赋予你控制那个东西的权限的——我拽上门的 handle,就可以控制那个门;我抓上锅的 handle(把手),就可以控制这个锅。我觉得更合适的一个中文翻译叫"把柄"——我抓到你一个把柄以后,我就可以让你做任何事。相当于我有一个指向操作系统对象的指针——我持有编号为 1 的指针,就能对这个文件做 read、write 操作。
所以我觉得 handle 实际上是一个更形象的名字。
为了证明我刚才不是胡说八道,我也准备了一些简单的示例代码。比如实现了一个函数叫 try_open——直接 open 一个文件名,以可读可写的方式打开,查看 open 的结果。如果返回的文件描述符小于 0——因为地址空间从 0 开始,返回 -1 就说明打开失败了——这时候可以用 perror 把为什么失败给打印出来。
所有的打开都失败了——没有这些文件。比如没有 /dev/sda,这是因为我在树莓派上运行的,树莓派上是 SD 卡,所以叫 mmcblk——一个 MMC 的 SD 卡。你们的机器上可能会有不一样的——可能是 sda,可能是 NVMe 的闪存。
如果试图打开一个不存在的文件,它返回 -1,并且告诉你 "No such file or directory"。你会经常看到这个信息。甚至如果你改变系统的语言,能看到这句话是中文的。这是因为使用了标准的 perror——当系统调用或库函数调用发生失败的时候,都能正确打印出和你当前语言设置相同的错误信息。
文件描述符确实是从 3 开始分配的——0、1、2 分别分配给了标准输入、标准输出、标准错误输出。再打开一个文件是 3、4、5。关掉以后总是会从最小的开始再分配。
大家如果在 Windows 里面按 Ctrl+Shift+Esc 进入任务管理器,在详细版本里有一个字叫"句柄"。"句柄"实际上就是 Handle。Windows 里有一个 HANDLE 类型,做任何事比如 OpenProcess 就会返回一个 Handle。Windows 的设计更像是一个指向操作系统对象的指针,那个指针里不仅有对象的编号,而且还有一些权限等信息。而 UNIX 里它就是一个 0、1、2 的编号,大部分信息全在操作系统内部。如果你想知道比如这个文件描述符到底是可读的还是可写的,你还需要再用额外的系统调用去查询。
"句柄"是怎么来的呢?这是一个错误的翻译——以前有一本讲编译的书,书上说了一个 "sentence handle"(一个句子的 handle),"一个句子的把柄",结果错误地一直沿用下来,被 Windows 的官方翻译使用以后就变成了奇怪的"句柄"。
大家记得文件描述符、句柄和指向操作系统对象的指针就可以了。
到这儿大家其实都比较熟悉了,尤其在计算机系统基础课上已经了解过。但有一点是在操作系统课上要告诉大家的:看起来简单的设计,在实际的操作系统里面会带来一些不小的麻烦。
比如我们说文件是一个字节数组。假设我打开了一个 log.txt,现在是 3 号文件描述符。我往 3 号先写一个 '1',再写一个 '2',会在文件里先写下一个 '1' 再写下一个 '2'——这个行为大家非常清楚。
下面会想到一个问题:假设文件就是一个字节数组,操作系统内核实现的时候也真的给它分配了一块内存。我的 FILE 结构体有一个 buf,有一个 offset。执行 write 的时候,根据 3 号文件描述符空间找到打开的文件,我知道 offset,写一个 '1',offset 往后加 1。这样下一次写 '2' 的时候就会写到后面的位置。
麻烦是什么呢?麻烦是我在这个时候可以对进程做 fork。还记得 fork 是干什么的吗?fork 是把整个进程完整做一个拷贝。所有的内存和寄存器的拷贝都容易——完全相同,除了父子进程返回值不同:一个返回了子进程的 PID,子进程直接返回 0。
但在操作系统里就麻烦了。我有两个进程——如果我先写一个 '1',然后 fork,然后再写一个 '2',这时候就有两个进程在写 '2' 了。问题是:它们应该覆盖还是应该写两个 '2'?这个程序执行完以后你应该看到一个 '2' 还是两个 '2'?
这件事情其实是操作系统设计者的自由。我可以在 fork 的时候把这个 FILE 也拷一份,offset 也拷一份——那两个 offset 都在同一个位置,我就会得到一个 '2'(互相覆盖)。也可以做一个浅拷贝——两个指针指向同一个 FILE 结构。这样第一个进程写 '2' 的时候 offset 往后移,第二个进程再写的时候又往后移,最终得到两个 '2'。
UNIX 的设计者选了后者——会让父子进程写出两个 '2'。为什么做这个设计呢?想想看,这可能是一个 log.txt。设计者希望的是:如果父子进程共享一个文件、以追加方式写入的时候,不会丢掉数据,也不会让两者的数据随意互相覆盖——因为覆盖一般来说不是你想要的。程序员不希望父子进程写文件的时候先来的人会被后来的人覆盖掉。
所以 UNIX 设计者选择了把 offset 做一个浅拷贝——两个指针指向它。如果你想要得到一个独立的 offset 深拷贝,你需要把文件再打开一次。
这也是 fork 的默认行为带来的麻烦——默认时候父子进程所有东西都是继承的。而 Windows 的 Handle 设计得更好一点:默认时候 Handle 是不继承的,和 UNIX 正好相反。你可以在创建一个 Handle 的时候设置 inherit handles,或者在某个时刻标记这个 Handle 在创建新进程的时候会被继承。所以 Windows 做了一个最小权限原则,这在软件工程上是更好的设计。
为什么呢?你想:如果父进程持有了一个非常敏感的文件描述符,然后通过 execv 执行另外一个程序时不小心忘记把那个敏感的文件描述符关掉,子进程就有了这个文件的访问权限。这就很危险——可能有 bug 甚至安全漏洞,导致误写了系统中的文件。
所以后来 Linux 引入了一个叫 O_CLOEXEC(close-on-exec)的标志,使得一个文件描述符在子进程创建的时候不被继承。你看操作系统演化到今天,它的 API 实际上也是千疮百孔的。不过好消息是 AI 对这些行为理解得都非常好。你们需要的是在遇到真实问题的时候,能够意识到这个地方有一个潜在的复杂性,然后能够 reason about 这些程序的执行——有这个能力就 OK 了。
好,这就是文件描述符相关的 API。我们知道了操作系统的文件目录树长什么样,也知道了可以用 open、read、write 这样的系统调用来访问它。我们现在就可以看看操作系统里面到底有什么文件了。
用一个命令打开 /proc 下所有进程的 fd 目录下所有的东西——你会看到每一个进程都打开了一些文件。因为 Everything is a File,我甚至可以看到一个进程的文件描述符下每个文件到底打开了哪一个文件。有些进程打开了一些 tty7,有些打开了一些 pipe——你真的可以在文件系统里看到所有其他进程打开的所有文件。
我可以再用 UNIX Philosophy 去做处理——把不想看的东西去掉,得到更干净的结果。然后直接管道给 Claude Code 让它 explain it。我做的是先打印出所有程序打开的文件,稍微处理一下格式,然后直接让 Claude Code 去解释。
在 Everything is a File 的加持下,理解操作系统里任何一个部分都变得前所未有的简单。你们现在需要的就是想象力——你们可能没有想到还可以干这件事。但当你们做得越来越多以后,会发现计算机世界里没有任何的魔法,你想知道任何事都会有一个很干净的解释。
AI 的分析显示了系统中各种进程打开的文件描述符。在我们的系统里,你就真的看到了系统中的程序打开了什么:比如 /dev/pts——这叫伪终端或者叫终端模拟器。
比如我现在这个终端叫 /dev/pts/5。我可以把某些数据写到这个终端——背后相当于 open("/dev/pts/5"),然后执行一个 write 把 hello 打进去。打完以后你会看到这个终端上就出现了一个 hello。也就是说我真的可以打开一个别人的终端——只要有权限,往里面写数据,别人的终端上就出现我写入的数据。
再比如真正的控制台 /dev/tty7、空设备、GPU 渲染设备、输入设备、随机数,还有一些套接字、管道等等。所有这些都可以用 open、read、write 来访问——发挥你们的想象力往前走一步,就可以把这些东西理解得更清楚。
管道
在刚才的例子里我们看到了很多特殊文件的类型——有些是套接字,有些是管道。今天要讲的一个很重要的系统调用就是管道。
管道是什么呢?你可以理解成它是一个文件。这个管道有一个写口、有一个读口。你可以用 write 系统调用往写口里面写东西,用 read 系统调用从读口把东西读出来。
管道有一个特点:如果管道是空的、没有数据,read 就会等——等到有数据为止。而管道的容量是有限的,如果写入的数据写不下了,write 就会等——等到管道里面空出了足够多的空间。read 没有数据的时候也会等。
这不就能实现进程间通信了吗?比如你用 128 个进程算数据,每个进程算出来的东西通过管道的写口写到文件里,最后主进程从管道里读出 128 个数据、加起来、打印——这就是用管道实现的数据传递。用管道还可以实现等待——每个写完的进程往管道里写一个 "OK",主进程读到 "OK" 就知道谁完成了。管道提供了一个进程间通信的机制。
我这里有两个例子。第一个是命名管道——比如叫 /tmp/mypp。我们可以通过 mkfifo 系统调用来创建一个命名管道,也可以直接在 shell 里创建。有了管道以后,我可以 open 它写、也可以 open 它读。
我的程序会 mkfifo 一个 pipe name 叫 /tmp/mypp,然后如果命令行参数是 pipe_read 就调用 pipe_read,否则往 pipe 里写数据。
比如我现在执行 named_pipe read——这时候没有数据,它就会等在这儿。如果我在另一个终端 write 一个 hello,write 马上就返回了,然后 read 这边就读到了 hello。反过来也一样——如果没有人在读的时候我 write hello,write 也不会返回,要等到 read 读到 hello 的时候 write 才结束。
所以管道有一个互相等待的机制,能够在进程之间传递数据。命名管道在 ls 的时候会看到一个 p 标记,说明这是一个管道。
但如果是在 UNIX 里面我们想做 ls | wc 这样的操作,这个管道运算符不需要创建一个命名管道。它用的是匿名管道的系统调用——int pipe(int pipefd[2]),它会返回两个文件描述符。
在 C 里面使用起来其实挺丑陋的:你需要先定义一个 pipefd[2] 的数组,然后调用 pipe 系统调用。pipe 做的事情是在系统里创建一个管道——这个管道是匿名的,没有名字。然后它会打开两个文件描述符,比如 4 和 5——一个指向管道的读口,一个指向写口。其中 pipefd[0] 是读口,pipefd[1] 是写口。
如果发生 fork,行为就更复杂了。fork 会把所有文件描述符都继承,所以子进程也会有 4 和 5——4 指向读口,5 指向写口。
一直以来理解管道的复制是这样的:在第 39 行定义了一个 pipefd,42 行调用 pipe 得到两个文件描述符——一个指向读口一个指向写口。fork 以后父子进程这两个文件描述符都还在,所以要在子进程和父进程里分别做不同的事情。
但今天你们不需要用这种原始的方式去理解程序了。如果你想理解 pipe 到底怎么用的,不需要读文档,直接调试示例代码。因为程序——我们随时随地都可以得到程序状态。刚才我们做了一件很有趣的事:我可以查看进程已经打开的文件。既然能查看,我就知道每个进程——父进程、子进程——到底打开了哪些文件、哪一个是读口、哪一个是写口,我不就直接能把这个图给画出来了吗?所以我再也不需要在课堂上画这个了。
我昨天晚上花了一点时间,直接让 AI 帮我写了一个可视化工具。
我们来看这个匿名管道的例子:子进程会执行 do_child——从管道里读;父进程会执行 do_parent——往管道里写。效果就是父进程写一个 "hello world",子进程得到这个 "hello world",然后结束。
那在管道意义上到底发生了什么呢?我只要用 GDB 去调试就可以了。神奇的地方在这儿——我就一句话让 AI 帮我生成了一个能够调试任何管道程序的脚本,它能生成一个 Mermaid 的可视化文件,保存到 fdstate.md 里。
打开 Markdown 预览,你就看到现在我的进程打开了 fd 0、1、2——以读写方式打开的 /dev/pts/4。如果你不知道 /dev/pts/4 是什么,我可以直接往上面打一些字符——hello 就出现在我的 /dev/pts/4 了。计算机世界里每一个东西都是严丝合缝的。
接下来执行 pipe 系统调用——成功了。pipefd 确实是 3 和 4,返回了两个文件描述符。你看到了这个管道——操作系统里的一个对象——进程里多了两个文件描述符:3 连接向管道的读口,4 连接向管道的写口。
接下来第 48 行做 fork。fork 完了以后状态也更新了,确实父子进程的 0、1、2 都指向了 /dev/pts/4,管道的读口和写口也都更新了。
接下来继续看程序干了什么。我现在是 parent——如果你想实现 UNIX 里 ls | wc 那样的管道,你需要把 ls 的输出接到管道的写口,把 wc 的输入接到管道的读口。就是要保留第一个程序的管道写口、关掉读口,保留第二个程序的读口、关掉写口,然后再做一些文件描述符的腾挪。这个我下节课再讲。
在这个更简单的例子里,父进程把管道的读口关掉——调试一步,确实读口关掉了。父进程的 fd 4 指向写口还在。切换到子进程——子进程把管道的写口关掉。完成以后就得到了一个相对干净的视图:父进程 4 号文件描述符可以向管道写入,子进程 3 号文件描述符指向管道读口、可以从管道读出。
所以我们需要做的是:父进程往 4 号里写,子进程从 3 号里读。如果你想要实现 ls | wc 的效果,只需要用 dup 系统调用把标准输出指向管道写口、把标准输入指向管道读口——这个我下节课详细讲。
管道这个系统调用从使用者的角度确实挺讨厌的——它是 pipefd[2],每次都需要 pipefd[0]、pipefd[1]。更好的做法应该是给它们有意义的名字,比如 int write_fd = pipefd[1],这样代码才能读起来更顺畅。
但行为还是可以理解的。尤其是你再也不需要像我这样一步一步调试代码然后给你解释发生了什么——我这个可视化脚本是 AI 帮我生成的,我一个字都没看,一遍就搞定了。
TestKit 简介
最后还有一点时间,讲一下我们的测试框架 TestKit。
首先,再也不要用"古法"去读代码了——AI 能做得非常好。
仔细想想 TestKit 怎么用的时候就觉得有趣了。比如我现在可以实现两个测试用例:一个是 test_pass——assert(access_status == 0);一个是 test_fail——assert(access_status != 0),这一定会失败。
注意 assert 会把整个程序给 crash 掉——assert(0) 以后后面所有东西都不会执行了。那为什么我的 TestKit 在 verbose=1 的时候还能在 test_fail 以后继续执行,而且还能告诉你有两个 test case、哪个 pass 了?
回去你可以让 AI 帮你读代码。核心是:我们为每一个注册的测试用例先用 mmap 创建一个共享的 buffer,从它里面收集子进程的输出来测试对不对。然后创建一个子进程,子进程会设置一个闹钟(timeout)——如果超时,子进程就被杀掉。所以你们如果程序写了死循环也会被 test case 逮住。然后在父进程上等它执行完,并检查程序的输出。
有兴趣的同学可以把它作为一个进程管理、mmap 等的综合示例来学习。
好,谢谢大家!