1.8 异常控制流:进程与信号
程序的下一条指令为什么不一定来自 jmp/call?一次缺页、一个 Ctrl-C、一次 fork,怎样让 CPU 从用户代码跳进内核,再回到另一条路径?信号为什么总在最坏的时机打断系统调用?
区分中断、故障、陷阱、终止四类异常;写出对 EINTR、僵尸进程、SIGCHLD 合并都正确的父子进程代码;用 sigsuspend 消除信号竞态;解释作业控制与会话的关系。
到目前为止,你看到的控制流都是程序自己安排的:顺序、分支、循环、函数调用。CPU 的 %rip 沿着编译器铺好的轨道走。但真实系统里,轨道会被外力掰弯:网卡收到一个包、定时器到期、进程访问了不在内存的页、用户按下 Ctrl-C、子进程退出。这些事件发生时,CPU 必须暂停当前指令流,跳到一个内核预先注册的入口。
这类「不是由当前指令直接引起的控制转移」叫异常控制流(exceptional control flow, ECF)。它有三个层次:硬件异常(CPU 与内核交接)、进程(内核给每个程序一个独立执行上下文)、信号(内核把异步事件通知给进程)。三者层层嵌套:异常是机制,进程是容器,信号是接口。
1.1.4 已经讲过 fork/exec/wait 的基本形态和五状态表。这一章不再重复那些入门内容,而是补三块硬骨头:异常分类与内核入口、信号处理的竞态与可重入性、非本地跳转与作业控制。读完这一章,你应该能解释 strace 输出里每一个 --- SIGCHLD ---,也能写出不会被信号打断出错的代码。
1.8.1 异常:CPU 与内核的交接点
异常是 CPU 在执行指令时检测到某种事件,暂停当前控制流,转去执行内核中与该事件对应的处理程序,处理完后再决定返回哪里。每个异常有一个编号(向量),内核在启动时把处理程序地址填进中断描述符表(interrupt descriptor table, IDT)。异常可以是同步的(由当前指令引起),也可以是异步的(由外部设备引起)。
CSAPP 把异常分成四类,这个分类的关键是返回地址:
| 类别 | 触发方式 | 同步/异步 | 返回地址 | 例子 |
|---|---|---|---|---|
| 中断(interrupt) | 外部设备发信号 | 异步 | 下一条指令 | 网卡收包、定时器、键盘 |
| 故障(fault) | 指令执行失败,可能可修复 | 同步 | 当前指令(重试) | 缺页、段错误、除零 |
| 陷阱(trap) | 指令有意触发 | 同步 | 下一条指令 | int3、syscall、into |
| 终止(abort) | 不可恢复的硬件错误 | 同步 | 不返回 | 机器检查、总线错误 |
「返回当前指令」是故障与陷阱的分水岭。缺页处理程序分配物理页、填好页表,然后 iret 回到同一条 mov 重新执行,这次就成功了;系统调用是陷阱,内核执行完服务例程后返回到 syscall 的下一条指令。若故障无法修复,内核不会返回,而是把异常转成信号或直接终止进程。
x86-64 的 IDT 有 256 个表项,每项 16 字节,记录处理程序的段选择子、偏移和类型。前 32 个向量由 CPU 保留:
| 向量 | 名称 | 内核通常转成 |
|---|---|---|
| 0 | 除零 | SIGFPE |
| 3 | 断点(int3) | SIGTRAP(调试器) |
| 6 | 非法指令 | SIGILL |
| 13 | 通用保护故障 | SIGSEGV |
| 14 | 缺页 | SIGSEGV/SIGBUS,或内核自己处理 |
| 128 (0x80) | 传统系统调用 | 系统调用入口 |
缺页的出错地址不在指令里,而在控制寄存器 %cr2;出错码在栈上,用位标记「读/写/用户态/保护违规」。这也是为什么段错误日志里能打印 SEGV_MAPERR 或 SEGV_ACCERR:内核把缺页的细节翻译成了 siginfo_t。
dmesg | tail -5
# [12345.678] traps: demo[4242] general protection fault ip:4011a0 ...
strace -f -e trace=signal ./demo
第一行是内核日志,第二行是 strace 观察到的信号递送。两者能对上:CPU 异常 → 内核处理 → 若无法修复,向进程发送信号 → 进程的信号处理或默认动作。
函数调用只能跳到同一特权级、同一地址空间的代码。用户态不能直接执行内核代码,因为那会绕过权限检查。陷阱指令(syscall/int 0x80)由硬件完成三件事:切换到内核栈、切换到 ring 0、跳到 IDT 里注册的入口。内核入口再保存用户寄存器、按调用号分派。特权级切换是硬件强制的,不是软件约定。
1.8.2 进程:异常控制流的容器
异常处理程序需要一个「当前进程」的上下文才能工作:它要访问这个进程的页表、文件描述符、信号处理表、凭证。Linux 用 task_struct 把这些东西捆在一起。关键字段可以按「异常处理需要什么」来记:
| 字段 | 作用 | 异常/信号里的用途 |
|---|---|---|
mm | 地址空间、页表基址 | 缺页处理、copy_to_user |
files | fd 表 | 信号处理程序里 write |
sighand / signal | 信号处理表、阻塞掩码、pending | 递送信号、决定默认动作 |
cred | uid/gid/capabilities | 权限检查、kill 许可 |
thread_struct | 寄存器、%rsp、%cr3 | 上下文切换 |
__state | 运行/睡眠/停止/僵尸 | 调度与唤醒 |
exit_state / exit_code | 退出状态 | wait 回收 |
1.1.4 已给出五状态表。这里只补充信号如何改变状态:SIGSTOP 把进程从任何可中断状态推入 TASK_STOPPED,SIGCONT 把它放回运行队列;SIGKILL 不可捕获、不可阻塞,直接把进程推向退出;SIGSEGV 的默认动作是终止并产生 core;SIGCHLD 把父进程从睡眠唤醒,让它去 wait。图中 TASK_TRACED 是 ptrace 专用状态,gdb 的断点就停在这里。
ps 的 STAT 列把这些状态压缩成字母:
ps -eo pid,ppid,stat,wchan:20,cmd | head
# STAT R=运行 S=可中断睡眠 D=不可中断睡眠 T=停止 Z=僵尸
# 附加:s=会话首进程 l=多线程 +=前台进程组
D 状态值得单独警惕:它不响应信号,kill -9 也要等内核把 I/O 完成。大量 D 进程通常意味着存储或网络文件系统卡住,而不是进程本身有问题。
每个进程还有一套内核栈。x86-64 上通常是 16 KiB(4 个 4 KiB 页),进程在系统调用或异常里执行时使用它;用户栈和内核栈是两个独立的栈指针。上下文切换时,内核保存的是「被调用者保存寄存器」(%rbx、%rbp、%r12–%r15)、%rsp,以及 %rip(由 switch_to 的调用/返回隐含);浮点/SIMD 状态按需保存。切换页表基址 %cr3 会让 TLB 失效,PCID(process-context identifier)允许内核给不同进程的 TLB 项打标签,减少切换时的冲刷。一次上下文切换的成本不只是几百条指令,还包括缓存和 TLB 工作集的污染。
1.8.3 fork/exec/wait 的边界
这三个系统调用是进程控制的主干。基本用法在 1.1.4 已经出现,这里只讲容易踩的边界。
fork 复制了什么,没复制什么
fork() 成功后,子进程得到父进程地址空间的逻辑副本:页表被复制,页表项指向同一批物理页并被标记为只读;任一方写入触发写保护缺页,内核才复制那一页。这就是写时复制(copy-on-write, COW)。COW 让 fork 的成本主要落在页表复制上,而不是内存复制。
复制的内容包括:fd 表(表项指向同一个 struct file,所以文件偏移共享)、信号处理表、阻塞掩码、当前工作目录、umask、资源限制。不复制的内容包括:子进程的 pending 信号被清空、定时器不继承、父进程的锁状态不被继承(多线程下尤其危险)、fork 返回值不同。
exec 保留什么,重置什么
execve 用新程序替换当前进程的地址空间、寄存器、栈,但保留:
- pid、ppid、进程组、会话;
- 真实 uid/gid、已打开且未设
FD_CLOEXEC的 fd; - 被显式设为
SIG_IGN的信号处置(忽略继续忽略); - 被捕获的信号处置重置为默认(因为旧的处理函数地址在新地址空间里没有意义);
- 阻塞的信号掩码。
所以「父进程忽略 SIGPIPE,子进程 exec 后仍忽略」是 POSIX 规定的行为;而自定义的 SIGINT 处理函数在 exec 后消失。FD_CLOEXEC 是防止 fd 泄漏的关键,打开文件时应默认加 O_CLOEXEC。
wait 的状态与僵尸
子进程退出后不会立刻消失:内核保留它的退出状态、资源统计,等父进程 wait 读取。这个「已死但未回收」的状态就是僵尸(zombie)。父进程先于子进程退出时,子进程被 init(或最近的 subreaper)收养,不会永久僵尸。
waitpid 的状态是一个 16 位整数,必须用宏解析,不能直接读:
if (WIFEXITED(status))
printf("exit %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("signal %d, core=%d\n", WTERMSIG(status), WCOREDUMP(status));
else if (WIFSTOPPED(status))
printf("stopped by %d\n", WSTOPSIG(status));
下面的程序把 fork、exec、wait 的边界一次走完:
/* spawn.c:fork + exec + wait,处理 EINTR 与 exec 失败 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *what)
{
fprintf(stderr, "%s: %s\n", what, strerror(errno));
exit(EXIT_FAILURE);
}
int main(void)
{
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
/* 子进程:exec 成功后不返回;失败必须 _exit,避免刷新父进程的 stdio 缓冲 */
execlp("sh", "sh", "-c", "echo child pid=$$; exit 7", (char *)NULL);
fprintf(stderr, "execlp: %s\n", strerror(errno));
_exit(127);
}
int status;
for (;;) {
pid_t r = waitpid(pid, &status, 0);
if (r == pid)
break;
if (r == -1 && errno == EINTR)
continue; /* 被信号打断,重试 */
die("waitpid");
}
if (WIFEXITED(status))
printf("parent: child %d exited with %d\n", (int)pid, WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("parent: child %d killed by %d\n", (int)pid, WTERMSIG(status));
return 0;
}
预期输出里子进程打印自己的 pid,父进程打印 exited with 7。注意子进程失败时用 _exit(127) 而不是 exit(127):exit 会刷新从父进程继承来的 stdio 缓冲区,可能把父进程尚未写出的内容重复打印一次。
- 「
fork后父子进程的 fd 偏移独立」——不独立。fork复制的是 fd 表,表项指向同一个打开文件描述,f_pos共享。要独立偏移,各自open一次。 - 「子进程退出后立刻消失」——退出后先进入僵尸,等父进程
wait才释放task_struct。长期不wait的服务会积累僵尸。 - 「
exec会保留信号处理函数」——捕获的处理函数被重置为默认;只有SIG_IGN会保留。别指望在 exec 后的程序里继续用父进程的 handler。 - 「
wait只能等一个固定 pid」——waitpid(-1, ...)等任意子进程,waitid提供更细的选项;WNOHANG可以非阻塞轮询。 - 「
system()等价于 fork+exec」——system还会 fork 一个 shell,信号处理、返回值编码、SIGINT的忽略都不同;安全敏感场景不要用system拼接命令。
1.8.4 信号:产生、阻塞、递送
信号是内核向进程通知异步事件的方式。它像一个「软件中断」,但语义比中断复杂得多:信号有生成、挂起(pending)、阻塞(blocked)、递送(delivery)四个阶段,还有处置方式(disposition)和排队规则。
信号产生后进入目标进程(或线程)的 pending 集合;若该信号当前被阻塞,它留在 pending 集合里,直到解除阻塞;解除阻塞后,内核在进程下一次从内核态返回用户态时递送它,执行处置:默认动作、忽略,或调用用户注册的处理函数。标准信号(1–31)不排队,同一信号在 pending 期间再次产生只记一次;实时信号(SIGRTMIN–SIGRTMAX)排队并带值。
标准信号「不排队」是很多 bug 的根源。父进程处理 SIGCHLD 时,如果两个子进程几乎同时退出,内核只保证至少有一个 SIGCHLD 处于 pending;处理函数只调用一次 waitpid,就会留下一个僵尸。正确写法是循环 waitpid(-1, ..., WNOHANG) 直到返回 0 或 -1。
常见信号的默认动作:
| 信号 | 默认动作 | 典型来源 | 能否捕获/阻塞 |
|---|---|---|---|
SIGINT | 终止 | Ctrl-C | 能 |
SIGQUIT | 终止 + core | Ctrl-\ | 能 |
SIGTERM | 终止 | kill、systemd | 能 |
SIGKILL | 终止 | kill -9、OOM killer | 不能 |
SIGSEGV | 终止 + core | 非法内存访问 | 能(但通常不该恢复) |
SIGPIPE | 终止 | 写已关闭的管道/socket | 能 |
SIGCHLD | 忽略 | 子进程退出/停止 | 能 |
SIGSTOP | 停止 | kill -STOP、作业控制 | 不能 |
SIGCONT | 继续 | fg/bg、kill -CONT | 能 |
SIGUSR1/2 | 终止 | 用户自定义 | 能 |
「默认动作」是进程没有注册处理函数、也没有忽略时的行为。SIGCHLD 的默认动作是忽略,但父进程仍可以 wait 到状态;忽略 SIGCHLD 与捕获它是两回事。
sigaction 比 signal 可控
signal() 在不同 UNIX 上的语义不一致(是否重启系统调用、是否重置处置)。POSIX 推荐 sigaction:
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask); /* 处理函数执行期间额外阻塞的信号 */
sa.sa_flags = SA_RESTART | SA_SIGINFO;
sigaction(SIGINT, &sa, NULL);
sa_mask 会在处理函数执行期间追加阻塞(SIGINT 自身默认已被阻塞,除非 SA_NODEFER)。SA_RESTART 让可重启的系统调用自动重启;SA_SIGINFO 使用三参数处理函数,能拿到 siginfo_t 和 ucontext_t;SA_RESETHAND 让处理函数执行一次后恢复默认。
异步信号安全
信号处理函数可能在任意指令处打断主程序,包括 malloc 正在改堆链表、printf 正在锁 stdio 缓冲区的时候。因此处理函数只能调用异步信号安全(async-signal-safe)函数。POSIX 列出的安全函数包括 write、read、open、close、_exit、kill、sigaction、sigprocmask 等;printf、malloc、free、exit、strerror 都不安全。
实践中的最小处理函数只做一件事:把一个 volatile sig_atomic_t 标志置位,或者往 pipe 写一个字节。主循环看到标志后再做真正的处理。
竞态:sigsuspend 的原子性
一个经典错误是:
/* 错误:检查与睡眠之间有空窗 */
while (!got_sig)
pause(); /* 如果信号在 while 判断后、pause 前到达,pause 永久睡眠 */
pause() 会一直睡到「下一次」信号,而那次信号可能已经在上面的空窗里递送过了。正确做法是用 sigsuspend 把「解除阻塞」和「进入睡眠」合成一个原子操作:
/* sigwait.c:阻塞 SIGINT,用 sigsuspend 原子地等待 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t got_sig;
static void handler(int signo)
{
int saved = errno;
got_sig = signo;
errno = saved; /* 处理函数不应改变主程序的 errno */
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不设 SA_RESTART,观察 EINTR */
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
sigset_t block, prev;
sigemptyset(&block);
sigaddset(&block, SIGINT);
if (sigprocmask(SIG_BLOCK, &block, &prev) == -1) {
perror("sigprocmask");
return 1;
}
puts("waiting for SIGINT (Ctrl-C)...");
while (got_sig == 0)
sigsuspend(&prev); /* 原子地恢复 prev 掩码并睡眠 */
printf("caught signal %d\n", (int)got_sig);
sigprocmask(SIG_SETMASK, &prev, NULL);
return 0;
}
sigsuspend(&prev) 的语义是:把信号掩码设为 prev(即解除对 SIGINT 的阻塞),然后挂起,直到递送一个信号;返回后掩码恢复为调用前的值。这样信号要么在 sigsuspend 之前到达并被记录在 pending,要么在睡眠期间到达并唤醒它,不存在「睡死」的窗口。
信号与 fork/exec/线程
fork后子进程继承处理函数和阻塞掩码,但 pending 集合被清空;exec后捕获的处理函数重置为默认,忽略的保持忽略,阻塞掩码保留;- 多线程进程中,进程级信号可以递送给任意一个未阻塞该信号的线程;
pthread_sigmask控制每线程掩码; - 专门等信号的线程可以用
sigwaitinfo/sigwait同步地取信号,避免处理函数的可重入问题; signalfd把信号变成 fd 事件,能塞进epoll循环。
- 「
signal()和sigaction()一样」——signal()在历史实现里可能重置处置、可能不重启系统调用;可移植代码统一用sigaction。 - 「信号会排队,来几个处理几次」——标准信号不排队,
SIGCHLD尤其容易合并;必须循环回收子进程。 - 「处理函数里可以
printf」——stdio有锁和缓冲区,printf不是异步信号安全的;用write或只置标志。 - 「设了
SA_RESTART就万事大吉」——poll、epoll_wait、select、msgrcv等不会自动重启;即使可重启的调用,也可能因超时语义需要自己处理EINTR。 - 「多线程里
fork很安全」——子进程只继承调用线程,其他线程持有的锁在子进程中永远不释放;fork后到exec前只能调用异步信号安全函数。 - 「
SIGKILL也能被捕获」——SIGKILL和SIGSTOP不能被捕获、阻塞或忽略;这是内核留下的最后手段。
1.8.5 非本地跳转:setjmp 与 longjmp
正常的函数返回是栈式的:后调用先返回。longjmp 打破这个顺序,直接跳回之前用 setjmp 保存的上下文,中间的函数栈帧全部丢弃。
setjmp(env) 保存当前寄存器上下文(%rsp、%rip、被调用者保存寄存器、信号掩码视实现而定),返回 0;longjmp(env, val) 恢复上下文,使 setjmp 再次返回 val(val 为 0 时返回 1)。
/* setjmp.c:用非本地跳转做错误恢复 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
static jmp_buf recover;
static void fail(int code)
{
longjmp(recover, code); /* 不返回 */
}
static int parse(int depth)
{
if (depth > 3)
fail(42);
return depth * 2;
}
int main(void)
{
volatile int attempts = 0;
int code = setjmp(recover);
if (code != 0) {
attempts++;
printf("recovered: code=%d attempts=%d\n", code, attempts);
if (attempts >= 2)
return 0;
}
printf("parse=%d\n", parse(attempts == 0 ? 5 : 2));
return 0;
}
两个细节决定这段代码是否正确:
attempts必须是volatile。setjmp之后、longjmp之前被修改的自动变量,若没有volatile,在longjmp后可能还是寄存器里的旧值(C 标准只保证volatile变量的值被恢复)。longjmp不会调用中间栈帧的清理代码。C 里没有析构函数,问题不大;C++ 里会跳过析构,导致泄漏;有锁、有临时文件、有内存分配时,必须手工回滚。
信号处理函数里用 longjmp 跳出是另一个常见场景,但普通 longjmp 不恢复信号掩码。若处理函数执行期间 SIGINT 被自动阻塞,跳出后它仍是阻塞的。POSIX 提供 sigsetjmp(env, 1)/siglongjmp 来保存和恢复信号掩码:
sigjmp_buf jb;
if (sigsetjmp(jb, 1) == 0) {
/* 正常路径 */
} else {
/* 从信号处理函数 siglongjmp 回来,掩码已恢复 */
}
C 没有内建异常。常见的三种模拟方式是:setjmp/longjmp(错误码跳转)、返回码逐层检查(最显式)、goto cleanup(局部资源清理)。C++ 的 throw 在 ABI 层用 unwind 表逐帧清理,和 longjmp 的「直接跳」完全不同;不要用 longjmp 跨越 C++ 栈帧。
1.8.6 作业控制、会话与控制终端
shell 能同时跑前台和后台任务,靠的是三个层次:
| 概念 | 内核对象 | 作用 |
|---|---|---|
| 进程组(process group) | pgid | 信号可以发给整组;作业控制的单位 |
| 会话(session) | sid | 一组进程组的集合,通常对应一次登录 |
| 控制终端(controlling terminal) | tty | 会话与终端设备绑定;前台组独占输入 |
每个会话最多有一个控制终端。终端驱动维护一个前台进程组(foreground process group):只有前台组的进程可以读终端;后台组读终端会收到 SIGTTIN,默认停止;后台组写终端(若 TOSTOP 开启)会收到 SIGTTOU。
作业控制的信号链:
Ctrl-Z→ 终端驱动向前台组发SIGTSTP→ 进程停止,shell 拿回控制权;fg→ shell 把作业设为前台组,发SIGCONT;bg→ shell 把作业放到后台,发SIGCONT,但不再给它终端读权限;Ctrl-C→ 终端驱动向前台组发SIGINT;- 终端关闭 → 内核向会话首进程发
SIGHUP,通常级联到整个会话。
sleep 300 & # 后台作业
jobs -l # 看作业号、pid、进程组
ps -o pid,ppid,pgid,sid,tpgid,stat,cmd -p $$
# TPGID 是前台进程组;为 -1 表示没有前台组
kill -TSTP %1 # 暂停作业 1
bg %1 # 让作业 1 在后台继续
disown %1 # 从 shell 作业表移除,退出 shell 不转发 SIGHUP
一次真实会话里,三个层次的关系可以直接从字段读出来:
$ sleep 300 & [1] 4242 $ ps -o pid,ppid,pgid,sid,tpgid,stat,cmd -p 4242 -p $$ PID PPID PGID SID TPGID STAT CMD 4200 4199 4200 4200 4200 Ss bash 4242 4200 4242 4200 4200 S sleep 300 $ jobs -l [1]+ 4242 Running sleep 300 &
bash 是会话首进程(SID=4200,STAT 里的 s),也是前台进程组(TPGID=4200)。后台作业 sleep 被放进新的进程组 PGID=4242,但仍在同一会话里。Ctrl-Z 停止的是前台组,不会误伤后台作业;kill -- -4242 则可以把整个作业组一起终止。
写守护进程时,这套机制要主动切断:setsid() 创建新会话、脱离控制终端;再 fork 一次确保不是会话首进程,防止重新获得终端;把 stdin/stdout/stderr 重定向到 /dev/null 或日志。现代系统更推荐交给 systemd 管理,由它处理日志、重启和依赖。
终端是共享的交互设备。如果多个进程同时读,输入会随机分配给其中一个,用户无法预期。内核用「前台进程组」把终端输入独占给一个作业;后台进程尝试读时,发 SIGTTIN 把它停住,等 fg 把它带回前台再继续。这不是权限错误,而是作业控制的流控机制。
1.8.7 观测异常控制流
排查进程/信号问题时,先分清「谁发的」「发给谁」「处理了没有」。三组工具足够:
# 1. 系统调用与信号的时间线
strace -f -tt -T -e trace=process,signal ./prog
# 2. 进程的待处理/阻塞/忽略信号
grep -E 'SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt' /proc/$$/status
# 3. 让 gdb 在信号或系统调用处停下
gdb -q ./prog
(gdb) catch syscall fork
(gdb) catch signal SIGSEGV
/proc/<pid>/status 的信号字段是 64 位掩码,按十六进制打印:
| 字段 | 含义 |
|---|---|
SigPnd | 该线程的 pending 信号 |
ShdPnd | 进程共享的 pending 信号 |
SigBlk | 该线程阻塞的信号 |
SigIgn | 被忽略的信号 |
SigCgt | 被捕获的信号 |
第 1 位对应信号 1(SIGHUP),第 9 位对应 SIGKILL。把十六进制转成二进制,就能看出哪些位被置上。例如 SigCgt: 0000000000004000 只捕获了信号 15(SIGTERM)。
core dump 是异常控制流的「事后现场」。ulimit -c 控制大小,/proc/sys/kernel/core_pattern 控制文件名和管道;coredumpctl 在 systemd 系统上直接列出和调试。拿到 core 后,gdb ./prog core 的 bt 能看到异常发生时的调用栈。
ulimit -c unlimited
./crash_app
coredumpctl list --reverse | head -3
coredumpctl debug crash_app
# (gdb) bt
# (gdb) info registers rip
如果 core_pattern 以 | 开头,core 会被管道给一个用户态程序(如 systemd-coredump),不再落盘成 core 文件。排查「为什么没有 core」时,先看 ulimit -c,再看 core_pattern,最后看 /proc/sys/fs/suid_dumpable 和容器是否屏蔽了 core。
1.8.8 异常的四类与 x86-64 的异常表
这一节回答:缺页、除零、断点、系统调用、时钟中断,在内核看来是不是同一类事件?CPU 把控制权交给内核时,压栈了什么、返回时执行哪条指令?把这套机制弄清楚,信号、strace、SIGSEGV 的现场才读得懂。
四类异常
| 类别 | 触发方式 | 返回行为 | 例子 |
|---|---|---|---|
| 中断(interrupt) | 异步,外部设备 | 返回下一条指令 | 时钟、网卡、键盘 |
| 陷阱(trap) | 故意,指令 | 返回下一条指令 | syscall、int3、into |
| 故障(fault) | 指令执行出错,可能可恢复 | 重新执行当前指令 | 缺页、#GP、#DE(除零) |
| 终止(abort) | 不可恢复 | 不返回,终止进程 | 双重故障、机器检查 |
「故障」之所以能恢复,是因为 CPU 在指令提交之前报告错误,保存的返回地址指向出错指令本身;内核修好条件(如装入页面)后 iret 回去重新执行,指令这次就成功了。缺页处理是「故障」类最典型的应用:一次 mov 触发 #PF,内核分配物理页、填页表,返回后同一条 mov 再执行一遍。
x86-64 的向量与错误码
x86-64 有 256 个中断向量,0–31 保留给异常,32–255 给外部中断与系统调用(Linux 用 0x80 老式入口,x86-64 用 syscall 指令,不走 IDT 的 int 门)。常见向量:
| 向量 | 名称 | 类别 | 错误码 | 典型原因 |
|---|---|---|---|---|
| 0 | #DE | fault | 无 | 除零、INT_MIN / -1 |
| 3 | #BP | trap | 无 | int3 断点 |
| 6 | #UD | fault | 无 | 非法指令 |
| 8 | #DF | abort | 有(0) | 处理异常时又出错 |
| 13 | #GP | fault | 有 | 段/权限/非规范地址 |
| 14 | #PF | fault | 有 | 缺页、写只读页、保护违规 |
| 17 | #AC | fault | 有 | 非对齐访问(开启对齐检查时) |
| 18 | #MC | abort | 无 | 机器检查 |
#PF 的错误码按位解释:
| 位 | 含义 |
|---|---|
| 0 (P) | 0=页不存在,1=保护违规 |
| 1 (W/R) | 0=读,1=写 |
| 2 (U/S) | 0=内核态,1=用户态 |
| 3 (RSVD) | 1=页表项保留位被置位 |
| 4 (I/D) | 1=取指导致 |
出错地址在 CR2。内核的 do_page_fault() 用地址找到 vm_area_struct:若在合法 VMA 内且权限允许,按需分配页(匿名页清零、文件页读入、COW 复制);若不在,发 SIGSEGV;若是栈附近的访问,还可能扩展栈。用户态看到的 Segmentation fault (core dumped) 就是这条路径的终点。
系统调用的入口
syscall 指令不是异常,而是特权级切换指令:它把 %rip 存进 %rcx、%rflags 存进 %r11,跳到 MSR_LSTAR 指向的入口(Linux 的 entry_SYSCALL_64),并用 swapgs 切换内核 GS 基址。参数按 SysV 变体放在 rdi/rsi/rdx/r10/r8/r9,编号在 rax,返回值在 rax。它与函数调用的差别:
| 项目 | 函数调用 | 系统调用 |
|---|---|---|
| 参数寄存器 | rdi,rsi,rdx,rcx,r8,r9 | rdi,rsi,rdx,r10,r8,r9 |
| 返回地址 | call 压栈 | syscall 存 %rcx |
| 特权级 | 不变 | 用户 → 内核 |
| 栈 | 用户栈 | 内核栈(swapgs 后取) |
| 典型开销 | 几纳秒 | 几十到几百纳秒(含 Spectre 缓解) |
strace 之所以能看到参数,是因为它用 ptrace 在 syscall 入口/出口停下,读寄存器;perf trace 则用 tracepoint,开销更小。
$ ./crash
Segmentation fault (core dumped)
$ dmesg | tail -2
segfault at 0 ip 000055d0... sp 00007ffd... error 4 in crash[...]
error 4 = 二进制 100:P=0(页不存在)、W/R=0(读)、U/S=1(用户态)。at 0 是 CR2 的值,说明解引用了空指针。若 error 6(110),是用户态写不存在页;error 7 是保护违规(写只读页)。内核日志里的 ip 是出错指令地址,和 gdb 的 $pc 对得上。
1.8.9 进程控制:fork、exec、wait 的完整语义
这一节回答:fork 之后哪些东西被复制、哪些共享?execve 保留什么、重置什么?waitpid 的状态字怎么拆?这些细节决定了「为什么我的 fd 泄漏到子进程」「为什么信号处理函数在 exec 后失效」。
fork 的复制与共享
| 对象 | 行为 |
|---|---|
| 地址空间 | 写时复制(COW):页表复制,物理页共享只读,任一方写才复制 |
| 文件描述符表 | 复制一份,指向同一个打开文件描述(共享偏移与状态标志) |
| 文件偏移 | 共享(同一 struct file) |
| 信号处理函数 | 继承(处置方式复制) |
| 未决信号 | 清空(子进程不继承 pending) |
| 定时器 | 清空(alarm 不继承) |
| 进程组/会话 | 继承 |
| 锁 | 不继承 fcntl 记录锁(子进程不持有) |
FD_CLOEXEC | 保留在 fd 上,exec 时生效 |
「fd 表复制、打开文件描述共享」是最容易混的一条:父子各自 close(fd) 只减少引用计数,真正的文件关闭要等最后一个引用;而 lseek 改的是共享偏移,父子会互相影响。要独立偏移,子进程应 open 新 fd 或 dup 后 lseek(dup 仍共享偏移,必须重新 open)。
execve 保留与重置
| 对象 | exec 后 |
|---|---|
| PID / PPID | 不变 |
| 打开的文件 fd | 保留(除非 FD_CLOEXEC) |
| 信号处置 | 自定义 handler 重置为默认;被忽略的信号保持忽略 |
| 阻塞的信号掩码 | 保留 |
| 未决信号 | 保留 |
| 内存映射 | 全部替换(MAP_SHARED 的映射仍在,但地址空间换新) |
atexit 处理器 | 不执行(exec 不跑退出处理) |
| 环境变量 | 由调用者传入的 envp 决定 |
「被忽略的信号保持忽略」是 POSIX 的明确规定,也是 nohup 的原理:nohup 把 SIGHUP 设为 SIG_IGN,exec 后仍然忽略。
waitpid 的状态字
status 不是退出码,而是编码过的状态:
| 宏 | 含义 |
|---|---|
WIFEXITED(s) | 正常退出 |
WEXITSTATUS(s) | 退出码(低 8 位) |
WIFSIGNALED(s) | 被信号杀死 |
WTERMSIG(s) | 终止信号编号 |
WCOREDUMP(s) | 是否产生 core |
WIFSTOPPED(s) / WSTOPSIG(s) | 被信号停止(需 WUNTRACED) |
WIFCONTINUED(s) | 继续(需 WCONTINUED) |
常见错误是直接 printf("%d", status):退出码 7 会打印 1792(7 << 8)。另一个错误是忘了循环 waitpid:EINTR 时要重试,多个子进程时要 while ((pid = waitpid(-1, &st, 0)) > 0)。
僵尸、孤儿与 subreaper
子进程退出后,内核保留它的退出状态直到父进程 wait,这个残骸叫僵尸(zombie,ps 状态 Z)。僵尸不占内存,只占 PID 与一个 task_struct;大量僵尸会耗尽 PID。
父进程先死、子进程还活着,子进程成为孤儿,被 init(PID 1)或最近的 subreaper 收养。Linux 3.4+ 用 prctl(PR_SET_CHILD_SUBREAPER, 1) 让某个进程充当「二传手」,容器运行时(runc、systemd)用它来回收孙进程。
clone 与线程
fork 在 Linux 上就是 clone(SIGCHLD, 0);线程是 clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|...)。标志决定共享哪些资源:
| 标志 | 共享 |
|---|---|
CLONE_VM | 地址空间 |
CLONE_FILES | fd 表 |
CLONE_FS | cwd/root/umask |
CLONE_SIGHAND | 信号处置 |
CLONE_THREAD | 线程组(同一 TGID) |
CLONE_NEWPID/NEWNS/NEWNET | 命名空间 |
vfork 是 CLONE_VM|CLONE_VFORK:子进程借用父进程地址空间并阻塞父进程,直到 exec/_exit,避免 fork 的页表复制开销。它只适合「立刻 exec」的场景,子进程不能返回、不能改数据。
1.8.10 信号:从产生到递送的完整路径
这一节回答:信号发出后到 handler 执行,中间经过哪些状态?为什么 signal() 的语义在不同系统上不一样?EINTR 什么时候发生、SA_RESTART 救不救?
生命周期
产生(generate) → 挂起(pending) → 递送(deliver) → 处理(handle/ignore/default)
↑
阻塞(blocked) 时停在 pending
- 产生:
kill/raise/sigqueue、终端驱动(SIGINT/SIGQUIT/SIGTSTP)、硬件异常(SIGSEGV/SIGFPE)、内核事件(SIGCHLD/SIGPIPE/SIGALRM)。 - 挂起:每个进程有
pending位图与blocked位图;标准信号(1–31)同一种只保留一个,实时信号(SIGRTMIN–SIGRTMAX)排队。 - 递送:进程被调度回用户态、且信号未被阻塞时,内核在返回用户态的路径上设置栈帧、跳进 handler。
- 处理:默认动作(终止/忽略/停止/继续)、忽略、或用户 handler;
SIGKILL/SIGSTOP不能被捕获或阻塞。
signal 与 sigaction
signal() 在不同 Unix 上的语义不一致(是否重启系统调用、是否重置 handler);POSIX 的 sigaction() 把行为写成显式标志:
| 标志 | 作用 |
|---|---|
SA_RESTART | 被该信号打断的慢系统调用自动重启 |
SA_NODEFER | handler 执行时不禁用该信号本身 |
SA_RESETHAND | 进入 handler 后把处置重置为默认 |
SA_SIGINFO | 使用三参数 handler,拿到 siginfo_t 与 ucontext_t |
SA_ONSTACK | 在备用信号栈(sigaltstack)上执行 |
SA_NOCLDSTOP | 子进程停止/继续时不发 SIGCHLD |
SA_NOCLDWAIT | 子进程退出不产生僵尸(自动回收) |
SA_RESTART 不是万能的:read/write/wait 等可重启,但 pause、sigsuspend、nanosleep、epoll_wait(旧内核)等总是返回 EINTR;read 已经读了部分数据时也不会重启,而是返回短读。所以健壮的代码必须自己处理 EINTR。
异步信号安全
handler 可能在主程序的任意指令之间执行,因此它只能调用异步信号安全(async-signal-safe)的函数。POSIX 列出的安全函数很少:write、read、_exit、signal、sigaction、kill、open 等;printf、malloc、free、pthread_mutex_lock 都不安全(可能死锁或破坏堆)。
安全 handler 的写法:
static volatile sig_atomic_t got_sigint;
static void on_sigint(int sig) { (void)sig; got_sigint = 1; }
主循环轮询 got_sigint,或使用 self-pipe(handler 里 write(pipefd[1], "x", 1),主循环 poll 读端),或 Linux 的 signalfd 把信号变成 fd 事件。这样 handler 只做一次 write/赋值,复杂逻辑回到主循环。
信号与线程
- 信号可以发给进程(
kill)或线程(tgkill/pthread_kill)。 - 进程级信号的递送目标是「任意一个未阻塞它的线程」;多线程程序通常在主线程
sigwait或所有线程阻塞信号,由专用线程处理。 pthread_sigmask只影响当前线程;sigprocmask在 POSIX 多线程程序里行为未定义,应使用pthread_sigmask。
- 「
signal()会重启系统调用」——BSD 语义会,System V 不会;Linux 的 glibcsignal()用 BSD 语义,但可移植代码应使用sigaction并显式写SA_RESTART。 - 「handler 里可以
printf」——printf会拿 stdio 锁、可能malloc,不是异步信号安全的;用write或设置标志。 - 「
SIGKILL可以被捕获来清理资源」——不能;清理逻辑应放在正常退出路径、atexit、或父进程/监督进程里。 - 「信号不会丢失」——标准信号同一种只保留一个 pending;连续发 10 个
SIGUSR1,handler 可能只跑 1 次。需要计数用实时信号或 self-pipe。
1.8.11 完整程序一:fork/exec/wait 与信号
这个程序是一个「最小 shell」:安装 SIGCHLD/SIGINT 处理器,fork 后子进程 execvp,父进程 waitpid 并按状态宏解释结果。它覆盖了 1.8.9 与 1.8.10 的大部分边界:EINTR 重试、退出码与信号终止的区分、SA_RESTART/SA_NOCLDSTOP 的作用。
/* spawn.c — fork/exec/wait + SIGCHLD + EINTR 处理
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 spawn.c
* 运行:./spawn /bin/sh -c 'exit 7'
*/
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <sys/wait.h>
static volatile sig_atomic_t sigchld_count;
static volatile sig_atomic_t sigint_count;
static void on_sigchld(int sig) { (void)sig; sigchld_count++; }
static void on_sigint(int sig) { (void)sig; sigint_count++; }
int main(int argc, char **argv)
{
if (argc < 2) { fprintf(stderr, "usage: %s cmd [args...]\n", argv[0]); return 2; }
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
if (sigaction(SIGCHLD, &sa, NULL) < 0) { perror("sigaction SIGCHLD"); return 1; }
sa.sa_handler = on_sigint;
sa.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &sa, NULL) < 0) { perror("sigaction SIGINT"); return 1; }
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
execvp(argv[1], &argv[1]);
fprintf(stderr, "execvp %s: %s\n", argv[1], strerror(errno));
_exit(127);
}
int status = 0;
pid_t r;
do {
r = waitpid(pid, &status, 0);
} while (r < 0 && errno == EINTR); /* 被信号打断就重试 */
if (r < 0) { perror("waitpid"); return 1; }
if (WIFEXITED(status))
printf("child %d exited, status=%d\n", (int)pid, WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("child %d killed by signal %d\n", (int)pid, WTERMSIG(status));
else if (WIFSTOPPED(status))
printf("child %d stopped by signal %d\n", (int)pid, WSTOPSIG(status));
printf("sigchld_count=%d sigint_count=%d\n",
(int)sigchld_count, (int)sigint_count);
return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}
运行:
$ ./spawn /bin/sh -c 'exit 7'
child 79904 exited, status=7
sigchld_count=1 sigint_count=0
$ echo $?
7
$ ./spawn /bin/sh -c 'kill -TERM $$'
child 79906 killed by signal 15
sigchld_count=1 sigint_count=0
关键点:
execvp失败后必须_exit(127),不能用exit:子进程继承了父进程的 stdio 缓冲区,exit会 flush 两份;_exit直接走系统调用。waitpid的EINTR循环是防御性的:SA_RESTART通常会让waitpid自动重启,但并非所有内核/信号组合都如此;写循环不亏。WIFEXITED/WIFSIGNALED二选一,不能直接看status;WTERMSIG只在被信号杀死时有意义。sigchld_count是volatile sig_atomic_t:handler 与主流程共享,必须保证每次读写都落到内存,且类型是信号安全的。SA_NOCLDSTOP让子进程被SIGSTOP/SIGCONT时不产生SIGCHLD,否则waitpid会被大量停止事件打扰。
把 SIGCHLD 处理器去掉,程序仍然能工作(waitpid 阻塞等待),但如果有多个子进程、父进程又不想阻塞,就必须靠 SIGCHLD 或 signalfd 来驱动回收。注意标准信号不排队:短时间内多个子进程退出,SIGCHLD 可能只递送一次,所以 handler 里应当用 while (waitpid(-1, ..., WNOHANG) > 0) 循环收割,而不是「一个信号收一个」。
1.8.12 完整程序二:setjmp/longjmp 的非局部跳转
setjmp 保存当前调用帧的寄存器上下文(%rsp、%rbp、返回地址、callee-saved 寄存器),longjmp 恢复它,让执行「跳回」到保存点。它常用于错误恢复、解释器、协程(配合 ucontext/汇编切换栈)。这个程序演示两种用法:普通错误恢复与从信号处理函数跳出。
/* jmp.c — setjmp/longjmp 的错误恢复与 sigsetjmp 的超时跳出
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 jmp.c
*/
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <setjmp.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
static jmp_buf err_env;
static sigjmp_buf alarm_env;
static void fail(int code)
{
longjmp(err_env, code); /* 第二参数不能为 0,否则会被改成 1 */
}
static void on_alarm(int sig)
{
(void)sig;
siglongjmp(alarm_env, 1); /* 从信号处理函数跳出,仅演示 */
}
static int parse_positive(const char *s)
{
int v = 0;
if (!s || !*s) fail(1);
for (const char *p = s; *p; p++) {
if (*p < '0' || *p > '9') fail(2);
v = v * 10 + (*p - '0');
}
if (v <= 0) fail(3);
return v;
}
int main(void)
{
int r = setjmp(err_env);
if (r == 0) {
printf("parse=%d\n", parse_positive("123"));
printf("parse=%d\n", parse_positive("12x")); /* 触发 fail(2) */
printf("unreachable\n");
} else {
printf("recovered from error %d\n", r);
}
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGALRM, &sa, NULL) < 0) { perror("sigaction"); return 1; }
if (sigsetjmp(alarm_env, 1) == 0) {
alarm(1);
printf("waiting for alarm...\n");
pause(); /* 被 SIGALRM 打断 */
printf("not reached\n");
} else {
printf("alarm fired, jumped back\n");
}
return 0;
}
输出:
parse=123
recovered from error 2
waiting for alarm...
alarm fired, jumped back
必须记住的规则:
longjmp的第二个参数为 0 时会被改成 1,因为setjmp返回 0 表示「首次进入」,不能用 0 表示错误码。- 在
setjmp之后被修改的自动变量,longjmp后值不确定,除非声明为volatile。上面的r由setjmp赋值,sa在第二次setjmp之后修改但没有跨跳转读取,所以安全。 longjmp不会执行中间栈帧的清理:C 没有析构函数,但pthread_mutex_unlock、free、close都不会被调用;在 C++ 里跳过析构是 UB,在 Rust 里会泄漏或触发panic=abort。- 从信号处理函数跳出要用
sigsetjmp(env, 1)+siglongjmp,它会恢复信号掩码;普通setjmp/longjmp不恢复,可能让某些信号永久阻塞。 - 跳出后不要依赖 handler 里未定义的状态:
longjmp只恢复寄存器,不恢复堆、文件、锁。
setjmp/longjmp 与 1.8.8 的异常是两套机制:硬件异常由内核转成信号,longjmp 是用户态在同一个进程内跳过栈帧。它不能跨进程、不能跨线程(longjmp 到另一个线程的 jmp_buf 是 UB)。
1.8.13 实验:用 strace、/proc、ps 观测异常控制流
strace 看进程与信号
$ strace -f -e trace=process,signal ./spawn /bin/sh -c 'exit 7'
rt_sigaction(SIGCHLD, {sa_handler=0x...}, NULL, 8) = 0
rt_sigaction(SIGINT, {sa_handler=0x...}, NULL, 8) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...) = 12345
[pid 12345] execve("/bin/sh", ["/bin/sh", "-c", "exit 7"], ...) = 0
[pid 12345] exit_group(7) = ?
wait4(12345, [{WIFEXITED(s) && WEXITSTATUS(s) == 7}], 0, NULL) = 12345
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=12345, si_status=7} ---
读法:
clone(... SIGCHLD ...)就是fork的底层系统调用;flags里没有CLONE_VM,说明是进程而不是线程。execve的参数是 argv/envp;返回 0 表示成功,之后的exit_group(7)是子进程退出。wait4的第二个参数直接显示了解码后的状态WIFEXITED && WEXITSTATUS==7,比程序里手拆status直观。--- SIGCHLD ---是信号递送的记录,si_code=CLD_EXITED说明是正常退出(不是被信号杀死)。
给一个正在运行的进程发信号:
$ ./spawn sleep 30 &
$ strace -e trace=signal -p $!
$ kill -INT <pid>
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=..., si_uid=...} ---
SI_USER 表示来自 kill;SI_KERNEL/SI_TKILL 等 si_code 能区分来源。strace 附着会暂停进程,生产环境用 perf trace 或 eBPF 更合适。
/proc 看信号状态
$ grep -E 'SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt' /proc/self/status
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000010000
SigIgn: 0000000000000000
SigCgt: 0000000180000000
四个十六进制掩码分别是「线程 pending」「进程 pending」「阻塞」「忽略/捕获」。SigCgt 的位图能看出程序装了哪些 handler:bit 0 是 SIGHUP,bit 1 是 SIGINT,依此类推。SigBlk 非零说明有信号被阻塞,调试「为什么收不到信号」时先看这里。
ps 看进程关系
$ ps -o pid,ppid,pgid,sid,tpgid,stat,cmd -p $$
PID PPID PGID SID TPGID STAT CMD
1234 1200 1234 1234 1234 Ss bash
$ ps -eo stat,cmd | grep '^Z'
Z [sh] <defunct>
PGID 是进程组,SID 是会话,TPGID 是控制终端的前台进程组;STAT 的 Z 是僵尸、Ss 是会话首进程、+ 是前台进程组。ps -eo stat 里出现 Z 就要找它的父进程 wait。
1.8.14 作业控制:会话、进程组与控制终端
Shell 的「前台/后台/挂起」不是魔法,而是四个概念的组合:
| 概念 | 作用 |
|---|---|
| 会话(session) | 一组进程组,首进程是 session leader;登录 shell 通常是一个会话 |
| 进程组(process group) | 作业的单位;kill(-pgid, sig) 发给整组 |
| 控制终端(controlling terminal) | 会话关联的 tty;同一时刻只有一个前台进程组 |
| 前台进程组(foreground pgrp) | 可以读 tty 的组;后台组读 tty 会收到 SIGTTIN |
相关系统调用与信号:
| 操作 | API/信号 |
|---|---|
| 建新进程组 | setpgid(0, 0) |
| 建新会话 | setsid()(同时成为新会话首进程、脱离控制终端) |
| 设前台组 | tcsetpgrp(fd, pgid) |
| 取前台组 | tcgetpgrp(fd) |
| 挂起前台作业 | SIGTSTP(Ctrl-Z,可捕获) |
| 强制停止 | SIGSTOP(不可捕获) |
| 继续 | SIGCONT |
| 后台读 tty | SIGTTIN(默认停止) |
| 后台写 tty | SIGTTOU(默认停止,stty tostop 开启时) |
| 终端关闭 | SIGHUP 发给会话首进程/前台组 |
一个最小 shell 的作业控制循环:
fork,子进程setpgid(0,0)后exec。- 父进程
setpgid(pid, pid)(避免竞态,父子都设),tcsetpgrp(tty, pid)把前台给子进程。 waitpid(pid, &st, WUNTRACED);若WIFSTOPPED,把作业记入后台表。- 子进程结束后,
tcsetpgrp(tty, shell_pgid)把前台还给 shell。 fg/bg用kill(-pgid, SIGCONT)恢复,并重新设前台。
这套逻辑在 bash 的 jobs.c、dash 的 jobs.c 里都有;写一遍最小版本,就能理解为什么 Ctrl-Z 之后 fg 能恢复、为什么后台进程读终端会被停住。
1.8.15 Rust 的进程与信号
Rust 标准库把 fork/exec/wait 包成 std::process::Command,不直接暴露 fork(因为 fork 后只允许 async-signal-safe 操作,与 Rust 的运行时不兼容)。等价的写法:
// spawn.rs — rustc -O spawn.rs -o spawn_rs && ./spawn_rs
use std::process::Command;
fn main() {
let status = Command::new("/bin/sh")
.arg("-c")
.arg("exit 7")
.status()
.expect("spawn failed");
println!("code={:?} success={}", status.code(), status.success());
}
$ ./spawn_rs
code=Some(7) success=false
Rust 侧的差异:
| 主题 | C | Rust |
|---|---|---|
| 创建进程 | fork + exec | Command::spawn(内部 posix_spawn/fork+exec) |
| 等待 | waitpid | Child::wait(自动处理 EINTR) |
| 信号 | sigaction | signal_hook/nix crate,或 libc FFI |
| handler 限制 | async-signal-safe | 同样;signal_hook 用自管道把信号转成迭代器 |
fork 后 | 任意 | 只允许 async-signal-safe;Rust 不提供安全封装 |
需要精细控制(signalfd、pidfd、clone 标志)时,Rust 通过 libc/nix/rustix 调同样的系统调用。pidfd_open+pidfd_send_signal 是 Linux 5.1+ 的更安全替代:用 fd 引用进程,避免 PID 复用导致的「杀错进程」。
1.8.16 十二条异常控制流陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | fork 后输出重复两遍 | 子进程继承了 stdio 缓冲区,exit 又 flush 一次 | 子进程用 _exit;fork 前 fflush(NULL) |
| 2 | 子进程持有父进程的 fd | fork 复制 fd 表,exec 默认保留 | 打开时加 O_CLOEXEC,或 exec 前关闭 |
| 3 | 退出码打印成 1792 | 直接打印 status 而不是 WEXITSTATUS | 用状态宏 |
| 4 | 慢系统调用莫名失败 | 信号打断返回 EINTR | 循环重试,或 SA_RESTART(并非万能) |
| 5 | handler 里死锁/崩溃 | 调用了 printf/malloc/锁 | 只做 write/赋值,复杂逻辑回主循环 |
| 6 | 多个子进程只收到一次 SIGCHLD | 标准信号不排队 | handler 里 while (waitpid(-1,...,WNOHANG)>0) |
| 7 | waitpid 不返回 | 子进程被停止但没加 WUNTRACED | 按需加 WUNTRACED/WCONTINUED |
| 8 | 大量僵尸进程 | 父进程没 wait | SIGCHLD 回收、SA_NOCLDWAIT、或 subreaper |
| 9 | 多线程下信号只被一个线程收到 | 进程级信号递送给任意未阻塞线程 | 所有线程阻塞,专用线程 sigwait |
| 10 | longjmp 后变量值不对 | 自动变量未加 volatile | 跨 setjmp 的变量声明 volatile |
| 11 | longjmp 后锁没释放/内存泄漏 | 跳过中间栈帧的清理 | 用 goto cleanup 或结构化错误处理 |
| 12 | 从 handler 跳出后信号永久阻塞 | setjmp 不恢复信号掩码 | 用 sigsetjmp(env,1)/siglongjmp |
- 「
fork会复制整个地址空间」——现代实现是 COW,只有页表复制;但「复制」的语义仍成立,子进程看不到父进程之后的修改。 - 「
SIGCHLD一定对应一个子进程退出」——它还可能是停止/继续(SA_NOCLDSTOP可关),且多个退出可能合并成一个信号。 - 「
kill(pid, 0)能判断进程是否存在」——它只检查权限与是否存在;PID 可能已被复用,pidfd才是可靠引用。
1.8.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 区分四类异常 | 能说出返回地址与同步/异步 | 1.8.1 |
| 解释缺页为何重试当前指令 | 能联系 %cr2 与页表 | 1.8.1 |
| 说出 fork 共享/复制什么 | 至少包括 fd 偏移、pending 信号、COW | 1.8.3 |
| 写出无竞态的信号等待 | 会用 sigprocmask + sigsuspend | 1.8.4 |
解释 SA_RESTART 的边界 | 知道 epoll_wait 不会自动重启 | 1.8.4 |
用 setjmp 做错误恢复 | 知道局部变量要 volatile | 1.8.5 |
解释 Ctrl-Z 的完整链路 | 终端驱动 → SIGTSTP → shell → SIGCONT | 1.8.6 |
Q1 一个进程访问了未映射的地址,CPU 产生缺页异常。内核在什么情况下修复并返回原指令,什么情况下给进程发 SIGSEGV?
Q2 父进程用 signal(SIGCHLD, handler) 注册处理函数,handler 里调用一次 waitpid(-1, NULL, 0)。同时退出 3 个子进程,可能留下几个僵尸?怎么改?
Q3 下面的代码为什么可能永久睡眠?
while (!stop)
pause();
Q4 longjmp 跳回 setjmp 后,为什么 int i 的值可能不是你以为的那个?哪些变量不受影响?
答案
A1 若地址属于进程的某个 VMA、页不在内存(或需要 COW 复制、需要从文件读入),内核分配/填充物理页、更新页表,返回当前指令重试,这次访问成功。若地址不在任何 VMA、或权限不符(写只读页、用户态访问内核页),内核无法修复,向进程发送 SIGSEGV(或 SIGBUS),默认动作是终止并 core dump。
A2 最多可能留下 2 个僵尸。标准信号不排队,3 个 SIGCHLD 可能只递送 1 次;handler 只 waitpid 一次,剩下两个子进程仍处于僵尸。改法是在 handler 里循环 while (waitpid(-1, NULL, WNOHANG) > 0) ;,直到没有可回收的子进程。
A3 pause() 只保证睡到「下一次」信号。如果 SIGINT 在 while (!stop) 判断之后、pause() 调用之前到达,处理函数把 stop 置 1 并返回,随后 pause() 仍然会睡下去,直到再来一个信号。用 sigsuspend 把「检查条件」和「睡眠」放在同一个原子操作里,或改用 sigwaitinfo/signalfd。
A4 setjmp 之后、longjmp 之前被修改的自动变量,若没有 volatile 限定,C 标准不保证 longjmp 后能看到新值(可能仍在寄存器里)。volatile 变量、全局/静态变量、setjmp 之前就已确定的值不受影响。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 8 章 异常控制流(8.1–8.7) | 1.8.1–1.8.7 | 完整:异常四类、进程控制、信号、非本地跳转、工具 |
| K(Kerrisk) | 第 6 章 进程 | 1.8.2–1.8.3 | 精选:进程内存布局、fork 语义 |
| K(Kerrisk) | 第 20–23 章 信号 | 1.8.4 | 完整:sigaction、阻塞、sigsuspend、实时信号 |
| K(Kerrisk) | 第 24–27 章 进程创建/终止/监控/执行 | 1.8.3 | 完整:fork、wait、exec 的边界 |
| O(OSTEP) | 第 4–6 章 进程与受限直接执行 | 1.8.1–1.8.3 | 背景:用户态/内核态切换、上下文保存 |
| G(鸟哥) | 第 16 章 程序管理与 SELinux | 1.8.6 | 背景:作业控制与 ps 字段 |
CSAPP 第 8 章小节清单
| CSAPP 小节 | 页码 | 状态 | 本书落点 |
|---|---|---|---|
| 8.1 异常 | 538 | 已覆盖 | 1.8.1、1.8.8(四类、向量、错误码、#PF) |
| 8.2 进程 | 544 | 已覆盖 | 1.8.2、1.8.9 |
| 8.3 系统调用错误处理 | 548 | 合并 | 1.8.3、1.8.11(EINTR、errno) |
| 8.4 进程控制 | 549 | 已覆盖 | 1.8.3、1.8.9、1.8.11 |
| 8.5 信号 | 562 | 已覆盖 | 1.8.4、1.8.10、1.8.13 |
| 8.6 非本地跳转 | 582 | 已覆盖 | 1.8.5、1.8.12(setjmp/sigsetjmp) |
| 8.7 操作进程的工具 | 586 | 已覆盖 | 1.8.7、1.8.13(strace//proc/ps) |
| 8.8 小结 | 586 | 合并 | 1.8.16 |
下一站:进程以为自己独占的内存,其实是内核用页表变出来的。一次地址翻译要查几级页表?缺页时内核怎样决定换出哪一页?进入 1.9 虚拟内存,从页表和地址翻译算起。