第2讲 · 应用视角的操作系统

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

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

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

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

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


引子:这一学期,我们如何看操作系统

(参考时间: 00:00)

上一讲回顾了操作系统发展的历史,也在一定程度上回答了「在人工智能的时代,为什么还要认真学操作系统」。从这一讲开始,我们正式进入本学期最重要的视角——应用程序的视角:把操作系统看成「一组 API」,从这个角度去理解它。

今天的主角是程序。我们要回答三个层层递进的问题:

  1. 什么是程序?——精确到每一个 bit 都是 well-defined;
  2. 什么是编译?——一个 C 程序怎样从高级语言变成机器指令;
  3. 操作系统上的程序长什么样?——为什么你看到的一切应用程序,本质上都和一段最小的汇编程序没有区别。

整个学期的语言选择是 C 语言。原因并不神秘:更高层的语言(JavaScript、TypeScript……)绝大部分内容都与操作系统无关,它们是建立在操作系统之上的一层库函数包装。而操作系统是硬件与软件之间的中间层,最理想的表达是汇编——但汇编又不足以开发应用程序。于是我们选择那个「离机器最近、又能写应用程序」的语言,也就是最早构建起整个应用生态的语言:C。C 语言是一种高级汇编语言,这正是它在本课程中不可替代的价值。

编者拓展|高级语言、汇编与编译器:机器指令是 CPU 唯一真正认识的东西(一串 0/1);汇编语言是机器指令的「助记符」写法(mov、call……),和指令几乎一一对应;高级语言(C、Python……)更接近人的表达,需要编译器(compiler)翻译成汇编 / 机器指令。C 恰好处于「高级语言」与「汇编」之间,所以被称为「高级汇编语言」。

操作系统上的程序

📷 操作系统上的程序
回到原视频 (00:00:45)


一、程序:解释与编译

1.1 单步解释执行 C 程序

(参考时间: 00:03)

从复杂表达式到 SimpleC

先回顾一个你可能从未深究、却始终成立的事实:虽然 C 语言提供了 x = y + z + w、函数调用参数里塞复杂表达式等方便的写法,但你总是可以把任何 C 程序改写成「每一行只做一小件事」的形式:

// 原始写法
x = y + z + w;

// 改写为每行只做一件事
t1 = y + z;
t2 = t1 + w;
x  = t2;

条件判断里的复杂表达式、函数调用的参数,都可以这样被「提出来」。改写之后,每一个语句几乎都能和几条显而易见的指令一一对应:赋值对应 mov,分支对应跳转,循环对应带条件的跳转。这正是「C 是高级汇编语言」的含义——编译器因此非常容易构建。反观 C++,pointer->f() 这样的调用可能是虚函数、要查 vtable,很难一眼看出会被翻译成什么指令序列。

编者拓展|虚函数与 vtable:C++ 里被 virtual 修饰的函数,调用时要「根据对象的运行时类型决定调用哪份实现」,这个决策靠一张叫 vtable(虚函数表)的表格在运行时查。因此它不像普通 C 语句那样能一眼看出会变成哪几条指令——这也是「C 更接近机器」的原因。

这种改写后的最小语言,我们称为 SimpleC。既然「每行只做一小件事」,那么理论上我们就可以单步解释执行它:像计算机系统基础课上写过的全系统模拟器一样,给一个 CPU 状态(每个寄存器、每个内存单元的值),根据 PC 处的语句执行状态迁移。

理解计算机程序:C → SimpleC

📷 理解计算机程序:C → SimpleC
回到原视频 (00:03:15)

用 GDB 单步,看见状态迁移

要体会这一点,不必真的写一个解释器。我们可以把程序改写成 SimpleC 的形式,在 GDB 中单步执行,收集程序运行的状态和迁移,反过来「看」解释器是怎么工作的。

编者拓展|GDB 与 objdump:GDB 是 Linux 上的调试器,可以让程序「一次走一步」(单步),查看此刻每个变量和寄存器的值;objdump 是「反汇编」工具,把二进制文件里的机器码翻译回汇编指令,方便你「看」程序里到底有什么。二者都是理解程序运行时状态的核心工具。

在 GDB 中单步执行 hello.c,观察每一条语句

📷 在 GDB 中单步执行 hello.c,观察每一条语句
回到原视频 (00:09:05)

