打开活动监视器,几百个进程都显示”正在运行”,可你的机器只有十来个核。更奇怪的是,一个写了死循环的程序并不会让整台机器卡死,你按下 Ctrl-C 它就停了。一个只会顺序执行指令的 CPU,是怎么在一堆程序之间来回切换、还能强行把一个不听话的程序叫停的?答案是一条贯穿全篇的线索:控制流必须能被”打断”,而打断它的机制叫异常。

前置:机器级程序

C 例子都在 macOS(Apple Silicon,Apple clang 21)上编译运行;Linux 专属的行为会单独标出。

从”一条指令接一条”说起

CPU 从通电开始只做一件事:取 PC 指向的指令、执行、把 PC 改成下一条的地址。这串地址叫控制流。在 机器级程序 里,跳转、调用、返回都只是在程序自己的意愿下改 PC。

只靠这些,有三件事做不到。

第一,外部世界没法插话。网卡收到一个包、磁盘读完一块数据、定时器到点,CPU 如果一直埋头跑用户代码,就永远不知道。让 CPU 每隔几条指令去”看一眼”每个设备(轮询)太浪费。

第二,程序自己出了错,比如除以零、访问一个不存在的地址,CPU 不能就这样继续往下跑,也不能让程序自己处理一个它根本没预料到的情况。

第三,程序想要它自己没权限做的事:读文件、开网络连接、创建新进程。这些操作要碰硬件和别的进程的数据,不能让任意一段用户代码直接做。

三个问题共享同一个解法:硬件提供一种机制,在某个事件发生时把 PC 强行改到一段预先登记好的内核代码,同时把 CPU 切到特权模式;内核处理完以后,再决定回到哪里。这就是异常(exception)。CPU 启动时内核在一张异常表里登记好每类事件对应的处理程序地址,事件发生时硬件用事件编号查表跳过去。

按”事件从哪来、处理完回哪去”,异常分成四类:

类别原因同步吗处理完返回到例子
中断 interrupt外部设备异步下一条指令定时器、网卡、键盘
陷阱 trap程序故意同步下一条指令系统调用
故障 fault可能可修复的错误同步重新执行当前指令缺页
终止 abort不可恢复的错误同步不返回硬件内存校验错

“同步”指异常是由当前这条指令直接引发的,重跑同样的程序会在同样的地方再次发生;中断则和当前指令无关,什么时候来取决于外部。故障”重新执行当前指令”这一点要记住:缺页故障修好后,那条访存指令会从头再来一次,程序根本察觉不到,虚拟内存 整个就建立在这一点上。

术语在不同书里不统一。xv6 和 RISC-V 文档把这三种进入内核的方式统称为 trap,x86 手册又有自己的叫法。概念上记住”中断来自外部、系统调用是故意的、故障可以修好后重来”就够了。

停一下,检查理解单选

程序访问了一个合法但还没装进物理内存的地址,内核把数据装进来之后,CPU 接下来执行哪条指令?

系统调用:故意掉进内核

用户程序和内核之间隔着一道硬件墙:CPU 有一个模式位,用户模式下执行特权指令(改页表、关中断、直接访问设备)会立刻引发异常。想做这些事,唯一合法的门是一条特殊指令:x86-64 上叫 syscall,ARM64 上叫 svc,RISC-V 上叫 ecall。它就是一个陷阱:把模式切到内核,跳到内核预先设定的入口。

调用约定在 机器级程序 那种函数调用的基础上多了一个系统调用号:把号码放进约定的寄存器,参数放进其它寄存器,执行陷阱指令。内核入口查号码表,调用对应的处理函数,把返回值放回寄存器,再用一条”从异常返回”的指令回到用户模式。C 库的 write()、getpid() 只是包了这几条指令的薄壳。

