作者: andylin02
学习章节: 第 8 章 异常控制流
关键词: 异常控制流;异常;中断;陷阱;系统调用;进程;上下文切换;信号;非本地跳转;Shell Lab


引言:当程序“失控”时,谁在掌控局面?

“现代系统通过使控制流发生突变来对系统状态变化做出反应,这些突变称为异常控制流(Exceptional Control Flow,ECF)。” —— CSAPP 作者

在第 1-7 章中,我们关注的都是程序的正常运行:指令按顺序执行,函数调用和返回形成有规律的栈帧。然而,现实世界的程序并不总是这样“循规蹈矩”。当你在终端按下 Ctrl+C 时,程序是如何响应的?当程序发生除零错误或访问非法内存时,操作系统是如何介入的?当你运行多个程序时,CPU 是如何在它们之间切换的?

答案就是异常控制流。ECF 是操作系统为应用提供的一种访问处理器资源之外的能力,对应于嵌入式和 CPU 等硬件的中断概念。系统调用、进程管理、并发、I/O 访问都属于异常控制流

本章结构速览

  • 8.1 异常:异常处理机制、异常类别(中断/陷阱/故障/终止)
  • 8.2 进程:逻辑控制流、并发、上下文切换
  • 8.3 进程控制forkexitwaitexecve
  • 8.4 信号:信号发送与接收、信号处理、阻塞与未决信号
  • 8.5 非本地跳转setjmplongjmp
  • 8.6 配套实验:Shell Lab —— 实现支持作业控制的 shell

一、异常——ECF 的硬件基础

1.1 什么是异常?

异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。异常就是控制流中的突变,用来响应处理器中的一些变化。

┌─────────────────────────────────────────────────────────────────────┐
│                        异常处理流程                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  当前指令 Icurr ──→ 处理器检测到事件 ──→ 查找异常表 ──→ 异常处理程序  │
│       │                                      │                      │
│       │                                      ├──→ 返回 Icurr        │
│       │                                      ├──→ 返回 Inext        │
│       │                                      └──→ 终止程序         │
│       │                                                             │
│       └── 异常处理程序运行在**内核模式**,对系统资源有完全访问权限    │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

1.2 异常表与异常号

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。其中一些号码是由处理器的设计者分配的(如被零除、缺页、内存访问违例),其他号码是由操作系统内核的设计者分配的(如系统调用和来自外部 I/O 设备的信号)。

系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目 k 包含异常 k 的处理程序的地址。

1.3 四类异常

异常分为四类:中断、陷阱、故障、终止。其中中断属于异步异常,其他三种属于同步异常。

类别 同步/异步 返回位置 示例
中断 异步 下一条指令 I/O 设备信号、定时器
陷阱 同步 下一条指令 系统调用syscall
故障 同步 当前指令(可修复时) 缺页错误、保护错误
终止 同步 不返回 硬件错误
① 中断(Interrupt)

中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。

┌─────────────────────────────────────────────────────────────────────┐
│                        中断处理流程                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   处理器执行 Icurr                  中断处理程序                      │
│   ┌─────────┐                      ┌─────────┐                     │
│   │  取指    │──→ 检测中断引脚 ──→   │  处理   │ ──→ 返回 Inext      │
│   │  译码    │    (异步)           │  中断   │                     │
│   │  执行    │                      └─────────┘                     │
│   └─────────┘                                                       │
│                                                                      │
│   💡 特点:中断处理程序与进程没有任何关系,既没有参数也没有返回值     │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘
② 陷阱(Trap)与系统调用

陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用

// 汇编语言中进行系统调用
movq $SYS_write, %rax    // 系统调用号放在 %rax
movq $1, %rdi            // 第一个参数:文件描述符 stdout
movq $msg, %rsi          // 第二个参数:消息地址
movq $len, %rdx          // 第三个参数:消息长度
syscall                  // 触发陷阱,进入内核模式

💡 系统调用 vs 普通函数调用:普通函数运行在用户模式,系统调用运行在内核模式。普通函数只能访问用户空间的数据,系统调用可以访问系统所有资源。

③ 故障(Fault)

故障由错误情况引起,它可能被故障处理程序修正。如果修正成功,处理器重新执行当前指令(而非下一条)。

经典案例:缺页错误(Page Fault)

┌─────────────────────────────────────────────────────────────────────┐
│                        缺页故障处理流程                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   程序访问虚拟地址 →  MMU 查页表 → 页不在内存 → 触发缺页故障         │
│                              │                                      │
│                              ↓                                      │
│   缺页处理程序:将页面从磁盘加载到内存 → 更新页表                     │
│                              │                                      │
│                              ↓                                      │
│   返回并**重新执行**当前指令 → 这次就能命中                            │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

