CSAPP:异常控制流与进程

现代系统通过控制流发生突变来对系统状态变化做出反应,我们把这些突变称为异常控制流(exception control flow, ECF)。异常控制流可以发生在硬件层,操作系统层和应用层。这里的异常与高级编程语言,和C++,JAVA等的异常概念不一样。

异常的类型

异常可以分为4类:中断(interrupt),陷阱/陷入(trap),故障(fault),终止(abort)。其区别如下:

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

中断

中断(interrupt)是唯一一个异步发生的异常,它来自处理器外部的IO设备的信号。硬件中断不是由任何一条专门的指令造成的,所以称为异步。硬件中断的异常处理程序通常称为中断处理程序。

中断是由硬件产生的信号,比如网络适配器,磁盘控制器,定时芯片等,它们先向处理器芯片的一个引脚发送信号,然后将异常号放在系统总线上来触发中断。处理器“感知”到引脚的电压变化后就去系统总线读取异常号,并调用相应的中断处理程序,调用返回时就将控制返回到下一条指令,程序继续执行。可以认为这类中断对于程序执行是“透明”的。

中断之外的几种异常都是同步发生的,是当前指令执行的结果,我们把导致这些异常的指令称为故障指令。

陷阱

陷阱(trap)是有意的异常。陷阱最重要的作用是在用户程序和内核之间提供一个像过程一样的接口,即系统调用

陷阱允许了用户程序对内核服务的受控访问,处理器提供一条特殊的 syscall n 的指令来请求服务 n。执行 syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序对参数解码并调用适当的内核程序。从程序员的角度看,系统调用和普通的函数调用是一样的,但是他们的实现却非常不同。普通函数是运行在用户模式(user mode)的,它们使用于调用函数相同的栈,而系统调用运行在内核模式,允许系统调用执行指令,并访问定义在内核的栈。

常见的陷阱/系统调用如下:结束进程(exit),创建进程(fork),读/写/打开/关闭文件,等待子进程结束(waitpid)等等。

C程序可以用syscall函数调用任何系统调用,但是实际中应该使用标准C库提供的包装函数,这些函数将参数打包在一起,以适当的系统调用号陷入内核,然后将系统调用的返回状态传递回调用程序。我们将系统调用和这类包装函数称为系统级函数。

使用系统调用写一个更简单的hello world 如下:

int main() {
write(1, "hello, world\n", 13);
exit(0);
}

其中write和exit都是是系统函数,write第一个参数表示输出发送到stdout,第二个参数是要写的字节序列,第三个是要写的长度。

故障

故障(fault)有错误引起,可能能够被故障处理程序修正,即潜在可恢复的错误。当故障发生时,处理器将控制转移给故障处理程序,如果能修正这个错误,将返回到引起故障的指令而重新执行它,如果不能修正则返回到内核的abort例程。

一个常见的故障是缺页异常,指令引用了一个虚拟地址,而与该地址对应的物理页面地址不在存储器中,因此需要从磁盘取出(调用缺页处理程序),就会发生故障,缺页处理程序加载相应的页面后,控制会返回当前指令并在此执行(正常执行)。

常见的故障有如下:出发错误,一般保护故障,缺页等。

终止

终止(abort)是不可恢复的致命错误造成的,通常是一些硬件错误,比如RAM位损坏时发生的奇偶校验错误。终止处理程序不再将控制返回给应用程序,而是直接终止其运行。

进程与异常

异常时允许操作系统提供进程所需要的基本构造快。进程的景点定义就是一个执行中的程序的实例。系统的每个程序都运行在某个进程的上下文中的。上下文包含程序正确运行所需的状态,这些状态包括在存储器中的程序代码和数据,栈,通用寄存器的内容,程序计数器,环境变量以及打开的文件描述符的集合。

进程为程序运行提供两个关键的抽象:

  1. 一个独立的逻辑控制流,它提供一个程序独占地使用处理器的假象
  2. 一个私有的地址空间,它提供一个程序独占地使用存储系统的假象

