第八章 异常控制流
从开机到关机,处理器做的工作其实很简单,就是不断读取并执行指令,每次执行一条,整个指令执行的序列,称为处理器的控制流。到目前为止,我们已经学过了两种改变控制流的方式:
- 跳转和分支
- 调用和返回
这两个操作对应于程序的改变。但是这实际上仅仅局限于程序本身的控制,没有办法去应对更加复杂的情况。系统状态发生变化的时候,无论是跳转/分支还是调用/返回都是无能为力的,比如:
- 数据从磁盘或者网络适配器到达
- 指令除以了零
- 用户按下 ctrl+c
- 系统的计时器到时间
这时候就要轮到另一种更加复杂的机制登场了,称之为异常控制流(exceptional control flow)。
异常
这里的异常指的是把控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常驻内存的一部分,而这类事件包括除以零、数学运算溢出、页错误、I/O 请求完成或用户按下了 ctrl+c 等等系统级别的事件。
具体的过程可以用下图表示:
系统会通过异常表(Exception Table)来确定跳转的位置,每种事件都有对应的唯一的异常编号,发生对应异常时就会调用对应的异常处理代码
异常的类别
异常分为四种:
- 中断
只有中断是异步的,是来自处理器外部的I/O设备的信号的结果。
是在当前指令执行完之后才进行中断处理程序。 - 陷阱和系统调用
有意的异常,是执行指令的结果。用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户经常需要向内核请求服务,比如读文件,fork,execve等,为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令来请求服务n。这哥指令导致一个异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
系统调用运行在内核模式中。 - 故障
由错误情况引起,可能被故障处理程序修正。
经典的是缺页异常,处理是从磁盘中加载适当的页面,然后将控制返回给引起故障的指令。再重新运行这个指令。 - 终止
不可恢复的致命错误导致,通常是一些硬件错误。
进程
经典定义:一个执行中程序的实例
进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
上下文切换
切换进程时,内核会负责具体的调度,如下图所示
fork()的原理及用法
我们都知道通过fork()系统调用我们可以创建一个和当前进程印象一样的新进程.我们通常将新进程称为子进程,而当前进程称为父进程.而子进程继承了父进程的整个地址空间,其中包括了进程上下文,堆栈地址,内存信息进程控制块(PCB)等.
- 父子进程
那么我们首先来先说说父进程和子进程之间的区别:
- 父进程设置了锁,子进程不继承
- 进程ID不同
- 子进程的未决告警被清除
- 子进程的未决信号集设置为空集
- fork系统调用说明
通过man手册我们可以轻松知道fork()包含的头文件<sys/types.h>和<unistd.h>,功能就是创建一个子进程.函数原型:pid_t fork(void),pid_t是带一个代表经常号pid的数据结构.如果创建成功一个子进程,对于父进程来说是返回子进程的ID.而对于子进程来说就是返回0.而返回-1代表创建子进程失败.
进程图
进程图是一个很好的帮助我们理解进程执行的工具:
- 每个节点代表一条执行的语句
- a -> b 表示 a 在 b 前面执行
- 边可以用当前变量的值来标记
- printf 节点可以用输出来进行标记
- 每个图由一个入度为 0 的节点作为起始
fork函数:
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0);
}
我们可以用下面两个函数获取进程的相关信息:
- pid_t getpid(void) - 返回当前进程的 PID
- pid_t getppid(void) - 返回当前进程的父进程的 PID
把整个 fork() 包装起来,就可以自带错误处理,比如
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
对于进程图来说,只要满足拓扑排序,就是可能的输出。用例子来简单示意一下:
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0)
{ // Child
printf("child! x = %d\n", --x);
exit(0);
}
// Parent
printf("parent! x = %d\n", x);
exit(0);
}
对应的进程图为:
回收子进程
即使主进程已经终止,子进程也还在消耗系统资源,我们称之为『僵尸』。为了『打僵尸』,就可以采用『收割』(Reaping) 的方法。父进程利用 wait 或 waitpid 回收已终止的子进程,然后给系统提供相关信息,kernel 就会把 zombie child process 给删除。
如果父进程不回收子进程的话,通常来说会被 init 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程中,就需要显式回收(例如 shell 和 server)。
如果想在子进程载入其他的程序,就需要使用 execve 函数,具体可以查看对应的 man page,这里不再深入。
信号
对于前台进程来说,我们可以在其执行完成后进行回收,而对于后台进程来说,因为不能确定具体执行完成的时间,所以终止之后就成为了僵尸进程,无法被回收并因此造成内存泄露。
这怎么办呢?同样可以利用异常控制流,当后台进程完成时,内核会中断常规执行并通知我们,具体的通知机制就是『信号』(signal)。
信号是 Unix、类 Unix 以及其他 POSIX 兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。
这样看来,信号其实是类似于异常和中断的,是由内核(在其他进程的请求下)向当前进程发出的。信号的类型由 1-30 的整数定义,信号所能携带的信息极少,一是对应的编号,二就是信号到达这个事实。下面是几个比较常用的信号的编号及简介:
内核通过给目标进程发送信号,来更新目标进程的状态,具体的场景为:
- 内核检测到了如除以零(SIGFPE)或子进程终止(SIGCHLD)的系统事件
- 另一个进程调用了 kill 指令来请求内核发送信号给指定的进程
目标进程接收到信号后,内核会强制要求进程对于信号做出响应,可以有几种不同的操作:
- 忽略这个型号
- 终止进程
- 捕获信号,执行信号处理器(signal handler),类似于异步中断中的异常处理器(exception handler)
具体的过程如下:
进程组
每个进程都只属于一个进程组,想要了解相关信息,一般使用如下函数:
- getpgrp() - 返回当前进程的进程组
- setpgid() - 设置一个进程的进程组
如果想要发送信号,可以使用 kill 函数,下面是一个简单的示例,父进程通过发送 SIGINT 信号来终止正在无限循环的子进程。
void forkandkill()
{
pid_t pid[N];
int i;
int child_status;
for (i = 0; i < N; i++)
if ((pid[i] = fork()) == 0)
while(1) ; // 死循环
for (i = 0; i < N; i++)
{
printf("Killing process %d\n", pid[i]);
kill(pid[i], SIGINT);
}
for (i = 0; i < N; i++)
{
pid_t wpid = wait(&child_status);
if (WIFEXITED(child_status))
printf("Child %d terminated with exit status %d\n",
wpid, WEXITSTATUS(child_status));
else
printf("Child %d terminated abnormally\n", wpid);
}
}
非本地跳转
所谓的本地跳转,指的是在一个程序中通过 goto 语句进行流程跳转,尽管不推荐使用goto语句,但在嵌入式系统中为了提高程序的效率,goto语句还是可以使用的。本地跳转的限制在于,我们不能从一个函数跳转到另一个函数中。如果想突破函数的限制,就要使用 setjmp 或 longjmp 来进行非本地跳转了。
setjmp 保存当前程序的堆栈上下文环境(stack context),注意,这个保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,如果调用 setjmp 的函数返回了,这个保存的堆栈上下文环境就失效了。调用 setjmp 的直接返回值为 0。
longjmp 将会恢复由 setjmp 保存的程序堆栈上下文,即程序从调用 setjmp 处重新开始执行,不过此时的 setjmp 的返回值将是由 longjmp 指定的值。注意longjmp 不能指定0为返回值,即使指定了 0,longjmp 也会使 setjmp 返回 1。
我们可以利用这种方式,来跳转到其他的栈帧中,比方说在嵌套函数中,我们可以利用这个快速返回栈底的函数,我们来看如下代码
jmp_buf env;
P1()
{
if (setjmp(env))
{
// 跳转到这里
} else
{
P2();
}
}
P2()
{
...
P2();
...
P3();
}
P3()
{
longjmp(env, 1);
}
对应的跳转过程为:
也就是说,我们直接从 P3 跳转回了 P1,但是也有限制,函数必须在栈中(也就是还没完成)才可以进行跳转