CSAPP:第八章——异常控制流

一、异常控制流

程序计数器假设有一个值序列a1,a2, ... , an-1,其中,每个ak是某个相应指令Ik的地址,每次从地址akak+1的过渡称为控制转移。而这样的控制转移序列叫做处理器的控制流

最简单的一种控制流是一个平滑的序列,其中每个指令IkIk+1在内存中的位置都是相邻的。当然也有平滑流的突变,即指令IkIk+1在内存中的位置不相邻,通常是由跳转、调用和返回这种程序指令造成的。 这种平滑流的突变是由程序内部变量带来的。

还有一种突变是由程序外部的原因造成的系统状态变化,比如磁盘返回数据,鼠标关闭程序等。系统也必须能够对系统状态的变化做出反应,这些系统状态不能由内部程序变量捕获,而且也不一定要和程序的执行相关。现代系统通过使控制流突变来对这些情况做出反应,这种突变称为异常控制流(Exceptional Control Flow, ECF)

异常控制流 ECF 发生在计算机系统的各个层次:

  1. 硬件层,硬件中断
  2. 操作系统层,内核通过上下文切换将控制从一个进程转移到另一个进程
  3. 应用层,一个进程给另一个进程发送信号,信号接收者将控制转移到信号处理程序。

ECF 的应用:

  1. 操作系统内部。ECF 是操作系统用来实现 I/O、进程和虚拟内存的基本机制。
  2. 与操作系统交互。应用程序通过使用一个叫做系统调用(system call)的 ECF 形式,向操作系统请求服务。
  3. 编写应用程序。操作系统为应用程序提供了 ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件、检测和响应这些事件。
  4. 并发。ECF 是计算机系统中实现并发的基本机制。并发的例子有:异常处理程序或信号处理程序中断应用程序的执行,时间上重叠执行的进程和线程。
  5. 软件异常处理。C++ 和 Java 通过 try、catch、throw 等语句来提供异常处理功能。异常处理允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用层 ECF,在 C 中由 setjmp和 longjmp 函数提供。

二、异常

异常是异常控制流的一种形式,它的一部分由硬件实现,一部分由操作系统实现。异常就是控制流中的突变,用来响应处理器中的一些变化。如下图所示,当处理器状态发生一个重要的变化时,处理器正在执行某个当前指令Icurr。在处理器中,状态被编码为不同的信号,状态变化称为事件(event)。
在这里插入图片描述
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序exception handler),当异常处理完成处理后,根据引起异常的事件类型,会发生以下3种情况中的一种:

  • 处理程序将控制返还给当前指令Icurr,即当事件发生时正在执行的指令;
  • 处理程序将控制返还给Inext,如果没有发生异常将会执行下一条指令;
  • 处理程序终止被中断的程序

2.1 异常处理

系统为每种可能的异常都分配了一个唯一的非负整数异常号

  1. 一部分异常号是处理器设计者分配的(对应硬件部分)。比如被零除、缺页、内存访问违例、断点、算术溢出。
  2. 另一部分是由操作系统内核的设计者分配的(对应软件部分)。比如系统调用、来自外部 I/O 设备的信号。

在系统启动时,操作系统分配和初始化一张异常表,使得表目 k 包含异常 k 的处理程序的地址。
在这里插入图片描述
在运行时(系统执行某个程序),处理器检测到一个事件(处理状态发生了变化),并确定了相应的异常号k。然后处理器执行间接过程调用,通过异常表的表目k,转到相应的程序。异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器中。
在这里插入图片描述

异常类似过程调用,但有一些不同:

  1. 过程调用时,在跳转到处理程序前,处理器将返回地址压入栈中。而在异常中,返回地址是当前指令(事件发生时正在执行的指令)或下一条指令。
  2. 处理器也会把一些额外的处理器状态压入栈中,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。。
  3. 如果控制从用户程序转移到内核,所有这些项目都被压倒内核栈中,而不是用户栈中。
  4. 异常处理程序运行在内核模式下,因此它们对所有的系统资源都有完全的访问权限。

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

2.2 异常的类别

异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort),下表对这些类别属性做了点总结。
在这里插入图片描述

1)中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上说它是异步的。硬件中断的异常处理程序常常称为中断处理程序

下面是一个中断处理程序的过程:外部I/O设备向处理器芯片的一个引脚发个信号,并将异常号放到系统总线上(异常号标识了引起中断的设备),以此来触发中断。在当前指令完成执行后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,调用对应的中断处理程序。
在这里插入图片描述

剩下的异常类型(陷阱、故障和终止)是同步发生的,是由执行当前指令引起来的,这类引起异常的指令叫做故障指令

2)陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。

用户程序经常要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve)、或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的"syscall n"指令,当应用程序想要请求服务n时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核参数。下图描述了一个系统的调用过程。
在这里插入图片描述

