异常控制流
从给处理器加电开始,直到断电为止,程序计数器假设一个值的序列$a_0,a_1,…,a_{n-1}$,其中,每个$a_k$是某个相应指令$I_k$的地址。每次从$a_k$到$a_{k+1}$的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流。
异常
- 异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随操作系统的不同而有所不同。
- 异常就是控制流中的突变,用来相应处理器状态中的某些变化。
- 在图中,当处理器状态中发生一个重要的变化时,处理器正在执行某个当前指令$I_{curr}$。在处理器中,状态被编码为不同的位和信号。状态变化称为事件。事件可能和当前指令的执行直接相关。
- 在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子进程(异常处理程序)。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况的一种:
- 处理程序将控制返回给当前指令$I_{curr}$,即当事件发生时正在执行的指令。
- 处理程序将控制权返回给$I_{next}$,如果没有发生异常将会执行下一条指令。
- 处理程序终止被中断的程序。
异常处理
- 系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。其中一些号码时有处理器的设计者分配的,其他号码时有操作系统内核的设计者分配的。前者的示例包括被零除,缺页,内存访问违例,断点以及算术运算溢出。后者的示例包括系统调用和来自外部I/O设备的信号。
- 在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得标目k包含异常k的处理程序的地址。
- 在运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的标目k,转到相应的处理程序。
异常的类别
- 异常可以分为4类:中断(interrupt),陷阱(trap),故障(fault)和终止(abort)。
- 中断: 中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。
- 陷阱和系统调用: 陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个向过程一样的接口,叫做系统调用。
- 故障: 故障有错误情况引起,它可能能够被故障处理程序修正,
- 终止: 终止是不可恢复的致命错误造成的结果。
Linux/x86_64系统中的异常
- 256中不同的异常类型。0~31的号码对应的是由Intel架构师定义的异常,因此对任何x86_64系统都是一样的。32~255的号码对应的是操作系统定义的中断和陷阱。![](8-9.png%}
- Linux/x86_64故障和终止
- Linux/x86_64系统调用: Linux提供了几百种系统调用,当应用程序想要请求内核服务时可以使用,包括读写文件或创建一个新进程。每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量。
- C程序用
syscall
函数可以直接调用任何系统调用。对于大多数系统调用,标准C库提供了一组方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序。
进程
- 异常是允许操作系统内核提供进程概念的基本构造块。
- 进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器,程序计数器,环境变量以及打开文件描述符的集合。
- 每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。
逻辑控制流
- 即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它们在独占地使用处理器。如果想用调式器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流。
并发流
- 计算机系统中逻辑流有许多不同的形式。异常处理程序,进程,信号处理程序,线程和Java进程都是逻辑流的例子。
- 一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。
- 多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
- 并发流的思想和流运行的处理器核数无关。如果两个流在时间上重叠,那么它们就是并发的。
私有地址空间
- 进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台n位地址的机器上,地址空间是$2^n$个可能地址的集合,$0,1,…,2^n-1$。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上来说,这个地址空间是私有的。
用户模式和内核模式
- 为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
- 处理器通常是用某个寄存器中的一个位模式来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了位模式时,进程就运行在内核模式中。一个运行在内核模式中的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置位模式时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令。
- 运行应用程序代码的进程初始时是在用户模式中的,进程从用户模式变为内核模式的唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。
- Linux提供了一种聪明的机制,叫做
/proc
文件系统,它允许用户模式进程访问内核数据结构的内容。/proc
文件系统将许多内核数据结构的内容输出为一个用户进程可以读的文本文件的层次结构。比如,你可以使用/proc
文件系统找出一般的系统属性,比如CPU类型cat /proc/cpuinfo
。
上下文切换
- 操作系统内核使用一种称为上下文切换的较高层次形式的异常控制流来实现多任务。
- 内核为每个进程维持一个上下文。上下文就是内核启动一个进程所需的状态。它有一些对象的值组成,这些对象包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构。
- 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。在内核调度了一个新的进程运行时,他就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
- 上下文切换:
系统调用错误处理
- 当Unix系统级函数遇到错误时,它们通常会返回-1,并设置全局整数变量errno来表示什么出错了,strerror(errno)返回错误字符串。
进程控制
- Uinx提供了大量从C程序中操作进程的系统调用。
获取进程ID
- 每个进程都有一个唯一的正数进程ID。
getpid
函数返回调用进程的PID。getppid
函数返回它的附近成的PID(创建调用进程的进程)。#include <sys/types.h> #include <unistd.h> pid_t getpid(void); pid_t getppid(void);
getpid
和getppid
函数返回一个类型为pid_t
的整数值,在Linux系统上它在types.h
中被定义为int
。
创建和终止进程
- 进程总是处于下面三种状态之一:
- 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
- 停止。进程的执行被挂起,且不会被调度。当收到SIGSTOP,SIGTSTP,SIGTTIN或者SIGTTOU信号时,进程就停止,并且保持停止直到它受到一个SIGCONT信号,在这个时刻,进程再次开始运行。
- 终止。进程永远停止。进程会因为三种原因终止:
- 收到一个信号,该信号的默认行为是终止进程。
- 从主程序返回。
- 调用
exit
函数。void exit(int status)
exit
函数以status
退出状态来终止进程。- 父进程通过调用
fork
函数创建一个新的运行的子进程。#include <sys/types.h> #include <unistd.h> pid_t fork(void); //返回: 子进程返回0,父进程返回子进程的PID,如果出错,则为-1。
- 新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的一份副本,包含代码和数据段,堆,共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用
fork
时,子进程可以读取父进程中打开的任何文件。父进程和新创建的子进程之间的最大区别在于它们有不同的PID。 fork
函数被调用一次,却会返回两次:一次在父进程中,一次在新创建的子进程中。在父进程中,fork
返回子进程的PID。在子进程中,fork
返回0。因为子进程的PID总是非0,返回值就提供了一个明确的方法分辨程序是在父进程还是子进程中执行。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); }
- 调用一次,返回两次。
fork
函数被父进程调用一次,但是却返回两次。一次是返回到父进程中,一次是返回到新创建的子进程中。 - 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。
- 相同但是独立的地址空间。父进程和子进程对
x
所作的任何改变都是独立的,不会反映在另一个进程的内存中。 - 共享文件。当运行这个示例程序时,父进程和子进程都把它们的输出显示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用
fork
时,stdout
文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
回收子进程
- 当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态,直到它被父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程。
- 如果父进程没有回收它的僵死子进程就终止了,那么内核会安排
init
进程去回收它们。 - 一个进程可以通过调用
waitpid
函数来等待它的子进程终止或者停止。#include <sys/types.h> #include <sys/wait.h> pid_t waitpid(pid_t pid, int *statusp, int options); //返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1
waitpid
函数,默认情况下(当options=0
时),waitpid
挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚开始调用就已经终止了,那么waitpid
就立即返回。在这两种情况下,waitpid
返回导致waitpid
返回的已终止子进程的PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。- 判定等待集合中的成员:
- 等待集合中成员是由参数
pid
来确定的。 - 如果
pid>0
,那么等待集合就是一个单独的子进程,它的进程ID就是pid
。 - 如果
pid=-1
, 那么等待集合就是父进程所有的子进程组成的。
- 等待集合中成员是由参数
- 修改默认行为:
- 可以通过将
options
设置为常量WNOHANG,WUNTRACED和WCONTINUED的各种组合来修改默认行为。 - WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回。默认的行为是挂起调用进程,直到有子进程终止,
- WUNTRACED: 挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。默认的行为是只返回已终止的子进程。
- WCONTINUED: 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或者等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。
- 可以通过将
- 检查已回收进程的退出状态:
- 如果
statusp
参数是非空的,那么waitpid
就会在status
中放上关于导致返回的子进程的状态信息,status
是statusp
指向的值。wait.h
头文件定义了解释status
参数的几个宏。 - WIFEXITED(status): 如果子进程通过调用
exit
或者一个返回正常终止,就返回真。 - WEXITSTATUS(status): 返回一个正常终止的子进程的退出状态。只有在WIFEXITED(status)返回为真时,才会定义这个状态。
- WIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
- WTERMSIG(status): 返回导致子进程终止的信号的编号。只有在WIFSIGNALED(status)为真时,才定义这个状态。
- WIFSTOPPED(status): 如果引起返回的子进程当前是停止的,那么就返回真。
- WSTOPSIG(status): 返回引起子进程停止的信号的编号。只有在WIFSTOPPED(status)返回为真时,才定义这个状态。
- 如果
- 错误条件:
- 如果调用进程没有子进程,那么
waitpid
返回-1,并且设置errno
为ECHILD。如果waitpid
函数被一个信号中断,那么它返回-1,并设置errno
为EINTR。
- 如果调用进程没有子进程,那么
让进程休眠
sleep
函数将一个进程挂起一段指定的事件。#include <unistd.h> unsigned int sleep(unsigned int secs); //返回: 还要休眠的秒数
- 如果请求的时间量已经到了,
sleep
返回0,否则返回还剩下的要休眠的秒数。 pause
函数,让调用函数休眠,直到该进程收到一个信号。#include <unistd.h> int pause(void)
加载并运行程序
execve
函数在当前进程的上下文中加载并运行一个新程序。#include <unistd.h> int execve(const char *filename, const char *argv[], const char* envp[]); //如果成功,则不返回,如果错误,则返回-1。
execve
函数加载并运行可执行目标文件filename
,且带参数列表argv
和环境变量列表envp
。envp
变量指向一个以null
结尾的指针数组,其中每个指针指向一个环境变量字符串,其中每个指针指向一个环境变量字符串,每个串都是形如name=value
的名-值对。execve
函数启动的主函数原型如int main(int argc, char**argv, char **envp)
。- Linux下操作环境变量函数:
#include <stdlib.h> char* getenv(const char *name); int setenv(const char* name, const char* newvalue, int overwrite); void unsetenv(const char* name);
信号
- 一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
信号术语
- 传送一个信号的目的进程是由两个不同步骤组成的:
- 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有如下两种原因:
- 内核检测到一个系统事件,比如除0错误或子进程终止。
- 一个进程调用了
kill
函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给自己。
- 接受信号。当目的进程被内核强制以某种方式对信号的发送作出反应时,它就接受了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号。
- 一个发出而没有被接受的信号叫做待处理信号。在任何时刻,一种类型至多只会有一个待处理信号。一个待处理信号最多只能被接受一次。内核为每个进程在
pending
位向量中维护者待处理信号的集合,而在blocked
位向量中维护者被阻塞的信号集合。
- 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有如下两种原因:
发送信号
- Unix系统提供了大量向进程发送信号的机制,所有这些机制都是基于进程组。
- 进程组:
- 每个进程都只属于一个进程组,进程组是由一个正整数进程ID来标识的。
getpgrp
函数返回当前进程的进程组ID:#include <unistd.h> pid_t getpgrp(void); //返回: 调用进程的进程组ID
- 默认地,一个子进程和它的父进程统属于一个进程组。一个进程可以通过使用
setpgid
函数来改变自己和其他进程的进程组:#include <unistd.h> int setpgid(pid_t pid, pid_t pgid); //返回:成功返回0,失败返回-1
setpgid
函数将进程pid
的进程组该为pgid
。如果pid
是0,那么就使用当前进程的PID,如果pgid
是0,那么就用pid
指定的进程PID作为进程组的ID。
- 每个进程都只属于一个进程组,进程组是由一个正整数进程ID来标识的。
- 用/bin/kill程序发送信号
/bin/kill
程序可以向另外的进程发送任意的信号。$ /bin/kill -9 pid
发送信号9(SIGKILL)给进程pid。- 一个为负的PID会导致信号被发送到进程组PID中的每个进程。
$ /bin/kill -9 -pid
发送一个SIGKILL信号给进程组为pid的每个进程。
- 用键盘发送信号
- Unix shell使用作业这个抽象概念来表示为对一个命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。
- 在键盘上输入
Ctrl+C
会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前它作业。类似地,输入Ctrl+Z
会发送一个SIGISTP信号到前台进程组中的每个进程。默认情况下,结果是停止前台作业。
- 用kill函数发送信号
- 进程通过调用
kill
函数发送信号给其他进程(包括它们自己)。#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
- 如果
pid
大于0,那么kill
函数发送信号码sig
给进程pid
。如果pid
等于0,那么kill
发送信号sig
给调用进程所在进程组的所有进程。
- 进程通过调用
- 用alarm函数发送信号
- 进程可以通过调用
alarm
函数向它自己发送SIGALARM信号。#include <unistd.h> unsigned int alarm(unsigned int secs);
alarm
函数会在secs
秒后发送一个SIGALARM信号给调用进程。
- 进程可以通过调用
接收信号
- 当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号的集合。如果这个集合为空,那么内核将控制传递到p的逻辑控制流的下一条指令,然而如果集合是非空的,那么内核选择集合中的某个信号k,并且强制p接受信号k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流的下一条指令。每个信号类型都有一个预定以的默认行为,是下面的一种:
- 进程终止。
- 进程终止并转储内存。
- 进程停止(挂起)直到被SIGCONT信号重启。
- 进程忽略该信号。
- 进程可以通过使用
signal
函数修改和信号相关联的默认行为。唯一的例外就是SIGSTOP和SIGKILL,它们的默认行为是不能改变的。#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
signal
函数可以通过三种方法之一来改变和信号signum
相关联的行为:- 如果
handler
是SIG_IGN,那么忽略类型为signum
的信号。 - 如果
handler
是SIG_DFL,那么类型为signum
的信号行为恢复为默认行为。 - 否则,
handler
就是用户定义的函数的地址,这个函数称为信号处理程序,只要进程收到一个类型为signum
的信号,就会调用这个程序。通过把处理程序的地址传递到signal
函数从而改变默认行为,这叫做设置信号处理程序。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
- 如果
- 当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个正数参数被设置为k。这个参数允许同一个处理函数捕获不同类型的信号。
- 下面的例子用来捕获
Ctrl+C
时发送的SIGINT信号:#include <signal.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> void sigint_handler(int sig){ printf("Caught SIGINT!\n"); exit(0); } int main(){ if (signal(SIGINT,sigint_handler)==SIG_ERR){ printf("SIG_ERR\n"); } pause(); return 0; }
- 执行流程如下:
阻塞和解除阻塞信号
- Linux提供阻塞信号的隐式和显式的机制:
- 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。假设程序捕获了信号s,当前正在运行处理程序S。如果发送给该进程另一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接收。
- 显示阻塞机制。应用程序可以使用
sigprocmask
函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigemptyset(sigset_t *set); int sigfillset(sigset_t *set); int sigaddset(sigset_t *set, int signum); int sigdelset(sigset_t *set, int signum); int sigismember(const sigset_t *set, int signum);
sigprocmask
函数改变当前阻塞的信号集合。具体行为依赖于how
的值:- SIG_BLOCK: 把
set
中的信号添加到blocked
中。 - SIG_BLOCK: 从
blocked
中删除set
中的信号。 - SIG_SETMASK:
block=set
- 如果
oldset
非空,那么blocked
位向量之前的值保存在oldset
中。
- SIG_BLOCK: 把
sigemptyset
初始化set
为空集合。sigfillset
函数把每个信号都添加到set
中。sigaddset
函数把signum
添加到set
,sigdelset
从set
中删除signum
,如果signum
是set
的成员,那么sigismember
返回1,否则返回0。
编写信号处理程序
- 信号处理是Linux系统编程最棘手的一个问题:
- 处理程序和主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序相互干扰
- 如何以及何时接受信号
- 不同的系统有不同的信号处理语义
- 安全的信号处理:
- 处理程序要尽可能简单
- 在处理程序中只调用异步信号安全的函数
- 保存和恢复errno
- 阻塞所有的信号,保护对共享全局数据结构的访问
- 用
volatile
声明全局变量 - 用sig_atomic_t声明标志
- 正确的信号处理
- 可移植的信号处理
操作进程的工具
- strace: 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹.
- ps: 列出当前系统中的进程
- top:打印出关于当前进程资源使用的信息。
- pmap: 显示进程的内存映射。
- /proc: 一个虚拟文件系统,以ASCII文本个是输出大量内核数据结构的内容,用户程序可以读取这些内容。
cat /proc/loadavg
- 原文作者:生如夏花
- 原文链接:https://blduan.top/post/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/csapp/%E5%BC%82%E5%B8%B8%E6%8E%A7%E5%88%B6%E6%B5%81/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。