Lecture 17: 异常控制流——信号与非本地跳转 (Exceptional Control Flow: Signals and Nonlocal Jumps)

目录 · ← l16 · l18 →

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(信号、非本地跳转、进程控制工具) 关联 LabL6 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 并置 errnopid 的符号决定目标集合:
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 安装处理程序:signalsigaction

  • 定义与目的:安装(install)处理程序即修改某个信号号的默认动作。signal(int, handler_t *) 是历史接口;sigaction(int signum, const struct sigaction *act, struct sigaction *oldact) 是现代接口,可精确控制标志,oldact 非空时写回原先的处理方式。三个特殊常量:SIG_IGN(忽略)、SIG_DFL(恢复默认)、用户函数地址(捕获)。
  • sa_flags 常用位
标志含义
SA_RESTART自动重启被该信号打断的慢速系统调用(如 readwait
SA_NODEFER处理程序运行期间自动阻塞自身(默认阻塞,见 17.2.3)
SA_RESETHAND进入处理程序时把动作重置为 SIG_DFL(只生效一次)
SA_SIGINFO改用三参数 sa_sigaction,可取得 siginfo_t 与上下文
SA_ONSTACK在备用信号栈(sigaltstack)上运行处理程序

实测 SA_RESTART 数值为 0x10000000signal 不可移植:不同系统语义不同——有的在一次触发后把处理程序重置为默认(System V 风格),有的自动重启系统调用;可移植代码一律使用 sigaction。三个可移植性差异点——① 触发后是否重置处理程序;② 运行期间是否自动阻塞自身;③ 被中断的系统调用是否自动重启——分别由 SA_RESETHANDSA_NODEFERSA_RESTART 显式决定。

17.2.6 阻塞信号与 sigprocmask

  • 接口int sigprocmask(int how, const sigset_t *set, sigset_t *oldset)
how含义
SIG_BLOCKset 中的信号加入当前掩码(屏蔽)
SIG_UNBLOCKset 中的信号从掩码中移除
SIG_SETMASK把当前掩码替换set
  • 集合操作sigemptysetsigfillsetsigaddsetsigdelsetsigismember;另有 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_buflongjmp(env, retval) 恢复该上下文——效果是直接掀掉中间所有栈帧跳回 setjmp 处。直观上像夹一张书签后决定”直接翻回那一页、跳过中间所有内容”。
  • 返回值语义setjmp 直接返回 0;从 longjmp 返回时”返回”的是其第二个参数 retval,故 retval 必须非 0setjmp 只能出现在 ifswitch、循环条件或比较表达式中,不能写成 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)
  • 两个必须记住的陷阱
    1. 资源泄漏longjmp 不释放中间栈帧——像撕掉书中页码,夹在里面的借书卡却还在。中间层 malloc 的内存、打开的 fd 都不被清理。
    2. 变量值不确定(UB):在 setjmplongjmp 之间被修改的局部变量,若被分配到寄存器(而 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;
}

【代码做什么?】installsigactionSIGINT 装”忽略但计数”、给 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=0x10000000SA_RESTART 的真实数值;SIGQUITflags=0x0 不重启系统调用。

17.3.2 示例 (b):SIGCHLD 的两个经典竞态——正确版与错误版对比

这是 Shell Lab 的核心考点。完整源码见 /tmp/csapp17/ecf_b_sigchld.c,用 ./ecf_b_sigchld <race\|racefix\|once\|loop> 切换四种模式。

竞态一:forkaddjob 之间的窗口。 讲义 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 列表干净、僵尸收干净

onceloop 的对比把”信号不排队”体现得淋漓尽致: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> 切换模式。