更进一步:既然整个计算机硬件是 register / memory 的状态机,C 程序也是状态机,那我们能不能把程序运行过程中每一个状态和迁移都打印出来?讲师准备了一个小脚本(脚本内容本身由 AI 生成,讲师几乎没有逐页检查),运行它会得到一份 trace.log:

AI 生成的脚本导出的状态迁移 trace.log

📷 AI 生成的脚本导出的状态迁移 trace.log
回到原视频 (00:12:10)

你会看到状态是怎么一步步走的:先是 c、d、format(指针)这些变量,然后走到 n = 42、q = n / 10……就像一个调试器。这就在实践上证明了:C 程序确实是一个状态迁移系统(state transition system),而且可以用一个工具把任意程序运行时的状态提取出来。

函数调用:状态到底发生了什么迁移

大多数同学对 x = y + z 这类语句毫不陌生,但一旦语句变成函数调用,就说不清状态到底发生了怎样的迁移。为了检验大家是否真的理解,讲师给出一道题:写一个非递归的汉诺塔。

汉诺塔是递归与分治的经典问题。之所以它让人「搞懂了又没完全搞懂」,是因为 C/C++ 里的「函数」和数学里的「函数」根本不同。Fibonacci 数列的递归,你可以写成 return f(n-1) + f(n-2),也可以交换两个调用的顺序,结果不变;但汉诺塔不行——因为它的 printf 会带来全局副作用。C/C++ 遵循「顺序执行」,函数调用有先后,不同顺序会得到不同结果。

编者说明:C 标准甚至不保证 return f(n-1) + f(n-2); 从左到右的求值顺序(现代编译器为防止产生难以理解的执行,通常按自然顺序、不做激进优化)。

理解计算机程序:函数调用(Tower of Hanoi)

📷 理解计算机程序:函数调用(Tower of Hanoi)
回到原视频 (00:13:10)

终极考题:f 与 g 的相互递归

比汉诺塔更狠的版本,是两个函数相互调用:

int f(int n) { return (n <= 1) ? 1 : f(n - 1) + g(n - 2); }
int g(int n) { return (n <= 1) ? 1 : f(n + 1) + g(n - 1); }

题目有三个难度递增的版本:① 写非递归汉诺塔;② 写对非递归的 f/g;③ 输出 f、g 的函数调用序列。第三题最难。下表记录了历年模型在这道题上的表现,也折射出 AI 能力的快速增长:

模型 汉诺塔 f 和 g 输出调用序列
GPT-3.5 (22.12) ✅ N/A N/A
GPT-4 (23.3) ✅ ❌ N/A
deepseek-v3 (24.12) ✅ ❌ ❌
deepseek-r1 (25.1) ✅ ✅ ❌ 错的 Python
o3-mini (25.1) ✅ ✅ 逃课 ✅
deepseek-?? (26.2) ✅ ✅ ✅

有意思的是,AI 有时会用「出乎意料但确实正确」的取巧方式做对第②题——但那是逃课,不是我们想要的完整模拟。

理解函数调用:终极考题

📷 理解函数调用:终极考题
回到原视频 (00:16:00)

程序状态机:一个严格的定义

(参考时间: 00:19)

为什么「一切皆状态机」值得反复强调?因为状态机是拥有严格数学定义的对象。这意味着你可以把定义写出来,用离散数学(集合、关系、函数)严格地理解它——这就是形式化方法。

编者拓展|形式化方法:用数学语言(集合、函数、关系、逻辑)精确地描述程序和系统的行为,从而可以证明某些性质(比如「这个编译优化不会改变程序结果」)。SimpleC 的语义定义就是形式化方法的一个小例子。

当我们谈 C 程序的状态时,一个常见的(但不精确)说法是「状态 = 变量 + 栈 + PC」。真正的定义是:

编者拓展|栈与栈帧(Stack Frame):函数每被调用一次,系统就在一块「后进先出」的内存区(栈)里为它开一小块记录,叫栈帧——里面放着这个函数的参数、局部变量,以及「执行到哪了」(PC)。函数返回时,对应的栈帧被弹出、销毁。所以「调用」= 压入一个新栈帧,「返回」= 弹出当前栈帧。 - 初始状态:仅有一个 StackFrame(main, argc, argv, PC=0),全局变量全部取初始值。 - 状态迁移:执行栈顶 frame(top-most frame)的 PC 所指向的语句。 - 若是普通语句(如 x = x + 1),PC 前进到下一句,变量更新; - 若是函数调用 f(x),则:把当前 frame 的 next_pc 保存下来,再在栈顶 push 一个新的 frame(形参 x 作为局部变量,PC = f)。这一步是完全机械的操作,许多体系结构都提供完成它的 call 指令。