3)故障

故障由错误引起的,它可能能够被故障处理程序修正。当故障发生,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核的abort例程,abort会终止当前应用程序。下图是故障的处理:

在这里插入图片描述

4)终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,终止处理程序从不将控制返还给应用程序。
在这里插入图片描述

2.3 Linux/x86-64 系统中的异常

1)Linux/x86-64 故障和终止

IA32有高达256种不同的异常,0-31的号码是Intel架构师定义的;32-255号是操作系统定义的中断和陷阱。下图是常见的异常示例:

  • 除法错误。不会恢复,会选择终止程序,Linux shell将除法错误报告为“浮点异常(floating exception)”。
  • 一般保护故障。如引用未定义的虚拟内存、写一个只读文本段,不会恢复,该类异常报告称为“段故障(segmentation fault)”。
    在这里插入图片描述

2)Linux/x86-64 系统调用

Linux 提供几百种系统调用(其中有一部分在 unistd.h 文件中),供应用程序请求内核服务时使用。系统中有一个跳转表(类似异常表),每个系统调用都有一个唯一的整数号,对应一个到内核中跳转表的偏移量。

理论上C程序可以使用syscall函数直接调用任何系统调用,但是标准C提供了一组方便的包装函数,像这种直接调用系统调用的函数和包装用来系统调用的函数,称为系统级函数

所有 Linux 系统调用的参数都是通用寄存器而不是栈传递的。一般寄存器 %rax 包含系统调用号。系统调用是通过陷阱指令 syscall 进行的(这里的 syscall 指令是汇编级别的指令)。
在这里插入图片描述

三、进程

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

每次用户向shell输入一个可执行文件的名字运行程序时,shell就会创建一个新的进程,可执行文件在这个新进程的上下文中运行。应用程序也可以创建进程,并在这个新进程的上下文中运行他们自己的代码或其他应用程序。

进程提供给应用程序两个关键抽象。

  • 一个独立的逻辑流,它提供一个假象,好像我们的程序独占地使用处理器。
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

3.1 逻辑控制流

如果我们调试程序单步执行,就会看到一系列程序计数器(PC)的值,这些值唯一的对应于程序的可执行文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流

考虑一个运行着三个进程的系统,处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。每个竖线表示一个进程逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A运行一会儿,然后是进程B开始运行到完成。然后是进程C运行了一会儿,进程A接着运行直到完成。最后是进程C可以运行到结束了。
在这里插入图片描述
A、B、C三个进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被抢占(暂时挂起),轮到其他进程。

3.2 并发流

一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发运行。更准确的说,流X和流Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。如图8-12的进程A和进程B、进程A和进程C就是并发。由于进程B执行结束以后才开始进程C,所以B和C不算是并发。

多个流并发地执行的一般现象被称为并发,也就是某进程开始执行以后(并未完成),PC跳转到其他进程执行。一个进程和其他进程轮流的运行的概念称为多任务。一个进程执行它控制流的一部分的每一时间段叫做时间片,如上面进程A由两个时间片组成。因此,多任务也叫做时间分片

【注】并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们运行在同一个处理器上。如果两个流并发的运行在不同的处理器核或者计算机上,那么我们称它们为并行流,它们并行的运行并且并行的执行,并行流是并发流的一个真子集

3.3 私有地址空间

进程为每个程序提供它自己的私有地址空间,且这些空间具有相同的结构,如图8-13。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他的进程读或者写的,从这个意义上说,这个地址空间是私有的。
在这里插入图片描述

3.4 用户模式和内核模式

处理器为了安全起见,不至于损坏操作系统,必须限制一个进程可执行指令能访问的地址空间范围。就发明了两种模式内核模式用户模式

  • 内核模式(超级用户模式),有最高的访问权限,可以执行任何指令,访问任何内存位置,甚至可以停止处理器、改变模式位,或者发起一个I/O操作。
  • 用户模式,不允许执行特定权限的指令。处理器使用一个寄存器当作模式位,描述当前进程的特权。进程从用户变为内核的唯一方法是中断、故障或者陷入系统调用时,才会将模式位设置成内核模式,得到内核访问权限,其他情况下都始终在用户权限中,就能够保证系统的绝对安全。

处理器使用某个控制寄存器中的一个模式位(mode bit)来区分用户模式与内核模式。进程初始时运行在用户模式,当设置了模式位时,进程就运行在内核模式。

Linux 提供了一种叫做 /proc 文件系统的机制来允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。可以通过 /proc 文件系统找出一般的系统属性(如 CPU 类型:/proc/cpuinfo)或者某个特殊的进程使用的内存段(/proc/<process-id>/maps)。2.6 版本的 Linux 内核引入了 /sys 文件系统,它输出关于系统总线和设备的额外的低层信息。

