Lecture 16: 进程与多任务 (Processes and Multitasking)

目录 · ← l15 · l17 →

Lecture 16: 进程与多任务 (Processes and Multitasking)

讲义对应:CMU 15-213 Lecture 16 — Processes and Multitasking(素材:F25-16-processes.txt;配套 recitation:F25-rec09_slides.txt教材对应:CS:APP3e 第 8 章 8.1–8.4(异常、进程、系统调用与进程控制) 关联 LabL6 Shell Lab(tsh——本讲是 Shell Lab 的全部基础

16.1 概述

从 1957 年 NASA 的 IBM 704 一次只能跑一个批处理作业,到今天一台笔记本上同时有几百个”任务”,中间隔着一整套操作系统机制。本讲的核心问题是:一台只有有限 CPU 核的机器,如何让许多程序”同时”运行,并且彼此互不干扰?

答案是操作系统提供的两个虚假承诺:”每个程序独占 CPU”(由上下文切换实现)与”每个程序独占内存”(由第 11–12 讲的虚拟内存实现)。支撑它们的底层机制是异常控制流(Exceptional Control Flow, ECF)——程序计数器从 $a_k$ 突然跳到 $b_k$ 的那种”突变”。承接 Lecture 15 的代码优化,本讲把视角从”一条指令怎么快”提升到”整个系统怎么调度”;通向 Lecture 17 的信号与非局部跳转,并直接为 L6 Shell Lab 铺路。

16.2 核心概念与底层机制图解

16.2.1 异常控制流(Exceptional Control Flow, ECF)

  • 定义与目的:ECF 是”处理器状态发生变化时,控制权从当前指令流转移到内核”的机制,存在于计算机系统的所有层次:低层是硬件 + 操作系统共同实现的异常(exception);高层依次是进程上下文切换(OS + 硬件定时器)、信号(OS 软件)、非局部跳转 setjmp/longjmp(C 运行时库)。本讲聚焦前两者,信号留到 Lecture 17。
  • 直观解释(”它是什么?”):普通控制流像一列按时刻表行驶的火车,只能沿轨道一站一站走(jmp/call/ret 还在轨道内)。ECF 则像车上的紧急制动 + 调度中心:定时器一跳闸(中断),火车必须立刻靠边,由调度中心决定下一趟走哪辆车。程序员看不到这个切换,但它的后果(并发、竞态)无处不在。
  • 底层机制图解
           用户代码 (user code)                内核代码 (kernel code)
        +---------------------------+      +---------------------------+
        |  I_current                |      |                           |
        |     ...                   |      |   异常处理程序 k           |
        |  I_next  <-- 返回目标      |      |   (exception handler k)   |
        +---------------------------+      +---------------------------+
                    |                                  ^
                    |  ① 事件发生 (event)               |  ③ 处理
                    v                                  |
              [ 处理器状态变化 ]  ──② 陷入(trap/中断)──>+
                    ^
                    |  ④ 三种返回方式之一:
                    |     · 返回到 I_current  (fault 重执行当前指令)
                    |     · 返回到 I_next     (interrupt / trap)
                    |     · 不返回,终止进程   (abort)

        异常表 Exception Table (又称中断向量 interrupt vector)
        +---------------------+        异常号 k  →  异常表中的下标
        |  0  handler 0 代码   |        (每个事件类型有唯一的 exception number k)
        |  1  handler 1 代码   |
        |  2  handler 2 代码   |
        | ...                 |
        |  n-1 handler n-1    |
        +---------------------+
  • 与机器码/硬件的对应:异常号 $k$ 加在异常表基址寄存器上,得到处理程序入口。用户程序请求内核服务时执行 syscall 指令(历史上是 int 0x80):系统调用号放在 %rax,参数依次放 %rdi, %rsi, %rdx, %r10, %r8, %r9,返回值放 %rax。真实的 open 封装:
00000000000e5d70 <__open>:
   ...
   e5d79:  b8 02 00 00 00   mov  $0x2,%eax   # open 是 2 号系统调用
   e5d7e:  0f 05            syscall          # 返回值在 %rax
   e5d80:  48 3d 01 f0 ff ff cmp $0xfffffffffffff001,%rax
   ...
   e5dfa:  c3               retq

它”几乎像一次函数调用”:同样有控制转移、同样用调用约定传参、同样在 %rax 拿结果,返回后执行下一条指令。但有一处根本不同——它由内核以更高特权执行,且失败时返回一个 $-1$ 到 $-4095$ 之间的负值(对应 errno 的负值)。

16.2.2 四类异常(Interrupt / Trap / Fault / Abort)

  • 定义与目的:按”事件来自处理器外部还是当前指令”以及”返回行为”划分,硬件 ECF 分成四类。异步异常(asynchronous exception) 由处理器外部事件引起,通过置位处理器的中断引脚(interrupt pin) 通知 CPU;同步异常(synchronous exception) 由正在执行的指令直接引起。
  • 直观解释:异步就像手机来电——不管你正在做什么都会打断你;同步就像你写字时笔断了——是你手上这个动作直接导致的后果。
  • 底层机制图解(对照表)
+----------+--------+----------+---------------------+---------------------------+
|  类别     | 原因    | 异步/同步 | 返回行为            | 典型例子                   |
+----------+--------+----------+---------------------+---------------------------+
| 中断      | 来自处理器|          | 返回到 I_next        | 定时器中断(每几毫秒)、      |
| Interrupt| 外部     | 异步     | (下一条指令)         | I/O 中断、Ctrl-C、网络包到达|
+----------+--------+----------+---------------------+---------------------------+
| 陷阱      | 有意为之 |          | 返回到 I_next        | 系统调用(syscall)、        |
| Trap     | 的指令   | 同步     | (下一条指令)         | gdb 断点                   |
+----------+--------+----------+---------------------+---------------------------+
| 故障      | 指令引起的|         | 重执行 I_current     | 缺页(page fault, 可恢复)、  |
| Fault    | 意外     | 同步     | 或终止进程           | 保护故障、浮点异常          |
+----------+--------+----------+---------------------+---------------------------+
| 终止      | 意外且   |          | 直接终止当前程序      | 非法指令、奇偶校验错、      |
| Abort    | 不可恢复 | 同步     | (不返回)            | 机器检查(machine check)    |
+----------+--------+----------+---------------------+---------------------------+
  • 与机器码/硬件的对应:x86-64 常见异常号(补充说明,取自教材 Figure 8.9):0 = 除法错误,13 = 一般保护故障,14 = 缺页,18 = 机器检查,32–255 由操作系统自定义(Linux 用 128 号即 0x80)。缺页最能说明 fault 的语义:
80483b7:  c7 05 10 9d 04 08 0d  movl  $0xd,0x8049d10   # a[500] = 13;

这条指令触发缺页 → 内核把页从磁盘调入内存 → 重新执行同一条 movl,这次命中。若访问的是非法地址(如 a[5000]),内核无法恢复,改向进程发送 SIGSEGV,进程以 “segmentation fault” 退出——同一个 fault 机制,两种结局。

16.2.3 用户模式与内核模式(User Mode / Kernel Mode)

  • 定义与目的:处理器用模式位(mode bit) 标记当前特权级。置位(内核模式)时进程可执行特权指令(privileged instructions)、访问全部内存与 I/O 端口;清零(用户模式)时受限。为什么需要它:若用户程序能随意改写页表、关中断、直接操作磁盘控制器,任何一个 bug 或恶意程序都能毁掉整台机器。模式位是”内核受保护、进程之间相互隔离”的最后一道物理防线。
  • 直观解释(”它是什么?”):普通员工(用户模式)只能进自己办公室和公共走廊;物业管理员(内核模式)持万能钥匙,能进配电间。唯一合法的”召唤管理员”方式,就是按下墙上的对讲机——这正好就是 syscall 指令所做的事。
  • 底层机制图解
      用户模式 (mode bit = 0)                 内核模式 (mode bit = 1)
   +------------------------+            +--------------------------+
   | 自己的代码/数据          |            | 页表、中断描述符表        |
   | 自己的栈、堆            |  syscall   | 设备寄存器、I/O 端口      |
   | 只能通过 /proc /sys 观察 | ─────────> | 特权指令 (cli/hlt/lgdt…)  |
   | 内核的"只读窗口"         | <───────── | 全部物理内存              |
   +------------------------+   ret       +--------------------------+
          /proc/PID/maps, /proc/PID/status, /sys/... 是用户态读内核状态的接口
  • 与机器码/硬件的对应syscall 之外的任何”越权”动作都会产生一般保护故障(异常号 13),内核据此终止进程。Linux 把 /proc/sys 实现为伪文件系统:读 /proc/self/maps(虚拟内存各区间映射)、/proc/self/status(任务状态)时,内核在 read 系统调用里现算现返回,磁盘上并不存在这些数据。

16.2.4 进程与它的两个关键抽象

  • 定义与目的进程(process)是一个执行中程序的实例。注意它与”程序”(磁盘上的静态文件)和”处理器”(执行单元)都不同。进程提供两个关键抽象:① 私有地址空间(private address space)——每个程序看起来独占主存,由虚拟内存实现;② 逻辑控制流(logical control flow)——每个程序看起来独占 CPU,由上下文切换实现。
  • 直观解释:进程就像独立公寓:墙(地址空间隔离)让你看不到也碰不到邻居的家具(其他进程的内存),水电闸(CPU、寄存器)也都是”你一个人的”——实际上整栋楼是分时共享的。
  • 底层机制图解(进程地址空间与页表)
     进程 A 的虚拟地址空间 (每个进程一份,独立)        0x00007fffffffffff
   +-------------------------------+  ^               +--------------------+
   | 内核虚拟内存(用户不可访问)      |  |               |  页表 (page table) |
   +-------------------------------+  |               |  由内核为每个进程维护 |
   | 用户栈  <- %rsp                |  |               +--------------------+
   |     ...                       |  | 高地址            VPN (虚拟页号)  PPN (物理页号)
   | 共享库映射区 (libc.so .text)    |  |                    0x7ffd... --> 0x0031a
   |     ...                       |  |                    0x00400... --> 0x008f2
   | 运行时堆 (malloc 分配)          |  |                    ...
   +-------------------------------+  |
   | 未初始化数据 .bss (demand-zero) |  |
   | 已初始化数据 .data(文件后备)    |  |
   | 程序代码 .text(文件后备,只读)  |  v
   +-------------------------------+  0x0000000000400000
   | 未使用                         |
   +-------------------------------+  0
              ↑ 每个进程都有独立的一套页表,把同一批虚拟地址
                映射到不同的物理页 —— 这就是"私有地址空间"的全部秘密
  • 与机器码/硬件的对应:每次上下文切换都要换页表基址寄存器(x86-64 的 %cr3),这使得 TLB 失效、地址翻译重新开始——这也是上下文切换比普通函数调用昂贵得多的原因之一。切换的触发者可以是 syscall(进程主动陷入内核,如 read 阻塞),也可以是定时器中断(内核借此夺回控制权,实现抢占式调度)。内核不是独立进程,它”跑在某个既有进程的上下文里”。

16.2.5 并发流、多任务与上下文切换

  • 定义与目的:让单个 CPU 核交错执行多个进程称为多任务(multitasking),分配的时间段称为时间片(time slice)。如果两个进程的执行在时间上重叠,则称它们是并发(concurrent)的;否则是顺序(sequential)的。保存当前进程寄存器、恢复下一个进程寄存器并切换地址空间的过程,就是上下文切换(context switch)
  • 关键区分并发只要求时间上重叠,并行(parallel) 要求物理上真正同时执行。单核上 A、B 只能并发(交错)而非并行;多核上才可能并行。多核共享主存(以及部分缓存),每个核可执行不同进程,把处理器调度到核上由内核完成
  • 底层机制图解(时间轴:父/子进程的逻辑控制流)
   时间 ──────────────────────────────────────────────────────────────>
   进程 A  ┌────┐        ┌────┐                    ┌──────┐
   (父)    │用户 │        │用户 │                    │用户   │
           └─┬──┘        └─┬──┘                    └──┬───┘
             │  syscall    │                          │
             v             v                          v
   内核     ┌─────────┐   ┌─────────┐              ┌─────────┐
            │ 调度/切换 │   │ 调度/切换 │              │ 调度/切换 │
            └────┬────┘   └────┬────┘              └────┬────┘
                 v             v                        v
   进程 B  ──────┴──┐     ┌────┴───┐              ┌─────┴────┐
   (子)            │用户  │        │用户           │  用户     │
                   └─────┘        └──────────────┘
          ↑ A 与 B 的执行区间在时间上重叠 => A 与 B 并发
            但在单核上任一时刻只有一个在真正执行 => 不是并行

   对比:B 完全在 A 结束之后才开始 => B 与 A 顺序(sequential)
  • 与机器码/硬件的对应:上下文切换保存的是寄存器状态 + 地址空间标识;单核实现”保存当前寄存器到内存 → 调度下一个进程 → 装载被保存的寄存器并切换地址空间”三步。

16.2.6 进程组与作业(Process Group / Job)

  • 定义与目的进程组(process group) 是一组进程的集合,由正整数 PGID 标识;setpgid(pid, pgid) 设置、getpgrp() 查询。作业(job) 是一次命令行解析所产生的全部子进程的总称。Shell 用进程组组织作业,从而支持作业控制(job control)
  • 直观解释:进程组就像”一个施工队”,队长的 PID 就是队号。要通知整个队停工,不必挨个打电话——冲着队号喊即可:kill(-pgid, sig) 中的负号正是”发给整组”。
  • 底层机制图解
   终端 (terminal driver)
     │  用户按 Ctrl-C / Ctrl-Z
     │  终端驱动【不知道】shell 的作业表,它只认一件事:
     v  把信号发给【前台进程组】(foreground process group)
   +-----------------------------------------------------+
   | 前台进程组 PGID = 100                               |
   |   [100] tsh 的子进程 (job 1)  ← 收到 SIGINT/SIGTSTP  |
   +-----------------------------------------------------+
   | 后台进程组 PGID = 105   (job 2)  —— 收不到,继续跑    |
   | 后台进程组 PGID = 110   (job 3)  —— 收不到,继续跑    |
   +-----------------------------------------------------+
   关键推论:子进程 fork 后必须 setpgid(0, 0) 把自己放进【新】进程组,
            否则它会留在 shell 的前台进程组里,Ctrl-C 会连带打死 shell 自己。
  • 与机器码/硬件的对应kill 系统调用(编号 62)的 pid 参数为负时表示”向 PGID = |pid| 的进程组发送信号”。这正是 Shell Lab 里 sigint_handler / sigtstp_handler 必须写 kill(-pid, SIGINT) 的原因——sdriver.pl 会专门测试这个错误。

16.3 代码示例与底层机制分析

16.3.1 示例一:fork —— 调用一次,返回两次

代码 (C)

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    int x = 1;
    pid_t pid;

    pid = fork();
    if (pid < 0) { perror("fork"); exit(1); }
    if (pid == 0) {
        /* 子进程:fork 在这里返回 0;它拥有 x 的独立副本 */
        x++;                    /* 只影响子进程的 x */
        printf("[child ] pid=%d ppid=%d | x=%d\n",
               (int)getpid(), (int)getppid(), x);
        return 0;               /* 或者 exit(0); 二者等价 */
    }
    /* 父进程:fork 在这里返回子进程 PID;它拥有 x 的另一份副本 */
    x--;                        /* 只影响父进程的 x */
    printf("[parent] pid=%d ppid=%d | x=%d\n",
           (int)getpid(), (int)getppid(), x);
    waitpid(pid, NULL, 0);      /* 回收子进程,避免僵尸 */
    return 0;
}

【代码做什么?】 ① 主进程调用 fork();② 内核复制出一个几乎相同的进程,把同一个 fork() 调用点变成两个返回点——父进程拿到子进程 PID,子进程拿到 0;③ 父子各自独立修改自己的 x(两边初值都是 1,之后互不影响);④ 父进程 waitpid 回收子进程。

【底层机制透视】 fork 的概念模型是”完整复制执行状态:寄存器、内存、被保存的寄存器,然后指定一个为父、一个为子”。逐字节复制内存极其昂贵,实现上采用写时复制(copy-on-write):父子先共享同一批只读物理页,任一进程试图写入时触发保护故障,内核才复制那一页。子进程还继承父进程的文件描述符stdin/stdout/stderr 指向同一批打开文件),所以父子输出会交错到同一终端上。

