CSAPP-----异常控制流

本节目录:

1、异常

2、进程

3、系统调用错误处理

4、进程控制

5、信号

6、非本地跳转

7、操作进程工具

8、小结


  本系列文章的观点和图片均来自《深入理解计算机系统第3版》仅作为学习使用

         现代系统通过使控制流发生突变来应对一些情况,这些突变也就是异常控制流(ECF)。异常控制流可以发生在计算机系统的各个层次,硬件层、操作系统层、应用层。作为程序员理解ECF的重要性:

        *理解ECF将帮助你理解重要的系统概念。ECF是操作系统用来实现IO、进程和虚拟内存的基本机制。

        *理解ECF将帮助你理解应用程序是如何与操作系统交互的,应用程序通过使用一个叫做陷阱或系统调用的ECF形式,向操作系统请求服务。比如向磁盘写数据、从网络读数据、创建一个新进程、终止当前进程等都是通过应用程序调用系统调用实现的。

       *理解ECF将帮助你编写有趣的应用程序,操作系统为应用程序提供了强大的ECF机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件、以及检测和响应这些事件。

       *理解ECF帮助你理解并发。ECF是计算机系统中实现并发的基本机制,在运行中并发的例子有:中断应用程序执行的异常处理程序、在时间上重叠执行的进程和线程、以及中断应用程序执行的信号处理程序。

        *理解ECF将帮助你理解软件异常如何工作。C++中用try、catch、throw语句提供软件一场机制,软件异常允许程序进程非本地跳转来响应错误情况。

1、异常

        异常是异常控制流的一种形式,一部分由硬件实现,一部分由操作系统实现,这一节中主要是对异常和异常处理有一个一般性的了解。异常就是控制流中的突变,用来相应处理器状态中的某些变化。下图为其基本思想:

       CSAPP-----异常控制流_第1张图片

        上图中,当处理器状态中发生一个重要的变化时,处理器正在执行当前指令Icurr,在处理器中状态被编码成为不同的位和信号,状态变化称为事件,事件可能与当前执行的指令直接相关,比如发生虚拟内存缺页,算术溢出,定时器信号,IO请求等。

        在任何情况下,当处理器检测到有事件发生,会通过一张叫做一场表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类时间的操作系统子程序(异常处理程序)当异常处理程序完成以后,根据引起异常的事件类型会发生以下三种情况的一种:

        1)处理程序将控制返回给当前的指令Icurr,即当事件发生时正在执行的指令。

        2)处理程序将控制返回给Inext,如果没有发生异常将会执行的下一条指令。

        3)处理程序终止被中断的程序。

    1.1 异常处理

        系统中为每种类型的异常分配了唯一的非负整数的异常号,一些是处理器的设计者分配的,其他是操作系统内核设计者分配的。前者包括被0除,缺页,内存访问违例,断点及算术溢出,后者包括系统调用和来自外部IO设备的信号。系统启动时,会初始化一张异常表的跳转表,使条目k包含异常k的处理程序地址。在运行时,处理器检测到发生了一个事件,并确定了相应的异常号k,随后,处理器触发异常,方法是执行间接过程调用,通过异常表的条目k转到相应的处理程序。异常处理过程类似于调用但有一些不同:

        *过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中,然而根据异常的类型,返回地址要么是当前指令,要么是一条指令。

        *处理器也把一些额外的处理器状态压入栈里,在处理程序返回时,重新开始执行被中断的程序可能会需要这些状态。

        *如果控制从用户程序转移到内核,所有这些项目都被压入栈中,而不是用户栈中。

        *异常处理程序运行在内核模式下,意味着它们对所有的系统资源都有完全的访问权限。

        一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成,在处理程序处理完事件之后,会执行一条特殊的从终端返回的指令,可选的返回被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断是一个用户程序,就将状态恢复为用户模式。然后将控制返回被中断的程序。

    1.2 异常的类别

        异常可以分为四类:中断、陷阱、故障、终止。

        CSAPP-----异常控制流_第2张图片

    1.3 linux/x86-64 系统中的异常

