计算机系统基础-04:异常控制流
异常控制流
OS 抽象
异常控制流(Exceptional Control Flow, ECF)指程序控制流因系统事件而发生的转移。它是操作系统实现进程、系统调用、信号、上下文切换和调度的基础。
计算机硬件系统主要由 Processor,Main memory,I/O devices组成;操作系统是管理硬件的软件层,为应用提供简单、统一的硬件访问机制,并防止应用直接滥用硬件资源。操作系统提供三个基本抽象:
| 抽象 | 作用 |
|---|---|
| Process | 正在运行的程序实例 |
| Virtual memory | 为每个进程提供独立地址空间的抽象 |
| Files | 将普通文件和 I/O 设备统一为字节序列接口 |
内核与用户模式
进程是正在运行的程序实例。每个程序都在某个进程上下文中运行。进程由由两部分组成:
- 用户部分:应用代码、用户栈、堆、数据段等。
- OS 部分:内核维护的进程状态和内核资源。
内核由所有进程共享。进程运行时可能处于用户模式或内核模式:
| 模式 | 权限 |
|---|---|
| Kernel mode | 可执行任意指令,可访问系统任意内存位置 |
| User mode | 不能执行特权指令,不能直接访问内核地址空间 |
处理器通常用控制寄存器中的 mode bit 区分两种模式。例如 bit 置位表示 kernel mode,清零表示 user mode。
应用不能直接访问 I/O 设备。它通过系统调用进入内核,由内核代表它访问硬件。
虚拟内存
虚拟内存让每个进程看起来独占主存。一个典型虚拟地址空间包含:

每个进程都有私有地址空间。硬件和操作系统共同完成虚拟地址到物理地址的转换。
文件抽象
普通文件是字节序列。Unix 将 I/O 设备也抽象为文件,通过少量系统调用读写。系统中的输入输出通常都通过 Unix I/O 完成。
系统调用
普通跳转、函数调用和返回只能在同一权限模式内改变控制流。用户程序需要进入内核时,使用系统调用。

syscall / sysret 是硬件提供的用户模式与内核模式之间控制转移机制。系统调用是用户程序访问内核服务的过程式接口。
示例:
1 | int main() |
对应 x86-64 汇编调用:
1 | movq $1, %rax # write 系统调用号 |
系统调用参数传递:
- 最多 6 个参数通过寄存器传递:
%rdi, %rsi, %rdx, %r10, %r8, %r9。 - 返回值通过
%rax返回。 %rcx和%r11会被 CPU 用于保存%rip和%rflags,因此会被破坏。- 用户模式如需保留 caller-saved register,应自行保存。
常见 Linux x86-64 系统调用:
| Number | Name | Desc. | Number | Name | Desc. |
|---|---|---|---|---|---|
| 0 | read |
Read a file | 33 | pause |
Wait for signal |
| 1 | write |
Write a file | 37 | alarm |
Set an alarm clock |
| 2 | open |
Open a file | 39 | getpid |
Get process ID |
| 3 | close |
Close a file | 57 | fork |
Create a process |
| 4 | stat |
Get file status | 59 | execve |
Execute a program |
| 9 | mmap |
Map a file into memory | 60 | _exit |
Terminate the process |
| 12 | brk |
Set the top of heap | 61 | wait4 |
Wait for process to stop |
| 32 | dup2 |
Duplicate a file descriptor | 62 | kill |
Send signal to a process |
异常
处理器状态由内部位和信号编码,例如 kernel bit、%rflags 等。事件(event)是处理器状态的重要变化。事件来源包括:
- 执行
syscall/sysret。 - 磁盘或网络适配器有数据到达。
- 指令除零。
- 缺页或非法内存访问。
- 定时器到期。
异常是硬件响应事件而把控制转移到内核的一种机制。
异常处理
每类事件有唯一异常号 k。异常表的第 k 项指向对应异常处理程序。异常发生时,处理器根据异常号查表并跳转到内核中的 handler。
异常处理程序运行在 kernel mode。处理器会把恢复被中断程序所需的信息压入内核栈,例如返回地址、RFLAGS、RSP 等。系统调用不一定需要保存同样的信息。
handler 完成后可能:
- 返回当前指令
Icurr,重新执行被中断指令。 - 返回下一条指令
Inext。 - 终止被中断程序。
异常分类:
| 类别 | 同步性 | 原因 | 返回位置 | 示例 |
|---|---|---|---|---|
| Interrupt | 异步 | 处理器外部事件 | 下一条指令 | I/O 完成、定时器 |
| Trap | 同步 | 有意触发 | 下一条指令 | syscall、断点 |
| Fault | 同步 | 非有意但可能恢复 | 当前指令或终止 | 缺页、保护异常 |
| Abort | 同步 | 非有意且不可恢复 | 终止 | parity error、machine check |