因此,call 就是往栈上 push 一个 frame,return 就是从栈上 pop 一个值并恢复到 PC。下表用状态机语言精确描述了 C 的函数调用。

flowchart TD
    S["状态 = 栈帧序列 + 全局变量"] --> Q{"执行栈顶 frame 的 PC 处语句"}
    Q -->|"普通语句 x = x+1"| A["更新变量; PC 指向下一条"]
    Q -->|"函数调用 f(x)"| B["旧 frame 保存 next_pc"]
    B --> C["push 新 frame: 形参+局部变量, PC=f 入口"]
    Q -->|"return"| D["pop 栈顶 frame, 恢复调用者的 PC"]
    A --> S
    C --> S
    D --> S

有了这个模型,f 调 g、g 调 f 就自然而然被解决了——因为解释器根本不在乎 PC 指的是 f 还是 g,它只遵循一条规则:从 top-most frame 的 PC 取出一条语句来执行。严格遵循 C 语言的形式语义,一切就都是对的。

理解函数调用:程序状态机

📷 理解函数调用:程序状态机
回到原视频 (00:19:05)

同样的模型也解释了非递归汉诺塔怎么写:把「递归」直接翻译成「手动维护一个栈帧栈」。call 就是把一个 frame 压上去,return 就是弹出来;如果当前 PC 为 0,说明在函数入口,就做入口该做的事;如果 n == 1 就返回;否则依次处理「把 n-1 个盘子从 A 移到 C」等步骤。每一个 frame 里都同时保存了 PC、参数(参数也是局部变量)和局部变量。

1.2 什么是编译器

(参考时间: 00:29)

既然讲清楚了「什么是程序」,就可以问:什么是编译器?

什么是「编译后」的程序?

📷 什么是「编译后」的程序?
回到原视频 (00:29:20)

先把模型对齐:C 程序最终还是在计算机上执行,而计算机是一个「无情的、执行指令的状态机」:

struct CPUState {
    uint32_t regs[32], csrs[CSR_COUNT];
    uint8_t *mem;
    uint32_t mem_offset, mem_size;
};

SimpleC 到机器指令几乎可以「原样翻译」:取指令、译码、执行……这正是计算机系统基础课上的 memory + register 模型。早期的编译器也和今天的 transpiler 很像——它把语句原样一条条翻译成指令。但后来大家发现:编译器其实可以改变程序的行为,只要保证结果等价。

什么是编译器?

📷 什么是编译器?
回到原视频 (00:31:50)

编译正确性

于是就有了那个关键问题:什么样的优化是允许的,什么是不允许的?

考虑「死代码消除」(DCE):x = 12 + 3 之后如果 x 再也没被用到,这条语句就可以删掉。

编者拓展|死代码消除(DCE, Dead Code Elimination):编译器的一种优化——如果某段计算结果之后永远不会被用到、也不会影响任何对外行为,就可以把它直接删掉。它是「编译器可以改变程序、只要结果等价」的一个最直观例子。更一般地:假设程序一定会终止,并且它从不调用任何外部 API,那么编译器原则上可以把整个程序优化成一个空程序。因为所有计算都只影响内部状态,而内部状态没人看得见。

反过来说,唯一不能优化的,是「编译器看不到的行为」——向外部世界的调用。如果一段代码调用了之后才链接进来的外部函数(可能是汇编写的),编译器不知道它做什么:它可能读一个输入、写一个输出,甚至「把一个电信号送出计算机、让物理世界发生变化」。因此:

编译正确:对任何输入,编译后程序的输出序列(包括外部 API 调用序列与终止行为)都与按 C 语言形式语义执行的结果完全相同。

可以把它理解成一个状态机的外部可见迹(trace):只要对所有输入产生的 syscall 序列完全相同,这个优化就是对的。这也解释了为什么「编译器不能把冒泡排序优化成快速排序」——两者对外部的调用序列不同(比较次数虽然看不见,但整体外部可见行为必须一致)。

在真实的机器上验证