3.5 上下文切换

内核为每个进程维持一个上下文,上下文就是重新启动一个被抢占的进程所需要的状态,包括寄存器、程序计数器、用户栈、内核栈和各种内核数据结构

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种策略叫做调度。内核中有一个专门的调度程序,被称为调度器

当内核选择一个进程时,就说内核调度了该进程,内核调度进程抢占当前进程后,会使用上下文切换机制来进行控制转移,如从进程A切换到进程B:

  1. 保存进程A的上下文
  2. 恢复被保存的进程B的上下文
  3. 将控制传递到新恢复的进程

在这里插入图片描述

当内核代表用户执行系统调用的时候,就会发生上下文切换。如果系统调用因为等待某个事件而阻塞(比如 sleep 系统调用显式地请求让调用进程休眠,或者一个 read 系统调用要从磁盘度数据),内核就可以让当前进程休眠,切换到另一个进程。即使系统调用没有阻塞,内核也可以进行上下文切换,而不是将控制返回给调用进程。

如上图所示,当进程A调用read函数的时候,内核代表进程A开始执行系统调用读取磁盘上的文件,这需要耗费相对很长的时间,处理器这时候不会闲着什么都不做,而是开始一种上下文切换机制,切换到进程B开始执行。当B在用户模式下执行了一段时间,磁盘读取完文件以后发送一个中断信号,将执行进程B到进程A的上下文切换,将控制权返回给进程A系统调用read指令后面的那条指令,继续执行进程A(注:在切换的临界时间内核模式其实也执行了B一个小段时间)。

3.6 系统调用错误处理

当 Unix 系统级函数遇到错误时,它们通常会返回 -1,并设置全局整数变量 errno 来表示什么出错了。strerror 函数返回一个文本串,描述了和某个 errno 值相关联的错误。可使用 strerror 来查看错误:

// 调用 Unix fork 时检查错误
if((pid = fork()) < 0) { //如果发生错误,此时 errno 已经被设置为对应值了 
    fprintf(stderr, "fork error: %s\n", strerror(errno));//strerror(errno) 返回描述当前 errno 值的文本串
    exit(0);
}

许多人因为错误检查会使代码臃肿、难读而放弃检查错误。可以通过定义错误报告函数及对原函数进行包装来简化代码。对于一个给定的基本函数,定义一个首字母大写的包装函数来检查错误。如对fork的调用可以直接使用下面包装的Fork函数:

// 错误报告函数
void unix_error(char *msg) {
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}
// fork 函数的错误处理包装函数 Fork
pid_t Fork(void) {
    pid_t pid;
    if ((pid = fork()) < 0)
        unix_error("Fork error"); //调用上面定义的包装函数
    return pid;    
}

四、进程控制

4.1 父进程与子进程

1)父进程

指已创建一个或多个子进程的进程。在UNIX里,除了进程0以外的所有进程都是由其他进程使用系统调用fork创建的,这里调用fork创建新进程的进程即为父进程,而相对应的为其创建出的进程则为子进程,因而除了进程0以外的进程都只有一个父进程,但一个进程可以有多个子进程。

2)子进程

指的是由另一进程(对应称之为父进程)所创建的进程。子进程继承了对应的父进程的大部分属性,如文件描述符。在Unix中,子进程通常为系统调用fork的产物。在此情况下,子进程一开始就是父进程的副本,而在这之后,根据具体需要,子进程可以借助exec调用来链式加载另一程序。

3)父子进程之间的关系

  • 关于资源。子进程得到的是除了代码段是与父进程共享的以外,其他所有的都是得到父进程的一个副本,子进程的所有资源都继承父进程,得到父进程资源的副本。既然为副本,也就是说,二者是单独的进程,都拥有自己的私有地址空间,继承了以后二者就没有什么关联了,子进程单独运行。

  • 关于文件描述符。继承父进程的文件描述符时,相当于调用了dup函数,父子进程共享文件表项,即共同操作同一个文件,一个进程修改了文件,另一个进程也知道此文件被修改了。

4.2 获取进程 ID

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

#include <sys/types.h>
#include <unistd.h>

//pid_t 在Linux系统的types.h中定义为int
pid_t getpid(void);
pid_t getppid(void);

4.3 创建和终止进程

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

  • 运行。进程要么在CPU上执行,要么在等待被执行且最终会被CPU内核调度。
  • 停止。进程的执行被挂起,且不会被调度。当收到SIGSTOPSIGTSTPSIGTTIN或者SIGTTOU信号时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时候,进程再次运行。
  • 终止。进程永远地停止了。进程会因为3种原因终止:
    1)收到一个信号,该信号的默认行为是终止进程;
    2)从主程序返回;
    3)调用exit函数。
    #include <stdlib.h>
    //exit函数以status退出状态来终止进程
    void exit(int status);
    