2、进程

        异常是允许操作系统内核提供进程概念的基本构造块,进程是计算机科学中最深刻最成功的概念之一。在现代操作系统上运行一个程序时,会得到一个假象,就好像程序时目前系统中唯一的程序,程序似乎独占处理器和内存等等假象都通过进程的概念提供给我们的。

        进程的经典定义就是一个执行中程序的实例。系统中的每个程序都在运行某个进程的上下文中,上下文是由程序正确运行所需要的状态组成,每个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容,程序计数器、环境变量以及打开文件描述符的集合。

        每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件,应用程序也能够创建新进程,并在这个新进程的上下文中运行它们自己的代码或者其他应用程序。

        进程提供给应用程序的关键抽象:

        *一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占的使用处理器。

        *一个私有的地址空间,提供一个假象,好像我们的程序独占的使用内存系统。

    2.1 逻辑控制流

        即使在系统中通常有许多其他程序在运行,进程也可以像每个程序提供一种假象,好像它在独占的使用处理器,如果想用调试器单步执行程序,会看到一系列PC值,这些值唯一的对应包含在程序的可执行目标文件中的指令,或是包含在运行时动态连接到程序的共享对象的指令,这个PC值的序列叫做逻辑控制流。

       CSAPP-----异常控制流_第3张图片

        上图中,处理器的一个物理控制流被分为三个逻辑流,每个进程一个,每个竖直的条表示一个进程的逻辑流的一部分。上图的关键点在于,进程是轮流使用处理器的,每个进程执行他的流的一部分,然后被抢占,然后轮到其他进程,对于一个运行在这些进程之一的上下文中的程序,看上去是独占的使用处理器。如果精确的测量每条指令使用的时间,会发现在程序中一些指令的执行时间,CPU好像会周期性的停顿,每次停顿之后会继续执行我们的程序,并不改变程序中内存位置或寄存器内容。

    2.2 并发流

        计算机系统中逻辑流有许多不同形式,异常处理程序、进程、信号处理程序、线程等都是逻辑流的例子。一个逻辑流的执行在时间上与另一个流重叠,称为并发流。这两个流称为并发执行。多个流并发的执行的一般现象称为并发。一个进程和其他进程轮流运行的概念称为多任务,一个进程执行它的控制流的一部分的每一时间段叫做时间片,因此多任务也叫做时间片。在上图中,A由两个时间片组成。

        注意,并发流的思想与流运行的处理器核数或计算机数无关,如果两个流在时间上重叠,那就是并发的,即使运行在同一个处理器。

    2.3 私有地址空间

        进程也为每个程序提供一种假象,好像独占的使用系统地址空间,在一台n位地址的机器上,地址空间是2^n个可能地址的集合,进程为每个程序提供自己的私有地址空间,一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或写的,从这个意义上来说,这个地址空间是私有的。

        尽管和每个私有地址空间相关联的内存的内容一般是不同的,但这个这样的空间都有相同的通用结构,下图为一个x86-64 linux进程的地址空间的组织结构。

        CSAPP-----异常控制流_第4张图片

        地址空间底部是保留给用户程序的,包括通常的代码、数据、栈和栈段。代码段总是从地址0x400000开始,地址空间顶部保留给内核(操作系统常驻内存部分)地址空间这个部分包含内核在代表进程执行指令时使用的代码、数据和栈。

    2.4 用户模式和内核模式

        处理器提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。通常是通过控制某个寄存器的模式位来提供这种功能,该寄存器描述了进程当前享有的特权,设置模式位之后,进程出域内核模式,也叫做超级用户模式,这个进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位,进程运行在用户模式,用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位、发起IO操作、不允许引用地址空间中内核区的代码和数据,这样的尝试都会引起故障。用户程序必须通过系统调用接口间接的访问内核代码和数据。

        进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障、或者陷入系统调用这样的异常,当异常发生时,控制传递到异常处理程序,处理器会从用户模式变为内核模式,处理程序运行在内核模式,当返回到应用程序代码时,处理器就从内核模式换回用户模式。

        linux提供了一种机制,叫做/proc文件系统,允许用户模式进程访问内核数据结构中的内容,/proc文件系统将许多内核数据的内容输出为一个用户程序可以读的文本文件的层次结构。例如,你可以使用/proc文件系统找出一般的系统属性,比如CPU(/proc/cpuinfo),或者某个特殊的进程使用的内存段(/proc//maps)。

    2.5 上下文切换

        操作系统内核使用一种称为上下文切换的较高层次的异常控制流来实现多任务。上下文切换机制是建立在上面讨论过的低层的异常机制上的。

        内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需要的状态,由一些对象的值组成, 包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内存栈和各种内核数据结构,比如描述地址空间的页表、包含当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

        在进程执行的某些时刻,内核可以决定抢占当前的进程,并重新开始一个先前被强占的进程,这种决策叫做调度,是内核中成为调度器的代码处理的,当内核选择一个新的进程运行时,我们会说内核调度了这个进程,在内核调度一个新的进程运行后,它就抢占当前进程,并使用一种上下文切换的机制将控制转移到新的进程。上下文切换:1)保存当前进程的上下文。2)恢复某个先前被强占的进程被保存的上下文。3)将控制传递给这个新恢复的进程。

        当内核代表用户执行系统调用时,可能会发生上下文切换,如果系统调用因为等待某个事件发生阻塞。那么内核可以让当前进程休眠,切换到另一个进程。一般而言,系统调用即使没有阻塞,内核也可以执行上下文切换,而不是将控制返回给调用进程。中断也可以引发上下文切换。下图为一对进程上下文切换的示例,进程A初始运行在用户模式在,直到执行系统调用read陷入内核,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器:

        CSAPP-----异常控制流_第5张图片

        磁盘读取数据要用一段相对较长的时间,所以内核执行从进程A到进程B的上下文切换,而不是在这个时间什么也不做,切换之前内核正代表A在用户模式下执行指令,切换的第一部分代表A在内核模式下执行指令,某一时刻又开始代表B(内核模式)执行指令,在切换之后内核代表进程B在用户模式下执行指令。随后B在用户模式下运行一会直到磁盘发出一个中断信号,表示数据已经从磁盘传送到内存,内核判定B已经运行足够长事件,执行一个从B到A的上下文切换,将控制返回给A紧随read之后的指令,A继续运行。

