第8章 异常控制流
异常控制流ECF的作用:
1、ECF是操作系统用来实现I/O、进程和虚拟存储器的基本机制。
2、应用软件通过一个人叫做陷阱或者系统调用的ECF形式,向操作系统请求服务。
3、操作系统给应用系统提供了强大的ECF机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件。
4、ECF是计算机系统中实现并发的基本机制。
5、应用层ECF中的非本地跳转是程序响应错误情况的方式。
8.1 异常
异常是异常控制流的一种形式,一部分由硬件实现,一部分由操作系统实现。
异常是控制流中的突变,用在响应处理器状态中的某些变化。在处理器中,状态被编码成不同的位和信号,状态变化称为事件,事件可能和当前指令的执行直接相关。
当处理器检测到有事件发生时,它会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生下面三种情况中的一种:
1、处理程序将控制返回给当前指令Icurr,即当时间发生时正在执行的指令。
2、处理程序将控制返回给Inext,即如果没有发生异常将会执行的下一条指令。
3、处理程序终止被中断的程序。
8.1.1 异常处理
当系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得条目k包含异常k的处理程序的地址。
下图是一张异常表的格式:
在运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k,随后,处理器触发异常,方法是执行间接过程调用,通过异常表的条目k转到相应的处理程序,下图是处理器如何使用异常表形成适当的异常处理程序的地址:
异常和过程调用的对比:
1、过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中,异常则根据类型,返回地址位当前指令或下一条指令。
2、处理器会把一些额外的处理器状态压到栈里,处理程序返回时,重新开始被中断的程序会需要这些状态。
3、如果控制从一个用户程序转移到内核,那么所有这些项目都被压到内栈中,而不是压到用户栈中。
4、异常处理程序运行在内核模式下,它们对所有的系统资源都有完全的访问权限。
8.1.2 异常的类别
中断——异步,陷阱 故障 终止——同步
一、中断
中断时异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的。硬件中断的异常处理程序通常称为中断处理程序。
下表是异常的类别:
下图概述了一个中断的处理:
二、陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
下图是一个陷阱处理:
三、故障
故障由错误情况引起,它可能能够被故障处理程序修正,故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正,那么它控制返回到引起故障的指令,重新执行它,否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
下图是一个故障的处理:
四、终止
1、linux/IA32故障和终止
2、linux/IA32系统调用
8.2 进程
异常是允许操作系统提供进程的概念所需要的基本构造快。
进程的经典定义就是一个执行中的程序的实例.系统中的每个程序都是运行在某个进程的上下文中的。上下文是由程序正确运行所需的状态组成的。
每次用户通过向外壳输入一个可执行目标文件的名字,并运行一个程序时,外壳就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
应用程序的关键抽象:
1、一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
2、一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统。
8.2.1 逻辑控制流
调用调试器单步执行程序,会看到一系列程序计数器(PC)的值,这些值唯一的对应于包含程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象中的指令,这个PC值的序列叫做逻辑控制流。
上图中的关键在于进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被强占(暂时挂起),然后轮到其他进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。
8.2.2 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流。
多个流并发地执行的一般现象称为并发。一个进程和其他进程轮流运行的概念称为多任务. 一个进程执行它的控制流的一部分的每一时间段叫做时间片 。因此,多任务也叫做时间分片 。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它为并行流,它们并行地运行且执行。
8.2.3 私有地址空间
一个进程为每个程序提供它自己的私有地址空间,一般而言,和这个空间中某个地址相关联的那个存储器字节是不能被其他进程读或写的,从这个意义上说,这个地址空间是私有的。
每个私有地址空间有着相同的通用结构,地址空间底部留给用户程序,包括通常的文本,数据,堆和栈段,地址空间顶部保留给内核,这个部分包含内核在代表进程执行指令时使用的代码、数据和栈。
8.2.4 用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器提供了一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程就运行在内核模式中,一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何存储器的位置。
没有设置模式位时,进程就运行在用户模式中,用户模式中的进程不允许执行特权指令也不允许进程直接引用地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接的访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的,进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常,异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式,处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
Linux 提供了一种聪明的机制,叫做/proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/proc 文件系统找出一般的系统属性。
8.2.5 上下文切换
操作系统内核使用上下文切换这种较高层次的一场控制流来实现多任务。
内核位每个进程维持一个上下文,上下文就是内核重新启动一个被强占的进程所需的状态,它由一些对象的值组成,这些值包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构,比如描绘地址空间的页表,包含有关相关进程信息的进程表,以及包含进程已打开文件信息的文件表。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定就叫做调度 ,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们就说内核调皮了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为土下丈切换的机制来将控制转移到新的进程。
上下文切换:
1)保存当前进程的上下文,
2) 恢复某个先前被抢占的进程被保存的上下文,
3) 将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为某个等待时间发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。
中断也可以发生上下文切换。
进程上下文切换的示例:
进程A运行在用户模式中,直到它通过执行系统调用read陷入到内核,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到存储器的数据传输后,磁盘中断处理器。
8.4 进程控制
8.4.1 获取进程ID
每个进程都有一个唯一的正数进程ID(PID)。getpid函数返回调用进程的PID。getppid函数返回它的父进程的PID。
#include <sys/types.h>
#include<unistd.h>
pid_t getpid(void)‘
pid_t getppid(void);
8.4.2 创建和终止进程
进程总是处于下面三种状态之一:
1、运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
2、停止。进程的执行被挂起,且不会被调度。当收到SIGSTOP\SIGTSTP\SIDTTIN或者SIGTTOU信号时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
3、终止。进程永远的停止了。进程会因为三种原因停止:1.收到一个信号,该信号的默认行为是终止进程。2.从主程序返回3.调用EXIT函数
exit函数以status退出状态来终止进程:
#include <stdlib.h>
void exit(int status);
父进程通过调用fork函数创建一个子进程:
#include <sys/types.h>
#include<unistd.h>
pid_t fork(void);
新创建的子进程几乎但不完全与父进程相同。当父进程调用FORK时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
FORK函数只被调用一次,却会返回两次:一次是在调用父进程中,一次是在新创建的子进程中。在父进程中,FORK返回子进程的PID。在子进程中,FORK返回0.因为子进程的PID总是非零的,返回值就提供一个明确的方法来分辨程序是在父进程还是子进程中执行的。
一个调用fork函数的例子:
8.4.3 回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。一个终止了但还未被回收的进程称为僵死进程。
一个进程可以通过调用WAITPID函数来等待它的子进程终止或者停止。
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
1、判定等待集合的成员
等待集合的成员是由参数PID来确定的:
1、如果PID>0,那么等待集合就是一个单独的子进程,它的进程ID等于PID。
2、如果PID=—1,那么等待集合就是由父进程所有的子进程组成的。
2、修改默认行为
可以通过将optioins 设置为常量WNOHANG 和WUNTRACED的各种组合,修改默认行为:
WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止的同时,如果还想做些有用的工作,这个选项会有用。
WUNTRACED :挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID 为导致返回的己终止或被停止子进程的PID。默认的行为是只返回己终止的子进程。当你想要检查已终止和被停止的子进程时,这个选项会有用。
WNOHANG阳UNTRACED: 立即返回,如果等待集合中没有任何子进程被停止或已终止,那么返回值为0 ,或者返回值等于那个被停止或者己终止的子进程的PID 。
3、检查已回收子进程的退出状态
如果status参数是非空的,那么waitpid就会在status参数中放上关于导致返回的子进程的状态信息。wait.h头文件定义解释了status参数的几个宏:
WIFEXITED:如果子进程通过调用exit或者一个返回正常终止,就返回真。
WEXITSTATUS:返回一个正常终止的自己成的退出状态,只有在WIFEXITED返回为真时,才会定义这个状态。
WIFSIGHALED:如果子进程是因为一个未被捕获的信号终止的,那么就返回为真。
WTERMSIG:返回导致子进程终止的信号的编号,只有在WIFSIGHALED返回为真时,才定义这个状态。
WIFSTOPPED:如果引起返回的子进程当前是被停止的,返回为真。
WSTOPSIG:返回引起子进程停止的信号的数量,只有在WIFSTOPPED返回为真时,才定义这个状态。
4、错误条件
如果调用进程没有子进程,那么WAITPID返回-1,并且设置ERRNO为ECHILD。如果WAITPID函数被一个信号中断,那么它返回-1.并设置ERRNO为EINTR。
5、wait函数
wait函数是waitpid函数的简单版本
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status);
调用wait(&status)等价于调用waitpid(-1,&status,0)。
8.4.4 让进程休眠
sleep函数将一个进程挂起一段指定的时间
#include<unistd.h>
unsigned int sleep(unsigned int secs);
如果请求的时间量已经到了,返回0,否则返回剩下的要休眠的秒数。
pause函数让调用函数休眠,直到该进程收到一个信号:
#include<unsitd.h>
int pause(void);
8.4.5 加载并行程序
execve函数在当前进程的上下文中加载并运行一个新程序
#include<unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp,只有当出现错误时,才会返回到调用程序,所以与fork调用一次返回两次不同,execve调用一次并从不返回。
参数列表由下图表示.ARGV变量指向一个以NULL结尾的指针数组,其中每个指针都指向一个参数串。ARGV[0]是可执行目标文件的名字。
ENVP变量指向一个以NULL结尾的指针数组,其中每个指针指向一个环境变量串。
在execve加载了filename后,它调用启动代码,启动代码设置栈,并将控制传递给新的主函数,主函数形式如下:
int main(int argc,char **argv,char **envp);
UNIX提供了几个函数来操作环境数组:
getenv函数在环境数组中搜索字符串"name = value".如果找到了,返回一个指向VALUE的指针,否则返回NULL。
#include<stdlib.h>
char *getenv(const char *name);
如果环境数组包含一个形如“name=oldvalue”的字符串,那么unsetenv会删除它,而sentenv会用newvalue代替oldvalue,但是只有在overwrite非零时才会这样,如果name不存在,那么setenv就把“name=oldvalue”添加到数组中。
#include<stdlib.h>
int serenv(const char *name,const char *newvalue, int overwrite);
void unsetenv(const char *name);
8.4.6 利用fork和execve运行程序
外壳是一个交互性的应用及程序,它代表用户运行其他程序,最早的歪歌是sh程序。
外壳执行一系列的读、求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
8.5 信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。更高层的软件形式的异常linux信号允许进程中断其他进程。
下图是一些linux信号
8.5.1 信号术语
传送一个信号到目的的进程由两个步骤组成:
1、发送信号:内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。
发送信号的原因:1)内核检测到一个系统事件;2)一个进程调用了kill函数
2、接收信号:进程可以忽略,终止,或者通过执行一个称为信号处理程序的用户层一个资金函数捕获这个信号。
一个只发出而没有被接受的信号叫做待处理信号。
一个类型至多只有一个待处理信号,如果已经有这个类型的待处理信号,那么后来的这种类型的信号都会被简单的丢弃。
一个进程可以选择性的阻塞接受某种信号,被阻塞仍可以被发送,但是不会被接收。
一个待处理信号最多只能被接收一次。
8.5.2 发送信号
一、进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的,一个子进程和它的父进程属于同一个进程组。
getpgrp函数返回当前进程的进程组ID,setpgid函数改变自己或者其他进程的进程组。
二、用/bin/kill程序发送信号
/bin/kill程序可以向另外的进程发送任意的信号,如:
/bin/kill -9 15213
发送信号9给进程15213.
一个负的PID会导致信号被发到PID组的每一个进程。
三、从键盘发送信号
任何时刻都只有一个前排作业和0个或者多个后台作业。
进程组ID是取自作业中父进程中的一个。
四、用kill函数发送信号
进程调用kill函数发送信号给别的进程(包括自己)。
如果pid大于0,kill函数发送信号sig给进程pid,如果pid小于0,那么kill发送信号sig给进程组abs(pid)中的每个进程。
五、用alarm函数发送信号
进程调用alarm函数向自己发送SIGALRM信号。
#include <unistd.h>
unsigned int alarm(unsigned int secs);
返回前一次闹钟剩余的秒数,若没有返回0.
8.5.3 接收信号
当内核从一个异常处理程序返回,准备将控制传递给进程p 时,它会检查进程p 的未被阻塞的待处理信号的集合 。如果这个集合为空(通常情况下),那么内核将控制传递到p 的逻辑控制流中的下一条指令。
然而,如果集合是非空的,那么内核选择集合中的某个信号k ( 通常是最小的k) , 并且强制p 接收信号k. 收到这个信号会触发进程的某种行为。一旦进程完成了这个行为,那么控制就传递回p 的逻辑控制流中的下一条指令 。每个信号类型都有一个预定义的默认行为,是下面中的一种:
进程终止。
进程终止并转储存储器(dump core) 。
进程停止直到被SIGCONT 信号重启.
进程忽略该信号。
signal函数:
#include<signal.h>
typedf void(*sighandler_t)(int);
sighandler_t signal(int sighum, sighandlei_t handlei);
若成功指向前次处理程序的指针,若出错则为SIG_ERR(不设置errno)
signal函数可以通过下面三种方法之一改变信号和信号signum相关联的行为:
如果handler 是SIG_IGN. 那么忽略类型为signum 的信号。
如果handler 是SIG_DFL,那么类型为signum 的信号行为恢复为默认行为。
否则, handler 就是用户定义的函数的地址,这个函数称为信号处理程序,只要进程接收到一个类型为signurn 的信号,就会调用这个程序。通过把处理程序的地址传递到signal 函数从而改变默认行为,这叫做设置信号处理程序。调用信号处理程序称为捕获信号。执行信号处理程序称为处理信号。
当一个进程捕获了一个类型为k的信号时,为信号k设置的处理程序被调用,一个整数参数被设置为k,这个参数允许同意同一个处理函数捕获不同类型的信号。
8.5.4 信号处理问题
信号处理问题:
1、待处理信号被阻塞:Unix 信号处理程序通常会阻塞当前处理程序正在处理的类型的待处理信号。比如,假设一个进程捕获了一个SIGINT 信号,并且当前正在运行它的SIGINT 处理程序。如果另一个SIGINT 信号传递到这个进程,那么这个SIGINT 将变成待处理的,但是不会被接收,直到处理程序返回。
2、待处理信号不会排队等待。任意类型至多只有一个待处理信号。因此,如果有两个类型为k 的信号传送到一个目的进程,而由于目的进程当前正在执行信号k 的处理程序,所以信号k 是阻塞的,那么第二个信号就被简单地丢弃,它不会排队等待。关键思想是存在一个待处理的信号仅仅表明至少已经有一个信号到达了。
3、系统调用可以被中断。像read 、wait 和accept 这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在某些系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno。设置为EINTR。
注意:不能用信号对其他进程中发生的事件计数。
8.5.5 可移植的信号处理
不同系统之间,信号处理语义的差异〈比如一个被中断的慢速系统调用是重启还是永久放弃)是Unix 信号处理的一个缺陷。为了处理这个问题, Posix 标准定义了sigaction 函数,它允许像Linux 和Solaris 这样与Posix 兼容的系统上的用户,明确地指定他们想要的信号处理语义。
#include<signal.h>
int sigaction(int sighnum, struct sigaction *act, strut sigaction *oldact);
若成功为0,出错为-1
sigaction 函数运用并不广泛,因为它要求用户设置多个结构条目。一个要简洁的方式,就是定义一个包装函数,称为Signal ,它调用sigaction 。Signal 的调用方式与signal 函数的调用方式一样。Signal 包装函数设置了一个信号处理程序,其信号处理语义如下:
只有这个处理程序当前正在处理的那种类型的信号被阻塞。
和所有信号实现一样,信号不会排队等待。
只要可能,被中断的系统调用会自动重启。
一旦设置了信号处理程序,它就会一直保持,直到Signal 带着handler 参数为SIG_IGN 或者SIG_DFL 被调用。(一些比较老的‘Unix 系统会在一个处理程序处理完一个信号之后,将信号行为恢复为它的默认行为。
8.5.6 显式地阻塞和取消阻塞信号
应用程序使用sigprocmask函数显式地阻塞和取消阻塞信号
#include<signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
sigprocmask 函数改变当前已阻塞信号的集合(8.5.1 节中描述的blocked 位向量〉。具体的行为依赖于how 的值:
SIG_BLOCK: 添加set 中的信号到blocked 中(blocked = blocked I set).
SIg_.UNBLOCK: 从blocked 中删除set 中的信号(blocked = blocked & -set) 。
SIG_SEtMASK : blocked = set 。
如果oldset 非空, blocked 位向量以前的值会保存在oldset 中。
可以使用下列函数操作像set 这样的信号集合。sigemptyset 初始化set 为空集。
sigfillset 函数将每个信号添加到set 中。sigaddset 函数添加signum 到set , sigdelset从set 中删除signum,如果signum 是set 的成员,那么sigismember 返回1 ,否则返回0。
8.6 非本地跳转
非本地跳转:控制直接从一个函数转移到另一个当前正在执行的函数,不经过正常的调用-返回序列。
非本地跳转是通过setjmp和longjmp 函数来提供的。
setjmp函数在ecv缓冲区中保存当前调用环境,以供后面longjmp使用,并返回0,调用环境包括程序计数器、栈指针、通用目的寄存器。
longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回,然后setjmp返回,并带有非零的返回值retval。
setjmp函数只被调用一次,但返回多次,longjmp函数被调用一次,但从不返回。
8.7 操作进程的工具
STRACE:打印一个正在运行的程序和他的子程序调用的每个系统调用的轨迹。
PS:列出当前系统中的进程,包括僵死进程。
TOP:打印出关于当前进程资源使用的信息。
PMAP:显示进程的存储器映射。
参考资料
教材
原文:http://www.cnblogs.com/echo178905/p/4985798.html