讲师用一块 arm64 的树莓派演示(和手机、笔记本同样属于 ARM 架构)编译同一段 C 代码:

gcc -O0 为 F1..F4 生成的汇编

📷 gcc -O0 为 F1..F4 生成的汇编
回到原视频 (00:34:45)

gcc -O2 优化后的汇编:8 条指令变成 2 条

📷 gcc -O2 优化后的汇编:8 条指令变成 2 条
回到原视频 (00:35:55)

编者说明:即使你不熟悉 ARM 汇编,也不必担心。今天的 AI 可以把 ldp、load/store 之类的 ARM 指令用 x86 的方式再解释一遍。学过一个体系结构之后,迁移到另一个体系结构只是换一套助记符。

graph LR
    C["C 源程序 (.c)"] --> SimpleC["SimpleC(每行一小步)"]
    SimpleC --> ASM["汇编指令序列"]
    ASM --> ELF["ELF 可执行文件 (a.out)"]
    ELF -->|"进程加载/执行"| State["寄存器 + 内存状态机"]
    State -->|"syscall"| OS["操作系统"]

二、操作系统上的最小程序

(参考时间: 00:41)

2.1 从一开始就取得机器的控制权

我们从 printf 开始学编程。但你若试图调试 printf,会发现在 main 执行之前,机器早已在库函数里执行了成千上万条指令。能不能写一个最小的、能打印 hello world 的程序?

一个普通的 hello.c 编译出来的文件有 70 KB,用 objdump 看里面全是不知道干什么的代码,显然不是最小的。改用 gcc -c 只编译、或者直接 ld 链接:

$ ld hello.o
ld: hello.o: in function `main':
hello.c:(.text+0x...): undefined reference to `puts'

链接器会拒绝它——现代编译器甚至把 printf 优化成了 puts。那要真正写出一个最小程序呢?我们知道约定俗成的入口叫 _start:

.globl _start
_start:
    # ...

用 ld 链接成 a.out 后,用 gdb a.out 配合 si(step instruction)从第一条指令开始执行:第一条指令停在了 _start,随后进入那条跳转指令,然后就死循环了。

那如果让 _start 直接 return 会怎样?它会崩溃。原因用我们的状态机模型一看就懂:C 程序从 main 开始,每次执行 top-most frame 的 PC;如果 main 返回、栈空了,就不存在栈顶 frame,也就取不出 PC——进入了未定义状态。

但真实的硬件世界里并不是这样的。每一个进程在被加载时,都有一个初始状态(register + memory),而且它也是有一个栈的(初始 %rsp)。函数返回,本质上就是执行一条 pop 指令:把栈顶保存的 PC 装回 PC。当我们手动调 _start 并让它返回时,它从栈上 pop 出来的值是 0,于是 PC = 0,而 0 是非法内存,程序随即 segmentation fault。

编者拓展|段错误(segmentation fault, segfault):程序访问了它没有被授权访问的内存地址(比如 0,或越界的指针),CPU/操作系统会立刻终止它并报 Segmentation fault。这是 C 程序最常见的崩溃之一。

从一开始就获得机器的控制权

📷 从一开始就获得机器的控制权
回到原视频 (00:41:50)

这说明:真实的编译器工具链在处理程序时并不是从 main 开始的。System V ABI 规定了整个进程的初始状态;工具链会插入一段初始化代码,它做好一切准备后调用 main,main 返回后它继续执行一段不能返回的代码——它会执行一个系统调用来结束进程。

编者拓展|ABI(Application Binary Interface,应用二进制接口):它规定「二进制程序之间」的约定——参数放在哪些寄存器、栈怎么用、函数返回值怎么给……API 是给源码用的约定,ABI 是给机器码用的约定。System V ABI 是 Linux/x86-64 上的标准约定,它决定了进程加载时寄存器和栈的初始状态。

编者说明:「进程加载的完整二进制初始状态」会在本课程专门的一节课展开,这里只需建立直觉:_start 之前有 ABI 约定的初始化代码,main 之后有负责退出的代码。

2.2 退出程序与系统调用

程序自己是停不下来的

指令集里没有任何一条指令能「关闭计算机」。那么操作系统是怎么在关掉所有软件后切断电源的?答案:程序自己是不能停下来的,只能借助操作系统。用 x86-64 写一个真正的最小程序:

movq $SYS_exit, %rax   # exit(
movq $1,        %rdi   #   status = 1
syscall                # );

