异常控制流(csapp)
【前言】程序按照一定顺序执行称为控制转移。最简单的是平滑流,跳转、调用和返回等指令会造成平滑流的突变。系统也需要能够对系统状态的变化做出反应,这些系统状态不能被内部程序变量捕获但是,操作系统通过使控制流发生突变来对这些情况做出反应,称为异常控制流。异常发生在计算机系统各个层次,在硬件层有硬件中断,比如来自io口的;在操作系统层,内核通过上下文切换将控制从一个进程转移到另一个用户进程,异常不是指不希望得到的“异常”,有时是一种通信手段;在应用层,一个进程可以发信号到另一个进程,而接收者会突然将控制转移到他的信号处理进程。本文将从硬件、系统调用和应用层三个角度说明。
【CSAPP第八章的读书笔记】
一、异常
异常时异常控制流一种形式,一部分由硬件实现,一部分由操作系统实现,它就是控制流中的突变,用来响应处理器状态的某些变化。注意和应用级的异常控制流概念区分。处理器中,状态被编码为不同的位和信号,状态变化被称为事件,事件不一定和当前指令的执行有关。处理器检测到有事件发生时,会通过异常表进行间接过程调用,到一个专门设计处理事件的操作系统子程序,称为异常处理程序。
1、 异常处理程序完成处理后,根据异常事件的类型会(执行一种):
(1)将控制返回给当前指令(事件发生时正在执行的)。
(2)将控制返回给下一条指令(没有异常将会执行的)。
(3)终止被中断的程序。
2、异常表是一张跳转表,表目k包含异常k的处理程序的地址,在系统启动时由操作系统分配和初始化。系统中每种可能的异常都分配了一个唯一的非负整数的异常号。
3、异常类似过程调用,不同的是:
- 过程调用跳转前会将返回地址压入栈中,但异常的返回地址只能是当前指令或下一条指令。
- 处理器会把一些额外的重新开始被中断程序需要的处理器状态压入栈中。
- 控制从用户程序转到内核,这些项目压入内核栈中,而不是用户栈。
- 异常处理程序运行在内核模式下(对系统资源有完全访问权限)。
4、异常可以分为四类:
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复错误 | 同步 | 不返回 |
(1)异步异常是由处理器外部的I/O设备中的事件产生的,同步异常是CPU执行一条指令的产物。
(2)中断是异步发生的,硬件中断不由任何指令造成,所以说是异步的。硬件中断的异常处理程序称为中断处理程序。
(3)陷阱、故障和终止是同步发生的,称为故障指令。
(4)陷阱是有意的异常,主要用来在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。处理器提供了 syscall n 指令来满足用户向内核请求服务 n , syscall 指令会导致一个到异常处理程序的陷阱,处理程序调用适当的内核程序。普通函数运行在用户模式,而系统调用运行在内核模式。
(5)故障由错误引起,如缺页异常。故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正错误,就将控制返回到故障指令,重新执行;否则处理程序返回到内核的 abort 例程, abort 终止应用程序。
(6)终止是不可恢复的致命错误的结果,主要是一些硬件错误。终止处理程序将控制返回到 abort 例程, abort 终止应用程序。
5、下面是Intel的Pentium系统的异常:
异常号 | 描述 | 异常类别 |
---|---|---|
0 ~ 31 | Pentium体系结构定义的异常 | |
0 | 除法错误 | 故障 |
13 | 一般保护故障 | 故障 |
14 | 缺页 | 故障 |
18 | 机器检查 | 终止 |
32 ~ 127 | 操作系统定义的异常 | 中断或陷阱 |
128(0x80) | 系统调用 | 陷阱 |
129 ~ 255 | 操作系统定义的异常 | 中断或陷阱 |
- 除法错误:除零或除法结果太大,Unix终止程序,报告为浮点异常。
- 一般保护故障:通常为引用未定义的虚拟存储器区域或写一个只读的文本段,Unix终止程序,报告为段故障。
- 缺页:将物理存储器相应的页面映射到虚拟存储器的页面,重新执行故障指令。
- 机器检查:检测到致命的硬件错误。
二、进程
进程是一个执行中程序的实例。系统中每个程序都是运行在某个进程的上下文中的。上下文由程序正确运行所需的状态组成,包括程序的存放在存储器中的代码和数据、栈、通用目的寄存器的内容、程序计数器、环境变量和打开文件描述符的集合。
在shell中运行程序时,shell会创建一个新的进程,然后在新进程的上下文中运行可执行目标文件。应用程序还能创建新进程。
进程给应用程序提供了两个关键抽象:(虚拟内存)
- 独立的逻辑控制流,提供程序独占处理器的假象。
- 私有的地址空间,提供程序独占存储器系统的假象。
1、逻辑控制流
程序执行的一系列PC(程序计数器)值唯一地对应于包含在程序的可执行目标文件中的指令或包含在运行时动态链接的共享库中的指令,这个PC值的序列称为逻辑控制流。
![/media/note/2012/03/22/linux-ecf/fig2.png](http://www.yeolar.com/media/note/2012/03/22/linux-ecf/fig2.png)
2、并发流
(1)进程轮流使用处理器,每个进程执行它的流的一部分,然后被抢占,其他进程开始执行。
(2)程序运行在进程的上下文中,因此像是在独占地使用处理器。
(3)逻辑流是相互独立的,进程互不影响。可以通过进程间通信(IPC)机制来实现进程间交互。
(4)逻辑流在时间上和其他逻辑流重叠的进程称为并发进程,这两个进程称为并发运行。如A和B、A和C,而B和C不是并发运行的。
(5)进程执行控制流的一部分的时间段称为时间片,进程和其他进程轮换运行称为多任务,也称时间分片。
若同时刻运行则称为并行流(其实没那么细,并发并行一个概念)单核并发,多核并行。但是此书是区分的!!!!
3、用户模式和内核模式
需要限制一个应用可以执行的指令以及可访问的地址空间范围来实现进程抽象,通过特定控制寄存器的一个模式位来提供这种机制。设置了模式位时,进程运行在内核模式中,进程可以执行任何指令和访问任何存储器位置。没设置模式位时,进程运行在用户模式中,进程不允许执行特权指令和访问地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。
用户程序的进程初始是在用户模式中的,必须通过中断、故障或陷入系统调用这样的异常来变为内核模式。Linux有一种 /proc 文件系统,包含内核数据结构的内容的可读形式,运行用户模式进程访问。
4、上下文切换
内核为每个进程维持一个上下文它是内核重新启动一个被抢占进程所需的状态。包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(页表、进程表和文件表等)的值。
内核通过上下文切换来实现多任务,它是一种高级的异常控制流,建立在低级异常机制上。内核决定抢占当前进程,重新开始一个先前被抢占的进程,称为调度了一个新进程,由内核中的调度器代码处理。使用上下文切换来将控制转移到新进程。
上下文切换步骤:(1)保存当前进程的上下文;(2)恢复先前被抢占进程保存的上下文;(3)将控制传递给新恢复的进程。系统调用和中断可以引发上下文切换。
5、进程控制
进程有三种状态:
- 运行。进程在CPU上执行,或等待被执行(会被调度)。
- 停止。进程被挂起(不会被调度)。收到 SIGSTOP 、 SIGTSTP 、 SIDTTIN 、 SIGTTOU 信号,进程停止,收到 SIGCONT 信号,进程再次开始运行。
- 终止。进程永远停止。原因可能是:收到终止进程的信号,从主程序返回,调用 exit 函数。
(1)创建新进程可以使用 fork 函数。新创建的子进程和父进程几乎相同,它获得父进程用户级虚拟地址空间和文件描述符的副本,主要区别是它们的PID不同。 fork 函数调用一次,返回两次;父子进程是并发(不是并行)运行的,不能假设它们的执行顺序;两个进程的初始地址空间相同,但是是相互独立的(三级页表映射不同)所以fork()后的再操作都是独立的了(读时共享写时复制);它们还共享打开的文件。
(2) 因为有相同的程序代码,所以如果调用 fork 三次,就会有八个进程。
(3)进程终止时,并不会被立即清除,而是等待父进程回收,称为僵死进程。父进程回收终止的子进程时,内核将子进程退出状态传给父进程,然后抛弃该进程。如果回收前父进程已经终止,那么僵死进由 init 进程回收。回收子进程可以用 wait 和 waitpid 等函数。
(4)内核调用 execve 函数在当前进程的上下文中加载并运行一个新程序。int execve(const char *filename,const har *argv[],const char *envp[]).。第一个参数是要执行的可执行文件的名字;第三个参数的每个指针都指向一个环境变量“name=value”的式。 execve 加载 filename 后,调用启动代码,启动代码准备栈,将控制传给新程序的主函数 int main(int argc,char *argv[], char *envp[]) 。
下面是用户栈的典型组织:
![/media/note/2012/03/22/linux-ecf/fig3.png](http://www.yeolar.com/media/note/2012/03/22/linux-ecf/fig3.png)
用户栈的典型组织
高地址是栈底,从高地址往低地址增加,栈顶在低地址(下面)。
(5)参数数组和环境数组会被传递给程序。按C标准, main 函数只有两个参数,一般环境数组使用全局变量 environ 传递。
操作环境数组可以通过 getenv 函数族。
#include <stdlib.h>
/** 在环境数组中搜索字符串"name=value"
* @return 返回指向value的指针,若无返回NULL */
char *getenv(const char *name);
/** 以"name=value"格式取字符串,添加到数组
* @return 返回0,出错返回-1 */
int putenv(char *string);
/** 若name不存在,将"name=value"添加到数组;如存在且overwrite非0,用value覆盖原值
* @return 返回0,出错返回-1 */
int setenv(const char *name, const char *value, int overwrite);
/** 删除环境变量name
* @return 返回0,出错返回-1 */
int unsetenv(const char *name);
name 为环境变量名。
程序是代码和数据的集合,可以作为目标模块存在于磁盘,或作为段存在于地址空间中。进程是执行中程序的一个实例。程序总是运行在某个进程的上下文中。
ps 命令可以查看系统中当前的进程。
top 命令会打印当前进程资源使用的信息。
三、信号
信号是一种更高层软件形式的异常,它允许进程中断其他进程。一个信号即一条信息,通知进程一个某种类型的事件已经在系统中发生了。
下表是Linux系统中的信号:
号码 | 名字 | 默认行为 | 相应事件 |
---|---|---|---|
1 | SIGHUP | 终止 | 终端线挂起 |
2 | SIGINT | 终止 | 来自键盘的中断 |
3 | SIGQUIT | 终止 | 来自键盘的退出 |
4 | SIGILL | 终止 | 非法指令 |
5 | SIGTRAP | 终止并转储存储器 | 跟踪陷阱 |
6 | SIGABRT | 终止并转储存储器 | 来自 abort 函数的终止信号 |
7 | SIGBUS | 终止 | 总线错误 |
8 | SIGFPE | 终止并转储存储器 | 浮点异常 |
9 | SIGKILL | 终止 [1] | 杀死程序 |
10 | SIGUSR1 | 终止 | 用户定义的信号1 |
11 | SIGSEGV | 终止并转储存储器 | 无效的存储器引用(段故障) |
12 | SIGUSR2 | 终止 | 用户定义的信号2 |
13 | SIGPIPE | 终止 | 向一个没有读用户的管道做些操作 |
14 | SIGALRM | 终止 | 来自 alarm 函数的定时器信号 |
15 | SIGTERM | 终止 | 软件终止信号 |
16 | SIGSTKFLT | 终止 | 协处理器上的栈故障 |
17 | SIGCHLD | 忽略 | 一个子进程停止或终止 |
18 | SIGCONT | 忽略 | 使停止进程继续 |
19 | SIGSTOP | 停止直到下一个 SIGCONT [1] | 不来自终端的暂停信号 |
20 | SIGTSTP | 停止直到下一个 SIGCONT | 来自终端的暂停信号 |
21 | SIGTTIN | 停止直到下一个 SIGCONT | 后台进程从终端读 |
22 | SIGTTOU | 停止直到下一个 SIGCONT | 后台进程向终端写 |
23 | SIGURG | 忽略 | 套接字上的紧急情况 |
24 | SIGXCPU | 终止 | CPU时间限制超出 |
25 | SIGXFSZ | 终止 | 文件大小限制超出 |
26 | SIGVTALRM | 终止 | 虚拟定时器期满 |
27 | SIGPROF | 终止 | 剖析定时器期满 |
28 | SIGWINCH | 忽略 | 窗口大小变化 |
29 | SIGIO | 终止 | 在某个描述符上可执行I/O操作 |
30 | SIGPWR | 终止 | 电源故障 |
[1] | (1, 2) 信号不能被捕获和忽略。 |
每种信号类型都对应某个类型的系统事件。底层硬件异常通常对用户进程不可见,信号提供了一种机制向用户进程通知这些异常的发生。其他信号对应内核或其他用户进程中较高层的软件事件。可以看到软硬件异常,都能通过信号传播出来。
1、发送信号指内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号的原因有:
- 内核检测到一个系统事件,如除零或子进程终止。
- 进程调用了 kill 函数,显示要求内核发送信号给目的进程。进程可以给自己发送信号。
2、接收信号指目的进程被内核强迫以某种方式对信号的发送做出反应。进程可以忽略信号,终止,或执行信号处理程序捕获信号。发出而没有被接收的信号称为待处理信号。一种类型最多有一个待处理信号,重复的信号被丢弃。进程可以阻塞某种信号,这时仍可被发送,但不会被接收。一个待处理信号最多只能被接收一次。
1、发送信号
发送信号给进程基于进程组的概念。进程组由一个正整数ID标识,每个进程只属于一个进程组。
前面章节提到过作业的概念,shell为每个作业创建一个独立的进程组,进程组ID一般为作业中父进程中的一个。
^C 发送 SIGINT 信号到shell,shell捕获信号发送给前台进程组的每个进程,默认终止前台作业。 ^Z 发送 SIGTSTP 信号到shell,shell捕获信号发送给前台进程组的每个进程,默认挂起前台作业。
用 kill 命令向其他进程发送任意信号,给定的PID为负值时,表示发送信号给进程组ID为PID绝对值的所有进程。
进程可以用 kill 函数发送信号给任意进程(包括自己)。
2、接收信号
每个进程都有一个信号屏蔽字,它规定了当前要阻塞递送到该进程的信号集。每个可能的信号都有一位屏蔽字,对应位设置时表明信号当前是被阻塞的。用 sigprocmask 函数检测和更改当前信号屏蔽字。
内核从异常处理程序返回,将控制传递给进程p之前会检查未被阻塞的待处理信号的集合。集合为空则内核传递控制给进程p的逻辑控制流的下一条指令;集合非空则内核选择集合中某个信号k(通常取最小k),强制进程p接收k。信号触发进程的某种行为,进程完成行为后控制传递给p的逻辑控制流的下一条指令。
每种信号都有默认行为,可以用 signal 函数修改和信号关联的默认行为(除 SIGSTOP 和 SIGKILL 外):
#include <signal.h>
typedef void (*sighandler_t)(int);
/** 改变和信号signum关联的行为
* @return 返回前次处理程序的指针,出错返回SIG_ERR */
sighandler_t signal(int signum, sighandler_t handler);
参数说明:
- signum
- 信号编号。
- handler
-
指向用户定义函数,也就是信号处理程序的指针。或者为:
- SIG_IGN :忽略 signum 信号。
- SIG_DFL :恢复 signum 信号默认行为。
信号处理程序的调用称为捕捉信号,信号处理程序的执行称为处理信号。 signal 函数会将 signum 参数传递给信号处理程序 handler 的参数,这样 handler 可以捕捉不同类型的信号。
signal 的语义和实现有关,最好使用 sigaction 函数代替它。
例:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>
void w_error(const char *msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
void handler(int sig)
{
printf("caught SIGINT\n");
/* exit(0); */
}
int main()
{
if (signal(SIGINT, handler) == SIG_ERR)
w_error("signal error");
pause();
printf("come back\n");
exit(0);
}
3、信号处理
前面已经指出,不会有重复的信号排队等待。信号处理有以下特性:
- 信号处理程序阻塞当前正在处理的类型的待处理信号。
- 同种类型至多有一个待处理信号。
- 会潜在阻塞进程的慢速系统调用被信号中断后,在信号处理程序返回时不再继续,而返回一个错误条件,并将 errno 设为 EINTR 。
对于第三点,Linux系统会重启系统调用,而Solaris不会。不同系统之间,信号处理语义存在差异。Posix标准定义了 sigaction 函数,使在Posix兼容的系统上可以设置信号处理语义。
四、非本地跳转
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 未设置时不被中断。
例:
![](http://www.yeolar.com/static/img/code-lineno.png)
#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);
}
总结半天不如别人总结的好,基本复制于:http://www.yeolar.com/note/2012/03/22/linux-ecf/