同步异常事件一般是由当前程序引起的;异步异常事件则是外部事件触发的。
x86-64 常见异常:
| 异常号 | 描述 | 类别 |
|---|---|---|
| 0 | Divide error | Fault |
| 13 | General protection fault | Fault |
| 14 | Page fault | Fault |
| 18 | Machine check | Abort |
| 32 ~ 255 | OS defined exception | Interrupt 或 Trap |
缺页示例:
若用户写入的地址所在页面尚未准备好(这个涉及后面虚拟内存的 Unallocated 状态),但地址合法:
1
2
3
4
5
6
7 long a[1000];
int main()
{
/* 地址合法但页面可能尚未调入,第一次访问可触发可恢复 page fault。 */
a[500] = 13;
}处理流程:
- 指令访问页时触发 page fault。
- page fault handler 将页载入物理内存。
- handler 返回 faulting instruction。
- 指令第二次执行成功。
若地址非法:
1
2
3
4
5
6
7 int a[1000];
int main()
{
/* 下标越界,访问地址不属于合法对象,通常会触发 SIGSEGV。 */
a[5000] = 13;
}处理流程:
- page fault handler 检测到非法地址。
- 内核向用户进程发送
SIGSEGV。- 进程以 segmentation fault 终止。
异步中断与 I/O
异步异常由处理器外部事件引起,通常通过设置处理器中断引脚触发。handler 返回下一条指令。
例如:
- 键盘输入
Ctrl-C。 - 网络包到达。
- 磁盘扇区读取完成。
- 硬重启中断。
- 软重启中断。
I/O 设备触发中断时,会在系统总线上放置一个编号,用于标识中断来源。CPU 暂停当前工作,跳转到操作系统中的 interrupt handler。
设备可通过 DMA(Direct Memory Access)自行执行读写总线事务,不需要 CPU 逐字节参与。典型磁盘读取流程:
- CPU 向磁盘控制器发起读请求。
- 控制器读取磁盘扇区。
- 控制器通过 DMA 把数据写入主存。
- 控制器发出中断通知 CPU。
异步异常发生时,内核并不一定立刻返回被打断的当前进程。它会先把当前进程的执行现场保存起来,然后根据调度器决定返回哪个进程。当前进程会在之后再次被调度到 CPU 时,从被打断的位置继续执行。
进程与上下文
上下文
从启动到关机,CPU 按物理控制流读取并执行指令。多个进程并发运行时,操作系统通过交错执行不同进程的指令实现多任务。每个进程都有自己的逻辑控制流。两个进程的逻辑控制流在时间上重叠,则称为并发;否则称为顺序执行。
内核为每个进程维护上下文。上下文是内核重启被中断进程所需的状态。上下文包括:
- 程序代码和数据。
- PC、通用寄存器、状态寄存器。
- 用户栈和内核栈。
- 环境变量。
- 内核数据结构,例如 process table、page table、file table。
上下文切换
上下文切换是较高层次的异常控制流,建立在底层异常机制之上。

