Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 与内核的交接点

定义 1.8.1 · 异常(exception)

异常是 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
filesfd 表信号处理程序里 write
sighand / signal信号处理表、阻塞掩码、pending递送信号、决定默认动作
creduid/gid/capabilities权限检查、kill 许可
thread_struct寄存器、%rsp、%cr3上下文切换
__state运行/睡眠/停止/僵尸调度与唤醒
exit_state / exit_code退出状态wait 回收
进程状态与迁移 运行、可中断睡眠、不可中断睡眠、停止、僵尸与退出。 进程状态机(Linux 6.x,task_struct->__state)TASK_RUNNING可运行 / 正在运行TASK_INTERRUPTIBLE可被信号唤醒TASK_UNINTERRUPTIBLE不可被信号唤醒TASK_STOPPEDSIGSTOP / 调试TASK_ZOMBIE已退出,等待 waitTASK_TRACED被 ptrace 暂停EXIT_DEAD彻底释放等待事件事件就绪 / 唤醒等待 I/OI/O 完成SIGSTOPSIGCONTexit()wait() 回收ptrace就绪队列只收 TASK_RUNNING;睡眠状态通过等待队列唤醒;ZOMBIE 占的是 task_struct,不是内存页
读图:重点看两条环:可运行 ↔ 可中断睡眠(等待队列 + 唤醒),以及运行 → 僵尸 → 回收。不可中断睡眠(D 状态)不响应信号,常见于磁盘 I/O;大量 D 状态通常意味着 I/O 或文件系统卡住。

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 缓冲区,可能把父进程尚未写出的内容重复打印一次。

关于进程控制的五个误解
  1. 「fork 后父子进程的 fd 偏移独立」——不独立。fork 复制的是 fd 表,表项指向同一个打开文件描述,f_pos 共享。要独立偏移,各自 open 一次。
  2. 「子进程退出后立刻消失」——退出后先进入僵尸,等父进程 wait 才释放 task_struct。长期不 wait 的服务会积累僵尸。
  3. 「exec 会保留信号处理函数」——捕获的处理函数被重置为默认;只有 SIG_IGN 会保留。别指望在 exec 后的程序里继续用父进程的 handler。
  4. 「wait 只能等一个固定 pid」——waitpid(-1, ...) 等任意子进程,waitid 提供更细的选项;WNOHANG 可以非阻塞轮询。
  5. 「system() 等价于 fork+exec」——system 还会 fork 一个 shell,信号处理、返回值编码、SIGINT 的忽略都不同;安全敏感场景不要用 system 拼接命令。

1.8.4 信号:产生、阻塞、递送

信号是内核向进程通知异步事件的方式。它像一个「软件中断」,但语义比中断复杂得多:信号有生成、挂起(pending)、阻塞(blocked)、递送(delivery)四个阶段,还有处置方式(disposition)和排队规则。

定义 1.8.2 · 信号的生命周期

信号产生后进入目标进程(或线程)的 pending 集合;若该信号当前被阻塞,它留在 pending 集合里,直到解除阻塞;解除阻塞后,内核在进程下一次从内核态返回用户态时递送它,执行处置:默认动作、忽略,或调用用户注册的处理函数。标准信号(1–31)不排队,同一信号在 pending 期间再次产生只记一次;实时信号(SIGRTMIN–SIGRTMAX)排队并带值。

标准信号「不排队」是很多 bug 的根源。父进程处理 SIGCHLD 时,如果两个子进程几乎同时退出,内核只保证至少有一个 SIGCHLD 处于 pending;处理函数只调用一次 waitpid,就会留下一个僵尸。正确写法是循环 waitpid(-1, ..., WNOHANG) 直到返回 0 或 -1。

常见信号的默认动作:

信号默认动作典型来源能否捕获/阻塞
SIGINT终止Ctrl-C能
SIGQUIT终止 + coreCtrl-\能
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 循环。
关于信号的六个误解
  1. 「signal() 和 sigaction() 一样」——signal() 在历史实现里可能重置处置、可能不重启系统调用;可移植代码统一用 sigaction。
  2. 「信号会排队,来几个处理几次」——标准信号不排队,SIGCHLD 尤其容易合并;必须循环回收子进程。
  3. 「处理函数里可以 printf」——stdio 有锁和缓冲区,printf 不是异步信号安全的;用 write 或只置标志。
  4. 「设了 SA_RESTART 就万事大吉」——poll、epoll_wait、select、msgrcv 等不会自动重启;即使可重启的调用,也可能因超时语义需要自己处理 EINTR。
  5. 「多线程里 fork 很安全」——子进程只继承调用线程,其他线程持有的锁在子进程中永远不释放;fork 后到 exec 前只能调用异步信号安全函数。
  6. 「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#DEfault无除零、INT_MIN / -1
3#BPtrap无int3 断点
6#UDfault无非法指令
8#DFabort有(0)处理异常时又出错
13#GPfault有段/权限/非规范地址
14#PFfault有缺页、写只读页、保护违规
17#ACfault有非对齐访问(开启对齐检查时)
18#MCabort无机器检查