xv6 把这条路径写得很直白,读一遍就能把整个机制串起来:

  1. 用户态 write(fd, buf, n) 是 usys.S 里的一个小桩:li a7, SYS_write; ecall; ret。
  2. ecall 让硬件把当前 PC 存进 sepc、切到 supervisor 模式、跳到 stvec 寄存器里登记的地址,也就是 trampoline.S 里的 uservec。
  3. uservec 把 32 个用户寄存器存进这个进程的 trapframe,换上内核页表和内核栈,跳到 C 函数 usertrap()。
  4. usertrap() 看 scause 判断原因:是系统调用就调 syscall(),它用 trapframe->a7 查表执行 sys_write,返回值写回 trapframe->a0;是设备中断就交给对应驱动;都不是就杀掉进程。
  5. usertrapret() 和 userret 反向操作:恢复用户寄存器、切回用户页表,sret 回到 sepc + 4,也就是 ecall 后面那条指令。

整条路上要存一整套寄存器、切页表,所以系统调用比普通函数调用贵一个数量级以上。在这台 Mac 上用 Python 粗测:

import os, time
n = 200000
t = time.perf_counter()
for _ in range(n): os.getppid()          # 每次都真的进一次内核
a = (time.perf_counter() - t) / n * 1e9
f = lambda: 0
t = time.perf_counter()
for _ in range(n): f()                   # 对照:纯用户态的 Python 函数调用
b = (time.perf_counter() - t) / n * 1e9
print(f'os.getppid(): {a:.0f} ns/call   python no-op call: {b:.0f} ns/call')
os.getppid(): 1253 ns/call   python no-op call: 220 ns/call

两边都含 Python 解释器的开销,数字只能看量级;C 里一次空函数调用是纳秒级,而一次系统调用通常是几十到上千纳秒,具体看 CPU、内核版本和安全缓解措施。后面 文件系统与 IO 里”每字节一次 write 慢 13 倍”就是这个成本累积出来的。也有例外:Linux 的 vDSO 把 gettimeofday 这类只读调用映射到用户态执行,根本不进内核。

停一下,检查理解多选

关于系统调用,下面哪些说法是对的?

进程:给每个程序一台假机器

有了”随时能把控制权拿回内核”的能力,内核就能编织一个幻觉:每个程序都以为自己独占 CPU 和内存。这个幻觉的载体是进程,一个正在运行的程序实例。它给程序两样东西:

  • 独立的逻辑控制流:程序觉得 PC 一直在自己的代码里走,其实中间被切走过无数次。
  • 私有的地址空间:程序看到的地址 0x1000 和别的进程的 0x1000 是两块不同的物理内存,靠的是 虚拟内存。

内核为每个进程维护一个结构体(xv6 里是 struct proc,Linux 里是 task_struct),里面有:进程号 pid、状态、页表、打开的文件表、保存下来的寄存器(上下文)、父进程指针。所谓”进程”,从内核的视角看,就是这样一个结构体加上它指向的那堆资源。

进程在一生中会在几个状态之间移动。xv6 的 enum procstate 只有六个值:UNUSED(槽位空着)、USED(正在创建)、RUNNABLE(准备好了,等 CPU)、RUNNING(正在某个核上跑)、SLEEPING(在等某件事,比如磁盘或子进程)、ZOMBIE(已退出,等父进程收尸)。Linux 的状态更多,但骨架一样,ps 的 STAT 列里 R、S、Z 对应的就是这些。调度器只在 RUNNABLE 的进程里挑;一台”几百个进程都在运行”的机器,绝大多数进程其实是 SLEEPING。

两个进程的控制流在时间上重叠,就叫并发;真正在同一时刻跑在两个核上叫并行。单核机器上没有并行,但照样有并发:内核在它们之间快速切换。

fork、exec、wait:Unix 造进程的三个动作

Unix 造新进程的方式乍看很怪:先把自己完整复制一份,再让副本变成别的程序。

fork() 创建一个子进程,它是父进程的副本:同样的代码、同样的变量值、同样的打开文件,从 fork 返回处继续跑。区别只在返回值:父进程拿到子进程的 pid,子进程拿到 0。调用一次,返回两次。