3、系统调用错误处理

        当Unix系统级函数遇到错误时,通常会返回-1,并设置全局整数变量errno来表示什么出错了,程序员应该检查错误,但是很多人并不检查错误,因为貌似这样做会使代码变得臃肿,我们可以使用错误处理包装函数,使代码简化。如下

        CSAPP-----异常控制流_第6张图片

        给定这个包装函数对fork的调用就缩减为1行,这一点在csapp.c中很常见。

4、进程控制

    4.1 获取进程ID

        每个进程都有唯一的正数(非0)进程ID(PID),getpid函数返回调用进程的PID,getppid函数返回他的父进程的PID(创建调用进程的进程)。

#include
#include

pid_t getpid(void);

pid_t getppid(void)

        在Linux系统上pid_t被定义为int类型。

    4.2 创建和终止进程

        从程序员角度,可以认为进程总是处于下面三种状态之一:

        *运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。

        *停止。进程的执行被挂起,且不会被调度,当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进程会停止,并保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。

        *终止。进程永远的停止,进程会因为三种原因终止:1)收到一个信号,该信号的默认行为是终止进程。2)从主程序返回。3)调用exit函数。

 #include 
 
 void exit(int status);

        exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序返回一个整数值)。父进程调用fork函数创建一个新的运行的子进程。

#include 

pid_t fork(void);

        新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的但是独立的一个副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着父进程调用fork时,子进程可以读写父进程中打开的任何文件父进程和新创建的子进程最大的区别在于他们有不同的PID。

        fork函数有趣的在于它调用一次,但是会返回两次,一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中fork返回的是子进程的PID,在子进程中fork返回0。因为子进程的PID总是非0,返回值就明确的分辨出程序是在父进程还是在子进程。

#include"csapp.h"

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);


}

        会得到这个结果:

CSAPP-----异常控制流_第7张图片

        从上述可以看到:

        *调用一次,返回两次。fork函数被父进程调用一次,却返回两次。一次是返回到父进程,一次是返回到新创建的子进程。

        *并发执行。父进程和子进程是并发运行的独立进程,内核能以任意方式交替执行它们的逻辑控制流中的指令。

        *相同但是独立的地址空间。

        *共享文件。子进程共享了父进程打开的文件,stdout。所以子进程输出也是指向屏幕。

        画进程图有利于理解fork。如下图表示上面的程序:

        CSAPP-----异常控制流_第8张图片

        初始时,父进程将变量x设置为1,父进程调用fork,创建一个子进程,它在自己的私有地址空间与父进程并发执行。对于运行在单处理器上的程序,对应进程图的所有顶点的拓扑排序表示程序中语句的一个可行的全序排列,给定进程图定点的一个排列,把定点序列从左到右写成一行,然后画出每条有向边。排列是一个拓扑排序,当且仅当画出每条边的方向都是从左往右的,因此在上面的程序中,父进程和子进程的printf语句可以任意先后顺序执行。

