第八章 异常控制流
第八章 异常控制流
控制流:控制转移序列。
控制转移:从一条指令到下一条指令。
异常控制流:现代操作系统通过使控制流发生突变来对系统状态做出反应,这些突变称为异常控制流。
程序计数器中指令的地址的过渡称为控制转移,控制转移的序列称为处理器的控制流。最简单的是平滑流。跳转、调用和返回等指令会造成平滑流的突变,来对内部的程序状态中的变化做出反应。系统也需要能够对系统状态的变化做出反应,这些系统状态不能被内部程序变量捕获,系统通过使控制流突变来完成,这些突变称为异常控制流(ECF)。ECF发生在系统的各个层次,包括异常、系统调用、信号和非本地跳转等
8.1 异常
异常的剖析
8.1.1 异常处理
异常处理程序完成处理后,根据异常事件的类型会(执行一种):
- 将控制返回给当前指令(事件发生时正在执行的)。
- 将控制返回给下一条指令(没有异常将会执行的)。
- 终止被中断的程序。
异常表是一张跳转表,表目k包含异常k的处理程序的地址,在系统启动时由操作系统分配和初始化。系统中每种可能的异常都分配了一个唯一的非负整数的异常号。
8.1.2 异常的类别
异常可以分为四类:
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复错误 | 同步 | 不返回 |
异步异常是由处理器外部的I/O设备中的事件产生的,同步异常是执行一条指令的产物。
中断是异步发生的,硬件中断不由任何指令造成,所以说是异步的。硬件中断的异常处理程序称为中断处理程序。
陷阱、故障和终止是同步发生的,称为故障指令。
陷阱是有意的异常,主要用来在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。处理器提供了 syscall n 指令来满足用户向内核请求服务 n , syscall 指令会导致一个到异常处理程序的陷阱,处理程序调用适当的内核程序。普通函数运行在用户模式,而系统调用运行在内核模式。
故障由错误引起,如缺页异常。故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正错误,就将控制返回到故障指令,重新执行;否则处理程序返回到内核的 abort 例程, abort 终止应用程序。
终止是不可恢复的致命错误的结果,主要是一些硬件错误。终止处理程序将控制返回到 abort 例程,abort 终止应用程序。
8.1.3 linux/ia32系统中的异常
1.Linux/IA32故障和终止
除法错误
一般保护故障
缺页
机器检查
2.Linux/IA32系统调用
每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量
8.2 进程
1.进程(操作系统层):逻辑控制流,私有地址空间,多任务,并发,并行,上下文,上下文切换,调度。
2.进程就是一个执行中的程序实例。系统中的每个程序都是运行在某个进程的上下文中的。进程提供给应用程序的关键抽象:a)一个独立的逻辑控制流 ;b)一个私有的地址空间。
8.2.1 逻辑控制流
程序执行的一系列PC(程序计数器)值唯一地对应于包含在程序的可执行目标文件中的指令或包含在运行时动态链接的共享库中的指令,这个PC值的序列称为逻辑控制流。
进程轮流使用处理器,每个进程执行它的流的一部分,然后被抢占,其他进程开始执行。程序运行在进程的上下文中,因此像是在独占地使用处理器。
逻辑流是相互独立的,进程互不影响。可以通过进程间通信(IPC)机制来实现进程间交互。
逻辑流在时间上和其他逻辑流重叠的进程称为并发进程,这两个进程称为并发运行。如A和B、A和C,而B和C不是并发运行的。
进程执行控制流的一部分的时间段称为时间片,进程和其他进程轮换运行称为多任务,也称时间分片。
8.2.2 并发流
1.并发流:并发流一个逻辑流的执行在时间上与另一个流重叠,叫做并行流
2.并发:多个流并发执行的一般现象称为并发。
3.多任务:多个进程并发叫做多任务。
4.并行:并发流在不同的cpu或计算机上,叫做并行。
8.2.3 私有地址空间
一个进程为每个程序提供它自己的私有地址空间。
8.2.4 用户模式和内核模式
需要限制一个应用可以执行的指令以及可访问的地址空间范围来实现进程抽象,通过特定控制寄存器的一个模式位来提供这种机制。设置了模式位时,进程运行在内核模式中,进程可以执行任何指令和访问任何存储器位置。没设置模式位时,进程运行在用户模式中,进程不允许执行特权指令和访问地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。
用户程序的进程初始是在用户模式中的,必须通过中断、故障或陷入系统调用这样的异常来变为内核模式。
Linux有一种 /proc 文件系统,包含内核数据结构的内容的可读形式,运行用户模式进程访问。
8.2.5 上下文切换
内核为每个进程维持一个上下文,它是内核重新启动一个被抢占进程所需的状态。包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(页表、进程表和文件表等)的值。
内核通过上下文切换来实现多任务,它是一种高级的异常控制流,建立在低级异常机制上。
内核决定抢占当前进程,重新开始一个先前被抢占的进程,称为调度了一个新进程,由内核中的调度器代码处理。使用上下文切换来将控制转移到新进程。上下文切换保存当前进程的上下文,恢复先前被抢占进程保存的上下文,将控制传递给新恢复的进程。
系统调用和中断可以引发上下文切换。
8.3 系统调用错误处理
当Unix系统级函数遇到错误时,它们典型地会返回―1,并设置全局整数变量errno来表示什么出错了。程序员应该总是检查错误,但是不幸的是,许多人都忽略了错误检查,因为它使代码变得臃肿,而且难以读懂。比如,下面是我们调用Unix fork函数时会如何检查错误:
通过使用错误处理包装函数,我们可以更进一步地简化我们的代码。对于一个给定的基本函数foo,我们定义一个具有相同参数的包装函数Foo,但是第一个字母大写了。包装函数调用基本函数,检查错误,如果有任何问题就终止。比如,下面是fork函数的错误处理包装函数:
8.4 进程控制
进程有三种状态:
1.运行。进程在CPU上执行,或等待被执行(会被调度)。
2.停止。进程被挂起(不会被调度)。收到 SIGSTOP 、 SIGTSTP 、 SIDTTIN 、 SIGTTOU 信号,进程停止,收到 SIGCONT 信号,进程再次开始运行。
3.终止。进程永远停止。原因可能是:收到终止进程的信号,从主程序返回,调用 exit 函数。
创建新进程可以使用 fork 函数。新创建的子进程和父进程几乎相同,它获得父进程用户级虚拟地址空间和文件描述符的副本,主要区别是它们的PID不同。 fork 函数调用一次,返回两次;父子进程是并发运行的,不能假设它们的执行顺序;两个进程的初始地址空间相同,但是是相互独立的;它们还共享打开的文件。因为有相同的程序代码,所以如果调用 fork 三次,就会有八个进程。
进程终止时,并不会被立即清除,而是等待父进程回收,称为僵死进程。父进程回收终止的子进程时,内核将子进程退出状态传给父进程,然后抛弃该进程。如果回收前父进程已经终止,那么僵死进程由 init 进程回收。
回收子进程可以用 wait 和 waitpid 等函数。
8.4.1 获取进程
8.4.2 创建和终止进程
8.4.3 回收子进程
1.回收:当一个进程终止时,内核并不立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。
2.僵死进程:一个终止了但是还未被回收的进程称为僵死进程。
回收子进程的两种方法:1,内核的init进程 2,父进程waitpid函数
1)如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排init进城来回收它们。init进程的PID为1,并且是在系统初始化时创建的。
2)一个进程可以通过调用waitpid函数来等待它的子进程终止或停止。
8.4.4 让进程休眠
1.sleep函数将一个进程挂起一段指定的时间。
2.如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数。后一种情况是可能的,如果因为sleep函数被一个信号中断而过早地返回。我们将在8.5节中详细讨论信号
8.4.5 加载并运行程序
1.execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
2.参数中每个指针都指向一个参数串。按照惯例,argv[0]是可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的。envp变量指向一个以null结尾的指针数组,其中每个指针指向个环境变量串,其中每个串都是形如“NAME=VALUE”的名字一值对。
8.4.6 利用fork和execve运行程序
1.像Unix外壳和Web服务器这样的程序大量使用了fork和e×ecve函数。外壳是一个交互型的应用程序,它代表用户运行其他程序。最早的外壳是Sh程序,后面出现了一些变种,比如csh、tcsh、ksh和bash。外壳执行一系列的读/求值(readeaUte)步骤然后终止。
2.如果builtin_command返回0,那么外壳创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么外壳返回到循环的顶部,等待下一个命令行否则,外壳使用Waitpid函数等待作业终止。当作业终止时,外壳就开始下一轮迭代。注意这个简单的外壳是有缺陷的,因为它并不回收它的后台子进程。修改这个缺陷就要求使用信号,我们将在下一节中讲述信号。
8.5 信号
信号是一种更高层软件形式的异常,它允许进程中断其他进程。一个信号即一条信息,通知进程一个某种类型的事件已经在系统中发生了。
8.5.1 信号术语
1.传送一个信号到目的进程是由两个步骤组成的
2.发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
发送信号可以有如下两种原因:
1)内核检测到一个系统事件。
2)一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程,一个进程可以发送信号给它自己。
2.接收信号。当目的进程被内核强迫以某种方式的发送做出反应时,目的进程就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数不活这个信号。
一个只发出而没有被接收的信号叫做待处理信号。在任何时刻,一种类型至多只会有一个待处理信号。
3.一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,他仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
4.一个待处理信号最多只能被接收一次。
8.5.2 发送信号
发送信号给进程基于进程组的概念。进程组由一个正整数ID标识,每个进程只属于一个进程组。
前面章节提到过作业的概念,shell为每个作业创建一个独立的进程组,进程组ID一般为作业中父进程中的一个。
^C 发送 SIGINT 信号到shell,shell捕获信号发送给前台进程组的每个进程,默认终止前台作业。 ^Z 发送SIGTSTP 信号到shell,shell捕获信号发送给前台进程组的每个进程,默认挂起前台作业。
用 kill 命令向其他进程发送任意信号,给定的PID为负值时,表示发送信号给进程组ID为PID绝对值的所有进程。
进程可以用 kill 函数发送信号给任意进程(包括自己)。
8.5.3 接收信号
每个进程都有一个信号屏蔽字,它规定了当前要阻塞递送到该进程的信号集。每个可能的信号都有一位屏蔽字,对应位设置时表明信号当前是被阻塞的。用 sigprocmask 函数检测和更改当前信号屏蔽字。
内核从异常处理程序返回,将控制传递给进程p之前会检查未被阻塞的待处理信号的集合。集合为空则内核传递控制给进程p的逻辑控制流的下一条指令;集合非空则内核选择集合中某个信号k(通常取最小k),强制进程p接收k。信号触发进程的某种行为,进程完成行为后控制传递给p的逻辑控制流的下一条指令。
每种信号都有默认行为,可以用 signal 函数修改和信号关联的默认行为(除 SIGSTOP 和 SIGKILL 外):
8.5.4 信号处理问题
前面已经指出,不会有重复的信号排队等待。信号处理有以下特性:
- 信号处理程序阻塞当前正在处理的类型的待处理信号。
- 同种类型至多有一个待处理信号。
- 会潜在阻塞进程的慢速系统调用被信号中断后,在信号处理程序返回时不再继续,而返回一个错误条件,并将 errno 设为 EINTR 。
对于第三点,Linux系统会重启系统调用,而Solaris不会。不同系统之间,信号处理语义存在差异。Posix标准定义了 sigaction 函数,使在Posix兼容的系统上可以设置信号处理语义。
8.5.5 可移植的信号处理
Signal包装函数设置的信号处理程序的信号处理语义:
(1)只有这个处理程序当前正在处理的那种类型的信号被阻塞
(2)和所有信号实现一样,信号不会排队等候
(3)只要有可能,被中断的系统调用会自动重启。
(4)一旦设置了信号处理程序,它就会一直保持,知道signal带着handler参数为SIG_IGN或者SIG_DFL被调用。
8.5.6 显式地阻塞和取消阻塞信号
8.5.7 同步流以避免讨厌的并发错误
1.一般而言,流可能交错的数量是与指令的数量呈指数关系的。
2.以某种方式同步并交流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。
3.如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。比如,竞争问题。
8.6 非本地跳转
C提供了一种用户级的异常控制流,称为非本地跳转。它将控制直接从一个函数转移到另一个正在执行的函数。
#include <setjmp.h>
/** 在env缓冲区中保存当前栈的内容,供longjmp使用,返回0
* @return setjmp返回0,longjmp返回非0 */
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
/** 从env缓冲区中恢复栈的内容,触发一个从最近一次初始化env的setjmp调用的返回,setjmp返回非0的给定val值 */
void longjmp(jmp_buf env, int val);
void siglongjmp(sigjmp_buf env, int val);
非本地跳转可以用来从一个深层嵌套的函数调用中立即返回,如检测到错误;或者使一个信号处理程序转移到一个特殊的代码位置,而不是返回到信号中断的指令的位置。
在信号处理程序中进行非本地跳转时应使用 sigsetjmp 和 siglongjmp 。如果 savesigs 非0,则 sigsetjmp在 env 中保存进程的当前信号屏蔽字,调用 siglongjmp 时从 env 恢复保存的信号屏蔽字。同时,应该使用一个 volatile sig_atomic_t 类型的变量来确保 env 未设置时不被中断。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <setjmp.h>
static sigjmp_buf buf;
static volatile sig_atomic_t canjmp;
void handler(int sig)
{
if (canjmp == 0)
return;
/* ... */
canjmp = 0;
siglongjmp(buf, 1);
}
int main()
{
signal(SIGINT, handler);
if (!sigsetjmp(buf, 1))
printf("starting\n");
else
printf("restarting\n");
canjmp = 1;
while (1) {
sleep(1);
printf("processing ...\n");
}
exit(0);
}
参考资料
1.教材:第八章,详细学习指导:http://group.cnblogs.com/topic/73069.html
2.课程资料:https://www.shiyanlou.com/courses/413 实验十,课程邀请码:W7FQKW4Y
3.http://blog.csdn.net/yangxuefeng09/article/details/10066357
遇到的问题
1.用什么函数修改和信号关联的默认行为
2.sigprocmask函数的作用