一、阶段小结
不知不觉已经讲到 Virtualization 部分的最后一节课了,进度有一点快。每一次课都在给大家调代码,每一次课都会引入新的工具和知识。今天的 Hacking Day 也算是一个回顾,给大家讲讲在操作系统上面应用生态是怎样构建起来的。今天大部分内容不会在考试里出现,但对大家应该会比较有用。
我们花了好多次课讲虚拟化。虚拟化到底讲了什么?我们从程序和进程开始讲,讲进程的地址空间——里面有游戏修改器;再讲到文件描述符,讲我们怎么去打开操作系统里面各种各样的对象。你们做了实验 PS3、Sperf,也知道了 C 标准库是怎样一层一层实现起来的。
上一次课,我合上了整个 Virtualization 部分的最后一片拼图,讲的是链接和加载。但其实,我整个学期到现在所有的时间,都是在讲系统调用——讲操作系统给应用程序提供的各种各样的 API。所以上节课讲的实质是 execve 系统调用的行为。
你们可能当时没有完全反应过来。execve 的行为就是:把 execve 的 path 对应的可执行文件的初始状态,搬到这个进程的内存里。这里面包含了寄存器的初始值——不管是 x86、ARM 还是任何体系结构,都有一个 stack pointer;stack 上面有初始的进程栈,包括 Auxiliary Vector;还有一个初始的内存布局。我们甚至看了 Linux 内核里加载器的代码,知道了里面每一项都是有代码写进去的,没有任何魔法。
除此之外我们还讲了加载:有静态链接加载,也有动态链接加载。execve 的行为就是把 ELF 文件里所有 PT_LOAD 段的代码、数据映射到进程的地址空间里。对于动态链接的可执行文件,在用 start 调试它第一条指令的时候,libc 这些库还不在进程地址空间里,而是由加载器(interpreter)再用 mmap 把那些 .so 文件的各段内存映射进来。等所有东西都准备完毕之后,PC 会指向 interpreter 里的第一条指令,程序就开始往后执行——所有行为都是你们在 strace 里能看到的。
所以说,我讲的本质是 execve 系统调用的行为。你再回过头,在 AI 的帮助下把那些代码看一遍,会有一个惊喜:这么复杂的东西,在概念上其实并没有什么困难的。你建立了正确的概念,就能知道里面每一个字节是怎么来的;你提出正确的问题,AI 会帮你找到答案。
这个学期我到现在一直都是在讲系统的行为。Virtualization 部分快要结束了,后面我会讲由这些系统调用引发的更大的麻烦,比如并发和持久性的文件系统。但前半部分课程传递的核心 message 是:你们可以通过阅读手册,指导 AI 来写代码,去理解任何在计算机系统里看到的、不理解的东西;并且还可以回过头来问"为什么要这样设计"。
二、从 UNIX 到 Linux(毒鸡汤)
今天我就会讲一点系统调用之外的内容——我们现在已经知道各种系统调用的行为是什么了,再来讲这些系统调用是怎么来的,以及系统调用出现以后,又是怎样把应用生态建立起来的。
故事的起点是一篇论文,叫做 The Evolution of the Unix Time-Sharing System,我强烈推荐每一个同学去阅读一下。它告诉你 UNIX 怎么从零开始一点一点实现成今天这个样子。最早版本的 UNIX 甚至没有 fork:它创建进程的方式是,shell 如果要运行一个外部程序(比如 ls),就把所有打开的文件都关闭,把文件描述符 0 和 1 打开到终端,然后把自己退出,用 execve 把一份代码加载到内存里执行。可执行文件退出之后,shell 再被重新加载回来——就这样 shell、可执行文件、shell、可执行文件地来回切换。
后来,Thompson 用 27 行汇编实现了 fork,因为那时候简单的进程模型只需要几个指针就能描述,实现 fork 非常简单。这给我们一个启发:所有复杂的东西,在刚开始的时候都是很简单的。
1987 年,Andrew S. Tanenbaum 写了一个叫 Minix 的操作系统,配套一本书 Operating System Design and Implementation。这本书前一半讲操作系统原理,后一半是代码——我们那个时候有惊人的注意力,可以一边看书一边翻到后面找代码,一行一行人肉解读,它算是我操作系统的启蒙老师。Minix 最早是 UNIX V7 兼容,1997 年做了 Minix 2.0,实现了 POSIX 兼容;后来还发展出了 Minix 3,是一个相当成熟的操作系统,甚至因为许可证宽松被英特尔用在了芯片组的固件里——后来被人发现,这才有了"Minix 才是世界上最流行的操作系统"的帖子。
我给大家准备了一个 Minix 的演示。有爱好者已经把 Minix 完整存档,你下载下来直接 make minix2 就可以从 GitHub 上拉取源码并编译,生成 Minix 1.7 和 Minix 2.0 的镜像——在今天看非常小,但在那个时候已经不小了。感谢开源世界里随手可得的工具,我们直接 make run,就可以在 QEMU 模拟器里运行它。
你可以看到,Minix 1.7.5 是随书附带的代码,每一本 Tanenbaum 操作系统教科书的读者都可以使用。系统启动日志显示"Minix executing in 16-bit protected mode",这是一个如假包换的 16 位 UNIX V7 兼容系统。在这个系统里,你可以用 vi 编辑 C 文件,用 cc 编译,用管道和 wc 统计行数,用 man 查看手册——man 1 用户命令、man 2 系统调用、man 3 库函数都有,体验和今天的 Linux 几乎一样。你甚至可以找到整个 Minix 世界所有工具的源代码,包括 vi、cc 等——这是一个完整的、可以自己编辑自己的操作系统。
然后在 1991 年 8 月 25 日,发生了一个里程碑式的事件。来自芬兰的 21 岁年轻人 Linus Torvalds,在 Minix 的邮件列表(comp.os.minix)上发了这样一个帖子,大意是:我现在在做一个 free 的 operating system,just a hobby,won't be big and professional like GNU。他说他只是想在手头的 386 上做一个能取代 Minix 的操作系统,4 月份就开始搞了,现在快可以给大家用了。很快,他就把最早的一批源代码发了出来。
早期的 Linux 还依赖 Minix 的工具链——文件系统需要格式化成 Minix 格式,上面运行的软件包括 GNU 的 GCC 和 Bash 的早期版本。有时候就是这样,在合适的时间、合适的地点,有一个合适的人,就做成了一件事。当然,这个世界上可能还有更多合适的人,在不合适的时间做了合适的事,然后这件事就被雪藏了几十年。Frank Rosenblatt 1957 年提出感知机,那不就是今天的深度神经网络吗?但没有算力、没有合适的氛围,进入了 AI 的寒冬,直到 2012 年的 AlexNet 才迎来转机。
Linux 发出来之后,Tanenbaum 在邮件列表上公开和 Linus 论战。这是一个大学教授和一个毛头小伙子在"贴吧"上公开互喷的故事,非常精彩。Tanenbaum 站在道德制高点说,"to me, writing a monolithic system in 1991 is a truly poor idea"——攻击性非常强。Linus 当然也喷了回去。
更有意思的是,Ken Thompson——图灵奖得主、UNIX 的创造者,相当于中科院院士级别的人物——也在那个邮件列表里实名发表了自己的看法。他说他同意微内核可能是未来的方向,但也预言 Minix 的实现"may be turning into a mess in a hurry"——结果他说的这些后来都成真了。Linux 在 2.4 到 2.6 之间确实经历过一段代码量断崖式下降、陷入泥潭的时期,经过多个子系统的巨大重构,才逐渐变成了今天这个既像微内核又像宏内核的成熟系统。
那 Linus 是怎么成功的?Just for fun。他有一本回忆录叫 Just for Fun: The Story of an Accidental Revolutionary——"意外的革命",没有人想到。那个 21 岁的少年 1991 年 8 月 25 日在贴吧上说,我就是做着玩的,没想到诞生了今天整个计算机世界的基座。革命不是 born 的,can be planned,can be managed——它们就这样发生了,just happen。
我们现在处在一个很有意思的交叉路口。从零到零点一,变得前所未有的容易。比如有一个叫 Caveman 的项目,用极致的 prompt 压缩大模型的输出——大模型被微调成了一个"舔狗",什么话都要把主人伺候好,但这些废话对于完成任务没有实际帮助,是浪费 token 的行为。Caveman 让模型"能用文言文就用文言文,能省就省",并利用 agent 的容错性(做错了就再试一次)来弥补偶尔的准确率下降。这个想法背后的道理,其实和我在 Claude Code 里让它用中文简洁作答是一样的。
从零到零点一变得容易,但从零点一到零点九五可能也很快会变得容易。创新的模式在改变——也许你们当中有同学有一个想法,借助 AI 工具就能快速做出原型,然后去"贴吧"发个帖子,就像当年的 Linus 一样。所以我讲这段历史,不只是讲历史,是希望你们——哪怕在大学里已经有点 burn out 了——能找到那种状态,在 AI 的帮助下做一点了不起的事情。
三、从 initramfs 开始构建 Linux 世界
好,前面讲了历史,现在来看一个真正的 Linux——我们能不能从零开始真正掌控它?
先回顾一下进程的初始状态:execve 时给出 argc、argv、envp 等参数,path 和 interpreter 被加载到内存,PC 被设置为正确的值,程序开始执行。我们的硬件也有一个 CPU reset 状态,由固件(firmware)来加载操作系统,操作系统再加载第一个进程。
那么,Linux 操作系统加载完成后,运行的第一个进程是什么?你们在 PS3 里看到所有进程都有一个共同的根叫 systemd,那第一个进程就是 systemd 吗?今天这部分内容就来讲这个。
只要能问出"我们能不能控制 Linux 加载的第一个进程",答案就一定是肯定的。你马上可以让 AI 帮你搞定。答案是:你需要准备一个东西,叫 initramfs(初始内存文件系统)。
execve 的第一个参数是一个路径名,指向系统里的一个文件。但是内核刚启动时,还不知道文件系统存在哪里——是 U 盘还是磁盘?所以最早期的文件来自 initramfs,这是你完全可以控制和构建的东西。你在 Linux 上执行 apt update 时,有一个特别慢的操作叫 update-initramfs,原因就在这里:某些软件更新之后,新版本需要被重新打包进这个最初始的文件系统。
知道这一点之后,你就可以让 AI 帮你 build 一个 initramfs。你准备一个目录树,比如 /bin 里放 init,打包成 cpio 归档再用 gzip 压缩,生成 initramfs.cpio.gz。启动 QEMU 时,用 -kernel 加载内核,用 -initrd 加载这个打包好的 initramfs,再加一个内核命令行参数 rdinit=/init 来覆盖默认行为,指定第一个进程。
Linux 内核代码里硬编码了一系列路径,会依次尝试 /sbin/init、/etc/init、/bin/init——直到 execve 成功为止。这就是第一个进程的来历,而且我们完全可以控制它。
我给大家准备了一个最小的案例。首先是一个最小的 x86-64 二进制文件,它用系统调用打印"this is a minimal binary",然后调用 exit——不需要任何额外的库函数。把这个文件作为 initramfs 里的 /init,打包、启动,就可以看到:Linux 6.1.0 启动之后,内核日志里出现"run /init as init process",然后打印出红色的"this is a minimal binary"。
有意思的是,当这个 init 执行 exit 退出之后,整个操作系统内核直接 panic,打印"Kernel panic - not syncing: Attempted to kill init!"——因为 init 进程退出了,整个系统就挂掉了。这就证明了我们确实控制了 Linux 加载的第一个进程。
initramfs 的真实结构
我用 Claude Code 帮我解包了树莓派上真实的 initrd,发现它分成了两段压缩数据——第一段只有 7.3 MB,但文件总共有 23 MB,还有额外的 19 MB 用 ZST 格式压缩,解出来是另一个 cpio 归档。这是 Linux 内核 initramfs 格式的一个复杂之处:它可以分多段压缩拼接在一起。
解包之后,你会看到一个相当完整的小系统,目录结构长得非常像根目录:有 lib、usr、bin。里面有核心的启动脚本,告诉系统按照什么顺序启动;有 NTFS3G,可以从 NTFS 驱动器读取数据;有键盘布局和字体配置;有大量由 BusyBox 提供的精简版命令行工具;有动态链接库;有内核模块信息;还有部分固件的二进制文件。
在 initramfs 的最后阶段,有一个关键的系统调用叫 pivot_root,它会把根文件系统切换到真实的磁盘文件系统,然后启动 systemd,由此创建你在进程树里看到的整个操作系统进程世界。这就是为什么你从来没在正常的 Linux 上见过那个 initramfs 的目录结构——它在启动完成之后就被替换掉了。
动手构建一个最小 Linux
我给大家准备了一份脚本,分两个阶段:
第一阶段(initramfs 阶段): initramfs 里只有一个 /bin/busybox 和必要的驱动模块,没有其他任何东西。init 脚本用 BusyBox 的 shell 来解释。脚本先运行一个交互式 shell,shell 退出后,执行一段神奇的三行脚本:
ounter(lineounter(lineounter(linefor command in busybox --list; do busybox ln -s busybox /bin/$commanddone
这三行脚本把 BusyBox 支持的所有命令(ls、grep、wget……)都以符号链接的形式创建在 /bin 下,瞬间拥有了一整套命令行工具。随后脚本继续:mkdir 挂载点,mount procfs 和 sysfs,insmod 加载驱动,mknod 创建设备文件,最后打印"initramfs busybox sh"并启动一个新 shell。
你在这个 shell 里运行的每一条命令背后,都是系统调用:ln 背后是 symlink 系统调用,mkdir 背后是 mkdir 系统调用,mount 背后是 mount 系统调用,mknod 需要指定主设备号和从设备号才能创建 /dev 里的设备文件。这就是 /dev/random(主 1 次 8)、/dev/urandom(主 1 次 9)、/dev/null(主 1 次 3)等设备的来源——没有任何魔法。
第二阶段(pivot_root 之后): 脚本挂载 /dev/vda(QEMU 的虚拟磁盘),切换根文件系统,执行磁盘上的 /init。这个新的 init 再次完整地创建 BusyBox 符号链接、挂载 procfs、sysfs,创建设备文件,然后加载网卡驱动 e1000,配置 IP 地址,最后启动 httpd 监听 8080 端口。
由于 QEMU 做了端口映射,把虚拟机内的 8080 映射到宿主机的 8080,我在浏览器里访问 localhost:8080/init,真的看到了虚拟机磁盘里的 init 脚本内容。
这就证明了:我们真的点亮了这个 Linux。从 initramfs 加载的第一个进程开始,之后所有的事情都是用系统调用实现的——你在抖音上看到的每一个像素点,背后都是系统调用。
四、应用生态
操作系统的 API 很大程度上是和使用这个操作系统的应用程序共同演化的:应用程序发现性能不够,操作系统就提供新的 API 和新的对象,它们共同进步,变成了今天操作系统的样子。是应用生态成就了操作系统的繁荣。
操作系统本身只是一套 API,但因为厂商和个人开发者每天都在 GitHub 上发布新的应用,所以操作系统还需要一套核心的工具链来支撑这个生态:基本运行库(libc)、图形库、核心工具链——这里面一个安全漏洞都不能有。还有辅助工具、版本管理……这些加在一起,远比 Linux 内核的 2000 万行代码体量大得多。一个复杂的业务应用本身就可能是上千万行代码,它们还要互相协作。
应用生态的分发方式也在演进。早期 DOS 时代用软盘和光盘发布软件;进入互联网时代,Linux 在 1998 年就有了 apt(Advanced Packaging Tool),你只需要 apt install firefox 就装好了——比苹果 App Store 还早了将近十年。apt 有一整套开源供应链的管理流程:代码从 unstable 分支,到愿意尝鲜的 testing,再慢慢进入稳定(stable)状态,有志愿者维护整个流程。
Debian 的使命是"creating a free operating system"——这里的 free 不是免费,而是"software freedom":所有软件的源代码都可见、可理解、可贡献。我觉得计算机科学能发展得这么好,很大一个原因就是像 Linus 这样的人,有了好东西就迫不及待地想和别人分享;每个人看到了别人分享的好东西,也自然愿意把自己的好东西分享出去。
今天的应用生态分发平台还有:App Store、rpm、Python Package Index、npm、Hugging Face、Ollama……这些都是内容分发生态的不同形态。你们随便找一个系统里的软件包,让 AI 帮你解开,就可以完整地理解这个应用生态是怎样一层一层构建起来的。
操作系统是分层的:最内层是内核 API,上面是 libc 和核心工具链,再上面是整个应用生态。我们这门课讲的是操作系统原理,主要讲的是那个最内层的 API——但当我们谈论"为什么中国没有国产操作系统"时,谈论的往往是这个生态的全貌。所以,操作系统有狭义的和广义的;而这个生态,以及它上面还在不断生长的大语言模型、Agent、抖音、淘宝……精彩的故事,并没有结束。