int x = 1;
pid_t pid = fork();
if (pid == 0) { x += 10; printf("child : x=%d\n", x); return 7; }
int status; waitpid(pid, &status, 0);       // 等子进程结束,顺便收回它的退出码
printf("parent: x=%d child exit=%d\n", x, WEXITSTATUS(status));
child : x=11
parent: x=1 child exit=7

子进程改 x 不影响父进程,因为它们的地址空间是两份。(这两份内存不是 fork 当场真的复制出来的,而是先共享、谁写谁复制,这个”写时复制”的把戏在 虚拟内存 讲。)

fork 的返回两次很容易数错。连续三次 fork() 后打印一行,会打印几次?每次 fork 让进程数翻倍,所以是 2³ = 8:

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

更隐蔽的坑来自用户态缓冲。printf 并不直接调 write,而是先写进 C 库在进程内存里的缓冲区,遇到换行(终端)或攒满(管道、文件)才真正输出。fork 复制的是整个内存,缓冲区里还没输出的内容也被复制了:

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

"hello " 只调了一次 printf,却出现了两次。fork 之前 fflush(stdout) 就能避免。

execve(path, argv, envp) 做相反的事:不创建进程,而是把当前进程的代码、数据、栈全部替换成新程序,从新程序的 main 开始执行。pid 不变,打开的文件也保留。成功的 exec 不返回,因为原来的代码已经不在了:

pid_t pid = fork();
if (pid == 0) {
    char *argv[] = {"echo", "from exec", NULL};
    execvp("echo", argv);
    perror("execvp");   /* 只有 exec 失败才会走到这里 */
    return 1;
}
waitpid(pid, NULL, 0);
printf("parent done\n");
from exec
parent done

为什么拆成 fork + exec 两步,而不是一个 spawn(program)?因为两步之间有一个窗口:子进程已经存在、但还在跑父进程的代码。shell 就在这个窗口里调整子进程的环境,比如关掉标准输出、换成某个文件,再 exec。新程序完全不知道自己被重定向了,它照常往 1 号文件描述符写。ls > out.txt 和管道都是这么实现的,细节在 文件系统与 IO。代价是 fork 要复制一整个地址空间(哪怕马上被 exec 扔掉),所以现代系统也提供 posix_spawn 和 Linux 的 vfork/clone 这类优化路径。

wait/waitpid 让父进程阻塞到某个子进程结束,并取回它的退出状态。如果父进程一直不 wait 会怎样?子进程退出后,内核已经释放了它的内存,但还得留着那个进程结构体,好让父进程将来能读到退出码。这种”死了但没被收尸”的进程叫僵尸(zombie):

pid_t pid = fork();
if (pid == 0) _exit(0);
sleep(1);                                    // 子进程已经退出,但父进程还没 wait
system("ps -o stat=,comm= -p <pid>");        // 示意:实际代码用 snprintf 拼 pid
waitpid(pid, NULL, 0);
system("ps -o stat=,comm= -p <pid>");
printf("after wait: ps prints nothing\n");
Z    <defunct>
after wait: ps prints nothing

状态 Z 就是僵尸。一个长期运行、不停 fork 却从不 wait 的服务会攒出成千上万个僵尸,最终耗尽 pid。如果父进程先死了,孤儿会被过继给 1 号进程(Linux 上的 init/systemd,macOS 上的 launchd),由它负责 wait。

停一下,检查理解单选

printf("A"); fork(); printf("B\n"); 输出到管道时,最可能看到什么?

上下文切换:在进程之间换手

现在可以回答开头的问题了。每个核上有一个硬件定时器,内核把它设成每隔几毫秒发一次中断。中断一来,不管用户程序在干什么(包括死循环),控制权都回到内核。内核的调度器于是可以决定:要不要换一个进程跑?

换手的动作叫上下文切换:

  1. 把当前进程的寄存器(含 PC、栈指针)保存到它的内核结构里。
  2. 选出下一个要跑的进程。
  3. 恢复那个进程之前保存的寄存器,切到它的页表。
  4. 从异常返回,CPU 继续执行那个进程上次被打断的地方。

