CSAPP-异常控制流

从给处理器上电开始,知道断电,程序计数器会按照一定的顺序执行指令,从一条指令地址转移到另外一条指令地址的过渡过程称为*控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流(flow of control 或者 control flow)*。

最简单的一种控制流是一个平滑流,即顺序执行的指令在内存中是相邻的。这种平滑流的突变通常是由跳转,调用和返回这样的一些熟悉的程序指令造成的。这样的一些指令都是必要的机制,使得程序能够对应程序变量表示的内部程序状态中的变化做出反应。

同时我们的系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如,中断,DMA等等。

现代系统中通过使控制流发生突变来对这些情况做出反应。一般来说,我们把这些突变称为*异常控制流(Exceptional Control Flow, ECF)*。异常控制发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层面,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

理解ECF可以帮助我们:

  1. 理解ECF将帮助我们理解重要的系统概念。
  2. 理解ECF将帮助你理解应用程序是如何与操作系统交互的。
  3. 理解ECF将帮助你编写有趣的新应用程序。
  4. 理解ECF将帮助你理解并发。
  5. 理解ECF将帮助你理解软件异常如何工作。

1. 异常

异常是异常流的一种形式,它一部分由硬件实现,一部分由操作系统实现。

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

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

  1. 处理程序将控制返回给当前指令I_curr,即当事件发生时正在执行的指令。
  2. 处理器将控制返回给I_next,即如果没有发生异常将会执行的下一条指令。
  3. 处理器终止 被中断的程序。

1.1 异常处理

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除,缺页,内存访问违例,断点以及算数运算溢出。后者包含来自系统调用和来自外部I/O设备的信号。

在系统上电启动的时候(计算机重启或上电),操作系统分配和初始化一张称为异常表的跳转表(可以理解为startup.s文件),使得表目k包含异常k的处理程序的地址。

在运行时(当系统在执行某个程序的时候),处理器检测到发生了一个事件,并且确定了相应的异常号k。随后处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序。异常号是到异常表中的索引,异常表的起始地址放在一个被叫做异常表基地址寄存器(exception table base register)的特殊CPU寄存器中。

异常类似于过程调用,但是也有一些不同的地方:

  • 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常类型,返回地址要么是当前指令(当事件发生的时候正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令)。
  • 处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。
  • 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
  • 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都是有完全访问权限。

一旦硬件触发异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的”从中断返回”指令,可选地返回到被中断程序,该指令将适当的状态弹回到处理器的控制器和数据寄存器中,如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制返回给被中断的程序。

1.2 异常的类别

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

类别 原因 异步/同步 返回行为 备注
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令 当用户程序向内核请求服务,调用syscall指令的时候
故障 潜在可恢复的错误 同步 可能返回到当前指令 触发故障将会进入故障恢复函数 |
终止 不可恢复的错误 同步 不会返回

1.3 Linux/x86-64 系统中的异常

X86-64系统中,有高达256种不同的异常类型。0~31的号码对应由Intel硬件架构定义的异常,因此对任何x86-64系统都是一样的。异常表如下:

异常号 描述 异常类别
0 除法错误 故障
13 一般保护故障 故障
14 缺页 故障
18 机器检查 终止
32~255 操作系统定义的异常 中断或陷阱

2 进程

异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻,最成功的概念之一。

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

进程可以提供给应用程序的关键抽象

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

多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为*多任务(multitasking)。一个进程执行它的控制流的一部分的每一段时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)*。

若果两个流并发的运行在不同的处理器核或者计算机上,那么我们称他们为并行流(parallel flow),它们并行地运行,并且并行的执行。

进程给每一个程序提供了一种假象,好像它独占的使用系统的地址空间。尽管每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构。

处理器提供一种机制,限制一个应用程序可以访问的地址空间范围。处理器通常通过某个控制寄存器中的一个模式位(mode bit)来提供这种功能,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

用户模式下的应用程序进程不允许执行特权指令,比如停止处理器,改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。

运行应用程序代码的进程出始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核摸式改回到用户模式。

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常流来实现多任务。类比调度器机制。

3. 系统调用错误处理

当Unix系统级函数遇到错误时候,它们通常会返回-1,并设置全局整数变量errno 来表示什么出错了。

4. 进程控制

Unix提供大量的C程序中操作进程的系统调用,主要如下:

  • 获取进程ID
  • 创建和终止进程 (程序状态运行,停止,终止)
  • 回收子进程
  • 进程休眠 (sleep)
  • 加载并运行程序
  • 利用fork和execve运行程序

