GuangchaoSun's Blog

第8章 异常控制流

学习目标

  • 了解异步异常与同步异常
  • 理解进程的工作机制
  • 了解信号的基本原理以及如何处理信号
  • 如何进行非本地跳转

异常控制流

异常控制流存在于系统的每个层级,最底层的机制称为异常(Exception),用以改变控制流以响应系统事件,通常是由硬件和操作系统共同实现的。更高层次的异常控制流包括进程切换(Process Context Switch)信号(Signal)非本地跳转(Nonlocal Jumps),也可以看做是一个从硬件过渡到操作系统。再从操作系统过渡到语言库的过程。进程切换是由硬件计时器和操作系统共同实现的,而信号只是操作系统层面的概念看了,到了非本地跳转就已经是在C运行库中实现了。

接下来我们就分别来看看这四个跨域计算机不同层级的异常控制流机制

异常

异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。

当处理器检测到有事件(event)发生时,它会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler))。

当异常处理完成后,根据引起异常的事件的类型,会发生以下三种情况:

  1. 处理程序将控制返回给当前指令I(curr),即当事件发生时正在执行的指令。
  2. 处理程序将控制返回给I(next),即如果没有发生异常将会执行的下一条指令。
  3. 处理程序终止被中断的程序。

系统中每种类型的异常都分配了唯一一个非负整数的异常号(exception number)。
异常号是异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。

异常的分类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。

类别 原因 异步/同步 返回指令
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

异步异常(中断)

异步异常(Asynchronous Exception)称之为中断(Interrupt),是由处理器外部的 I/O 设备引起的。
比较常见的中断有两种:

  • 计时器中断:计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器中断来从用户程序手上拿回控制权。
  • I/O 中断类型比较多样,比如是键盘输入了ctrl-c,网络中一个包接收完毕。

同步异常

陷阱

陷阱最重要的用途是在用户程序内核之间提供一个像过程一样的接口,叫做系统调用
用户程序经常需要向内核请求服务,比如readforkexecveexit。为了允许这些服务的受控访问,处理器提供了一系列特殊的 syscall n 指令。
执行 syscall n 指令会导致一个到异常处理程序的陷阱,这个处理程序对参数解码,并调用适当的内核程序。

故障

故障的处理根据故障能否被修复,故障处理程序要么重新执行处理引起故障的指令,要么终止。
缺页异常:当指令引用一个虚拟地址,而与该地址对应的物理地址页面不在存储器中,因此必须从磁盘中取出时,就会发生故障。(具体的请参考第九章)

终止

终止处理程序,就像名字说的那样,从不将控制返回给应用程序。

进程

进程是计算机科学中最深刻最成功的概念之一
进程的经典定义:一个执行中的程序的实例。
进程提供给程序的关键抽象:

  • 一个独立的逻辑控制流。逻辑控制流通过上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器
  • 一个私有的地址空间。私有地址空间则是通过虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存

逻辑控制流

程序计数器的值唯一对应于包含在程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。

并发流

一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow)。
多个流并发地执行的一般现象称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。

上下文切换

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。
内核为每一个进程维持一个上下文(context)上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表
上下文切换:

  • 保存当前进程的上下文
  • 恢复某个先前被抢占的进程被保存的上下文
  • 将控制传递给这个新恢复的进程

进程切换 Process Context Switch

这么多进程,是如何工作的呢?

左边是单进程模型,内存里保存着进程所需的各种信息,因为该进程独占CPU,所以并不需要保存寄存器值。
而在右边的单核多进程中,虚线部分可以认为是当前正在执行的进程,因为我们可能会切换到其他进程,所以内存中需要另一块区域来保存当前的寄存器值,以便下次执行的时候进行恢复(也就是所谓的上下文切换)。整个个过程中,CPU交替执行不同的进程,虚拟内存系统会负责管理地址空间,而没有执行的进程的寄存器值会保存在内存中。切换到另一个进程的时候,会载入已保存的对应于将要执行的进程的寄存器值。