把系统调用的编号放进 %rax,参数放进 %rdi、%rsi、%rdx……然后执行 syscall,操作系统接管程序。

编者拓展|寄存器与 x86-64 调用约定:寄存器是 CPU 内部极快的小存储单元。按 System V ABI 的规定:系统调用编号放在 %rax,第一、二、三个参数分别放 %rdi、%rsi、%rdx。你只要照这个约定「填表」,再执行 syscall 即可。操作系统可以任意改变程序状态,甚至终止程序。

退出程序

📷 退出程序
回到原视频 (00:49:10)

最小可执行文件:打印 Hello World 并正常退出

为了构造一个能直接被操作系统加载、并打印 Hello World 的指令序列,需要用写文件 + 退出两个系统调用。x86-64 的 write 参数:fd(写到哪里)、buf(缓冲区地址)、count(写多少字节)。

minimal.S 编译运行,输出 Hello OS World

📷 minimal.S 编译运行,输出 Hello OS World
回到原视频 (00:51:55)

System Call 是区别于所有其它指令的一条最特别的指令。其他指令(数据移动、访存、赋值、分支、循环)都只能改变当前进程的 memory / register——它们只能改变这个抽象 C 状态里的变量值,无法改变另一个进程的状态。只有 syscall 可以。

系统调用指令:请求操作系统服务

📷 系统调用指令:请求操作系统服务
回到原视频 (00:52:35)

用「全身麻醉」理解系统调用

syscall 的执行过程,可以类比成一次全身麻醉:

  1. 你先和手术医生签好「合同」——也就是按约定把参数放进寄存器,告诉操作系统你想干什么;
  2. 躺下、闭眼——执行 syscall,把控制权完全交给操作系统;
  3. 醒来时,可能什么都没发生,可能「你已经变成另一个人」了,也可能再也醒不过来(比如 exit)。

对于计算机而言,这就是一次向操作系统代码的跳转,而且跳转目的地是操作系统「写死」的,程序无权更改。操作系统会检查你的请求是否越权:越权就返回失败;合理就替你完成。做 read 时,操作系统甚至会把数据搬进你的地址空间、填进缓冲区,并把「读到的字节数」作为返回值还给你。

用 GDB 从第一条指令开始单步执行最小程序,会清楚地看到:当单步走过那条 syscall 后,hello world 已经被打印出来——操作系统在背后执行了几万条指令,但你这个进程对此毫无感知。再往前走,最后一条 svc(ARM 上对应 x86 的 syscall)是 exit,程序就此退出,再也不会醒来。

GDB 单步 svc 指令:hello world 立即打印

📷 GDB 单步 svc 指令:hello world 立即打印
回到原视频 (00:58:20)

sequenceDiagram
    participant App as 应用程序 (用户态)
    participant OS as 操作系统 (内核)
    App->>App: 按 ABI 把参数放入寄存器
    App->>OS: 执行 syscall(交出控制权)
    Note over OS: 检查权限、执行操作<br/>(read/write/fork/execve/exit…)
    OS-->>App: 返回结果(可能修改进程地址空间)
    Note over App: exit 时不再返回,进程终止

三、操作系统上的应用世界

3.1 各式各样的应用程序

(参考时间: 00:58)

我们每天说操作系统,但其实感知不到操作系统的存在——我们能感受到的,是运行在操作系统之上的程序(进程)。

操作系统上的应用程序

📷 操作系统上的应用程序
回到原视频 (00:58:55)

能直接看到的程序(Applications):

能直接看到的程序:Applications

📷 能直接看到的程序:Applications
回到原视频 (01:00:05)

能直接看到的(幕后)程序:Utilities:

编者拓展|coreutils、binutils 这些「工具集」是什么:它们是一组组命令行的标准小程序。coreutils 提供最基础的文本 / 文件操作(ls、cat、cp……);binutils 提供处理二进制的工具(汇编器 as、链接器 ld、objdump、readelf……)。UNIX 的哲学就是「每个程序只做好一件事,再用管道把它们串起来」。

能直接看到的(幕后)程序:Utilities

📷 能直接看到的(幕后)程序:Utilities
回到原视频 (01:03:00)

不能直接看到的:各种后台程序(daemon):