int main()
{
   Fork();
   Fork();
   printf("hello");
   exit(0);
}

        进程图如下:

        CSAPP-----异常控制流_第9张图片

    4.3 回收子进程

        当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除,相反进程被保持在一种已终止的状态中,直到被父进程回收,当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。从此时开始,该进程就不存在了。一个终止了但没有被回收的进程称为僵尸进程。

        如果一个父进程终止了,内核会安排init进程称为它的孤儿进程(如果父进程先退出,子进程还没退出那么子进程将被 托孤给init进程)的养父。init进程的PID为1,是系统在启动时由内核创建的,他不会终止,是所有进程的祖先。如果父进程没有回收它的僵尸进程就终止了,那么内核会安排init进程回收它们,长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵尸子进程,即使僵尸进程没有运行,仍然会消耗系统的内存资源。一个进程可以通过调用waitpid函数等待它的子进程终止或停止。

 #include 
 #include 

//成功返回子进程pid,WNOHANG则为0,其他错误-1
pid_t waitpid(pid_t pid, int *statusp, int options);

        默认情况下,options=0时,waitpid挂起调用进程的执行,直到它的等待集合中一个子进程终止,如果等待集合中的一个进程在刚调用的时候就已经终止,那么waitpid立即返回。在这两种情况下,waitpid返回导致waitpid返回的已终止子进程的PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。

        1)判定等待集合的成员

        等待集合的成员是由参数pid来确定。

        *如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid。

        *如果pid=-1,那么等待集合就是由父进程所有的子进程组成。

        2)修改默认行为

        可以通过将options设置为常量WNOHANG、WUNTRACED和WCONTINUED的各种组合修改默认行为。

        *WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回,返回值为0,默认的行为是挂起调用进程,直到有子进程终止,在等待子进程终止的同时如果还有想做的工作,这个选项会有用。

        *WUNTRACED:挂起调用进程的执行,直到等待集合中一个进程变成已终止或者已经被停止,返回的PID为呆滞返回的已终止或被停止的子进程的PID,默认的行为是只返回已终止的子进程。当想要金叉已终止和被停止的子进程时会有用。

        *WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止,或等待集合中和一个被停止的进程收到SIGCONT信号重新开始执行。

       可以用或用算把这些选项组合起来。

        3)检查已回收子进程的退出状态

        如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回子进程的状态信息,status是statusp指向的值。wait.h中定义了status参数的几个宏。

        *WIFEXITED:如果子进程调用exit或者返回正常终止,就返回真。

         *WEXITSTATUS:返回一个正常终止的子进程的退出状态,只有在WIFEXITED()返回为真时,才定义这个状态。

        *WIFSIGNALED:如果子进程是因为一个未被捕获的信号终止的,那么就返回真。

        *WTERMSIG:返回导致子进程终止的信号的编号,只有在WIFSIGNALED()返回为真时,才定义这个状态。

        *WIFSTOPPED:如果引起返回的子进程当前是停止的,那么返回真。

        *WSTOPSIG:返回引起子进程停止的信号的编号。只有在WIFSTOPPED()返回为真才定义这个状态。

        *WIFCONTINUED:如果子进程收到SIGCONT信号重新启动,则返回真。

        4)错误条件

        如果调用进程没有子进程,那么waitpid返回-1,并设置errno为ECHILD。如果waitpid函数被一个信号中断,那么返回-1,并设置erron为EINTR。

        5)wait函数

        wait函数是waitpid函数的简单版本。

#include 
#include 

pid_t wait(int *status);

        调用wait(&status)等价于调用waitpid(-1,&status,0)。

        6)使用waitpid示例

