下面是针对《深入理解计算机系统》和博客文章-异常控制流的学习总结
一、异常控制流
从开机到关机,处理器做的工作其实很简单,就是不断读取并执行指令,每次执行一条,整个指令执行的序列,称之为处理器的控制流。
一般改变控制流的方式,比如:
- 跳转和分支
- 调用和返回
这两个操作对应于程序状态的改变,但是这实际上仅仅局限与程序本身的控制,没有办法去应对更加复杂的情况,系统状态发生变化的时候,上述操作是不行的,比如:
- 数据从磁盘或者网络适配器到达
- 指令除以0
- 用户按下ctrl+c
- 系统的计时器到时间
但是系统必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,,而且也不一定要和程序的执行相关。所以就引入了一种更加复杂的机制来处理,现代操作系统通过使控制流发生突变来对以上情况做出反应,这些突变就称为异常控制流(exceptional control flow,ECF)。
异常控制流发生在计算机系统的各个层次,比如,硬件检测到的事件会触发控制突然转移到异常处理程序,在操作系统层面,内核通过上下文转换将控制从一个用户进程转移到另一个用户进程
异常
异常是异常控制流的一种形式,它一部分是由硬件实现的,一部分是由操作系统实现的。
异常就是控制流中的突变,用来响应处理器状态中的某些变化,或者说,异常指的是将控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常住内存的一部分,而这类事件包括除以0、数学运算溢出,页错误,I/O请求完成或用户按下了ctrl+c等系统级别的事件
具体过程如下:
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表来确定跳转位置,每种事件都有对应的唯一的异常编号,发生对应异常时就会调用对应的异常处理代码
异常一般可以分为四类:
- 中断(interrupt)
- 陷阱(trap)
- 故障(fault)
- 终止(abort)
1. 中断 (硬件中断)
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果,不知道它什么时候会发生,CPU对其的响应也是完全被动的。
比较常见的中断有两种:计数器中断和I/O中断,计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器终端来从用户程序手上拿回控制权。
I/O中断比较多,比如说键盘输入了ctrl+c,网络适配器中一个包接收完毕,都会触发这样的中断。
中断处理的过程如下:
I/O设备,比如网络适配器、磁盘控制器等,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,以触发中断,这个异常号就标识了引起中断的设备
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序,当处理程序返回时,它就将控制返回给下一条指令,结果是程序继续执行。
2. 陷阱
陷阱、故障和终止都是属于同步异常,是因为执行某条指令所导致的事件。陷阱是有意的异常,是执行一条指令的结果,就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。
陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit),为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的”syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令,执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序对参数解码,并调用适当的内核程序。
3. 故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它,否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
一个经典的故障示例是缺页异常,当指令引用一个虚拟地址时,而与该地址相对应的物理页面不在存储器中,因此必须从磁盘中取出时,就会发生故障。而缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令,当指令再次执行时,相应的物理页面已经驻留在存储器中了,指令就可以没有故障地运行完成。
4.终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,终止处理程序不会将控制返回给应用程序,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
5. 异常小结
对异常做个小结,异步异常只有中断一种,而同步异常有陷阱、故障和终止,下面是对4种类别进行的总结
二、进程
1. 进程概念
进程的定义就是一个执行中的程序的实例。
系统中的每个程序都是运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的,这个状态包括存放在存储器中的程序代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合
1.1 私有地址空间
进程为每个程序提供了一种假象,好像它独占地使用系统地址空间,在一台有n位地址的机器上,地址空间有2^n个可能地址的集合,比如对于32位系统,它的虚拟地址空间为4GB,一个进程为每个程序提供它自己的私有地址空间。
1.2 用户态和内核态
在x86结构中,有4种特权级别,特权级别最高的是ring 0,被视作为内核态,级别最低的是ring 3,就被看做为用户态。当程序运行在内核态时,可以执行指令集中的任何命令,并且可以访问系统中任何存储器位置;而处于用户态的程序,也就是一般普通程序运行的模式怕,不能直接访问操作系统内核的数据结构和程序。
而处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位,进程就运行在内核态中,没有设置模式位时,进程就运行在用户模式中,用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位。或者发起一个I/O操作,也不允许用户模式中的进程直接引用地址空间中的内核区内的代码和数据。
进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常,当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式转换为内核模式,处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式
2. 进程切换
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务,内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括:
- 通用目的寄存器
- 浮点寄存器
- 程序计数器
- 用户栈
- 状态寄存器
- 内核栈
- 各种内核数据结构,比如描绘地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表
上下文切换过程如下:
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
下面展示一对进程A和进程B之间上下文切换的例子,切换进程时,内核负责执行具体的调度,如下图所示:
- 进程A运行在用户模式,直到通过执行系统调用read陷入到内核
- 磁盘读取数据需要一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程A到进程B的上下文切换
- 切换的第一部分内核代表进程A在内核模式下执行指令,然后在某一时刻,它开始代表进程B(仍然是内核模式)执行指令
- 切换之后,内核代表进程B在用户模式下执行命令
- 磁盘发出中断信号,数据已经从磁盘传送到存储器上,然后就执行进程B到进程A的上下文切换
- 将控制返回给进程A中紧随在系统调用read之后的那条指令,进程A继续运行,直到下一次异常发生,依次类推
3. 进程控制
获取进程ID
pid_t getpid(void)
: 返回当前进程的PIDpid_t getppid(void)
: 返回当前进程的父进程PID
创建和终止进程
我们可以认为进程有三种主要状态:
- 运行
- 正在被执行、正在等待执行或者最终将会被执行
- 停止
- 执行被挂起,在进一步通知前不会计划执行
- 终止
- 进程被永久停止
进程被终止的原因:
- 收到一个信号,该信号的默认行为是终止进程
- 从主程序返回
- 调用exit函数
exit函数被调用一次,但从不返回,具体的函数原型是:1
2//以status 状态终止进程,0表示正常结束,非0则是出现了错误
void exit(int status)
创建进程
父进程通过调用fork函数创建一个新的运行子进程,fork函数很有趣,因为它只被调用一次,却返回两次:
- 一次在父进程中,fork返回子进程的PID
- 一次在新创建的子进程中,fork返回0 (因为子进程的PID总是非零的,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中进行)
1 | // 对于子进程,返回 0 |
子进程几乎和父进程一模一样,会有相同且独立的虚拟地址空间,也会得到父进程已经打开的文件描述符(file descriptor)。比较明显的不同之处就是进程 PID 了。
看一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0)
{ // Child
printf("I'm the child! x = %d\n", ++x);
exit(0);
}
// Parent
printf("I'm the parent! x = %d\n", --x);
exit(0);
}
输出为:1
2
3linux> ./forkdemo
I'm the parent! x = 0
I'm the child! x = 2
- fork调用一次,但是会有两个返回值
- 并行执行,不能预计父进程和子进程的执行顺序
- 每个进程拥有自己独立的地址空间(也就是变量都是独立的),除此之外其他都相同
- 在父进程和子进程中 stdout 是一样的(都会发送到标准输出)
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除,相反,进程被保持在一种已终止的状态中,直到被它的父进程回收,当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此开始,进程就不存在了。父进程一般利用wait或者waitpid回收已终止的子进程
而一个终止了但未被回收的进程称为僵尸进程。如果父进程不回收子进程的话,通常来说会被init进程(pid==1)回收。
三、信号
在前面已经谈到的异常控制流中,我们看到了操作系统是如何使用异常来支持一种称为进程上下文切换的异常控制流方式,这一节将讨论更高层的软件形式的异常,称为Unix信号。
它是一种异步的通知机制,用来提醒进程一个事件已经发生,当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都被中断,如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数
低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的,而信号提供了一种机制,通知用户进程发生了这些异常。信号的类型由1~30的整数定义,常用的信号类型如下:
内核通过给目标进程发送信号,来更新目标进程的状态,具体的场景为:
- 内核检测到了如除以0(SIGFPE)或子进程终止(SIGCHLD)的系统事件
- 另一个进程调用了kill指令来请求内核发送信号给指定的进程
目标进程接收到信号后,内核会强制要求进程对于信号做出响应,可以有几种不同的操作:
- 忽略这个信号
- 终止进程
- 捕获信号,执行信号处理器(signal handler),类似于异步中断中的异常处理器
接收信号
当内核从异常处理程序返回,准备将控制传递给进程p时,它会检查进程p的未被阻塞的待处理信号的集合(pending & ~blocked),如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中的下一条指令(Inext)。
然而,如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k,收到这个信号会触发进程的某种行为,一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令(Inext),每个信号类型都有一个预定义的默认行为,下面的一种:
- 进程终止
- 进程终止并转储存储器
- 进程停止直到被SIGCONT信号重启
- 进程忽略该信号
阻塞信号
我们知道,内核会阻塞与当前在处理的信号同类型的其他正待等待的信号,也就是说,一个 SIGINT 信号处理器是不能被另一个 SIGINT 信号中断的。
四、非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用 - 返回序列。非本地跳转是通过setjmp和longjmp函数来提供的。
setjmp函数在env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0,调用环境包括程序计数器、栈指针和通用目的寄存器。
longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用返回,然后setjmp返回,并带有非0的返回值retval.
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果一个深层嵌套函数调用中发现了一个错误,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。
参考链接
- 《深入理解计算机系统》
- https://wdxtub.com/2016/04/16/thin-csapp-5/