xv6 的实现把这件事浓缩到一个 14 行的汇编函数 swtch(old, new):它只保存和恢复 callee-saved 寄存器(ra、sp、s0–s11),因为调用 swtch 本身是一次普通的 C 函数调用,caller-saved 寄存器已经由编译器在调用前处理了。切换的路径是”进程 A 的内核线程 → 调度器线程 → 进程 B 的内核线程”:定时器中断让 A 进入 usertrap,yield() 把 A 标成 RUNNABLE 并 swtch 到每个 CPU 的调度器循环,调度器挑出 B,再 swtch 到 B。B 从它自己当初调用 swtch 的地方返回,一路回到用户态。

除了定时器,进程主动等待也会触发切换:read 一个还没数据的管道、wait 一个还没结束的子进程,内核会把它标成睡眠(xv6 的 sleep/wakeup),转而调度别人。等数据到了,中断处理程序把它唤醒,它重新变成可运行。

这就是”同时跑”的真相:

  • 时间片让 CPU 密集的进程轮流跑,任何进程都没法霸占 CPU,因为定时器中断不归它管。
  • 阻塞时让出让等 IO 的进程不白占 CPU,这也是一个 IO 密集的程序开很多进程/线程能变快的原因。

切换本身有成本:几百纳秒到几微秒的直接开销,再加上换了页表后 TLB 和 cache 里都是别人的数据(内存层次与缓存),新进程刚开始会跑得慢一些。线程切换比进程切换便宜,因为同一进程的线程共享页表,见 并发与同步。

停一下,检查理解单选

一个用户程序写了 while (1) {},既不做系统调用也不出错。操作系统靠什么把 CPU 拿回来,去跑别的进程?

信号:内核通知进程的方式

异常是硬件打断内核或用户程序,信号是内核打断用户进程的软件版本。它是一个很小的通知:只有一个编号,不带数据。常见的几个:

信号编号(macOS/Linux)典型来源默认动作
SIGINT2终端里按 Ctrl-C终止
SIGKILL9kill -9终止,不可捕获
SIGSEGV11访问非法地址(由缺页故障转来)终止并 core dump
SIGCHLDLinux 17 / macOS 20子进程退出或暂停忽略
SIGTERM15kill 默认终止

这里能看到信号和异常的接缝:程序解引用空指针,硬件产生缺页故障,内核的缺页处理程序发现这个地址根本不属于进程,于是不去”修”,而是给进程发一个 SIGSEGV。Ctrl-C 也类似:键盘中断 → 终端驱动 → 内核给前台进程组发 SIGINT。

内核里的机制是两个位向量:每个进程有一个 pending(已送达但未处理)和一个 blocked(暂时屏蔽)。发送信号只是把 pending 里对应的位置 1,所以同类信号来很多次,在处理前只记一次,信号不排队。用它来数”有几个子进程退出了”会漏数,正确做法是在 SIGCHLD 处理程序里循环 waitpid(-1, ..., WNOHANG) 直到收不到为止。

真正”处理”信号发生在进程从内核返回用户态的那一刻:内核检查 pending & ~blocked,有位被置就去处理。如果用户登记了处理函数,内核会伪造一个栈帧,让进程回到用户态时先跳进处理函数,函数返回时再经由一个小的 sigreturn 系统调用回到被打断的地方。所以信号处理函数是在程序的任意两条指令之间插进来执行的。

volatile sig_atomic_t got = 0;
void on_int(int s){ got = s; }
 
signal(SIGINT, on_int);
kill(getpid(), SIGINT);                 // 给自己发信号;kill 返回前处理函数就跑了
printf("handler ran, got=%d\n", got);
 
pid_t pid = fork();
if (pid == 0) { for(;;) pause(); }
kill(pid, SIGKILL);
int st; waitpid(pid, &st, 0);
printf("child killed by signal %d? %d\n", WTERMSIG(st), WIFSIGNALED(st));
 