触发时机:
- 进程执行阻塞型系统调用,例如
read、sleep。 - 系统调用不阻塞,但内核决定调度其他进程。
- 定时器中断到达。
scheduler 执行的基本步骤:
- 判断是否抢占当前进程。
- 选择一个可运行进程。
- 保存当前进程上下文。
- 恢复目标进程上下文。
- 将控制转移给恢复后的进程。
进程状态
| 状态 | 含义 |
|---|---|
| Running | 正在 CPU 上运行,或等待被调度并最终运行 |
| Stopped / blocked | 执行被挂起,不会被调度 |
| Terminated | 永久停止 |
- 进程收到
SIGSTOP可进入 stopped。 - stopped 进程收到
SIGCONT可恢复为 running。 - 进程收到默认动作为终止的信号,或从
main返回 / 调用exit,会进入 terminated。
系统调用错误处理
Unix 系统级函数出错时通常返回 -1,并设置全局变量 errno 表示错误原因。
1 | void unix_error(char *msg) |
这种封装可减少重复错误检查代码。
创建与终止进程
PID
每个进程有唯一正整数 PID。
1 |
|
getpid()返回调用进程 PID。getppid()返回父进程 PID。
exit
1 |
|
exit(status) 以状态码 status 终止当前进程,不返回。
fork
1 |
|
返回值:
- 子进程中返回
0。 - 父进程中返回子进程 PID。
- 出错返回
-1。
fork 调用一次,返回两次:一次在父进程中,一次在子进程中。
子进程特征:
- 与父进程 PID 不同。
- 获得父进程用户级虚拟地址空间的独立副本,包括
text、data、bss、heap、user stack。 - 获得父进程已打开文件描述符的副本。
- 父子进程地址空间内容初始相同,但后续修改互不影响。
- 父子进程共享打开文件,因此可通过同一
stdout输出。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 int main()
{
pid_t pid;
int x = 1;
/* fork 后父子进程各自拥有 x 的独立副本。 */
pid = Fork();
if (pid == 0) {
/* 子进程中 pid 为 0,修改的是子进程自己的 x。 */
printf("child : x=%d\n", ++x);
exit(0);
}
/* 父进程中 pid 为子进程 PID,修改的是父进程自己的 x。 */
printf("parent: x=%d\n", --x);
exit(0);
}父进程和子进程并发执行,输出顺序不可假设。
多次
fork会产生多个进程:
1
2
3 Fork(); // 1 次无条件 fork 后共有 2 个进程
Fork(); Fork(); // 2 次无条件 fork 后共有 4 个进程
Fork(); Fork(); Fork(); // 3 次无条件 fork 后共有 8 个进程一般地,连续执行 次无条件
fork会产生 个进程。
回收子进程
终止的子进程不会立即从系统中删除。内核保留其终止状态,直到父进程回收它。
- zombie:已终止但尚未被父进程回收的进程。
- reaping:父进程获取子进程退出状态后,内核删除该终止进程。
如果父进程终止时仍有 zombie 子进程,内核会安排 init 进程回收它们。init 的 PID 为 1,由内核在系统初始化时创建。
长时间运行的程序,例如 shell 和 server,应主动回收 zombie 子进程。zombie 不运行,但仍占用系统资源。
waitpid
1 |
|
返回值:
- 成功返回导致返回的子进程 PID。
- 若设置
WNOHANG且没有子进程状态变化,返回0。 - 出错返回
-1。
pid 含义:
pid |
wait set |
|---|---|
pid > 0 |
PID 等于 pid 的单个子进程 |
pid = -1 |
所有子进程 |
错误情况:
- 调用进程没有子进程:返回
-1,errno=ECHILD。 - 被信号中断:返回
-1,errno=EINTR。
options:
| 选项 | 含义 |
|---|---|
0 |
阻塞等待 wait set 中某个子进程终止 |
WNOHANG |
若没有子进程终止,立即返回 0 |
WUNTRACED |
等待子进程终止或停止 |
WUNTRACED/WNOHANG |
若没有子进程终止或停止,立即返回 0 |
子进程状态宏
若 status 非空,waitpid 会写入子进程状态。常用宏:
| 宏 | 含义 |
|---|---|
WIFEXITED(status) |
子进程是否正常终止 |
WEXITSTATUS(status) |
正常终止时的退出状态 |
WIFSIGNALED(status) |
子进程是否因未捕获信号终止 |
WTERMSIG(status) |
导致终止的信号编号 |
WIFSTOPPED(status) |
子进程是否停止 |
WSTOPSIG(status) |
导致停止的信号编号 |
回收顺序可能是不确定的:
1 | while ((pid = waitpid(-1, &status, 0)) > 0) { |
若需要固定顺序,可保存子进程 PID,并按 PID 调用 waitpid(pid[i], &status, 0)。
休眠与程序加载
sleep 与 pause
1 |
|
sleep(secs)挂起进程指定秒数;若提前被信号中断,返回剩余秒数。pause()挂起进程直到收到信号,总是返回-1。
execve
1 |
|
execve 加载并运行可执行目标文件 filename,参数为 argv,环境变量为 envp。若成功,不返回;只有出错时返回 -1。

新程序启动时,用户栈包含:

环境变量接口:
1 |
|
setenv 和 unsetenv 只修改当前进程及其之后创建的子进程的环境变量,不会修改父进程,也不会永久修改 shell 环境。
Shell
shell 是交互式应用程序,代表用户运行其他程序。基本循环是 read / evaluate:
1 | int main() |
parseline 解析空格分隔的命令行参数,构造 argv,并判断是否后台执行。
- 若最后一个参数是
&,返回1,表示后台作业。 - 否则返回
0,表示前台作业,shell 需要等待它结束。
执行非内置命令:
1 | if (!builtin_command(argv)) { |
内置命令示例:
1
2
3
4
5
6
7
8
9
10
11 int builtin_command(char **argv)
{
/* quit 是 shell 自身处理的内置命令。 */
if (!strcmp(argv[0], "quit"))
exit(0);
/* 单独的 & 不启动外部程序。 */
if (!strcmp(argv[0], "&"))
return 1;
/* 返回 0 表示不是内置命令,需要 fork/exec。 */
return 0;
}
信号
Unix signal 是较高层的软件异常形式,它允许进程和内核中断其他进程。信号是通知进程某类事件发生的消息。事件来源包括:
- 低层硬件异常经内核转换后暴露给用户进程。
- 内核中的软件事件。
- 其他用户进程显式发送的信号。
| 常见信号 | 编号 | 触发场景 |
|---|---|---|
SIGINT |
2 | 前台进程运行时输入 Ctrl-C |
SIGILL |
4 | 执行非法指令 |
SIGFPE |
8 | 除零等算术错误 |
SIGKILL |
9 | 强制终止进程 |
SIGSEGV |
11 | 非法内存引用 |
SIGCHLD |
17 | 子进程终止或停止 |
信号发送与接收
信号传递分为两步:
- Sending:内核通过更新目标进程上下文中的状态来递送信号。
- Receiving:目标进程被内核强制对信号作出反应。
信号递送原因:
- 内核检测到系统事件,例如除零、子进程终止。
- 进程调用
kill请求内核向目标进程发送信号。
接收信号后的动作:
- 忽略信号。
- 终止进程。
- 捕获信号,执行用户级 signal handler。
Pending 与 Blocking
pending signal 是已发送但尚未接收的信号:
- 同一类型最多只有一个 pending signal。
- pending signal 不排队;若类型
k已 pending,再次发送类型k会被丢弃。 - pending signal 最多被接收一次。
进程可阻塞某些信号。被阻塞信号可以被递送并置为 pending,但不会被接收(也就是不会调用信号处理函数),直到进程解除阻塞。
内核为每个进程维护:
- pending bit vector:记录 pending signal 集合。
- blocked bit vector:记录 blocked signal 集合。
递送信号 k 时,内核置位 pending[k] 为 1。接收并处理信号 k 时,内核清除 pending[k]。
信号发送与接收
进程组
每个进程属于唯一进程组。进程组 ID 是正整数。默认情况下,子进程与父进程属于同一进程组。
1 |
|
setpgid(0, 0) 将当前进程放入一个新进程组,进程组 ID 等于当前进程 PID。
kill 程序与 kill 函数
/bin/kill 可向进程发送任意信号:
1 | kill -9 15213 # 向 PID 15213 发送 SIGKILL |
对应函数:
1 |
|
pid > 0:向进程pid发送信号sig。pid < 0:向进程组abs(pid)中所有进程发送信号sig。
键盘发送信号
shell 使用 job 表示一次命令行求值产生的进程集合。任意时刻最多有一个前台 job,后台 job 可以有多个。

shell 通常为每个 job 创建单独进程组。
- 输入
Ctrl-C:shell 捕获SIGINT,再向前台进程组发送SIGINT,默认结果是终止前台 job。 - 输入
Ctrl-Z:shell 捕获SIGTSTP,再向前台进程组发送SIGTSTP,默认结果是停止前台 job。
alarm
1 |
|
alarm(secs) 安排内核在 secs 秒后向调用进程发送 SIGALRM。
secs=0表示不设置新 alarm。- 新调用会取消之前未到期的 alarm。
- 返回之前 alarm 剩余秒数;若无未到期 alarm,返回
0。
信号接收
内核从异常处理程序返回并准备将控制交给进程 p 时,会检查:
1 | pending & ~blocked |
若集合为空,内核将控制交给下一条指令。若集合非空,内核选择某个未阻塞 pending signal(通常是编号最小者),强制进程接收它。
每类信号都有默认动作:
- 终止进程。
- 终止进程并 core dump。
- 停止进程,直到收到
SIGCONT。 - 忽略信号。
SIGKILL 默认终止进程;SIGCHLD 默认忽略。
进程可用 signal 修改默认动作,SIGSTOP 和 SIGKILL 例外,不能修改。
1 |
|
handler |
行为 |
|---|---|
SIG_IGN |
忽略该信号 |
SIG_DFL |
恢复默认动作 |
| handler 函数地址 | 捕获信号并调用 handler |
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 void handler(int sig)
{
/* sig 可用于区分触发 handler 的具体信号编号。 */
printf("Caught SIGINT\n");
exit(0);
}
int main()
{
/* 将 Ctrl-C 对应的 SIGINT 动作改为调用 handler。 */
if (signal(SIGINT, handler) == SIG_ERR)
unix_error("signal error");
/* 等待信号到达。 */
pause();
exit(0);
}同一个 handler 可处理多类信号,handler 参数
sig用于区分信号类型。
显式阻塞信号
signal handler 可被其他类型信号打断。内核会隐式阻塞当前 handler 正在处理的同类型信号,但不同类型信号仍可打断。

显式阻塞通过 sigprocmask 和信号集合函数完成,其改变当前阻塞的信号集合并在离开时恢复:
1 |
|
how 参数:
how |
效果 |
|---|---|
SIG_BLOCK |
blocked = blocked | set,添加set信号至阻塞集合 |
SIG_UNBLOCK |
blocked = blocked & ~set,从阻塞集合删除set信号 |
SIG_SETMASK |
blocked = set,直接将set设置为阻塞集合 |
若 oldset 非空,旧 blocked set 会写入 oldset。sigprocmask 可以通过选择阻塞信号来避免并发导致的 race 问题。
安全信号处理
规则
主程序和 signal handler 并发执行,因此 signal handler 中容易出现并发 bug。
| 规则 | 内容 |
|---|---|
| G0 | handler 尽量简单,例如只设置全局 flag 后返回 |
| G1 | handler 中只调用 async-signal-safe 函数 |
| G2 | 保存并恢复 errno |
| G3 | 访问共享全局数据结构前阻塞所有相关信号 |
| G4 | 全局变量用 volatile 声明 |
| G5 | flag 用 sig_atomic_t 声明 |
printf、sprintf、malloc、exit 等不是 async-signal-safe。_exit 是 async-signal-safe 的退出函数。
在 handler 函数中使用 printf 这样的函数可能引发死锁等并发错误,应当使用安全 I/O:
1 | ssize_t sio_puts(char s[]) |
回收所有子进程
错误写法只回收一个子进程:
1 | void handler1(int sig) |
由于同类 pending signal 不排队,多个子进程几乎同时终止时,SIGCHLD 可能只触发一次。正确写法应循环回收:
1 | void handler2(int sig) |
可移植信号处理
慢系统调用如 read、write、wait、accept 可能阻塞较长时间。不同 Unix 系统对 signal handler 的语义可能不同,例如:
- 捕获信号后是否重置 handler 为默认动作。
- 信号中断慢系统调用后是否自动重启。
可手动处理 EINTR:
1 | while ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) |
更推荐使用 sigaction 封装稳定语义:
1 |
|
CSAPP Signal 封装:
1 | handler_t *Signal(int signum, handler_t *handler) |
、
- handler 执行时只阻塞当前处理的同类信号。
- 信号不排队。
- 被中断的系统调用尽可能自动重启。
- handler 安装后保持有效,直到再次调用
Signal修改。
fork 与 SIGCHLD 的竞态
典型竞态:
- 父进程
fork。 - 内核调度子进程。
- 子进程终止,内核向父进程发送
SIGCHLD。 - 父进程 handler 执行
deletejob(pid)。 - 父进程从
fork返回后执行addjob(pid)。
结果是已终止子进程可能被加入 job list。
修正方法:在 fork 前阻塞 SIGCHLD,父进程完成 addjob 后恢复信号掩码;子进程在 execve 前恢复信号掩码。
1 | sigset_t mask_all, mask_one, prev_one; |
显式等待信号
等待 SIGCHLD 时,不应使用空转:
1 | while (!pid) |
该空转循环浪费 CPU。
也不能简单改为:
1 | while (!pid) |
若 SIGCHLD 在 while 判断之后、pause 之前到达,那么 pause() 实际开始运行的时候已经没有需要处理的信号了。进程可能永久睡眠。
sleep(1) 或 nanosleep 也不合适:间隔过小浪费 CPU,间隔过大响应慢,不容易调整。
正确方法是 sigsuspend:
1 |
|
sigsuspend(mask) 临时把当前 blocked set 替换为 mask(含有 SIGCHLD),并挂起进程直到收到一个信号。若 handler 返回,sigsuspend 返回并恢复原 blocked set。
它等价于以下三步的原子版本:
1 | /* 非原子版本:在解除屏蔽和 pause 之间存在竞态窗口。 */ |
这样改写避免了进程在某个危险时刻结束,导致 SIGCHLD 被提前处理而使 pause 永久睡眠。
使用示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 volatile sig_atomic_t pid;
void sigchld_handler(int s)
{
/* handler 中保存 errno,避免影响主控制流。 */
int olderrno = errno;
/* 回收一个已终止子进程,并用 pid 通知主循环。 */
pid = waitpid(-1, NULL, 0);
errno = olderrno;
}
while (1) {
/* 阻塞 SIGCHLD,避免子进程在 pid 清零前结束。 */
Sigprocmask(SIG_BLOCK, &mask, &prev);
if (Fork() == 0)
exit(0);
pid = 0;
while (!pid)
/* 原子恢复 prev 并等待信号,避免 pause 竞态。 */
sigsuspend(&prev);
/* 恢复进入循环前的 signal mask。 */
Sigprocmask(SIG_SETMASK, &prev, NULL);
}在这个版本中,进入循环先屏蔽
SIGCHLD并将原来的 blocking 数组存在prev中,并在执行到pause()时恢复接收SIGCHLD信号。由于sigsuspend(&prev)是一个原子操作,SIGCHLD不会在修改 blocking 位之后和执行pause()之前被处理掉,因此避免了永久睡眠的问题。
非本地跳转
非本地跳转(nonlocal jump)允许控制流从一个函数直接跳转到另一个仍在执行的函数,不经过正常调用返回序列。C 通过 setjmp / longjmp 提供用户级异常控制流。
1 |
|
语义:
setjmp(env)保存当前调用环境,包括 PC、stack pointer 和通用寄存器,首次返回0。longjmp(env, retval)恢复env中保存的环境。- 恢复后,最近一次初始化
env的setjmp会再次返回,返回值为非零的retval。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 jmp_buf buf;
int main()
{
/* setjmp 首次返回 0;之后 longjmp 会使其返回非零错误码。 */
switch (setjmp(buf)) {
case 0:
/* 正常路径:调用可能出错的函数。 */
foo();
break;
case 1:
printf("Detected error1 in foo\n");
break;
case 2:
printf("Detected error2 in bar\n");
break;
default:
printf("Unknown error\n");
}
exit(0);
}
void foo(void)
{
if (error1)
/* 直接跳回 main 中 setjmp 的位置,并让 setjmp 返回 1。 */
longjmp(buf, 1);
bar();
}
void bar(void)
{
if (error2)
/* 直接跳回 main 中 setjmp 的位置,并让 setjmp 返回 2。 */
longjmp(buf, 2);
}大致流程是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 main
↓
setjmp(buf) 第一次返回 0
↓
foo() → longjmp(buf, 1) → 回到 setjmp(buf)
↓ ↓
bar() setjmp(buf) 第二次返回 1
↓ ↓
longjmp(buf, 2) 执行 case 1
↓
回到 main 中 setjmp(buf) 的位置
↓
setjmp(buf) 第二次返回 2
↓
执行 case 2
信号场景可使用 sigsetjmp / siglongjmp:
1 | /* 信号场景下的非本地跳转版本,可选择保存和恢复 signal mask。 */ |
若 savemask 非零,当前 signal mask 会保存到 env,后续 siglongjmp 会恢复该 mask。
进程调度
调度讨论从机制与策略开始:
- 机制 Mechanisms:实现功能的低层方法,例如 context switch、paging。
- 策略 Policies:在机制之上作决策的算法,例如选择运行哪个进程、替换哪个页面。
调度策略决定在可运行进程中选择哪个进程使用 CPU。
Workload 假设
为分析简单算法,先假设:
- 每个 job 运行时间相同。
- 所有 job 同时到达。
- job 一旦开始就运行到完成。
- job 只使用 CPU,不做 I/O。
- job 运行时间已知。
这些假设后续逐步松弛。
周转时间
周转时间是 job 完成时间减去到达时间:
该指标关注 job 从进入系统到完成的总时间。
FIFO / FCFS
FIFO(First In, First Out)也称 FCFS(First Come, First Served),按到达顺序运行 job。

若 A、B、C 同时到达,各运行 10s:
若 A 运行 100s,B、C 各运行 10s:
短 job 被长 job 阻塞的现象称为 convoy effect。
SJF
SJF(Shortest Job First)先运行最短 job。在所有 job 同时到达且不可抢占的条件下,SJF 对平均周转时间最优。

若 A=100s,B=10s,C=10s 且同时到达:
若 A 在 到达并运行 100s,B、C 在 到达并各运行 10s,非抢占 SJF 仍需等 A 完成:
STCF
STCF(Shortest Time-to-Completion First)是抢占式 SJF。每当新 job 到达,调度器选择剩余时间最短的 job。

在上述例子中,B、C 在 到达后可抢占 A:
STCF 改善周转时间,但需要知道剩余运行时间。
响应时间与 Round Robin
交互式系统还关注响应时间:
响应时间衡量 job 到达后第一次被调度的等待时间。
Round Robin(RR)机制:
- 运行一个 job 一个时间片。
- 切换到运行队列中的下一个 job。
- 重复直到所有 job 完成。
时间片也称 scheduling quantum,通常是定时器中断周期的倍数,例如 10 ms 的整数倍。

上下文切换有成本:
- 权限切换。
- 保存和恢复上下文。
- 可能刷新 TLB 和流水线 / 乱序执行状态。
时间片过短会增加切换成本;时间片过长会增加响应时间,需要权衡。
| 策略 | 周转时间 | 响应时间 |
|---|---|---|
| SJF | 通常较好 | 可能较差 |
| RR | 通常较差 | 通常较好 |
纳入 I/O
当 job 发起 I/O 请求:
- job 调用系统调用。
- 内核阻塞该 job。
- 调度另一个 ready job 使用 CPU。
当 I/O 完成:
- 设备向 CPU 发送中断。
- job 变为 ready。
- 调度器决定之后运行哪个 job。

调度应尽量重叠 CPU 与 I/O,提高系统利用率。

多级反馈队列
MLFQ(Multi-Level Feedback Queue)目标:
- 在不知道 job 长度的情况下优化周转时间。
- 降低交互式任务响应时间。
核心思想:调度器根据 job 运行历史推测其行为。短作业和频繁让出 CPU 的交互式作业保持高优先级;长时间占用 CPU 的作业逐步降级。
基本规则
MLFQ 有多个队列,每个队列有不同优先级。一个 ready job 位于某一个队列中。
| 规则 | 内容 |
|---|---|
| Rule 1 | 若 Priority(A) > Priority(B),运行 A |
| Rule 2 | 若 Priority(A) = Priority(B),A 和 B 按 RR 运行 |
| Rule 3 | 新 job 进入系统时放入最高优先级队列 |
| Rule 4a | job 用完整个时间片后,优先级降低 |
| Rule 4b | job 在时间片用完前让出 CPU,则保持当前优先级 |
该规则使短 job 先以高优先级运行。若 job 实际较长,会逐渐下降到低优先级。频繁 I/O 的交互式 job 往往不会用完整时间片,因此可保持较高优先级。
| 问题 | 表现 |
|---|---|
| Starvation | 若交互式 job 太多,长时间 CPU-bound job 可能长期得不到 CPU |
| Gaming scheduler | 进程在时间片用完前主动发起 I/O,避免降级,从而获得更多 CPU |
| 行为变化 | 进程可能从 CPU-bound 阶段转为交互式阶段,旧优先级不再合适 |
Rule 5(Priority Boost):每隔时间 ,将系统中所有 job 移到最高优先级队列。该改进防止长期饥饿,并让已经改变行为的进程重新获得较高优先级。

为防止 gaming,细化 Rule 4:只要 job 在某一优先级累计用完其时间配额,不论中间主动让出 CPU 多少次,都降低优先级。

调度器记录进程在当前优先级已经使用的 CPU 时间。一旦达到该层配额,将其降到下一层队列。
MLFQ 需要调节的参数:
- 队列数量。
- 每个队列的时间片长度。
- priority boost 的周期 。
常见设置:
- 高优先级队列使用较短时间片,适合交互式 job。
- 低优先级队列使用较长时间片,适合 CPU-bound job。
这些参数通常依赖经验和典型工作负载。
MLFQ 最终规则:
- 若
Priority(A) > Priority(B),运行 A。 - 若
Priority(A) = Priority(B),A 和 B 按 RR 运行。 - 新 job 进入系统时放入最高优先级队列。
- job 在某层累计用完时间配额后,优先级降低。
- 每隔时间 ,将所有 job 移到最高优先级队列。
MLFQ 不要求预先知道 job 长度,而是观察运行行为并调整优先级。许多系统使用 MLFQ 或其变体作为基础调度器