#include"csapp.h"
#define N 2
int main()
{
    int status,i;
    pid_t pid;
   //创建N个子进程
    for(i=0;i0){
        if(WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n",pid,WEXITSTATUS(status));
        else
          printf("child %d terminated abnormally \n",pid);
    }

    if(errno!=ECHILD)
      unix_error("waitpid error");
    exit(0);

        上图程序不按照特定顺序等待它的所有子进程终止,每个子进程以唯一的退出状态退出,while里检查子进程退出状态,如果是正常退出那么提取退出状态,输出。当回收了所有子进程后再调用waitpid就返回-1,并设置errno为ECHILD,并检查程序是否正常终止,否则输出一个错误信息。该程序不会按照特定的顺序回收子进程,这种不确定性使得并发推理非常困难,所以需要尽量避免。该程序输出如下图:

        下图对之前程序做些许改变,可以消除回收的不确定性。按照父进程创建子进程的相同顺序回收子进程。并且按照顺序存储它的子进程PID,通过适当的PID作为第一个参数调用waitpid,按照相同的顺序等待每个子进程。

#include"csapp.h"
#define N 2
int main()
{
    int status,i;
    pid_t pid[N],retpid;
   //创建N个子进程
    for(i=0;i0){
        if(WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n",retpid,WEXITSTATUS(status));
        else
          printf("child %d terminated abnormally \n",retpid);
    }

    if(errno!=ECHILD)
      unix_error("waitpid error");
    exit(0);

    4.4 让进程休眠

        sleep函数将一个进程挂起一段指定时间。

#include

unsigned int sleep(unsigned int secs);

        如果请求的时间量到了,sleep返回0,否则返回还剩下的要休眠的秒数,后一种情况是可能的,如果sleep函数被一个信号中断而过早的返回,还有一个有用的函数是pause函数,该函数让调用函数休眠,直到进程收到一个信号。

 #include 

 int pause(void);

    4.5 加载并运行程序

        execve函数在当前进程的上下文中加载并运行一个新程序。

 #include 

 int execve(const char *filename, char *const argv[],
                  char *const envp[]);

        execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序,所以,与fork一次调用两次返回不同,execve调用一次不返回。

        参数列表是如下图的数据结构,argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串,按惯例,argv[0]是可执行文件的名字,环境变量的列表是由一个类似的数据结构表示的,envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字值对。

        CSAPP-----异常控制流_第10张图片

        在exceve加载了filename之后,调用一些启动代码,启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式

        

        或者等价的:

        

        当main开始执行时,用户的栈组织结构如下图所示,从栈底(高地址)往栈顶(低地址)看,首先是参数和环境字符串,栈网上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中一个环境变量字符串,全局变量environ指向这些指针中的第一个envp[0],紧跟环境变量数组之后的是以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串,在栈顶部是系统启动函数libc_start_main的栈帧。

     CSAPP-----异常控制流_第11张图片

        main函数有三个参数,1)argc,它给出argv[]数组中非空指针的数量,2)argv指向argv[]数组中第一个条目,3)envp,指向envp[]数组中第一个条目。linux提供了几个函数操作环境数组,

 #include 

 char *getenv(const char *name);

        getenv函数在环境数组中搜索字符串“name=value”,找到了返回一个指向value的指针,否则返回NULL。

#include 

int setenv(const char *name, const char *value, int overwrite);

int unsetenv(const char *name);

         如果环境数组包含一个形如“name=oldvalue”的字符串,那么usetenv会删除它。如果setenv会用newvalue代替oldenv,但只有在overwirte非0时才会这样,如果name不存在,那么setenv就把“name=newvalue”添加到数组。

        程序与进程:程序是一堆代码和数据,程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。进程是执行中程序的一个具体的实例,程序总是运行在某个进程的上下文中。fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品,execve函数会在当前进程上下文中加载并运行一个程序,会覆盖当前进程的地址空间,但是并没有创建新的进程,新的程序仍然有相同的PID。

    4.6 利用fork和execve运行程序

        查看(https://blog.csdn.net/zl6481033/article/details/86540520)。

5、信号

        信号是一种更高层的软件形式的异常,它允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生一个某种类型的事件。每种信号对应某种系统事件,低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的,信号提供一种机制,通知用户进程发生了这些异常,比如,一个进程试图除以0,那么内核就会发送一个SIGFPE信号,如果一个进程执行一条非法指令,那么内核就发送给它一个SIGSEGV信号。下图为linux信号。

       CSAPP-----异常控制流_第12张图片

        5.1 信号术语

        传送一个信号到目的进程是由两个不同步骤组成的:

        *发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程,发送信号可以有如下两种原因,1)内核检测到一个系统事件,比如除0错误或子进程终止。2)一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给自己。

        *接收信号。当目的进程被内核强迫以某种方式对信号地发送做出反应时,它就接受了信号,进程可以忽略这个信号,终止或者通过一个称为信号处理程序地用户层函数捕获这个信号。下图是一个信号处理程序捕获信号的基本思想。

        CSAPP-----异常控制流_第13张图片

        一个发出而没有被接收的信号,叫做待处理信号,在任何时刻,一种类型至多只会有一个待处理的信号,如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们只是被简单地丢弃,一个就能成可以有选择性地阻塞接收某种信号,当一种信号被阻塞时,它仍可以发送,但是产生地待处理信号不会被接收,直到进程取消对这种信号的阻塞。

        一个待处理信号最多只能被接收一次,内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量(信号掩码)中维护着被阻塞的信号集合,只要传送了一个类型为k的信号,内核就会设置pending中的第k位。而只要接受一个类型为k的信号,内核就会清除pending中的第k位。

    5.2 发送信号

        Unix系统提供了大量向进程发送信号的机制。所有的这些机制都是基于进程组这个概念。

        1、进程组

        每个进程都属于一个进程组,进程组是由一个正整数进程组ID来标识的,getpgrp函数返回当前进程的进程组ID。