处理器的一个物理控制流,可以分为多个逻辑控制流(逻辑流),每个进程一个。一个逻辑流在执行时间上与另一个流重叠,称为并发流,这两个流程为并发地运行。多个流并发执行的现象称为并发,一个进程和其他进程轮流执行的概念成为多任务。这里有一个时间切片的概念。

并发(concurrency)的思想与流运行的处理器核的数量无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们运行在一个处理器上。并行(parallel)是并发的一个真子集,两个流并发地运行在不同的处理器核或者计算机上,它们就是并行的。

私有地址空间

一个进程为每个程序提供它自己的私有地址空间,一般而言,这个空间某个地址相关联的存储器字节是不能被其他进程读写的,也就是说这个地址空间是私有的。

尽管每一个私有地址空间相关联的存储器的内容是不同的,但是每个这样的空间的结构是通用的。一个 x86 Linux进程的地址空间结构如下图:

用户模式和内核模式

处理器通常是用某个控制寄存器中的一个模式位(mode bit)控制进程的运行模式。当设置了这个位时,进程运行在内核模式,可以执行任何指令访问任何存储器位置,否则则运行在用户模式,不允许执行特权指令,只能访问私有空间的数据,运行在用户模式的进程从用户模式转变为内核模式的唯一方法是通过异常(中断,故障/陷入系统调用)。

