CSAPP 08 - 异常流

  异常是控制流中的突变,用来响应处理器状态中的某些变化。

基础

  从给处理器加电开始,假设 PC 寄存器的值为一个序列:a0 a1 … an-。其中 ak 是某个指令的地址,每次从 ak 到 ak+1 的过渡称为控制转移,这样的一个控制转移序列叫做处理器的控制流

  最简单的控制流是一个平滑的序列,其中每条指令在内存中都是相邻的。平滑流的突变通常是由诸如跳转,调用,返回这些指令造成的。这些指令使得程序能对使用程序变量表示的状态中的变化作出反应。

  除了这些程序变量表示的状态外,还存在系统状态,系统状态不被内部程序变量捕获,而且不一定和程序的执行相关。如硬件定时器产生的信号,程序向磁盘请求数据,进程终止等。

  对于上面的系统状态的突变,现代系统通过使控制流发生突变来作出反应。一般将这些突变称作异常控制流即 ECF,异常控制流发生在各个层次:硬件,操作系统和应用层。

异常

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

  当处理器状态发生一个重要的变化时,处理器正在处理当前指令 Icurr。在处理器中,状态的变化称为事件,事件可能与当前的指令执行有关,如尝试除零操作,发生虚拟内存缺页等。事件也可能与当前指令无关,如一个系统定时器发送的信号,一个 I/O 请求完成等。

  在任何情况下,处理器检测到由事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用 (异常),到一个专门设计用来处理这类事件的异常处理程序。当异常处理程序完成处理后,根据引起异常的事件类型,有三种情况:

  • 处理程序将控制返回当前指令 Icurr
  • 处理程序将控制返回没有发生异常时要执行的下一条指令 Inext
  • 处理程序终止被中断的程序。

异常处理

  系统为可能发生的每种异常都分配了一个唯一的非负整数的异常号。其中异常号是处理器设计者分配的,其他号码是操作系统内核设计者分配的。

  当计算机加电时,操作系统分配和初始化异常表,使得表目 k 包含异常 k 的处理程序的地址:

  当运行时,处理器检测到发生一个事件,并且确定其对应的异常号 k 后。处理器触发异常,具体就是执行间接过程调用,通过异常表取得异常处理程序的地址。

  异常表的起始地址放在异常表基址寄存器中,异常类似过程调用,但有一些不同之处:

  • 过程调用会将返回地址入栈,而异常会根据事件类型决定是返回当前指令,还是下一条指令。
  • 触发异常时,处理器也会通过压栈来保存一些当前的状态,并在返回时恢复。
  • 如果要保存状态信息,那么这些信息保存在内核栈上而不是程序栈上。
  • 异常处理程序运行在内核模式下,它对系统所有资源都有完全的访问权限。

  一旦触发硬件异常,那么就必须等待异常处理程序完成工作。它通过执行一条特殊的从中断处返回指令,可选地返回到中断处或者下一条指令,该指令会恢复中断前的状态,并且将状态恢复到用户模式 (如果是从用户态中断的话)。