父进程通过调用fork函数创建一个新的运行的子进程(父子进程并发执行)。

#include <sys/types.h>
#include <unistd.h>
// 返回:子进程返回 0,父进程返回子进程的 PID,如果出错,则为 -1。
pid_t fork(void);

子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本。父进程与子进程最大的区别是它们有不同的PID。

下面是一个创建子进程的实例,子进程和父进程拥有同样的代码片段:

int main() {
	pid_t pid;
	int x = 1;

	pid = Fork();
	if(pid == 0) {
		printf("child : x=%d, id = %d\n", ++x, getpid());
		exit(0);
	}

	printf("parent: x=%d, id = %d\n", --x, getpid());
	exit(0);
}

上面程序的执行结果是:

parent: x=0, id = 3087
child : x=2, id = 3088

理解上面的代码:

  • fork函数被调用一次,却会返回两次:一次是返回到调用进程(父进程)中,一次是返回到新创建的子进程中(子进程也是从这个返回点开始执行的)。在父进程中fork返回子进程的PID,在子进程中fork返回0,所以上面用例的输出结果不一样。
  • 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先完成它的 printf 语句,然后是子进程。然而,在另一个系统上可能正好相反。一般而言,作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。
  • 相同但是独立的地址空间。从变量x中就可以看出来,起初x在父进程和子进程的值都是1,后面父子进程分别对值做了改变,但是两者的改变互不影响。
  • 共享文件。当运行这个示例程序时,我们注意到父进程和子进程都把它们的输出显示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用 fork 时,stdout 文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。

进程图可以帮助理解fork函数:
在这里插入图片描述

比较复杂的fork函数应用:
在这里插入图片描述

4.4 回收子进程

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

如果一个进程的父进程终止了,内核会安排init进程成为它的孤儿进程的养父。init进程的PID为1,是在系统启动内核时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵尸子进程就终止了,那么内核会安排init进程取回收它们。

一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。

#include <sys/types.h>
#include <sys/wait.h>
//返回:如果成功,则为子进程的PID,如果WNOHANG,则为0;如果其他错误则为-1.
pid_t waitpid(pid_t pid, int *statusp,int options);

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

1)判定等待集合的成员

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

  • pid > 0,等待集合就是一个单独的子进程,它的进程 ID 等于 pid。;
  • pid = -1,等待集合为父进程的所有子进程。

2)修改默认行为

可以通过将 options 设置为常量 WNOHANGWUNTRACEDWCONTINUED 的各种组合来修改默认行为:

  • WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止的同时,如果还想做些有用的工作,这个选项会有用。
  • WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止,返回的 PID 为导致返回的已终止或被停止子进程的 PID。默认的行为是只返回已终止的子进程。当你想要检査已终止和被停止的子进程时,这个选项会有用。
  • WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到 SIGCONT 信号重新开始执行。(“五、信号” 节会解释这些信号。)

可以用或运算把这些选项组合起来。例如:

WNOHANG | WUNTRACED:立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为 0;如果有一个停止或终止,则返回值为该子进程的 PID

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

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

  • WIFEXITED(status):如果子进程通过调用 exit 或者一个返回(return)正常终止,就返回真。
  • WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。只有在 WIFEXITED() 返回为真时,才会定义这个状态。
  • WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
  • WTERMSIG(status):返回导致子进程终止的信号的编号。只有在 WIFSIGNALED() 返回为真时,才定义这个状态。
  • WIFSTOPPED(status):如果引起返回的子进程当前是停止的,那么就返回真。
  • WSTOPSIG(status):返回引起子进程停止的信号的编号。只有在 WIFSTOPPED() 返回为真时,才定义这个状态。
  • WIFCONTINUED(status):如果子进程收到 SIGCONT 信号重新启动,则返回真。

4)错误条件

如果调用进程没有子进程,那么 waitpid 返回 -1,并且设置 errnoECHILD。如果 waitpid 函数被一个信号中断,那么它返回 -1,并设置 errnoEINTR

5)wait 函数

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

#include <sys/types.h>
#include <sys/wait.h>
// 返回:如果成功,则为子进程的 PID,如果出错,则为 -1。
pid_t wait(int *statusp);

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

6)使用 waitpid 的示例

下面是对waitpid函数使用的一个示例:

#include "csapp.h"
#define N 2

