卷 I · 边界CH 01深度 01/24

一个你从没注意过的谎言

你写过成千上万行代码。它们全都建立在一个你从未质疑过的前提上:这台机器是我的。这一章要说的是——这个前提是假的,而且是被精心伪造的。伪造它的那个程序,叫操作系统。

主线三重假象那道线价目表

先看一段最无聊的代码

没有比这更朴素的程序了:

#include <stdio.h>

int main(void) {
  printf("hello\n");
  return 0;
}

你觉得它做了什么?把六个字节送到屏幕上。一件事。

实际上,从你按下回车到屏幕上出现那六个字母,机器做了十四件事,而其中八件发生在一个你完全无法进入的地方

▶ 动手 · 一行 printf 的全程

下面这台机器把那十四步拆开摆给你看。上半部分是你的进程能做的事,下半部分是只有内核能做的事,中间那道双线就是本书的主角。一步一步点下去,注意两件事:分界线被跨过了几次,以及每一步各花了多少时间。

点完你会发现一件有点扎心的事:真正把字节搬到屏幕上那一步,只占全程的一小部分。大头花在别的地方——把参数摆成内核认识的样子、跨过那道线、让内核逐项检查「你到底有没有资格干这件事」,然后再跨回来。

这本书就是关于那道线的。

三个你信了很多年的说法

「我的程序从头跑到尾」

你写了一个循环,它从第一次迭代跑到最后一次,中间没有断过——至少看起来是这样。

真相是:在你的循环执行期间,它每秒被强行打断几百到几千次。每一次打断,CPU 都跑去执行别人的代码——你的浏览器、系统的日志服务、某个后台索引进程——然后再回来。

你察觉不到,是因为每次打断时,你的全部状态(所有寄存器、程序计数器、栈指针)都被完整地保存下来,回来时又被完整地恢复。就像有人把书签精确地夹在你正在读的那个字上,把书拿走给别人读,再放回你手里,翻到那一页。你唯一能察觉的证据,是时间过去了

负责这件事的,是调度器(卷 II)。

「我的变量在某个内存地址上」

你打印一个指针,得到 0x7ffd4a2b1c30。这个地址看起来无比具体。

但如果你的同事在他的机器上跑同一个程序,他会得到一个非常相似甚至完全相同的地址。你们俩的变量当然不在同一条内存上——那么这个地址到底是什么?

它是编的。你的程序里出现的每一个地址,都是「虚拟地址」——一个只在你这个进程内部有意义的编号。真正的物理内存地址长什么样,你这辈子都没见过,也没有任何办法见到。每次你访问内存,都有一个叫 MMU 的硬件在中间做翻译,把你编的那个地址翻成真的。

负责这件事的,是虚拟内存与页表(卷 III)。

「我打开了一个文件」

你写 open("data.txt"),拿到一个 3。你觉得这个 3 代表那个文件。

它不代表。它是一个数组下标——内核给你的进程维护了一张表,3 是这张表里的第 3 格。同一时刻,我的进程里的 3 可能指向一个网络连接,另一个进程里的 3 可能指向你的摄像头。这个数字本身什么信息都不携带

你从来没有「拿到过文件」。你拿到的是一个号码,凭这个号码去求内核替你做事。

负责这件事的,是文件描述符与 VFS(卷 IV)。

◆ 全书主线

操作系统的全部工作,是维持一个骗局:让每个进程都以为自己独占整台机器。

这个骗局有三重:时间上骗你独占 CPU(其实被切成片)、空间上骗你独占内存(其实一个真地址都没碰过)、接口上骗你独占设备(其实只拿到一个号码)。

三重假象的背后是同一个东西:一道线。线的上面是你,只能做「不会影响别人」的事;线的下面是内核,全机器只有一份,它知道所有真相。你每次想干点真事,都得跨过去——而每一次跨越都要付钱

这本书就是把这三重假象逐一拆开,最后交给你一张价目表

为什么非得骗你?

值得停下来想一想:这一切能不能不要?让每个程序直接用真的物理地址、直接跟硬盘控制器说话、自己决定什么时候让出 CPU——不是更简单更快吗?

这个方案真的存在过。DOS 就是这么干的,早期的嵌入式系统也是。而它带来的后果非常具体:

如果没有这个骗局会发生什么这本书在哪章解决它
大家用真的物理地址你的程序写错一个指针,改掉的是别人的变量。而且每个程序都得知道自己被装在内存的哪一段——换台机器就得重新编译卷 III
程序自己决定何时让出 CPU一个死循环就能让整台机器躺下。你无法 kill 它,因为 kill 也是个程序,而它拿不到 CPU卷 II
程序直接操作硬件两个程序同时往硬盘写,扇区交错,文件系统结构当场损坏。而且每个程序都得自带一套硬盘驱动卷 IV
没有权限边界任何一个程序都能读走你的密码、改掉系统文件。「安全」这个词失去意义卷 I

所以这个骗局不是为了骗你,是为了保护你——保护你不被别人搞垮,也保护别人不被你搞垮。它让你可以放心地写 int x = 5; 而不必操心这块内存会不会正巧是别人的,让你可以写一个死循环而不必担心整台机器陪你一起死。

你享受的每一份「简单」,都是这个骗局替你挡下来的复杂度。

✎ 掌故 · 这个词的来历

「操作系统」(operating system)这个名字,来自它最早的职责:在批处理时代,程序员把打孔卡交给机房,由操作员(operator)一批批装载运行。后来这套流程被自动化成一个常驻程序,人们就管它叫「自动操作员的系统」。

换句话说,它一开始只是个排队员。「保护」和「假象」这两个后来的核心职责,是随着多任务和多用户的出现,被现实一点点逼出来的——每一层保护,背后都躺着一次真实的事故。

那道线,和它的价目表

这本书会反复回到一个动作:跨过那道线。它有三种发生方式,你会在卷 I 里逐一见到:

  • 你主动下去——系统调用(第 3 章)。你需要内核帮忙:读文件、开进程、发网络包。
  • 你被迫下去——异常(第 4 章)。你访问了一个还没准备好的地址、你除以了零。CPU 当场把控制权交给内核。
  • 你被打断——中断(第 4 章)。跟你毫无关系:网卡收到了包、时钟走了一格。你的执行被强行暂停。

三种方式,同一个结果:CPU 从「执行你的代码」切换到「执行内核的代码」。而这件事有成本——保存状态、切换特权级、换栈、让缓存变冷。

¤ 价目表 · 第一条

一次什么也不做的系统调用,往返一趟大约 100 ns

参照物:一次 L1 缓存命中约 1 ns,一次主存访问约 80 ns。也就是说——光是「下去再上来」这个动作本身,就值一次内存访问还多。它什么都还没帮你做。

这个数字会跟着你走完全书。每次你看到一段代码在循环里调系统调用,心里应该立刻响一声:这里每转一圈,就有一百纳秒扔在门口了。

全书每一章都会往这张价目表上添一行。到第 24 章,你会拿到完整的一张——从 1 纳秒到 8 毫秒,横跨七个数量级

这本书怎么走

卷 I · 边界(你在这儿):把那道线本身说清楚。为什么必须有它、它长什么样、有哪三种方式跨过去。

卷 II · 分身:拆第一重假象。进程是什么、fork 为什么会返回两次、调度器凭什么决定谁跑、切换一次到底多贵。

卷 III · 幻境:拆第二重假象,也是全书最硬的一卷。地址是怎么被翻译的、缺页时发生了什么、malloc 底下藏着什么、以及「为什么 malloc 从不失败但你还是被 OOM 杀了」。

卷 IV · 万物:拆第三重假象。fd 的三层表、inode 与「删了文件磁盘没变空」、IO 一路要经过哪些层、一个线程怎么守住一万条连接。

卷 V · 邻里:进程之间怎么说话,以及——怎么把这个骗局再演一遍(那就是容器)。

卷 VI · 落地:把前面所有东西接回你的日常。一章排障对照表,一张完整价目表。

↑ 回到应用层

每一章都会以这样一节结尾,把当章的机制接回一个你可能真的遇到过的现象。这一章的是:

「为什么我的程序在容器里比在物理机上慢?」——这个问题至少有五个完全不同的答案,分别藏在第 8 章(CPU 配额怎么影响调度)、第 12 章(内存限制怎么变成颠簸)、第 18 章(存储驱动多加了一层)、第 20 章(网络多走了一跳)和第 22 章(cgroup 与 namespace 的区别)里。

在读完这本书之前,你只能靠试。读完之后,你会知道该先量哪一个。

这一章的一句话

你写的每一行代码,都活在一个被精心维护的假象里:你以为你独占整台机器。这个假象由一道硬件边界撑着,而每一次跨越边界都要付钱——这本书就是把假象拆开、把价目表交给你。

下一章,我们去看那道线本身:它在硬件上到底是什么,以及划线的标准为什么朴素得让人意外。