程序计数器中指令的地址的过渡称为控制转移,控制转移的序列称为处理器的控制流。最简单的是平滑流。跳转、调用和返回等指令会造成平滑流的突变,来对内部的程序状态中的变化做出反应。系统也需要能够对系统状态的变化做出反应,这些系统状态不能被内部程序变量捕获,系统通过使控制流突变来完成,这些突变称为异常控制流(ECF)。ECF发生在系统的各个层次,包括异常、系统调用、信号和非本地跳转等,本篇对它们一一总结。
异常
异常的一部分由硬件实现,一部分由操作系统实现,它就是控制流中的突变,用来响应处理器状态的某些变化。注意和语言中的应用级的异常概念区分。
处理器中,状态被编码为不同的位和信号,状态变化被称为事件,事件不一定和当前指令的执行有关。处理器检测到有事件发生时,会通过异常表进行间接过程调用,到一个专门设计处理事件的操作系统子程序,称为异常处理程序。
异常处理程序完成处理后,根据异常事件的类型会(执行一种):
- 将控制返回给当前指令(事件发生时正在执行的)。
- 将控制返回给下一条指令(没有异常将会执行的)。
- 终止被中断的程序。
异常表是一张跳转表,表目k包含异常k的处理程序的地址,在系统启动时由操作系统分配和初始化。系统中每种可能的异常都分配了一个唯一的非负整数的异常号。

生成异常处理程序的地址
异常类似过程调用,不同的是:
- 过程调用跳转前会将返回地址压入栈中,但异常的返回地址只能是当前指令或下一条指令。
- 处理器会把一些额外的重新开始被中断程序需要的处理器状态压入栈中。
- 控制从用户程序转到内核,这些项目压入内核栈中,而不是用户栈。
- 异常处理程序运行在内核模式下(对系统资源有完全访问权限)。
异常可以分为四类:
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
异步异常是由处理器外部的I/O设备中的事件产生的,同步异常是执行一条指令的产物。
中断是异步发生的,硬件中断不由任何指令造成,所以说是异步的。硬件中断的异常处理程序称为中断处理程序。
陷阱、故障和终止是同步发生的,称为故障指令。
陷阱是有意的异常,主要用来在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。处理器提供了 syscall n 指令来满足用户向内核请求服务 n , syscall 指令会导致一个到异常处理程序的陷阱,处理程序调用适当的内核程序。普通函数运行在用户模式,而系统调用运行在内核模式。
故障由错误引起,如缺页异常。故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正错误,就将控制返回到故障指令,重新执行;否则处理程序返回到内核的 abort 例程, abort 终止应用程序。
终止是不可恢复的致命错误的结果,主要是一些硬件错误。终止处理程序将控制返回到 abort 例程,abort 终止应用程序。
下面是Intel的Pentium系统的异常:
异常号 | 描述 | 异常类别 |
---|---|---|
- 除法错误:除零或除法结果太大,Unix终止程序,报告为浮点异常。
- 一般保护故障:通常为引用未定义的虚拟存储器区域或写一个只读的文本段,Unix终止程序,报告为段故障。
- 缺页:将物理存储器相应的页面映射到虚拟存储器的页面,重新执行故障指令。
- 机器检查:检测到致命的硬件错误。
进程
进程是一个执行中程序的实例。系统中每个程序都是运行在某个进程的上下文中的。上下文由程序正确运行所需的状态组成,包括程序的存放在存储器中的代码和数据、栈、通用目的寄存器的内容、程序计数器、环境变量和打开文件描述符的集合。
在shell中运行程序时,shell会创建一个新的进程,然后在新进程的上下文中运行可执行目标文件。应用程序还能创建新进程。
进程给应用程序提供了两个关键抽象:
- 独立的逻辑控制流,提供程序独占处理器的假象。
- 私有的地址空间,提供程序独占存储器系统的假象。
逻辑控制流
程序执行的一系列PC(程序计数器)值唯一地对应于包含在程序的可执行目标文件中的指令或包含在运行时动态链接的共享库中的指令,这个PC值的序列称为逻辑控制流。