int main() {
	int status, i;
	pid_t pid;

	/* Parent creates N children */
	for (i=0; i<N; i++)
		if((pid = Fork()) ==0)
			exit(100+i);	// 每个进程以唯一的退出状态退出

	// 每终止一个子进程,循环会执行一次,直至所有的进程终止结束
	while((pid = waitpid(-1, &status, 0))>0){
		if(WIFEXITED(status))
			printf("child %d terminated normally with exit status=%d\n", pid, WEXITSTATUS(status));
		else
			printf("child %d terminated abnormally\n", pid);
	}

	/* The only normal termination is if there are no more children */
	if(errno != ECHILD)
		unix_error("waitpid error");
	exit(0);
}

上面使用 waitpid 函数不按照特定的顺序回收僵尸子进程。

在第 15 行,父进程用 waitpid 作为 while 循环的测试条件,等待它所有的子进程终止。因为第一个参数是 -1,所以对 waitpid 的调用会阻塞,直到任意一个子进程终止。在每个子进程终止时,对 waitpid 的调用会返回,返回值为该子进程的非零的 PID

第 16 行检查子进程的退出状态。如果子进程是正常终止的——在此是以调用 exit 函数终止的——那么父进程就提取出退出状态,把它输出到 stdout 上。

当回收了所有的子进程之后,再调用 waitpid 就返回 -1,并且设置 errnoECHILD。第 24 行检查 waitpid 函数是正常终止的,否则就输出一个错误消息。在我们的 Linux 系统上运行这个程序时,它产生如下输出:

linux> ./waitpid1
child 22966 terminated normally with exit status=100
child 22967 terminated normally with exit status=101

程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机系统的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。这是非确定性行为的一个示例,这种非确定性行为使得对并发进行推理非常困难。两种可能的结果都同样是正确的,作为一个程序员,你绝不可以假设总是会出现某一个结果,无论多么不可能出现另一个结果。唯一正确的假设是每一个可能的结果都同样可能出现。

可以改动代码成下面示例,则子进程的创建和退出是以同样的顺序进行的:

#include "csapp.h"
#define N 2

int main()
{
    int status, i;
    pid_t pid[N], retpid;

    /* Parent creates N children */
    for (i = 0; i < N; i++)
        if ((pid[i] = Fork()) == 0) /* Child */
            exit(100+i);

    /* Parent reaps N children in order */
    i = 0;
    while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n",
                   retpid, WEXITSTATUS(status));
    else
        printf("child %d terminated abnormally\n", retpid);
    }

    /* The only normal termination is if there are no more children */
    if (errno != ECHILD)
        unix_error("waitpid error");
    
    exit(0);
}

4.5 让进程休眠

sleep函数将一个进程挂起一段指定的时间。如果请求的时间到了,sleep返回0,否则返回还剩下的要休眠的秒数。sleep函数可以被信号中断,从而过早返回。

#include <unistd.h>
unaigned int sleep(unsigned int sec);

pause函数,该函数让调用函数休眠,直到该进程接收到一个信号。

#include <unistd.h>
int pause(void);

4.6 加载并运行程序

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

#include <unistd.h>
// 如果成功,则不返回,如果错误,则返回 -1。
int execve(const char*filename,const char *argv[],const char*envp[]);

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

下图是参数列表和环境变量列表所使用的的数据结构,均是以NULL结尾的指针数组,每个指针均指向字符串。按照惯例,argv[0] 是可执行目标文件的名字。
在这里插入图片描述
在 execve 加载了 filename 之后,它调用加载可执行目标文件的启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型。

int main(int argc, char **argv, char **envp);
// argc,它给出 argv[ ] 数组中非空指针的数量;
// argv,指向 argv[ ] 数组中的第一个条目;
// envp,指向 envp[ ] 数组中的第一个条目。

Linux 提供了几个函数来操作环境数组:

#include <stdlib.h>
// 返回:若存在则为指向 name 的指针,若无匹配的,则为 NULL。
char *getenv(const char *name);
// 返回:若成功则为 0,若错误则为 -1。
int setenv(const char *name, const char *newvalue, int overwrite);
// 删除指定的环境变量
void unsetenv(const char *name);

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

在这里插入图片描述

4.7 利用 fork 和 execve 运行程序

下列用例展示了一个简单 shell 的 main 例程。shell 打印一个命令行提示符,等待用户在 stdin 上 输入命令行,然后对这个命令行求值。注意,这个简单的 shell 是有缺陷的,因为它并不回收它的后台子进程。

#include "csapp.h"
#define MAXARGS 128

/* Function prototypes */
void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);

int main()
{
    char cmdline[MAXLINE]; /* Command line */

    while (1) {
        /* Read */
        printf("> ");
        Fgets(cmdline, MAXLINE, stdin);
        if (feof(stdin))
            exit(0);

        /* Evaluate */
        eval(cmdline);
    }
}