编者拓展|守护进程(daemon):在后台长期运行、不与用户直接交互的进程,名字常以 d 结尾(sshd、cron、udiskd)。它们默默提供系统服务——「插上 U 盘桌面就出现图标」这类魔法,背后都是某个 daemon 在干活。 - 图形与媒体:Wayland compositor、pulseaudio、pipewire、video4linux……

不能直接看到的:各种后台程序

📷 不能直接看到的:各种后台程序
回到原视频 (01:06:25)

一个具体的例子:把一个 U 盘插到电脑上,桌面会自动出现图标,双击即可浏览内容。但一个「什么都没有」的操作系统对插 U 盘是毫无反应的。你在操作系统里看到的一切事件,都源于某个程序和操作系统交流之后主动去做的。比如:bluetoothd 管蓝牙,cron 定时任务,udiskd 负责挂载 U 盘(d 就是 daemon 的意思)。这些后台程序大部分时候独立工作,有时也会和工具程序交互。

graph TD
    subgraph OS["操作系统内核"]
        K["syscall 接口"]
    end
    subgraph U["Utilities / 工具程序"]
        SH["Shell / Claude Code / binutils / coreutils"]
    end
    subgraph D["后台守护进程 daemon"]
        SD["systemd / cron / udiskd / dbus / bluetoothd"]
    end
    subgraph A["Applications"]
        APP["VS Code / Firefox / OBS / 游戏 / 浏览器"]
    end
    A -->|"syscall 或与 daemon 通信"| K
    A --> SH
    A --> D
    SH --> K
    D --> K

编者说明:为什么要有一层层抽象?如果每个程序都直接向屏幕输出像素,屏幕就乱套了。因此在操作系统之上又加了一层:图形程序通过 GTK 之类的库,把「我想显示什么」发给 Wayland 合成器,由合成器统一与操作系统通信、把像素显示出来。这种「一层套一层、每层只做一件事」的结构,正是 UNIX Philosophy 的体现,也是后面 strace 分析能奏效的前提。

树莓派桌面:窗口管理器、VS Code、终端

📷 树莓派桌面:窗口管理器、VS Code、终端
回到原视频 (00:59:10)

当前系统里的后台程序(systemd / bluetoothd / cron / udiskd)

📷 当前系统里的后台程序(systemd / bluetoothd / cron / udiskd)
回到原视频 (01:07:10)

3.2 应用程序是什么

(参考时间: 01:10)

一切应用程序:它们都是「一样」的

那么,你看到的一切应用程序,和刚才那个 minimal.S 到底有什么区别?简短的回答:没有区别。任何程序 = minimal.S = 状态机。

编者拓展|ELF(Executable and Linkable Format):Linux 上可执行文件 / 目标文件 / 共享库统一使用的格式。它像一份「说明书」,写明「入口在哪、代码在哪、数据在哪、需要哪些动态库」——操作系统(加载器)照着它,就能把程序变成正在运行的进程。用 readelf、objdump 或 file 可以看到它的内部结构。 - 任何程序:总是从被操作系统加载开始(通过另一个进程执行 execve 设置初始状态);经历状态机执行(计算 + syscalls);最终调用 _exit(exit_group)退出。 - 进程管理:fork、execve、exit……;文件 / 设备管理:open、close、read、write……;存储管理:mmap、brk……

那浏览器、游戏、杀毒软件、病毒呢?它们难道不是调用别的 API 吗?——还是这些 API。

一切应用程序:它们都是「一样」的

📷 一切应用程序:它们都是「一样」的
回到原视频 (01:10:05)

用 file 查看即可验证:minimal 是一个 ELF 64-bit 可执行文件;/usr/bin 下的 as(汇编器)也是 ELF 64-bit 可执行文件,同样架构。整个世界里的所有程序都是同一种东西,只是复杂程度不同。

用 file / objdump 查看 minimal 与 /usr/bin 下的 ELF

📷 用 file / objdump 查看 minimal 与 /usr/bin 下的 ELF
回到原视频 (01:10:10)

我的第一次二进制逆向(约 2000 年)

(参考时间: 01:14)

既然二进制文件只是一个字节序列,序列里每个字节都对应程序里的某一部分、都有合理解释,那你完全可以自己 hack 它。

编者拓展|file / hexdump / 逆向工程:file 命令只看文件开头的几个字节(magic number)就能猜出它是什么类型;hexdump 把文件以十六进制逐字节打印出来。逆向工程(reverse engineering)就是反过来「读懂」一个没有源码的二进制程序——从改一个存档字节,到分析病毒,「打开一个程序看看里面是什么」正是它的起点。

