Lecture 17: 异常控制流——信号与非本地跳转 (Exceptional Control Flow: Signals and Nonlocal Jumps)
Lecture 17: 异常控制流——信号与非本地跳转 (Exceptional Control Flow: Signals and Nonlocal Jumps)
讲义对应:CMU 15-213 Lecture 17 — Exceptional Control Flow(素材:
F25-17-ecf.txt) 教材对应:CS:APP3e 第 8 章 8.5–8.8(信号、非本地跳转、进程控制工具) 关联 Lab:L6 Shell Lab(tshlab)
17.1 概述
上一讲用 fork/execve/waitpid 建立了”进程”抽象,并写出一个能跑前台任务的简单 shell——但它有致命缺陷:shell 设计寿命无限长,却对后台作业(background job)束手无策。后台子进程终止后变成僵尸(zombie),永久占用内核进程表项;shell 从不停下来 wait,PID 被一个接一个耗尽。
本讲引入信号(signal):一条由内核或进程发出的小消息,通知目标进程系统中发生了某类事件。它让进程无须轮询就能感知系统状态变化(子进程终止、定时器到期、用户按下 ctrl-c)。在此之上还引入非本地跳转(nonlocal jump)——setjmp/longjmp,让 C 拥有类似 C++ throw/catch 的跨栈帧错误传播。
信号最”反直觉”之处在于:处理程序与主程序并发运行、共享全局数据,随时可能插进一条赋值语句中间;而内核的态度是”至多记一位“,从不排队。这是 L6 Shell Lab 的全部难点,也为 Lecture 21 的并发编程埋伏笔。
17.2 核心概念与底层机制图解
17.2.1 信号是什么(What a Signal Is)
- 定义与目的:一条小消息,通知进程系统中发生了某类事件。它是异常与中断在用户态的对应物:异常由硬件与内核处理,信号则让应用软件也能参与异常控制流。
- 直观解释:像办公室的火警铃——铃声不携带信息,你只知道”响了”,不知道谁按的、为什么按;能区分的只有”哪一种铃”(信号类型,整数 ID,Linux 上 1–30)和”它响了”这一事实。信号没有数据载荷。
- 底层机制图解(综合讲义 8.5 的时间轴):内核并不立即打断目标进程,而是先在位向量里记一笔;下次准备把控制权交还该进程时,才顺便让它”收到”信号并执行处理程序。
进程 A(发送方) 内核 进程 p(接收方)
| | |
| kill(p, SIGINT) | |
|------------------>| |
| | ① 在 p 的 pending 位向量 |
| | 中置位 SIGINT 对应位 |
| | |
| | ② 内核调度回 p 之前: |
| | pnb = pending&~blocked|
| | 非零 → 强制 p 接收 |
| |----------------------->| ③ 控制流切到 handler
| | | handler 运行
| |<-----------------------| ④ handler 返回
| | ⑤ 恢复 p 被中断处的下一条 |
| | 指令 Inext 继续执行 |
时间 ───────────────────────────────────────────────────►
↑ 发送(send) ↑ 待处理(pending) ↑ 接收(receive)
- 与机器码/硬件的对应:发送信号只是”内核修改目标进程上下文中的某个状态位”,没有专门的机器指令;真正把控制流转向处理程序的是内核在返回用户态前对进程栈与
%rip的改写。
17.2.2 发送信号:kill、进程组与 alarm
- 定义与目的:内核以两种方式发送信号:自己检测到系统事件(除零→
SIGFPE、子进程终止→SIGCHLD、定时器到期→SIGALRM),或某进程调用kill显式请求。 - 进程组(process group):每个进程恰好属于一个进程组(正整数
pgid),它是信号广播的单位。shell 借它实现作业控制:一个作业就是一个进程组。
Shell (pid=10, pgid=10)
| fork + setpgid
+-----------------------+-----------------------+
| | |
前台作业 后台作业 #1 后台作业 #2
pgid=20 pgid=32 pgid=40
| | | |
pid=20 pid=21,22 pid=32 pid=40
↑
ctrl-c / ctrl-z 只发给【前台进程组 20】的所有成员
kill的语义:int kill(pid_t pid, int sig),成功返回 0,失败返回 −1 并置errno。pid的符号决定目标集合:
pid 取值 | 含义 |
|---|---|
pid > 0 | 只发给进程 pid |
pid == 0 | 发给调用者所在进程组的每个进程(含自己) |
pid < 0 | 发给进程组 |pid| 中的每个进程 |
pid == -1 | 发给调用者有权发送信号的所有进程 |
alarm(n):请求内核在 $n$ 秒后发SIGALRM。两个必须记住的细节:① 只触发一次,不周期重复;② 再次调用会覆盖旧闹钟,并返回上一个闹钟的剩余秒数(实测:alarm(3)后立刻alarm(1)返回 3)。- 与机器码/硬件的对应:内核侧定时由硬件定时器芯片周期性中断 CPU(约每几毫秒一次)驱动;
kill是系统调用号 62 的syscall,参数走%rdi(pid) 与%rsi(sig)。
17.2.3 接收信号:内核递送的时机与 pnb
- 定义与目的:进程接收信号,指它被内核强制做出反应。反应有三种:忽略、终止(可带 core dump)、捕获(执行用户级信号处理程序 signal handler)。
- 底层机制图解:讲义给出的判定规则极简单,却是一切推理的出发点——注意内核把 pnb 中所有信号递送完才回到用户态,故多个不同信号会按编号从小到大依次处理。
内核准备把控制权交给进程 p 时:
pnb = pending & ~blocked /* pending 且未被阻塞的信号集合 */
if (pnb == 0)
按逻辑流继续执行 p 的下一条指令
else
取 pnb 中【编号最小】的非零位 k,强制 p 接收信号 k
对 pnb 中所有非零位重复上述过程
再按逻辑流继续执行 p 的下一条指令
- 处理程序是独立的逻辑流:它与主程序并发运行,但只是同一进程内的一条逻辑流(logical flow),不是新进程、也没有独立地址空间。它可以被打断——处理
SIGINT时若又来SIGTSTP,内核会再插入一层处理程序(嵌套)。内核还会自动阻塞正在被处理的那个信号类型(隐式阻塞),所以SIGINT的处理程序不会被另一个SIGINT打断。
主程序 Icurr ──► 收到信号 s ──► Handler S 运行 ──► 收到信号 t ──► Handler T 运行
▲ |
└──────── T 返回,S 继续 ◄─────────┘
S 返回 ──► 主程序 Inext 继续
17.2.4 待处理与阻塞:内核中的两个位向量
- 定义与目的:
pending=已发送但尚未接收;blocked(又名信号掩码 signal mask)=可以屏蔽其接收。二者是内核在每个进程上下文中维护的两个位向量。 - 直观解释:
pending像邮箱里”有件快递到了”的小纸条,blocked像门上挂的”免打扰”牌子。挂了牌子快递员照样塞纸条(pending 置位),只是你暂不去取;牌子一摘立刻去取——而不管纸条被塞了几张,你只看到一条消息。 - 底层机制图解:
进程 p 的内核上下文(每个进程各一份)
pending 位向量(已发送、未接收)
15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 1| 0| 0| ← SIGALRM(14) 待处理
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
blocked 位向量(信号掩码,可由 sigprocmask 修改)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 0| 1| 0| 1| ← 阻塞了 SIGALRM(14) 与 SIGINT(2)
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
pnb = pending & ~blocked = 0 → 本次不递送,先执行用户指令
(SIGINT 此时若到达,只会把 pending 的第 2 位置 1,仍需等待解除阻塞)
- 最重要的事实:信号不排队(signals are not queued)。每种信号在
pending中只占一位,故同一时刻至多一个该类型的待处理信号;在它被接收前,后续同类型信号被直接丢弃。推论:不能用信号计数事件。本地实测:阻塞SIGUSR1后连续kill自己 100 次,sigpending查到的集合里SIGUSR1仍只是一个位。 - 不可阻塞的信号:
SIGKILL(9) 与SIGSTOP(19) 不可捕获、不可忽略、不可阻塞——内核为管理员保留的”最后手段”。实测:对二者调用sigaction一律失败并返回EINVAL(22)。
17.2.5 安装处理程序:signal 与 sigaction
- 定义与目的:安装(install)处理程序即修改某个信号号的默认动作。
signal(int, handler_t *)是历史接口;sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)是现代接口,可精确控制标志,oldact非空时写回原先的处理方式。三个特殊常量:SIG_IGN(忽略)、SIG_DFL(恢复默认)、用户函数地址(捕获)。 sa_flags常用位:
| 标志 | 含义 |
|---|---|
SA_RESTART | 自动重启被该信号打断的慢速系统调用(如 read、wait) |
SA_NODEFER | 处理程序运行期间不自动阻塞自身(默认阻塞,见 17.2.3) |
SA_RESETHAND | 进入处理程序时把动作重置为 SIG_DFL(只生效一次) |
SA_SIGINFO | 改用三参数 sa_sigaction,可取得 siginfo_t 与上下文 |
SA_ONSTACK | 在备用信号栈(sigaltstack)上运行处理程序 |
实测 SA_RESTART 数值为 0x10000000。signal 不可移植:不同系统语义不同——有的在一次触发后把处理程序重置为默认(System V 风格),有的自动重启系统调用;可移植代码一律使用 sigaction。三个可移植性差异点——① 触发后是否重置处理程序;② 运行期间是否自动阻塞自身;③ 被中断的系统调用是否自动重启——分别由 SA_RESETHAND、SA_NODEFER、SA_RESTART 显式决定。
17.2.6 阻塞信号与 sigprocmask
- 接口:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset):
how | 含义 |
|---|---|
SIG_BLOCK | 把 set 中的信号加入当前掩码(屏蔽) |
SIG_UNBLOCK | 把 set 中的信号从掩码中移除 |
SIG_SETMASK | 把当前掩码替换为 set |
- 集合操作:
sigemptyset、sigfillset、sigaddset、sigdelset、sigismember;另有sigpending(&set)查询待处理集合。 - 推荐的配对写法:不要用
SIG_UNBLOCK解除,而用SIG_SETMASK+ 保存的prev恢复——SIG_UNBLOCK会无条件解除,即使该信号进入本段代码前本就阻塞,从而破坏嵌套的正确性。讲义与 recitation 均强调这一点:
sigset_t mask, prev_mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
/* 屏蔽 SIGINT,同时把"原掩码"保存到 prev_mask */
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
/* 这段临界区不会被 SIGINT 打断 */
/* 恢复原掩码 —— 等价于解除 SIGINT,且对嵌套安全 */
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
17.2.7 非本地跳转(Nonlocal Jumps)
- 定义与目的:C 的控制流原语只能在同一栈帧内跳转(
goto)或按栈纪律返回(return)。setjmp(env)把当前寄存器上下文与栈指针存入jmp_buf,longjmp(env, retval)恢复该上下文——效果是直接掀掉中间所有栈帧跳回setjmp处。直观上像夹一张书签后决定”直接翻回那一页、跳过中间所有内容”。 - 返回值语义:
setjmp直接返回 0;从longjmp返回时”返回”的是其第二个参数retval,故retval必须非 0。setjmp只能出现在if、switch、循环条件或比较表达式中,不能写成x = setjmp(env)。 - 底层机制图解(
%rsp一次性弹回main栈顶,中间 4 层栈帧被凭空抹去):
高地址
+-------------------------+ <-- main 的栈帧(setjmp 在此保存上下文)
| main(argc, argv) |
| jmp_buf env ──────────┼──┐ 保存 %rsp, %rbp, %rbx, %r12-%r15, %rip
+-------------------------+ │
| level1 的栈帧 | │
+-------------------------+ │
| level2 的栈帧 | │ longjmp(env, 0x213) 让 %rsp 一次性
+-------------------------+ │ 弹回 main 的栈顶,并令 setjmp 返回 0x213
| level3 的栈帧 | │ ★ 中间 4 个栈帧被"凭空抹去",
+-------------------------+ │ 它们占用的资源不会被释放
| level4 的栈帧 ← 出错点 | │
+-------------------------+ │
| ... | │
+-------------------------+ │
低地址 └──► 跳回此处(setjmp 返回非 0)
- 两个必须记住的陷阱:
- 资源泄漏:
longjmp不释放中间栈帧——像撕掉书中页码,夹在里面的借书卡却还在。中间层malloc的内存、打开的 fd 都不被清理。 - 变量值不确定(UB):在
setjmp与longjmp之间被修改的局部变量,若被分配到寄存器(而longjmp恢复了该寄存器的旧值),之后读到的值就”回到了过去”。标准只保证volatile变量一定正确。
- 资源泄漏:
- 信号版本:
sigsetjmp(env, savesigs)/siglongjmp(env, retval);savesigs非 0 时同时保存信号掩码并在跳回时恢复,从处理程序里跳出应当用这一对。C++ 的throw/catch与 Rust 的panic语义相同,但其实现(栈展开)会逐个调用中间栈帧的析构函数,故不泄漏——这是与longjmp最本质的区别。
17.3 代码示例与底层机制分析
17.3.1 示例 (a):用 sigaction 捕获 SIGINT/SIGQUIT 并优雅退出
代码(C):完整源码见 /tmp/csapp17/ecf_a_sigaction.c。核心部分:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>
/* G4/G5:全局标志用 volatile sig_atomic_t */
static volatile sig_atomic_t sigint_count = 0;
static volatile sig_atomic_t sigquit_seen = 0;
/* G1:处理程序里只用异步信号安全函数 —— write 是唯一安全的输出函数 */
static void safe_puts(const char *s)
{
size_t n = strlen(s); /* strlen 在 POSIX 安全清单中 */
ssize_t r = write(STDOUT_FILENO, s, n);
(void)r;
}
static void sigint_handler(int signum)
{
int saved_errno = errno; /* G2:进场先存 errno */
sigint_count++; /* G5:只做赋值 */
safe_puts("[handler] caught SIGINT (ctrl-c), ignoring it\n");
sleep(1); /* sleep 也是异步信号安全的 */
errno = saved_errno; /* G2:退场恢复 errno */
(void)signum;
}
static void sigquit_handler(int signum)
{
int saved_errno = errno;
sigquit_seen = 1; /* 只置位,收尾交给主程序 */
safe_puts("[handler] caught SIGQUIT (ctrl-\\), asking main to quit\n");
errno = saved_errno;
(void)signum;
}
static void install(int signum, void (*handler)(int), int flags)
{
struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_handler = handler;
sigemptyset(&act.sa_mask); /* 运行期间不额外屏蔽别的信号 */
act.sa_flags = flags;
if (sigaction(signum, &act, NULL) < 0) { perror("sigaction"); exit(EXIT_FAILURE); }
}
int main(void)
{
install(SIGINT, sigint_handler, SA_RESTART);
install(SIGQUIT, sigquit_handler, 0);
for (int i = 0; i < 3; i++) {
kill(getpid(), SIGINT); /* 立刻被递送并处理 */
printf("[main] back from SIGINT, sigint_count = %d\n", (int)sigint_count);
}
kill(getpid(), SIGQUIT);
while (!sigquit_seen) pause();
printf("[main] graceful exit. SIGINT handled %d times, SIGQUIT seen %d\n",
(int)sigint_count, (int)sigquit_seen);
return 0;
}
【代码做什么?】 ① install 用 sigaction 给 SIGINT 装”忽略但计数”、给 SIGQUIT 装”置位请求退出”的处理程序;② 主程序三次 kill(getpid(), SIGINT),每次 kill 返回时处理程序已执行完毕;③ 发 SIGQUIT 后进入 pause() 挂起等待标志置位;④ 主程序自己收尾并打印统计。
【底层机制透视】 关键是”kill 返回的时机”。kill 是系统调用,返回路径必经内核的”递送信号”检查点,于是 pending & ~blocked 立刻非零,内核当场把 %rip 指向 sigint_handler 并为它构造临时栈帧;处理程序 return 后恢复主程序上下文。因为处理程序运行期间内核隐式阻塞 SIGINT,处理程序内部再来的 SIGINT 会留在 pending 里等返回后再递送——这正是 sigint_count 能精确加到 3 的原因。
实测输出(gcc -g -Wall -std=c11 ecf_a_sigaction.c -o ecf_a_sigaction,用 stdbuf -oL 保证行缓冲):
[main] pid = 3570419
[main] installed handler for signal 2 (flags=0x10000000)
[main] installed handler for signal 3 (flags=0x0)
[main] SIGINT=2 SIGQUIT=3 SIGCHLD=17 SIGKILL=9 (不可捕获)
[main] sending SIGINT to self (round 1)
[handler] caught SIGINT (ctrl-c), ignoring it
[main] back from SIGINT, sigint_count = 1
[main] sending SIGINT to self (round 2)
[handler] caught SIGINT (ctrl-c), ignoring it
[main] back from SIGINT, sigint_count = 2
[main] sending SIGINT to self (round 3)
[handler] caught SIGINT (ctrl-c), ignoring it
[main] back from SIGINT, sigint_count = 3
[main] sending SIGQUIT to self
[handler] caught SIGQUIT (ctrl-\), asking main to quit
[main] back from SIGQUIT
[main] graceful exit. SIGINT handled 3 times, SIGQUIT seen 1
flags=0x10000000 即 SA_RESTART 的真实数值;SIGQUIT 的 flags=0x0 不重启系统调用。
17.3.2 示例 (b):SIGCHLD 的两个经典竞态——正确版与错误版对比
这是 Shell Lab 的核心考点。完整源码见 /tmp/csapp17/ecf_b_sigchld.c,用 ./ecf_b_sigchld <race\|racefix\|once\|loop> 切换四种模式。
竞态一:fork 与 addjob 之间的窗口。 讲义 procmask1.c 写的是”先 fork,再 addjob“,隐含假设父进程先跑。但 fork 后父子进程并发:若子进程在 addjob 前终止,SIGCHLD 处理程序会先运行,去 deletejob(pid) 而那个 job 还不在列表里——删除失败,父进程随后又把它加进去,列表中就留下一个永远无法回收的幽灵 job(ghost job)。正确版本必须在 fork 之前屏蔽 SIGCHLD:
/* ---- 正确版(等价于讲义 procmask2.c):阻塞 / 解除阻塞的配对 ---- */
int main(int argc, char **argv)
{
int pid;
sigset_t mask_all, mask_one, prev_one;
int n = 5;
sigfillset(&mask_all);
sigemptyset(&mask_one);
sigaddset(&mask_one, SIGCHLD); /* 只管 SIGCHLD 一个 */
signal(SIGCHLD, handler);
initjobs();
while (n--) {
/* ① 屏蔽 SIGCHLD —— 必须在 fork 之前,否则窗口仍存在 */
sigprocmask(SIG_BLOCK, &mask_one, &prev_one);
if ((pid = fork()) == 0) { /* 子进程 */
/* 子进程继承父进程的屏蔽字,execve 之前必须解除 */
sigprocmask(SIG_SETMASK, &prev_one, NULL);
setpgid(0, 0); /* 自建进程组,隔离 ctrl-c */
execve("/bin/date", argv, NULL);
_exit(127);
}
/* ② 父进程:屏蔽所有信号,保护 job 列表这一共享数据结构(G3) */
sigprocmask(SIG_BLOCK, &mask_all, NULL);
addjob(pid); /* 此刻子进程即使已死,信号也进不来 */
/* ③ 解除屏蔽 —— 用 SIG_SETMASK 恢复 prev_one,而不是 SIG_UNBLOCK */
sigprocmask(SIG_SETMASK, &prev_one, NULL);
}
exit(0);
}
★ 三点必须同时做到:(i) 屏蔽发生在 fork 之前;(ii) addjob 时屏蔽所有信号以保护 job 列表;(iii) 解除用 SIG_SETMASK + prev_one 恢复而非 SIG_UNBLOCK,这样可安全嵌套。子进程侧那次 SIG_SETMASK 也不可省——子进程继承父进程的信号掩码,不解除则 execve 出来的程序会带着 SIGCHLD 被永久屏蔽的状态运行。
竞态二:信号合并导致的漏收。 即使修好上面的问题,若处理程序只调用一次 waitpid 仍会出错:内核对待 SIGCHLD 只有一位,多个子进程短时间陆续终止时多次 SIGCHLD 会合并成一次递送,处理程序只回收一个僵尸,其余永远留在进程表里。修复办法是用 while 循环 + WNOHANG 收干净:
/* ---- 正确版处理程序:一次递送,回收全部已终止子进程 ---- */
void sigchld_handler(int sig)
{
int olderrno = errno; /* G2 */
sigset_t mask_all, prev_all;
pid_t pid;
sigfillset(&mask_all);
/* WNOHANG:还有活着的子进程时立即返回 0,绝不阻塞等待 */
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* G3:保护 job 列表 */
deletejob(pid);
sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
if (pid != 0 && errno != ECHILD) /* 0=还有活子进程;<0 且 ECHILD=无子进程 */
sio_error("waitpid error");
errno = olderrno; /* G2 */
}
实测输出(四种模式各跑一次,真实结果):
$ ./ecf_b_sigchld race
=== mode = race (N = 5 children) ===
[handler] deletejob: pid 3579688 NOT in job list -- 幽灵 job 即将产生!
[handler] deletejob: pid 3579696 NOT in job list -- 幽灵 job 即将产生!
(同样的告警共出现 5 次,此处略去 3 行)
handler_calls = 5
reaped_in_handler= 5
reaped_in_main = 0
最终 job 列表: njobs_inuse = 5 [ (jid=1,pid=3579688) (jid=2,pid=3579696) ... ]
结论: FAIL —— 存在幽灵 job 或残留僵尸
$ ./ecf_b_sigchld racefix
handler_calls = 5
reaped_in_handler= 5
reaped_in_main = 0
最终 job 列表: njobs_inuse = 0 [ ]
结论: PASS —— job 列表干净、僵尸收干净
$ ./ecf_b_sigchld once
[main] 5 个子进程已全部死亡,此刻 pending 位向量里 SIGCHLD 只有 1 位
handler_calls = 1 (SIGCHLD 处理程序实际被调用次数)
reaped_in_handler= 1
reaped_in_main = 4 (处理程序漏收、主程序补收的僵尸数)
最终 job 列表: njobs_inuse = 4 [ (jid=2,pid=3579812) ... ]
结论: FAIL —— 存在幽灵 job 或残留僵尸
$ ./ecf_b_sigchld loop
[main] 5 个子进程已全部死亡,此刻 pending 位向量里 SIGCHLD 只有 1 位
handler_calls = 1
reaped_in_handler= 5
reaped_in_main = 0
最终 job 列表: njobs_inuse = 0 [ ]
结论: PASS —— job 列表干净、僵尸收干净
once 与 loop 的对比把”信号不排队”体现得淋漓尽致:5 个子进程全部死亡,处理程序只被调用 1 次,却因 while 循环回收了全部 5 个。这正是 Shell Lab 必须采用 while (waitpid(-1, &status, WNOHANG \| WUNTRACED) > 0) 的原因——WUNTRACED 让它同时捕获”被停止”的子进程。
【数据结构图解】 幽灵 job 的产生过程:
时刻 t0 父进程:fork() 返回 pid=X 子进程 X 开始运行
时刻 t1 父进程:被调度走的瞬间(还没 addjob) 子进程 X:_exit(0) → 变僵尸,内核发 SIGCHLD
时刻 t2 内核把 SIGCHLD 递给父进程 → 处理程序运行
handler: waitpid 收走 X;deletejob(X) → 查无此 job,删除失败
时刻 t3 父进程回到用户态,执行 addjob(X) ★ 把一个已死进程登记进 job 列表
结果:jobs 表里留下 (jid=1, pid=X),而 X 早已不存在 → jobs 命令永远显示它
17.3.3 示例 (c):setjmp/longjmp 的深层跳转与 volatile 陷阱
完整源码见 /tmp/csapp17/ecf_c_setjmp.c,用 ./ecf_c_O2 <deep\|volatile\|sigjmp> 切换模式。
深层跳转:main 用 setjmp 设置落点,依次调用 level1 → level2 → level3 → level4,在 level4 里 longjmp(deep_env, 0x213)。
static jmp_buf deep_env;
static void level4(int errcode)
{
printf(" level4: 发现错误 errcode=%d,调用 longjmp 直接跳回 main 的 setjmp 点\n", errcode);
longjmp(deep_env, errcode); /* 第二个参数必须非 0! */
printf(" level4: 这一行永远不会执行\n");
}
static void level3(int e) { level4(e); }
static void level2(int e) { level3(e); }
static void level1(int e) { level2(e); }
int mode_deep(void)
{
int code = setjmp(deep_env); /* 只能出现在 if / switch / 比较中 */
if (code == 0) {
level1(0x213); /* 直接调用时 setjmp 返回 0 */
} else {
printf("[deep] 从 longjmp 返回,setjmp 这次返回 %d (0x%x)\n", code, code);
}
return 0;
}
实测输出(-O0 与 -O2 完全一致):
[deep] 调用点地址附近的 setjmp 返回了 0(直接调用)
[deep] 开始向下钻:level1 -> level2 -> level3 -> level4
level1: 进入
level2: 进入
level3: 进入
level4: 发现错误 errcode=531,调用 longjmp 直接跳回 main 的 setjmp 点
[deep] 从 longjmp 返回,setjmp 这次返回 531 (0x213)
[deep] 中间 4 层栈帧已被一次性丢弃
【底层机制透视】 setjmp 把 %rsp、%rbp、%rbx、%r12–%r15 与返回地址写进 jmp_buf;longjmp 把这些寄存器原样写回,于是 %rsp 一下子弹回 main 的栈顶,%rip 指向 setjmp 之后。中间 4 层栈帧里 printf 之后的语句被彻底跳过——没有执行任何清理代码。
volatile 陷阱演示(⚠️ 故意踩 UB,请勿模仿):
/* ⚠️ 仅供演示:这正是 CS:APP3e 8.5 节指出的未定义行为 */
int mode_volatile(void)
{
int i = 1; /* 非 volatile:可能被放进寄存器 */
volatile int j = 1; /* volatile:强制每次访问都去内存 */
register int k = 1;
if (setjmp(vol_env) == 0) {
i = 2; j = 2; k = 2; /* setjmp 之后修改 */
vol_inner(); /* 里面 longjmp(vol_env, 7) */
}
printf("i(非volatile)=%d j(volatile)=%d k(register)=%d\n", i, j, k);
return 0;
}
实测输出(同一份源码,只改优化级别):
$ ./ecf_c_O0 volatile
[volatile] longjmp 之后: i(非volatile)=2 j(volatile)=2 k(register)=2
$ ./ecf_c_O2 volatile
[volatile] longjmp 之后: i(非volatile)=1 j(volatile)=2 k(register)=1
这行输出最值得凝视:-O0 下 i 与 k 都是 2,一切”看起来正确”;-O2 下它们倒退回了 setjmp 之前的值 1,只有 volatile 的 j 始终是 2。对应的真实汇编(gcc -S -O2)给出了常量折叠的直接证据:
mode_volatile:
subq $24, %rsp
movl $vol_env, %edi
movl $1, 12(%rsp) # j = 1,并且【只】存在内存里
call _setjmp
testl %eax, %eax
je .L6
movl 12(%rsp), %edx # 从内存读回 j —— 一定是 2
movl $1, %ecx # k 直接折叠成常量 1
movl $1, %esi # i 直接折叠成常量 1
...
.L6:
movl $7, %esi
movl $vol_env, %edi
movl $2, 12(%rsp) # 只写回 j;i 与 k 根本没进内存
call longjmp
【与汇编 / 硬件的对应】 i 与 k 之所以”倒退”,是因为编译器认定其值恒为 1(setjmp 返回 0 的路径上 i=2 之后必然 longjmp 离开),于是把两者折叠成立即数,不分配内存位置。volatile 的 j 被强制放到 12(%rsp),而 longjmp 恰好恢复了 %rsp,故它读到的仍是 2。结论:跨越 setjmp/longjmp 的局部变量必须声明为 volatile。
sigsetjmp/siglongjmp:第三个模式从 SIGALRM 处理程序里跳出。实测:
[sigjmp] 安装好 SIGALRM 处理程序,alarm(1) 后进入 pause()
[sigjmp] 从信号处理程序 siglongjmp 回来,jumped=1
[sigjmp] sigsetjmp 的第二参数为 1,故信号屏蔽字已被恢复
17.3.4 信号速查表
| ID | 名称 | 默认动作 | 对应事件 | 可否捕获/忽略/阻塞 |
|---|---|---|---|---|
| 1 | SIGHUP | Terminate | 终端挂断 | 可 |
| 2 | SIGINT | Terminate | 用户按 ctrl-c | 可 |
| 3 | SIGQUIT | Terminate + core | 用户按 ctrl-\ | 可 |
| 4 | SIGILL | Terminate + core | 非法指令 | 可 |
| 5 | SIGTRAP | Terminate + core | 断点/陷阱 | 可 |
| 6 | SIGABRT | Terminate + core | abort() | 可 |
| 7 | SIGBUS | Terminate + core | 总线错误 | 可 |
| 8 | SIGFPE | Terminate + core | 除零/算术错误 | 可 |
| 9 | SIGKILL | Terminate | 强杀 | 不可(EINVAL) |
| 10 | SIGUSR1 | Terminate | 用户自定义 | 可 |
| 11 | SIGSEGV | Terminate + core | 段错误 | 可 |
| 12 | SIGUSR2 | Terminate | 用户自定义 | 可 |
| 13 | SIGPIPE | Terminate | 写已关闭的管道 | 可 |
| 14 | SIGALRM | Terminate | alarm() 定时器到期 | 可 |
| 15 | SIGTERM | Terminate | 温和终止请求 | 可 |
| 16 | SIGSTKFLT | Terminate | 协处理器栈错误 | 可 |
| 17 | SIGCHLD | Ignore | 子进程停止或终止 | 可 |
| 18 | SIGCONT | Continue | 恢复被停止的进程 | 可 |
| 19 | SIGSTOP | Stop | 强制停止 | 不可(EINVAL) |
| 20 | SIGTSTP | Stop | 用户按 ctrl-z | 可 |
| 21 | SIGTTIN | Stop | 后台进程读终端 | 可 |
| 22 | SIGTTOU | Stop | 后台进程写终端 | 可 |
| 23 | SIGURG | Ignore | socket 紧急数据 | 可 |
| 24 | SIGXCPU | Terminate + core | CPU 时间超限 | 可 |
| 25 | SIGXFSZ | Terminate + core | 文件大小超限 | 可 |
| 26 | SIGVTALRM | Terminate | 虚拟定时器 | 可 |
| 27 | SIGPROF | Terminate | 性能剖析定时器 | 可 |
| 28 | SIGWINCH | Ignore | 终端窗口大小改变 | 可 |
| 29 | SIGIO | Terminate | 异步 I/O 就绪 | 可 |
| 30 | SIGPWR | Terminate | 电源故障 | 可 |
实测确认:sigaction(SIGKILL) 与 sigaction(SIGSTOP) 均返回 −1 且 errno = 22 (EINVAL);SIGINT=2, SIGQUIT=3, SIGALRM=14, SIGCHLD=17, SIGCONT=18, SIGTSTP=20 与表格一致。
17.3.5 异步信号安全函数清单
定义:函数异步信号安全(async-signal-safe),当且仅当它要么可重入(所有变量都在栈帧上,CS:APP3e 12.7.2),要么不可被信号打断。POSIX 保证 117 个函数满足该性质(man 7 signal-safety)。
| ✅ 安全(可在处理程序中调用) | ❌ 不安全(严禁在处理程序中调用) |
|---|---|
write(唯一安全的输出函数) | printf / fprintf(stdio 缓冲区非可重入) |
_exit / _Exit | exit(会跑 atexit 与 stdio 清理) |
read、open、close、dup2、lseek | malloc / free(堆状态可能被撕裂) |
wait、waitpid、sleep、alarm、pause | sprintf / snprintf(依赖 locale 与缓冲) |
kill、raise、sigqueue | strerror、getpwnam(静态缓冲区) |
sigprocmask、sigpending、sigsuspend | readdir、localtime、asctime(静态缓冲区) |
sigaction、signal 与 sigemptyset/addset/fillset/delset/ismember | setenv / putenv(修改全局环境) |
fork、execve、setpgid、getpid、getpgrp | rand / srand(全局种子状态) |
strlen、strcmp、strcpy、memcpy、memset、memmove | stdout/stderr 的任何 f* 系列 |
longjmp、siglongjmp(见注释) | pthread_* 中未列入清单者、dlopen |
注意:longjmp/siglongjmp 自 POSIX.1-2008 TC2 起列入安全清单,但有重要限定——若处理程序中断的正是某个非异步信号安全函数,从处理程序 longjmp 出去行为未定义;setjmp 本身不在清单里。实用替代:CS:APP 的 csapp.c 提供可重入安全 I/O 库(SIO):sio_puts、sio_putl、sio_error,以及支持受限格式串(%c %s %d %u %x %%)的 sio_printf;它们内部只用 write。
17.3.6 显式等待信号:pause、sigsuspend
讲义用四种写法对比”如何等一个信号”,这段对照是本章精华:
| 写法 | 是否浪费 CPU | 是否有竞态 | 响应延迟 |
|---|---|---|---|
while (!pid) ; | ★ 忙等待,疯狂占用 CPU | 无 | 立即 |
while (!pid) sleep(1); | 不忙等 | 无 | 最坏 1 秒(太慢) |
while (!pid) pause(); | 不忙等(挂起) | 有竞态! | 立即 |
while (!pid) sigsuspend(&prev); | 不忙等 | 无 | 立即 |
pause 的竞态在于:检查 !pid 为真之后、调用 pause() 之前信号可能恰好到达——处理程序已把 pid 置为非零,但 pause() 仍会挂起,程序永远醒不过来。int sigsuspend(const sigset_t *mask) 是下述序列的原子(不可中断)版本:”挂起之前先解除目标信号的阻塞,且两步之间不存在可被信号插入的缝隙”:
sigprocmask(SIG_SETMASK, &mask, &prev); /* 换掩码 */
pause(); /* 挂起 */
sigprocmask(SIG_SETMASK, &prev, NULL); /* 恢复掩码 */
正确用法:主流程先阻塞 SIGCHLD,等待循环里用 sigsuspend(&prev) 临时解除它——信号只可能在 sigsuspend 内部到达,绝不会丢失。
主流程: sigprocmask(SIG_BLOCK, &mask, &prev) ← SIGCHLD 进不了
fork()
pid = 0
while (!pid)
sigsuspend(&prev) ← 原子地:解除阻塞 + 挂起
(信号在此之内到达 → 处理程序置 pid → 返回 → sigsuspend 返回 → 循环退出)
sigprocmask(SIG_SETMASK, &prev, NULL) ← 恢复
17.4 实验关联
本讲与 L6 Shell Lab(tshlab) 一一对应:要写出 tsh 的 7 个函数 eval、builtin_cmd、do_bgfg、waitfg、sigchld_handler、sigint_handler、sigtstp_handler。评分按 16 个 trace 各 5 分计 80 分正确性 + 10 分风格分(注释 5 分 + 每个系统调用都查返回值 5 分)。
必须做对的四件事:
sigchld_handler的正确写法:用while循环 +WNOHANG \| WUNTRACED收干净,循环体内用sigprocmask/SIG_SETMASK保护 job 列表,进出各存/恢复一次errno(见 17.3.2)。一个 trace 里可能只有一次SIGCHLD递送却对应多个退出/停止的子进程——这正是 trace16 与mysplit系列要考的。SIGINT/SIGTSTP处理程序向前台进程组转发:必须写kill(-fg_pgid, SIGINT),用负号;后台作业在fork后execve前必须setpgid(0, 0),否则 ctrl-c 会同时打到 shell 自己(sdriver.pl专门检测这一错误)。无前台作业时信号应无效果。waitfg用sigsuspend等待:当下 recitation 明确把”紧密循环”列为 禁止 做法。历史版 writeup 曾建议”waitfg忙循环sleep、sigchld_handler里只调一次waitpid“,那与竞态二直接冲突——以sigsuspend+while循环为准。jobs输出顺序:参考输出tshref.out中按 JID 升序列出,格式[jid] (pid) Running ./cmd &/[jid] (pid) Stopped ./cmd &;fg转前台先打印[jid] (pid) cmd;终止打印Job [jid] (pid) terminated by signal 2,停止打印Job [jid] (pid) stopped by signal 20。字符串必须逐字符与tshref一致。
从 trace01.txt 起逐个推进,每步用 make test01 与 make rtest01 对比。不要用 more/less/vi/emacs 测试(会改终端设置);用 /bin/ls、/bin/ps、/bin/echo 与 ./myspin、./mysplit、./mystop、./myint。
17.5 常见错误与调试技巧
SIGCHLD被”忽略”或一直阻塞:waitpid收不到通知,僵尸堆积、jobs列出早已消失的作业。调试:ps -o pid,ppid,stat,comm -l看Z状态与<defunct>;strace -f -e trace=wait4,kill,rt_sigprocmask,rt_sigaction ./tsh。fork与addjob之间的竞态(幽灵 job):jobs里有 PID 早已死亡的条目;复现见 17.3.2 的race模式。调试:在deletejob失败分支用write打印 PID,观察”NOT in job list”。- 处理程序里调用
printf:偶发的输出错乱、死锁或崩溃——stdio 缓冲区被重入。调试:gdb在sigchld_handler打断点,bt看是否落在_IO_file_xsputn内部;改用sio_puts/write。 - 忘记
volatile sig_atomic_t:-O2下while (!pid) ;变成死循环(pid被缓存在寄存器)。调试:objdump -d -M intel ./tsh \| grep -A20 '<waitfg>'看循环体是否每次从内存重载。 longjmp后的局部变量”倒退”:错误码丢失、循环计数复位。调试:-O0与-O2各编一份对比(本例即如此暴露),用volatile修正。alarm覆盖或只响一次:周期性定时只触发一次,或返回值被丢掉导致计时混乱。调试:strace -e trace=alarm,rt_sigaction,rt_sigreturn ./prog。- ctrl-c 打到了 shell 自己:子进程没
setpgid(0, 0),与 shell 同组。调试:ps -o pid,pgid,tpgid,comm看 STAT 的+(前台进程组)标记。 SIG_UNBLOCK破坏嵌套:解除后外层原本的屏蔽状态被永久丢失。调试:打印sigprocmask的oldset比对;统一改用SIG_SETMASK+ 保存的prev。
17.6 关键要点
- 信号只是一条无载荷的小消息,全部信息就是”哪个信号号 + 它到了”;发送只是内核在目标进程的
pending位向量里置一位,递送时机由内核在返回用户态前用pnb = pending & ~blocked判定。 - 信号不排队,每种至多一位:同类型多次到达会被合并且丢弃——绝不能用信号计数事件,必须用
while (waitpid(..., WNOHANG))把状态清干净。 SIGKILL与SIGSTOP是内核的底线:不可捕获、不可忽略、不可阻塞;其余 28 种信号都可被程序接管。- 处理程序是与主程序并发的独立逻辑流,必须遵守 G0–G5:尽量简单、只调用异步信号安全函数(
write是唯一安全的输出)、保存恢复errno、用sigprocmask保护共享数据、全局变量加volatile sig_atomic_t。 - 凡是”检查标志再阻塞”的写法都有竞态;正确姿势是”先阻塞信号 → 做临界区工作 → 用
sigsuspend原子地解除并等待”,Shell Lab 的fork/addjob与waitfg都遵循这一模式。 longjmp只恢复寄存器,不做任何清理:中间栈帧的资源会泄漏,跨越setjmp的局部变量必须volatile;从处理程序跳出要用sigsetjmp/siglongjmp以正确恢复信号掩码。
17.7 思考题(带答案)
Q1(推演题) 执行顺序:① sigprocmask(SIG_BLOCK, {SIGCHLD}, &prev);② fork 出 C;③ C 立刻 _exit(0);④ 父进程 addjob(C);⑤ 再 fork 出 D,D 也立刻 _exit(0);⑥ sigprocmask(SIG_SETMASK, &prev, NULL) 解除阻塞;⑦ 进入 while (!done) sigsuspend(&prev);。SIGCHLD 处理程序运行几次?共回收几个?若只调用一次 waitpid,还剩几个僵尸?
答:C 与 D 的 SIGCHLD 在阻塞期间只是把 pending 第 17 位置 1,故处理程序只运行 1 次(两次事件合并)。用 while (waitpid(-1, NULL, WNOHANG) > 0) 这一次运行即可回收两个,剩余 0 个僵尸;只调用一次 waitpid 则只回收一个,另一个永远成为僵尸。第三个陷阱:若第 ⑥ 步在第 ⑦ 步判断之后,解除阻塞与 sigsuspend 之间就有窗口;sigsuspend(&prev) 原子地解除并挂起才能消除它。另需注意:若 done 靠”处理程序调用次数”累加,合并会使它永远达不到 2 而挂死——必须改用”是否还有未回收的子进程”这类状态量。
Q2(”直观但错误”题) 有同学说:”volatile int counter = 0; 配上 void handler(int s) { counter++; },fork 10 个子进程后 while (counter < 10); 等待,程序一定能正常结束。”错在哪?
答:一是计数假设错了:counter 要到 10 必须处理程序被调用 10 次,而 SIGCHLD 不排队——10 个几乎同时退出的子进程可能只引起 1 次递送,主循环死等。二是 counter++ 不是原子操作。正确做法:用 volatile sig_atomic_t 并把子进程收干净,主程序用 sigsuspend 而非忙等。
Q3(计算/推演题) shell 启动 3 个后台作业,分别在 1s、2s、2.05s 退出;主程序全程未阻塞 SIGCHLD,处理程序用 while (waitpid(-1, NULL, WNOHANG) > 0)。处理程序被调用几次?每次回收几个?
答:3 次,每次回收 1 个。三者间隔远大于”内核从 _exit 到父进程被调度回来处理信号”的延迟,pending 位每次都被及时消费并重新置位,没有合并。对比 loop 模式:5 个子进程在 60ms 内密集退出且父进程尚未获得 CPU,pending 位一直未消费,5 次事件塌缩成 1 次递送。处理程序调用次数 = 时间上足够分散的事件数;回收数 = 真实的终止子进程数。
Q4 setjmp/longjmp 与 C++ 的 throw/catch 都能跨栈帧传递控制,为何后者不会泄漏资源?
答:C++ 异常会执行栈展开(stack unwinding),逐个销毁中间栈帧的自动对象;longjmp 则只写回 %rsp/%rip 与若干寄存器,中间栈帧直接作废。补救:把待释放资源集中登记在跳转目标那一层,返回后统一释放。longjmp 只搬控制流,不搬所有权。