返回
查看原链接原链接
Bilibili1小时27分56秒 · —

应用视角的操作系统 · 课堂笔记

应用视角的操作系统:程序、状态机与系统调用

核心结论: 操作系统从应用程序视角看就是一组 API(系统调用),而任何应用程序——无论是最简单的 hello world 还是复杂的图形界面编辑器——本质上都是同一个东西:一个通过状态机模型精确定义、通过系统调用与操作系统交互的普通程序。

背景与课程定位

本讲是操作系统课程的第二讲,目标是建立"应用视角的操作系统"这一核心理解框架。课程延续上一讲的操作系统发展史回顾,从应用程序的角度回答"操作系统是什么"以及"程序是什么"这两个根本问题。

为什么在人工智能时代还要学操作系统?核心原因在于:操作系统给应用程序提供了强大的能力(API),如果你了解这些 API 能做什么,就能指挥 AI 去调用这些 API 来真正实现复杂任务——AI 本身很强大,但缺少合适的 prompt 时它不会主动行动。此外,本课程选择 C 语言作为贯穿整个学期的编程语言,因为 C 是"离机器最近但又可以写应用程序的编程语言"——高级语言(如 JavaScript)的多数特性与操作系统无关,而汇编语言虽能直接与操作系统交互但无法高效开发应用程序,C 恰好处于中间位置。

程序与 C 语言的性质

C 是"高级的汇编语言"

任何 C 程序,无论表达式多么复杂,都可以改写为每行只做一小件事的形式。例如 X = Y + Z + W 可以被改写为:

T1 = Y + Z;
T2 = T1 + W;
X = T2;

同理,任何条件判断中的复杂表达式也可以提前提取,使得每个 if 判断中直接是一个布尔变量。经过这样的改写后,C 语言中的所有语法成分都能直接用几条汇编指令对应:

  • 赋值语句 → 数据移动/算术指令
  • 条件分支 → 条件跳转指令
  • 循环 → 带条件的跳转

这使得 C 的编译器非常容易构建,因为 C 代码可以基本原样翻译为汇编代码。相比之下,C++ 中的虚函数调用涉及 vtable 查找等复杂机制,JavaScript 等语言则完全无法直接对应到指令序列。

有一个实际的工具项目叫 CIL(C Intermediate Language),它可以将符合 C99 标准的程序编译成更简单的 C 语言形式,用于程序分析和编译研究。在今天,借助大模型也可以做到类似的事情。

程序的执行模型

计算机硬件是 register 和 memory 在指令驱动下的状态迁移。C 程序同样遵循这个模型:一个 simple C 程序在执行时,也是"从当前状态执行一条语句,迁移到下一状态"的循环。gdb 单步调试 C 程序的过程,本质上就是解释执行程序的过程。

课堂上实际演示了:通过一个小脚本,AI 可以生成工具将任何程序的运行时状态提取出来,生成 trace 文件,展示每条语句执行前后所有变量的值(如 N=42, Q=4 等),证明了"任何程序的运行时状态都能被精确提取"这一事实。

状态机视角与形式语义

"Everything is a state machine"

课程反复强调"everything is a state machine"——这不仅仅是一个直观比喻,而是有严格数学定义的:所有东西都是集合、关系、函数,可以用离散数学的语言精确描述。状态机的初始状态、状态迁移函数都是良好定义的。

但需要注意:C 程序的状态不是简单地有一个全局 PC(program counter)加上当前作用域内的变量。课程明确指出,一个常见的错误理解是认为程序状态 = 全局变量 + 当前局部变量 + 一个独立的 PC 指针。

C 程序状态的精确定义

C 程序的初始状态是:有一个函数调用栈(call stack),栈中有一个 main 的 frame,包含 main 的所有局部变量;同时还有全局变量(如 S.D.D.)及其值。

最关键的区别是:PC 不在全局,而是在每个 stack frame 里。程序执行一条语句时,从 top-most frame 的 PC 处取出语句执行;PC 随后移到下一条语句。这正是"函数调用"的本质:

  • 函数调用 = 创建一个新的 frame 压在当前栈顶,新 frame 的 PC 指向被调函数的入口;旧 frame 和其中的 next PC 全部保留
  • 函数返回 = 从栈顶 pop 一个 frame,恢复调用者的 PC

这使得"递归"不再神秘——看起来复杂的递归程序,只需要机械地遵循"从栈顶 frame 的 PC 取语句执行"这一条规则即可。

非递归汉诺塔作为理解工具

课程以汉诺塔(Tower of Hanoi)为例说明递归程序的本质。与数学中的 F(N) = F(N-1) + F(N-2) 不同(数学函数与求值顺序无关),汉诺塔的两个递归调用的顺序不能交换,否则结果错误。

课程调查发现:即使是很优秀的学生,也普遍没有信心在考场一个小时内写出正确的非递归汉诺塔。更复杂的题目是"F 和 G 互相调用"(F 调 G、G 调 F),要求写出非递归代码并输出函数调用序列——这个问题即使在保研研究生中也很少有人能写对。

