指令序列 a 0 , a 1 , . . . , a n − 1 a_0,a_1,...,a_{n-1} a0,a1,...,an−1(其中 a k a_k ak对应相应指令 I k I_k Ik的地址),其中从 a k a_k ak到 a k + 1 a_{k+1} ak+1的过渡称为控制转移(control transfer),这样的控制转移 序列称为处理器的控制流(flow of control/ control flow)。
不平滑的控制流( I k I_k Ik和 I k + 1 I_{k+1} Ik+1不相邻),通常是由跳转、调用和返回这些程序指令所造成的,这些指令机制使得程序能够对程序变量表示的内部程序状态中的变化做出反应。
相应的,系统也须对系统状态的变化做出反应。比如,硬件定时器的定时中断,数据包到达网络适配器后将其放入内存,子进程终止时父进程应当得到通知。
对以上系统状态的变化情形,现代系统通过使控制流发生突变来对这些情况做出反应。将这些突变称为异常控制流(Exceptional Control Flow, ECF)。为何需要理解 ECF:
异常就是控制流中的突变,用来响应处理器状态中的某些变化。它一部分由硬件实现,一部分由操作系统实现。
在任何情况下,处理器检测到有事件发生时,通过异常表(exception table)的跳转表,进行一间接过程调用(异常),到异常处理程序(exception handler, 专门设计用来处理相关事件的操作系统子程序)。异常处理程序完成处理后,会有三种情况:
系统为每个可能出现的异常都分配了一唯一的非负整数异常号(exception number)。分别是处理器设计者(被零除、缺页、内存访问违例、断点以及算术运算溢出等)和操作系统内核(操作系统常驻内存部分)设计者(系统调用和来自外部I/O 设备的信号等)所分配的。
当系统启动(计算机重启或上电时),OS分配和初始化一异常表的跳转表,使得表目 k k k包含异常 k k k的处理程序地址。异常表格式如左下图。
|
|
在运行时,CPU检测到事件的发生,且确定了异常号 k k k。CPU触发异常,执行间接调用过程(通过异常表的表目 k k k,转至相应异常处理程序)。右上图是如何寻获异常处理程序地址的过程(以位OS为例),其中异常表基址寄存器(exception table base register)存放异常表的起始地址。
异常与过程调用类似,但是过程调用跳转到处理程序前,将返回地址压入栈中,而异常的返回地址不定,要么是当前指令要么是下一指令;异常处理时可能由用户态切换至内核态,则相关信息被压入内核栈中,而不是用户栈,调用过程则只有用户态。
异常可分为四类,如下表所示:
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断(interruption) | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱(trap) | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障(fault) | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止(terminate) | 不可恢复的错误 | 同步 | 不会返回 |
syscall n
指令来请求相应的系统调用。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。对于系统调用错误的处理,多是通过使用错误处理包装函数,更进一步地简化错误处理代码。比如,对于一个给定的基本函数foo
, 我们定义一个具有相同参数的包装函数Foo
, 但是第一个字母大写了。包装函数调用基本函数,检查错误,如果有任何问题就终止。包装函数定义在一个叫做csapp.c
的文件中,它们的原型定义在一个叫做csapp.h
的头文件中
x86-64操作系统中所定义的异常,高达256种。 0 − 31 0-31 0−31是Intel所定义的, 32 − 255 32-255 32−255是操作系统所定义的,会随操作系统的不同而改变,x86-64系统中的常见异常和常用系统调用的编号如下:
|
|
异常是允许操作系统内核提供进程(process)概念的基本构造块。进程的定义为:一个执行中程序的实例。
系统中的每个程序都运行在某个进程的 上下文(context) 中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程提供给应用程序(可执行目标文件)的两个关键抽象:
处理器的一个物理控制流可包含多个逻辑流(一个进程的执行过程),并发流指的是一个逻辑流的执行时间与另一个流重叠,同时多个流并发地执行的一般现象称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow), 它们并行地运行(runningin parallel),且并行地执行(parallel execution)。
进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的。每个这样的空间都有相同的通用结构。
地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址0x400000 开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
Linux的/proc文件系统,允许用户模式进程访问内核数据结构的内容。见:Linux的文件、目录、磁盘和文件系统——1.4 Linux目录配置
上下文的概念见上文蓝字。内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling), 是由内核中称为调度器(scheduler)的代码处理的。
整个上下文切换的过程可以分为三步:
示例如下:
Unix 提供了大量从C 程序中操作进程的系统调用。常用的如下表所示:
函数名 | 所在头文件 | 用途 | 补充 |
---|---|---|---|
pid_t getpid(void); |
|
返回调用进程的PID | 返回一个类型为 pid_t 的整数值,在Linux系统上它在 中被定义为int |
pid_t getppid(void); |
|
返回它的父进程的PID(创建调用进程的进程) | 返回一个类型为 pid_t 的整数值,在Linux系统上它在 中被定义为int |
void exit(int status); |
|
以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值,return num ) |
进程总是处于以下三种状态: ① 运行:进程要么在CPU 上执行,要么在等待被执行且最终会被内核调度; ② 停止:进程的执行被挂起(suspended), 且不会被调度。直到它收到一 SIGCONT 信号,进程再次开始运行;③ 终止:进程永远地停止了。进程会因为三种原因终止:(1)收到一个信号,该信号的默认行为是终止进程;(2)从主程序返回;(3)调用exit 函数。 |
pid_t fork(void); |
|
创建一个新的运行的子进程 | ① fork函数只被调用一次,但返回两次:父进程中fork返回子进程Pid,子进程中fork返回0(子进程的Pid总是0,可用来分辨当前运行程序是父/子进程,几乎但不相同的父进程和子进程中的不同即是Pid不同); ② 并发执行:父进程和子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流中的指令; ③ 相同但独立的地址空间:父子进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。可以说子进程是父进程的完全拷贝; ④ 共享文件:子进程会继承父进程所有的打开文件(即文件标识符); 学习fork 函数,画进程图通常会有所帮助,是刻画程序语句的偏序的一种简单的前趋图。 |
pid_t waitpid(pid_t pid, int *statusp, int options); |
|
进程可通过调用waitpid 函数来等待它的子进程终止或者停止,以便于进行子进程回收。例如长时间运行的shell程序,需要回收其僵死子进程,以节省系统资源 |
僵死进程(zombie):终止了但还未被回收的进程; 孤儿进程(orphan):父进程已终止,其尚未回收的子进程,一般是由内核将init进程成为其养父以进行回收; init 进程:系统启动时内核创建,Pid为1,不会终止且是进程树中的老祖宗节点;waitpid函数的内含信息和参数: ① 判定等待集合的成员 pid:Pid来确定等待集合成员,pid>0,则等待集合是单独子进程;pid=-1,则等待集合是父进程所有子进程; ② 修改默认行为 options:通过将options 设置为常量WNOHANG(挂起调用进程,直到有子进程终止),WUNTRACED(只返回已终止的子进程) 和WCONTINUED(…)的各种组合来修改默认行为; ③ 检查已回收子进程的退出状态 statusp:如果statusp参数非空,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,status是statusp指向的值。wait.h头文件定义了解释status参数的几个宏; ④ 错误条件:如果调用进程没有子进程,那么waitpid 返回-1, 并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1, 并设置errno为EINTR。 |
pid_t wait(int *statusp); |
|
wait 函数是waitpid 函数的简单版本 | 调用wait(&status) 等价于调用waitpid(- l,&status,0) |
unsigned int sleep(unsigned int secs); |
|
将一个进程挂起一段指定的时间 | ① 时间到了,返回0; ② 时间未到,由于信号中断而未足时返回,返回剩余休眠时间; |
int pause(void); |
|
让调用函数休眠,直到该进程收到一个信号 | |
int execve(const char *filename, const char *argv[],const char *envp[]); |
|
加载并运行可执行目标文件filename, 且带参数列表argv(argument vector)和环境变量列表envp(environment vector pointer) | ① execve调用一次且从不返回,只有当出现错误时,例如找不到 filename,execve 才会返回到调用程序; ② 参数列表和环境列表的数据结构入下左中两图所示,其结尾都是null; ③ execve加载了filename后,调用filename中的启动代码(见链接器、链接过程及相关概念解析——2.2 可执行目标文件(无后缀)),启动代码设置栈,并将控制传递给新程序主函数,进而main开始执行,用户栈的组织结构如右下图所示; ④ 环境数组操作函数(均在 ):- char *getenv(const char *name); :获取name的环境变量;- int setenv(const char *name, const char *newvalue, int overwrite) :用 newvalue 代替 oldvalue,但是只有在overwirte 非零时才会这样;- void unsetenv(const char *name) :删除名为name的环境变量 |
|
|
|
fork
函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。execve
函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用 execve 函数时已打开的所有文件描述符。
shell就是使用fork()和execve()函数完成读取指令并执行的功能的,并配有eval()函数执行命令行中指令,parseline()函数分割命令行,构建参数向量,built_command()函数判断是否是内置指令。
Linux系统中,软件形式的异常称为Linux信号,其允许进程和内核中断其他进程。每个信号对应通知进程系统中发生了一个某种类型的事件。下图展示了Linux系统上所支持的30中不同类型的信号:
内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号原因有:
当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。
一个发出而没有被接收的信号叫做待处理信号(pending signal)。在任何时刻,一种类型至多只会有一个待处理信号;
如果一个进程有一个类型为 k k k 的待处理信号,那么任何接下来发送到这个进程的类型为 k k k 的信号都不会排队等待;它们只是被简单地丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在 pending位向量 中维护着待处理信号的集合,而在 blocked位向量 中维护着被阻塞的信号集合。只要传送了一个类型为 k k k 的信号,内核就会设置 pending 中的第 k k k 位,而只要接收了一个类型为 k k k 的信号,内核就会清除 pending 中的第 k k k 位。
1. 进程组:每个进程都属于一进程组,pid_t getpgrp(void);
获取当前进程的进程组ID。默认情况下,一个子进程和它的父进程同属于一个进程组。一个进程可以使用int setgpid(pid_t pid, pid_t pgid)
(pid=0则更改自己所处的进程组,pgid=0用pid指定的进程的PID作为进程组的PID)来更改自己或其他进程的进程组。
2. 发送信号的方式(内容可见:进程管理和SELinux)
/bin/kill
程序发送信号:一个为负的PID会导致信号被发送到进程组PID 中的每个进程,如linux> kill -9 pid
;kill
函数发送信号:在程序中调用int kill(pid_t pid, int sig);
函数,运行时,程序实例即进程调用kill函数发送信号给其他进程(包括它们自己);alarm
函数发送信号:unsigned int alarm(unsigned int secs);
,alarm函数安排内核在 secs 秒后发送一个 SIGALRM 信号给调用进程。当内核把进程 p p p 从内核模式切换到用户模式时,先检查进程 p p p 的未被阻塞的待处理信号的集合(pending &~blocked)。如果集合非空,则内核选择集合中的某个信号 k k k(通常是最小的 k k k),并且强制 p p p 接收信号 k k k。收到信号则会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回 p p p 的逻辑控制流中的下一条指令( I n e x t I_{next} Inext),每个信号类型都有一个预定义的默认行为:
除了SIGSTOP和SIGKILL两种信号之外,其余信号进程可以通过使用signal
函数修改和信号相关联的默认行为。
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
//返回:若成功则为指向前次处理程序的指针,若出错则为SIG_ERR(不设置error)
signal
函数可通过三种方法来改变和信号 signum 相关联的行为:
Linux 提供阻塞信号的隐式和显式的机制:
sigprocmask
函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号;#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
//sigprocmask signal process mask
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 的成员则为1,如果不是则为0,若出错则为一1。
sigpromask函数可改变当前阻塞的信号集合,具体行为依赖于how值:
C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump), 它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本地跳转是通过 setjmp 和 longjmp 函数来提供的。
#include
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
//返回:setjmp 返回0,longjmp 返回非零。
//setjmp 函数在env 缓冲区中保存当前调用环境,以供后面的longjmp 使用,并返回0
//调用环境包括程序计数器、栈指针和通用目的寄存器。
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
//从不返回
//longjmp 函数从env 缓冲区中恢复调用环境,然后触发一个从最近一次初始化
//的setjmp 调用的返回。然后setjmp 返回,并带有非零的返回值retval
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。
C++、Java 提供的异常机制是较高层次的,是 C 语言的 setjmp 和 longjmp 函数的更加结构化的版本。可以把 try 语句中的 catch 子句看做类似于 setjmp 函数。相似地,throw 语句就类似于 longjmp 函数。