程序是一堆代码和数据;程序可以作为目标文件存储在磁盘上,或者作为段存放在地址空间中。进程是执行程序的一个具体的实例;程序总是运行在某个进程的上下文中。

5 信号

这里将讨论一种更高层次的软件形式的异常,称为Linux信号,它允许进程和内核中断其他进程。

传送一个信号是由两个不同的步骤组成:发送信号和接受信号。

使用CTRL+C的组合键盘会导致内核发送一个SIGINT的信号到前台进程组中的每个进程。默认情况下,会终止前台作业。输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。

一个进程可以使用Kill函数发送信号给其他进程(包括它们自己),也可以使用alarm函数来发送SIGALRM信号。

当内核把进程p从内核模式切换到用户模式的时候(例如,从系统调用返回或者是完成一次上下文切换),它会检查进程p的未被阻塞的待处理信号的集合。如果这个集合是空那么就执行下一条指令。如果这个集合非空,那么内核选择集合中的某个信号k,并且强制p接收信号k。接收到信号k之后会触发采取某种行为。一旦进程完成这个行为,那么控制就传递到控制流中的下一条指令。每个信号都有一个预定义的默认行为,是下面的一种:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT信号重启
  • 进程忽略该信号

例如捕获Ctrl+C的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void sigint handler(int sig) /* SIGINT handler */ 
{
printf("Caught SIGINT!\n");
exit(0);
}

int main()
{
/* Install the SIGINT handler */
if (signal(SIGINT, sigint_handler) == SIG_ERR)
{
unix_error(nsignal error");
}

/* Wait for the receipt of a signal */
pause();
return 0;
}

Linux 提供阻塞信号的隐式和显式的机制。

信号处理是Linux系统编程中最棘手的一个问题。处理程序有几个属性使得它们很难分析处理:

  1. 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰。
  2. 如何以及何时接收信号的规则常常有违人的直觉;
  3. 不同的系统有不同的信号处理语意。

信号处理中复杂的情况是因为它们和主程序以及其他信号处理程序并发地运行,这里安全的信号处理有下面的几个原则:

  • 处理程序要尽可能简单;
  • 在处理程序中只调用异步信号安全的函数;
  • 保存和恢复errno;
  • 阻塞所有的信号,保存对共享全局数据结构的访问;
  • 用volatile声明全局变量;
  • 用 sig_atomic_t 声明标志;这个数据类型对它的写操作和读操作保证了会是原子的(不可中断的),因为可以用一条指令来实现它们。

信号的一个与直觉不符合的方面是未处理的信号是不排队的。因为pending位向量中每种类型的信号只对应有一位,所以每种类型最对只有一个未处理的信号。

sigsupend比起原来循环的版本没有那么浪费,避免了引入pause带来的竞争,又比sleep更有效率。

6 非本地跳转

C语言提供了一种用户级的异常流控制,称为*非本地跳转(nonlocal jump)*,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要进过正常的调用–返回序列。非本地跳转是通过setjmplongjmp函数来提供的。这里setjmp函数在env缓冲区中保存当前调用环境,提供给后面的longjmp使用,并返回0。longjmp函数从env中恢复调试环境,然后触发一个从最近一次初始化env的setjump调用的返回。longjmp允许它跳过所有的中间调用的特性和可能产生意外的后果。

类比理解setjmplongjmptry...catch

下面程序的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "csapp.h"

sigjmp_buf buf;

void handler(int sig)
{
siglongjump(buf, 1);
}

int main()
{
if (!sigsetjmp(buf, 1))
{
Signal(SIGINT, handler);
Sio_puts("starting\n");
}
else
Sio_puts("restarting\n");

while(1)
{
Sleep(1);
Sio_puts("processing...\n");
}
exit(0);
}

上面程序的输出:

1
2
3
4
5
6
7
8
9
10
linux> ./restart
starting
processing...
processing...
Ctrl+C
restarting
processing...
Ctrl+C
restarting
processing...

7 操作进程的工具

Linux 系统提供了大量的监控和操作进程的有用工具:

STRACE : 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用-static编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
PS : 列出当前系统中的进程(包括僵死进程)。
TOP : 打印出关于当前进程资源使用的信息。
PMAP : 显示进程的内存映射。
/proc : 一个虚拟文件系统,以 ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输入cat /proc/loadavg,可以看到你的 Linux 系统上当前的平均负载。