异常控制流存在于系统的每个层级,最底层的机制称为异常(Exception),用以改变控制流以响应系统事件,通常是由硬件的操作系统共同实现的。更高层次的异常控制流包括进程切换(Process Context Switch)、信号(Signal)和非本地跳转(Nonlocal Jumps),也可以看做是一个从硬件过渡到操作系统,再从操作系统过渡到语言库的过程。进程切换是由硬件计时器和操作系统共同实现的,而信号则只是操作系统层面的概念了,到了非本地跳转就已经是在 C 运行时库中实现的了。
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。
异常就是控制流中的突变,用来响应处理器状态中的某些变化。
在任何情况下,当处理器检测到有事情发生时,他就会通过一张叫做异常表(exception table)的跳转表,进行一个简介过程调用(异常),到一个专门用来处理这类事件操作系统子程序(异常处理程序 exception handler)。当异常处理程序完成以后,根据引起异常的事件的类型,会发生以下3种情况的一种:
系统中的每一个可能的异常类型都被分配了一个唯一的非负整数的异常号,一些是处理器设计者分配的,一些是操作系统内核设计者分配的。前者包括被0除,缺页,内存访问违例,断点,以及算数运算溢出。后者包括系统调用和来自外部I\O设备的信号。
异常可以分为四类:中断,陷阱,故障,终止。
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常,例如系统调用(syscall) | 同步 | 总是返回到下一条指令 |
故障 | 无意可恢复的错误,如页缺失 | 同步 | 有可能返回到当前指令 |
中止 | 无意不可恢复的错误 | 同步 | 不会返回 |
1.中断
中断(Interrupt)称之为异步异常(Asynchronous Exception),是由处理器外面发生的事情引起的。对于执行程序来说,这种“中断”的发生完全是异步的,因为不知道什么时候会发生。CPU对其的响应也完全是被动的,但是可以屏蔽掉[1]。这种情况下:
比较常见的中断有两种:计时器中断和 I/O 中断。计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器终端来从用户程序手上拿回控制权。I/O 中断类型比较多样,比方说键盘输入了 ctrl-c,网络中一个包接收完毕,都会触发这样的中断。
2.陷阱
陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫系统调用。
用户程序经常需要向内核程序请求服务,比如读一个文件,创建一个新进程。加载一个新程序,或终止当前进程。
3.故障
故障由错误情况引起,但可能被故障处理程序修正。
一个典型的故障实例是缺页异常。
4.终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。
终止处理程序从不将控制返回给应用程序,而是返回给一个abort例程,该例程会终止该应用程序。
进程是计算机科学中最为重要的思想之一,进程才是程序(指令和数据)的真正运行实例。之所以重要,是因为进程给每个应用提供了两个非常关键的抽象:一是逻辑控制流,二是私有地址空间。逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。
进程是一个执行中的程序的实例。系统中的每个程序都是运行在某个进程的上下文中。上下文由程序正确运行所需的状态组成。这个状态包括,存放在存储器中的代码和数据,它的栈,通用目的寄存器内容,程序计数器,环境变量,以及打开文件描述符的集合。
每次用户向外壳(shell)运行一个可执行目标文件,shell会创建一个新的进程,然后再这个新进程的上下文中运行这个程序。应用程序也可以自己创建进程,并在创建的新进程中运行自己的代码或其他应用程序。
即使在系统中通常有很多程序在运行,进程也可以为每个程序提供一种假象,让它以为自己在独占CPU,而此时存在一系列程序计数器的值,即PC的值,这些值对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流。
一个逻辑流的执行在时间上与另个流重叠, 称为并发流( cocurrent flow),这两个流被称为并发地运行。更准确地说,流X和Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。不过,有时我们会发现确认并行流是很有帮助的,它是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),它们并行地运行(runningparallel),且并行地执行(parallel execution)。
进程为每个应用程序提供一个假象,好像它独占地使用系统地址空间。在一台有n位地址的机器上,地址空间是2n个可能地址的集合,0 ~ 2n-1。一般而言,和这个进程地址空间中的某个地址相关联的存储器字节,是不能被其他进程读写的。从这个意义上说,这个地址空间是私有的。
地址空间的顶部(?~ 2n-1)是保留给内核的。这个部分包含内核的代码、数据、堆、栈等。
处理器通常在某个控制寄存器中设置一个模式位(mode bit),该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
当程序切换到内核模式执行系统调用时,可能发生。如果系统调用因为某个等待的时间发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,一个read系统调用请求一个磁盘访问,内核可以选择执行上下文切换,运行另一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式的请求让调用进程休眠。
中断也可能引发上下文切换。每次发生定时器中断时,内核就判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
1.系统调用的错误处理
在遇到错误的时候,Linux 系统级函数通常会返回 -1 并且设置 errno 这个全局变量来表示错误的原因。使用的时候记住两个规则:
例如,对于 fork() 函数,我们应该这么写:
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0);
}
如果觉得这样写太麻烦,可以利用一个辅助函数:
void unix_error(char *msg) /* Unix-style error */
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
上面的片段可以写为
if ((pid = fork()) < 0)
unix_error("fork error");
我们甚至可以更进一步,把整个 fork() 包装起来,就可以自带错误处理,比如
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
调用的时候直接使用 pid = Fork(); 即可(注意这里是大写的 F)
2.获取进程信息
每个进程都有一个唯一的非零数进程ID。我们可以用下面两个函数获取进程的ID:
pid_t getpid(void) //返回当前进程的 PID
pid_t getppid(void) //返回当前进程的父进程的 PID
3.终止进程
在下面三种情况时,进程会被终止:
exit 函数会被调用一次,但从不返回,具体的函数原型是
// 以 status 状态终止进程,0 表示正常结束,非零则是出现了错误
void exit(int status)
4.创建进程
调用 fork 来创造新进程。这个函数执行一次,但是会返回两次,具体的函数原型为
int fork(void)
关于fork()函数的详细介绍及实例可以参见 异常控制流(一)fork()函数
5*. fork和execve的区别
fork函数在新的子进程中运行相同的程序,新的子进程时父进程的一个复制品。execve函数在当前进程的上下文中运行一个新的程序。它会覆盖当前进程的地址空间,但是并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时,已打开的所有文件描述符。
6*. 利用fork和execve运行程序
shell进程就是这样来执行命令。简单版如下,再结合waitpid就可以实现相应的后台执行等功能。
if(pid = fork() == 0)
{
if(execve(argv[0], argv, environ) < 0)
{
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}