逻辑控制流
进程轮流使用处理器,每个进程执行它的流的一部分,然后被抢占,其他进程开始执行。程序运行在进程的上下文中,因此像是在独占地使用处理器。
逻辑流是相互独立的,进程互不影响。可以通过进程间通信(IPC)机制来实现进程间交互。
逻辑流在时间上和其他逻辑流重叠的进程称为并发进程,这两个进程称为并发运行。如A和B、A和C,而B和C不是并发运行的。
进程执行控制流的一部分的时间段称为时间片,进程和其他进程轮换运行称为多任务,也称时间分片。
私有地址空间
进程为每个程序提供私有地址空间,和这个空间中某地址相关联的存储器字节不能被其他进程读写。和私有地址空间关联的存储器内容一般不同,但空间有相同的结构。前一篇Linux运行时存储器映像给出了Linux进程的地址空间结构。
用户模式和内核模式
需要限制一个应用可以执行的指令以及可访问的地址空间范围来实现进程抽象,通过特定控制寄存器的一个模式位来提供这种机制。设置了模式位时,进程运行在内核模式中,进程可以执行任何指令和访问任何存储器位置。没设置模式位时,进程运行在用户模式中,进程不允许执行特权指令和访问地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。
用户程序的进程初始是在用户模式中的,必须通过中断、故障或陷入系统调用这样的异常来变为内核模式。
Linux有一种 /proc 文件系统,包含内核数据结构的内容的可读形式,运行用户模式进程访问。
上下文切换
内核为每个进程维持一个上下文,它是内核重新启动一个被抢占进程所需的状态。包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(页表、进程表和文件表等)的值。
内核通过上下文切换来实现多任务,它是一种高级的异常控制流,建立在低级异常机制上。
内核决定抢占当前进程,重新开始一个先前被抢占的进程,称为调度了一个新进程,由内核中的调度器代码处理。使用上下文切换来将控制转移到新进程。上下文切换保存当前进程的上下文,恢复先前被抢占进程保存的上下文,将控制传递给新恢复的进程。
系统调用和中断可以引发上下文切换。
系统调用
在Linux中,可以使用 man syscalls 查看全部系统调用的列表。系统级函数遇到错误时,通常返回-1,并设置全局变量 errno 。
strace 命令可以打印程序和它的子进程调用的每个系统调用的轨迹。
进程控制
进程有三种状态:
- 运行。进程在CPU上执行,或等待被执行(会被调度)。
- 停止。进程被挂起(不会被调度)。收到 SIGSTOP 、 SIGTSTP 、 SIDTTIN 、 SIGTTOU 信号,进程停止,收到 SIGCONT 信号,进程再次开始运行。
- 终止。进程永远停止。原因可能是:收到终止进程的信号,从主程序返回,调用 exit 函数。
创建新进程可以使用 fork 函数。新创建的子进程和父进程几乎相同,它获得父进程用户级虚拟地址空间和文件描述符的副本,主要区别是它们的PID不同。 fork 函数调用一次,返回两次;父子进程是并发运行的,不能假设它们的执行顺序;两个进程的初始地址空间相同,但是是相互独立的;它们还共享打开的文件。
因为有相同的程序代码,所以如果调用 fork 三次,就会有八个进程。
进程终止时,并不会被立即清除,而是等待父进程回收,称为僵死进程。父进程回收终止的子进程时,内核将子进程退出状态传给父进程,然后抛弃该进程。如果回收前父进程已经终止,那么僵死进程由 init 进程回收。
回收子进程可以用 wait 和 waitpid 等函数。
内核调用 execve 函数在当前进程的上下文中加载并运行一个新程序。 execve 加载 filename 后,调用启动代码,启动代码准备栈,将控制传给新程序的主函数 int main(int argc, char *argv[], char *envp[])。下面是用户栈的典型组织:

用户栈的典型组织
参数数组和环境数组会被传递给程序。按C标准, main 函数只有两个参数,一般环境数组使用全局变量environ 传递。
操作环境数组可以通过 getenv 函数族。
#include <stdlib.h> /** 在环境数组中搜索字符串"name=value" * @return 返回指向value的指针,若无返回NULL */ char *getenv(const char *name); /** 以"name=value"格式取字符串,添加到数组 * @return 返回0,出错返回-1 */ int putenv(char *string); /** 若name不存在,将"name=value"添加到数组;如存在且overwrite非0,用value覆盖原值 * @return 返回0,出错返回-1 */ int setenv(const char *name, const char *value, int overwrite); /** 删除环境变量name * @return 返回0,出错返回-1 */ int unsetenv(const char *name);
name 为环境变量名。
程序是代码和数据的集合,可以作为目标模块存在于磁盘,或作为段存在于地址空间中。进程是执行中程序的一个实例。程序总是运行在某个进程的上下文中。
ps 命令可以查看系统中当前的进程。
top 命令会打印当前进程资源使用的信息。
信号
信号是一种更高层软件形式的异常,它允许进程中断其他进程。一个信号即一条信息,通知进程一个某种类型的事件已经在系统中发生了。
下表是Linux系统中的信号:
号码 | 名字 | 默认行为 | 相应事件 |
---|---|---|---|
每种信号类型都对应某个类型的系统事件。底层硬件异常通常对用户进程不可见,信号提供了一种机制向用户进程通知这些异常的发生。其他信号对应内核或其他用户进程中较高层的软件事件。
发送信号指内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号的原因有:
- 内核检测到一个系统事件,如除零或子进程终止。
- 进程调用了 kill 函数,显示要求内核发送信号给目的进程。进程可以给自己发送信号。
接收信号指目的进程被内核强迫以某种方式对信号的发送做出反应。进程可以忽略信号,终止,或执行信号处理程序捕获信号。
发出而没有被接收的信号称为待处理信号。一种类型最多有一个待处理信号,重复的信号被丢弃。进程可以阻塞某种信号,这时仍可被发送,但不会被接收。一个待处理信号最多只能被接收一次。
发送信号
发送信号给进程基于进程组的概念。进程组由一个正整数ID标识,每个进程只属于一个进程组。
前面章节提到过作业的概念,shell为每个作业创建一个独立的进程组,进程组ID一般为作业中父进程中的一个。
^C 发送 SIGINT 信号到shell,shell捕获信号发送给前台进程组的每个进程,默认终止前台作业。 ^Z 发送SIGTSTP 信号到shell,shell捕获信号发送给前台进程组的每个进程,默认挂起前台作业。
用 kill 命令向其他进程发送任意信号,给定的PID为负值时,表示发送信号给进程组ID为PID绝对值的所有进程。
进程可以用 kill 函数发送信号给任意进程(包括自己)。
接收信号
每个进程都有一个信号屏蔽字,它规定了当前要阻塞递送到该进程的信号集。每个可能的信号都有一位屏蔽字,对应位设置时表明信号当前是被阻塞的。用 sigprocmask 函数检测和更改当前信号屏蔽字。
内核从异常处理程序返回,将控制传递给进程p之前会检查未被阻塞的待处理信号的集合。集合为空则内核传递控制给进程p的逻辑控制流的下一条指令;集合非空则内核选择集合中某个信号k(通常取最小k),强制进程p接收k。信号触发进程的某种行为,进程完成行为后控制传递给p的逻辑控制流的下一条指令。
每种信号都有默认行为,可以用 signal 函数修改和信号关联的默认行为(除 SIGSTOP 和 SIGKILL 外):
#include <signal.h> typedef void (*sighandler_t)(int); /** 改变和信号signum关联的行为 * @return 返回前次处理程序的指针,出错返回SIG_ERR */ sighandler_t signal(int signum, sighandler_t handler);
参数说明:
-
signum
- 信号编号。 handler
-
指向用户定义函数,也就是信号处理程序的指针。或者为:
- SIG_IGN :忽略 signum 信号。
- SIG_DFL :恢复 signum 信号默认行为。
信号处理程序的调用称为捕捉信号,信号处理程序的执行称为处理信号。 signal 函数会将 signum 参数传递给信号处理程序 handler 的参数,这样 handler 可以捕捉不同类型的信号。
signal 的语义和实现有关,最好使用 sigaction 函数代替它。
例:
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <signal.h> #include <unistd.h> void w_error(const char *msg) { fprintf(stderr, "%s: %s\n", msg, strerror(errno)); exit(0); } void handler(int sig) { printf("caught SIGINT\n"); /* exit(0); */ } int main() { if (signal(SIGINT, handler) == SIG_ERR) w_error("signal error"); pause(); printf("come back\n"); exit(0); }
信号处理
前面已经指出,不会有重复的信号排队等待。信号处理有以下特性:
- 信号处理程序阻塞当前正在处理的类型的待处理信号。
- 同种类型至多有一个待处理信号。
- 会潜在阻塞进程的慢速系统调用被信号中断后,在信号处理程序返回时不再继续,而返回一个错误条件,并将 errno 设为 EINTR 。
对于第三点,Linux系统会重启系统调用,而Solaris不会。不同系统之间,信号处理语义存在差异。Posix标准定义了 sigaction 函数,使在Posix兼容的系统上可以设置信号处理语义。
非本地跳转
C提供了一种用户级的异常控制流,称为非本地跳转。它将控制直接从一个函数转移到另一个正在执行的函数。
#include <setjmp.h> /** 在env缓冲区中保存当前栈的内容,供longjmp使用,返回0 * @return setjmp返回0,longjmp返回非0 */ int setjmp(jmp_buf env); int sigsetjmp(sigjmp_buf env, int savesigs); /** 从env缓冲区中恢复栈的内容,触发一个从最近一次初始化env的setjmp调用的返回,setjmp返回非0的给定val值 */ void longjmp(jmp_buf env, int val); void siglongjmp(sigjmp_buf env, int val);
非本地跳转可以用来从一个深层嵌套的函数调用中立即返回,如检测到错误;或者使一个信号处理程序转移到一个特殊的代码位置,而不是返回到信号中断的指令的位置。
在信号处理程序中进行非本地跳转时应使用 sigsetjmp 和 siglongjmp 。如果 savesigs 非0,则 sigsetjmp在 env 中保存进程的当前信号屏蔽字,调用 siglongjmp 时从 env 恢复保存的信号屏蔽字。同时,应该使用一个 volatile sig_atomic_t 类型的变量来确保 env 未设置时不被中断。
例:
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <setjmp.h> static sigjmp_buf buf; static volatile sig_atomic_t canjmp; void handler(int sig) { if (canjmp == 0) return; /* ... */ canjmp = 0; siglongjmp(buf, 1); } int main() { signal(SIGINT, handler); if (!sigsetjmp(buf, 1)) printf("starting\n"); else printf("restarting\n"); canjmp = 1; while (1) { sleep(1); printf("processing ...\n"); } exit(0); }