段故障(Segmentation Fault) 通常因为程序访问了受限的内存块导致的,如读未定义的虚拟内存,写只读内存等。这种故障无法修复,会导致程序终止。

④ 终止(Abort)

终止是发生了不可恢复的致命错误,通常是硬件错误,异常处理程序直接终止程序。

1.4 同步 vs 异步

  • 同步异常:由执行当前指令直接产生,与指令执行有因果关系。程序可以预期并在一定程度上控制这类异常。
  • 异步异常:由外部事件触发,与当前指令无关。程序无法预测其发生时机。

Linux/x86-64 系统中的异常示例

异常号 名称 类型
0 除法错误 故障
13 一般保护故障 故障
14 缺页错误 故障
18 机器检查 终止
0x80 系统调用 陷阱/中断

二、进程——ECF 的操作系统抽象

“进程提供给应用程序两个假象:1、独立的逻辑控制流(每个程序似乎独占 CPU);2、私有的空间地址(每个程序似乎独占内存)”

2.1 逻辑控制流与并发

逻辑控制流:对于一个进程而言,某段时间上执行该进程的整个控制流称为逻辑控制流。

并发流:一个逻辑流的执行在时间上与另一个流重叠,称为并发流。多个流并发的执行的现象被称为并发

  • 并发 ≠ 并行。并发是逻辑上的同时运行,并行是物理上的同时运行。
  • 并行流必须是在不同的处理器或计算机上同时运行。并行流一定是并发流,反之不一定。

多任务(multitasking):一个进程和其他进程轮流运行,每个进程执行它的控制流的一部分的每一段时间叫做时间片

2.2 私有地址空间

进程为每个程序提供一种假象:它似乎独占地使用系统的内存。每个进程看到的都是虚拟内存,拥有独立的地址空间。

2.3 用户模式与内核模式

处理器通过一个控制寄存器中的一个模式位来区分用户模式和内核模式。

模式 权限 如何进入
用户模式 不能执行特权指令,不能直接访问内核空间 初始状态
内核模式 可以执行任何指令,访问任何内存位置 通过异常(中断、陷阱、故障)

进程从用户模式变为内核模式的唯一方法是通过异常(如中断、故障、系统调用)。

2.4 上下文切换

操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。

┌─────────────────────────────────────────────────────────────────────┐
│                        上下文切换                                     │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   进程 A                      内核                      进程 B      │
│   ┌─────┐                    ┌─────┐                    ┌─────┐    │
│   │ 运行 │──→ 系统调用 ──→    │保存  │                    │     │    │
│   │     │                    │上下文│                    │     │    │
│   │     │                    │   A  │                    │     │    │
│   │     │                    │     │──→ 恢复上下文 B ──→ │ 运行 │    │
│   │     │                    │     │                    │     │    │
│   │     │←── 定时器中断 ──→   │保存  │                    │     │    │
│   │     │                    │上下文│                    │     │    │
│   │     │                    │   B  │                    │     │    │
│   │     │                    │     │──→ 恢复上下文 A ──→ │     │    │
│   └─────┘                    └─────┘                    └─────┘    │
│                                                                      │
│   上下文包含:寄存器、用户栈、内核栈、页表、进程表条目、文件表等      │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

调度时机:内核使用调度器对进程做上下文切换,切换时机由调度器决定,比如时间片用完、sleep、请求磁盘数据后等。

三、进程控制——系统调用的实战

3.1 获取进程 ID

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);   // 返回调用进程的 PID
pid_t getppid(void);  // 返回父进程的 PID

3.2 创建进程:fork()

fork() 创建一个与父进程几乎完全相同的子进程。这个"几乎完全相同"的特性,正是 fork 最容易被误解的地方。

#include <unistd.h>
#include <stdio.h>

int main() {
    int x = 1;
    pid_t pid = fork();
    
    if (pid == 0) {      // 子进程
        printf("Child: x = %d\n", ++x);
    } else if (pid > 0) { // 父进程
        printf("Parent: x = %d\n", --x);
    }
    return 0;
}

fork 的关键特性

特性 说明
调用一次,返回两次 父进程返回子进程 PID,子进程返回 0
写时复制(COW) 子进程不立即复制父进程内存,而是共享物理内存,直到任一进程修改数据
执行流分离 fork 后父子进程独立执行,调度顺序由 OS 决定
代码位置相同 子进程从 fork 调用处继续执行,不是从 main 开始

