第 8 章:异常控制流
主题:异常控制流 (Exceptional Control Flow, ECF)
目标:理解硬件异常、进程、信号和非本地跳转如何改变普通的顺序执行;能正确使用fork、execve、waitpid、信号掩码与sigsuspend,并避开竞态条件。
1. 本章核心视角
普通控制流由 call、ret、分支和循环在同一程序内部推进。异常控制流则在事件发生时,把 CPU 控制权转移到操作系统、另一个进程或某段恢复代码。
flowchart TB
P["当前指令流"] --> E{"事件"}
E -->|"同步:除零、缺页、系统调用"| X["异常处理程序"]
E -->|"异步:时钟、设备 I/O"| I["中断处理程序"]
E -->|"进程级:SIGINT、SIGCHLD"| S["信号处理函数"]
E -->|"程序级:longjmp"| J["恢复点"]
X --> R["返回、重试或终止"]
I --> R
S --> R
J --> R
ECF 是并发、Shell、虚拟内存、文件系统和网络服务器的共同基础。它难在控制转移可能发生在任意指令边界,不能只按源码的线性顺序推理。
2. 异常:从处理器到内核
异常 (Exception) 是处理器检测到事件后,跳转到内核异常处理程序的控制转移。CPU 用异常表(向量表)将异常号映射到处理程序地址;处理完成后可能回到下一条指令、重新执行当前指令,或终止进程。
| 类型 | 触发方式 | 典型例子 | 返回行为 |
|---|---|---|---|
| 中断 (Interrupt) | 异步,外部设备 | 时钟、磁盘、网卡完成 I/O | 下一条指令 |
| 陷阱 (Trap) | 同步,主动执行指令 | syscall、断点 |
下一条指令 |
| 故障 (Fault) | 同步,可恢复错误 | 缺页、保护性异常 | 重试当前指令或终止 |
| 终止 (Abort) | 同步,不可恢复错误 | 机器检查、严重硬件错误 | 终止进程 |
2.1 系统调用是受控的陷阱
用户程序不能直接执行特权操作,例如访问设备寄存器或创建进程;它通过系统调用进入内核。x86-64 Linux 常用 syscall 指令,系统调用号放在 %rax,参数按 ABI 放在寄存器中。
用户态程序
│ syscall
▼
内核态系统调用处理程序
│ 完成 open / read / fork / ...
▼
返回用户态下一条指令
printf、malloc 这类 C 库函数不等于系统调用;它们可能完全在用户态完成,也可能在内部调用多个系统调用。
3. 进程模型
进程 (Process) 是正在运行程序的实例。它为程序提供两个关键抽象:
| 抽象 | 含义 |
|---|---|
| 逻辑控制流 | 每个进程仿佛独占 CPU,实际由内核调度交错运行 |
| 私有地址空间 | 每个进程仿佛独占内存,实际由虚拟内存隔离和映射 |
进程状态的典型转换:
stateDiagram-v2
[*] --> Running: fork / exec 后被调度
Running --> Stopped: 收到 SIGSTOP / SIGTSTP
Stopped --> Running: 收到 SIGCONT
Running --> Terminated: exit / 致命信号
Terminated --> Zombie: 等待父进程回收
Zombie --> [*]: wait / waitpid
- 已停止 (Stopped):进程存在,但不会被调度。
- 已终止 (Terminated):已不再执行;内核仍保留退出状态等少量信息。
- 僵尸 (Zombie):父进程尚未调用
wait/waitpid回收的已终止子进程。 - 孤儿 (Orphan):父进程先退出;它会被
init/systemd等收养,之后由收养者回收。
僵尸并非“卡住的运行进程”,但它会占用 PID 和内核表项。长期运行的父进程必须回收子进程。
4. 创建、替换与等待
4.1 fork:复制当前进程
pid_t pid = fork();
if (pid < 0) {
/* 创建失败 */
} else if (pid == 0) {
/* 子进程:从 fork 返回处继续执行 */
} else {
/* 父进程:pid 是新子进程的 PID */
}
fork 调用一次,返回两次。父子进程各有独立的用户地址空间;现代系统通常用写时复制 (Copy-on-Write),在真正写入页面前不复制物理页。
flowchart LR
A["父进程:fork()"] --> B{"返回值"}
B -->|"父:child PID"| C["父逻辑流"]
B -->|"子:0"| D["子逻辑流"]
C --> E["并发执行,先后不可假定"]
D --> E
fork 后变量内容相同,但地址空间独立:对子进程变量的修改不会直接改变父进程变量。例外是它们可能通过共享文件、共享内存或内核对象间接相互影响。
4.2 文件描述符:副本指向同一打开文件表项
父子进程的描述符表会被复制,但对应描述符通常指向同一个打开文件表项 (open file description),因此共享文件偏移量和状态标志。
父 fd 1 ─┐
├─ 打开文件表项:offset、status flags ─ 文件 inode
子 fd 1 ─┘
这解释了为什么父子进程向同一文件写入时,输出顺序不可预测;也解释了 fork 前尚未刷新的 stdio 缓冲可能被父子各自刷新一次。分叉前输出后应 fflush(stdout),或直接使用不经 stdio 缓冲的 write。
4.3 execve:在原 PID 中装入新程序
char *argv[] = {"/bin/ls", "-l", NULL};
char *envp[] = {"LANG=C", NULL};
execve("/bin/ls", argv, envp);
perror("execve"); /* 只有失败才会执行 */
_exit(127);
execve 不创建新进程;它以新程序的代码、数据、堆和栈替换当前进程映像,PID 保持不变。成功时不返回。未设置 FD_CLOEXEC 的文件描述符会保留,因此 Shell 能通过“先 dup2,再 execve”实现重定向。
| 参数 | 约束 |
|---|---|
filename |
可执行文件路径 |
argv |
字符串指针数组,必须以 NULL 结束;argv[0] 通常为程序名 |
envp |
key=value 字符串指针数组,以 NULL 结束 |
execvp、execlp 等是 libc 包装:可按 PATH 搜索,或采用更方便的参数形式;最终都归结为 execve。
4.4 waitpid:同步并回收
int status;
pid_t child = waitpid(pid, &status, 0);
if (child < 0) {
perror("waitpid");
} else if (WIFEXITED(status)) {
printf("exit=%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("signal=%d\n", WTERMSIG(status));
}
| 调用 | 作用 |
|---|---|
waitpid(pid, &status, 0) |
阻塞等待指定子进程状态改变 |
waitpid(-1, &status, 0) |
等待任意一个子进程 |
waitpid(-1, &status, WNOHANG) |
非阻塞检查;没有状态变化时返回 0 |
waitpid(-1, &status, WUNTRACED) |
也报告已停止的子进程 |
不要写“fork 后子进程总先打印”或“父进程一定先回收”。调度顺序不是语言或内核 API 的保证。
5. 信号:进程级的异步异常
信号 (Signal) 是内核通知进程发生某类事件的短消息。每个信号有默认动作:终止、停止、继续、产生 core,或忽略。进程可为大多数信号安装处理函数。
| 信号 | 常见来源 | 默认动作 | 备注 |
|---|---|---|---|
SIGINT |
终端 Ctrl-C |
终止 | 发给前台进程组 |
SIGTSTP |
终端 Ctrl-Z |
停止 | 可捕获或忽略 |
SIGSTOP |
kill -STOP |
停止 | 不能捕获、阻塞或忽略 |
SIGCONT |
bg、fg、kill -CONT |
继续 | 继续已停止进程 |
SIGCHLD |
子进程停止或终止 | 忽略 | 父进程应借机 waitpid 回收 |
SIGALRM |
alarm 计时到期 |
终止 | 简单超时机制 |
SIGSEGV |
非法内存访问 | 终止并常产生 core | 常见于越界、空指针 |
5.1 发送、接收与阻塞
一次信号生命周期可概括为:
flowchart LR
A["产生:硬件、内核、kill"] --> B["目标进程的 pending 集"]
B --> C{"该信号是否被 block?"}
C -->|"是"| B
C -->|"否"| D["默认动作或 handler"]
D --> E["恢复或终止"]
- pending 集记录已有待处理信号;普通标准信号不排队,同一信号多次到达通常合并为一个待处理位。
- **blocked 集(信号掩码)**记录暂时禁止递送的信号。
- 进程从内核态返回用户态前,内核检查 pending 且未 blocked 的信号并递送;因此处理函数可能在“看似任意”的用户态指令之间运行。
kill 名称容易误导:它发送信号,并不必然杀死进程。
kill -INT 1234 # 向 PID 1234 发送 SIGINT
kill -TERM 1234 # 请求终止
kill -KILL 1234 # 强制终止,进程无法处理
kill -STOP -4321 # 向进程组 4321 发送 SIGSTOP
Shell 通常让一个作业中的 pipeline 处于同一进程组,Ctrl-C/Ctrl-Z 交给前台进程组,而非只交给 Shell。
5.2 安装处理函数:使用 sigaction
教学代码常用 Signal 包装;生产代码优先 sigaction,它的语义更明确,可控制掩码和重启行为。
static void on_sigint(int sig)
{
(void)sig;
}
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &sa, NULL) < 0)
perror("sigaction");
进入 handler 时,当前处理信号通常自动加入临时阻塞集,防止同一 handler 无限重入;sa_mask 可额外屏蔽其他信号。SA_RESTART 会让部分被信号中断的慢系统调用自动重启,但不能假定所有调用都如此。
5.3 Handler 的硬约束
处理函数与主控制流异步并发。printf、malloc、exit 等通常不是 async-signal-safe:它们可能持有锁或依赖被中断时处于不一致状态的内部数据。
| 在 handler 中 | 建议 |
|---|---|
| 输出简单消息 | 使用 write,或 CSAPP 的 sio_* 安全 I/O 包装 |
| 更新标志 | 使用 volatile sig_atomic_t |
| 回收多个子进程 | waitpid 循环,保存并恢复 errno |
| 复杂逻辑 | handler 只设标志;主循环再处理 |
static volatile sig_atomic_t child_changed = 0;
static void sigchld_handler(int sig)
{
int olderrno = errno;
int status;
(void)sig;
while (waitpid(-1, &status, WNOHANG) > 0)
child_changed = 1;
errno = olderrno;
}
上例只演示“回收”结构;真实 Shell 还必须在临界区内维护作业表,不能只依赖一个标志。
6. 竞态条件与正确同步
最经典错误是:父进程 fork 后还没记录子 PID,子进程就退出并触发 SIGCHLD;handler 找不到作业,之后父进程记录了一个永远不会再次通知的僵尸子进程。
错误的时间线:
父:fork() ─────────────── addjob(pid)
子: exit()
内核: 递送 SIGCHLD ─ handler:deletejob(pid) 失败
正确模式是先阻塞 SIGCHLD,再 fork,父进程在信号仍被阻塞时更新共享状态,最后解除阻塞:
sigset_t mask, prev;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev);
pid_t pid = fork();
if (pid == 0) {
sigprocmask(SIG_SETMASK, &prev, NULL);
execve(argv[0], argv, environ);
_exit(127);
}
/* 父进程:此处安全地把 pid 加入作业表 */
sigprocmask(SIG_SETMASK, &prev, NULL);
子进程必须恢复父进程原有信号掩码,否则新程序可能在错误地阻塞 SIGCHLD、SIGINT 等状态下运行。
6.1 为什么不能用 while (!flag) ;
忙等浪费 CPU,而且“检查 flag”和“进入睡眠”不是原子操作:信号可能恰好落在二者之间,从而永久睡眠。
sigsuspend 临时替换信号掩码并休眠,这两个动作原子完成:
sigset_t mask, prev;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev);
while (!child_changed)
sigsuspend(&prev); /* 临时恢复原掩码,收到信号后返回 */
sigprocmask(SIG_SETMASK, &prev, NULL);
循环仍然必要:可能收到无关信号,也可能遇到伪唤醒式的控制流返回。pause() 不具备“解除阻塞并睡眠”的原子组合,不能代替这里的 sigsuspend。
7. 非本地跳转
setjmp 保存当前控制环境,longjmp 让控制流返回该位置,不按普通调用栈逐层 return。
#include <setjmp.h>
static sigjmp_buf env;
static void on_alarm(int sig)
{
(void)sig;
siglongjmp(env, 1);
}
if (sigsetjmp(env, 1) == 0) {
/* 首次经过:安装 handler,开始可能阻塞的操作 */
} else {
/* 从 handler 跳回:执行超时恢复 */
}
| 接口 | 要点 |
|---|---|
setjmp / longjmp |
保存/恢复基本执行环境,不保存信号掩码 |
sigsetjmp(env, 1) / siglongjmp |
同时保存并恢复信号掩码,适合信号恢复路径 |
setjmp 返回值 |
初次为 0;经 longjmp(env, val) 返回时为 val,若 val == 0 则为 1 |
限制很重要:不能跳到已经返回的函数中保存的环境;跳转会绕过自动变量清理、锁释放和 C++ 析构,因此不适合替代正常错误处理。C 中 setjmp 后被修改的非 volatile 自动变量值也可能不确定。
8. 调试与观察工具
| 目标 | 命令 | 观察点 |
|---|---|---|
| 查看进程树/状态 | ps -o pid,ppid,pgid,stat,cmd -ax |
Z 为僵尸,T 为停止 |
| 查看作业 | jobs -l |
Shell job ID、PID、停止/后台状态 |
| 发送信号 | kill -l、kill -TERM PID |
信号编号与名称 |
| 跟踪系统调用 | strace -f ./app |
fork、execve、wait4、信号递送 |
| GDB 跟踪 fork | set follow-fork-mode child |
跟随父或子进程 |
| 查看信号处理 | info signals |
stop/print/pass 设置 |
| 查看信号掩码 | cat /proc/PID/status |
SigBlk、SigPnd、ShdPnd |
strace -f -e trace=process,signal ./fork_demo
ps -o pid,ppid,pgid,stat,cmd -ax | less
kill -STOP <pid>
kill -CONT <pid>
调试并发输出时,先记录 PID/PPID/PGID 和每条日志的时间;只看打印顺序很容易把调度偶然性误判成语言保证。
9. 易错点清单
| 错误认识 | 正确理解 |
|---|---|
fork 后父进程一定先运行 |
两者顺序不可预测 |
execve 创建子进程 |
它替换当前进程映像,成功不返回 |
| 终止子进程会自动消失 | 父进程必须 waitpid 回收,否则成为僵尸 |
kill 总是终止 |
kill 发送指定信号 |
一次 SIGCHLD 对应一个子进程 |
标准信号可能合并;handler 必须循环 waitpid |
handler 中可随意 printf |
应只调用 async-signal-safe 函数 |
volatile 等于线程安全 |
它不提供互斥、原子复合操作或内存同步 |
pause 可避免等待竞态 |
用 sigsuspend 原子地解除阻塞并等待 |
SIGKILL / SIGSTOP 可捕获 |
二者不能捕获、阻塞或忽略 |
10. 与后续章节和练习的关联
| 场景 | 本章知识 |
|---|---|
| Shell Lab | 作业控制、进程组、SIGCHLD、信号掩码、sigsuspend |
| 第 9 章虚拟内存 | 缺页 fault 可由内核修复后重试指令 |
| 第 10 章 I/O | 文件描述符继承、fork 后共享打开文件表项 |
| 第 11 章网络 | 阻塞 I/O 被信号中断、SIGPIPE、多进程服务器 |
| 第 12 章并发 | 异步控制流、竞态条件、临界区与同步原语 |
| 调试崩溃 | SIGSEGV、core dump、GDB、strace |
11. 最小命令备忘
# 编译并查看系统调用与信号
gcc -g -O0 -Wall fork_demo.c -o fork_demo
strace -f -e trace=process,signal ./fork_demo
# 查进程、进程组与状态
ps -o pid,ppid,pgid,stat,cmd -ax
# 发送常用信号
kill -TERM <pid>
kill -STOP <pid>
kill -CONT <pid>
# 在 GDB 中选择跟随子进程
gdb ./fork_demo
(gdb) set follow-fork-mode child