第7讲 · 访问操作系统对象:Everything is a File 与文件描述符

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

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

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

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

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


引子:关于实验的一次「小白演示」

(参考时间: 00:00)

本讲从一次「事故」讲起。上周发布了本学期的第一个实验;但讲师在 3 月 22 日中午更新 main 分支时拷错了目录——用了新版本的框架,却把旧版本的 testkit 复制了进去,于是无论怎么做都通不过 System Test。所幸有同学发现了并写邮件反馈,讲师「本来已经准备离开办公室,吓得赶快把 bug 修了」。

比 bug 本身更有意思的,是讲师演示的处理方式:他把自己假装成一个完全的新手小白,打开一个 coding agent,把实验要求粘贴进去,看 AI 会怎么做。

AI 的表现相当到位:它知道要在命令行里执行正确的命令,会把框架代码 clone 到 OS2026 目录,会真的去检查目录结构是否和实验手册一致,然后给出肯定的答复;你还可以不断追问,让它一步步把整个作业做完。

接着是最精彩的一段:讲师想做一件「不太常规」的事——把 main 分支往前回退一个 commit:

编者拓展|git reset --hard 与「平行世界」:git reset --hard <commit> 会把「当前分支的指针」移动到指定提交,并同时把工作区和暂存区也强制改回那个提交的状态——所以未提交的改动会丢失。之所以在演示里很安全,是因为那是新 clone 下来的仓库,自己的代码不在里面。而 git reflog 记录了 HEAD 的每一次移动(哪怕已经「看不见」的提交),所以退回的提交通常还能靠它找回——这正是我们在第 04 讲说的「Git 给了你无数个平行宇宙」。

短短几分钟里,讲师就「学会」了 git reset、git log、git fetch、git merge 好几个命令。他的结论是:

拥抱世界的变化,让 AI 放大你的自主性(self-motivation)。 你愿意往前走一小步,就能做很多事情。

编者说明:这句话是本讲(乃至整门课)的方法论:你负责知道「能做什么」和「下一步该做什么」,把「具体怎么做」交给 AI。 以前学一门课要被迫啃很多文档、翻很厚的书;现在你随时可以让 AI 把「你需要的那一点知识」从海量信息里精准隔离出来。

回到主线:进程是一个「封闭的世界」

(参考时间: 00:06)

上周我们讲了一组系统调用——fork、execve、exit:一个程序运行起来后就是一个状态机(内存 + 寄存器);想创建进程就 fork(完整复制状态)、想执行别的程序就 execve(复位状态)、想结束就 exit。

由此得到一个很重要的观念:

进程是一个「封闭的世界」——除了寄存器和内存是程序「自己」的,其他一切都要通过系统调用去访问。

这就是本讲的主题:操作系统中的对象,以及管理它们的 API。

一个很自然的例子会逼出「新对象」的需求:你用 fork 创建了 128 个进程并行算科学计算,每个进程算出一个数字,你想把这些数字加起来——光靠 fork/execve/exit 是做不到的。你需要在操作系统里创建一个新对象,让父子进程都能访问它,从而交换数据。


一、程序员想什么,操作系统设计者想什么

1.1 程序员的脑袋:请求操作系统「做一件事」

(参考时间: 00:10)

写程序时,除了「把一个数据结构变成另一个数据结构」的计算型任务,你还会不断调用库函数去完成「非计算型任务」:发一个网络请求、写一个文件、和别的进程通信……这些事情的本质,都是去访问操作系统里的对象。

程序员脑袋里想的,是面向对象、抽象、舒服的接口。

1.2 操作系统设计者的脑袋:提供一套简单、稳定的通用 API

(参考时间: 00:12)

而操作系统设计者脑子里想的,是一套简单、稳定、通用的抽象层,让应用生态能繁荣发展——「开发者要什么就给什么」是一种设计(Windows),「提供一个通用机制让你自己组合」是另一种设计(UNIX)。