/* eval - Evaluate a command line */
void eval(char *cmdline)
{
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE];   /* Holds modified command line */
    int bg;              /* Should the job run in bg or fg? */
    pid_t pid;           /* Process id */

    strcpy(buf, cmdline);
    bg = parseline(buf, argv);
    if (argv[0] == NULL)
        return;   /* Ignore empty lines */

    if (!builtin_command(argv)) {
        if ((pid = Fork()) == 0) {   /* Child runs user job */
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }

        /* Parent waits for foreground job to terminate */
        if (!bg) {
            int status;
            if (waitpid(pid, &status, 0) < 0)
                unix_error("waitfg: waitpid error");
            }
        else
            printf("%d %s", pid, cmdline);
    }
    return;
}

/* If first arg is a builtin command, run it and return true */
// 该函数检查第一个命令行参数是否是一个内置的 shell 命令。
// 如果是,它就立即解释这个命令,并返回值 1。否则返回 0。
// 简单的 shell 只有一个内置命令—— quit 命令,该命令会终止 shell。
// 实际使用的 shell 有大量的命令,比如 pwd、jobs 和 fg。
int builtin_command(char **argv)
{
    if (!strcmp(argv[0], "quit")) /* quit command */
        exit(0);
    if (!strcmp(argv[0], "&"))    /* Ignore singleton & */
        return 1;
    return 0;                     /* Not a builtin command */
}

/* parseline - Parse the command line and build the argv array */
// 这个函数解析了以空格分隔的命令行参数,并构造最终会传递给 execve 的 argv 向量。
// 第一个参数被假设为要么是一个内置的 shell 命令名,马上就会解释这个命令,
// 要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
int parseline(char *buf, char **argv)
{
    char *delim;         /* Points to first space delimiter */
    int argc;            /* Number of args */
    int bg;              /* Background job? */

    buf[strlen(buf)-1] = ' ';  /* Replace trailing '\n' with space */
    while (*buf && (*buf == ' ')) /* Ignore leading spaces */
        buf++;

    /* Build the argv list */
    argc = 0;
    while ((delim = strchr(buf, ' '))) {
        argv[argc++] = buf;
        *delim = '\0';
        buf = delim + 1;
        while (*buf && (*buf == ' ')) /* Ignore spaces */
            buf++;
    }
    argv[argc] = NULL;

    if (argc == 0) /* Ignore blank line */
        return 1;

    /* Should the job run in the background? */
    if ((bg = (*argv[argc-1] == '&')) != 0)
        argv[--argc] = NULL;

    return bg;
}

五、信号

信号就像你每天早上起床而设置(调用kill函数)的闹钟一样,你接收到这个闹钟以后,被强迫要处理这个信号(一直闹也没办法睡觉啊),这时候你有三种选择:继续睡觉(忽略)、关闭闹钟(终止)、起床(执行闹钟给我的命令:信号处理程序)。

信号是一种更高层次的软件形式的异常,允许进程和内核中断其他进程,它提供了一种机制,通知用户进程发生了那些具体的异常。

  • 进程试图除以0,内核会给它发送信号8
  • 进程执行一条非法指令,内核会发送信号4
  • 进程进行非法内存引用,内核会给它发送信号11
  • 进程在前台运行时,键入Ctrl+C,内核会发送信号2给前台进程组中的每个进程。
  • 一个进程可以向另一个进程发送信号9强制将其终止。
  • 子进程终止或停止,内核会给父进程发送信号17

Linux系统上提供了30中不同类型的信号(Linux 的各种 signal):

序号名称默认行为相应事件
1SIGHUP终止终端线挂断
2SIGINT终止来自键盘的中断
3SIGQUIT终止来自键盘的退出
4SIGILL终止非法指令
5SIGTRAP终止并转储内存跟踪陷阱
6SIGABRT终止并转储内存来自 abort 函数的终止信号
7SIGBUS终止总线错误
8SIGFPE终止并转储内存浮点异常
9SIGKILL终止杀死程序
10SIGUSR1终止用户定义的信号 1
11SIGSEGV终止并转储内存无效的内存引用(段故障)
12SIGUSR2终止用户定义的信号 2
13SIGPIPE终止向一个没有读用户的管道做写操作
14SIGALRM终止来自 alarm 函数的定时器信号
15SIGTERM终止软件终止信号
16SIGSTKFLT终止协处理器上的栈故障
17SIGCHLD忽略一个子进程停止或者终止
18SIGCONT忽略继续进程如果该进程停止
19SIGSTOP停止直到下一个SIGCONT不是来自终端的停止信号
20SIGTSTP停止直到下一个SIGCONT来自终端的停止信号
21SIGTTIN停止直到下一个SIGCONT后台进程从终端读
22SIGTTOU停止直到下一个SIGCONT后台进程向终端写
23SIGURG忽略套接字上的紧急情况
24SIGXCPU终止CPU 时间限制超出
25SIGXFSZ终止文件大小限制超出
26SIGVTALRM终止虚拟定时器期满
27SIGPROF终止剖析定时器期满
28SIGWINCH忽略窗口大小变化
29SIGIO终止在某个描述符上可执行 I/O 操作
30SIGPWR终止电源故障