异常类别

  异常可以分为四类:中断,陷阱,故障和终止:

  1. 中断

  中断是异步发生的,是收到来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由一条专门的指令造成的,硬件中断的异常处理程序称为中断处理程序

  如 I/O 设备,网络适配器,磁盘控制器,定时器的芯片等。他们通过向处理器芯片上的一个引脚发送信号,并将异常号发送到系统总线上,来触发中断,这个异常号标识了引起中断的设备:

  中断异常处理程序会将控制返回下一条指令。而剩下的异常类型都是同步发生的,是执行当前指令的结果,通常把这类指令叫做故障指令

  1. 陷阱和系统调用

  陷阱是有意的异常,是执行一条指令的结果。陷阱处理程序也将控制返回下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,被称为系统调用

  用户程序经常需要通过系统调用向内核请求服务,如读取文件、创建新进程、加载新程序和终止当前进程等。为了允许这些对内核服务的受控的访问,处理器提供了syscall n指令,当用户程序想请求服务 n 时,可以执行这条指令。执行 syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用相应的内核程序:

  在程序员看来,系统调用和常规函数没有什么区别。但常规函数运行在用户态,因此其可执行的指令类型受限,也只能访问用户栈。而系统调用运行在内核态,能够执行特权指令并访问内核栈。

  1. 故障

  故障由错误情况引起,它可能被故障处理程序修正。当故障发生时,处理器将控制移给故障处理程序。如果故障处理程序能够修复这个错误,它就将控制返回引起故障的指令,并重新执行它,否则,处理程序返回内核中的 abort 例程,abort 例程会终止引起故障的程序:

  经典的故障示例是缺页异常,当指令引用一个虚拟地址且该地址对应的物理页面不在内存时,此时必须从磁盘中取出就会引发故障。缺页处理程序从磁盘加载适当的页面后,就可以返回当前指令重新执行。

  1. 终止

  与故障相比,引发终止的错误状况无法挽救。通常是硬件出现问题,如 RAM 位损坏引起的奇偶校验错误。终止处理程序永远不会将控制权返回给应用程序,而是直接返回到 abort 例程来终止程序。

Linux/x86-64 异常

  x86-64 系统中定义的异常多达 256 种。其中号码 0~31 对应的是由 Intel 架构师定义的异常。32~255 对应的是是操作系统定义的中断和陷阱:

异常号 描述 异常类型
0 除法错误 故障
13 一般保护故障 故障
14 缺页 故障
18 机器检查 终止
32 - 255 操作系统定义的异常 中断或陷阱
  1. Linux/x86-64 故障和终止

  当指令试图进行除零操作时,会触发除法错误,Unix 不会试图从除法错误中恢复,而是直接终止程序,shell 一般会将除法错误报告为浮点异常

  一般保护故障通常是程序引用了一个未定义的虚拟内存区域,或者尝试写入一个只读的文本段。Linux 不会尝试恢复这类故障。shell 一般会将这种一般保护故障报告为段故障

  缺页故障可能会重新执行产生故障的指令,处理程序将磁盘上虚拟内存的一个页面映射到物理内存的一个页面后,会重新执行这条指令。

  机器检查故障是在执行指令时被检查到致命硬件故障后发生的,此时处理程序不会将控制返回程序。

  1. Linux/x86-64 系统调用

  Linux 提供几百种系统调用,当应用程序需要请求内核服务时可以使用,包括读写文件,创建进程等。每个系统调用都有一个唯一的整数号,对应一个到内核中的跳转表的偏移量 (和异常表不同)。

  C 程序可以使用 syscall 函数直接调用系统调用。同时,C 标准库提供了一组方便的包装函数,这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序。

  在 x86-64 系统上,系统调用通过 syscall 陷阱指令来提供。所有到 Linux 系统调用的参数都是通过通用寄存器而不是栈来传递的。按照惯例,寄存器 %rax 包含系统调用号,%rdi %rsi %rdx %r10 %r8 和 %r9 包含最多六个参数。从系统调用中返回时 %rcx %11 都会被破坏,%rax 包含返回值。

进程

  异常是允许操作系统内核提供进程概念的基本构造块,进程是计算机科需中最伟大的抽象之一。

  进程的经典定义是一个执行中程序的实例。系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需要的状态组成的。这里的状态包括:存放在内存中的代码和数据,他的栈,通用寄存器中的数据,程序计数器,环境变量,打开的文件描述符的集合。

  进程提供给应用程序两个关键的抽象:

  • 一个独立的逻辑控制流,好像程序在独占的使用处理器。
  • 一个私有的地址空间,好像程序在独占的使用内存系统。