Windows 走的是「迎合」路线:你想要创建文件,就给你 CreateFileW、WriteFile、SetFilePointerEx;你想访问别的进程,就给你 OpenProcess、ReadProcessMemory、VirtualQueryEx、K32EnumProcessModulesEx……这些 API 的参数直接传到内核,所以效率很高,也「非常符合软件工程规范」。

UNIX 走的是另一条路:Everything is a File。

编者拓展|两种路线可以互译,代价不同:同样是「读进程内存映射」,UNIX 的做法是把内核里的数据结构序列化成文本、写到 /proc/<pid>/maps,用户再用文本解析读回来;Windows 则是直接把内核数据结构/查询结果返回。前者多了一次序列化和一次解析(性能更差),却换来一个巨大的好处——这个接口既是机器可读的,也是人类可读的,而且能复用已有的文件工具。讲师说:「今天谁好谁坏,其实又不好说了。」

/proc/<pid>/maps:内核把地址空间映射「序列化」成文本

📷 /proc//maps:内核把地址空间映射「序列化」成文本
回到原视频 (00:14:30)


二、Everything is a File

2.1 什么是「文件」?

(参考时间: 00:17)

「文件」是大家上小学电脑课就学过的概念——一个有名字的数据对象,一个可以按位置读写的字节序列。而字节序列在计算机世界里是一个极其朴实的抽象(冯·诺依曼结构里,最重要的就是代码和数据):

而这个抽象可以覆盖的东西远超你的想象:

graph LR
    F["文件(字节序列)"] --> S["数据流 / 数组"]
    F --> N["网络连接(正在向你发数据的服务器)"]
    F --> D["设备:打印机 / 终端 / 显卡"]
    F --> L["一把锁"]
    F --> P["一个管道"]

连打印机、终端、显卡都可以「假装」自己是文件(为什么?讲到设备驱动时会回答)。甚至一把锁、一个管道,也可以。

于是 UNIX 的基本想法就是:如果所有东西都是文件,那我只要有一套工具(cat、wc、grep……),就能处理操作系统里的任何对象。再进一步,用目录来管理这些名字——一个操作系统对象对应一个路径名。真要自己实现文件系统,甚至可以简单到「一个从 Path 到 bytes 的 dict」。

编者拓展|「一切皆文件」的历史与边界:这并非空想。Linux 把大量内核对象都做成了文件:/proc(进程与内核信息)、/sys(设备与驱动模型)、/dev(设备节点)。但这套抽象也有代价:文本序列化有性能开销、格式随内核演进、/proc 里很多文件其实是「一次性的伪文件」(读一次内容就变)。所以现代系统里也有大量非文件对象(例如 epoll、io_uring、seccomp)——「一切皆文件」是一种强大的默认,而不是绝对教条。

2.2 文件系统足以构建任何信息系统

(参考时间: 00:22)

讲师用课程网站举例:他选了提交系统里直接看后台目录——files_receive/OS2025/...,每个同学的提交就是一个文件;后端收到提交就往某个目录写一个文件;online judge 不断 watch 这个目录,评测完就生成一个 .result 文件;你刷新页面时,后端就是遍历目录树。

为什么不用数据库? 讲师的理由很有意思:

编者拓展|文件系统 vs 数据库:一致性的代价:文件系统没有强一致性保证——如果你要写三个文件,写到第二个时程序崩了,系统就进入了不一致状态。讲师用「人力 + 概率」来换方便:赌服务器运行时几乎不出这种事,出了就用一两个命令删掉。如果你的场景对一致性要求高(金融、库存……),数据库的事务(ACID)就是必需的。讲师也给出了 AI 时代的新思路:先用文件系统写一个低性能但正确的参考实现,再让大模型把它翻译成数据库 / serverless 的实现,甚至自动证明幂等性与语义等价。

2.3 文件系统里到底有什么:FHS

