0%

第八章-异常控制流

控制流 简单来讲就是程序执行期间代码执行的顺序和方式,即程序从一个语句到另一个语句的跳转和执行顺序。

![](第八章-异常控制流/QQ截图20230607165922.png)

异常控制流(Exception Control Flow)是指程序执行中发生的异常事件所导致的控制流改变。异常事件可以是许多不同类型的错误或意外情况,例如除数为零、内存访问违规、IO错误等等。

当一个异常事件发生时,程序的当前控制流会停止并开始执行异常处理程序。异常处理程序可以是开发者预先编写好的一些代码,也可以是操作系统提供的默认的异常处理程序。当异常处理程序结束后,程序会返回到发生异常的位置继续执行。

为什么要学习ECF:

  • 理解ECF帮助理解重要的系统概念。
  • 有助于我们理解应用程序是如何与操作系统交互
  • 有助于我们编写有趣的新型应用程序
  • 有助于理解并发
  • 有助于理解软件异常如何工作

异常

异常是控制流的突变,用来相应处理器状态的某些变化。

举个例子:我在玩手机,女朋友喊我去吃饭,那我就要放下手头的工作去配女朋友吃饭,吃完饭我可以继续玩手机,也可以去干别的事情。

在这个例子中,正常的控制流就是指我一直玩手机,女朋友喊我就是控制流突变,处理完异常事件会发生一下三种情况:

  1. 处理程序将控制返回给当前指令(我继续玩手机)
  2. 处理程序返回给如果没有异常将会执行的下一条指令(可能去睡觉)
  3. 处理程序终止被终端的程序,也就是g了。

异常号

系统中每种类型的一场都分配了一个唯一的非负整数的异常号。在系统启动时操作系统反配合初始化一张成为异常表的跳转表,该表存放的是异常处理程序的地址。异常表的起始地址放在一个叫做异常表基地址寄存器的特殊CPU寄存器里。

异常的类别

四类:中断陷阱故障终止

中断

中断是异步的,是来自处理器外部的I/O设备的信号的结果。在指令执行过程中,中断引脚的电压变高(这是一个信号),当前指令结束后,处理器注意到中断引脚电压变高,于是从系统总线读取异常号,从而触发中断。

陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果(上面的中断就不是某条指令的结果)。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。读文件(read)、创建新进程(fork)等都是内核提供的服务,用户程序通过syscall n指令来请求服务n,这条指令会导致一个道异常处理程序的陷阱。

故障

故障由错误情况引起,可能被故障处理程序修正。

终止

终止是不可恢复的致命错误造成的结果,终止处理程序从不将控制返回给应用程序。

linux系统调用

使用syscall指令可以调用任何系统调用,然而我们更多使用的是一些封装好的系统级函数。

来看一下系统级函数的实际应用

对应的汇编内容为

可以看到,系统调用编号被放置在rax寄存器,rdi,rsi,rdx,r10,r8,r9依次传递第一二至第六个参数。

进程

进程的经典定义是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。得益于进程这一抽象,我们的程序好像是系统中唯一运行的程序,好像独占的使用处理器和内存。

逻辑控制流

PC的值组成的序列叫做逻辑控制流,如上图所示共有三个逻辑流即

PCa1、PCa2;

PCb;

PCc1、PCc2;

进程是轮流使用处理器的,每个进程执行它流的一部分然后被抢占(暂时挂起),然后处理其他进程。

并发流

有交替的逻辑流就是并发流,A和B并发,A和C并发,B和C不是并发,因为B进程结束的时候C进程才开始,两个进程并没有时间上的重合。

进程控制

每个进程都有一个唯一的整数进程ID(PID)。getpid函数返回调用进程的PID,getppid函数返回它的父进程的PID(创建调用进程的进程)。

创建和终止进程

进程的三种状态:运行、停止、终止。

父进程可以通过fork函数创建一个新的运行的子进程。子进程和父进程几乎完全一样,最大的区别是他们具有不同的PID。fork函数被调用一次,却返回两次,一次返回父进程,一次返回给子进程。在父进程中fork返回创建的子进程的PID,在子进程中fork返回0。

