第 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