Linux系统的 /proc 文件系统允许用户模式进程访问内核数据结构(文本内容),/proc 目录下的文件包含一般的系统属性,如CPU信息,或者某个进程使用存储器段的映射(/proc//maps)。Linux系统的 /sys 文件系统则提供关于系统总线和设备的额外的底层信息。

进程控制

Unix提供了很多从C程序中操作进程的系统调用,比如比较简单的 pid_t getpid(void)pid_t getppid(void)获取当前执行进程的pid和其父进程的pid。pid_t是pid的类型,在Linux系统中在 sys/types.h 定义为int。这两个函数包含在 unistd.h 中。

创建和终止进程

首先要了解进程的状态,从程序员的角度看,我们可以认为进程总是处于下面三种状态之一:

  1. 运行。进程要么在CPU上执行,要么在等待执行且最终会被内核调度
  2. 停止。进程的执行被挂起(suspend),且不会被调度。直到它收到一个 SIGCONT 信号会再次开始执行
  3. 终止。进程永远地停止了。进程终止有三种原因:收到终止进程的信号;从主程序返沪;调用了exit函数

从操作系统的角度看会有更多的状态,但是在程序员的角度看,只需要了解这三个状态就可以了。

创建一个进程和终止一个进程都是用系统调用函数,即fork和exit,其函数声明如下:

void exit(int status);
pid_t fork(void);

其中exit声明在 stdlib.h, 而fork声明在 unistd.h 中。
新创建的进程几乎(但是不完全)和父进程相同。子进程得到父进程虚拟地址空间相同的一份拷贝,包括文本,数据和bss段、堆以及用户栈;子进程还会获得与父进程打开的文件描述符相同的拷贝,也就是说子进程可以读写父进程打开的任何文件。理解这部分是很重要的,否则对于多进程的执行会难以理解。由于子进程几乎就是对父进程的一个复制,所以他们的PC(程序计数器)都是指向同样的虚拟地址,这在后面的示例程序中很重要。父子进程之间最大的区别只是它们的PID不同。

fork 函数非常不同的一点是它只被调用一次,却会返回两次,一次是返回子进程的PID给父进程,一次是在新创建的子进程中返回0。在我们的程序中使用这个不同的PID(肯定是一个0一个非0)判断当前是父进程还是子进程。这和其他高级语言的并行程序设计很不一样(不过像Java等一般是多线程,很少用多进程)。看下面一个我修改过的经典示例:

#include <stdio.h>
#include <unistd.h>

void exit(int); // 函数声明,防止vim保存时报错

int main(void) {
int a[] = {1,2,3,4};
pid_t pid = fork();
// 子进程和父进程都会执行之后的代码
if (pid == 0) {
a[0] = 11;
a[1] = 22;
printf("fork pid is %5d, a is %d, %d, %d, %d\n", pid, a[0], a[1], a[2], a[3]);
//exit(0); // if no exit here, it will go-on to the rest lines of(15+) of main
return 0;
}
a[0] = 111;
a[1] = 222;
a[3] = 444;
printf("main pid is %5d, a is %d, %d, %d, %d\n", pid, a[0], a[1], a[2], a[3]);
exit(0);
}

可以在一个Linux系统编译运行这段代码,尝试取消注释和注释子进程中的 exit(0) 调用看看前后的区别。

通过上面的示例,我们知道子进程是由一份独立的拷贝,实际上操作系统课程也讲到过,进程之间使用的是独立的地址空间,相互不会影响,所以也不难理解了。不过虽然数据是独立的,但是父子进程共享打开的文件,对同一个文件资源的操作还是反映在同一个文件上的。

创建了子进程后,内核可以以任何方式交替执行他们的逻辑控制流中的指令。下面这张图对进程的理解很有帮助:


其中Fork定义如下:

pid_t Fork(void) {
pid_t pid;
if ( (pid = fork()) < 0 ) {
fprintf(stderr, "Fork error: %s\n“, strerror(errno) );
exit(0);
}
return pid;
}

其中fork调用失败,返回-1且设置全局变量 errno表示错误类型,使用strerror将错误代码转换为可理解的字符串描述。

回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统清除,而是保持一种已终止的状态,知道被它的父进程回收(reap)。终止了但是还没有回收的进程称为僵死进程(zombie)。当父进程回收已终止的进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。

如果父进程不回收僵死进程,内核会安排init进程(PID为1的系统进程)进行回收操作。对于长时间运行的程序,总是应该由父进程进行回收操作以及时释放系统资源。

父进程进行回收的函数,也是一个等待子进程执行结束的函数就是waitpid。这在 APUE(advanced programming in Unix enviroment)中很早就提过这个函数,它的声明如下:

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statud, int options)

默认情况下(option=0)时,挂起调用进程(父进程)等待指定的子进程终止,如果这个子进程已经终止了则会立即返回。该函数调用返回已终止的子进程的PID,并将该子进程从系统清除。

第一个参数指定等待的子进程的PID,如果 pid > 0 则是一个单独的子进程,如果 pid== -1 那么指定的就是所有的子进程的集合,也就是会等待子进程中的一个终止该函数才会返回。
第二个参数是是一个指针,用于返回子进程终止的状态码(exit(status))。子进程的退出状态是一些int常量,定义在 wait.h 中。常见的有 WIFEXITED,WEXITSTATUS, WIFSIGNALED, WTERMSIG和其他的一些,这里不细列了。参考Page 496
第三个参数是对默认行为的修改,可以是 WNOHANG, WUNTRACEDWNOHANG|WUNTRACED 三个值。waitpid默认的行为是挂起调用进程等待子进程终止,WNOHANG会导致如果子进程没有终止那么就立即返回。如果在等待子进程终止时还要做一些工作可以设置这个参数。

如果调用进程没有子进程则返回-1,并设置errno为ECHILD,如果waitpid调用被信号中断,返回-1,并设errno为EINTR。

其他进程相关函数

上面的是多进程中比较难理解的部分,还有一些常用的进程相关的函数就不详细写了,分列如下:

  1. 休眠函数,将一个进程挂起一段指定的时间。
#include <unistd.h>
unsigned int sleep(unsigned int secs);
int pause(void); // 一直休眠,直到收到一个信号
  1. 加载并运行程序 execve
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);

这个函数可以执行目标文件 filename,并通过后两个参数传入相关的参数。这个函数比较复杂,要使用可以查看Page 500。不过比较有用的是环境变量相关的部分(第三个参数),环境变量是数组对的形式,Unix提供了几个函数来操作环境变量:

#include <stdlib.h>
chat *getenv(const char *name);
int setenv(const char *name, const char *newvalue, int overwrite);
void unsetenv(const char *name);

fork与execve不同,fork是在新的子进程中运行相同的程序,子进程是父进程的一个复制品,execve 则是在当前进程的上下文加载并运行一个新的程序,它会覆盖当前的地址空间,而不是创建一个新的进程。