异常控制流(Exception Control Flow)

Posted 清水寺扫地僧

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了异常控制流(Exception Control Flow)相关的知识,希望对你有一定的参考价值。



指令序列 a 0 , a 1 , . . . , a n − 1 a_0,a_1,...,a_{n-1} a0,a1,...,an1(其中 a k a_k ak对应相应指令 I k I_k Ik的地址),其中从 a k a_k ak a k + 1 a_{k+1} ak+1的过渡称为控制转移(control transfer),这样的控制转移 序列称为处理器的控制流(flow of control/ control flow)。

不平滑的控制流( I k I_k Ik I k + 1 I_{k+1} Ik+1不相邻),通常是由跳转、调用和返回这些程序指令所造成的,这些指令机制使得程序能够对程序变量表示的内部程序状态中的变化做出反应。

相应的,系统也须对系统状态的变化做出反应。比如,硬件定时器的定时中断,数据包到达网络适配器后将其放入内存,子进程终止时父进程应当得到通知。

对以上系统状态的变化情形,现代系统通过使控制流发生突变来对这些情况做出反应。将这些突变称为异常控制流(Exceptional Control Flow, ECF)。为何需要理解 ECF:

  • ECF 是操作系统用来实现I/O、进程和虚拟内存的基本机制;
  • 应用程序通过使用陷阱(trap)或者系统调用(system call) 的 ECF 形式,向操作系统请求服务;
  • 操作系统为应用程序提供了强大的 ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件,以及检测和响应这些事件;
  • ECF 是计算机系统中实现并发的基本机制;
  • C++ 和Java 这样的语言通过 try catch 以及 throw 语句来提供软件异常机制。软件异常允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况;


1. 异常(Exception)

异常就是控制流中的突变,用来响应处理器状态中的某些变化。它一部分由硬件实现,一部分由操作系统实现。

在任何情况下,处理器检测到有事件发生时,通过异常表(exception table)的跳转表,进行一间接过程调用(异常),到异常处理程序(exception handler, 专门设计用来处理相关事件的操作系统子程序)。异常处理程序完成处理后,会有三种情况:

  • ①控制返回至 I c u r r I_{curr} Icurr,即事件发生时正执行的指令;
  • ②控制返回值 I n e x t I_{next} Inext,即没发生异常将执行的下一指令;
  • ③终止被中断程序;

1.1 异常处理

系统为每个可能出现的异常都分配了一唯一的非负整数异常号(exception number)。分别是处理器设计者(被零除、缺页、内存访问违例、断点以及算术运算溢出等)和操作系统内核(操作系统常驻内存部分)设计者(系统调用和来自外部I/O 设备的信号等)所分配的。

当系统启动(计算机重启或上电时),OS分配和初始化一异常表的跳转表,使得表目 k k k包含异常 k k k的处理程序地址。异常表格式如左下图。

异常表结构
生成异常处理程序的地址

在运行时,CPU检测到事件的发生,且确定了异常号 k k k。CPU触发异常,执行间接调用过程(通过异常表的表目 k k k,转至相应异常处理程序)。右上图是如何寻获异常处理程序地址的过程(以位OS为例),其中异常表基址寄存器(exception table base register)存放异常表的起始地址。

异常与过程调用类似,但是过程调用跳转到处理程序前,将返回地址压入栈中,而异常的返回地址不定,要么是当前指令要么是下一指令;异常处理时可能由用户态切换至内核态,则相关信息被压入内核栈中,而不是用户栈,调用过程则只有用户态。


1.2 异常类别

异常可分为四类,如下表所示:

类别原因异步/同步返回行为
中断(interruption)来自I/O设备的信号异步总是返回到下一条指令
陷阱(trap)有意的异常同步总是返回到下一条指令
故障(fault)潜在可恢复的错误同步可能返回到当前指令
终止(terminate)不可恢复的错误同步不会返回
  • 中断中断是异步发生的,是来自处理器外部的I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的(没有固定的事件发生和处理顺序的)。硬件中断的异常处理程序通常称为中断处理程序。在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。
  • 陷阱和系统调用陷阱是有意的异常,是执行一条指令的结果。其最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用(当Unix 系统级函数遇到错误时,它们通常会返回一1,并设置全局整数变量来表示什么出错了。程序员应该总是检査该错误变量)。由syscall n指令来请求相应的系统调用。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。
  • 故障故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。若是可以修正,则重新执行它;否则,返回至内核下的abort处理程序,终止引起故障的应用程序;(经典的是缺页异常,若是缺页,则从磁盘中读取,读取到则继续进行,否则终止)
  • 终止终止是不可恢复的致命错误造成的结果,终止处理程序从不将控制返回给应用程序,而是返回给abort处理程序,其通常是一些硬件错误。