【内存布局 / 数据结构图解】 关键点是”同一个虚拟地址,两份不同的物理内容”:

   父进程虚拟地址空间              子进程虚拟地址空间
   +------------------+          +------------------+
   |  ...             |          |  ...             |   虚拟地址相同:
   |  x @ 0x7ffd1234  |          |  x @ 0x7ffd1234  |   x 都在 0x7ffd1234
   |    值 = 0        |          |    值 = 2        |   物理页不同!
   |  ...             |          |  ...             |
   +------------------+          +------------------+
   父进程 PID = 3575538           子进程 PID = 3575540
   父进程 PPID = 2126633 (shell)  子进程 PPID = 3575538 (父进程)

【与汇编 / 硬件的对应】 栈上的 x-4(%rbp) 之类的地址访问。fork 返回后,glibc 通过比较 %rax(系统调用返回值)区分父子:testq %rax,%rax; je .Lchild。x86-64 上 fork 实际由 clone(CLONE_CHILD_CLEARTID\|CLONE_CHILD_SETTID\|SIGCHLD) 实现。

【实测验证】 真实运行(gcc -g -Wall -std=c11 forkdemo.c -o forkdemo,本机 Linux x86-64):

$ ./forkdemo
[child ] pid=3575540 ppid=3575538 | x=2
[parent] pid=3575538 ppid=2126633 | x=0