逻辑控制流

  进程提供给程序一个假象:好像程序在独占的使用处理器。如果使用调试器单步执行程序,可以看到一系列的 PC 寄存器的值,这个 PC 值的序列叫做逻辑控制流简称逻辑流。

  下面是一个运行着三个进程的系统:

  上图的关键点在于进程是轮流使用处理器的。每个进程执行它流的一部分,然后被抢占,然后轮到其他进程。对于一个正在运行进程来说,它就像在独占的使用处理器。

并发流

  计算机系统中逻辑流有许多中形式:异常处理程序,进程,信号处理程序,线程等。一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流并发的运行。

  多个流并发的执行称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它控制流的一部分的一部分时间叫做时间片,因此多任务也叫时间分片

  并发流的思想与处理器核数和计算机数无关。如果两个流在时间上重叠,那么他们就是并发的,即使他们运行在一个处理器上。并行流是并发流的一个真子集,它明确指明:两个流并发的运行在两个不同的处理器核心上或计算机上。

私有地址空间

  进程提供给程序的另一个假象是:好像程序独占的使用系统地址空间。在一台 n 位地址机器上,地址空间是 2n 个可能的地址的集合。进程为每个程序提供他们自己的私有地址空间。一般而言,这个地址空间内的数据不能被其他进程读写。

  Linux/x86-64 系统提供了一个通用的地址空间组织结构:

  地址空间底部是保留给用户程序的,包括通常的代码,数据,堆和栈段。代码段总是从地址 0x400000 开始。地址空间的顶部保留给内核。

用户和内核模式

  为了提供完整的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。

  处理器通常是用某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权。当设置了该模式位时,进程就运行在内核模式,它可以执行指令集中的任何指令,并且可以访问系统中的所有内存位置。

  没有设置该模式位时,进程运行在用户模式,用户模式不允许执行特权指令如:停止处理器,改变模式位,发起 I/O 操作。也不允许引用地址空间中内核区内的代码和数据。任何这样的操作都会引起致命保护故障。

  应用程序的进程初始是在用户模式下的,进入内核模式的唯一方法是通过中断,故障或者陷入系统调用这样的异常。当异常发生时,控制转移到异常处理程序,处理器将设置模式位。处理程序运行在内核模式下,当它返回到应用程序代码时,处理器由将模式切换回用户模式。

上下文切换

  内核使用上下文切换这种较高层次形式的异常来实现多任务。上下文切换建立在以上较低层次的异常之上。

  内核为每个进程维护一个上下文。上下文是内核重新启动一个被抢占的进程所需要的状态。在进程执行的某个时刻,内核可以决定抢占当前进程,并重新开始一个进程。这种决策叫做调度,是由内核中的调度器代码处理的。调度主要完成三个动作:保存当前上下文,恢复某个先前被抢占进程的上下文,最后将控制传递给恢复的进程。

  当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为某个时间而被阻塞,内核可以让当前进程休眠,切换到另一个进程。一般而言,即使没有发生阻塞,内核也可以决定上下文切换:

进程控制

  Unix 提供了大量从 C 程序中操作进程的系统调用。

获取进程 ID

  每个进程都有一个唯一的非零正数的进程 ID (PID)。使用 getpid 函数可获取调用进程的 PID,getppid 函数返回其父进程的 PID:

pid_t getpid(void);
pid_t getppid(void);

  其中返回类型 pid_t 是 PID 的类型,在 Linux 系统中被定义为 int。

创建和终止进程

  从程序员的角度,可以认为进程总是处于以下三种状态之一:

  1. 运行:进程要么运行在 CPU 上,要么在等待运行且终将被调度。
  2. 停止:进程被挂起,且不会被调度。
  3. 终止:进程永远的被停止了。

  使用 exit 函数可以让调用进程以 status 退出状态来终止进程。

void exit(int status);

  父进程可以通过调用 fork 函数创建一个新的运行的子进程:

pid_t fork(void);

  新创建的子进程几乎和父进程相同:子进程得到父进程用户级虚拟地址空间的一个副本,包括代码,数据,堆,共享库,以及用户栈。子进程还得到了父进程所有打开文件的副本。父进程和子进程最大的不同就是他们的 PID。

  fork 函数被调用一次,但是返回两次:一次是在父进程中,fork 返回字进程的 PID。一次是在子进程中,fork 返回 0。因为进程的 PID 是非零的,所以返回值可以用来区分程序运行在父进程还是子进程。

回收子进程

  当一个进程由于某些原因终止时,内核不是立即将它从系统中清除。相反,进程被保持在一种终止的状态中,知道它被父进程回收。当父进程回收已经终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃子进程,从这时开始,该进程就不存在了。一个终止了,当还未被回收的进程称为僵死进程

  如果父进程也终止了,内核会安排 init 进程 (PID 为 1) 为它孤儿进程的养父。一个进程可以调用 waitpid 函数来等待它的子进程终止或停止:

pid_t waitpid(pid_t pid, int *statusp, int options);

  当 options 为 0 时 (默认情况),waitpid 会挂起调用进程,直到它的等待集合中的一个进程终止。如果等待集合中已经有一个进程终止了,那么 waitpid 会立即返回。这两种情况中,waitpid 都会返回终止进程的 PID,并且完成了子进程回收,内核会清除他们所有的痕迹。

  1. 等待集合的成员

  等待集合的成员由参数 pid 确定:

  • 如果 pid > 0:那么等待集合就是一个单独的进程,且进程的 PID 就是 pid。
  • 如果 pid = -1:那么等待集合就是由父进程创建的所有子进程组成的。
  1. 修改默认行为

  可以通过修改 options 的值为以下常量或组合来修改默认行为:

  • WNOHANG:如果等待集合中没有已经终止的进程,那么立即返回 (返回值为 0)。
  • WUNTRACED:挂起调用进程,直到等待集合中出现一个终止或停止的进程。返回该进程的 PID。默认行为只检测终止进程,这个选项可以用来检测终止或停止的子进程。
  • WCONTNUED:挂起调用进程,直到等待集合中一个进程终止或一个被停止的进程收到 SIGCONT 信号重新开始执行。
  1. 回收进程的退出状态

  如果 statusp 参数非空,那么 waitpid 会在其指向的值中放上关于进程退出的状态信息。

  1. 错误条件

  如果调用进程没有子进程,那么 waitpid 返回 -1 并设置 errno。如果 waitpid 函数被一个信号中断,那么它也返回 -1 并设置 errno。

  1. wait 函数

  wait 函数是 waitpid 函数的简化版本:

pid_t wait(int *statusp);

  相当于调用:waitpid(-1, &status, 0)。

进程休眠

  使用 sleep 函数让进程挂起一段指定的时间:

unsigned int sleep(unsigned int secs);

  如果请求时间到了,sleep 函数返回 0,否则返回还剩下要休眠的时间。后一种情况是有可能的:如果 sleep 函数被一个信号中断而过早的返回。

  或者使用 pause 函数,让调用进程陷入休眠,直到进程收到一个信号。

int pause(void);

加载运行程序

  使用 execve 函数可以在当前进程的上下文中加载并运行一个程序:

int execve(const char *filename, const char *argv[], const char *envp[]);

  execve 函数带着参数列表 argv 和环境变量列表 envp 加载并运行程序。只有当出现错误时,execve 函数才会返回到调用调用程序。与 fork 不同,execve 调用一次且从不返回。

  传递的参数中,argv 指向的是一个以 NUL 结尾的字符串数组,按照惯例,argv[0] 是程序名。环境变量的列表类似,envp 指向也是字符串数组。其中每个串都是形如 key=val 的键值对:

  execve 加载程序之后,它调用启动函数libc_start_main函数来初始化。并将控制传递给新程序的主函数 main:

int main(int argc, char **argv, char**envp);

  当 main 开始执行时,用户栈的组织如下:

  main 函数有三个参数,其中 argc 指出 argv 中非空指针的数量。argv 指向命令行参数列表的第一项。envp 指向环境变量列表中的第一项。

  可以使用以下函数操作环境变量数组:

