0%

异常控制流

现代系统通过使控制流发生突变来对这些情况做出反应。我们把这些突变 称为异常控制流 (ECF)。

理解 ECF 原因:

  1. 理解 ECF 将帮助你理解重要的系统概念。

  2. 理解 ECF 将帮助你理解应用程序是如何与操作系统交互的。

    应用程序通过使用一 个叫做陷阱或者系统调用的 ECF 形式,向操作系统请求服务。

  3. 理 解 ECF 将 帮 助 你 编 写 有 趣 的 新 应 用 程 序。

  4. 理解 ECF 将帮助你理解并发。

  5. 理解 ECF 将帮助你理解软件异常如何工作。

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

1 处理程序将控制返回给当前指令,即当事件发生时正在执行的指令。

2)处理程序将控制返回给如果没有发生异常将会执行的下一条指令。

3 处理程序终止被中断的程序。

异常:

异常类似于过程调用,但是有一些重要的不同之处 :

过程调用时,在跳转到处理程序之前,处理器将返回地址压人栈中。然而,根据异 常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一 条指令(如果事件不发生,将会在当前指令后执行的指令)。

处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被 中断的程序会需要这些状态。比如,x86 -64 系统会将包含当前条件码的 EFLAGS 寄存器和其他内容压人栈中。

如果控制从用户程序转移到内核,所有这些项目都被压到内核桟中,而不是压到用 户栈中。

异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都 有完全的访问权限。

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

中断 来自 I/O 设备的信号 异步 总是返回到下一条指令

陷阱 有意的异常 同步 总是返回到下一条指令

故障 潜在可恢复的错误 同步

可能返回到当前指令 终止 不可恢复的错误 同步 不会返回

异步异常是由处理器外部的 I/O设备中的事件产生的。同步异常 是执行一条指令的直接产物

陷阱和系统调用

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

普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型,而且它们 只能访问与调用函数相同的栈。系统调用运行在内核模式中,内核模式允许系统调用执行特 权指令,并访问定义在内核中的栈。

Linux/x86- 64 系统中的异常

0 31 的号码对应的是由 Intel 架构师定义的异常,因此对任何 x86-64 系统都 是一样的。32 255 的号码对应的是操作系统定义的中断和陷阱。

寄存器% rax 包含系统调用号

一4095 到一1 之间的负数返回值表明发生了错误,对应于负的 errno。

进程:

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

一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。

一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

并 发 流 :

一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个 流被称为并发地运行。

多个流并发地执行的一般现象被称为并发 。

一个进程和其他进程轮流运行的概念称为多任务。

一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。

用户模式和内核模式 :

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

用户程序必须通过系统调用接口间接地访问内核代码和数据。

Linux 提供了一种聪明的机制,叫做/proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文 本文件的层次结构。

上 下 文 切 换:

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

上下文就是内核重新启动一个被抢占的进 程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、 浮点寄存器、程 序计数器、用户桟、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页 表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

进程控制:

我们可以认为进程总是处于下面三种状态之一:

运行。进程要么在 CPU 上执行,要么在等待被执行且最终会被内核调度。

停止。进程的执行被挂起(suspended ), 且不会被调度。当收到 SIGSTOP、SIGT-STP、SIGTTIN 或者 SIGTTOU 信号时,进程就停止,并且保持停止直到它收到 一个 SIGCONT 信号,在这个时刻,进程再次开始运行。(信号是一种软件中断的 形式)

终止。进程永远地停止了。进程会因为三种原因终止:

1收到一个信号,该信号的 默认行为是终止进程

2从主程序返回

3调用 exit 函数。

信 号 :

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。

每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正 常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。

信号术语 :

发送 信 号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目 的进程。发送信号可以有如下两种原因:

1.内核检测到一个系统事件,比如除零错误或者子进程终止。

2.—个进程调了kill 函数,显式地要求内核发送一个信号给目的进程。进程可以发送信号给它自己 。

接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了 信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号。

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

一个待处理信号最多只能被接收一次。内核为每个进程在 pending 位向量中维护着 待处理信号的集合,而在 blocked 位向量6中维护着被阻塞的信号集合。只要传送了一个 类型为K的信号,内核就会设置 pending 中的第k位,而只要接收了一个类型为k的信 号,内核就会清除 pending 中的第k位 .

接 收 信 号:

当内核把进程从内核模式切换到用户模式时(例如,从系统调用返回或是完成了一 次上下文切换),它会检査进程的未被阻塞的待处理信号的集合(pending &~ blocked)。 如果这个集合为空(通常情况下),那么内核将控制传递到 p 的逻辑控制流中的下一条指令 。然而,如果集合是非空的,那么内核选择集合中的某个信号 k(通常是最小的k), 并且强制 P 接收信号k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行 为,那么控制就传递回 p 的逻辑控制流中的下一条指令。每个信号类型都有一个预 定义的默认行为 ,是下面中的一种:

进程终止。 • 进程终止并转储内存。

进程停止(挂起)直到被 SIGCONT 信号重启。

进程忽略该信号。

安全的信号处理 :

信号处理程序很麻烦是因为它们和主程序以及其他信号处理程序并发地运行。如果处理程序和主程序并发地访问同样的全局数据结构,那 么结果可能就不可预知,而且经常是致命的。 这里我们的目标是给你一些保守的编写处理程序的原则,使得这些处理程序能安全地并发运行。如果你忽视这些原则,就可能有引人细 微的并发错误的风险。如果有这些错误,程序可能在绝大部分时候都能正确工作。然而当 它出错的时候,就会错得不可预测和不可重复,这样是很难调试的。一定要防患于未然!

GO. 处理程序要尽可能简单。避免麻烦的最好方法是保持处理程序尽可能小和简 单。例如,处理程序可能只是简单地设置全局标志并立即返回;所有与接收信号相 关的处理都由主程序执行,它周期性地检查(并重置)这个标志。

G1. 在处理程序中只调用异步信号安全的函数。所谓异步信号安全的函数(或简称安全的函 数)能够被信号处理程序安全地调用,原因有二:要么它是可重入的(例如只访问局部变量),要么它不能被信号处理程序中断。

G2. 保存和恢复 errno。许多 Linux 异步信号安全的函数都会在出错返回时设置 errno.在处理程序中调用这样的函数可能会干扰主程序中其他依赖于 errno 的部 分。解决方法是在进人处理程序时把 errno 保存在一个局部变量中,在处理程序返 回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用 _exit终止该进程,那么就不需要这样做了。

G3. 阻塞所有的信号,保护对共享全局数据结构的访问。如果处理程序和主程序或其 他处理程序共享一个全局数据结构,那么在访问(读或者写)该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是从主程序访问一个数据结构d 通常需要一系列的指令,如果指令序列被访问 d 的处理程序中断,那么处理程序 可能会发现 的状态不一致,得到不可预知的结果。在访问 时暂时阻塞信号保证了 处理程序不会中断该指令序列。

G4. 用 volatile 声明全局变量。考虑一个处理程序和一个 main 函数,它们共享一个全 局变量 g。处理程序更新 g,main 周期性地读 g。对于一个优化编译器而言,main 中 g 的值看上去从来没有变化过,因此使用缓存在寄存器中 g 的副本来满足对 g 的每次引用 是很安全的。如果这样,main 函数可能永远都无法看到处理程序更新过的值。 可以用 volatile 类型限定符来定义一个变量,告诉编译器不要缓存这个变量。例如: volatile int g; volatile 限定符强迫编译器每次在代码中引用 g 时,都要从内存中读取 g 的 值。一般来说,和其他所有共享数据结构一样,应该暂时阻塞信号,保护每次对全 局变量的访问。

G5. 用 Sig_atomic_t 声明标志。在常见的处理程序设计中,处理程序会写全局标 志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对 于通过这种方式来共享的标志,C 提供一种整型数据类型 sig_atomic_t, 对它的 读和写保证会是原子的(不可中断的),因为可以用一条指令来实现它们: volatile sig_atomic_t flag; 因为它们是不可中断的,所以可以安全地读和写 Sig_atornic_t 变量,而不需 要暂时阻塞信号。注意,这里对原子性的保证只适用于单个的读和写,不适用于像 flag+ +或 flag=flag+10 这样的更新,它们可能需要多条指令。 要记住我们这里讲述的规则是保守的,也就是说它们不总是严格必需的。例如,如果 你知道处理程序绝对不会修改 errno, 那么就不需要保存和恢复 errno。或者如果你可以 证明 printf 的实例都不会被处理程序中断,那么在处理程序中调用 printf 就是安全的。 对共享全局数据结构的访问也是同样。不过,一般来说这种断言很难证明。所以我们建议 你采用保守的方法,遵循这些规则,使得处理程序尽可能简单,调用安全函数,保存和恢 复 errno,保护对共享数据结构的访问,并使用 volatile 和 sig_atomic_t。