无论跑多少次,每一行的内容都是确定的(子必打印 x=2ppid 等于父的 pid,父必打印 x=0),但两行的先后顺序不确定——讲义给出的三次运行结果中就有一次是子在前。strace -f -e trace=clone,execve,wait4 ./forkdemo 证实了这条链路:

execve("./forkdemo", ["./forkdemo"], ...) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...) = 3578767
[pid 3578763] wait4(3578767, <unfinished ...>
[pid 3578767] exit_group(0)             = ?
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=3578767, si_status=0} ---
<... wait4 resumed>NULL, 0, NULL)       = 3578767

16.3.2 示例二:真实的 fork 陷阱——输出重复

代码 (C)(⚠️ 仅供演示,请勿模仿):

/* forktrap_bad.c —— 故意制造"同一行打印两遍" */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    printf("BUFFERED-LINE");        /* 无换行、无 fflush:仍在用户态缓冲区 */
    if (fork() == 0) {
        exit(0);                    /* 子进程用 exit(),会冲刷 stdio 缓冲区 */
    }
    exit(0);                        /* 父进程也冲刷一次 */
}

【代码做什么?】 printf 的内容因为没有换行且 stdout 被重定向(全缓冲模式),停留在 glibc 用户态的 FILE 缓冲区里,一个字节都没写到内核。随后 fork() 复制整个地址空间——包括这个缓冲区。父子各自 exit() 时各冲刷一次,同一行于是被写两遍。

