从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列
其中,每个是某个相应的指令的地址。每次从到的过渡称为控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流(flow of control或control flow)。
最简单的一种控制是一个“平滑的”序列,其中每个和在内存中都是相邻的。这种平滑流的突变通常是由诸如跳转、调用和返回这样一些程序指令造成的。我们把这些突变称为异常控制流(exceptional control flow,ECF)。
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。
异常刨析图:
在图中,当处理器状态中发生一个重要变化时,处理器正在执行某个当前指令。在处理器中,状态被编码为不同的位和信号。状态变化称为事件(event)。事件可能和当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除以零。另一方面事件也可能和当前指令的执行没有关系。比如,一个系统定时器产生信号或者一个I/O请求完成。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler))。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3中情况中的一种:
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其它号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。
在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。
在运行时,处理器检测到发生一个事件,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序。
异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常基址寄存器(exception table base register)的特殊CPU寄存器里。
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
进程的经典定义就是一个执行中程序的实例。
进程提供给应用程序的关键抽象:
即使在系统中通常有许多其它程序在运行,进程也可以向每个程序提供一种假象,好像它在独占的使用处理器。用调试器单步执行程序,我们会看到一系列的程序计数器的值,这些值唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发的运行。
多个流并发的执行的一般现象被称为并发(concurrent)。一个进程和其它进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
注意:并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。
并行流是并发流的一个真子集。如果两个流并发的运行在不同的处理器核或者计算机上,那么我们称它们位并行流(parallel flow),它们并行的运行(running in parallel),且并行的执行(parallel execution)。
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台n位地址的机器上,地址空间是个可能地址的集合,。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其它继承读或者写的,从这个意义上说,这个地址空间是私有的。
x86-64 Linux进程的地址空间组织结构:
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接的访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法时通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制转递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式改回到用户模式。
Linux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/proc文件系统找出一般的系统属性,比如CPU类型(/proc/cpuinfo),或者某个特殊的进程使用的内存段(/proc/
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。
内核为每个进程维持一个上下文(context)。
上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并从新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。
上下文切换工作:
Unix提供了大量从C程序中操作进程的系统调用。下面将描述这些重要的函数。
每个进程都有一个唯一的整数(非零)进程ID(PID)。
getpid函数返回调用进程的PID。
getppid函数返回它的父进程的PID(创建调用进程的进程)。
#include
#include
pid_t getpid(void);
pid_t getppid(void);
getpid和getppid函数返回一个类型为pid_t的整数值,在Linux系统上它在types.h中被定义为int。
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
创建新进程
父进程通过调用fork函数创建一个新的运行的子进程。
#include
#include
pid_t fork(void);
fork函数创建的子进程特点:
终止进程
exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。
#include
void exit(int status);
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。
一个终止了但还未被回收的进程称为僵死进程(zombie)。
如果一个父进程终止了,内核会安排init进程成为这个父进程的子进程的父进程(换种说法,一个父进程终止了,内核就会安排init称为这个父进程下的子进程的养父)。init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排init进程去回收它们。不过,长时间运行的程序总是应该及时回收它们的僵死子进程的,因为即使僵死进程没有运行,也是会消耗系统的内存资源的。
waitpid函数
一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
#include
#include
pid_t waitpid(pid_t pid, int *statusp, int options);
在默认情况下(当options=0时),waitpid挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。如果等待集合中的一个进程中在刚调用的时刻就已经终止了,那么waitpid就立即返回。在这两种情况中,waitpid返回导致waitpid返回的已终止子进程的PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。
pid:pid用来确定等待集合的成员。
*statusp:检查已回收子进程的退出状态。如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,status是statusp指向的值。wait.h头文件定义了解释status参数的几个宏:
options:修改默认行为。
错误条件:如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR。
wait函数
wait函数是waitpid函数的简单版本。
#include
#include
pid_t wait(int *statusp);
调用wait(&status)等价于调用waitpid(-1,&status,0)。
sleep函数
sleep函数将一个进程挂起一段指定的时间。
#include
unsigned int sleep(unsigned int secs);
如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数。后一种情况是可能的,如果因为sleep函数被一个信号中断而过早的返回。
pause函数
pause函数让调用函数休眠,直到该进程收到一个信号
#include
int pause(void);
execve函数中在当前进程的上下文中加载并运行一个新程序。
#include
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
Linux信号,它允许进程和内核中断其它进程。
一个信号就是一条消息,它通知进程系统中发生了一个某种类型的事件。
序号 | 名称 | 默认行为 | 相应事件 |
1 | SIGHUP | 终止 | 终端线挂断 |
2 |
SIGINT | 终止 | 来自键盘的中断 |
3 | SIGOUIT | 终止 | 来自键盘的退出 |
4 | SIGILL | 终止 | 非法指令 |
5 | SIGTRAP | 终止并转储内存 | 跟踪陷阱 |
6 | SIGABRT | 终止并转储内存 | 来自abort函数的终止信号 |
7 | SIGBUS | 终止 | 总线错误 |
8 | SIGFPE | 终止并转储内存 | 浮点异常 |
9 | SIGKILL | 终止 | 杀死程序 |
10 | SIGUSR1 | 终止 | 用户定义的信号1 |
11 | SIGSEGV | 终止并转储内存 | 无效的内存引用(段故障) |
12 | SIGUSR2 | 终止 | 用户定义的信号2 |
13 | SIGPIPE | 终止 | 向一个没有读用户的管道做写操作 |
14 | SIGALRM | 终止 | 来自alarm函数的定时器信号 |
15 | SIGTERM | 终止 | 软件终止信号 |
16 | SIGSTKFLT | 终止 | 协处理器上的栈故障 |
17 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
19 | SIGSTOP | 停止直到下一个SIGCONT | 不是来自终端的停止信号 |
20 | SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 |
21 | SIGTTIN | 停止直到下一个SIGCONT | 后台进程从终端读 |
22 | SIGTTOU | 停止直到下一个SIGCONT | 后台进程向终端写 |
23 | SIGURG | 忽略 | 套接字上的紧急情况 |
24 | SIGXCPU | 终止 | CPU时间限制超出 |
25 | SIGXFSZ | 终止 | 文件大小限制超出 |
26 | SIGVTALRM | 终止 | 虚拟定时器期满 |
27 | SIGPROF | 终止 | 剖析定时器期满 |
28 | SIGWINCH | 忽略 | 窗口大小变化 |
29 | SIGIO | 终止 | 在某个描述上可执行I/O操作 |
30 | SIGPWR | 终止 | 电源故障 |
传送一个信号到目的进程是由两个不同步骤组成的:
待处理信号
一个发出而没有被接收的信号叫做待处理信号(pending signal)。
在任何时刻,一种类型至多只会有一个待处理信号。
如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待。它们只是简单的被丢弃。
一个进程可以有选择性的阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量中维护着被阻塞的信号集合。只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核而就会清除pending中的第k位。
Unix系统提供了大量向进程发送信号的机制。所有这个机制都是基于进程组(process group)这个概念的。
进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。
一个子进程和它的父进程同属于一个进程组。
getpgrp函数返回当前进程的进程组ID:
#include
pid_t getpgrp(void); //返回:调用进程的进程组ID。
setpgid函数改变自己或者其它进程的进程组:
#include
int setpgid(pid_t pid, pid_t pgid); //返回:若成功则为0,若失败则为-1。
用/bin/kill程序发送信号
/bin/kill程序可以向另外的进程发送任意的信号。比如,命令
linux> /bin/kill -9 15213
发送信号9(SIGKILL)给进程15213。一个为负的PID会导致信号被发送到进程组PID中的每个进程。
从键盘发送信号
Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。比如,键如
linux> ls / sort
会创建一个由两个进程组成的前台作业,这两个进程是通过Unix管道连接起来的:一个进程运行ls程序,另一个运行sort程序。shell为每个作业创建一个独立的进程组。进程组ID通常取自作业中父进程中的一个。
在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。类似的,输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。
用kill函数发送信号
进程通过调用kill函数发送信号给其它进程(包括它们自己)。
#include
#include
int kill(pid_t pid, int sig); //返回:若成功则为0,若错误则为-1。
如果pid大于零,那么kill函数发送信号号码sig给进程pid。如果pid等于零,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己。如果pid小于零,kill发送信号sig给进程组|pid|(pid的绝对值)中的每个进程。
用alarm函数发送信号
进程可以通过调用alarm函数向它自己发送SIGALRM信号。
#include
unsigned int alarm(unsigned int secs); //返回:前一次闹钟剩余的秒数,若以前灭有设定闹钟,则为0。
alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。如果secs是零,那么不会调度安排新的闹钟(alarm)。在任何情况下对alarm的调用都将取消任何待处理的(pending)闹钟,并且返回任何待处理的闹钟在被发送钱还剩下的秒数(如果这次对alarm的调用没有取消它的话);如果没有任何待处理的闹钟,就返回零。
当内核把进程p从内核模式切换到用户模式时(例如,从系统调用返回或是完成了一次上下文切换),它会检查进程p的未被阻塞的待处理信号的集合(pending &~blocked)。如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中的下一条指令。然而,如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令。每个信号类型都有一个预定义的默认行为,是下面中的一种:
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler); //返回:若成功则为指向前次处理程序的指针,若出错则为SIG_ERR(不设置errno)。
signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:
当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个整数参数被设置为k。这个参数允许同一个处理函数捕获不同类型的信号。
当处理程序执行它的return语句时,控制(通常)传递回控制流中进程被信号接收终端位置处的指令。我们说”通常“是因为在某些系统中,被中断的系统调用会立即返回一个错误。
Linux提供阻塞信号的隐式和显式的机制:
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); //设定对信号屏蔽集内的信号的处理方式(阻塞或不阻塞)。
int sigemptyset(sigset_t *set); //初始化set为空集合。
int sigfillset(sigset_t *set); //把每个信号都添加到set中。
int sigaddset(sigset_t *set, int signum); //把signum添加到set。
int sigdelset(sigset_t *set, int signum); // 从set中删除signum。
//返回:如果成功则为0,若出错则为-1。
int sigismember(const sigset_t *set, int signum); //测试signum是否在set中。
//返回:若signum是set的成员则为1,如果不是则为0,若出错则为-1。
sigprocmask函数改变当前阻塞的信号集合。具体的行为依赖于how的值:
如果oldset非空,那么blocked位向量之前的值保存在oldset中。
例子,展示了如何使用sigprocmask来临时阻塞接收SIGINT信号。
1 sigset_t mask, prev_mask;
2
3 Sigemtyset(&mask);
4 Sigaddset(&mask, SIGINT);
5
6 /* Block SIGINT and save previous blocked set */
7 Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
.
8 . //Code region that will not be interrupted by SIGINT
.
9 /* Restore previous blocked set, unblocking SIGINT */
10 Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
11
处理程序有几个属性使得它们很难推理分析:
安全的信号处理
正确的信号处理
可移植的信号处理
资料《深入理解计算机系统》