#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,r9rdi,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_FILESfd 表
CLONE_FScwd/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_NODEFERhandler 执行时不禁用该信号本身
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。
关于信号的四个误解
  1. 「signal() 会重启系统调用」——BSD 语义会,System V 不会;Linux 的 glibc signal() 用 BSD 语义,但可移植代码应使用 sigaction 并显式写 SA_RESTART。
  2. 「handler 里可以 printf」——printf 会拿 stdio 锁、可能 malloc,不是异步信号安全的;用 write 或设置标志。
  3. 「SIGKILL 可以被捕获来清理资源」——不能;清理逻辑应放在正常退出路径、atexit、或父进程/监督进程里。
  4. 「信号不会丢失」——标准信号同一种只保留一个 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

必须记住的规则:

  1. longjmp 的第二个参数为 0 时会被改成 1,因为 setjmp 返回 0 表示「首次进入」,不能用 0 表示错误码。
  2. 在 setjmp 之后被修改的自动变量,longjmp 后值不确定,除非声明为 volatile。上面的 r 由 setjmp 赋值,sa 在第二次 setjmp 之后修改但没有跨跳转读取,所以安全。
  3. longjmp 不会执行中间栈帧的清理:C 没有析构函数,但 pthread_mutex_unlock、free、close 都不会被调用;在 C++ 里跳过析构是 UB,在 Rust 里会泄漏或触发 panic=abort。
  4. 从信号处理函数跳出要用 sigsetjmp(env, 1) + siglongjmp,它会恢复信号掩码;普通 setjmp/longjmp 不恢复,可能让某些信号永久阻塞。
  5. 跳出后不要依赖 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
后台读 ttySIGTTIN(默认停止)
后台写 ttySIGTTOU(默认停止,stty tostop 开启时)
终端关闭SIGHUP 发给会话首进程/前台组

一个最小 shell 的作业控制循环:

  1. fork,子进程 setpgid(0,0) 后 exec。
  2. 父进程 setpgid(pid, pid)(避免竞态,父子都设),tcsetpgrp(tty, pid) 把前台给子进程。
  3. waitpid(pid, &st, WUNTRACED);若 WIFSTOPPED,把作业记入后台表。
  4. 子进程结束后,tcsetpgrp(tty, shell_pgid) 把前台还给 shell。
  5. 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 侧的差异:

主题CRust
创建进程fork + execCommand::spawn(内部 posix_spawn/fork+exec)
等待waitpidChild::wait(自动处理 EINTR)
信号sigactionsignal_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 十二条异常控制流陷阱

#现象原因修复
1fork 后输出重复两遍子进程继承了 stdio 缓冲区,exit 又 flush 一次子进程用 _exit;fork 前 fflush(NULL)
2子进程持有父进程的 fdfork 复制 fd 表,exec 默认保留打开时加 O_CLOEXEC,或 exec 前关闭
3退出码打印成 1792直接打印 status 而不是 WEXITSTATUS用状态宏
4慢系统调用莫名失败信号打断返回 EINTR循环重试,或 SA_RESTART(并非万能)
5handler 里死锁/崩溃调用了 printf/malloc/锁只做 write/赋值,复杂逻辑回主循环
6多个子进程只收到一次 SIGCHLD标准信号不排队handler 里 while (waitpid(-1,...,WNOHANG)>0)
7waitpid 不返回子进程被停止但没加 WUNTRACED按需加 WUNTRACED/WCONTINUED
8大量僵尸进程父进程没 waitSIGCHLD 回收、SA_NOCLDWAIT、或 subreaper
9多线程下信号只被一个线程收到进程级信号递送给任意未阻塞线程所有线程阻塞,专用线程 sigwait
10longjmp 后变量值不对自动变量未加 volatile跨 setjmp 的变量声明 volatile
11longjmp 后锁没释放/内存泄漏跳过中间栈帧的清理用 goto cleanup 或结构化错误处理
12从 handler 跳出后信号永久阻塞setjmp 不恢复信号掩码用 sigsetjmp(env,1)/siglongjmp
三个关于进程与信号的误解
  1. 「fork 会复制整个地址空间」——现代实现是 COW,只有页表复制;但「复制」的语义仍成立,子进程看不到父进程之后的修改。
  2. 「SIGCHLD 一定对应一个子进程退出」——它还可能是停止/继续(SA_NOCLDSTOP 可关),且多个退出可能合并成一个信号。
  3. 「kill(pid, 0) 能判断进程是否存在」——它只检查权限与是否存在;PID 可能已被复用,pidfd 才是可靠引用。

1.8.17 本章出口检查

检查项达标线没达标去哪
区分四类异常能说出返回地址与同步/异步1.8.1
解释缺页为何重试当前指令能联系 %cr2 与页表1.8.1
说出 fork 共享/复制什么至少包括 fd 偏移、pending 信号、COW1.8.3
写出无竞态的信号等待会用 sigprocmask + sigsuspend1.8.4
解释 SA_RESTART 的边界知道 epoll_wait 不会自动重启1.8.4
用 setjmp 做错误恢复知道局部变量要 volatile1.8.5
解释 Ctrl-Z 的完整链路终端驱动 → SIGTSTP → shell → SIGCONT1.8.6
即时自测 · 1.8

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 章 程序管理与 SELinux1.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 虚拟内存,从页表和地址翻译算起。