讲师现场让 AI 做一个实验:把 minimal 二进制的返回值从 1 改成 42。AI 先运行程序看到返回值是 1,然后调用 coreutils 里的工具(例如 hexdump 打印实际字节序列)、定位到 hello world 所在位置、找到设置返回值的 move 指令、读懂 ARM 机器码,再把返回值改成 42。第一次改错了,第二次改对了。

用 hexdump 查看并修改 minimal 二进制的返回值

📷 用 hexdump 查看并修改 minimal 二进制的返回值
回到原视频 (01:11:50)

这勾起了讲师的一段童年回忆:在「五块钱一张盗版游戏光盘」的时代,他从人畜无害的《大富翁 4》里发现了一个神秘的文件夹,以及一个 GAME.SAV 存档文件。里面有一串神秘的字节 0x00 0x00 0x01 0x00 0x00,代表着解锁隐藏 CG 的「进度」。他把其中一个 0x00 改成别的数字,程序的行为就变了——这就是他的第一次二进制逆向。

我的第一次二进制逆向(约 2000)

📷 我的第一次二进制逆向(约 2000)
回到原视频 (01:14:20)

3.3 用正确的工具「打开」应用程序

感叹:世界变了

(参考时间: 01:15)

Curiosity is all you need。如果那时有人告诉他:同样的方式也可以去 hack Windows 二进制;binary 太复杂?我们总有办法用工具把「不在意」的部分屏蔽掉——那么很多事都会不一样。

在 AI 的辅助下,你与「顶尖人类」的差距几乎为零:一切代码工具都能自由组合、无限复用,只要你能给出正确的 prompt。让 Claude Code 去 hack 一下 minimal 吧——它做得又快又好。

感叹:世界变了

📷 感叹:世界变了
回到原视频 (01:15:32)

使用正确的工具

(参考时间: 01:18)

操作系统课会帮助大家建立正确的「工具体系」思维。要打开程序的执行,核心工具是 Trace(追踪):

In general, trace refers to the process of following anything from the beginning to the end. For example, the traceroute command follows each of the network hops as your computer connects to another computer.

System call trace(strace) 能打印出一个程序运行过程中的所有系统调用:

编者拓展|strace 与 ptrace:strace 是 Linux 上一件「透视」工具——它借用内核的 ptrace 机制,把目标程序每一次系统调用(调用了哪个、参数是什么、返回什么)都打印出来。想看一个程序「究竟在干什么」,strace 往往是第一步。

$ strace ./a.out
$ strace -f gcc a.c        # -f:连同创建的子进程一起追踪
使用正确的工具

📷 使用正确的工具
回到原视频 (01:17:50)

操作系统中的「任何程序」

回到那条最重要的结论:任何程序 = minimal.S = 状态机。它总是: 1. 被操作系统加载(execve 设置初始状态); 2. 经历状态机执行(计算 + syscalls); 3. 最终 _exit 退出。

而支撑这一切的,是进程管理、文件/设备管理、存储管理这三组系统调用。

操作系统中的「任何程序」

📷 操作系统中的「任何程序」
回到原视频 (01:20:10)

自己动手做实验:观察程序的执行

自己动手做实验:观察程序的执行

📷 自己动手做实验:观察程序的执行
回到原视频 (01:19:00)

工具程序代表:编译器(gcc)

$ strace -f gcc a.c

gcc 本身会启动其他进程(编译器前端、汇编器 as、链接器 ld)。你会看到它创建临时 .o 文件、又用 unlink 把它删掉;你会看到 execve 一个路径一个路径地找汇编器,直到某个路径成功。

这回答了计算机系统基础课上的一个问题:gcc 到底是自己完成了编译链接,还是调用了外部工具?从实现上两者都可以。而 strace 打开了系统调用序列,你就知道它真正是怎么发生的。

图形界面程序代表:编辑器(xedit)

$ strace xedit

xedit 虽然是图形界面程序,却并不会一个像素一个像素地直接往屏幕上画。它通过发消息的方式,告诉那个有权限管理所有窗口的程序(X11 服务器 / 合成器):「在我这个窗口的某个位置画点东西」「我收到了这个窗口某个位置的点击」。因此在它的 strace 输出里,绝大部分系统调用都是 poll / recvmsg 之类的收发消息。虚拟机中的 xedit 还可以通过 ssh(X11 forwarding)把 X11 命令转发到 Host。

