打开活动监视器,几百个进程都显示”正在运行”,可你的机器只有十来个核。更奇怪的是,一个写了死循环的程序并不会让整台机器卡死,你按下 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 接下来执行哪条指令?
缺页属于故障(fault):处理程序修复了原因,返回时重新执行出错的那条指令,这次访存就成功了。
返回"下一条"是陷阱和中断的行为,用在缺页上会让那次读写凭空消失。"不返回"是终止(abort)的行为,只在不可修复时发生。调度确实可能先让别的进程跑一会儿,但轮回到这个进程时,起点仍然是那条出错的指令。
系统调用:故意掉进内核
用户程序和内核之间隔着一道硬件墙:CPU 有一个模式位,用户模式下执行特权指令(改页表、关中断、直接访问设备)会立刻引发异常。想做这些事,唯一合法的门是一条特殊指令:x86-64 上叫 syscall,ARM64 上叫 svc,RISC-V 上叫 ecall。它就是一个陷阱:把模式切到内核,跳到内核预先设定的入口。
调用约定在 机器级程序 那种函数调用的基础上多了一个系统调用号:把号码放进约定的寄存器,参数放进其它寄存器,执行陷阱指令。内核入口查号码表,调用对应的处理函数,把返回值放回寄存器,再用一条”从异常返回”的指令回到用户模式。C 库的 write()、getpid() 只是包了这几条指令的薄壳。
xv6 把这条路径写得很直白,读一遍就能把整个机制串起来:
- 用户态
write(fd, buf, n)是usys.S里的一个小桩:li a7, SYS_write; ecall; ret。 ecall让硬件把当前 PC 存进sepc、切到 supervisor 模式、跳到stvec寄存器里登记的地址,也就是trampoline.S里的uservec。uservec把 32 个用户寄存器存进这个进程的trapframe,换上内核页表和内核栈,跳到 C 函数usertrap()。usertrap()看scause判断原因:是系统调用就调syscall(),它用trapframe->a7查表执行sys_write,返回值写回trapframe->a0;是设备中断就交给对应驱动;都不是就杀掉进程。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 这类只读调用映射到用户态执行,根本不进内核。
关于系统调用,下面哪些说法是对的?
陷阱指令是用户代码进入内核的唯一合法入口,模式切换由硬件和入口指令一起完成;内核用调用号查表分发。
系统调用要保存整套用户寄存器、切换栈(xv6 还要切页表),比函数调用贵得多。直接 call 内核地址会在用户模式下执行内核代码,而那段内存的页表项标了"仅内核可访问",硬件会触发故障,进程被杀掉。
进程:给每个程序一台假机器
有了”随时能把控制权拿回内核”的能力,内核就能编织一个幻觉:每个程序都以为自己独占 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"); 输出到管道时,最可能看到什么?
fork 时 "A" 还躺在 stdio 的用户态缓冲区里,缓冲区作为进程内存的一部分被完整复制,父子各自在后面追加 B\n 再刷出,所以是两行 AB。
第一个选项假设 "A" 在 fork 前已经写进内核,那要 fork 前 fflush。子进程确实不重新执行 fork 前的代码,但它继承了那段代码留下的内存状态。两个进程的输出各自整块刷出,不会把一行拆开交错成 ABB。
上下文切换:在进程之间换手
现在可以回答开头的问题了。每个核上有一个硬件定时器,内核把它设成每隔几毫秒发一次中断。中断一来,不管用户程序在干什么(包括死循环),控制权都回到内核。内核的调度器于是可以决定:要不要换一个进程跑?
换手的动作叫上下文切换:
- 把当前进程的寄存器(含 PC、栈指针)保存到它的内核结构里。
- 选出下一个要跑的进程。
- 恢复那个进程之前保存的寄存器,切到它的页表。
- 从异常返回,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 拿回来,去跑别的进程?
定时器中断是异步异常,不依赖用户代码配合:时间一到,硬件就把控制权交给内核的中断处理程序,调度器可以在返回前换一个进程。
编译器不会插让出调用,内核也不"识别"死循环,它只是公平地分时间片;死循环进程会照样拿到它那份时间片,但抢不走别人的。早期没有抢占的协作式系统(如经典 Mac OS)才会被这种程序卡死。
信号:内核通知进程的方式
异常是硬件打断内核或用户程序,信号是内核打断用户进程的软件版本。它是一个很小的通知:只有一个编号,不带数据。常见的几个:
| 信号 | 编号(macOS/Linux) | 典型来源 | 默认动作 |
|---|---|---|---|
| SIGINT | 2 | 终端里按 Ctrl-C | 终止 |
| SIGKILL | 9 | kill -9 | 终止,不可捕获 |
| SIGSEGV | 11 | 访问非法地址(由缺页故障转来) | 终止并 core dump |
| SIGCHLD | Linux 17 / macOS 20 | 子进程退出或暂停 | 忽略 |
| SIGTERM | 15 | kill 默认 | 终止 |
这里能看到信号和异常的接缝:程序解引用空指针,硬件产生缺页故障,内核的缺页处理程序发现这个地址根本不属于进程,于是不去”修”,而是给进程发一个 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)。三个子进程几乎同时退出,最可能出什么问题?
pending 是位向量,三个 SIGCHLD 在处理前可能合并成一个,处理函数只跑一两次、每次只回收一个,剩下的成了僵尸。修法是循环 while (waitpid(-1, NULL, WNOHANG) > 0);。
信号不排队,所以"三次"不可靠。处理某信号期间,同类信号默认会被暂时屏蔽,不会递归打断。wait/waitpid 属于异步信号安全函数,可以在处理函数里调用。
用这套机制看 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 扩展里最常见的崩溃形态,它的源头是一次内核判定为非法的缺页,见 虚拟内存。
记住这三件事
- 异常是硬件把控制权强行交给内核的机制:中断来自外部,陷阱(系统调用)是故意的,故障修好后会重跑出错的那条指令。
- 进程是内核造的幻觉,靠定时器中断和上下文切换轮流占用 CPU;fork 复制、exec 替换、wait 收尸,三步之间的窗口让 shell 能做重定向。
- 信号是内核在进程回到用户态时插入的异步通知,它不排队、可以在任意指令间打断你,所以处理函数只该设标志。
延伸阅读
- 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 两章