(参考时间: 00:30)

Linux 的文件树不是随便长的,背后有一个标准——FHS(Filesystem Hierarchy Standard)。它让软件和用户可以预测文件应该装在哪里:/home、/dev、/boot、/usr、/etc、/srv……(Mac OS 就不完全遵循 FHS,所以你换系统时得重新熟悉)。

编者拓展|FHS 里的几个常见目录: - /etc:系统的配置文件; - /usr:用户程序与数据(/usr/bin 放可执行文件、/usr/lib 放库); - /var:会变化的数据(日志、缓存、邮件……); - /proc:虚拟文件系统,暴露进程与内核信息; - /sys:另一个虚拟文件系统,暴露设备与驱动; - /srv:服务数据;/dev:设备文件。

以前学这些要靠「翻厚厚的书、搜零散博客」;现在你对哪个感兴趣就问 AI,它会精准地把你需要的那一点知识隔离出来。

讲师现场演示了 ls /:/home、/dev、/boot、/usr、/etc……你会看到几乎每一个 Linux 都遵循这套结构。

2.4 UNIX Philosophy:KISS

(参考时间: 00:33)

在这样一个「一切皆文件」的世界里,UNIX Philosophy 的力量就体现出来了。它又叫 KISS(Keep It Simple, Stupid),有三个要点:

  1. 每个工具只做一件事,并把它做好(grep、sed、awk……);
  2. 让工具能协同工作(work together);
  3. 用文本流(text stream)作为通用接口。

为什么「文本接口」是个了不起的设计? 因为文本既人类可读,也机器可读。看 /proc/<pid>/maps 的文本输出,power user 一眼就懂;而 Windows 返回的是一个二进制数据结构(里面混着字符串、指针、十六进制数),你还得再写一个工具去解析它。

编者拓展|这是一次勇敢的取舍,而且对 AI 特别友好:在 1970 年代,每一个时钟周期都很贵,选择「牺牲性能换取通用、可读的文本接口」需要很大勇气。今天这个决定意外地非常适合大语言模型——大模型正是在自然语言上预训练出来的,读命令行工具的文本输出、写命令行工具,它都很擅长。讲师现场演示了这条「像自然语言的命令」:

bash grep -s VmRSS /proc/*[0-9]/status | awk '{sum += $2} END {print sum " kB"}'

它用通配符找出 /proc 下所有进程的 status,再累加 VmRSS(物理内存占用),一行就求出了系统所有进程的物理内存总和。这就是「quick & dirty」的 hacker 风格。

当然,这套设计也被《UNIX 痛恨者手册》(The Unix Haters Handbook)骂得很惨——比如文件名里带空格会让一堆工具出错,连 GNU make 至今都不能很好地处理带空格的文件名。讲师的判断是:这些「诡异行为」的代价,换来了自然语言与编程语言之间的一个平衡点;而它今天也已经完成了历史使命——成了大模型的语料。


三、访问操作系统对象:文件描述符

(参考时间: 00:42)

现在回到最开始的问题:操作系统到底提供了什么样的 API 来访问它的对象?

3.1 从 CrazyOS 的 struct FILE 说起

(参考时间: 00:43)

还记得 CrazyOS 里那个 putchar 系统调用吗?它在操作系统内部,就是把字符写进每个进程的一个缓冲区:

struct FILE {          // CrazyOS 里的「文件」
    char *data;        // 指向缓冲区
    size_t offset;     // 写到哪了
};

// open:  f = malloc(sizeof(struct FILE));
// close: free(f);
// read/write: *(f->data++);       // 写一个字节,offset 后移
// lseek: f->offset = ...;         // 移动读写位置
// dup:   f_new = f;               // 指向同一个文件

这不就是「文件」吗! 文件在操作系统内部,无非就是一个 buffer、一个长度、一个指向「现在写到哪」的 offset。我们甚至可以想象把它塞进进程的 struct proc 里——根据 Everything is a File,你从用户的角度看到的是 /home/.../a.md 这样的路径,而它映射到内部的一个可访问的字节序列。

进一步的类比:C 标准库里的 struct FILE(fopen/fread/fwrite)其实就是对 UNIX 最早那套系统调用的一层封装。所以是「先有系统调用,才有 C 的 API」——只是你们先学了 C 的 API,我们反过来讲。

3.2 文件描述符:指向操作系统对象的「指针」

(参考时间: 00:47)

问题来了:操作系统内部有 struct FILE,但应用程序拿不到这个指针——程序只有内存和寄存器(也就是你在 main 里能碰的东西)。

UNIX 的答案,是给每个进程另一个「地址空间」:

0 → stdin(标准输入)
1 → stdout(标准输出)
2 → stderr(标准错误)
3, 4, 5, …  → 你 open 出来的文件

所以,文件描述符(file descriptor, fd)就是一个「指向操作系统对象的指针」,只不过它的编号从 0、1、2 开始。用 open 打开文件时,会在进程的 fd 空间里分配一个最小的未使用编号(所以新文件通常从 3 开始;文件关闭后编号可以被复用)。

编者拓展|它其实是「能力指针(capability)」:文件描述符不只是「第几号文件」这么简单,它是内核给你的一张受限的、可传递的凭据:你持有 fd,就代表你有权对这个对象做「该 fd 允许的」操作(读/写/追加……)。这解释了两件事:① 为什么 dup 能让你「多拿到一个指向同一对象的引用」;② 为什么把 fd 泄露给子进程是危险的——你等于把访问权限也交了出去(见 §3.4 的 O_CLOEXEC)。Windows 的 handle 把这个「带权限的指针」做得更显式,名字也更形象。

3.3 Windows Handle 与「句柄」的乌龙

(参考时间: 00:53)

Windows 里对应文件描述符的东西叫 Handle。讲师觉得这名字更形象:

所以「handle 指向操作系统对象、并赋予你操作权限」的直觉非常清楚。而中文把它翻译成「句柄」,纯属历史乌龙:早期一本讲编译的书把 "a handle of a sentence"(句子的「柄」)直译成「句柄」,于是这个奇怪的词就被一直沿用、最后被 Windows 官方翻译采用。你在任务管理器里看到的「句柄数」就是它。

Windows 任务管理器里的「句柄」(handle)

📷 Windows 任务管理器里的「句柄」(handle)
回到原视频 (00:57:30)

3.4 真正的复杂性:API 之间会互相影响

(参考时间: 00:59)

「打开一个文件、往里写一个字节」看起来极简单:

int fd = open("log.txt", O_WRONLY);
write(fd, "1", 1);
write(fd, "2", 1);   // 文件里会依次出现 1、2

但如果中间发生 fork 呢?

write(fd, "1", 1);   // 先写 1
fork();              // 复制出一个子进程
write(fd, "2", 1);   // 父进程和子进程都会执行这句!

现在世界上有两个进程都要「往同一个文件的当前位置写一个 2」。文件里最后是一个 2,还是两个 2? 这取决于操作系统设计者的选择:

graph TD
    A["打开 log.txt(内核中的一个 struct FILE<br>= 缓冲区 + offset)"] -->|"fork 时深拷贝"| B["父、子各有一份 offset<br>→ 结果是一个 2"]
    A -->|"fork 时浅拷贝(UNIX 的选择)"| C["父、子共享同一个 offset<br>→ 结果是两个 2,不互相覆盖"]

UNIX 选了浅拷贝。 理由是:当父子进程共享一个文件、并以追加方式写入时,不应该互相覆盖对方的数据(覆盖通常不是程序员想要的)。

编者拓展|fork 的「原罪」与 O_CLOEXEC:fork 复制的是整个进程持有的所有资源,于是它必须和几乎每一个系统调用、每一种对象都定义清楚「复制后的语义」——这正是「fork 看起来优雅、实现起来极其复杂」的原因。有一篇很有名的论文 A fork() in the road(微软研究院的 Andrew Baumann 等)就主张「不要再教 fork 了」。

更危险的场景是权限泄露:父进程持有一个敏感文件的 fd,如果不小心忘记关闭就去 execve 执行了另一个程序,那个子进程就继承了这份访问权限。所以 Windows 的 handle 默认不继承(可显式 bInheritHandles),遵循最小权限原则;Linux 则补了 O_CLOEXEC / close-on-exec:让 fd 在 execve 时自动关闭。软件系统的每一处设计,都要小心考虑它和其他部分的交互。


四、特殊的文件:看看系统里到底有什么

(参考时间: 01:06)

既然「一切皆文件」,那就可以直接把系统里的对象都列出来看。讲师用一条命令打开「所有进程打开的所有文件」:

ls -l /proc/*/fd/* 2>/dev/null \
  | awk '{print $(NF-2), $(NF-1), $NF}' \
  | claude explain it

你会看到:每个进程都打开了一些文件——有的打开 /dev/pts/5(伪终端)、有的打开 pipe、有的打开 /dev/tty7(真正的控制台)、/dev/null(空设备)、GPU 渲染设备、输入设备、/dev/urandom(随机数)、套接字、管道……

更神奇的是:你甚至能看到别的进程打开了什么文件(在文件系统里就能看到);只要你有权限,你甚至可以打开别人的终端往里写数据——比如往 /dev/pts/5 写一个 hello,别人的终端上就会出现这个 hello。

编者拓展|常见特殊文件的含义: - /dev/pts/N:伪终端(pseudo terminal slave),你的终端模拟器给 shell 分配的「一端」; - /dev/tty7:真正的虚拟控制台(Ctrl+Alt+F7 那种); - /dev/null:黑洞,写入的数据被丢弃,读永远是 EOF; - /dev/zero:读出来是无限的零字节; - /dev/urandom:随机字节流; - /dev/sda、/dev/nvme0n1:块设备(磁盘 / SSD);讲师在树莓派上则是 /dev/mmcblk0(SD 卡)。

讲师的原话是:「在 everything is a file 的加持下,你理解操作系统里任何一部分都变得前所未有的简单——你需要的只是想象力。」


五、匿名管道:让两个进程交换数据

5.1 管道是一种「会互相等待的文件」

(参考时间: 01:11)

「进程是一个封闭的世界」,所以父子进程之间要交换数据,就得靠一个新的操作系统对象。最经典的就是管道(pipe):

int pipe(int pipefd[2]);   // pipefd[0] = 读口,pipefd[1] = 写口

管道可以理解成一个特殊的文件,有一个读口、一个写口:

正是这个「阻塞」机制,让管道成了进程间同步与通信的好工具:128 个进程各自算出结果、从写口写进去,主进程从读口把 128 个数字读出来求和;每个进程写完再写一个「OK」,主进程读到足够的 OK 就知道大家都完成了。用管道,你就能实现「等进程做完再进入下一步」。

编者拓展|管道的容量与「死锁」:管道不是无限的。Linux 的管道缓冲区通常是 64 KiB,且对一个原子写入有 PIPE_BUF(至少 512 字节,Linux 上是 4096)的保证:小于 PIPE_BUF 的写是原子的,大于它的写可能被拆开、与其他写交错。如果写端持续写、读端却一直不读,缓冲区满后 write 就会一直阻塞;而如果两个进程互相等待对方(A 等 B 读、B 等 A 读),就会死锁。这些都是并发编程里最经典的问题。

5.2 命名管道 vs 匿名管道

(参考时间: 01:13)

编者拓展|为什么 shell 的 | 好用,而 pipe() 的 API 却很丑:C 里的用法是「先定义一个长度为 2 的数组,再调 pipe,然后 pipefd[0] 是读口、pipefd[1] 是写口」——参数是输出参数(out-params),语义要记。讲师吐槽:现代语言本可以写成 let (r, w) = pipe() 这样清晰的形式。而要实现 ls | wc,你还需要用 dup2 把子进程的标准输出指向管道的写口、把另一个子进程的标准输入指向管道的读口,并关掉多余的一端(「文件描述的腾挪」,下一讲细讲)。

5.3 用 AI 把「文件描述符的复制」画出来

(参考时间: 01:19)

管道 + fork 之后的 fd 关系图,是传统课堂上最容易「画得一塌糊涂」的地方。讲师的处理方式很有代表性:

他让 AI web code 了一个调试任意管道程序的脚本:它 fork、执行 pipe、在每一步读 /proc/<pid>/fd,然后把父子进程的 fd 指向关系渲染成一张 Mermaid 图,存进 Markdown 里预览。

于是你能亲眼看到:

讲师说:以前他要在黑板上画这张图;现在一行 prompt + AI 生成的调试脚本就能得到一张精确、可交互的图。「你再也不需要读文档去理解管道——直接调试示例代码,看程序的状态。」

AI 生成的 Mermaid 图:父子进程的文件描述符与管道指向关系

📷 AI 生成的 Mermaid 图:父子进程的文件描述符与管道指向关系
回到原视频 (01:22:00)


六、综合例子:TestKit 测试框架

(参考时间: 01:26)

最后一个例子把本讲的多个概念串了起来——同学们的第一个测试框架 TestKit:

graph TD
    M["父进程:为每个测试用例<br>用 mmap 创建一段共享缓冲区"] --> F["fork 出子进程运行该用例"]
    F --> T["给子进程设置一个闹钟(alarm / timeout)"]
    T --> R["子进程把结果写入共享缓冲区"]
    R --> C["父进程等待它结束,检查输出"]
    T -.->|"超时则杀掉"| C

讲师也照例提醒:「今天再也不要用古法去读代码了。」 你完全可以让 AI 去解释、去写测试用例——「用魔法打败魔法」,他自己甚至有个作业的测试用例是 AIGC 的、看都没看一眼。

TestKit:在子进程中运行测试用例,超时则杀掉

📷 TestKit:在子进程中运行测试用例,超时则杀掉
回到原视频 (01:27:30)


总结


附:官方参考与延伸阅读

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

主题 链接
Crazy OS(内部文件与系统调用的最小模型) https://jyywiki.cn/OS/demos/virtualization/crazy-os
文件描述符 Demo https://jyywiki.cn/OS/demos/virtualization/filedesc
UNIX 管道 Demo https://jyywiki.cn/OS/demos/virtualization/pipe
TestKit 测试框架 https://jyywiki.cn/OS/demos/virtualization/testkit
Filesystem Hierarchy Standard (FHS) https://refspecs.linuxfoundation.org/FHS_3.0/fhs/index.html
The Unix Haters Handbook https://web.mit.edu/%7Esimsong/www/ugh.pdf
A fork() in the road(微软研究院) https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road/
OSTEP:Operating Systems: Three Easy Pieces https://pages.cs.wisc.edu/~remzi/OSTEP/
open(2) 手册(含 O_CLOEXEC) https://man7.org/linux/man-pages/man2/open.2.html
pipe(2) / pipe(7) 手册 https://man7.org/linux/man-pages/man7/pipe.7.html
dup(2) / dup2(2) 手册 https://man7.org/linux/man-pages/man2/dup.2.html
mkfifo(3) / mkfifo(1) 手册 https://man7.org/linux/man-pages/man3/mkfifo.3.html
proc(5):/proc 文件系统 https://man7.org/linux/man-pages/man5/proc.5.html

阅读材料:教科书 Operating Systems: Three Easy Pieces 第 39 章(Files and Directories)。

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