int *p = NULL;
pid = fork();
if (pid == 0) { *p = 1; return 0; }     // 空指针写:缺页故障 → SIGSEGV
waitpid(pid, &st, 0);
printf("child terminated by signal %d (SIGSEGV=%d)\n", WTERMSIG(st), SIGSEGV);
handler ran, got=2
child killed by signal 9? 1
child terminated by signal 11 (SIGSEGV=11)

“任意两条指令之间插进来”带来了信号编程最大的坑:处理函数和主程序在逻辑上是并发的。如果主程序正在 malloc 里改堆的链表,处理函数又调了 malloc,链表就坏了。所以处理函数里只能调异步信号安全的函数(write、_exit、waitpid 等,POSIX 列了清单),printf 和 malloc 都不在其中;和主程序共享的变量要声明为 volatile sig_atomic_t,让编译器每次都从内存读、并保证读写是一次完成的。最稳妥的写法是处理函数只设一个标志,真正的工作留给主循环。

SIGKILL 和 SIGSTOP 不能被捕获或屏蔽,这是内核留给管理员的最后手段。也因此 kill -9 杀掉的进程没机会清理:临时文件、锁文件、没刷出的缓冲都留在原地。用 kill、ps、top 这些工具操作进程归 Shell 与命令行环境。

停一下,检查理解单选

父进程登记了 SIGCHLD 处理函数,在函数里调用一次 wait(NULL)。三个子进程几乎同时退出,最可能出什么问题?

用这套机制看 Python 和 AI Infra

这些机制在高层代码里随处可见,只是换了名字:

  • Python 的 KeyboardInterrupt 就是 SIGINT。CPython 的 C 级处理函数只设一个标志,解释器在字节码之间检查它再抛异常,正是上一节说的”处理函数只设标志”。所以一个卡在长时间 C 扩展调用里的 Python 程序按 Ctrl-C 没反应,要等那次调用回到解释器。
  • subprocess.run 底层就是 fork/exec(或 posix_spawn)加 wait;multiprocessing 在 Linux 上默认用 fork 启动 worker,macOS 上从 Python 3.8 起默认用 spawn,理由之一是 fork 一个已经起了线程的进程不安全(子进程只复制调用 fork 的那个线程,别的线程持有的锁会永远锁着)。PyTorch DataLoader 的 worker 也是这么来的,见 Python 并发与性能。
  • 训练集群的调度器(Slurm、Kubernetes)停任务时先发 SIGTERM,等一段宽限期再发 SIGKILL。训练脚本捕获 SIGTERM、存一次 checkpoint 再退出,就是利用这个窗口。
  • 段错误(SIGSEGV)是 C++/CUDA 扩展里最常见的崩溃形态,它的源头是一次内核判定为非法的缺页,见 虚拟内存。

记住这三件事

  1. 异常是硬件把控制权强行交给内核的机制:中断来自外部,陷阱(系统调用)是故意的,故障修好后会重跑出错的那条指令。
  2. 进程是内核造的幻觉,靠定时器中断和上下文切换轮流占用 CPU;fork 复制、exec 替换、wait 收尸,三步之间的窗口让 shell 能做重定向。
  3. 信号是内核在进程回到用户态时插入的异步通知,它不排队、可以在任意指令间打断你,所以处理函数只该设标志。

延伸阅读

  • Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd ed., 第 8 章 Exceptional Control Flow
  • Russ Cox, Frans Kaashoek, Robert Morris, xv6: a simple, Unix-like teaching operating system(第 2 章 Operating system organization、第 4 章 Traps and system calls、第 7 章 Scheduling)
  • MIT 6.1810 Operating System Engineering 课程主页与 lab:pdos.csail.mit.edu/6.1810
  • Linux man-pages:fork(2)、execve(2)、signal(7)、signal-safety(7)
  • Remzi & Andrea Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Process API 与 Limited Direct Execution 两章