使用模式fork() + execve() 是创建新进程的经典模式——fork() 负责创建进程,execve() 负责替换程序。

3.3 进程终止:exit()

进程终止的原因:

  1. 收到一个信号,该信号的默认行为是终止进程
  2. 从主程序返回(main 函数返回等价于调用 exit
  3. 调用 exit 函数
#include <stdlib.h>
void exit(int status);   // 终止进程,status 为退出状态

3.4 回收子进程:wait() 与 waitpid()

当一个进程终止时,它会成为僵尸进程(zombie),直到被父进程回收。如果父进程在回收它的僵尸子进程前就终止了,则由 init 进程(所有进程的祖先)回收它们。

#include <sys/wait.h>
#include <unistd.h>

pid_t wait(int *status);           // 等待任意子进程
pid_t waitpid(pid_t pid, int *status, int options);  // 等待指定子进程

waitpid 参数说明

  • pid > 0:等待特定 PID 的子进程
  • pid == -1:等待任意子进程(与 wait 类似)
  • optionsWNOHANG(不阻塞)、WUNTRACED(等待停止的进程)等

3.5 让进程休眠:sleep()

#include <unistd.h>
unsigned int sleep(unsigned int secs);   // 休眠指定秒数
int pause(void);                         // 休眠直到收到信号

3.6 加载并运行程序:execve()

execveexec 家族中最基础的系统调用,其他 exec 函数都是基于它的封装。

#include <unistd.h>

int execve(const char *filename, char *const argv[], char *const envp[]);
  • filename:要执行的程序路径
  • argv:参数数组,以 NULL 结尾
  • envp:环境变量数组,以 NULL 结尾

关键特性execve 调用不返回——它用新程序替换当前进程的整个映像。只有在出错时(如找不到文件)才会返回。

// fork + execve 经典模式
pid_t pid = fork();
if (pid == 0) {              // 子进程
    char *argv[] = {"ls", "-l", "/", NULL};
    char *envp[] = {"PATH=/usr/bin", NULL};
    execve("/bin/ls", argv, envp);
    perror("execve failed"); // 只有出错才会执行到这里
    _exit(1);
} else {                     // 父进程
    wait(NULL);              // 等待子进程结束
}

四、信号——软件层面的异常

“信号允许进程和内核中断其他进程。信号可以理解为一条消息,一个事件。”

4.1 信号术语

术语 含义
发送信号 内核通过更新目标进程上下文中的某个状态,发送一个信号给目标进程
接收信号 目标进程对信号做出反应(执行处理程序、忽略或默认动作)
未决信号(pending) 发出但没有被接收的信号。一个类型最多只能有一个待处理信号
阻塞信号 进程可以选择性地阻塞特定信号。被阻塞的信号不会接收,直到解除阻塞

4.2 Linux 中的常见信号

信号 编号 默认动作 触发事件
SIGINT 2 终止 键盘 Ctrl+C
SIGTSTP 20 停止 键盘 Ctrl+Z
SIGCHLD 17 忽略 子进程停止或终止
SIGKILL 9 终止 无法捕获/忽略
SIGSTOP 19 停止 无法捕获/忽略
SIGSEGV 11 终止 段故障

⚠️ SIGKILLSIGSTOP 信号无法被捕获和忽略

4.3 发送信号

从内核发送

  • 硬件异常(除零、段故障)
  • 键盘中断(Ctrl+CSIGINTCtrl+ZSIGTSTP

从一个进程发送到另一个进程

#include <signal.h>

int kill(pid_t pid, int sig);   // 发送信号到指定进程
int raise(int sig);             // 发送信号到自身

发送到进程组:使用 kill(-pid, SIGINT) 可以向进程组中的所有进程发送信号。

4.4 接收信号

#include <signal.h>

// 旧版接口(不推荐,行为因平台而异)
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

// 推荐使用 sigaction(更可靠的信号处理)
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

sigaction 允许指定:

  • 处理函数
  • 信号处理期间要阻塞的信号掩码
  • 其他标志(如 SA_RESTART 自动重启被中断的系统调用)

💡 异步信号安全:信号处理函数中只能调用异步信号安全(async-signal-safe) 的函数(如 writewaitpid),不能调用 printfmallocexit 等非安全函数。

4.5 阻塞和解除阻塞信号

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

// 操作 sigset_t 的辅助函数
int sigemptyset(sigset_t *set);      // 清空信号集
int sigfillset(sigset_t *set);       // 填满所有信号
int sigaddset(sigset_t *set, int signum);   // 添加信号
int sigdelset(sigset_t *set, int signum);   // 删除信号
int sigismember(const sigset_t *set, int signum); // 检查成员

how 参数:

效果
SIG_BLOCK set 中的信号加入阻塞集合
SIG_UNBLOCK 从阻塞集合中移除 set 中的信号
SIG_SETMASK 将阻塞集合设置为 set

4.6 等待信号

#include <signal.h>

int sigsuspend(const sigset_t *mask);   // 原子操作:设置临时阻塞集并等待信号

4.7 信号的生命周期

┌─────────────────────────────────────────────────────────────────────┐
│                        信号生命周期                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   事件发生 → 内核发送信号 → 信号变为 pending → 进程接收 → 执行处理   │
│        │                                                           │
│        └── 如果被阻塞 → 信号保持 pending,解除阻塞后接收            │
│                                                                      │
│   💡 同一类型的 pending 信号最多只有一个,后续同类型信号被丢弃       │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

五、非本地跳转——跨越栈帧的跳转

5.1 setjmp 与 longjmp

非本地跳转允许程序违反调用/返回栈规则,直接从当前函数跳转到另一个函数的某个位置。

#include <setjmp.h>

int setjmp(jmp_buf env);      // 保存当前栈环境,返回 0
void longjmp(jmp_buf env, int val);  // 跳转到 setjmp 保存的位置
  • setjmp 调用一次,返回多次(初次调用返回 0,longjmp 跳转返回 val
  • longjmp 从不返回
#include <stdio.h>
#include <setjmp.h>

jmp_buf buf;

void error_handler() {
    printf("Error occurred!\n");
    longjmp(buf, 1);   // 跳回 main 中的 setjmp
}

int main() {
    if (setjmp(buf) == 0) {
        // 第一次调用,正常执行
        error_handler();
    } else {
        // 从 longjmp 返回,处理错误
        printf("Recovered from error\n");
    }
    return 0;
}

5.2 应用场景

  • 深度嵌套错误处理:避免多层函数返回
  • 信号处理程序返回:在捕获信号后跳回主程序特定位置
  • C++/Java 异常机制的底层实现(软件异常使用非本地跳转)

六、配套实验:Shell Lab

Shell Lab 要求学生实现一个支持作业控制的简单 Unix shell 程序,完美串联了进程控制、信号处理和进程组管理等核心系统编程概念。

6.1 实验要求

tsh 应支持以下特性:

  1. 前台/后台执行:命令行以 & 结尾则后台运行,否则前台运行
  2. 信号处理Ctrl+CSIGINT)终止前台作业;Ctrl+ZSIGTSTP)停止前台作业
  3. 作业标识:PID 或 JID(JID 前缀 %
  4. 内建命令
    • quit:终止 shell
    • jobs:列出所有后台作业
    • fg <job>:将作业移至前台,发送 SIGCONT
    • bg <job>:将作业移至后台,发送 SIGCONT
  5. 僵尸进程回收:通过 SIGCHLD 信号处理

6.2 核心函数

需要在 tsh.c 中实现以下函数:

函数 作用
eval 解析和解释命令行的主例程
builtin_cmd 识别和处理内建命令(quitfgbgjobs
do_bgfg 实现 fgbg 命令
waitfg 等待前台作业完成
sigchld_handler 捕获 SIGCHLD 信号(子进程终止或停止)
sigint_handler 捕获 SIGINTCtrl+C
sigtstp_handler 捕获 SIGTSTPCtrl+Z

6.3 关键技术点

① fork + execve 的 fork-and-exec 模型

if ((pid = fork()) == 0) {
    setpgid(0, 0);                     // 创建新的进程组
    if (execve(argv[0], argv, environ) < 0) {
        printf("%s: Command not found\n", argv[0]);
        exit(1);
    }
}

② 进程组管理:子进程调用 setpgid(0, 0) 创建新进程组,确保前台进程组只有 shell 本身,防止被信号错误影响。

③ 同步问题处理forkaddjob 前阻塞 SIGCHLD 信号

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL);   // fork 前阻塞 SIGCHLD

if ((pid = fork()) == 0) {
    sigprocmask(SIG_UNBLOCK, &mask, NULL);  // 子进程解除阻塞
    setpgid(0, 0);
    execve(...);
} else {
    addjob(...);                         // 父进程添加作业
    sigprocmask(SIG_UNBLOCK, &mask, NULL);  // 解除阻塞
}

这避免了子进程在父进程调用 addjob 之前就终止并发送 SIGCHLD 的情况。

④ 信号处理程序规范

  • sigaction 中使用 SA_RESTART 自动重启被中断的系统调用
  • 信号处理函数中只调用异步信号安全函数(writewaitpid,不要用 printf
  • waitpid 使用 WNOHANGWUNTRACED 选项

6.4 测试

使用提供的 sdriver.pl 和 16 个 trace*.txt 文件进行回归测试:

./sdriver.pl -a "-p" -t trace01.txt -s ./tsh
./sdriver.pl -a "-p" -t trace01.txt -s ./tshref  # 对比参考输出

七、本章知识点思维导图

第 8 章 异常控制流
│
├── 1. 异常(硬件/OS 层)
│   ├── 异常处理(异常表、异常号)
│   └── 四类异常
│       ├── 中断(异步、I/O 设备)
│       ├── 陷阱(同步、系统调用)
│       ├── 故障(同步、可能修复)
│       └── 终止(同步、不可修复)
│
├── 2. 进程
│   ├── 逻辑控制流与并发
│   ├── 私有地址空间
│   ├── 用户模式 vs 内核模式
│   └── 上下文切换
│
├── 3. 进程控制
│   ├── fork(写时复制、调用一次返回两次)
│   ├── exit
│   ├── wait / waitpid(回收僵尸进程)
│   ├── sleep
│   └── execve(替换进程映像)
│
├── 4. 信号
│   ├── 信号概念(发送、接收、pending、阻塞)
│   ├── 信号类型(SIGINT、SIGTSTP、SIGCHLD 等)
│   ├── 发送信号(kill、raise)
│   ├── 接收信号(signal、sigaction)
│   ├── 阻塞信号(sigprocmask)
│   └── 异步信号安全
│
├── 5. 非本地跳转
│   ├── setjmp / longjmp
│   └── 应用场景(深度错误处理、异常实现)
│
└── 6. Shell Lab
    ├── 功能要求(前台/后台、作业控制、信号处理)
    ├── 核心函数(eval、builtin_cmd、do_bgfg、waitfg、三个 handler)
    └── 关键技术(fork+execve、setpgid、信号阻塞)

八、本章小结

第 8 章深入计算机系统的底层控制流机制,从硬件异常到操作系统进程,再到软件信号,构建了一个完整的异常控制流体系:

  1. 异常机制:理解了中断(异步)、陷阱、故障、终止四类异常的区别,以及系统调用作为陷阱异常的本质
  2. 进程抽象:掌握了逻辑控制流、并发、上下文切换等核心概念,以及用户模式与内核模式的切换机制
  3. 进程控制系统调用:深入理解了 fork 的写时复制机制、execve 的进程映像替换、wait/waitpid 的僵尸进程回收
  4. 信号机制:掌握了信号的发送、接收、阻塞、未决状态,以及异步信号安全的注意事项
  5. 非本地跳转:理解了 setjmp/longjmp 的原理及应用场景
  6. Shell Lab 实验:通过实现支持作业控制的 shell,将进程控制和信号处理理论转化为实践

💡 本章最核心的三个洞察

  1. 异常是计算机系统各层次的统一机制:硬件用异常处理中断和故障,操作系统用异常实现系统调用,应用层用信号实现进程间通知
  2. fork + execve 是 Unix 创建新进程的经典模式fork 创建进程,execve 加载程序——两个系统调用的分离提供了极大的灵活性
  3. 信号处理是并发编程的入门:信号处理程序与主程序并发执行,需要同步机制避免竞争条件(如 Shell Lab 中的 addjob/deletejob 同步)

🔜 下一篇预告

下一章我们将进入 第 9 章:虚拟内存

这一章将深入探讨现代操作系统最核心的内存管理机制:

  • 📌 虚拟内存的概念:为什么每个进程都以为自己独占整个内存?
  • 📌 地址翻译:虚拟地址到物理地址的转换过程(MMU、页表、TLB)
  • 📌 分页机制:页表结构、多级页表、缺页异常
  • 📌 内存映射mmap 如何将文件映射到进程地址空间
  • 📌 动态内存分配mallocfree 的底层实现(隐式空闲链表、显式空闲链表、分离适配)
  • 📌 垃圾收集:保守的标记-清扫垃圾收集器
  • 📌 配套实验:Malloc Lab —— 实现一个动态内存分配器

第 9 章将揭开虚拟内存的神秘面纱,解释为什么现代计算机可以同时运行几十个程序而不会互相干扰。

敬请期待!


本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