- 异常控制流
- 一、硬件层次ECF—异常
- 1.1 异常
- 1.2 异常处理
- 1.3 异常类别
- 1.3.1 中断
- 1.3.2 陷阱和系统调用
- 1.3.3 故障
- 1.3.4 终止
- 1.4 Linux/x86-64 系统中的异常
- 1.4.1 Linux/x86-64 故障和终止
- 1.4.2 Linux/x86-64 系统调用
- 二、操作系统层次ECF—进程切换
- 三、应用层次ECF—信号 和 非本地跳转
- 3.1 信号
- 3.1.1 信号术语
- (1)发送信号
- (2)接收信号
- 3.1.2 发送信号的机制
- (1)发送信号的对象:单个进程or指定进程组的每个进程
- (2)用/bin/kill程序发送任意信号
- (3)从键盘发送特定信号
- (4)进程用kill函数发送任意信号
- (5)用alarm函数发送信号SIGALARM
- 3.1.3 接收信号
- (1)内核何时处理信号&信号默认行为
- (2)设置信号处理程序(intsalling the handler)
- (3)信号处理程序可以被其他信号处理程序中断
- (4)sleep函数(慢速系统调用)会被信号中断
- 3.1.4 阻塞和解除阻塞信号
- (1)隐式阻塞机制
- (2)显式阻塞机制
- 3.1.5 编写信号处理函数
- (1)安全的信号处理
- (2)正确的信号处理
- (3)可移植的信号处理
- 3.2 非本地跳转
-
什么是控制流?
从给处理器加电开始,直到断电为止,程序计数器中值的序列 a0, a1, ..., an-1, 其中,每个ak是某个相应的指令Ik的地址。 每次从ak到ak+1的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流。
-
什么是异常控制流?Exceptional Control Flow
“平滑的”序列是指Ik和Ik+1正在内存中都是相邻的,而平滑流的突变就是指异常控制流(但是这里看起来并不包括过程调用,goto,条件分支和循环这样的控制流,而是指以下3个层次上列举的异常控制流)
-
异常控制流可分为哪几个层次,每个层次上有哪些异常控制
- 硬件层:异常是由处理器中的事件触发的控制流中的突变。控制会突然转移到异常处理程序(内核)。异常可分为4类:
- 中断:由外部I/O设备的在管脚触发信号(异步)。
- 故障:可能恢复,可能终止。
- 终止:一定终止
- 陷阱:系统调用
- 操作系统层:利用ECF提供“进程“的基本概念,而进程的切换就是发生在此层面的异常控制流。内核通过上下文切换完成。
- 应用层:信号和非本地跳转。
- 信号:信号的接收者会将控制流突然转移到它的一个信号处理程序,允许进程和内核中断其他进程。
- 非本地跳转:规避正常的调用/返回栈规则,跳转到其他函数的位置来对错误做出反应。
- 硬件层:异常是由处理器中的事件触发的控制流中的突变。控制会突然转移到异常处理程序(内核)。异常可分为4类:
-
异常的概念
异常(Exception)就是控制流的突变,用来响应处理器状态中的某些变化。
-
异常和当前指令相关也可能无关
处理器的状态变化被称为事件(event),事件可能和当前指令的执行直接相关,如虚拟内存缺页、算数溢出;也可能和当前指令无关,如一个系统定时器产生信号 或者 I/O请求完成。
-
异常表
在任何情况下,当处理器检测到有异常发生时,他就会通过“异常表(exception table)”进行一个间接过程调用(异常),(这里称为间接应是因为通过异常表获取指令地址),到一个专门设计用来处理这类事件的操作系统自程序(异常处理程序(exception handler))。当异常处理程序完成处理后,根据引起异常的类型,会发生以下3种情况之一:
- 处理程序将控制返回给当前指令Icurr
- 处理程序将控制返回给Inext
- 处理程序终止被中断的程序
-
硬件和软件的分工
-
异常号
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中的一些号码是由处理器的设计者分配的,其他号码则是由操作系统内核的设计者分配的。
- 处理器异常号:零除,缺页,内存访问违例,断点,算数运算溢出
- 操作系统异常号:系统调用,来自外部的I/O设备的信号
-
-
异常处理过程
-
系统启动,操作系统初始化“异常表”,使得表目k包含异常k的处理程序的地址
-
运行时,处理器检测到发生了一个事件,并确定异常号k。异常表的起始地址放在异常处理表基址寄存器,处理器执行间接过程调用。异常类似于过程调用,但是有一些重要的不同之处:
-
返回地址不同
过程调用时,跳转到过程程序之前,处理器会将返回地址压入栈中;然而,根据异常类型,返回地址要么是当前指令,要么是下一条指令。
-
额外的处理器状态压入栈
处理异常程序调用前,处理器会把一些额外的处理器状态压入栈中;因为,在异常处理程序完成后,重新执行被中断的程序会需要这些状态。
-
控制从用户程序转移到内核,则以上项目会被压入内核栈
而不是用户栈
-
异常处理程序运行在内核模式下
-
-
处理完事件后,执行一条特殊的“从中断返回”指令,此指令:
- 将适当的状态弹回处理器的控制和数据寄存器中
- 恢复到被中断的程序的模式——内核模式or用户模式
- 控制返回给被中断的程序
-
异常分为四类:中断(interrupt),陷阱(trap),故障(fault),终止(abort)
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自I/O设备信号 | 异步 | 总是返回到下一条指令 |
| 陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
| 故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
| 终止 | 不可恢复的错误 | 同步 | 不会返回 |
注意:
- 异步异常是指由外部I/O设备中的事件产生的,同步异常则指当前指令执行的产物
- 中断来源:中断是异步发生的,是来自外部的I/O设备的信号的结果。异常处理程序常常被称为中断处理程序(interrupt handler)。
- 中断产生:I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发送信号,并将异常号放在系统总线上,来触发中断。
- 异步异常:硬件层四类异常中,只有中断是异步的异常,剩下的陷阱,故障和终止都是由当前指令触发的同步异常。
-
陷阱来源:有意的异常,并和中断异常处理完成后将控制返回到下一条指令
-
系统调用:系统调用是陷阱最重要的用途,指在用户程序和内核之间提供一个像过程一样的接口。
-
syscall:用户程序经常需要向内核请求服务,比如读一个文件(read),创建一个新的进程(fork),加载一个新的程序(execve),或者终止当前进程(exit)。处理器提供了一条特殊的“syscall n”指令,当用户想要请求服务n时,可以执行这条指令。
syscall指令会导致一个到异常处理程序的陷阱,这个程序解析参数,并调用适当的内核程序。
- 故障来源:故障是由错误情况引起,它可能能够被故障处理程序修正。
- 返回行为不确定:如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它;否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
- 终止来源:终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。
- 终止程序:处理终止程序从不将控制返回给应用程序,而将控制返回给一个abort例程,该例程终止这个应用程序。
-
异常类型:256种。0-31号码对应Intel架构师定义的异常,因此对任何x86-64系统都是一样的。32-255的号码对应的是操作系统定义的终端和陷阱。示例:
异常号 描述 异常类别 0 除法错误 故障 13 一般保护性故障 故障 14 缺页 故障 18 机器检查 终止 32~255 操作系统定义的异常 中断或陷阱
- 除法错误:当试图除以零时,或者当一个除法指令的结果对于目标操作系统来说太大(?),就会发生除法错误(异常0)。Unix不会试图从除法错误中恢复,而是终止程序。Linux shell通常会把这种一般保护性故障报告为“浮点异常(Floating exception)”。
- 一般保护性故障:通常是因为一个程序引用了一个未定义的虚拟内存区域,或者程序试图写一个只读的文本段。Linux不会尝试恢复这类古战。Linux shell通常会把这种一般保护故障报告为“段故障(Segment fault)”。
- 缺页:重新执行产生故障指令的一个异常示例。处理程序将适当的磁盘上的虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这条产生故障的指令。
- 机器检查:机器检查是在导致故障的指令执行中检测到致命的硬件错误时发生的。机器检查程序从不返回控制给应用程序。
Linux提供几百种系统调用。当应用程序想要请求内核服务时可以使用,包括读文件,写文件或者创建一个新进程。下图给出了一些常见的系统调用:
| 编号 | 名字 | 描述 |
|---|---|---|
| 0 | read | 读文件 |
| 1 | write | 写文件 |
| 2 | open | 打开文件 |
| 3 | close | 关闭文件 |
| 4 | stat | 获取文件信息 |
| … | … | … |
- 系统调用方式:
- syscall函数:怀疑是C内嵌汇编实现的,待确认。
- 标准C库提供的一组方便的包装函数:将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用返回状态传递给调用程序。(怀疑是syscall的封装,有空可以在glibc中查看实现)
- 系统调用read
- 系统调用write
- 系统调用_exit
- syscall指令:注意和上面的函数分开。所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的:
- %rax:系统调用号
- 最多6个参数:%rdi(参数1),%rsi(参数2),%rdx,%r10,%r8 和 %r9(参数6)
- 系统调用返回时,%rcx 和 %r11都会被破坏
- %rax包含返回值:-4095 到 -1 之间的负数返回值表示发生了错误,对应于负的errno
- 信号提供机制通知用户进程
Linux信号是一种更高层的软件形式异常,它允许进程和内核中断其他进程。
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。而信号提供了一种机制,通知用户进程发生了这些异常。比如:
(1)同步类异常 一个进程试图除以0,那么指令执行发生零除异常(终止),而内核就发送给进程一个SIGFPE信号; 一个进程执行一条非法指令,那么指令执行发生非法指令异常,内核就发送给进程一个SIGILL信号; 如果进程进行非法内存引用,那么指令执行发生一般保护性故障,内核就发送给进程一个SIGSEGV信号; ... (2)异步类异常——中断 如果进程在前台运行时,你键入Ctrl+C,那么底层会发生一个外部中断,内核会发送一个SIGINT信号; ... (3)进程向进程(可以是自己)发送信号 一个进程可以向另一个进程发送一个SIGKILL信号(9)强制终止它; (4)内核向进程发送信号 当一个子进程终止时,内核会发送一个SIGCHILD信号(17)给父进程; ...3.1.1 信号术语
传送一个信号到目的进程是由两个步骤组成:
(1)发送信号- 如何发送信号?
内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。
- 发送信号的原因(仅2种)
- 内核检测到一个系统事件,如除零错误(SIGFPE)或者子进程终止(SIGCHLD)
- 一个进程调用了kill函数,显示地要求内核发送一个信号给目的进程,一个进程可以发送信号给自己
当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略,终止或执行信号处理程序(一个用户层函数)捕获这个信号。
-
待处理信号(pending signal)
一个发出而没有被接收的信号叫做待处理信号。在任何时刻,一种类型至多只会有一个待处理信号,因此一个进程接收到相同的信号并不会排队,而是简单地被丢弃。
-
阻塞信号
一个进程可以有选择性地接受信号。当一个信号被阻塞时,信号可以被发送,但是无法被接收。
-
pending位向量和blocked位向量
内核为每个进程pending位向量中维护着待处理信号的集合,在blocked位向量中维护者被阻塞的信号集合。
传送了一个类型为k的信号,内核就会设置pending中第k位;而只要接收了一个类型为k的信号,内核就会清除pending中的第k位。
Unix提供了大量向进程发送信号的机制,而所有这些机制都是基于进程组(process group)这个概念的。
-
每个进程只属于一个进程组,进程组由一个进程组ID标识。
-
默认地,一个子进程和它地父进程同属于一个进程组,但可以改变。一个进程可以通过使用setpgid函数来改变自己或其他进程的进程组。
#include
pid_t getpgrp(void); int setpgid(pid_t pid, pid_t pgid);
/bin/kill 程序可以指定进程发送 也可以指定 进程组发送
-
发送信号9(SIGKILL)给单个进程15213
linux> /bin/kill -9 15213
-
发送信号9(SIGKILL)给进程组15213的每一个进程
linux> /bin/kill -9 -15213
-
作业(Job):Unix使用作业这个概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多有一个前台作业和0个或多个后台作业。比如,键入:
linux> ls | sort
会创建一个由两个进程组成的前台作业,这两个进程是通过Unix管道连接起来的:一个进程运行ls程序,另一个运行sort程序。shell为每个作业创建一个独立的进程组。进程组ID通常会取自作业中父进程的一个。
-
在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。
-
在键盘上输入Ctrl+Z会导致内核发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下是停止(挂起)前台作业。
#include#include int kill(pid_t pid, int sig);
- 若pid > 0, kill发送信号sig给进程pid;
- 若pid == 0,kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己;
- 若pid < 0,kill发送信号sig给进程组**|pid|**中的每个进程。
进程可以通过alarm函数向它自己发送SIGALARM信号
#includeunsigned int alarm(unsigned int secs);
alarm函数安排内核在secs秒后发送一个SIGALARM信号给调用进程。
- 若secs == 0,则不会调度安排新的闹钟;
- 在任何情况下,对alarm的调用都将取消任何待处理的(pending)闹钟,并且返回剩余秒数;
- 若没有任何待处理的闹钟,就返回0。
当内核把进程p从内核模式切换为用户模式时(例如从系统调用返回或者完成一次上下文切换),它会检查进程p的未被阻塞的待处理信号的集合(pending & ~blocked)。
-
如果这个集合为空,内核将控制传递到p的逻辑控制流中的下一条指令(Inext)
-
如果集合非空,内核选择集合中的某个信号k(通常为最小的k),并且强制p接收信号k。收到这个信号会触发进程采取某种行为,一旦完成了这个行为,那么控制就会传递回p的逻辑控制流中的下一条指令(Inext)。每个信号都有预定义的默认行为:
- 进程终止
- 进程终止并转储内存(coredump)
- 进程停止(挂起)直到被SIGCONT信号重启
- 进程忽略该信号
进程可通过signal函数修改和信号相关联的默认行为:
#includetypedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
- 若handler是SIG_IGN,那么忽略类型为signum的信号
- 若handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为
- 否则,handler就是用户定义的函数的地址,这个函数就被称为信号处理函数。只要进程接收到一个类型为signum的信号,就会调用这个程序。调用信号处理程序被称为捕获信号;执行信号处理程序被称为处理信号。
注意是其他信号哦,相同的信号是会被丢弃的。
- 主程序捕获到信号s,则信号会中断主程序,将控制转移到处理程序S;
- S在运行过程中,程序捕获到信号t≠s,该信号会中断S,控制转移到处理程序T;
- 当T返回时,S从它被中断的地方继续执行;
- 最后,S返回,控制传送回主程序,主程序从它被中断的地方继续执行。
#includeunsigned int sleep(unsigned int seconds);
sleep函数回导致调用进程被挂起,直到下面某种情况发生:
-
seconds指定的挂钟时间已经逝去
-
进程捕获到一个信号,就会提前返回,返回值就是未睡眠够的时间
Linux提供隐式和显式的机制:
(1)隐式阻塞机制内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
(2)显式阻塞机制应用程序可以使用sigprocmask函数和它的辅助函数来明确地阻塞和解除阻塞选定的信号。
#include3.1.5 编写信号处理函数int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigemptyset(sigset_t *set); int sigfillset(sigset *set); int sigaddset(sigset_t *set, int signum); int sigdelset(sigset_t *set, int signum); 以上函数成功返回0,否则返回-1 int sigismember(const sigset_t *set, int signum);
信号处理函数是Linux系统编程最棘手的一个问题。处理程序中有几个属性使得它们很难推理分析:
- 处理程序与主程序并发执行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰
- 如何以及合适接收信号的规则常常有违 人的直觉,信号是不会排队的
- 不同的系统有不同的信号处理语义
-
问题:处理程序与主程序并发执行,且共享同样的全局变量。如果处理程序和主程序并发地访问同样的全局变量,那么结果就不可预知,而且经常是致命的。
-
编写指导:
-
G0:处理程序要尽可能简单。避免麻烦的最好办法是保持处理程序简单。例如,处理程序可能知识简单地设置全局标志并返回;所有与接收信号相关的处理都由主程序执行,它周期性的检查(并重置)这个标志。
-
G1:在处理程序中只调用异步信号安全的函数。所谓的异步安全的函数能够被信号处理程序安全地调用,原因有二,要么它是可重入的(例如只访问全局变量),要么它他不能被信号除程序中断。P534 图8-33列出了Linux保证安全的系统级函数。许多常见的函数(例如 printf、sprintf、malloc和exit)都不在此列。
-
G2:保存和恢复errno。许多Linux异步信号安全的函数都会在出错时设置errno。在处理程序中调用这样的函数可能会干扰到主程序中其他依赖于errno的部分。解决方法是:
进入处理函数时,把errno保存在一个局部变量中,在处理程序返回前恢复它。当然,如果处理函数直接终止了,那就不需要了。
-
G3:阻塞所有的信号,保护对共享全局数据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局变量,那么在访问(读或写)该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。
-
G4:用volatile声明全局变量:考虑一个处理程序和一个main函数,它们共享一个全局变量g,处理程序更新g,main周期性地读g。对编译器而言,main中的值看上去从来没有变化过,因此使用缓存在寄存器中的g的副本来满足main的每次引用。如果是这样,那么main永远无法看到g被更新。**因此,使用volatile保证编译器不要在寄存器中缓存这个变量,强迫编译器每次引用g时,都要从内存中读取。**此外,由于此变量被共享,需要按照G3,在访问g时,应暂时阻塞信号,保护每次对g的访问。
-
G5:用sig_atomic_t声明标志:在常见的处理程序设计中,处理程序会写全局变量标志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志,C提供一种整形数据类型sig_atomic_t,对她的读写军保证是原子的(不可中断),因为可以使用一条指令来实现:
volatile sig_atomic_t flag;
需要注意的是:这里对原子性的保证只实用于单个的读和写,而不适用于像flag++ 或 flag = flag + 10这样的更新,因为它们可能需要多条指令。
-
- 问题:当已存在待处理的信号时,同样的信号到达时,实际上会被丢弃,因此,如果存在一个未处理的信号,我们只能认为至少有一个信号到达了
- 解决:因此我们绝不能用信号来对其他进程中发生的事情计数,我们在编写程序时必须清楚地提醒自己这一点。
Unix信号处理地另一个缺陷在于不同地系统有不同地信号处理语义,例如:
-
signal函数的语义各有不同。有些老的Unix系统在信号k被处理程序捕获后,就会把信号的处理程序恢复为默认,因此在这些系统上,每次运行之后,处理程序必须重新调用signal显示得重新设置自己。
-
系统调用可以被信号中断。像read,write和accept这样的系统调用潜在地会阻塞进程较长时间,称为慢速系统调用。在某些较早版本的Unix系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。在这些系统上,程序员必须包括手动重启被中断的系统调用代码(通过检查返回值是否为EINTR)。
疑问:系统调用是怎么被中断的,不是在系统调用返回的时候才会去检查信号吗?
为了解决以上问题,Posix标准定义了sigaction函数,它允许用户在设置信号处理函数是,明确指定他们想要的语义:
#includeint sigaction(int signum, struct sigaction *act, struct sigaction *oldact);
sigaction的运用并不广泛,这是因为它要求用户设置一个复杂结构的条目。一个更为简洁的方式是定义一个包装函数Signal,其调用sigaction,且它的调用方式和signal相同,其定义如下:
handler_t *Signal(int signum, handler_t *handler)
{
struct sigaction action, old_action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask);
action.sa_flags = SA_RESTART;
if (sigaction(signum, &action, &old_action) < 0)
unix_erro("Signal error");
return (old_action.sa_handler);
}
Signal的语义为:
- 只有这个处理程序当前正在处理的那个类型的信号被阻塞
- 和所有信号一样,信号不会排队等待
- 只要可能,被中断的系统调用会自动重启
- 一旦设置了信号处理程序,它就会一直保持,直到Signal带着handler为SIG_IGN或SIG_DFL被调用时