char *getenv(const char *name);
int setenv(const char *name, const char *newvalue, int overwrite);

TODO 简单的 shell

  待更新。

信号

  Linux 信号是一种更高层的软件形式的异常,它允许进程和内核中断其他进程。

  一个信号就是一条消息,它通知进程系统中发生了一个某种类型的事件。底层的硬件异常是由内核异常处理程序处理的,通常对用户进程是不可见的。信号提供了另一种机制,允许用户进程接收到这些异常。

  信号可以由内核或者进程发送而来,Linux 系统上包含 30 种不同类型的信号:

信号术语

  发送一个信号到目的进程有两个步骤:

  1. 发送信号:内核通过更新目的进程上下文中的某个状态,发送一个信号给进程。发送信号可能有两种原因:内核检测到某个系统事件,或者某个进程调用了 kill 函数显示的发送信号。一个进程可以给自己发送信号。
  2. 接收信号:当目的进程被内核强迫以某种方式对信号的发送作出反应时,它就接收了信号。进程可以忽略信号,或者调用一个信号处理函数的用户级函数来捕获这个信号。

  一个发出但是没有被接收的信号叫做待处理信号,在任何时刻,一种类型最多只能有一个待处理信号,如果一个进程有一个类型为 k 的待处理信号,那么接下来发送来的任何类型为 k 的信号都不会排队,而是直接被丢弃。进程可以选择性的阻塞接收某种信号,当某种信号被阻塞时,它仍可以被发送,但是产生的信号不会被接收,直到进程取消阻塞。

  内核为每个进程维护着一个 pending 位向量,它记录待处理信号的集合。而 blocked 位向量则记录了被阻塞的信号集合。只要发送了类型为 k 的信号,内核就会设置 pending 向量中的第 k 位。只要接收了类型为 k 的向量,内核就会清除 pending 向量中的第 k 位。

发送信号

  Unix 系统提供的向进程发送信号的机制基于进程组概念。

  1. 进程组

  每个进程都只属于一个进程组,进程组是由一个正整数 ID 来标识的,可以使用 getpgrp 函数来获得:

pid_t getpgrp(void);

  默认的,子进程和他的父进程属于同一个进程组。可以使用函数 setpgid 来修改自己或其他进程的进程组:

int setpgid(pid_t pid, pid_t pgid);

  如果 pid 是 0,那么就改变当前进程。如果 pgid 是 0,那么就将 pid 进程的 PID 作为进程组 ID。

  1. 使用 kill 程序

  kill 程序可以向另外的进程发送任意信号:

kill -9 15213

  将信号 9 发送给进程 15213.一个负的 PID 会被当作进程组 ID,使得信号被发送给进程组中的每个进程中。

  1. 键盘发送信号

  Unix shell 使用作业概念来表示为对一条命令求值而创建的进程。任何时刻,最多只有一个前台作业和多个后台作业。shell 会为每个作业创建一个独立的进程组。进程组 ID 通常取自作业中父进程中的一个。

  1. 使用 kill 函数

  进程可以使用 kill 函数发送信号给其他进程 (包括自己):

int kill(pid_t pid, int sig);

  发送成功返回 0,否则返回 -1。如果 pid 大于零,则将 sig 发送给 pid 进程。如果 pid 等于零,则将信号 sig 发送给调用进程所在进程组中的所有进程,包括自己。如果 pid 小于零,则将 |pid| 当作进程组 ID,把 sig 发送给进程组中的每个进程。

  1. 使用 alarm 函数

  进程可以调用 alarm 函数向他自己发送 SIGALARM 信号:

unsigned int alarm(unsigned int secs);

  alarm 函数会安排内核在 secs 秒后发送一个 SIGALARM 信号给调用进程。对 alarm 函数调用会取消任何待处理的闹钟,并返回待处理闹钟剩余的秒数,如果没有待处理的闹钟,则返回 0。