【底层机制透视】 这是”fork 复制地址空间”的直接后果,也是讲义”Shared open files”那句话的反面:打开文件表共享,stdio 缓冲区私有。修复只需两步(forkfflush,子进程用 _exit):

/* forktrap_good.c —— 正确做法 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    printf("BUFFERED-LINE");
    fflush(stdout);                 /* 关键:fork 前把用户态缓冲区排空 */
    if (fork() == 0) {
        _exit(0);                   /* 关键:_exit 不触碰 stdio 缓冲区 */
    }
    _exit(0);
}

【实测验证】 真实运行结果对比(两者都重定向到文件,即触发全缓冲):

$ ./forktrap_bad  > bad.out ; wc -c bad.out
26 bad.out

$ cat -A bad.out
BUFFERED-LINEBUFFERED-LINE        <- 同一行出现两次(26 = 13 × 2 字节)

$ ./forktrap_good > good.out ; wc -c good.out
13 good.out

$ cat good.out
BUFFERED-LINE                     <- 只出现一次(13 字节)

在终端上直接运行 forktrap_bad 却只能看到一行——stdout 连着 tty 时是行缓冲,而缓冲区里没有换行符。所以这个 bug 只在”输出重定向 + 未 fflush”时暴露,这也正是 Shell Lab 里到处要写 fflush(stdout) 的原因。

16.3.3 示例三:waitpid 与 status 解析

代码 (C)

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

static pid_t spawn(int code, int sig, const char *tag)
{
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); exit(1); }
    if (pid == 0) {
        if (sig != 0) {
            signal(sig, SIG_DFL);       /* 恢复默认动作,再自杀 */
            raise(sig);
            _exit(99);                  /* 若信号被忽略才会到这里 */
        }
        _exit(code & 0xff);             /* 退出码只保留低 8 位 */
    }
    printf("spawn %-10s -> pid=%d\n", tag, (int)pid);
    return pid;
}

