笔记-CSAPP 第8章 异常控制流
This note is hosted on Notion: CSAPP 第8章 异常控制流
在给处理器加电开始,直到你断电为止,程序计数器假设一个值序列
$$ a_0,a_1,\cdots,a_{n-1} $$
其中,每个 $a_k$ 是每个相应的指令 $I_k$ 的地址。每次从 $a_k$ 到 $a_{k+1}$ 的过渡称为控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流。
最简单的一种控制流是一个“平滑的”序列,其中每个 $I_k$ 和 $I_{k+1}$ 在内存中都是相邻的。(这种)平滑流的突变(也就是 $I_{k+1}$ 与 $I_k$ 不相邻)通常是由注入跳转,调用和返回这样一些熟悉的程序指令造成的。这样一些指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化作出反应
现代程序通过控制流发生突变来对这些情况(硬件定时器信号、包到达网络适配器、子进程终止等系统状态变化)做出反应。一般而言,我们把这些突变称为异常控制流(Exceptional Control Flow,ECF)
- 异常不一定表示程序出错了,而是偏离普通顺序控制流的事务驱动跳转
异常
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化
状态变化称为事件
在任何情况下,当处理器检测到有事件发生时,它会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子系统(异常处理程序(exception handler))。当异常处理程序完成处理后,根据引发异常的事件类型,会发生以下3种情况的1种:
- 处理程序将控制返回给当前指令 $I_{curr}$,即当事件发生时正在执行的指令
- 处理程序将控制返回给 $I_{next}$,如果没有发生异常将会执行的下一条指令
- 处理程序终止被中断的程序
异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。其中一些号码是由处理器的设计者分配的。其他号码有由操作系统内核(操作系统常驻内存的部分)的设计者分配的。
前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。
后者的示例包括系统调用和来自外部 I/O 设备的信号。
在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目 $k$ 包含异常k的处理程序的地址。
异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表机制寄存器的特殊CPU寄存器中。
异常的类别
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自 I/O 设备的信号 | 异步 | 总是返回到下一条指令 |
| 陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
| 故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
| 终止 | 不可恢复的错误 | 同步 | 不会返回 |
异步异常是由处理器外部的 I/O 设备中的事件产生的。同步异常时执行一条指令的直接产物。
1. 中断
中断时异步发生的,是来自处理器外部的 I/O 设备信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序往往称为中断处理程序(interrupt handler)
剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction)
2. 陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。
陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用
执行 syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序
3. 故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。
4. 终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误,终止处理程序从不将控制返回给应用程序。
进程
异常是允许操作系统内核提供进程概念的基本构造块
进程的一个经典案例就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文。
进程提供给应用程序的关键假象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供了一个假象,好像我们的程序独占地使用内核系统。
逻辑控制流
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想要调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。
这个PC值的序列叫做逻辑控制流,或者简称逻辑流。
并发流
计算机系统中逻辑流有许多不同的形式。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。
多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
私有地址空间
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台 n 位的机器上,地址空间是 $2^n$ 个可能地址的集合, $0,1,\cdots,2^n-1$。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读写的。从这个意义上,这个地址空间是私有的。
用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及可以访问的地址空间范围
处理器通常是用某个控制寄存器的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可能执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),也不允许用户模式中的进程直接引用地址空间中内核。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接访问内核代码和数据。
运行引用程序代码的进程初始时是在用户模式的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回应用程序代码时,处理器就把模式改回用户模式。
上下文切换
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。
内核为每个进程维护一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成(对象:通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表,包含当前进程信息的进程表,以及包含进程已打开文件的文件表)
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种觉得叫做调度(Scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择了一个新的进程运行时,我们说内核调度了这个进程,在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来控制转移到新的进程,上下文切换 1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文 3)将控制传递给这个新恢复的进程
引发上下文切换:
- 内核代表用户执行系统调用
- 中断
回收子进程
父进程通过调用 fork 函数创建一个新的运行的子进程
exit 函数以 status退出状态来终止进程
当一个进程由于某种原因终止时,内核并不立即把它从系统中清除。相反,进程被保持在一中已终止的状态中,直到被它的父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程。
如果一个父进程终止了,内核会安排 init 进程成为它的孤儿进程的养父。 init 进程的 PID 为 1,是在系统启动时由内核创建的,它不会终止。是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排 init 进程去回收它们。
获取进程 ID
pid_t getpid(void);
pid_t getppid(void);
创建和终止进程
void exit(int status);
pid_t fork(void);
回收子进程
pid_t waitpid(pid_t pid, int *statusp, int options);
// 返回:如果成功,则为子进程pid,如果WHOHANG,则为0,如果其他错误,则为-1
pid_t wait(int *statusp); // waitpid(-1, &status, 0);
让进程休眠
unsigned int sleep(unsigned int secs);
int pause(void);
加载并运行程序
int execve(const char *filename, const char *argv[],
const char *envp[]);
信号
更高层次的软件形式的异常,称为 Linux 信号,它允许进程和内核中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
信号提供了一种机制,通知用户进程发生了这些异常。
在 Notion 参与讨论
本文托管在 Notion,欢迎到原文评论区留言交流