对于系统调用错误的处理,多是通过使用错误处理包装函数,更进一步地简化错误处理代码。比如,对于一个给定的基本函数foo, 我们定义一个具有相同参数的包装函数Foo, 但是第一个字母大写了。包装函数调用基本函数,检查错误,如果有任何问题就终止。包装函数定义在一个叫做csapp.c的文件中,它们的原型定义在一个叫做csapp.h的头文件中

x86-64操作系统中所定义的异常,高达256种。 0 − 31 0-31 031是Intel所定义的, 32 − 255 32-255 32255是操作系统所定义的,会随操作系统的不同而改变,x86-64系统中的常见异常和常用系统调用的编号如下:

x86-64系统中的常见异常
常用系统调用的编号


2. 进程

异常是允许操作系统内核提供进程(process)概念的基本构造块。进程的定义为:一个执行中程序的实例

系统中的每个程序都运行在某个进程的 上下文(context) 中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

进程提供给应用程序(可执行目标文件)的两个关键抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。让我们更深人地看看这些抽象。

2.1 逻辑控制流

处理器的一个物理控制流可包含多个逻辑流(一个进程的执行过程),并发流指的是一个逻辑流的执行时间与另一个流重叠,同时多个流并发地执行的一般现象称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。

注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow), 它们并行地运行(runningin parallel),且并行地执行(parallel execution)。


2.2 私有地址空间(虚拟内存)

进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的。每个这样的空间都有相同的通用结构。

地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址0x400000 开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。


2.3 用户模式和内核模式

处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

Linux的/proc文件系统,允许用户模式进程访问内核数据结构的内容。见:Linux的文件、目录、磁盘和文件系统——1.4 Linux目录配置


2.4 上下文切换

上下文的概念见上文蓝字。内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling), 是由内核中称为调度器(scheduler)的代码处理的。

整个上下文切换的过程可以分为三步:

  • ① 保存当前进程的上下文;
  • ② 恢复某个先前被抢占的进程被保存的上下文;
  • ③ 将控制传递给这个新恢复的进程;

示例如下:



3. 进程控制

3.1 进程操作函数

Unix 提供了大量从C 程序中操作进程的系统调用。常用的如下表所示:

函数名所在头文件用途补充
pid_t getpid(void);<sys/types.h> <unistd.h>返回调用进程的PID返回一个类型为 pid_t 的整数值,在Linux系统上它在<sys/types.h>中被定义为int
pid_t getppid(void);<sys/types.h> <unistd.h>返回它的父进程的PID(创建调用进程的进程)返回一个类型为 pid_t 的整数值,在Linux系统上它在<sys/types.h>中被定义为int
void exit(int status);<stdlib.h>以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值,return num)进程总是处于以下三种状态:
① 运行:进程要么在CPU 上执行,要么在等待被执行且最终会被内核调度;
② 停止:进程的执行被挂起(suspended), 且不会被调度。直到它收到一SIGCONT信号,进程再次开始运行;
③ 终止:进程永远地停止了。进程会因为三种原因终止:(1)收到一个信号,该信号的默认行为是终止进程;(2)从主程序返回;(3)调用exit 函数。
pid_t fork(void);<sys/types.h> <unistd.h>创建一个新的运行的子进程① fork函数只被调用一次,但返回两次:父进程中fork返回子进程Pid,子进程中fork返回0(子进程的Pid总是0,可用来分辨当前运行程序是父/子进程,几乎但不相同的父进程和子进程中的不同即是Pid不同);
② 并发执行:父进程和子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流中的指令;
③ 相同但独立的地址空间:父子进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。可以说子进程是父进程的完全拷贝;
④ 共享文件:子进程会继承父进程所有的打开文件(即文件标识符);

学习fork 函数,画进程图通常会有所帮助,是刻画程序语句的偏序的一种简单的前趋图。
pid_t waitpid(pid_t pid, int *statusp, int options);<sys/types.h> <sys/wait.h>进程可通过调用waitpid函数来等待它的子进程终止或者停止,以便于进行子进程回收。例如长时间运行的shell程序,需要回收其僵死子进程,以节省系统资源僵死进程(zombie):终止了但还未被回收的进程;
孤儿进程(orphan):父进程已终止,其尚未回收的子进程,一般是由内核将init进程成为其养父以进行回收;
init进程:系统启动时内核创建,Pid为1,不会终止且是进程树中的老祖宗节点;

