csapp第八章:异常控制流
异常控制流有什么用
每次从一条指令过渡到另外一条指令的过程称为控制转移,这样的一个控制转移序列叫做控制流,如果每条指令都是相邻的,这样的过渡就是平滑序列。如果一条指令与另外一条指令不相邻,这样突发性的过渡称为异常,也就是我们这一章要学到的异常控制流ECF
ECF有什么用处
-
ECF是操作系统用来实现IO,进程,虚拟内存的基本机制
-
理解ECF将帮你理解应用程序是如何与操作系统交互的:应用程序通过系统调用的ECF形式向操作系统请求服务
-
可以帮助理解并发
-
可以帮助理解软件异常是如何工作的
异常是什么
异常是异常控制流的一种形式,他一部分由硬件实现,一部分由操作系统实现
异常:控制流的突变,用来相应控制流的某些变化
这一过程如上图所示,应用程序本来在执行Icur指令,但是有些事件(定时器信号、算术溢出等)会使得处理器的状态发生变化,这时候处理器会通过一张异常跳转表,进行跳转到专门的异常处理程序中,异常处理程序执行完任务以后:可能返回当前正在执行指令、返回当前下一条指令或者终止被中断的程序。
异常处理
在系统启动时,操作系统分配或者初始化了一张称为异常表的跳转表,使得表目k包含异常处理k的处理程序的地址
当运行本机上的一个应用程序的时候,如果处理器检测到了一个事件,并且确定了异常号4,处理器就会触发异常,通过异常表中的地址4跳转到相应的异常处理程序中执行。
异常表起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里
控制从用户程序转移到内核,所有的这些项目(X-86会包含当前条件码的EFLag寄存器的值)都被压入内核栈中,而不是压入用户栈。由于是运行在内核模式下,意味着拥有对所有资源的访问权限
一旦硬件除触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,通过一条特殊的从中断返回指令可选的返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断是一个用户程序,就把状态恢复为用户模式,然后将控制返回被中断的程序。
异常的类别
中断,陷阱,故障,终止。
中断:是异步发生的,来自处理外部的I/O设备信号的结果。如我目前在打字的键盘:
我的电脑上也在播放音乐,当在听歌的同时输入字符到屏幕上的时候,处理器就会注意到I/O设备键盘上的中断引脚电压变高了,当前指令执行完毕以后就会从系统总线中读取异常号,然后调用中断处理程序,输入完字符以后,中断处理继续执行听歌程序的下一条指令。由于这个过程相当的快,就好像什么都没有发生一样。
陷阱(陷入内核)
系统调用:这是有意而为之的异常,是执行一条指令的结果,就像中断处理程序那样,陷阱处理程序将控制返回到下一条指令,陷阱最重要的用途就是在用户程序和内核之间提供一个接口。
比如要读一个文件的内容(read),这些内核服务受到控制的访问,处理器提供的是syscall n指令来响应用户的请求,导致一个陷阱异常,这个异常程序对参数解码并调用内核程序。这个异常处理程序运行在内核模式中
故障
故障发生时,处理器将控制转移程序给故障处理程序,如果处理器能够修正这个情况,就将控制返回到引起故障的指令,从而重新执行它,否则处理程序返回到内核的abort例程,然后终止引起故障的应用程序
最经典的故障就是缺页异常。
终止
通常是由一些硬件引起的不可恢复的致命错误
直接返回到abort中,终止该应用程序
LINUX系统下的异常
IA32有高达256种不同的异常,0-31的号码是Intel架构师定义的;32-255号是操作系统定义的中断和陷阱。
系统调用:陷入到内核中执行(不知道为啥翻译成了陷阱)
Linux系统上有数百个系统调用,比如常用的读文件、写文件、创建进程等等,这些系统调用都有一个唯一的整数编号对应到内核一个跳转表的位移量:这个跳转表和异常表不一样
由于历史的原因,系统调用通过异常128来处理(0x80)提供
c语言可以用systemcall 函数可以直接调用任何系统调用。但是对于大多数系统调用,标准c库提供了一组方便的包装函数
所有Linux系统调用的参数都是通过寄存器而不是栈来传递的。
进程
异常是允许操作系统提供进程概念的基本构造块。
上下文:程序正确运行所需要的状态组合,这个状态包括存放在内存的程序和数据,他的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合
进程提供给应用程序的两个关键抽象:程序独占处理器,程序独占内存系统
至于并发就很好理解了,说白了就是某进程开始执行以后并未完成以后,跳转到其他进程执行,这两个就是并发。如上图的进程A和进程B,进程A和进程C。由于进程B执行结束以后才开始进程C,所有B和C不算是并发。
时间片:进程A执行它控制流的一部分的每个时间片段,就叫时间片。
私有地址空间
进程为每一个程序提供它自己的私有地址空间
这个私有的地址空间最上部是内核保留的,最下部是预留给用户程序的。代码始终是从0x08048000处开始(32位系统)。
用户模式和内核模式
处理器为了安全起见,不至于损坏操作系统,必须限制一个应用程序可执行指令能访问的地址空间范围。就发明了两种模式用户模式和内核模式,其中内核模式(上帝模式)有最高的访问权限,甚至可以停止处理器、改变模式位,或者发起一个I/O操作,处理器使用一个控制寄存器当作模式位,描述当前进程的特权。进程只有当中断、故障或者陷入系统调用时,才会将模式位设置成上帝模式,得到内核访问权限,其他情况下都始终在用户权限中,就能够保证系统的绝对安全。
上下文切换
内核中有一个专门的调度程序,当从进程A切换到进程B的时候,内核调度器为每个进程保存一个上下文状态(运行环境保存):包含程序计数器、用户栈、状态寄存器等,然后切换到另外一个进程处开始执行
在进程执行的某个时刻,内核可以决定抢占当前进程,并重新开始一个先前被强占的进程,这个决策就是调度。
上下文切换:保存当前进程的上下文,恢复先前被抢占的进程被保存的上下文,将控制权传递给这个新恢复的进程。
当内核代表用户执行系统调用时候,可能会发生上下文切换,如果系统调用因为等待某个时间发生阻塞时,那么内核可以让当前进程休眠,切换到另一个进程,即使系统调用没有发生阻塞,内核也可以决定执行上下文切换,而不是把控制返回给调用进程
中断也可以引起上下文切换,比如每次发生定时器中断的时候,内核就会判定当前进程已经运行了足够长的时间,并且切换到新的进程
系统调用错误处理
当UNIX系统级别的函数遇到错误的时候,他们通常会返回-1,并设置全局整数变量errno来表示出现了什么错误
信号
每种信号都对应了某种系统事件,低层的硬件异常是由内核异常处理系统来处理的,正常情况下,对用户进程而言是不可见的,信号提供了一种机制通知用户发生了这种异常
发送信号
内核通过更新目的进程上下文的某个状态,发送一个信号,发送一个信号可能有如下两种原因
-
内核检测到一个系统事件,比如除零错误或者子进程终止
-
一个进程调用了KILL函数,显示的发送一个信号给目的进程
接收信号
当目的进程被内核以某种方式对信号作出反应的时候,他就接受了信号,进程可以忽略这个信号,终止或者通过一个信号处理程序的用户层函数来捕捉这个信号。
内核为每个进程维护了两个位图pending和blocked
当内核把进程p从内核模式切换到用户模式(例如从系统调用返回或者完成了一次上下文切换),他会检查进程p的没有被阻塞的待处理信号的集合(pending&~blocked)如果数值为0,那么内核将控制传递到p的逻辑控制流的下一条指令。如果集合是1,那么内核选择集合中某个信号k(如果同时有多个通常是最小的),并且强制p接收信号k,收到这个信号会触发进程中的某种行为,进程完成了这个行为,控制就传回给p的逻辑控制流的下一条指令。
每个信号类型都有一个预定义的行为,是下面的几种。
- 进程终止
- 进程终止并且转储内存
- 进程停止直到被SIGCONT信号重启
- 进程忽略该信号
当一个进程捕获了一个类型为k的处理程序,会执行为信号k设置的处理程序。然后当执行到他的return 语句时,控制通常传递回控制流中被信号中断位置的地方,、
通常是因为在某些系统中 ,被中断的系统调用会返回一个错误。
1> 待处理信号被阻塞:拿我们在接收信号处理程序中的sigint1中的程序为例,当我们的程序正在处理handler函数时,如果又捕获到了一个SIGINT信号,这时候并不会停止handler函数的处理,而是将这个SIGINT信号放到带处理程序的位置(阻塞),直到handler函数执行完毕返回以后才接受这个待处理信号;
2> 待处理信号不会排队等待:信号的等待队列中不会有两个相同的信号
3> 系统调用可以被中断:诸如read、wait函数,会阻塞进程一段时间,当处理程序捕获到一个信号时,被中断的系统调用在处理程序返回的时候就不会再执行了
信号的阻塞
隐式阻塞:内核默认阻塞任何当前处理信号类型的待处理信号
显示阻塞:直接改变blocked的数值
编写信号处理程序
处理程序与主程序并发执行,共享同样的全局变量,因此可能与主程序和其他程序相互干扰
如何以及何时接收信号的规则常常有违人的直觉
不同的系统又不同的信号处理语义
1.安全的信号处理:
处理程序尽可能简单
调用异步信号安全的函数:可重入,不能被信号处理函数中断
保存和恢复errno
访问全局数据结构的时候,阻塞所有的信号,保证安全
用volatile声明全局变量