深层跳转mainsetjmp 设置落点,依次调用 level1 → level2 → level3 → level4,在 level4longjmp(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_buflongjmp 把这些寄存器原样写回,于是 %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

这行输出最值得凝视-O0ik 都是 2,一切”看起来正确”;-O2 下它们倒退回了 setjmp 之前的值 1,只有 volatilej 始终是 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

【与汇编 / 硬件的对应】 ik 之所以”倒退”,是因为编译器认定其值恒为 1(setjmp 返回 0 的路径上 i=2 之后必然 longjmp 离开),于是把两者折叠成立即数,不分配内存位置。volatilej 被强制放到 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名称默认动作对应事件可否捕获/忽略/阻塞
1SIGHUPTerminate终端挂断
2SIGINTTerminate用户按 ctrl-c
3SIGQUITTerminate + core用户按 ctrl-\
4SIGILLTerminate + core非法指令
5SIGTRAPTerminate + core断点/陷阱
6SIGABRTTerminate + coreabort()
7SIGBUSTerminate + core总线错误
8SIGFPETerminate + core除零/算术错误
9SIGKILLTerminate强杀不可EINVAL
10SIGUSR1Terminate用户自定义
11SIGSEGVTerminate + core段错误
12SIGUSR2Terminate用户自定义
13SIGPIPETerminate写已关闭的管道
14SIGALRMTerminatealarm() 定时器到期
15SIGTERMTerminate温和终止请求
16SIGSTKFLTTerminate协处理器栈错误
17SIGCHLDIgnore子进程停止或终止
18SIGCONTContinue恢复被停止的进程
19SIGSTOPStop强制停止不可EINVAL
20SIGTSTPStop用户按 ctrl-z
21SIGTTINStop后台进程读终端
22SIGTTOUStop后台进程写终端
23SIGURGIgnoresocket 紧急数据
24SIGXCPUTerminate + coreCPU 时间超限
25SIGXFSZTerminate + core文件大小超限
26SIGVTALRMTerminate虚拟定时器
27SIGPROFTerminate性能剖析定时器
28SIGWINCHIgnore终端窗口大小改变
29SIGIOTerminate异步 I/O 就绪
30SIGPWRTerminate电源故障

实测确认: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 / _Exitexit(会跑 atexit 与 stdio 清理)
readopenclosedup2lseekmalloc / free(堆状态可能被撕裂)
waitwaitpidsleepalarmpausesprintf / snprintf(依赖 locale 与缓冲)
killraisesigqueuestrerrorgetpwnam(静态缓冲区)
sigprocmasksigpendingsigsuspendreaddirlocaltimeasctime(静态缓冲区)
sigactionsignalsigemptyset/addset/fillset/delset/ismembersetenv / putenv(修改全局环境)
forkexecvesetpgidgetpidgetpgrprand / srand(全局种子状态)
strlenstrcmpstrcpymemcpymemsetmemmovestdout/stderr 的任何 f* 系列
longjmpsiglongjmp(见注释)pthread_* 中未列入清单者、dlopen

注意longjmp/siglongjmp 自 POSIX.1-2008 TC2 起列入安全清单,但有重要限定——若处理程序中断的正是某个异步信号安全函数,从处理程序 longjmp 出去行为未定义;setjmp 本身不在清单里。实用替代:CS:APP 的 csapp.c 提供可重入安全 I/O 库(SIO):sio_putssio_putlsio_error,以及支持受限格式串(%c %s %d %u %x %%)的 sio_printf;它们内部只用 write

17.3.6 显式等待信号:pausesigsuspend

讲义用四种写法对比”如何等一个信号”,这段对照是本章精华:

写法是否浪费 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 个函数 evalbuiltin_cmddo_bgfgwaitfgsigchld_handlersigint_handlersigtstp_handler。评分按 16 个 trace 各 5 分计 80 分正确性 + 10 分风格分(注释 5 分 + 每个系统调用都查返回值 5 分)。

必须做对的四件事

  1. sigchld_handler 的正确写法:用 while 循环 + WNOHANG \| WUNTRACED 收干净,循环体内用 sigprocmask/SIG_SETMASK 保护 job 列表,进出各存/恢复一次 errno(见 17.3.2)。一个 trace 里可能只有一次 SIGCHLD 递送却对应多个退出/停止的子进程——这正是 trace16 与 mysplit 系列要考的。
  2. SIGINT/SIGTSTP 处理程序向前台进程组转发:必须写 kill(-fg_pgid, SIGINT)用负号;后台作业在 forkexecve 前必须 setpgid(0, 0),否则 ctrl-c 会同时打到 shell 自己(sdriver.pl 专门检测这一错误)。无前台作业时信号应无效果
  3. waitfgsigsuspend 等待:当下 recitation 明确把”紧密循环”列为 禁止 做法。历史版 writeup 曾建议”waitfg 忙循环 sleepsigchld_handler 里只调一次 waitpid“,那与竞态二直接冲突——sigsuspend + while 循环为准
  4. 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 test01make 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 -lZ 状态与 <defunct>strace -f -e trace=wait4,kill,rt_sigprocmask,rt_sigaction ./tsh
  • forkaddjob 之间的竞态(幽灵 job)jobs 里有 PID 早已死亡的条目;复现见 17.3.2 的 race 模式。调试:在 deletejob 失败分支用 write 打印 PID,观察”NOT in job list”。
  • 处理程序里调用 printf:偶发的输出错乱、死锁或崩溃——stdio 缓冲区被重入。调试gdbsigchld_handler 打断点,bt 看是否落在 _IO_file_xsputn 内部;改用 sio_puts/write
  • 忘记 volatile sig_atomic_t-O2while (!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 破坏嵌套:解除后外层原本的屏蔽状态被永久丢失。调试:打印 sigprocmaskoldset 比对;统一改用 SIG_SETMASK + 保存的 prev

17.6 关键要点

  1. 信号只是一条无载荷的小消息,全部信息就是”哪个信号号 + 它到了”;发送只是内核在目标进程的 pending 位向量里置一位,递送时机由内核在返回用户态前用 pnb = pending & ~blocked 判定。
  2. 信号不排队,每种至多一位:同类型多次到达会被合并且丢弃——绝不能用信号计数事件,必须用 while (waitpid(..., WNOHANG)) 把状态清干净。
  3. SIGKILLSIGSTOP 是内核的底线:不可捕获、不可忽略、不可阻塞;其余 28 种信号都可被程序接管。
  4. 处理程序是与主程序并发的独立逻辑流,必须遵守 G0–G5:尽量简单、只调用异步信号安全函数(write 是唯一安全的输出)、保存恢复 errno、用 sigprocmask 保护共享数据、全局变量加 volatile sig_atomic_t
  5. 凡是”检查标志再阻塞”的写法都有竞态;正确姿势是”先阻塞信号 → 做临界区工作 → 用 sigsuspend 原子地解除并等待”,Shell Lab 的 fork/addjobwaitfg 都遵循这一模式。
  6. 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 只搬控制流,不搬所有权。