waitpid函数的内含信息和参数:
① 判定等待集合的成员 pid:Pid来确定等待集合成员,pid>0,则等待集合是单独子进程;pid=-1,则等待集合是父进程所有子进程;
② 修改默认行为 options:通过将options 设置为常量WNOHANG(挂起调用进程,直到有子进程终止),WUNTRACED(只返回已终止的子进程) 和WCONTINUED(…)的各种组合来修改默认行为;
③ 检查已回收子进程的退出状态 statusp:如果statusp参数非空,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,status是statusp指向的值。wait.h头文件定义了解释status参数的几个宏;
④ 错误条件:如果调用进程没有子进程,那么waitpid 返回-1, 并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1, 并设置errno为EINTR。
pid_t wait(int *statusp);<sys/types.h> <sys/wait.h>wait 函数是waitpid 函数的简单版本调用wait(&status)等价于调用waitpid(- l,&status,0)
unsigned int sleep(unsigned int secs);<unistd.h>将一个进程挂起一段指定的时间 时间到了,返回0;
时间未到,由于信号中断而未足时返回,返回剩余休眠时间;
int pause(void);<unistd.h>让调用函数休眠,直到该进程收到一个信号
int execve(const char *filename, const char *argv[],const char *envp[]);<unistd.h>加载并运行可执行目标文件filename, 且带参数列表argv(argument vector)和环境变量列表envp(environment vector pointer)① execve调用一次且从不返回,只有当出现错误时,例如找不到 filename,execve 才会返回到调用程序;
参数列表和环境列表的数据结构入下左中两图所示,其结尾都是null;
execve加载了filename后,调用filename中的启动代码(见链接器、链接过程及相关概念解析——2.2 可执行目标文件(无后缀)),启动代码设置栈,并将控制传递给新程序主函数,进而main开始执行,用户栈的组织结构如右下图所示;
环境数组操作函数(均在<stdlib.h>):
- char *getenv(const char *name);:获取name的环境变量;
- int setenv(const char *name, const char *newvalue, int overwrite):用 newvalue 代替 oldvalue,但是只有在overwirte 非零时才会这样;
- void unsetenv(const char *name):删除名为name的环境变量
参数列表的组织结构
环境变量列表的组织结构
用户栈的典型组织结构

3.2 程序与进程

  • 程序是一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中;
  • 进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中;

fork 函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。execve 函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用 execve 函数时已打开的所有文件描述符。

shell就是使用fork()和execve()函数完成读取指令并执行的功能的,并配有eval()函数执行命令行中指令,parseline()函数分割命令行,构建参数向量,built_command()函数判断是否是内置指令。



4. 信号

Linux系统中,软件形式的异常称为Linux信号,其允许进程和内核中断其他进程。每个信号对应通知进程系统中发生了一个某种类型的事件。下图展示了Linux系统上所支持的30中不同类型的信号:

内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号原因有:

  • ① 内核检测到一个系统事件,比如除零错误或者子进程终止;
  • ② —个进程调用了kill函数;

当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号

一个发出而没有被接收的信号叫做待处理信号(pending signal)。在任何时刻,一种类型至多只会有一个待处理信号;

如果一个进程有一个类型为 k k k 的待处理信号,那么任何接下来发送到这个进程的类型为 k k k 的信号都不会排队等待;它们只是被简单地丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。

一个待处理信号最多只能被接收一次。内核为每个进程在 pending位向量 中维护着待处理信号的集合,而在 blocked位向量 中维护着被阻塞的信号集合。只要传送了一个类型为 k k k 的信号,内核就会设置 pending 中的第 k k k 位,而只要接收了一个类型为 k k k 的信号,内核就会清除 pending 中的第 k k k 位。


4.1 发送信号

1. 进程组:每个进程都属于一进程组,pid_t getpgrp(void);获取当前进程的进程组ID。默认情况下,一个子进程和它的父进程同属于一个进程组。一个进程可以使用int setgpid(pid_t pid, pid_t pgid)(pid=0则更改自己所处的进程组,pgid=0用pid指定的进程的PID作为进程组的PID)来更改自己或其他进程的进程组。