#include 

pid_t getpgid(void);

        默认的,一个子进程和它的父进程属于同一个进程组,一个进程可以通过使用setpgid函数改变自己或其他进程的进程组。

#include 

int setpgid(pid_t pid, pid_t pgid);

        setpgid函数将进程pid的进程组改为pgid,如果pid是0,那么就使用当前进程的PID,如果pgid是0,那么就用pid指定的进程的PID作为进程组ID。

        2、用/bin/kill程序发送信号

        /bin/kill程序可以向另外的进程发送任意的信号,比如

linux> /bin/kill -9 15213

        发送信号9(SIGKILL)给进程15213。一个负的PID会导致信号被发送到进程组PID中的每个进程。如果上面的15213变成负的,那么会发送信号9给进程组15213的每一个进程。

        3、从键盘发送信号

         Unix shell使用作业这个抽象概念来表示对一条命令行求值而创建的进程,在任何时刻,至多只有一个前台作业和0个或多个后台作业,比如输入 ls|sort 会创建一个由两个进程组成的前台作业,这两个进程是通过Unix管道连接起来的,一个进程会运行ls程序,另一个进程运行sort程序。shell为每个作业创建一个独立的进程组,进程组ID通常取自作业中父进程的一个。

        在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认情况下,结果是终止前台作业,类似的输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程,结果是停止(挂起)前台作业。

        4、用kill函数发送信号

        进程通过调用kill函数发送信号给其他进程(包括自己)。  

#include 
#include 

int kill(pid_t pid, int sig);

        如果pid大于0,那么kill函数发送信号号码sig给进程pid,如果pid等于0,那么kill发送信号sig给调用进程所在进程组的每个进程,包括调用进程自己。如果pid小于0,那么kill发送信号sig给进程组|pid|(pid绝对值)中的每个进程。

        5、用alarm函数发送信号

        进程可以通过alarm函数向它自己发送SIGALRM信号。

#include 

unsigned int alarm(unsigned int seconds);

        alarm函数安排内核在seconds秒之后发送一个SIGALRM信号给调用进程,如果seconds是0,那么就不会调度安排新的闹钟,任何情况乱下,对aralm的调用都将取消任何待处理的(pending)闹钟,并且返回任何待处理的闹钟在被发送前还剩下的秒数(如果这次对aralm的调用没有取消它的话),如果没有任何待处理的闹钟,就返回0。

    5.3 接收信号

        当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号的集合,如果这个集合为空,那么内核将控制传递到p的逻辑控制流中的下一条指令。如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k,收到这个信号会触发进程采取某种行为,一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令,每个信号类型都有一个预定的默认行为,是下面的一种:

        *进程终止

        *进程终止并转储内存。

        *进程停止(挂起)直到被SIGCONT信号重启

        *进程忽略该信号

        在本节开始的表格展示了与每个信号类型相关联的默认行为,比如收到SIGKILL的默认行为,就是终止接收进程,另外,接收到SIGCHLD的默认行为就是忽略这个信号。进程可以通过使用signal函数修改和信号相关联的默认行为,唯一例外的是SIGSTOP和SIGKILL,它们的默认行为是不能被修改的。