将这些递归程序改写为非递归程序,要求理解函数调用的真实机制。一个可行的做法是用 C 语言模拟 C 语言的执行:定义一个 frame 结构体(包含 PC、参数、局部变量),然后用栈数据结构手动模拟函数调用和返回。这样写出的非递归汉诺塔本质上就是"递归版本的模拟器",不需要理解任何魔法。

AI 能力在这个问题上的演进也值得一提:GPT-3.5 可以写出非递归汉诺塔(可能来自训练数据);GPT-4 对互相调用的 F/G 做不对;DeepSeek R1 等推理模型首次做对了;当前模型(可能是 200B 的较小模型)表现出现"降智"现象,变化显著。这说明即使是递归改写这类明确问题,AI 的能力也在快速演进中。

编译器与正确性

编译器能改变程序行为

编译器不一定需要把每条语句原样翻译成指令。早期(60-70年代)编译器确实如此,但后来发现编译器可以改变程序的行为——只要最终效果等价。例如 X = 1 + 2 + 3 如果 X 之后不再使用,这就是死代码,可以被删除。

实际观察 GCC 的编译行为:

  • -O0 版本已经开始做常数折叠(constant folding),但保留了一些冗余操作(如对 X 赋值两次仍生成两条 store 指令)
  • -O2 版本则更激进:如将 23+2 直接编译为常数 25(八条指令变成两条),消除重复赋值等

什么不能优化?

编译器唯一不能优化的是向外界(外部函数)的调用。如果一段代码调用了一个外部函数(如 printf),而这个函数可能在链接时才确定(甚至可能是汇编语言写的),那么编译器无法知道这个函数的行为——它可能有副作用,甚至可能触发物理世界的动作(如发送一个电信号驱动核武器)。

因此,"编译正确"的定义是:在任意输入下,程序的输出序列(包括系统调用序列)完全相同。如果程序不打印任何数据且确定会终止,理论上一段复杂的程序可以被优化成空程序——这是编译器允许做的事情。

这是否会出现在考试中?课程明确说不会考编译正确性。

最小的 hello world 与系统调用

常规程序启动过程

一个普通的 hello.c 程序作为进程加载时,并不是直接从 main 函数开始的。在 main 执行之前,已经执行了大量初始化代码(库函数等)。如果直接从目标文件尝试链接(ld),会报错 undefined reference to _start——因为真正的入口点不是 main 而是 _start

进程初始状态与 _start

每个进程在加载时都有一个初始化状态:一组寄存器和内存的初始值,其中栈上保存着返回地址等信息。_start 是程序的实际入口:

  1. 进程首先进入 _start,初始栈指针(sp)指向栈上某个位置
  2. _start 的代码负责初始化环境,然后调用 main
  3. main 最终返回后,_start 继续执行——但这次它不能返回(栈上已经没有有效返回地址)
  4. 因此 _start 的最后一步是发起一个系统调用(如 exit)来终止进程

如果尝试写一个空的 _start(直接 ret),程序会崩溃——因为初试栈上存储的值是 0,ret 会跳到地址 0,触发段错误。这验证了进程必须有初始化代码而不能直接跳到 main

最小程序的实现

最小 hello world 不是用 C 编写,而是直接写汇编。以 ARM64(课程演示用的树莓派)和 x86-64 为例:

x86-64 系统调用约定:(略,但课程讲到执行系统调用指令 syscall)将系统调用编号放入 rax,参数依次放入 rdirsirdx 等寄存器。例如 write 需要:

  • rax = 系统调用编号(write)
  • rdi = 文件描述符(1 = stdout)
  • rsi = 缓冲区地址
  • rdx = 写入字节数

ARM64 系统调用约定: 参数放入 x0x1x2 等寄存器,系统调用编号放入 x8,然后执行 svc 指令(产生软件中断进入操作系统)。

课程演示了一个最小 hello world 程序,只包含两个系统调用(write + exit),通过 asld 汇编链接后可直接运行。用 gdb 单步调试可以看到:

  • 程序的第一条指令就是用户写的代码
  • 单步执行 svc 指令后,hello world 已经打印出来——操作系统执行了大量指令在背后完成工作
  • 最后执行 exit 系统调用后,程序永远不再返回("麻醉后再也没有醒来")

系统调用的语义

系统调用(如 syscall 指令)是应用程序能执行的最特别的指令:除了它之外,所有其他指令(数据移动、算术、分支)都只能改变当前进程的 memory 和 register——它们无法触及另一个进程的状态。只有系统调用可以。

系统调用的执行方式:

  1. 程序与操作系统"签订 contract"——将参数放到约定好的寄存器中
  2. 执行 syscall/svc 指令,控制权完全交给操作系统
  3. 操作系统检查请求是否合理(是否越权),不合理则返回失败
  4. 合理则执行操作(如写入文件、读取键盘输入),可能改变进程的内存(如 read 系统调用将数据写入缓冲区)
  5. 系统调用返回后,程序继续执行

