异常是指令地址在控制流中发生突变所做出的反应,它不仅涉及到硬件,更与操作系统相联系。
正常来讲,程序计数器(PC)会在处理器运行时不断地读取指令序列的地址,两个指令间的过渡被称作 控制转移,该指令序列被称作 控制流,其中虽包含着一些平滑的 突变(即两条指令不相邻),如函数调用等,但终归是一些必要的机制。
但系统必须对系统状态的变化做出反应,这些变化未必和程序相关。如:看门狗定期产生的信号应得到回应;程序向磁盘请求数据后休眠;子进程终止后父进程将得到通知。
现代系统通过发生突变对其做出反应,我们将这种突变称作 异常控制流(ECF)。他不仅发生在硬件层面,也会在操作系统(上下文切换)或应用层(信号)进行。
一. 异常处理
1. 概论
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做 异常表的跳转表,进行一个 间接过程调用(异常), 转移一个专门设计用来处理这类事件的 操作系统子程序(异常处理程序)。 当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下 3 种情况中的一种 :
- 处理程序将控制返回给当前指令 ;
- 处理程序将控制返回给下一条指令 ;
- 处理程序终止被中断的程序。
系统中可能的每种类型的异常都分配了一个唯一的非负整数的 异常号,其中一些号码是巾处理器的设计者分配的, 其他号码是由 操作系统内核(操作系统常驻内存的部分) 的设计者分配的。
操作系统分配和初始化一张称为异常表的跳转表,使得表目 k 包含 异常 k 的处理程序的地址。 当处理器发生了一个事件后,其触发异常:执行间接过程调用,通过异常表的表目 k,转到相应的处理程序。
特别注意的是,异常处理程序存储在内核中,异常过程调用使得控制从用户端转移到内核(两者的访问权限等有一些区别),这些控制被压入内核栈中。
2. 类别
异常分为四类:中断,陷阱,故障与终止。
中断 属于是异步发生的(即并非由专门的指令造成),来源于处理器外部 IO 设备的信号。比如看门狗中断:Timer 从某个数开始倒计时,CPU 发出命令后使其复位,否则强制系统复位。这个系统有效地防止了程序死循等问题。
接下来几种都属于同步发生的,是执行当前指令的结果。陷阱 属于有意执行的异常,进行完陷阱中断程序,即将控制返回到下一条指令。其最重要的用途是 在用户程序和内核之间提供一个接口,叫做系统调用(如read, fork, exit 等)。
故障 由某些错误引起,也可以被故障处理程序修正后重新执行当前指令。一个经典的故障示例是 缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。
上述代码便是故障的一个栗子,a 数组为全局变量存在磁盘中,因此取 a[500]
时需要从磁盘加载到内存。movl
指令所指向的地址 isn't available
,因此触发了页缺失。当页缺失处理程序将缺失的地址送到内存后,便可再次执行 movl
指令了。
上述是一个无效引用的例子,开始处理器将其视为页缺失送到对应的处理程序中,结果内核发现这是虚拟空间的无效地址,因此程序会报错(段错误)。
二. 进程
进程 是执行中程序的实例。它会提供一种假象:每个程序都仿佛在独占着处理器与内存,使得程序好像独占着处理器令其一条条指令的执行,而代码和数据貌似成为内存中唯一的对象。事实上,这是通过进程的概念实现的。
系统的每个程序都运行在某个进程的 上下文 中,而上下文室友程序正确执行所需的状态组成的 — — 这包括程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
1. 概论
我们将关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。多个流并发地执行的一般现象被称为 并发(concurrency)。 一个进程和其他进程轮流运行的概念称为 多任务(multitasking)。
由于某些异常(如定时器中断或发生系统调用),OS kernel
会抢占当前另一个进程,并开始先前被强占的进程,这被称为 调度。此时他使用一种称为 上下文切换 的机制来将控制转移到新的进程,共有三步:
- 保存当前进程的上下文;
- 恢复某个先前被抢占的进程被保存的上下文;
- 将控制传递给这个新恢复的进程。
如图,磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程 A 到进程 B 的上下文切换,而不是在这个间歇时间内等待,什么都不做。
2. 进程控制
完全想象不到要控制进程干什么。
- 当 Unix 系统函数出现错误时,会返回 -1 . 利用这一点我们可以整一个 错误处理包装函数,便于我们便于处理乱七八糟的系统函数。
void unit_error(char *msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
pid_t Fork(void)
{
pid_t pid; // pid 即为函数引用
if (pid = fork() < 0) unit_error("Fork error");
return pid;
}
- 获取进程 ID
每个进程都有一个唯一的正数(非零)进程 ID(PID)。getpid
函数返回调用进程的 PID;getppid
函数返回它的父进程的 PID(创建调用进程的进程)。
#include <sys/types.h>
#include <unistd. h>
...
pid_t getpid(void);
pid_t getppid(void);
- 创建与终止进程
父进程通过 fork 函数创建一个新的运行的子进程。
新创建的子进程几乎但不完全 与父进程相同。 子进程得到与父进程 用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、 堆、 共享库以及用户栈。 子进程还获得与父进程任何 打开文件描述符相同的副本,这就意味着当父进程调用 fork 时, 子进程可以读写父
进程中打开的任何文件。 父进程和新创建的子进程之间最大的区别在千它们有不同的PID。
在父进程中, fork 返回子进程的 PID; 在子进程中, fork 返回 0。因为子进程的PID总是为非零, 返回值就提供一个明确的方法来 分辨程序是在父进程还是在子进程中执行 。
int main()
{
pid_t pid; int x = 1;
pid = Fork();
if (pid == 0) { // 子进程
printf("Child: x=%d\n", ++x);
exit(0);
}
// 父进程
printf("Parent: x=%d\n", --x);
exit(0);
}
得到结果如下:
parent: x=0
child: x=2
但事实上,父进程和子进程是 并发运行 的独立进程。上述结果也可能输出 child 再 parent . 这也衍生出很多题目 具体地说,我们可以通过 画进程图 的方式理解程序执行方式。
此外,看似 子进程与父进程调用了同一个变量 x,但实际上是两个 相同而独立的副本,其地址空间相同但两者改变不会相互影响,所以最后 x 有不同的值。
- 由父进程(或 init)回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程 回收(reaped)。当父进程回收己终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃己终止的进程。一个终止了但还未被回收的进程称为 僵死进程(zombie) 。
如果一个父进程终止了,内核会安排 init进程 成为它的孤儿进程的养父。 init 进程的 PID 为 1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。 进程没有运行,它们仍然消耗系统的内存资源。 一个进程可以通过调用 waitpid 函数 来等待它的子进程终止或者停止。