static void report(pid_t pid)
{
    int status = -1;
    pid_t w = waitpid(pid, &status, 0);
    if (w < 0) { perror("waitpid"); return; }
    printf("  waitpid returned %d ; raw status = 0x%04x\n", (int)w, status);
    if (WIFEXITED(status))
        printf("  WIFEXITED=1  WEXITSTATUS=%d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("  WIFSIGNALED=1 WTERMSIG=%d (%s)  WCOREDUMP=%d\n",
               WTERMSIG(status), strsignal(WTERMSIG(status)), WCOREDUMP(status));
    else if (WIFSTOPPED(status))
        printf("  WIFSTOPPED=1 WSTOPSIG=%d\n", WSTOPSIG(status));
    printf("\n");
}

int main(void)
{
    printf("=== (a) 正常 exit(42) ===\n");      report(spawn(42, 0, "exit(42)"));
    printf("=== (b) 正常 exit(0) ===\n");       report(spawn(0, 0, "exit(0)"));
    printf("=== (c) 段错误 -> SIGSEGV(11) ===\n");
    {
        pid_t pid = fork();
        if (pid < 0) { perror("fork"); exit(1); }
        if (pid == 0) {
            volatile int *p = (int *)0;   /* ⚠️ 仅供演示:故意解引用空指针 */
            *p = 13;
            _exit(1);
        }
        printf("spawn %-10s -> pid=%d\n", "segv", (int)pid);
        report(pid);
    }
    printf("=== (d) 被 SIGINT(2) 杀死 ===\n");  report(spawn(0, SIGINT, "SIGINT"));
    printf("=== (e) exit(0x213) 只保留低 8 位 ===\n"); report(spawn(0x213, 0, "exit(0x213)"));
    return 0;
}

【实测验证】 真实运行输出:

$ ./waitsdemo
=== (a) 子进程正常 exit(42) ===
spawn exit(42)   -> pid=3576150
  waitpid returned 3576150 ; raw status = 0x2a00
  WIFEXITED=1  WEXITSTATUS=42

=== (b) 子进程正常 exit(0) ===
  waitpid returned 3576151 ; raw status = 0x0000
  WIFEXITED=1  WEXITSTATUS=0

=== (c) 子进程段错误 -> SIGSEGV(11) ===
  waitpid returned 3576152 ; raw status = 0x008b
  WIFSIGNALED=1 WTERMSIG=11 (Segmentation fault)  WCOREDUMP=128

=== (d) 子进程被 SIGINT(2) 杀死 ===
  waitpid returned 3576229 ; raw status = 0x0002
  WIFSIGNALED=1 WTERMSIG=2 (Interrupt)  WCOREDUMP=0

=== (e) 退出码 0x213 只保留低 8 位 ===
  waitpid returned 3576230 ; raw status = 0x1300
  WIFEXITED=1  WEXITSTATUS=19

【底层机制透视】 关键数字:0x2a00 = 42 « 8,说明正常退出的状态码放在 status 高字节0x8b = 139 = 128(core dump 标志)+ 11(SIGSEGV),说明信号号在低 7 位,第 8 位是 core dump 标志。recitation 的 exit(0x213) 得到 0x1300,因为 WEXITSTATUS 只返回 1 个字节——$0x213 \bmod 256 = 0x13 = 19$。

【速查表:waitpid 选项与 status 宏】

  pid_t waitpid(pid_t pid, int *status, int options)
  ┌──── pid 取值 ────────┬──────────────────────────────────────────────┐
  │ pid > 0              │ 只等 PID == pid 的那一个子进程                  │
  │ pid == -1            │ 等【任意】一个子进程(等价于 wait)              │
  │ pid == 0             │ 等与调用者同一进程组中的任意子进程               │
  │ pid < -1             │ 等进程组 |pid| 中的任意子进程                   │
  └──────────────────────┴──────────────────────────────────────────────┘
  ┌──── options 位 ──────┬──────────────────────────────────────────────┐
  │ 0                    │ 阻塞直到目标子进程终止/停止                     │
  │ WNOHANG              │ 非阻塞:无子进程可回收时【立即】返回 0            │
  │ WUNTRACED            │ 除终止外,也报告【被停止】的子进程                │
  │ WCONTINUED           │ 也报告收到 SIGCONT 而恢复运行的子进程             │
  └──────────────────────┴──────────────────────────────────────────────┘
  返回值:> 0 = 被回收子进程的 PID ;0 = 仅 WNOHANG 且有子进程但都未终止 ;
          -1 = 出错(无子进程时 errno == ECHILD)

  ┌──── status 位域 ────────────────────────────────────────────────────┐
  │  bit15..8          │ bit7 │ bit6..0                                  │
  │  退出状态 (exit code)│ coredump│ 信号号 或 停止信号号 或 0x7f(SIGCONT) │
  └────────────────────────────────────────────────────────────────────┘
  ┌──── 判定宏 ──────────┬──── 取值宏 ──────┬────────────────────────────┐
  │ WIFEXITED(st)        │ WEXITSTATUS(st)  │ 正常 exit / main 返回       │
  │ WIFSIGNALED(st)      │ WTERMSIG(st)     │ 被未捕获信号杀死            │
  │                      │ WCOREDUMP(st)    │ 是否产生了 core 文件        │
  │ WIFSTOPPED(st)       │ WSTOPSIG(st)     │ 被信号停止(需 WUNTRACED)   │
  │ WIFCONTINUED(st)     │ —                │ 被 SIGCONT 恢复(需 WCONTINUED)│
  └──────────────────────┴──────────────────┴────────────────────────────┘
  铁律:这些宏【必须】配合使用——先 WIFEXITED 判断,再取 WEXITSTATUS。
        直接读 WEXITSTATUS 一个被信号杀死的子进程,读出的是垃圾。