编者拓展|X11 / Wayland / 合成器(compositor):Linux 的图形界面并不是「程序直接画屏」,而是一层显示协议:程序把「我想画什么」做成消息,发给一个合成器(compositor),由它统一汇总、真正往屏幕上画。老牌协议是 X11(X Window System),新协议是 Wayland。这正是「一层套一层、每层只做一件事」。

strace -f gcc a.c 输出的系统调用序列

📷 strace -f gcc a.c 输出的系统调用序列
回到原视频 (01:19:30)

图形界面程序 xedit 与 X11 消息机制

📷 图形界面程序 xedit 与 X11 消息机制
回到原视频 (01:25:20)

在 AI 之前(也就是「AI 的史前时代」),面对 strace -f gcc a.c 产生的 2249 行系统调用日志,你只能靠 UNIX Philosophy 一条条用管道过滤:

$ strace -f gcc a.c 2>&1 | grep -v -E 'fd|current directory'
# 2249 行 → 只剩 1700 行

$ ... | grep execve
# execve 只用于启动另一个程序:可以看到 gcc 在找汇编器

而今天,你只需要说一句「分析 gcc 在一段 C 代码上产生的所有系统调用(含子进程)」,AI 就会写脚本、把 strace 输出重定向到文件、逐项分析——它甚至会比讲师在黑板上画的进程树还要好:它会画出进程的父子关系,指出 gcc 先调用编译器前端、再调用汇编器、最后调用链接器,各用了哪些系统调用。

AI 分析 strace 日志并画出进程树

📷 AI 分析 strace 日志并画出进程树
回到原视频 (01:27:15)

想象「一切应用程序」的实现

既然应用程序 = 计算 + 操作系统 API,那么任何应用程序都可以被「想象」出来:

操作系统的职责,就是提供令应用程序舒适的抽象(对象 + API)。这正是我们用一个学期要讲的内容。

想象「一切应用程序」的实现

📷 想象「一切应用程序」的实现
回到原视频 (01:26:55)


总结

程序 = 状态机(变量 + 栈),通过指令 / 语句不断迁移状态:

graph TD
    A["你看到的一切应用程序"] --> B["ELF 可执行文件(状态机初始状态描述)"]
    B --> C["操作系统加载,设置初始栈与 PC"]
    C --> D["状态机执行:计算 + syscalls"]
    D --> E["进程管理 fork/execve/exit"]
    D --> F["文件/设备 open/close/read/write"]
    D --> G["存储 mmap/brk"]
    D --> H["最终 _exit 退出"]
    E --> D
    F --> D
    G --> D

附:官方参考与延伸阅读

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

主题 链接
操作系统定义(OSTEP) Operating Systems: Three Easy Pieces
C → SimpleC(CIL) https://cil-project.github.io/cil/
PicoC(小型 C 解释器) https://github.com/jpoirier/picoc
C 解释执行器 Demo https://jyywiki.cn/OS/demos/intro/cinterp
汉诺塔(非递归)Demo https://jyywiki.cn/OS/demos/intro/hanoi-nr
RISC-V 处理器模拟器(mini-rv32ima) https://jyywiki.cn/OS/demos/intro/mini-rv32ima · https://github.com/cnlohr/mini-rv32ima
什么是编译器 Demo https://jyywiki.cn/OS/demos/intro/ccompile
最小可执行文件 Demo https://jyywiki.cn/OS/demos/intro/minimal
GNU Coreutils https://www.gnu.org/software/coreutils/
busybox https://www.busybox.net/
toybox https://landley.net/toybox/
GNU binutils https://www.gnu.org/software/binutils/
了解 ELF(知乎) https://zhuanlan.zhihu.com/p/21347746

阅读材料:本次课没有书本阅读材料。建议花些时间和 AI 对话,了解 GNU Coreutils、Binutils,建立「手边有哪些可用命令行工具」的印象,并让 AI 带你玩一玩这些应用。你也可以浏览 gdb 文档的目录,找到感兴趣的章节(例如 “Reverse Execution”、“TUI: GDB Text User Interface”);对感兴趣的命令行工具,可参考 busybox 和 toybox 中的简化实现。

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