2. 发送信号的方式(内容可见:进程管理和SELinux

  • /bin/kill 程序发送信号:一个为负的PID会导致信号被发送到进程组PID 中的每个进程,如linux> kill -9 pid
  • 从键盘发送信号:Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业;
  • kill函数发送信号:在程序中调用int kill(pid_t pid, int sig);函数,运行时,程序实例即进程调用kill函数发送信号给其他进程(包括它们自己);
  • alarm函数发送信号unsigned int alarm(unsigned int secs);,alarm函数安排内核在 secs 秒后发送一个 SIGALRM 信号给调用进程。

4.2 接收信号

当内核把进程 p p p 从内核模式切换到用户模式时,先检查进程 p p p 的未被阻塞的待处理信号的集合(pending &~blocked)。如果集合非空,则内核选择集合中的某个信号 k k k(通常是最小的 k k k),并且强制 p p p 接收信号 k k k。收到信号则会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回 p p p 的逻辑控制流中的下一条指令( I n e x t I_{next} Inext),每个信号类型都有一个预定义的默认行为:

  • 进程终止(SIGKILL);
  • 进程终止并转储内存(由内存写入到磁盘);
  • 进程停止(挂起)直到被SIGCONT信号重启;
  • 进程忽略该信号(SIGCHLD);

除了SIGSTOP和SIGKILL两种信号之外,其余信号进程可以通过使用signal函数修改和信号相关联的默认行为。

#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
//返回:若成功则为指向前次处理程序的指针,若出错则为SIG_ERR(不设置error)

signal函数可通过三种方法来改变和信号 signum 相关联的行为:

  • 如果 handler 是 SIG_IGN,那么忽略类型为 signum 的信号;
  • 如果 handler 是 SIG_DFL,那么类型为signum 的信号行为恢复为默认行为;
  • 如果 handler 是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为 signum 的信号,就会调用这个程序。调用 signal 函数,并将 handler 函数地址传入信号处理程序叫做设置信号处理程序(installing the handler)。调用信号处理程序称为捕获信号。执行信号处理程序称为处理信号;

4.3 阻塞和解除阻塞信号

Linux 提供阻塞信号的隐式和显式的机制:

  • 隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号;
  • 显式阻塞机制:应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号;
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
//sigprocmask signal process mask

int sigemptyset(sigset_t *set); //初始化set为空集合
int sigfillset(sigset_t *set); //把每个信号都添加到set中
int sigaddset(sigset_t *set, int signum); //把signum添加到set
int sigdelset(sigset_t *set, int signum); //从set中删除signum,返回:如果成功则为0,若出错则为一1。
int sigismember(const sigset_t *set, int signum);//返回:若signum 是set 的成员则为1,如果不是则为0,若出错则为一1。

sigpromask函数可改变当前阻塞的信号集合,具体行为依赖于how值:

  • SIG_BLOCK: 把set 中的信号添加到blocked 中(blocked=blocked | set);
  • SIG_UNBLOCK: 从blocked 中删除set 中的信号(blocked=blocked &~set);
  • SIG_SETMASK: block=set;


5. 非本地跳转

C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump), 它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本地跳转是通过 setjmp 和 longjmp 函数来提供的。

#include <setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
//返回:setjmp 返回0,longjmp 返回非零。
//setjmp 函数在env 缓冲区中保存当前调用环境,以供后面的longjmp 使用,并返回0
//调用环境包括程序计数器、栈指针和通用目的寄存器。

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
//从不返回
//longjmp 函数从env 缓冲区中恢复调用环境,然后触发一个从最近一次初始化
//的setjmp 调用的返回。然后setjmp 返回,并带有非零的返回值retval

非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。

C++、Java 提供的异常机制是较高层次的,是 C 语言的 setjmp 和 longjmp 函数的更加结构化的版本。可以把 try 语句中的 catch 子句看做类似于 setjmp 函数。相似地,throw 语句就类似于 longjmp 函数。



6. 总结及归纳

  • 异常控制流(Exception Control Flow, ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制;
  • 在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流;
  • 有四种不同类型的异常:中断、故障、终止和陷阱。
    ①中断:当一个外部I/O设备(例如定时器芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,(对于任意指令)中断会异步地发生,控制返回到故障指令后面的那条指令;
    ②故障、终止:一条指令的执行可能导致故障和终止同步发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流;
    ③陷阱:用来实现向应用提供到操作系统代码的受控的入口点系统调用的函数调用(???)。
  • 在操作系统层,内核用ECF提供进程的基本概念。进程提供给应用两个重要的抽象:
    ①逻辑控制流,他提供给每个程序一个假象,好像是其在独占地使用处理器;
    ②私有地址空间(虚拟内存),他提供给每个程序一个假象,好像在独占地使用主存;
  • 在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同。然而,在与Posix兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义(signal());
  • 最后,在应用层,C程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且从一个函数分支到另一个函数。

以上是关于异常控制流(Exception Control Flow)的主要内容,如果未能解决你的问题,请参考以下文章

第八章 异常控制流 笔记

csapp:第八章 异常控制流ECF

捕获异常作为预期的程序执行流控制?

JAVA控制异常

CSAPP第八章

8.异常_EJa