16.3.4 示例四:fork + execve 迷你 shell

代码 (C)

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define MAXLINE 1024
#define MAXARGS 64

int main(void)
{
    char  buf[MAXLINE];
    char *argv[MAXARGS];
    extern char **environ;

    while (1) {
        printf("minish> ");
        fflush(stdout);
        if (fgets(buf, MAXLINE, stdin) == NULL)
            break;                                  /* Ctrl-D / EOF */

        int argc = 0;
        char *tok = strtok(buf, " \t\n");
        while (tok != NULL && argc < MAXARGS - 1) {
            argv[argc++] = tok;
            tok = strtok(NULL, " \t\n");
        }
        argv[argc] = NULL;
        if (argc == 0) continue;                    /* 空行忽略 */

        if (strcmp(argv[0], "quit") == 0)           /* 内建命令 */
            break;

        pid_t pid = fork();
        if (pid < 0) { perror("fork"); exit(1); }
        if (pid == 0) {
            execve(argv[0], argv, environ);         /* 成功则永不返回 */
            fprintf(stderr, "minish: %s: %s\n", argv[0], strerror(errno));
            _exit(127);
        }
        int status;
        if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); exit(1); }
        if (WIFEXITED(status))
            printf("[exit status %d]\n", WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            printf("[killed by signal %d]\n", WTERMSIG(status));
    }
    printf("bye\n");
    return 0;
}

【代码做什么?】 读一行 → 按空白切词成 argv → 内建命令就地执行 → 否则 fork,子进程 execve 加载目标程序,父进程 waitpid 等前台作业结束并报告死因。这就是讲义 shellex.ceval 主干。

【底层机制透视】 execve(filename, argv, envp) 做四件事:① 用 filename 的内容覆盖当前进程的代码、数据、栈;② 按 argv 布置新用户栈(含 argcargv[]envp[]environ);③ 保留 PID、已打开的文件描述符、信号上下文;④ 把 PC 设为 .text 入口点。Linux 的实现是:释放旧 vm_area_struct 与页表 → 为新区域建 vm_area_struct 与页表(代码/已初始化数据由目标文件后备.bss 与栈由匿名文件后备)→ 设置 PC;代码页与数据页随后由缺页故障按需调入。”调用一次,永不返回”——除非出错(此时返回 $-1$ 并设 errno)。

【内存布局图解:新程序启动时的栈】

   高地址 (Bottom of stack)
   +--------------------------------+
   | 环境变量字符串 "USER=..."        |
   | ...                            |
   | 命令行参数字符串 "/bin/ls","-lt"  |
   +--------------------------------+
   | envp[n] == NULL                |  <- %rdx 指向 envp[]
   | ...                            |
   | envp[0] --+                    |
   | argv[argc] = NULL              |  <- %rsi 指向 argv[]
   | ...       |                    |
   | argv[0] --|--------------------+---> "/bin/ls"
   +--------------------------------+
   | 未来 main 的栈帧                 |
   +--------------------------------+
   | libc_start_main 的栈帧           |  <- argc 在 %rdi
   低地址 (Top of stack)

【实测验证】 真实的交互与输出(命令通过管道喂入):

$ printf '/bin/echo hello from execve\n/bin/ls -d /tmp\n/tmp/c16/selfsig 2\n/tmp/c16/nosuchprog\n/bin/sleep 2\nquit\n' | ./minish
minish> hello from execve
[exit status 0]
minish> /tmp
[exit status 0]
minish> selfsig: pid=3578370 raising signal 2
[killed by signal 2]
minish> minish: /tmp/c16/nosuchprog: No such file or directory
[exit status 127]
minish> [exit status 0]
minish> bye

三种结局都覆盖到了:/bin/echo 正常退出(status 0)、selfsig 2 被 SIGINT 杀死(WIFSIGNALED 路径)、不存在的程序走 execve 失败路径(127,与真实 shell 的”命令未找到”约定一致)。注意:execve 不搜索 PATH,必须给出完整路径——这也解释了讲义示例里”必须写 /bin/ls 而不是 ls“。若要搜索 PATH,应使用包装函数 execl/execv/execvpexecvp 才会搜索 PATH)。

16.3.5 示例五:SIGCHLD 回收循环与竞态防护

代码 (C)(节选,Shell Lab 的核心骨架)