#include 

typedef void (*sighandler_t)(int);

//成功则指向前次处理程序的指针,出错则为SIG_ERR
sighandler_t signal(int signum, sighandler_t handler);

         上图中定义了一个类型sighandler_t,表示指向返回值为void型(参数为int型)的函数(的)指针。它可以用来声明一个或多个函数指针。signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:

        *如果handler是SIG_IGN,那么忽略类型为signum的信号。

        *如果handler是SIG_DFL,那么类型为signum的信号恢复为默认行为。

        *否则函数就是用户定义的函数的地址,这个函数就称为信号处理函数。只要进程收到一个类型为signum的信号,就会调用这个程序,通过把处理程序的地址传递到signal函数从而改变默认行为。这叫做设置信号处理程序,调用信号处理程序被称为捕获信号,执行信号处理程序。

        当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个整数参数被设置为k。这个参数允许同一个处理函数捕获不同类型的信号。当处理程序执行它的return语句,控制通常传递回控制流中进程被信号接收中断位置处的指令。

        例程:捕获键盘中断,并且编写信号处理程序,信号处理程序也有可能被中断。

 #include"csapp.h"
 void sigint_handler(int sig)
 {
     printf("caught SIGINT!\n");
     exit(0);
 }
 
 int main()
 {
     if(signal(SIGINT,sigint_handler)==SIG_ERR)
       unix_error("signal error");
     pause();
 
     return 0;
}

        CSAPP-----异常控制流_第14张图片

    5.4 阻塞和解除阻塞信号

        linux提供阻塞信号的隐式和显式的机制。

        隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。例如,上图,假设程序捕获了信号s,当前正在运行处理程序S,如果发送给该进程另一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接收。

        显式阻塞机制,应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。

#include 

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中的信号添加到block中。(blocked = blocked | set)。

        SIG_UNBLOCK:从blocked位向量中删除set中的信号(blocked = blocked &~ set)。

        SIG_SETMASK:block = set。

        如果oldset非空,那么block位向量之前的值保存在oldset中。

        试用下述函数对set信号集合,进行操作,sigemptyset初始化set为空集合,sigfillset函数把每个信号都添加到set中,sigaddset函数把signum添加到set,sigdelset从set中删除signum,如果signum是set成员,那么sigismember返回1,否则返回0。

     5.5 编写信号处理函数

        处理程序有几个属性使得它们很难推理分析:1)处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序相互干扰。2)如何以及何时接收信号的规则常常有违人的直觉。3)不同的系统有不同的信号处理语义。

        一些信号处理程序基本规则:

        1、安全的信号处理:

       1) 处理程序尽可能简单,

       2)在处理程序中只调用异步信号安全的函数,所谓的异步信号安全的函数能够被信号处理程序安全调用,要么它是可重入的(只访问局部变量)要么是不能被信号处理程序中断,下图是linux保证安全的系统级函数。

        CSAPP-----异常控制流_第15张图片

        信号处理函数中产生唯一安全的方法是使用write函数,特别的调用printf和sprintf是不安全的。我们可以开发一些安全的函数称为SIO包用来在信号处理程序中打印简单的信息。具体实现参考csapp.c。

        3)保存和恢复errno:许多linux异步信号安全的函数都会在出错返回设置errno,在处理程序中调用这样的函数会干扰主程序其他依赖errno的部分,解决办法是在进入处理程序时把errno保存在一个局部变量中,在处理程序返回前恢复它,只有在处理程序要返回时才有必要,如果处理程序调用exit终止进程就不需要这样做。

        4)阻塞所有信号,保护对全局数据结构的访问。

        5)用volatile声明全局变量。

        6)用sig_atomic_t声明标志,C提供这种类型保证对它的读写是原子的。

        2、正确的信号处理

        信号的一个与直觉不符的方面是未处理的信号是不排队的,因为pending位向量中每种类型的信号只对应一位,所以每种类型最多只能有一个未处理的信号,因此如果两种类型k信号发送给一个目的进程而因为目的进程当前正在执行信号k的处理程序,所以信号k被阻塞了,那么第二个信号就简单的丢弃了。它不会排队,关键思想是如果存在一个未处理的信号,就表示至少有一个信号到达了。

        这里需要注意的是如果信号处理函数正在处理一个信号,而又有一个同类型信号处于阻塞状态,那么在这个时候如果还有一个同类型信号到来,那么这个信号会被丢弃。所以,不可以用信号来对其他进程中发生的事件进行计数。

        3、可移植的信号处理

        Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。例如:

        *signal 函数的语义各有不同。

        *系统调用可以被中断。

        Posix标准定义了一个sigaction函数,允许用户设置信号处理时,明确指定他们想要的信号处理语句。