这个程序可以帮助我们理解fork函数。fork之后产生了一个子进程,子进程和父进程的代码完全相同,但是子进程不是从头开始运行,而是从创建进程的下一条语句开始执行即红框圈中的部分,所以我们可以理解为红框所圈中的部分被执行了两次,一次是在子进程中,一次是在父进程中。在子进程中fork返回0,执行if语句里的内容,父进程中PID为正,执行下面的printf,所以得到

值得注意的是子进程和父进程被执行的顺序不是固定的,也就是可能在另一台机器甚至在这台机器再运行一遍程序都有可能先打印child。

子进程和父进程是共享文件的,在父进程调用fork时,stdout文件是打开的,指向屏幕,子进程继承了这个文件,因此它的输入也是指向屏幕的。

回收子进程

当一个进程由于某种原因突然终止时,内核并不是立即把他从系统中清除。进程被保持在一种已终止的状态中直到它被父进程回收。当父进程回收子进程时内核将子进程的退出状态传递给父进程,然后抛弃,从此该进程就不存在了。

一个进程可以通过调用waitpid函数来等待它的子进程终止或停止。

在默认情况下(options=0时),waiopid挂起调用进程(父进程)的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个子进程再刚调用的时候已经终止了,那么waitpid立即返回。在以上两种情况下waitpid函数返回已终止的子进程的PID。

集合成员

  • 如果参数列表里的pid>0,那么等待集合就是一个单独的子进程,进程id等于PID。
  • 如果PID=-1,那么等待集合就是由父进程的所有子进程组成的。

修改默认行为

检查回收子进程的退出状态

如果statusp参数是非空的,也就是将第二个参数设置为了一个int的地址,那么waitpid函数将会在改地址处存放子进程的状态信息。如果不需要这些状态信息,填入NULL即可。

这里的退出状态为exit n中n。

来个例子理解一下

pid=-1代表回收所有子进程,NULL代表不需要子进程的状态信息,options是默认的0。

让进程休眠

sleep函数将一个进程挂起指定的时间(单位秒),如果请求时间量已经到了sleep返回0,否则返回还剩下的要休息的秒数,其实这两种属于同一种情况请求时间量到了,剩余休眠时长自然为0。

pause函数让调用进程休眠,直到该进程接收到一个信号。

加载并运行程序

execve函数在当前进程的上下文中加载并运行一个新程序。新的进程继承原进程的PID。

filename是可执行文件,可以是名字也可以是路径。argv为参数列表,envp为环境变量列表,要注意这两个表都要以NULL结尾,默认环境变量为0。来个例子

1
2
3
4
5
6
7
8
9
10
11
12
//execve.c
#include<stdio.h>
#include<unistd.h>
int main()
{
char*argv[]={NULL};
char*envp[]={0,NULL};
printf("I am process 1\n");
execve("execve1",argv,envp);
printf("hhh");
return 0;
}
1
2
3
4
5
6
7
//execve1.c
#include<stdio.h>
int main()
{
printf("I AM PROCESS 2\n");
return 0;
}

可以看到在execve函数执行完之后,原进程的代码就不再被执行了。

信号

一个信号就是一条小消息。他通知进程系统中发生了一个某种类型的事件。

可以看出,信号是异常的一种。

信号表:

信号术语

传送一个信号到达目的进程是由两个不同步骤组成的:

  • 发送信号。内核通过更新目的进程的上下文中的某个状态,发送一个信号给目的进程。发送信号的原因:1)内核检测到一个系统事件,如除零错误。2)一个进程调用了kill函数发送信号。
  • 接收信号。当目的进程被内核强迫以某种形式对信号作出反应,他就接受了信号。

发送信号

发送信号的机制基于进程组这一概念。

进程组:每个进程都只属于一个进程组,进程组由一个正整数进程组ID标识。getpgrp函数返回当前进程的进程组。默认的,一个子进程和它的父进程同属于一个进程组,一个进程可以通过setpgid函数来改变自己或者其他进程的进程组。

