第8章异常控制流
控制流:控制转移序列。
控制转移:从一条指令到下一条指令。
异常控制流:现代操作系统通过使控制流发生突变来对系统状态做出反应,这些突变称为异常控制流。
一、异常
1、异常的剖析,如下图所示:
异常:控制流中的突变,用来响应处理器状态的某些变化。
Icurr:当前指令
事件:状态变化
2、异常处理
异常表:当处理器检测到有事件发生时,它会通过跳转表,进行一个间接过程调用(异常),到异常处理程序。
异常号:系统中可能的某种类型的异常都分配了一个唯一的非负整数的异常号。异常号是到异常表中的索引。
异常类似于过程调用,但有一些重要的不同之处。
一旦硬件触发了异常,异常处理程序则由软件完成。
3、异常的类别——中断、陷阱、故障和终止
a)中断处理:异步是指硬件中断不是由任何一条指令造成的,而是由外部I/O设备的事件造成的。
b)陷阱和系统调用:系统调用是一些封装好的函数,内部通过指令int n实现。
陷阱最重要的用途是提供系统调用。系统调用运行在内核模式中,并且可以访问内核中的栈。
系统调用的参数是通过通用寄存器而不是栈来传递的,如,%eax存储系统调用号,%ebx,%ecx,%edx,%esi,%edi,%ebp最多存储六个参数,%esp不能用,因为进入内核模式后,会覆盖掉它。
c)故障
d)终止
二、进程
(操作系统层):逻辑控制流,私有地址空间,多任务,并发,并行,上下文,上下文切换,调度。
进程就是一个执行中的程序实例。系统中的每个程序都是运行在某个进程的上下文中的。
进程提供给应用程序的关键抽象:a)一个独立的逻辑控制流 ;b)一个私有的地址空间
1、逻辑控制流
程序计数器(PC)值的序列叫做逻辑控制流,简称逻辑流。如下图所示,处理器的一个物理控制流分成了三个逻辑流,每个进程一个。
一些概念:并发流:并发流一个逻辑流的执行在时间上与另一个流重叠。
并发:多个流并发执行的一般现象称为并发。
多任务:多个进程并发叫做多任务。
并行:并发流在不同的cpu或计算机上
2、私有地址空间
一个进程为每个程序提供它自己的私有地址空间。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过异常。
linux提供了/proc文件系统,它允许用户模式进程访问内核数据结构的内容。
3、上下文切换,调度
上下文切换:操作系统内核使用叫上下文切换的异常控制流来实现多任务。
上下文切换:a)保存当前进程的上下文;b)恢复某个先前被抢占的进程被保存的上下文; c)将控制传递给这个新恢复的进程
调度:内核中的调度器实现调度。
当内核代表用户执行上下文切换时,可能会发生上下文切换。如果系统调用发生阻塞,那么内核可以让当前进程休眠,切换到另一个进程,如read系统调用,或者sleep会显示地请求让调用进程休眠。一般,即使系统调用没有阻塞,内核亦可以决定上下文切换,而不是将控制返回给调用进程。
中断也可能引起上下文切换。如,定时器中断。
4、进程控制
获取进程ID:
创建和终止进程:进程的三种状态——运行、停止和终止。进程会因为三种原因终止进程:收到信号,该信号默认终止进程;从主程序返回;调用exit函数。
父进程通过调用fork创建一个新的运行子进程:父进程与子进程有相同(但是独立的)地址空间,有相同的文件藐视符集合。
回收子进程:
回收:当一个进程终止时,内核并不立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。
僵死进程:一个终止了但是还未被回收的进程称为僵死进程。
回收子进程的两种方法:1,内核的init进程 2,父进程waitpid函数
1)如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排init进城来回收它们。init进程的PID为1,并且是在系统初始化时创建的。
2)一个进程可以通过调用waitpid函数来等待它的子进程终止或停止。
waitpid函数有点复杂,默认地(当options=0时),waitpid挂起调用进程的执行,知道它的等待集合中的一个子进程终止。
- 判定等待集合的成员
- 修改默认行为
- 检查已回收子进程的退出状态
- 错误条件
wait函数:
wait(&status)函数,等价于调用wait(-1,&status,0)
让进程休眠:
sleep函数将一个进程挂起一段指定的时间。
pause函数让调用函数休眠,知道该进程收到一个信号。
加载并运行程序:
环境数组操作函数:
三、信号
(操作系统和应用程序之间):进程之间传送信号
一种更高层次的软件形式的异常,称为unix信号,它允许进程中断其他进程。
低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
1、信号处理过程
1)发送信号:内核通过更新目的进程中上下文中的某个状态,发送一个信号给目的进程。发送信号有两个原因:a)内核检测到一个系统事件; b)一个进程调用kill函数,心事发送信号
2)接收信号:,目的进程就接收了信号。进程可以忽略这个信号,终止或者通过执行信号处理程序捕获这个信号。
注意:待处理信号,一种类型的信号只能有一种待处理信号,多余的不会排队,而是会舍掉 ; 信号还可以阻塞。
2、发送信号:/bin/kill , kill函数,键盘,alarm函数
进程组:每个进程都只属于一个进程组,进程组是由一个进程组ID来标识的。默认的,一个子进程和它的父进程同属于一个进程组。
在任何时刻,至多只有一个前台作业和0个或多个后台作业。外壳为每个作业创建一个独立的进程组,一个作业对应一个进程组。
用kill函数发送信号:发送SIGKILL信号
用alarm函数发送信号:发送SOGALARM信号
3、接收信号
进程可以通过使用signal函数来修改和信号相关的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为不能被修改。
4、信号处理问题
当一个程序捕获多个信号时,容易有一些细问问题:
- 待处理信号不会排队等待
- 待处理信号被阻塞
- 系统调用被中断
程序示例:
缺陷程序如下:
输出如下:
从输出中可以看出,尽管发送了3个SIGCHILD信号,但是只有两个信号被接受了。原因是,3~12行程序,每个子进程结束,可以触发一个该信号;一次该函数调用只能处理一个SIGCHILD信号。
修改后的程序如下:
3~14行程序,虽然仍然是一个子进程结束出发一个信号,单该函数通过循环,能尽可能多的处理多个SIGCHILD信号;同时,34~36行,防止系统调用被中断,手动启动系统调用。
5、可移植的信号处理:目的是为了统一同一信号在不通系统中的语义。sigaction函数,或者是它的包装函数Signal函数。
6、显示地阻塞和取消阻塞函数
7、同步流以避免并发的错误
如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。
比如,竞争问题。程序示例如下:
解决办法如下图所示程序所示,在调用fork之前,阻塞SIGCHLD信号(19~22行),然后在我们调用了addjob之后就取消阻塞这些信号(25行),我们保证了在子进程在被添加到作业列表之后回收该子进程。注意,子进程继承了它们父进程的被阻塞信号,所以我们必须在调用execve之前,小心地接触子进程中阻塞的SIGCHLD信号(31行)(这是因为,子进程继承了父进程的阻塞信号集合,但这是属于两个不同的集合,他们不共享内存)。
四、非本地跳转(应用层)
c语言提供了一种用户级异常控制流形式,称为本地跳转。通过setjmp和longjmp函数来提供。
setjmp函数只被调用一次,但返回多次:一次是当第一次调用setjmp,而调用环境保存在缓冲区env中时,一次是为每个相应的longjmp调用。另一方面,longjmp只调用一次,但从不返回。sig—函数是setjmp和longjmp函数的可以被信号处理程序使用的版本。
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。例子如下:
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到达中断了的指令位置。例子如下:
程序输出如下:
附加:
管道pipe函数
进程通信的使用例子之一是UNIX系统的管道通信。UNIX系统从System V 开始,提供有名管道和无名管道两种通信方式,这里介绍无名管道。
无名管道为建立管道的进程及其子孙提供一条以比特流方式传送消息的通信管道。该管道再逻辑上被看作管道文件,在物理上则由文件系统的高速缓冲区构成,而很少启动外设。发送进程利用文件系统的系统调用write(fd[1],buf,size),把buf种的长度为size字符的消息送入管道入口fd[1],接收进程则使用系统调用read(fd[0],buf,size)从管道出口fd[0]出口读出size字符的消息置入buf中。这里,管道按FIFO(先进先出)方式传送消息,且只能单向传送消息
利用UNIX提供的系统调用pipe,可建立一条同步通信管道。其格式为:
int fd[2];
pipe(fd)
这里,fd[1]为写入端,fd[0]为读出端。
I/O重定向dup,dup2
dup和dup2也是两个非常有用的调用,它们的作用都是用来复制一个文件的描述符。它们经常用来重定向进程的stdin、stdout和stderr。这两个函数的原形如下:
C代码
#include <unistd.h>
int dup( int oldfd );
int dup2( int oldfd, int targetfd );
dup()函数
利用函数dup,我们可以复制一个描述符。传给该函数一个既有的描述符,它就会返回一个新的描述符,这个新的描述符是传给它的描述符的拷贝。这意味着,这两个描述符共享同一个数据结构。例如,如果我们对一个文件描述符执行lseek操作,得到的第一个文件的位置和第二个是一样的。下面是用来说明dup函数使用方法的代码片段:
C代码
int fd1, fd2;
...
fd2 = dup( fd1 );
需要注意的是,我们可以在调用fork之前建立一个描述符,这与调用dup建立描述符的效果是一样的,子进程也同样会收到一个复制出来的描述符。
dup2()函数
dup2函数跟dup函数相似,但dup2函数允许调用者规定一个有效描述符和目标描述符的id。dup2函数成功返回时,目标描述符(dup2函数的第二个参数)将变成源描述符(dup2函数的第一个参数)的复制品,换句话说,两个文件描述符现在都指向同一个文件,并且是函数第一个参数指向的文件。下面我们用一段代码加以说明:
C代码
int oldfd;
oldfd = open("app_log", (O_RDWR | O_CREATE), 0644 );
dup2( oldfd, 1 );
close( oldfd );
在本例中,我们打开了一个新文件,称为“app_log”,并收到一个文件描述符,该描述符叫做fd1。我们调用dup2函数,参数为oldfd和1,这会导致用我们新打开的文件描述符替换掉由1代表的文件描述符(即stdout,因为标准输出文件的id为1)。任何写到stdout的东西,现在都将改为写入名为“app_log”的文件中。需要注意的是,dup2函数在复制了oldfd之后,会立即将其关闭,但不会关掉新近打开的文件描述符,因为文件描述符1现在也指向它。
例子
下面我们介绍一个更加深入的示例代码。回忆一下命令行管道,我们可以将ls –l命令的标准输出作为标准输入连接到wc –l命令。接下来,我们就用一个C程序来加以说明这个过程的实现。代码如下所示。
C代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
int pfds[2];
if ( pipe(pfds) == 0 ) {
if ( fork() == 0 ) {
close(1);
dup2( pfds[1], 1 );
close( pfds[0] );
execlp( "ls", "ls", "-l", NULL );
} else {
close(0);
dup2( pfds[0], 0 );
close( pfds[1] );
execlp( "wc", "wc", "-l", NULL );
}
return 0;
}
在示例代码中,首先在第9行代码中建立一个管道,然后将应用程序分成两个进程:一个子进程(第13–16行)和一个父进程(第20–23行)。接下来,在子进程中首先关闭stdout描述符(第13行),然后提供了ls –l命令功能,不过它不是写到stdout(第13行),而是写到我们建立的管道的输出端,这是通过dup2函数来完成重定向的。在第14行,使用dup2 函数把stdout重定向到管道(pfds[1])。之后,马上关掉管道的输入端。然后,使用execlp函数把子进程的映像替换为命令ls –l的进程映像,一旦该命令执行,它的任何输出都将发给管道的输入端。
现在来研究一下管道的接收端。从代码中可以看出,管道的接收端是由父进程来担当的。首先关闭stdin描述符(第20行),因为我们不会从机器的键盘等标准设备文件来接收数据的输入,而是从其它程序的输出中接收数据。然后,再一次用到dup2函数(第21行),让管道的输入端作为输入,这是通过让文件描述符0(即常规的stdin)重定向到pfds[0]实现的。关闭管道的stdout端(pfds[1]),因为在这里用不到它。最后,使用 execlp函数把父进程的映像替换为命令wc -l的进程映像,命令wc -l把管道的内容作为它的输入(第23行)。
总结:
本章阅读内容较多,但是重点内容并不多,主要是掌握进程及其控制函数的使用,信号发送函数,接收函数和处理函数。在本章学习过程中的最大收获是了解进程的大致概念,最近操作系统这门课正在学习进程调度机制,让我补充了关于进程的空缺。
参考资料:
《深入理解计算机系统》第8章,第10章I/O重定向
管道pipe :http://blog.csdn.net/thinkinwm/article/details/8710857
Dup,dup2函数:http://eriol.iteye.com/blog/1180624