/* 信号处理程序:一次 waitpid 调用只能回收一个孩子,必须循环 */
static void sigchld_handler(int sig)
{
    int olderrno = errno;                       /* 保存/恢复 errno */
    pid_t pid;
    int status;

    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        if (WIFEXITED(status)) {
            deletejob(pid);                     /* 正常终止:出队 */
        } else if (WIFSIGNALED(status)) {
            deletejob(pid);                     /* 被信号杀死:出队 */
        } else if (WIFSTOPPED(status)) {
            getjob(pid)->state = ST;            /* 被停止:【留在】作业表中 */
        }
    }
    errno = olderrno;
}

/* eval 中的经典竞态防护:先阻塞 SIGCHLD,fork,addjob,再解阻 */
sigset_t mask, prev;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev);           /* ① 阻塞 */
pid_t pid = fork();
if (pid == 0) {
    sigprocmask(SIG_SETMASK, &prev, NULL);      /* 子进程必须解除继承的屏蔽 */
    execve(argv[0], argv, environ);
    _exit(127);
}
addjob(jobs, pid, bg ? BG : FG, cmdline);       /* ② 登记 */
sigprocmask(SIG_SETMASK, &prev, NULL);          /* ③ 解阻 */
if (!bg) waitfg(pid);                           /* ④ 前台作业:等它结束 */
else     printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);

【代码做什么?】 ① 阻塞 SIGCHLD;② fork;③ 父进程 addjob 登记;④ 解阻。此后每当有子进程终止或停止,内核递达 SIGCHLD,处理程序用 while ((pid = waitpid(-1, &status, WNOHANG\|WUNTRACED)) > 0) 循环回收所有已终止/停止的子进程——因为信号不排队,多个孩子同时死掉只会递达一次 SIGCHLD,只调一次 waitpid 会漏掉其余的僵尸。

【底层机制透视:为什么必须先阻塞】 若不加阻塞,存在这样一个窗口:

   父进程                                子进程
   fork() ────────────────────────────────> 创建
     │  ← 此处被内核抢占,父进程还没跑 addjob
     │                                      exit(0) → 变成僵尸
     │                                      内核向父进程递达 SIGCHLD
     │  sigchld_handler 运行:
     │     waitpid(-1, ...) 回收了子进程
     │     deletejob(pid)  —— 但作业表里【根本没有这个 pid】!
     │                                       (deletejob 找不到,静默失败)
     │  addjob(pid, ...)  ← 迟到的登记,把一个【已死】的 pid 写进作业表
     v
   结果:作业表里留下一个永远无法回收的幽灵条目,jobs 命令谎报"运行中"。

阻塞 SIGCHLD 让”创建-登记”对信号处理程序而言成为原子段,从而封死这个窗口。另两个细节同样关键:子进程要解除继承的屏蔽(否则 exec 后的程序收不到 SIGCHLD),处理程序要保存并恢复 errno

【实测验证】 用三个”fork 完立刻退出”的子进程 + 一个自我停止的子进程验证,输出为:

$ ./reapdemo
  [handler] pid 3576245 正常退出, exit status 10
  [handler] pid 3576246 正常退出, exit status 11
  [handler] pid 3576247 正常退出, exit status 12
[main] 等待中... nreaped=3
  [handler] pid 3576248 被信号 20 停止(作业仍在表中)
[main] 全部回收完毕,nreaped=4

while 循环在一次 SIGCHLD 里连续回收了 3 个已终止的子进程;WUNTRACED 则让第 4 个SIGTSTP(20)停止的子进程也被报告出来——这正是 tsh 需要区分 ST(停止)与 BG(后台运行)状态的原因。

16.4 实验关联