接收信号

  当内核把进程 p 从内核模式切换到用户模式时 (例如从系统调用中返回,或是完成一次上下文切换)。他会检查进程 p 中未被阻塞的待处理信号集合 pending & ~blocked,如果集合为空,则内核将控制传递给 Inext。如果集合不为空,那么内核将选择集合中的某个信号,并且强制进程 p 接收信号。

  进程收到信号后会采取某种行为,完成该行为后,内核才会将控制返回给下一条指令。每个信号都关联一个预定义的默认行为,通常是下面的一种:

  • 进程终止。
  • 进程终止并转储内存。
  • 进程被挂起直到收到 SIGCONT。
  • 进程忽略该信号。

  除了 SIGSTOP 和 SIGKILL 信号的默认行为不能修改,其他信号关联的默认行为都能通过函数 signal 来修改:

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

  针对 handler 有三种情况:

  • 如果 handler 是 SIG_IGN,那么忽略类型为 signum 的信号。
  • 如果 handler 是 SIG_DEF,那么恢复类型为 signum 信号的默认行为。
  • 否则,handler 是一个用户自定义的函数地址,这个函数就是信号处理程序,只要进程接收到 signum 信号,内核就会调用这个程序。

  信号处理程序可以被其他信号处理程序中断,如图:

信号阻塞和解除

  Linux 提供显示和隐式两种阻塞信号的机制

  • 隐式阻塞机制:内核默认阻塞任何当前正在处理的信号类型。这是为了防止信号处理程序产生递归混乱。
  • 显示阻塞机制:可以使用 sigpromask 函数明确的阻塞和解除阻塞某种信号。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
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);

  sigpromask 函数通过改变位向量 blocked 来改变阻塞信号集合,具体行为依赖 how 值:

  • SIG_BLOCK:将 set 中的信号添加到 blocked 中。
  • SIG_UNBLOCK:从 blocked 中删除 set 中的信号。
  • SIG_SETMASK:是 block = set。

  如果 oldset 非空,那么 blocked 之前位向量中的值保存在 oldset 中。

信号处理程序

  编写信号处理程序需要考虑其几个属性:处理程序和主程序并发运行,共享同样的全局变量。如果以及及时及时接收信号的规则。不同系统有不同的信号处理语义。

  1. 安全的信号处理
  2. 正确的信号处理
  3. 可移植的信号处理

TODO 避免并发错误

  待更新。

TODO 显示等待信号

  待更新。

非本地跳转

  C 语言提供了一种用户级异常控制流形式,称为非本地跳转,它将控制从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用 - 返回序列。非本地跳转使用 setjmp 和 longjmp 实现:

int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);

  setjmp 函数在 env 缓冲区中保存当前调用环境(如程序计数器,栈指针,通用寄存器)。注意 setjmp 函数的返回值不能被赋值给变量,但是可以用于 switch 和 if 语句中。

  longjmp 函数从 env 缓冲区中恢复调用环境,然后返回到一个最近调用的 setjmp 函数上,setjmp 此时返回一个非零的值 retval。

  setjmp 函数调用一次,却返回两次。longjmp 调用一次,但是从不返回。非局部跳转的一个重要应用是可以在检测到某些错误条件时,从深度嵌套的函数调用中立即返回。非本地跳转直接返回到常见的错误处理程序,无需费力地展开栈。

jmp_buf buf;

int error1 = 0;
int error2 = 1;
void foo(void);
void bar(void);

int main()
{
    switch (setjmp(buf))
    {
    case 0:
        foo();
        break;
    case 1:
        printf("Detected an error1 condition in foo\n");
        break;
    case 2:
        printf("Detected an error2 condition in foo\n");
        break;
    default:
        printf("Unknown error condition in foo\n");
    }
}

void foo(void)
{
    if (error1)
        longjmp(buf, 1);
    bar();
}

void bar(void)
{
    if (error2)
        longjmp(buf, 2);
}