从给处理器上电开始,知道你断电为止,程序计数器(PC)持续地从一个值过渡到另一个值(指令地址),这样的整个过程称为处理器的控制流。
现代系统通过使控制流发生突变来对一些特殊情况做出反应。一般而言,我们把这些突变称为异常控制流(ECF)。异常控制流发生在计算机系统的各个层次。
一、异常
异常就是控制流中的突变,用来响应处理器状态中的某些变化。
在任何状况下,当处理器检测到有事件发生时,他就会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况中的一种:
- 处理程序将控制返回给当前指令,即当事件发生时正在执行的指令。
- 处理程序将控制返回给下一条指令,即如果没有发生异常将会执行的指令。
- 处理程序终止被中断的程序。
1. 异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违规、断点以及算数运算溢出。后者的示例包括系统调用和来自外部I/O设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。
在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号k。随后,处理器出发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应地处理程序。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里。
2. 异常的类别
异常可以分为四类:中断、陷阱、故障和终止。
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
注:异步异常是由处理器外部的I/O设备中的事件产生的。同步异常是执行一条指令的直接产物。
2.1 中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令。
2.2 陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
2.3 故障
故障由错误情况引起,他可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,他就将控制返回到引起故障的指令,从而重新执行他。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
2.4 终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
3. Linux系统中的异常
为了使描述更具体,让我们来看看x86-64系统定义的一些异常。有高达256种不同的异常类型。0~31的号码对应的是由Intel架构师定义的异常,因此对任何x86-64系统都是一样的。32~255的号码对应的是操作系统定义的中断和陷阱。
异常号 | 描述 | 异常类型 |
---|---|---|
0 | 除法错误 | 故障 |
13 | 一般保护错误 | 故障 |
14 | 缺页 | 故障 |
18 | 机器检查 | 终止 |
32~255 | 操作系统定义的异常 | 中断或陷阱 |
二、进程
异常是允许操作系统内核提供进程概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
关于操作系统如何实现进程的细节的讨论超出了本书的范围。反之,我们将关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,他提供一个假象,好像我们的程序独占的使用处理器。
- 一个私有的地址空间,他提供一个假象,好像我们的程序独占的使用内存系统。
1. 逻辑控制流
即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好想他在独占的使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称控制流。
2. 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发的运行。更准确的说,流X和Y相互并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。
多个流并发的执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
3. 私有地址空间
进程也为每个程序提供一种假象,好像他独占的使用系统地址空间。进程为每个程序提供他自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
4. 用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及他可以访问的地址空间范围。
处理器通常使用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口简洁的访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当他返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
5. 上下文切换
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在已经讨论过的那些较低层异常机制之上的。
内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,1)保存当前进程的上下文,2)回复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
三、系统调用错误处理
当Unix系统级函数遇到错误时,它们通常会返回-1,并设置全局整数变量errno来表示什么出错了。程序员应该总是检查错误。
strerror函数返回一个文本串,描述了和某个errno值相关联的错误。
四、进程控制
Unix提供了大量从C程序中操作进程的系统调用。
1. 获取进程ID
每个进程都有一个唯一的正数(非零)进程ID(PID)。getpid函数返回调用进程的PID。getppid函数返回它的父进程的PID(创建调用进程的进程)
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
2. 创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
- 运行。进程要么在CPU执行,要么在等待被执行且最终会被内核调度。
- 停止。进程的执行被挂起,且不会被调度。当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进程就停止,并且保持停止直到它收到一个SIGCOUT信号,在这个时刻,进程再次开始运行。
- 终止。进程永远的停止了。进程会因为三种原因终止:1)收到一个信号,该信号的默认行为是终止进程,2)从主程序返回,3)调用exit函数。
#include <stdlib.h>
void exit(int status);
exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。
父进程通过调用fork函数创建一个新的运行的子进程。
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于他们有不同的PID。
fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
3. 回收子进程
当一个进程是由于某种原因终止时,内核并不是立即把她从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此刻开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程。
如果一个父进程终止了,内核会安排init进程成为他的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,他不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排init进程去回收它们。不过,长时间运行的程序,比如shell或者服务器,总是应该回收他们的僵死子进程。即使僵死子进程没有运行,他们仍然消耗系统的内存资源。
一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止,并且回收。
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);
4. 让进程休眠
sleep函数将一个进程挂起一段指定的时间。
#include <unistd.h>
unsigned int sleep(unsigned int secs);
如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数。后一种情况是可能的,如果因为sleep函数被一个信号中断而过早的返回。
5. 加载并运行程序
execve函数在当前进程的上下文中加载并运行一个新程序。
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
五、信号
一个信号就是一条小消息,他通知进程系统中发生了一个某种类型的事件。如下图展示了Linux系统上支持的30种不同类型的信号。
每种信号类型都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
序号 | 名称 | 默认行为 | 相应事件 |
---|---|---|---|
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 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
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. 信号术语
传送一个信号到目的进程是由两个不同步骤组成的:
- 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号到目的进程。发送信号可以有如下两种原因:1)内核检测到一个系统事件,比如除零错误或者子进程终止。2)一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程。
- 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接受了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号。
2. 发送信号
2.1 进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID:
#include <unistd.h>
pid_t getpgrp(void);
默认的,一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用setpgid函数来改变自己或者其他进程的进程组:
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
2.2 用/bin/kill程序发送信号
/bin/kill程序可以向另外的进程发送任意的信号。
2.3 从键盘发送信号
Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。
2.4 用kill函数发送信号
进程通过调用kill函数发送信号给其他进程(包括他们自己)。
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
2.5 用alarm函数发送信号
进程可以通过调用alarm函数向他自己发送SIGALRM信号。
#include <unistd.h>
unsigned int alarm(unsigned int secs);
3. 接收信号
上面的表展示了与每个信号类型相关联的默认行为。比如,收到SIGKILL的默认行为就是终止接收进程。另外,接收到SIGCHLD的默认行为就是忽略这个信号。进程可以通过使用signal函数修改和信号相关联的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为是不能修改的。
#include <signal.h>
typedif void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
4. 阻塞和解除阻塞信号
Linux提供阻塞信号的隐式和显式的机制:
- 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
- 显式阻塞机制。应用程序可以使用sigprocmask函数和他的辅助函数,明确的阻塞和解除阻塞选定的信号。
六、非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转,他将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本地跳转是通过setjmp和longjmp函数来提供的。
#include <setjup.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
#include <setjup.h>
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力的解开调用栈。