5.1 信号术语

传送一个信号到目的进程有两个不同的步骤:

  • 发送信号。内核通过更新目的进程上下文的某个状态,发送一个信号给目的进程,进程也可以发送信号给自己。发送信号有两个原因:
    1)内核检测到一个系统事件。比如被零除错误,或者子进程终止。
    2)一个进程调用了kill函数。显示要求进程发送信号给目的进程。
  • 接受信号。当目的进程 被内核强迫以某种方式对信号的发送做出反应。目的进程就接收了信号。进程可以忽略这个信号,终止或者通过一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。
    在这里插入图片描述

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

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

5.2 发送信号

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

1)进程组

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

  #include<unistd.h>
  pid_t getpgrp(void);

默认,一个子进程和它的父进程同属于一个进程组,一个进程可以通过setpgid()来改变自己或者其他进程的进程组。

 #include<unistd.h>
   /**
   *如果pid是0 ,那么使用当前进程的pid。
   *如果pgid是0,那么使用指定的pid作为pgid(即pgid=pid)。
   *例如:进程15213调用setpgid(0,0)
   *那么进程15213会 创建/加入进程组15213.
   */
  int setpgid(pid_t pid,pid_t pgid);

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

  • /bin/kill可以向另外的进程发送任意的信号。比如unix>/bin/kill -9 15213,发送信号9(SIGKILL)给进程15213
  • 一个为负的PID会导致信号被发送到进程组PID中的每个进程。unix>/bin/kill -9 -15213,发送信号9(SIGKILL)给进程组15213中的每个进程。

/bin/kill的原因是,有些Unix shell 有自己的kill命令。

3)从键盘发送信号

对一个命令行求值而创建的进程,称为作业(jop)。在任何时刻,至多有一个前台作业和0个或多个后台作业。

Linux> ls | sort

上面命令会创建一个由两个进程组成的前台作业,两个进程通过Unix管道连接起来,一个进程运行ls,另一个进程运行sort。shell为每个作业创建了一个独立的进程组,进程组ID取自作业中父进程中的一个。下图是有一个前台作业和两个后台作业的shell(前台作业和后台作业):
在这里插入图片描述
在键盘输入ctrl-c ,内核会发送一个SIGINT信号到前台进程组的每个进程。在默认情况下,结果是终止前台作业。类似,输入ctrl-z内核会发送一个SIGTSTP信号给前台进程组的每个进程,在默认情况,结果是停止(挂起)前台作业。

4)用kill函数发送信号

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

int kill(pid_t pid, int sig);
  • pid > 0,发送信号sig给进程pid
  • pid = 0,发送信号sig给进程所在进程组的每个进程
  • pid < 0,发送信号sig给进程组abs(pid)

5)用alarm函数发送信号

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

 #include<unistd.h>
 unsigned int alarm(unsigned int secs);
 //返回:前一次alarm剩余的秒数,若前一次没有设置alarm,则返回 0。

alarm函数安排内核在secs秒内发送一个SIGALRM信号给调用进程。如果secs=0 那么不会调度alarm,当然不会发送SIGALRM信号。在任何情况,对alarm的调用会取消待处理(pending)的alarm,并且会返回被取消的alarm还剩余多少秒结束,如果没有pending的话,返回0

5.3 接受信号

信号的处理时机是在从内核态切换到用户态时,会执行do_signal()函数来处理信号。

当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号集合

  • 如果这个集合为空,内核将控制传递到p的逻辑控制流的下一条指令。
  • 如果非空,内核选择集合中某个信号k(通常是最小的k),并且强制p接收k。收到这个信号会触发进程某些行为,一旦进程p完成行为,则控制就传递到p的逻辑控制流的下一条指令。

每个信号都有预定义的默认行为,是下面的一种:

  • 进程终止
  • 进程终止并转储内存(dump core)
  • 进程停止直到被SIGCONT信号重启
  • 进程忽略该信号

进程可以通过使用signal函数修改和信号相关联的默认行为,但是SIGSTOP(19)SIGKILL(9)是不能被修改的。

#include<signal.h>
typedef void (*sighandler_t)(int);

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

signal函数通过下列三种方式之一改变和信号signum相关联的行为:

  • 如果handlerSIG_IGN,那么忽略类型为signum的信号。
  • 如果handlerSIG_DFL,那么类型为signum的信号恢复为默认行为。
  • 否则,handler就是用户定义的函数地址,这个函数称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用handler
    在这里插入图片描述

当信号处理程序执行它的return语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。
在这里插入图片描述

