系统通过使处理器控制流发生突变来对系统状态的变化作出反应,我们把这种突变称为异常控制流(Exceptional Control Flow,ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文转换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接受者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其它函数中任意位置的非本地跳转来对错误作出反应。
异常
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。处理器状态中的变化(事件)触发从应用程序到异常处理程序的突发的控制转移(异常)。在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止。
处理异常需要硬件和软件的紧密结合。系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其它号码是由操作系统内核的设计者分配的。前者包括被零除、缺页、存储器访问违例、断点以及算术溢出等,后者包括系统调用和来自外部I/O设备的信号等。
在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得条目k包含异常k的处理程序的地址。在运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的条目k转到相应的处理程序。异常号是在异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。
异常类似与过程调用,但是有一些重要的不同之处。
- 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然后,根据异常的类型,返回地址要么是当前指令,要么是下一条指令。
- 处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始被中断的程序会需要这些状态。
- 如果控制从一个用户程序转移到内核,那么所有这些项目都被压到内核栈中,而不是压到用户栈中。
- 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限。
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序通常称为中断处理程序(interrupt handler)。
陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。用户程序经常需要向内核请求服务,比如读一个文件(read)或创建一个新的进程(fork)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“systemcall n”指令,当用户程序想要请求服务n时,可以执行这条指令。执行systemcall指令会导致一个到异常处理程序的陷阱,这个处理程序对参数解码,并调用适当的内核程序。下表是IA32系统常用的系统调用。
编号 | 名字 | 描述 | 编号 | 名字 | 描述 |
---|---|---|---|---|---|
1 | exit | 结束进程 | 27 | alarm | 设置传送信号的警告时钟 |
2 | fork | 创建新进程 | 29 | pause | 挂起进程直到信号达到 |
3 | read | 读文件 | 37 | kill | 发送信号到另一个进程 |
4 | write | 写文件 | 48 | signal | 安装一个信号处理程序 |
5 | open | 打开文件 | 63 | dup2 | 复制文件描述符 |
6 | close | 关闭文件 | 64 | getppid | 获得父进程ID |
7 | waitpid | 等待子进程结束 | 65 | getpgrp | 获得进程组 |
11 | execve | 加载和运行程序 | 67 | sigaction | 安装可移植的信号处理程序 |
19 | lseek | 定位到文件偏移量处 | 90 | mmap | 将存储器页映射到文件 |
20 | getpid | 获得进程ID | 106 | stat | 获得有关文件的信息 |
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
经典的故障示例就是缺页异常。当指令引用一个虚拟地址,而与该地址相对应的物理页面不在存储器中,因此必须从磁盘中取出时,就会发生故障。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当再次执行时,相应的物理页面已经驻留在存储器中了,指令就可以没有故障地运行完成了。
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。
进程
进程就是一个执行中的程序的实例。系统中的每个程序都是运行在某个进程的上下文(context)中的。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在存储器中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程为应用程序提供两个关键的抽象:
- 一个独立的逻辑控制流。它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间。它提供一个假象,好像我们的程序独占地使用存储器系统。
进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占(preempted)(暂时挂起),然后轮到其它进程。对于一个运行在这些进程之一的上下文程序,它看上去就像是独占地使用处理器。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。多个流并发地执行的一般现象称为并发(concurrency)。一个进程和其它进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
一个进程为每个程序提供它自己的私有地址空间。尽管和每个私有地址空间相关联的存储器的内容一般是不同的,但是每个这样的空间都有相同的通用结构。
上图是一个 x86 Linux进程的地址空间的组织结构。地址空间底部是保留给用户程序的,包括通常的文本、数据、堆和栈段。对于32位进程来说,代码段从地址0x08048000开始,对于64位进程来说,代码段从地址0x00400000开始。地址空间顶部是保留给内核的,这个部分包含内核在代表进程执行指令时(比如应用程序执行一个系统调用)使用的代码、数据和栈。
为了保证一个完整的进程抽象,处理器必须提供一种机制,以限制一个应用程序可以执行的指令以及它可以访问的地址空间的范围。处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何存储器位置。当没有设置位模式时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变位模式,或者发起一个I/O操作;也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法就是通过诸如中断、故障或者陷入系统调用这样的异常来触发。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
Linux提供了一种聪明的机制,叫做 /proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。你可以用 /proc 文件系统找出一般的系统属性,如CPU类型(/proc/cpuinfo),或者某个特殊的进程使用的存储器段(/proc/
/maps)。2.6版本的Linux内核引入 /sys 文件系统,它输出关于系统总线和设备的额外的低层信息。
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定就叫做调度(schedule),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们就说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用上下文切换的机制来将控制转移到新的进程。
上下文切换的三个动作:
- 保存当前进程的上下文;
- 恢复某个先前被抢占的进程被保存的上下文;
- 将控制传递给这个新恢复的进程。
信号
Unix信号是一种更高层的软件形式的异常,它通知进程系统中发生了一个某种类型的事件,它允许进程中断其它进程。每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的;信号则提供了一种机制,通知用户进程发生了这些异常。
序号 | 名称 | 默认行为 | 相应事件 |
---|---|---|---|
1 | SIGHUP | 终止 | 终端线挂断 |
2 | SIGINT | 终止 | 来自键盘的中断 |
3 | SIGQUIT | 终止 | 来自键盘的退出 |
4 | SIGILL | 终止 | 非法指令 |
5 | SIGTRAP | 终止并转储存储器 | 跟踪陷阱 |
6 | SIGABRT | 终止并转储存储器 | 来自abort函数的终止信号 |
7 | SIGBUS | 终止 | 总线错误 |
8 | SIGFPE | 终止并转储存储器 | 浮点异常 |
9 | SIGKILL | 终止 | 杀死程序 |
10 | SIGUSR1 | 终止 | 用户定义的信号1 |
11 | SIGSEGV | 终止并转储存储器 | 无效的存储器引用(段故障) |
12 | SIGUSR2 | 终止 | 用户定义的信号2 |
13 | SIGPIPE | 终止 | 向一个没有读用户的管道做写操作 |
14 | SIGALRM | 终止 | 来自alarm函数的定时器信号 |
15 | SIGTERM | 终止 | 软件终止信号 |
16 | SIGSTKFLT | 终止 | 协处理器上的栈故障 |
17 | SIGCHILD | 忽略 | 一个子进程停止或终止 |
18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
19 | SIGSTOP | 停止直到下一个SIGCONT | 不来自终端的停止信号 |
20 | SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 |
21 | SIGTTIN | 停止直到下一个SIGCONT | 后台进程从终端读 |
22 | SIGTTOU | 停止直到下一个SIGCONT | 后台进程向终端协 |
23 | SIGURG | 忽略 | 套接字上的紧急情况 |
24 | SIGXCPU | 终止 | CPU时间限制超出 |
25 | SIGXFSZ | 终止 | 文件大小限制超出 |
26 | SIGVTALRM | 终止 | 虚拟定时器期满 |
27 | SIGPROF | 终止 | 剖析定时器期满 |
28 | SIGWINCH | 忽略 | 窗口大小变化 |
29 | SIGIO | 终止 | 在某个描述符上可执行I/O操作 |
30 | SIGPWR | 终止 | 电源故障 |
传送一个信号到目的进程是由两个不同步骤组成的:
发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号有两个原因:1)内核检测到一个系统事件,比如被零除错误或者子进程终止。2)一个进程调用了 kill 函数,显示地要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。
接收信号。当目的进程被内核强迫以某种方式对信号的发送作出反应时,目的进程就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。
一个只发出而没有被接收的信号叫做待处理信号(pending signal)。在任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,只是被简单地丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在 pending 位向量中维护着待处理信号的集合,而在 blocked 位向量中维护着被阻塞的信号集合。只要传送了一个类型为k的信号,内核就会设置 pending 中的第k位,而只要接收了一个类型位k的信号,内核就会清除pending中的第k位。
当内核从一个异常处理程序返回,准备将控制传递给进程p时,它会检查进程p的未被阻塞的待处理信号的集合(pending & ~blocked)。如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中的下一条指令。如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k。收到这个信号会触发进程的某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令。
每个信号类型都有一个预定义的默认行为,是下面中的一种:
- 进程终止。
- 进程终止并转储存储器。
- 进程停止直到被SIGCONT信号重启。
- 进程忽略该信号。
非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用—返回序列。非本地跳转是通过setjmp和longjmp函数来提供的。
setjmp函数在env缓冲区中保存当前调用环境,以供后面longjmp使用,并返回0.调用环境包括程序计数器、栈指针和通用目的寄存器。longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的,这样就不用费力地解开调用栈。另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。
C++和Java提供的异常机制是较高层次的,是C语言的setjmp和longjmp函数的更加结构化的版本。可以把try语句中的catch字句看作是类似于setjmp函数;相似地,throw语句就类似于longjmp函数。