L6 Shell Lab(tsh 是本讲的直接落地。任务是在 tsh.c 中补全七个函数:eval(解析并执行命令行,约 70 行)、builtin_cmd(识别 quit/jobs/bg/fg,25 行)、do_bgfg(50 行)、waitfg(等前台作业完成,20 行)、sigchld_handler(约 80 行)、sigint_handlersigtstp_handler(各 15 行)。评分:16 个 trace × 5 分 = 80 分正确性 + 10 分风格(注释 5 分,检查每一个系统调用的返回值 5 分)。

必须记住的三个实验要点:① sigint_handler/sigtstp_handler 里用 kill(-pid, sig) 发给整个前台进程组sdriver.pl 专门测试这个错误;② waitfg 用忙等循环(while (fgpid(jobs) == pid) sleep(...)),所有回收集中在 sigchld_handler,不要两边都调 waitpid;③ 子进程在 fork 之后、execve 之前调用 setpgid(0, 0) 自立进程组,否则 Ctrl-C 会连同 shell 一起打死。讲义还提醒:不要tsh 里运行 more/less/vi/emacs,它们会乱改终端设置;请用 /bin/ls/bin/ps/bin/echo 测试。背景知识按顺序读 CS:APP 第 8 章即可,本章已覆盖 8.1–8.4,信号部分见 Lecture 17。

16.5 常见错误与调试技巧

  • 僵尸进程堆积(zombie / defunct):子进程已终止但父进程未 waitps 中显示 <defunct>,占用 PID 与内核表项。调试ps -o pid,ppid,stat,cmd -e \| awk '$3 ~ /Z/';修法是父进程显式回收(shell/服务器这类长命进程必须回收)。
  • fork 后忘记 exit,子进程继续跑父进程代码:同一段逻辑执行两遍、输出翻倍,甚至递归 fork 出进程树。调试pstree -p <pid>strace -f -e trace=clone
  • 输出重复(stdio 缓冲区被 fork 复制):见 16.3.2。调试:把 stdout 重定向复现;strace -f -e trace=write 数 write 次数;修法是 fflush + _exit
  • execve 不搜索 PATH:写 ls 而不是 /bin/ls 会得到 No such file or directory调试:用 execvp;或 printenv PATH 确认目录列表。
  • 只调一次 waitpid 就以为收完了:信号不排队,多个子进程同时终止只会得到一次 SIGCHLD调试:处理程序里打日志计数,对照 ps --ppid <shell_pid> 剩余僵尸数;必须用 while (...) > 0
  • status 未解码:直接读 status 得到 0x2a00 而不是 42。调试:始终用 WIFEXITED/WEXITSTATUS;gdb 里 p/x status 看原始位。
  • Ctrl-C 连 shell 一起打死:子进程没 setpgid(0, 0),留在 shell 的前台进程组里。调试ps -o pid,pgid,cmd -e \| grep tsh 对比 PGID;handler 必须用 kill(-pid, sig),并保存/恢复 errno
  • Ctrl-C 连 shell 一起打死:子进程没 setpgid(0, 0),留在 shell 的前台进程组里。调试ps -o pid,pgid,cmd -e \| grep tsh 对比 PGID;handler 里必须用 kill(-pid, sig)

16.6 关键要点

  • 进程 = 执行中程序的实例,它靠两个虚构抽象活得好:私有地址空间(虚拟内存)与逻辑控制流(上下文切换)。
  • 异常是”控制流的突变”,四类各有返回纪律:中断与陷阱返回到 $I_{next}$,故障重执行 $I_{current}$ 或终止,终止永不返回。syscall 是”有意的陷阱”,是用户程序接触外部世界的唯一合法通道。
  • 充分的隔离需要硬件特权级:模式位把用户进程挡在页表、设备与特权指令之外,/proc/sys 是留给用户态的只读窗口。
  • fork 复制地址空间(写时复制),execve 覆盖地址空间(保留 PID)。二者组合是所有 shell 的全部秘密:fork + execve + waitpid
  • “创建资源 + 登记资源”的两步操作都要考虑信号竞态:阻塞信号 → forkaddjob → 解阻,顺序不能颠倒。同一模式在并发编程里会以”锁的顺序”再次出现(见 Lecture 21–22)。
  • 长命进程必须回收子进程,否则僵尸会把 PID 耗尽——shell 与服务器是仅有的两类”必须显式回收”的程序。

16.7 思考题(带答案)

题 1(推演题) 下面是 recitation 中的一段代码。请写出所有可能的输出行数与内容,并指出哪些输出顺序是确定的

int main(void) {
    int status;
    if (fork() == 0) {
        pid_t pid = fork();
        printf("Child: %d\n", getpid());
        if (pid == 0) exit(0);
    }
    pid_t pid = wait(&status);
    printf("Parent: %d\n", pid);
    exit(0);
}

:进程图有三个叶子(原始父 P、子 C、孙 G)。printf("Child") 会被 C 和 G 各执行一次printf("Parent") 会被 P 和 C 各执行一次——因为 C 在 fork 之后没有 exit,会继续落到下面的 wait/printf。所以恰好 4 行输出:两行 Child:、两行 Parent:。确定的约束是:① Child: <C的pid> 一定在 C 的 Parent: <G的pid> 之前(C 先 printf 再 wait);② P 的 Parent: <C的pid> 一定在 Child: <C的pid> 之后(P 通过 wait 等到 C 终止);③ 两个 Child 行之间、两个 Parent 行之间的相对次序都自由。两种可行交错是:Child(C), Child(G), Parent(C), Parent(P)Child(C), Parent(C), Child(G), Parent(P);而 Child(G) 出现在 Child(C) 之前不可行(recitation 的答案正是这两种)。

题 2 小明说:”我按 Ctrl-C 之后 shell 也退出了,所以 tsh 应该把 SIGINT 的默认动作设成 SIG_IGN 来避免被打死。” 这个想法错在哪?

:错在根因判断。默认情况下 shell 创建的子进程与 shell 同属一个前台进程组,Ctrl-C 由终端驱动发给整个前台进程组,因此 shell 与子进程都被打到——问题出在进程组划分,不在信号处理。正确做法是子进程在 fork 之后、execve 之前 setpgid(0, 0) 自立门户,shell 自己安装 handler 捕获 SIGINT,再 kill(-pid, SIGINT) 转发给前台作业。若改成 SIG_IGN,shell 确实不死了,但 Ctrl-C 会彻底失效,且 SIG_IGN 会被 execve 后的程序继承,行为完全失控。

题 3(计算题) 某长命服务器每秒 fork 一个子进程,子进程 10 ms 后退出,而服务器从不调用 wait。假设 PID 空间为 $32768$ 个且被僵尸占满后无法再创建新进程。问到系统无法 fork 需要多久?(忽略其他进程)

:每秒产生一个僵尸且永不复用,僵尸数随时间线性增长。需要 $32768$ 个僵尸,即约 $32768$ 秒 ≈ 9.1 小时。注意它不取决于子进程的执行时间(10 ms),只取决于创建速率与 PID 空间大小;实际 Linux 的 pid_max 默认是 $4194304$,对应约 48.5 天——但只要有别的进程也在消耗 PID,耗尽会快得多。结论:长命进程必须回收子进程。