课程用两个比喻说明系统调用:

  • 麻醉手术比喻:你与外科医生签好合同,然后躺在手术台上失去意识,醒来时可能一切正常、可能发生了变化、也可能再也醒不过来(exit)
  • 祈祷比喻:在科幻世界中向全能的上帝祈祷,你无法感知上帝如何完成这个事,但事情就实现了

最重要的推论:除了系统调用之外,一切程序行为都是可优化的——只要对所有的输入,程序产生的系统调用序列完全相同,这个优化就是正确的。

应用生态与用户态服务

操作系统上的程序分层

虽然所有程序最终都通过系统调用与操作系统交互,但实际的应用生态系统是分层的:

  1. 核心工具程序(Core Utilities)——直接构建在系统调用 API 之上,独立工作。如 GNU Coreutils 中的 cp(拷贝文件)、mv(移动文件)、cat(打印文件内容)等
  2. 后台守护进程(Daemons)——提供各种后台服务。如 bluetoothd(管理蓝牙)、cron(定时任务)、udisksd(U 盘自动挂载)。如果没有这些守护进程,插上 U 盘不会自动出现在桌面上
  3. 窗口系统与图形界面——应用程序不直接画屏幕,而是通过消息传递(如 Wayland 协议)与窗口管理器通信,由窗口管理器统一管理所有窗口的渲染

这个分层结构意味着:应用程序想显示窗口,不会直接向操作系统请求"在坐标 (x,y) 画一个像素"(如果所有程序都直接占用屏幕,系统就会混乱),而是通过图形库(如 GTK)发送消息给窗口管理器。

二进制视角的"众生平等"

file 命令查看任何应用程序(包括 lsgcc、图形界面程序),它们都是 ELF 格式的二进制文件,架构一致(如 ARM64 或 x86-64)。用 objdump 可以查看二进制内容,用 strings 可以提取可打印字符串,用 hexdump 可以查看原始字节序列。

课程还演示了通过 AI 辅助 hack 二进制文件:AI 能读懂 ARM 机器码,找到设置返回值为 1 的指令并改为 42,从而直接修改程序行为(类似于早期在游戏存档文件中把字节从 00 改为 01 来解锁隐藏内容)。这说明计算机世界中没有任何魔法——每一个字节都有合理的解释,只要理解就能修改。

strace 与系统调用追踪

工具与方法

strace 是 Linux 下的系统调用追踪工具,可以打印程序运行期间的所有系统调用。常用选项:

  • -f:同时追踪所有子进程(因为父进程会 fork 子进程,如编译器会调用汇编器、链接器)

例如追踪 gcc 编译一个 C 程序,可以看到:

  • gcc 创建临时 .o 文件
  • 通过 execve 调用汇编器(as)和链接器(ld)
  • 完成后通过 unlink 删除临时文件

这证明了"gcc 是否调用外部工具"这个问题的答案:gcc 确实通过 fork/exec 启动外部汇编器和链接器进程来完成工作(虽然也可以实现为完全内部处理,但实际实现选择了外部进程方式)。

AI 时代之前的分析方法

在 AI 时代之前,分析 strace 日志(如 gcc 编译一个简单程序产生的 2249 个系统调用)需要大量手工操作:

  1. grep 过滤掉不关心的系统调用(如删掉所有涉及文件描述符的)
  2. 2249 行过滤后剩下约 1700 行
  3. grep execve 查看启动了哪些子进程——可以看到 gcc 在不同路径下寻找汇编器并最终执行

AI 时代之后

今天只需给出自然语言指令,AI 工具(如 Claude Code)就能:

  • 自动写脚本运行 strace 并将输出保存到文件
  • 分析输出文件,自动生成进程父子关系图
  • 标注每个进程使用了什么系统调用做什么事情
  • 回答后续追问

例如对图形编辑器(xedit)的追踪,可以看到它大部分系统调用是 sendmsg/recvmsg(通过消息与窗口管理器通信),而不是直接写屏幕——这证明了图形界面程序的工作机制。

总结

本讲的核心内容可归纳为:

  1. 程序 = 状态机:C 程序(以及所有程序)可以精确定义为一个状态迁移系统,初始状态包含 main 的 frame 和全局变量,每个 frame 有自己的 PC,执行过程是机械地"从栈顶 frame 的 PC 取语句执行"
  1. C 是高级汇编语言:C 代码可以改写为每行做一小件事的 simple C 形式,编译器可以高效翻译为汇编代码
  1. 编译器正确性:编译器可以优化掉几乎所有代码,唯一不可优化的是对外部(系统调用)的调用
  1. 系统调用是应用程序与操作系统的唯一边界:除系统调用外,所有指令都只能改变当前进程的内部状态;系统调用是程序接触外界、请求操作系统做事(输出、输入、退出、创建文件等)的通道
  1. 所有程序平等:无论是最小的汇编 hello world,还是复杂的图形界面编辑器,在操作系统面前都是同一个东西——通过系统调用与操作系统交互的普通程序
  1. 工具思维:在 AI 时代,理解"世界上有什么工具、工具能做什么、如何组合工具"比记忆具体概念更重要——strace、objdump、hexdump 等工具加上 AI 的理解能力,让每个人都能深入分析任何程序的行为