5.4 阻塞和解除阻塞信号

Linux提供了信号阻塞的隐式和显式机制:

  • 隐式阻塞机制:内核默认阻塞任何与当前处理程序正在处理的信号类型相同的待处理的信号。
    如图8-31中,程序捕获了信号s,然后运行处理程序S,此时如果再发送另一个相同类型的信号s给该进程,在处理程序S执行完后,第二个信号s会变成待处理而没有被接收。
  • 显式阻塞机制:程序可以使用sigprocmask函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。
    #include<signal.h>
    
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    

sigprocmask函数改变当前已阻塞信号的集合(4.1节描述的blocked位向量),具体行为依赖how值,如果oldset非空,block位向量以前的值会保存到oldset中:

  • SIG_BLOCK:添加set中的信号到blocked中(blocked = blocked | set)。
  • SIG_UNBLOCK: 从blocked删除set中的信号(blocked = blocked &~set)。
  • SIG_SETMASK: blocked = set

还有以下函数操作set集合:

#include<signal.h>
// 初始化set为空集合
int sigemptyset(sigset_t *set);
// 把每个信号全部填入到set中
int sigfillset(sigset_t *set);
// 把signum添加到set
int sigaddset(sigset_t *set,int signum);
// 从set中删除signum
int sigdelset(sigset_t *set,int signum);
				/*以上函数,返回:成功0,出错-1*/

//判断:若signum是set的成员,返回1,不是返回0,出错返回-1。
int sigismember(const sigset_t *set,int signum);

【链接】信号的阻塞与未决解除信号阻塞需注意的问题

5.5 编写信号处理程序

后面如果用到这一块的内容再做补充!也可以参考:https://www.cnblogs.com/zy691357966/p/5480537.html

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

后面如果用到这一块的内容再做补充!

5.7 显式地等待信号

后面如果用到这一块的内容再做补充!

五、非本地跳转

C语言提供一种用户级异常控制流形式,称为非本地跳转(nonlocal jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,不需要经过正常的调用-返回序列。非本地跳转是通过setjmplongjmp函数来提供:

  • setjmp函数在env缓冲区保存当前调用环境,以供后面longjmp使用,并返回0

    #include<setjmp.h>
    
    int setjmp(jmp_buf env);
    
    //参数savesigs若为非0则代表搁置的信号集合也会一块保存 
    int sigsetjmp(sigjmp_buf env,int savesigs);//信号处理程序使用
    
  • longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化envsetjmp调用的返回,然后从setjmp返回(longjmp不返回),并带有非零的返回值retval

    #include<setjmp.h>
    
    void longjmp(jmp_buf env,int retval);
    void siglongjmp(sigjmp_buf env,int retval);//信号处理程序使用
    

setjmp函数只被调用一次,但是返回多次,一次是第一次调用setjmp保存当前调用函数,返回0;一次是为每个相应的longjmp调用,返回retval。而longjmp不返回。

C++和Java提供的异常机制是较高层次的,是C语言setjmplongjmp函数的更加结构化的版本。你可以把try语句中的catch字句看作setjmp函数,相似地,throw语句就类似与longjmp函数。

  • 10
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Proxy(代理)是一种在计算机网络中广泛应用的中间服务器,用于连接客户端和目标服务器之间的通信。Proxy csapp是一个与计算机系统应用(Computer Systems: A Programmer's Perspective)相关的代理服务器。 Proxy csapp的设计目的是为了提供更高效的网络通信,增强系统的安全性,并提供更好的用户体验。在Proxy csapp中,客户端的请求首先会被发送到代理服务器,然后由代理服务器转发给目标服务器,并将目标服务器的响应返回给客户端。这种中间层的机制可以提供很多功能,如缓存、负载均衡、安全认证等。 在csapp中,Proxy csapp可以被用于优化网络数据传输的效率。代理服务器可以对客户端请求进行调度和协商,以减少网络延迟和数据传输量。通过缓存常用的数据和资源,代理服务器可以减少重复的数据传输和目标服务器的负载,提高网络性能和响应速度。 此外,Proxy csapp还可以提供安全的网络通信环境。代理服务器可以拦截和过滤网络流量,用于检测和阻止恶意攻击、垃圾邮件等网络安全威胁。代理服务器还可以对用户进行身份验证和授权,保护敏感数据的安全性。 最后,通过Proxy csapp可以实现更好的用户体验。代理服务器可以根据用户的需求进行个性化的服务,如按地理位置提供更快的网络连接、提供访问限制和控制等。代理服务器还可以对网络流量进行压缩和优化,提高网络传输效率,减少用户的等待时间。 总之,Proxy csapp在计算机系统应用中是一个重要的代理服务器,它可以提供高效的网络通信、增强系统的安全性,并带来更好的用户体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yelvens

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值