第八章 异常控制流
异常控制流ECF(exceptional control flow)
8.1 异常
异常是一种形式的异常控制流,它一部分是由硬件实现的,一部分是由操作系统实现的。
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。下图是其基本思想
当处理器状态中的一个变化(事件) 触发了从应用程序到一个异常处理程序的突发的控制转移(一个异常)。在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止。状态变化称为事件(event),事件可能和当前指令的执行直接相关,比如发生虚拟存储器缺页、算法溢出,或者除以0。也可能和当前指令的执行无关,如一个系统定时器阐释信号或者一个I/O请求完成。
任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序--异常处理程序(exception handler)。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况的一种:
1、处理程序将控制返回给当前指令Icurr(当事件发生时正在执行的指令);
2、处理程序将控制返回给Inext(如果没有发生异常,将会执行的下一条指令);
3、处理程序终止被中断的程序。
8.1.1 异常处理
每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。一些是由处理器的设计者分配的,其它的是由操作系统内核设计者分配的。前者包括除零、缺页、存储器访问违例、断点以及算数溢出。后者包括系统调用和来自外部I/O设备的信号。
系统启动时,会分配和初始化一张称为异常表的跳转表 ,如下图所示
当处理器检测到事件发生,并确定响应的异常号k后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序。下图展示了处理器如何使用异常表来形成适当的异常处理程序的地址。
异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基寄存器(exception table base register) 的特殊CPU寄存器了。
异常类似于过程调用,但是有一些重要的不同之处:
1、过程调用时,在跳转到处理程序之前,处理器将返回地址压到栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令)。
2、处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始被中断的程序会需要这些状态,比如,一个IA32系统将包含当前条件码的EFLAGS寄存器和其他一些东西压入栈中。
3、如果控制从一个用户程序转移到内核,所有这些项目(item)都被压到内核栈中,而不是压到用户栈中。
4、异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理了事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,将状态恢复为用户模式。如果异常中断的是一个用户程序,然后将控制返回给被中断的程序。
8.1.2 异常的类别
异常可以分为如下四类:
中断(interrupt)
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常被称为中断处理程序(interrupt handler)。
下图是一个中断的处理的概述。I/O设备,如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个管脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。
当处理器检测到中断管脚电压变化,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令。结果是程序继续执行,就像没有发生中断一样。
陷阱(trap)
陷阱是有意的异常,是执行一条指令的 结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在 用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个进程(fork)、加载一个程序(execve),或者终止当前进程(exit)。为了运行对这些内核服务的受控访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个程序对参数解码,并调用适当的内核程序。下图概述了一个系统调用的处理。
从程序员的角度来看,系统调用和普通的函数调用是一样的,然而,它们的实现是非常不同的。普通的函数运行在用户模式,用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。系统调用运行在内核模式,内核模式允许系统调用执行指令,并访问定义在内核中的栈。
故障(fault)
故障由错误情况引起,他可能被故障处理程序修正。当一个故障发生时,处理器将控制转移给故障处理程序 。如果能够修正,它就将控制返回到故障指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。下图概述了一个故障的处理
故障的经典示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在存储器中,因此必须从磁盘中取出时,就会发生这种故障。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在存储器中了,指令就可以没有故障的运行完成了。
终止(abort)
终止时不可恢复的致命错误造成的结果--典型的是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如下图所示,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
8.1.3 Intel处理器中的异常
Pentium系统可以有高达256种不同类型的异常。0~31对应Pentium体系结构定义的异常,因此对任何Pentium类的系统都是一样的。32~255对应的是操作系统定义的中断和陷阱。下图是一些异常的描述
当发生除以0,或者一个除法指令的结果对于目标操作数来说太大了时,就会发生除法错误(异常0) 。Unix不会试图从除法错误中恢复,而是选择终止程序。Unix shell典型地会把除法错误报告为浮点异常(Floating exception)。
8.2 进程
进程的经典定义就是一个执行中程序的示例。系统中的每个程序都是运行在某个进程的上下文(context)中的。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在存储器中的程序的代码和数据、它的栈、它的通用目的寄存器的内容、它的程序计数器、环境变量以及打开文件描述符的集合。
当在shell输入可执行目标文件的名字,运行程序时,shell会创建新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也可以创建新进程,且在这个 新进程的上下文中运行它们自己的代码或其他应用程序。
关于操作系统如何实现进程的细节超出了我们的讨论范围。我们只关注进程提供给应用程序的关键抽象:
1、一个独立的逻辑控制流,它提供一个假象,程序独占处理器
2、一个私有的地址空间,它提供一个假象,程序独占存储系统
8.2.1 逻辑控制流
如果用调试器单步执行程序,可以看到一些列的PC的值,这些值唯一的对应于包含在我们程序的可执行目标文件中的指令或是包含在运行时动态链接到我们程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流。
一般,和不同进程相关的逻辑流并不影响任何其他进程的状态,从这个意义上说,每个逻辑流都是与其他逻辑流相独立的。当进程使用进程间通信(IPC)机制,比如管道、套接字、共享内存、信号量,显式地与其他进程交互时,这条规则的唯一例外就会发生。
任何逻辑流在时间上和另外的逻辑流重叠的进程被称为并发进程(concurrent process),而这两个进程就被称为并发运行。如下图,A和B,A和C都是并发运行的,B和C不是,因为B的最后一条指令是在C的第一天指令之前执行的。
进程和其他进程轮换运行的概念称为多任务(multitasking) 。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫时间分片(time slicing)。
8.2.2 私有地址空间
进程为每个程序提供它自己的私有地址空间。一般而言,和这个地址空间中某个地址相关联的那个寄存器字节是不能被其他进程读写的,从这个意义上说,这个地址空间是私有的。
尽管和每个所以地址空间相关联的存储器的内容一般是不同的,但是每个这样的空间都有相同的结构。如下图是一个Linux进程的地址空间结构。其底部的3/4预留给用户程序,包括通常的文本、数据、堆和栈段。顶部的1/4预留给内核,包含内核在代表该进程执行指令时使用的代码、数据和栈。
8.2.3 用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
典型地,处理器是用某个控制寄存器的一个方式位(mode bit)来实现,个寄存器描述了进程当前享有的权利。当方式位被设置时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何存储器位置。
方式位没有设置时,进程运行在用户模式,不允许执行特权指令(privileged instruction),比如停止处理器、改变方式位的值或者发起一个I/O操作,也不允许用户模式中的进程直接引用地址空间总内核区的代码和数据,任何这样的尝试都会导致致命的保护故障。用户程序必须通过系统调用接口间接的访问内核代码和数据。
一个运行应用程序代码的进程初始时是在用户模式。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障、或者陷入系统调用(trapping system call)这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式切换到内核模式。处理程序运行在内核模式中,当它返回到应用代码是,处理器就把模式切换回用户模式。
Linux和Solairs提供了一种聪明的机制,叫做/proc文件系统,它运行用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出物一种用户程序可读的ASCII文件的层次结构。比如,你可以使用Linux /proc文件系统找出一般的系统上线,比如CPU类型(/proc/cpuinfo),或者摸个进程使用的存储器段(/proc/<process id>/maps).
8.2.4 上下文切换
操作系统内核利用一种称为上下文切换(context switch)的较高级形式的异常控制流来实现多任务,上下文切换机制是建立在8.1节中的那些较低层异常机制至上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新 启动一个被抢占进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描绘地址空间的页表(page table)、包含有关当前进程信息的进程表(process table)。以及包含进程已打开文件的信息的文件表(file table)。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,称为内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换可以:
1、保存当前进程的上下文;
2、恢复某个先前被抢占进程所保留的上下文;
3、将控制传递给这个新进程。
当内核代表用户执行系统调用时,可以发生上下文切换。如果系统调用因为等待某个事件发生而 阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果read系统调用请求一个磁盘访问,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。比如,sleep系统调用,它显示地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可以引发上下文切换。比如所有的系统都有某种产生周期性定时器中断的机制,典型的为美1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
下图展示了进程A和B之间上下文切换的示例。
开始时,A进程运行在用户模式中
然后通过执行read系统调用陷入到内核。内核中断陷阱处理程序请求来自磁盘控制器的DMA传输,并在磁盘控制器完成从磁盘到存储器的数据传输后,要求磁盘中断处理器。
磁盘取数据耗时较长,所以内核执行从进程A到进程B的上下文切换,而一直等待。
注意,在切换前,内核代表进程A在用户模式下执行指令。在切换的第一步中,内核代表进程A在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换完成之后,内核代表进程B在用户模式下执行指令。
随后,进程B在用户模式下运行一会,直到磁盘发出中断信号,表示数据已经从磁盘传送 到了存储器。内核判断进程B已经运行了足够长的时间了,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在read系统调用之后的那条指令。进程A就像运行,直到下一次异常发生,以此类推。
8.3 系统调用和错误处理
Unix系统提供了大量的系统调用,如Linux大约有160个,输入man syscalls 可以查看完整的
C通过使用“man 2 intro”里描述的_syscall宏,可以直接调用任何系统调用。然而,通常直接调用“系统调用”既不必要也不值得。标准C库提供了一组针对最常用系统调用的方便的包装(wrapper)函数。包装函数将参数打包好,通过适当的系统调用陷入内核,然后将系统调用的返回状态传递给调用程序。下面将系统调用和它们相关的包装函数可互换地称为系统级函数。
当Unix系统级函数遇到错误时,它们典型地会返回-1,并设置全局变量errno来表示什么出错了。程序员应该总是检查这些错误,但是不幸的是,许多人都忽略了错误检查,因为它使代码变得臃肿,而且难以读懂。如下面代码
1 if ((pid = fork()) < 0) {
2 fprintf(stderr, "fork error: %s\n", strerror(errno));
3 exit(0);
4 }
strerror函数返回一个文本串,描述了和某个errno值相关联的错误。通过定义下面的错误报告函数(error-reproting function) ,我们能够在某种程度上简化这个代码:
void unix_error(char *msg)
{
fprintf(stderr, %s: %s\n", msg, strerror(errno));
exit(0);
}
通过这个函数,可以把fork的调用从4行简化到2行。
if ((pid = fork()) < 0)
unix_error("fork error");
通过使用错误处理包装(error-handing wrapper) 函数,我们可以更进一步简化代码。对于一个给定的基本函数foo,我们定义一个具有相同参数的包装函数Foo,但是第一个字母大写了。包装函数调用基本函数来检查错误,如果有任何问题就终止。如下是fork函数的错误处理包装函数:
pid_t For(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
给定这个包装函数,对fork的调用就缩减为1行:
pid = Fork();
本书后面都 使用错误处理包装函数。它将使我们的示例代码保持简洁,而又不会给你错误的假象,认为允许忽略错误检查。注意,后面的系统级函数使用小写字母,它们的包装函数开头用大写字母。
8.4 进程控制
本节介绍重要的系统调用
8.4.1 获取进程ID
每个进程 都有一个唯一的整数进程ID(PID)。getpid函数返回调用进程的PID。getppid函数返回它的父进程的PID。它们的返回值pid_t在types.h中定义为int。
#include <unistd.h>
#include <sys/types.h>
pid_t getpid(void);
pid_t getppid(void);
8.4.2 创建和终止进程
从程序员角度,可以认为进程总是处于下面三种状态之一:
1、运行。进程要么在CPU上执行,要么在等待被执行且最终会被调度。
2、暂停。进程的执行被挂起(suspend),且不会被调度。当收到SIGSTOP、SIGTSTP、SIDTTIN或者SIGTTOU信号时,进程就暂停,并且保持暂停直到收到一个SIGCONT信号。
3、终止。进程永远地停止了。进程会因为三个原因终止:
1)收到一个信号,该信号的默认行为是终止进程;
2)从主程序返回;
3)调用exit函数;
exit 函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序返回一个整数值)
#include <stdlib.h>
void exit(int status);
父进程调用fork函数创建一个新的运行子进程:
#include <unistd.h>
#include <sys/types.h>
pid_t fork(void);
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的) 一份拷贝,包括文本、数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID.
fork函数有一个特点,因为它只被调用一次,却会返回两次:
一次是在父进程中,返回子进程的PID;
一次是在创建的子进程中,返回0
下图展示使用fork创建进程的实例:
其运行结果如下:
可见:
1、调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次:一次返回到父进程,一次返回到新创建的子进程。对于只创建一个子进程的程序来说,这还是相对简单的,但是多次fork 是,就会比较难看懂。
2、并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们多级控制流中的指令。我们的示例中,先打印父进程的printf,后打印子进程。然而其他系统上可能相反。通常,程序员不对不同进程中的指令交替执行做任何假设。
3、相同的但是独立的地址空间。如果我们能够在fork函数在父进程和子进程中返回后立即终止这两个进程,会发现每个进程的地址空间都是相同的,每个进程有相同的用户栈、本地变量值、堆、全局变量值,以及代码。因此在上面程序中,fork函数在第8行返回时,本地变量x在父进程和子进程中都为1。然而因为父进程和子进程都是独立的进程,它们每个都有自己的私有地址空间。后面父进程和子进程对x的改变都是独立的,不会反映到另一个进程的存储器中。所以父子进程的printf函数有不同的值。
4、共享文件。上面示例程序运行时,父子进程都把他们的输出显式在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用fork时,stdout文件是被打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
如下图所示,调用2次fork函数,会有四次hello打印,因为父进程调用fork创建一个子进程,然后父进程和子进程都调用fork,这就导致了两个更多的进程。以此类推,如果调用3次fork,则会有8个hello打印。
8.4.3 回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。取而代之的是,进程被保持在一个终止状态中,直到被它的父进程回收(reaped)。当父进程回收已经终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已经 终止的进程,此后,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie)。
如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排init进程来回收。init进程PID为1,并且是在系统初始化时由内核创建的。长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的存储器资源。
一个进程可以通过调用waitpid函数来等待它的子进程终止或者暂停:
waitpid函数有点复杂,默认地(当options=0时) ,waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回。在这两种情况中,waitpid返回导致waitpid返回的终止子进程的PID,并且将这个已终止的子进程从系统中去除。
判断等待集合的成员
等待集合的成员是由参数pid来确定的:
1、如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid;
2、如果pid=-1,那么等待集合就是由父进程所有的子进程组成的。
修改默认行为
可以通过用常量WNOHANG和WUNTRACED的不同组合来设置options,修改默认行为:
1、WNOHANG:如果没有等待集合中的任何子进程终止,那么就立即返回(返回值为0)。
2、WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成终止的或者被暂停。返回的PID为导致返回的终止或暂停子进程的PID;
3、WNOHANG|WUNTRACED:立即返回,如果没有等待集合中的任何子进程停止或终止,那么返回值为0,或者返回值等于那个被停止或者终止子进程的PID.
检查已回收子进程的退出状态
如果status参数是非空的,那么waitpid就会编码关于导致返回的子进程的状态信息到status参数。wait.h包含文件定义了解释status参数的几个宏:
1、WIFEXITED:返回一个 正常终止的子进程的退出状态。只有在WIFEXITED返回为真时,才会定义这个状态。
2、WIFSIGNALED:如果是因为一个未被捕捉的信号造成了子进程的终止,那么就返回真。
3、WTERMSIG:返回引起子进程终止的信号的数量。只有在WIFSIGNALED返回真时,才定义这个状态。
4、WIFSTOPPED:如果引起返回的子进程当前是暂停的,那么就返回真。
5、WSTOPSIG:返回引起子进程暂停的信号数量。只有在WIFSTOPPED返回真时,才定义这个状态。
错误条件
如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR。
示例
下面代码展示了一个创建N个子进程的程序,使用waitpid等待它们终止,然后查看每个终止主程序的退出状态
#include "csapp.h"
#define N 2
int main()
{
int status, i;
pid_t pid;
for (i = 0; i < N; i++)
if ((pid = Fork()) == 0) /* child */
exit(100+i);
/* parent waits for all its children to terminate */
while ((pid = waitpid(-1, &status, 0)) > 0) {
if (WIFEXITED(status))
printf("child %d terminated nornally with exit status=%d\n", pid, WEXITSTATUS(status));
else
printf("child %d terminated abnornally\n", pid);
}
if (errno != ECHILD)
unix_error("waitpid error");
exit(0);
}
在Unix系统执行,会产生如下结果:
程序不会按照某种特殊的顺序回收子进程,下图展示按照父进程创建子进程的相同顺序来回收上面程序的子进程。
8.4.4 让进程休眠
sleep函数将一个进程挂起一段时间。
如果请求是时间量已经到了,sleep返回0,否则返回剩下还要休眠的秒数。
pause函数让调用函数休眠,直到该进程收到一个信号为止。
8.4.5 加载并运行程序
execve函数在当前进程的上下文中加载并运行一个新程序。
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如不能发现filename,execve才会返回到调用程序。所以,不像fork会一次调用返回两次,execve调用一次并从不返回。
如下图所示,参数列表是用数据结构表示的。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数串。通常argv[0]是可执行目标文件的名字。
环境变量也是由数据结构表示的,如下图,envp变量指向一个以null结尾的指针数组,每个指针指向一个环境变量串,其中每个串都是形如“NAME=VALUE”的名字-值对。
在execve加载了filename之后,它调用7.9节中描述的启动代码。启动代码准备好栈,并将控制传递给新程序的主函数,该函数有如下形式的原型:
int main(int argc, char **argv, char **envp);
或者等价的:
int main(int argc, char *argcp[], char *envp[]);
当main开始在Linux系统上执行时,用户栈有下图所示的组织。
从栈底到栈顶依次分析:
首先是参数和环境字符串,它们都是连续地存放在栈中的,一个接一个,没有分隔。
紧随其后,在栈的更上层里,是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量串。全局变量environ指向这些指针中的第一个envp[0]。
紧随环境变量数组后的是以null结尾的argv[]数组,其中每个元素都指向栈中一个参数串。
在栈的顶部是main函数的三个参数:envp,它指向envp[];argv,它指向argv[];argc,它给出argv[]中非空指针的数量。
Unix提供了几个函数来操作环境数组:
getenv函数在环境数组中搜索字符串 “name=value” 。如果找到,返回一个指向value的指针,否则返回空指针。
如果环境数组包含一个形如“name=oldvalue” 的字符串,那么unsetenv会删除它,而setenv会用newvalue代替oldvalue,但是只有在overwirte非零时才会这样。如果name不存在,那么setenv就把“name=newvalue”添加到数组中。
8.4.6 利用fork和execve运行程序
下图展示了一个简单的shell的main函数。shell打印一个命令提示符,等待用户输入,然后求值(evaluate)这个命令行。
下图展示了求值(evaluate) 命令行的代码。
/* eval - evaluate a command line */
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* argv for execve() */
int bg; /* should the job run in bg or fg? */
pid_t pid; /* process id */
bg = parseline(cmdline, argv);
if(argv[0] == NULL)
return;
if(!builtin_command(argv)) {
if ((pid = Fork()) == 0) {
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 buildin command, run it and return ture */
int builtin_command(char **argv)
{
if (!strcmp(argv[0]), "quit"))
exit(0);
if(!strcmp(argv[0], "&"))
return 1;
return 0;
}
它的第一个任务是调用parseline函数,这个函数解析了以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名字,马上就会解释这个命令,要么是一个可执行的目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
int parseline(const char *cmdline, char **argv)
{
char array[MAXLINE]; /* hold local copy of command line */
char *buf = array; /* ptr that traverses command line */
char *delim; /* points to first space delimiter*/
int argc; /* number of args */
int bg; /* background job? */
strcpy(buf, cmdline);
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 backgroud?*/
if(bg = (*argv[argc-1] == '&')) != 0)
argv[--argc] = NULL;
return bg;
}
如果最后一个参数是&,那么parseline返回1,表示在后台执行(shell不会等待它完成) 。否则返回0,表示在前台执行(shell会等待它完成)。
在解析了命令行之后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令quit,该命令是用来终止shell的。实际使用的shell有大量的命令,比如pwd,jobs和fg。
如果builtin_command返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业的终止。当作业终止时,shell就开始下一轮迭代。
注意,这个简单的shell是有缺陷的,因为它并不回收它的后台子进程。修正这个缺陷就要使用的到信号。
8.5 信号
一个信号(signal)就是一条消息,它通知进程一个某种类型的事件已经 在系统中发生了。它是更高层次软件形式的异常,它允许进程中断其他进程。下图是Linux系统支持的30种不同类型的信号。
注释(1):多年前,主存储器使用一种称为磁芯存储器(core memory)的技术实现的。转储存储器(dumping core)是一个历史术语,意思是吧代码和数据存储器段的映像写到磁盘上。
注释(2):这个信号既不能被捕获,也不能被忽略。
底层的硬件异常是由内核异常处理程序处理的,对用户进程而言通常时不可见的。信号提供了一种机制向用户进程通知这些异常。例如一个程序除以0,内核就会发送给它一个SIGFPE信号。
8.5.1 信号术语
传送信号到目的进程分两步:
1、发送信号。内核通过跟新目的进程上下文中的某个状态,发送(传递)一个信号给目的进程。发送信号可以有如下两种原因:
1)内核检测到一个系统事件,比如除0错误或者子进程终止;
2)一个进程调用了kill函数,显示地要求内核发送一个信号给目的进程。进程可以发送信号给自己。
2、接收信号。当目的进程被内核强迫一某种方式对信号的发送做出反应时,目的进程就接收了信号。进程可以忽略这个信号,终止,或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。
一个只发出而没有被接收的信号叫做待处理信号(pending signal)。在任何时刻,一种类型之多只会有一个待处理信号,如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们会被丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在pending位向量中维护着待处理信号集合,而在blocked 位相量中维护着被阻塞的信号集合。只要一个类型为k的信号被传送,内核就会在pending位相量中设置第k个位,而只要一个类型为k的信号被接走,内核就会在pending位相量中清除第k个位。
8.5.2 发送信号
Unix提供大量的机制来发送信号给进程,这些机制都是基于进程组(process group)这个概念。
进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID:
默认情况,子进程和父进程同属一个进程组。可以通过setpgid函数来修改自己或其他进程的进程组。
setpgid函数将进程pid的进程组改为pgid。如果pid是0,那么就使用当前进程的PID,如果pgid是0,那么就用pid指定的进程的PID作为进程组ID。例如,进程15213是调用进程,那么setpgid(0,0)会创建一个新的进程组,其进程组ID是15213,并且把进程15213加入到这个新的进程组 中。
用kill程序发送信号
/bin/kill程序可以向另外的进程发送信号,比如
unix> kill -9 15213
发送信号9(SIGKILL) 给进程15213。一个为负的PID会导致信号被发送到PID进程组中的每个进程。
从键盘发送信号
Unix shell 使用作业(job)的抽象概念来表示求值(evaluating)一条命令行而产生的进程。在任何时刻,至多只有一个前台job和0或多个后台job。比如,键入
unix> ls | sort
创建一个由两个进程组成的前台job,这两个进程是通过Unix管道连接起来的:一个进程运行ls程序,另一个运行sort程序。
shell为每个job创建一个独立的进程组。典型地,进程组ID是取自job中父进程中的一个。比如下图展示了一个有一个前台job和两个后台job的shell。前台job中的父进程PID位20,进程组ID也为20,。父进程创建两个子进程,每个也都是进程组 20的成员。
在键盘上输入Ctrl + c,发送SIGINT信号到shell,shell捕获该信号,并发生SIGINT信号到这个前台进程组中的每个进程。在默认情况下,结果是终止前台job。输入Ctrl+z会发送一个SIGTSIP信号到shell,shell捕获该信号,并发送SIGTSTP信号给前台进程组中的每个进程。默认情况下,结果是暂停前台job。
用kill函数发送信号
进程通过调用kill函数发送信号给其他进程(包括自己)
如果pid大于0,那么kill函数发送信号号码sig给进程pid,如果pid小于0,kill发送信号sig给进程组abs(pid)中的每个进程。下图展示了父进程用kill函数发送SIGKILL信号给它的子进程:
用alarm函数发送信号
进程可以通过调用alarm函数向自己发送SIGALRM信号
alarm函数安排内核在secs秒内发送一个SIGALRM信号给调用进程。如果secs是0,那么不会调度新的闹钟(alarm)。在任何情况下,对alarm的调用豆浆取消任何待处理的(pending) 闹钟,并且返回任何待处理的闹钟应该发送前剩下的秒数(如果这次对alarm的调用没有取消它的话),或者如果没有任何待处理的闹钟,就返回0.
下图展示了一个调用alarm的程序,它安排自己被SIGALRM信号在5s内每秒被中断异常。当传送到第6个SIGALRM信号时,它就终止
注意,上面程序使用signal函数设置了一个信号处理函数handler,只要进程收到一个SIGALRM信号,就异步调用该函数,中断main程序中的无限while循环。当handler返回时,控制传递回main函数,它就从淡出被信号到达时 中断了第地方继续执行。设置和使用信号处理程序可能是相当微妙的,后面三节主要讨论它。
运行时,输出如下
unix>./alarm
BEEP
BEEP
BEEP
BEEP
BEEP
BOOM!
8.5.3 接收信号
当内核送一个异常处理程序返回,准备将控制传递给进程p时,它会检查未被阻塞的待处理信号的集合(pending&~blocked)。如果这个集合为空(通常情况),那么内核传递控制给p,的逻辑控制流中的下一条指令(Inext)。
如果集合是非空,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k。收到这个信号会触发进程的某种行为。一旦进程完成了这个行为,控制就传递回p的逻辑控制流中的下一条指令(Inext)。每个信号类型都有一个预定义的默认行为:
1、进程终止;
2、进程终止并转储存储器(dump core);
3、进程暂停直到被SIGCONT信号重启。
4、进程忽略该信号。
如收到SIGKILL的默认行为是终止接收进程、接收SIGCHLD的默认行为是忽略这个信号。进程可以通过使用signal函数修改和信号关联的默认行为,除了SIGSTOP和SIGKILL。
signal函数可以通过下面三种方法来改变和信号signum关联的行为:
1、如果handler是SIG_IGN,则忽略类型为signum的信号。
2、如果handler是SIG_DFL,则类型为signum的信号行为恢复为默认行为。
3、否则,handler就是用户定义的函数的地址,称为信号处理程序(signal handler),只要进程接收到一个类型为signum的信号,就会调用这个程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序。信号处理程序的调用被称为捕捉信号。信号处理程序的执行被称为处理信号。
当一个进程捕捉到一个类型为k的信号时,为信号k设置的处理程序被调用,同时唯一一个整数参数被设置为k,这个参数允许同一个处理函数捕捉不同类型的信号。
当处理程序执行它的return语句时,控制(通常)传递回控制流中进程被信号接收中断的位置处的指令。我们说“通常”是因为在某些系统中,被中断的系统调用会立即返回一个错误。
下面程序展示了一个捕获用户在键盘上输入Ctrl+c时shell发送的SIGINT信号的程序。SIGINT的默认行为是立即终止该进程。在这个示例中,我们将默认行为修改为捕捉信号,输出一条信息,然后终止该进程。
8.5.4 信号处理问题
对于只捕捉一个信号并终止的程序来说,信号处理是简单的,然而当程序要捕捉多个信号时,一些细微的问题就产生了:
1、待处理信号被阻塞。Unix信号处理程序典型地会阻塞当前处理程序正在处理的类型的待处理信号。比如,一个进程捕捉到了一个SIGINT信号,并且当前正在运行它的SIGINT处理程序。如果另一个SIGINT信号传递到这个进程,那么这个SIGINT将变成待处理的,但是不会被接收,直到处理程序返回。
2、待处理信号不会排队等待。任意类型至多只有一个待处理信号。因此,如果有两个类型为k的信号传送到一个目的进程,而由于目的进程当前正在执行k的处理程序,所以信号k是阻塞的 ,那么第二个信号就被简单地丢弃,它不会排队等待。关键思想是存在一个待处理的信号仅仅表明至少已经到达了一个信号。
3、系统调用可以被中断。像read、write、和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称之为慢速系统调用。在某些系统中,当处理程序捕捉到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。
让我们通过一个简单的应用程序深入了解信号处理的细节,这个程序本质上类似于shell和Web服务器这样的真实程序。基本结构是父进程创建一些子进程,子进程独立运行一会儿,然后终止。父进程必须回收子进程,以避免在系统中留下僵死进程。但是我们也想让父进程在子进程运行时可以*地做其它工作。所以,我们决定用SIGCHLD处理程序回收子进程,而不是显式地等待子进程终止。
下面是初始版本代码,父进程设置了一个SIGCHLD处理程序,然后创建3个子进程,其中每个子进程运行1秒,然后终止。同时,父进程等待来自终端的一个输入行,随后处理它。这个处理被模型化为一个无限循环。当每个子进程终止时,内核通过发送一个SIGCHLD信号通知父进程。父进程捕捉到这个信号,回收一个子进程,做一些其他的清除工作(模型化为sleep(2)语句),然后返回
#include "csapp.h"
void handler1(int sig)
{
pid_t pid;
if((pid = waitpid(-1, NULL, 0)) < 0)
unix_error("waitpid error");
printf("Handler reaped child %d\n", (int)pid);
Sleep(2);
return;
}
int main()
{
int i, n;
char bufpMAXBUF];
if(signal(SIGCHLD, handler1) == SIGERR)
unix_error("signal error");
/* parent creates children */
for(i = 0; i < 3; i++) {
if (Fork() == 0) {
printf("Hello from child %d\n", (int)getpid());
Sleep(1);
exit(0);
}
}
/* parent waits for terminal input and then processer it*/
if((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0))
unix_error("read");
printf("Parent processing input\n");
while(1)
;
exit(0);
}
这个程序的缺陷是无法处理信号会阻塞、信号不会排队等待和系统调用可以被中断这些情况。
运行结果如下:
可见,尽管发送了3个SIGCHLD信号给父进程,但是只有2个被接收,因此父进程值回收了2个子进程,10321成为了僵死进程。我们分析一下流程:父进程接收并捕获了第1个信号。的处理程序还在处理第1个信号时,第2个信号就传送并添加到了待处理信号集合。然而,因为SIGCHLD信号被处理程序阻塞了,所以第2个信号就不会被接收。接着,在处理程序还在处理第一个信号时,第3个信号到达了,它江北丢弃。一段时间后,处理程序返回,内核发现有一个待处理的SIGCHLD信号,就迫使父进程接收这个信号。父进程捕获信号,并第二次执行处理程序。在处理程序完成对第二个信号的处理后,已经没有待处理的SIGCHLD信号了,而且也绝不会再有,因为第三个SIGCHLD的所有信息已经丢失了。由此得到的重要教训是,信号不可以用来对其他程序中发生的事件计数。
为了修正这个问题,我们修改handler1,使用while循环尽量多的回收子进程,修改后的代码如下:
void handler1(int sig)
{
pid_t pid;
while((pid = waitpid(-1, NULL, 0)) > 0)
printf("Handler reaped child %d\n", (int)pid);
if(errno != ECHILD)
unix_error("waitpid error");
Sleep(2);
return;
}
执行结果如下:
可见它正确回收了所有的子进程。不过,在Solaris系统中,如果在被阻塞的read系统调用之前,我们不在键盘上输入,会提前返回一个错误。
solaris> ./signal1
Hello from child 18906
Hello from child 18907
Hello from child 18908
Handler reaped child 18906
Handler reaped child 18908
Handler reaped child 18907
read: Interrupted system call
因为在solaris系统上,诸如read这样想慢速系统调用在被信号发送中断后,是不会自动重启的,而会提前返回给调用应用程序一个错误条件。
为了编写可移植的信号处理代码,必须考虑系统调用过早返回,然后手动重启它们的情况。
把这段代码
/* parent waits for terminal input and then processer it*/
if((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0))
unix_error("read");
改成
/* Manually restart the read call if it is interrupted */
while ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0))
if(errno != EINTR)
unix_error("read");
solaris系统 执行结果如下,运行正常
8.5.5 可移植的信号处理
Posix标准定义了sigaction函数,它允许Posix兼容系统的用户,显式地指定它们想要的信号处理语义。
sigaction运用不广泛,更简单的方式是 调用它的包装函数Signal
Signal函数设置了一个信号处理程序,其信号处理语义如下:
1、只有这个处理程序当前正在处理的那种类型的信号被阻塞;
2、和所有信号实现一样,信号不会排队等待;
3、只有可能,被中断的系统调用会自动重启。
4、一旦设置了信号处理程序,它就会一直保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。
8.5.6 显式地阻塞信号
应用程序可以使用sigpromask函数显式地阻塞和取消阻塞选择的信号。
sigpromask函数改变当前已阻塞信号的集合。具体的行为依赖于how的值:
1、SIG_BLOCK:添加set中的信号到blocked中(blocked=blocked|set).
2、SIG_UNBLOCK: 从blocked中删除set中的信号(blocked=blocked&~set).
3、SIG_SETMASK:blocked=set。
如果oldset非空,blocked位向量以前的值会保存在oldset中。
可以使用下列函数操作像set这样的信号集合:
sigemptyset:初始化set为空集;
sigfillset:将每个信号添加到set中
sigaddset:添加signum到set
sigdelset:从set中删除signum
sigismember:如果signum是set的成员,返回1,否则返回0
sigprocmask函数对于同步父子进程是很方便的。比如下面程序,它总结了一个典型的Unix shell的结构。父进程在一个job列表中记录着它的子进程。当父进程创建一个新的子进程时,它就把这个子进程添加到job列表中。当父进程在SIGCHLD处理程序中回收一个终止的子进程时,它就从job列表中删除这个子进程。
上面代码中,父进程在相应的deletejob之前保证执行了addjob。
如果不以某种方法同步父子进程,那么可能发生下面的情况:
1、父进程执行fork函数,并且内核调度新创建的子进程代替父进程运行。
2、在父进程可以再次运行之前,子进程会终止,并变成一个僵死进程,使得内核传递一个SIGCHLD信号给父进程。
3、后来,当父进程再次变成可运行但又在它执行之前,内核注意到待处理的SIGCHLD信号,并通过在父进程中运行处理程序接收这个信号。
4、处理程序回收终止的子程序,并调用deletejob,这个函数什么也不做,因为父进程还没把该子程序添加到列表 中。
5、在处理程序运行完毕后,内核运行父进程,父进程从fork返回,通过addjob错误地把(不存在的) 子进程添加到job列表中。
上面代码通过在调用fork之前,阻塞SIGCHLD信号,然后在调用addjob后取消阻塞信号的操作解决上面的情况。
8.6 非本地跳转
非本地跳转将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。通过setjmp和longjmp函数实现
setjmp函数在env缓冲区保存当前栈的内容,以供后面longjmp使用,并返回0.
longjmp函数从env缓冲区中恢复栈的内容,然后触发一个从最近一次初始化env的setjmp调佣的返回。然后setjmp返回,并带有非0的返回值retval。
setjmp函数只被调用一次,但返回多次:一次是第一次调用setjmp,而栈的上下文保存在缓冲区env中是;一次是为每个相应的longjmp
longjmp只被调用一次,但是从不返回。
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的,可以直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。
非本地跳转的另一个重要应用时使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。
8.7 操作进程的工具
strace:打印一个程序和它的子进程调用的每个系统调用的轨迹。用-static编译程序,能得到更详细的轨迹,而不带有大量与共享库相关的输出。
ps:列出系统中当前的进程(包括僵死进程)。
top:打印关于当前进程资源使用的信息。
kill:发送一个信号给进程。对于调试带信号处理程序的程序以及清除难以琢磨的进程非常有用。
/procc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如输入“cat /proc/loadavg”,观察Linux系统上当前的平均负载。