而现代处理器一般有多个核心,所以可以真正同时执行多个进程。这些进程会共享主存以及一部分缓存,具体的调度是由内核控制的,示意图如下:

切换进程时,内核会负责调度,如下图所示:

系统调用错误处理

在遇到错误的时候,Linux 系统级函数通常会返回-1并且设置errno这个全局变量来表示错误的原因。使用的时候记住两个规则:

  1. 对于每个系统调用都应该检查返回值
  2. 当然有一些系统调用的返回值为 void,在这里就不适用

进程控制

获取进程信息:

  • pid_t getpid(void)-返回调用进程的PID
  • pid_t getppid(void)-返回它的父进程的PID(创建调用进程的进程)

上面两个函数都会返回一个类型为pid_t的整数值,在Linux上它在types.h中定义为int
进程总是处在下面三种状态:

  • 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
  • 停止。进程的执行被挂起(suspend),且不会被调度。当收到 SIGSTOP、SIGTSTP、SIDTTID 或者 SIGTTOU 信号时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
  • 终止。 进程终止的三个原因:
    • 收到一个信号,该信号的默认行为是终止进程
    • 从主程序返回
    • 调用exit函数

exit函数以status退出状态来终止进程。
父进程通过调用fork函数创建一个新的运行子进程。

创建进程
调用fork来创建新进程,具体的函数原型为

//对于子进程,返回0
//对于父进程,返回子进程的PID
int fork(void)

子进程和父进程几乎一模一样,会有相同且独立的虚拟地址空间,包括文本、数据和bss段、堆以及用户栈。也会得到父进程已经打开的文件描述符(file description)。最大的区别就是不同的PID。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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);
}
unix> ./fork
parent: x=0
child : x=2

fork函数的特征:

  • 调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次 —— 一次返回到父进程,一次返回到新创建的子进程。
  • 并发执行。父进程和子进程时并发运行的独立进程。
  • 相同的但是独立的地址空间
  • 共享文件

对应的进程图:

回收子进程

如果主进程已经终止,子进程还在消耗系统资源,我们称之为【僵尸】。
如果父进程没有回收它的僵死进程就终止了,那么内核就会安排init进程来回收他们。init进程的 PID 为1,并且是在系统初始化时由内核创建。
一个进程可以通过调用wait或者waitpid函数来等待它的子进程终止或者停止。

1
2
3
4
5
#include <sys/type.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
/*返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1*/

waitpid函数使用实例:使用waitpid函数不按照特定的顺序回收僵死子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "caspp.h"
#define N 2
int main(){
int status, i;
pid_t pid;
/*parents creates N childrens*/
for(i=0; i < N; i++)
if((pid = Fork()) == 0)/*Child*/
exit(100 + i);
/*parent reaps N children in no particular order*/
while((pid = waitpid(-1, &status, 0)) > 0){
if (WIFEXITED(status))
printf("child %d terminated normally with the exit status = %d\n",pid, WEXITSTATUS(status));
else
printf("child %d terminated abnormally\n", pid);
}
if(errno != ECHILD)
unix_error("waitpid error");
exit(0);
}

在第十四行,父进程调用waitpid函数作为while循环的测试条件,等待它的所有子进程终止。因为第一个参数是 -1,所以对waitpid的调用会阻塞,直到任意一个子进程终止。在每个子进程终止时,对waitpid调用会返回,返回值为该子进程的非零pid

sleep()函数将一个进程挂起一段指定时间。如果请求的时间量到了,sleep返回0,否则返回剩下的要休眠的秒数。

信号 Signal

信号是 Unix、类 Unix 以及其他 POSIX 兼容的操作系统中进程间通讯的一种有限的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。

下面是几个常用的信号类型:

编号 名称 默认动作 对应事件
2 SIGINT 终止 用户输入 ctrl+c
9 SIGKILL 终止 终止程序(不能重写或忽略)
11 SIGSEGV 终止且Dump 段冲突 Segmentation violation
14 SIGALRM 终止 时间信号
17 SIGCHLD 忽略 子进程停止或终止

如何传送一个信号到目的进程:

  • 发送信号:内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
    • 内核检测到一个系统事件,如被零除错误或者子进程终止
    • 一个进程调用了kill函数
  • 接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,目的进程就接收了信号。有以下几种操作:
    • 忽略这个信号
    • 终止进程
    • 捕获信号,执行信号处理器(signal handler),类似于异步中断中的异常处理器(exception handler)

如果信号已经发送但是未被接收,那么处于等待状态(pending),同类型的信号至多只会有一个待处理信号(pending signal),一定要注意这个特性,因为内部实现机制不可能提供复杂的数据结构,所以信号的接收并不是一个队列。比如说进程有一个 SIGCHLD 信号处于等待状态,那么之后进来的 SIGCHLD 信号都会被直接扔掉。

当然,进程也可以阻塞特定信号的接收,但信号的发送不受控制,所以被阻塞的信号仍然可以被发送,不过直到进程取消阻塞该信号之后才会被接收。内核为每个进程在pending位向量中维护着待处理信号的集合,在blocked位向量中维护着被阻塞的信号集合。

进程组

每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。

  • getpgrp()-返回当前进程的进程组
  • setpgid()-设置一个进程的进程组

简单示例:父进程通过发送 SIGINT 信号来终止正在无限循环的子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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)
{
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);
}
}

接收信号

所有的上下文切换都是通过调用某个异常处理器(exception handler)完成的,内核会计算对应于某个进程ppnb值:pnb = pending & ~blocked

  • 如果pnb == 0,那么就把控制交给进程p的逻辑流中的下一条指令
  • 如果pnb != 0
    • 选择pnb中最小的非零位k,并强制进程p接收信号k
    • 接收到信号之后,进程p会执行对应的动作
    • 对pnb中所有的非零位进行这个操作
    • 最后把控制交给进程p的逻辑流的下一条指令

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

  • 终止进程
  • 终止进程并转储存储器(dump core)
  • 停止进程直到被SIGCONT信号重启
  • 进程忽略该信号

signal可以修改默认动作,函数原型为 handler_t *signal(int signum, handler_t *handler)。通过一个例子来感受下,这里我们屏蔽了SIGINT函数,即使按下ctrl+c也不会终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void sigint_handler(int sig) // SIGINT 处理器
{
printf("想通过 ctrl+c 来关闭我?\n");
sleep(2);
fflush(stdout);
sleep(1);
printf("OK. :-)\n");
exit(0);
}
int main()
{
// 设定 SIGINT 处理器
if (signal(SIGINT, sigint_handler) == SIG_ERR)
unix_error("signal error");
// 等待接收信号
pause();
return 0;
}

阻塞信号(*)

安全处理信号(*)

非本地跳转 Non Local Jump

所谓的本地跳转,指的是在一个程序中通过 goto 语句进行流程跳转,尽管不推荐使用 goto 语句,但是在嵌入式系统中为了提高程序的效率,goto 语句还是可以使用的。本地跳转的限制在于,我们不能从一个函数跳转到另一个函数中。如果想突破函数的限制,就要使用setjmplongjmp来进行非本地跳转了。

setjmp保存当前程序的堆栈上下文环境(stack context),注意,这个保存的堆栈上下文环境仅在调用setjmp的函数内有效,如果调用setjmp的函数返回了,这个保存的堆栈上下文就失效了。调用setjmp的直接返回值为0。

longjmp将会恢复由setjmp保存的程序堆栈上下文,即程序从调用setjmp处开始执行,不过此时的setjmp的返回值将是由longjmp指定的值。注意longjmp不能指定0位返回值,即使指定了0,longjmp也会使setjmp返回1