#include 

int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);

        sigaction函数运用并不广泛,使用起来比较复杂。将其包装成Signal,下图是其具体实现:

        CSAPP-----异常控制流_第16张图片

        调用方式和signal一样,Signal信号处理语义如下:

        *只有这个处理程序当前正在处理的那种类型的信号被阻塞。

        *和所有信号实现一样,信号不会排队等待。

        *只要可能,被中断的系统调用会自动重启。

        *一旦设置了信号处理程序,就会一直保持,直到Signal带着handler参数为SIG_IGN或SIG_DFL被调用。

    5.6 同步流以避免讨厌的并发错误

        如何编写相同存储位置的并发流程序的问题是比较困难的。流的交错可能会造成一些问题,基本问题是以某种同步并发流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。

        如果主程序中的一个调用函数和信号处理函数中一个调用函数存在竞争,那么就会导致一个很经典的同步错误,也就是竞争。原因是,父进程main程序和信号处理流之间的某些交错会导致事件的发生顺序发生变化,我们可以采用一个很简单的办法消除竞争。就是在使用fork之前,阻塞需要SIGCHID信号,然后在主程序调用了函数之后,再取消阻塞,这样就可以保证子进程的调用函数发生在主程序调用函数之后。

    5.7 显式地等待信号

        有时候主程序需要显式地等待某个信号处理程序运行。例如,当linux shell创建一个前台作业时,在接收下一条用户命令之前,它必须等待作业终止, 被SIGCHLD处理程序回收。有一个合理的解决办法是使用sigsuspend。

#include 

//返回-1
int sigsuspend(const sigset_t *mask);

        sigsuspend函数暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程,如果它的行为是终止,那么该进程不从sigsuspend返回,就直接终止。如果行为是运行一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合。

        sigsuspend相当于下面的代码的原子版本。

        CSAPP-----异常控制流_第17张图片

        原子属性保证对sigprocmask和pause的调用总是一起发生的,不会被中断,这样就消除了潜在的竞争,即在调用sigprocmask之后但在调用pause之前收到一个信号。

6、非本地跳转

        c语言提供了一种用户级异常控制流形式,称为非本地跳转。它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要正常的调用-返回序列,非本地跳转是通过setjump和longjump函数提供的。

 #include 

//setjmp返回0,longjmp返回非0
 int setjmp(jmp_buf env);

 int sigsetjmp(sigjmp_buf env, int savesigs);

        setjmp函数在env缓冲区保存当前调用环境,以供后面的longjmp使用,并返回0。调用环境包括程序计数、栈指针和通用目的寄存器,setjmp返回的值不能被赋值给变量。不过可以安全的使用在switch或条件语句测试中。

#include 

void longjmp(jmp_buf env, int val);

void siglongjmp(sigjmp_buf env, int val);

        longjmp函数从env缓冲区中恢复调用环境,然后触发一个最近一次初始化env的setjmp调用的返回,然后setjmp返回,并带有非0的返回值retval。

        setjmp调用一次返回多次,一次是当第一次调用setjmp而调用环江保存在缓冲区env中,一次是为每个相应的longjmp,另一方面,longjmp函数被调用一次,从不返回。

        非本地跳转的一个重要应用是允许从一个深层嵌套的函数调用中立即返回,通常是检测到某个错误情况引起的,如果在一个深层嵌套的函数调用中发现一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化错误程序。

        另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。

7、操作进程工具

        STRACE:打印一个正在运行的程序和它的子进程调用的每个系统的调用轨迹。

        PS:列出当前系统中的进程(包括僵尸进程)

        TOP:打印出关于当前进程资源使用的信息。

        PMAP:显示进程的内存映射。

        /proc:一个虚拟的文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。

8、小结

         异常控制流发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。

        有四种不同类型的异常:中断、故障、终止、陷阱。

你可能感兴趣的:(【CSAPP】)