该函数将进程pid的进程组改为gpid。特殊的,当pid为0,那么就使用当前进程的PID,如果gpid为0,那么就是用pid指定的进程的PID作为进程组的ID。

1
2
//进程15213
setpgid(00

该函数会创建一个gpid为15213的进程组,并将进程15213加入到这个新的进程组中。

1.使用 /bin/kill 程序发送信号

/bin/kill程序可以向另外的进程发送任意的信号。

1
linux> /bin/kill -9 15213

实现的功能是发送信号9给进程15213。一个负的pid会导致信号被发送到进程组pid中的每个进程。

1
linux> /bin/kill -9 -15213

实现的功能是发送信号9给进程组15213中的每一个进程。

2.从键盘发送信号

Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多有一个前台作业和0或多个后台作业。job是一个或多个进程的集合。

1
ls | sort

键入该命令shell进程会创建一个由两个进程组成的前台作业,一个进程运行ls程序,另一个进程运行sort程序。shell为每个作业创建一个独立的进程组,进程组ID通常取自作业中父进程的一个。

在键盘上输入Ctrl+c会导致内核发送一个SIGINT信号到前台进程组的每个进程。默认情况下是终止前台作业。 Ctrl+z会发送一个信号到前台进程组的每个进程,默认情况下是停止(挂起)前台作业。

3.使用kill函数发送信号

进程通过调用kill函数发送信号给其他进程(包括他们自己)。

如果pid大于零,则kill函数发送信号sig给进程pid。如果pid等于零,kill函数发送信号给调用进程所在的进程组中的每个进程。如果pid小于零kill函数发送信号给进程组|pid|(pid的绝对值)中的每个进程。

4.用alarm函数发送信号

接收信号

当内核把进程从内核模式切换到用户模式时(从系统调用中返回或是完成了一次上下文切换),他会检查进程p的未被阻塞的待处理信号集合。如果这个集合为空那么内核将控制传递到p的逻辑控制流的下一条指令。如果集合非空,那么内核通常会选择从最小的信号k开始处理,强制进程p接收信号k。收到这个信号会触发进程采取某种行为,一旦完成,控制传递回逻辑控制流的下一条指令

每个信号类型都有一个预定义的默认行为:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止直到被sigcont信号重启
  • 进程忽略该信号
  • 进程可以通过signal函数修改和信号相关联的默认行为

配上这个程序解释一下

开始运行的时候,调用了signal函数对信号SIGINT即中断信号(Ctrl+c)进行了自定义,也就是当接收到该信号时,会运行指定的程序sigint_handler,pause函数执行后,进程被挂起,此时我们按下Ctrl+c,得到的不是直接终止程序而是”Caught SIGINT”字符串然后再exit(0)。

阻塞和解除阻塞信号

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

  • 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。说简单一点就是,如果当前进程就收到s信号并正在进行处理,此时就算再发出s信号进程也不予理会,此时的第二个s信号会被放到待处理信号集合中,当出现第三个信号s时,第三个信号s将直接被丢弃。
  • 显示阻塞机制。通过sigprocmask函数和它的辅助函数明确的阻塞和解除阻塞选定的信号。

首先来看sigprocmask函数的三个参数,第一个how,规定函数如何改变当前阻塞信号集合。第二个参数set是信号集合。第三个参数oldset如果不为NULL的话,则保存之前的阻塞信号集合。

how的值:

辅助函数:

来个例子

首先用了两个辅助函数,Sigemptyset函数将mask初始化为空集合,Sigaddset函数将信号SIGINT加入集合mask中。接下来的Sigprocmask函数使用BLOCK选项,指定将集合mask中的信号添加至阻塞集合,并将之前的阻塞集合保存到prev_mask之中。经过这么一设置我们的Ctrl+c将不再被处理。最后使用setmask方法将保存的prev_mask设置为阻塞列表,起到还原的作用。

未完待续。。。。。。