CSAPP第八章异常控制流
CSAPP第八章异常控制流
Hoshea Zhang从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列a0,a1,⋯ ,an−1
其中,每个 a~k~ 是某个相应的指令 I~k~ 的地址。每次从 a~k~到 a~k+1~ 的过渡称为控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流(flow of control 或 control flow)。
系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如,一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器后,必须存放在内存中。程序向磁盘请求数据,然后休眠,直到被通知说数据已就绪。当子进程终止时,创造这些子进程的父进程必须得到通知。
现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制流(Exceptional Control Flow,ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。—个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。
作为程序员,理解 ECF 很重要,这有很多原因:
- 理解 ECF 将帮助你理解重要的系统概念。ECF 是操作系统用来实现 I/O、进程和虚拟内存的基本机制。在能够真正理解这些重要概念之前,你必须理解 ECF。
- 理解 ECF 将帮助你理解应用程序是如何与操作系统交互的。应用程序通过使用一个叫做陷阱(trap)或者系统调用(system call)的 ECF 形式,向操作系统请求服务。比如,向磁盘写数据、从网络读取数据、创建一个新进程,以及终止当前进程,都是通过应用程序调用系统调用来实现的。理解基本的系统调用机制将帮助你理解这些服务是如何提供给应用的。
- 理解 ECF 将帮助你编写有趣的新应用程序。操作系统为应用程序提供了强大的 ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件,以及检测和响应这些事件。如果理解了这些 ECF 机制,那么你就能用它们来编写诸如 Unix shell 和 Web 服务器之类的有趣程序了。
- 理解 ECF 将帮助你理解并发。ECF 是计算机系统中实现并发的基本机制。在运行中的并发的例子有:中断应用程序执行的异常处理程序,在时间上重叠执行的进程和线程,以及中断应用程序执行的信号处理程序。理解 ECF 是理解并发的第一步。我们会在第 12 章中更详细地研究并发。
- 理解 ECF 将帮助你理解软件异常如何工作。像 C++ 和 Java 这样的语言通过 try、catch 以及 throw 语句来提供软件异常机制。软件异常允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用层 ECF,在 C 中是通过 setjmp 和 longjmp 函数提供的。理解这些低级函数将帮助你理解高级软件异常如何得以实现。
异常
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。然而,对于每个系统而言,基本的思想都是相同的。在这一节中我们的目的是让你对异常和异常处理有一个一般性的了解,并且向你揭示现代计算机系统的一个经常令人感到迷惑的方面。
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。下图展示了基本的思想。
在图中,当处理器状态中发生一个重要的变化时,处理器正在执行某个当前指令 I~curr~ 。在处理器中,状态被编码为不同的位和信号。状态变化称为事件(event). 事件可能和当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除以零。另一方面,事件也可能和当前指令的执行没有关系。比如,一个系统定时器产生信号或者一个 I/O 请求完成。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler)).当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下 3 种情况中的一种:
- 处理程序将控制返回给当前指令 I~curr~,即当事件发生时正在执行的指令。
- 处理程序将控制返回给 I~next~ ,如果没有发生异常将会执行的下一条指令。
- 处理程序终止被中断的程序。
异常处理
异常可能会难以理解,因为处理异常需要硬件和软件紧密合作。很容易搞混哪个部分执行哪个任务。让我们更详细地来看看硬件和软件的分工吧。
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部 I/O 设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目 k 包含异常 k 的处理程序的地址。下图展示了异常表的格式。
在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号 k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目 k,转到相应的处理程序。下图展示了处理器如何使用异常表来形成适当的异常处理程序的地址。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊 CPU 寄存器里。
异常类似于过程调用,但是有一些重要的不同之处:
- 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令)。
- 处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。比如,x86-64 系统会将包含当前条件码的 EFLAGS 寄存器和其他内容压入栈中。
- 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
- 异常处理程序运行在内核模式下(见 8.2.4 节),这意味着它们对所有的系统资源都有完全的访问权限。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序,就将状态恢复为用户模式(见 8.2.4 节),然后将控制返回给被中断的程序。
异常的类别
异常可以分为四类:中断(interrupt),陷阱(trap)、故障(fault)和终止(abort)。图 8-4 中的表对这些类别的属性做了小结。
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自 I/O 设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
中断
中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。
下图概述了一个中断的处理。I/O 设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令(也即如果没有发生中断,在控制流中会在当前指令之后的那条指令)。结果是程序继续执行,就好像没有发生过中断一样。
剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction)。
陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork),加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的 “syscall n” 指令,当用户程序想要请求服务 n 时,可以执行这条指令。执行 syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序。
一个经典的故障示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。就像我们将在第 9 章中看到的那样,一个页面就是虚拟内存的一个连续的块(典型的是 4KB)。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。
终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图 8-8 所示,处理程序将控制返回给一个 abort 例程,该例程会终止这个应用程序。
进程
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向 shell 输入一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
关于操作系统如何实现进程的细节的讨论超出了本书的范围。反之,我们将关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。让我们更深入地看看这些抽象。
逻辑控制流
即使在系统中通常有许多其他程序在运行,像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个 PC 值的序列叫做逻辑控制流,或者简称逻辑流。
考虑一个运行着三个进程的系统,如下图所示。处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。图 中每个竖直的条表示一个进程的逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程 A 运行了一会儿,然后是进程 B 开始运行到完成。然后,进程 C 运行了一会儿,进程 A 接着运行直到完成。最后,进程 C 可以运行到结束了。
下图的关键点在于进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占(preempted)(暂时挂起),然后轮到其他进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。唯一的反面例证是,如果我们精确地测量每条指令使用的时间,会发现在程序中一些指令的执行之间,CPU 好像会周期性地停顿。然而,每次处理器停顿,它随后会继续执行我们的程序,并不改变程序内存位置或寄存器的内容。
并发流
计算机系统中逻辑流有许多不同的形式。异常处理程序、进程、信号处理程序、线程和 Java 进程都是逻辑流的例子。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。更准确地说,流 X 和 Y 互相并发,当且仅当 X 在 Y 开始之后和 Y 结束之前开始,或者 Y 在 X 开始之后和 X 结束之前开始。例如图中,进程 A 和 B 并发地运行,A 和 C 也一样。另一方面,B 和 C 没有并发地运行,因为 B 的最后一条指令在 C 的第一条指令之前执行。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(timeslicing)。例如,上图中,进程 A 的流由两个时间片组成。
注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。不过,有时我们会发现确认并行流是很有帮助的,它是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)。
私有地址空间
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台 n 位地址的机器上,地址空间是2^n^个可能地址的集合,0,1,⋯,2^n^−1 。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构。比如,图 8-13 展示了一个 x86-64 Linux 进程的地址空间的组织结构。
地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址 0x400000 开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。
用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个 I/。操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
Linux 提供了一种聪明的机制,叫做 /proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用 / proc 文件系统找出一般的系统属性,比如 CPU 类型(/proc/cpuinfo),或者某个特殊的进程使用的内存段(/proc/
上下文切换
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在 8.1 节中已经讨论过的那些较低层异常机制之上的。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(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 继续运行,直到下一次异常发生,依此类推。
系统调用错误处理
当 Unix 系统级函数遇到错误时,它们通常会返回 —1,并设置全局整数变量 errno 来表示什么出错了。程序员应该总是检査错误,但是不幸的是,许多人都忽略了错误检查,因为它使代码变得臃肿,而且难以读懂。比如,下面是我们调用 Unix fork 函数时会如何检査错误:
1 | if ((pid = fork()) < 0) { |
strerror 函数返回一个文本串,描述了和某个 errno 值相关联的错误。通过定义下面的错误报告函数,我们能够在某种程度上简化这个代码:
1 | void unix_error(char *msg) /* Unix-style error */ |
给定这个函数,我们对 fork 的调用从 4 行缩减到 2 行:
1 | pid_t Fork(void) |
通过使用错误处理包装函数,我们可以更进一步地简化代码,Stevens 在【110】中首先提出了这种方法。对于一个给定的基本函数 foo,我们定义一个具有相同参数的包装函数 Foo,但是第一个字母大写了。包装函数调用基本函数,检査错误,如果有任何问题就终止。比如,下面是 fork 函数的错误处理包装函数:
1 | pid_t Fork(void) |
给定这个包装函数,我们对 fork 的调用就缩减为 1 行:
pid = Fork();
进程控制
获取进程id
每个进程都有一个唯一的正数(非零)进程 ID(PID)。getpid 函数返回调用进程的 PID。getppid 函数返回它的父进程的 PID(创建调用进程的进程)。
1 |
|
getpid 和 getppid 函数返回一个类型为 pid_t 的整数值,在 Linux 系统上它在 types.h 中被定义为 int。
创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
运行。进程要么在 CPU 上 执行,要么在等待被执行且最终会被内核调度。
停止。进程的执行被挂起(suspended),且不会被调度。当收到 SIGSTOP、SIGTSTP、SIGTTIN 或者 SIGTTOU 信号时,进程就停止,并且保持停止直到它收到一个 SIGCONT 信号,在这个时刻,进程再次开始运行。(信号是一种软件中断的形式,将在 8.5 节中详细描述。)
终止。进程永远地停止了。进程会因为三种原因终止:
1)收到一个信号,该信号的默认行为是终止进程;
2)从主程序返回;
3)调用 exit 函数。
1
2
3
4
5
void exit(int status);
// 该函数不返回。
父进程通过调用 fork 函数创建一个新的运行的子进程。
1 |
|
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的 PID。
fork 函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork 返回子进程的 PID。在子进程中,fork 返回 0。因为子进程的 PID 总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
下面的代码展示了一个使用 fork 创建子进程的父进程的示例。当 fork 调用在第 6 行返回时,在父进程和子进程中 x 的值都为 1。子进程在第 8 行加一并输出它的 x 的副本。相似地,父进程在第 13 行减一并输出它的 x 的副本。
1 | int main() |
linux> ./fork
parent:x=0
child :x=2
调用一次,返回两次。fork 函数被父进程调用一次,但是却返回两次。一次是返回到父进程,一次是返回到新创建的子进程。对于只创建一个子进程的程序来说,这还是相当简单直接的。但是具有多个 fork 实例的程序可能就会令人迷惑,需要仔细地推敲了。
并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先完成它的 printf 语句,然后是子进程。然而,在另一个系统上可能正好相反。一般而言,作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。
相同但是独立的地址空间。如果能够在 fork 函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。因此,在我们的示例程序中,当 fork 函数在第 6 行返回时,本地变量 x 在父进程和子进程中都为 1。然而,因为父进程和子进程是独立的进程,它们都有自己的私有地址空间。后面,父进程和子进程对 x 所做的任何改变都是独立的,不会反映在另一个进程的内存中。这就是为什么当父进程和子进程调用它们各自的 printf 语句时,它们中的变量 x 会有不同的值。
共享文件。当运行这个示例程序时,我们注意到父进程和子进程都把它们的输出显示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用 fork 时,stdout 文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
进程图特别有助于理解带有嵌套 fork 调用的程序。例如,下图中的程序源码中两次调用了 fork。对应的进程图可帮助我们看清这个程序运行了四个进程,每个都调用了—次 printf,这些 printf 可以以任意顺序执行。
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie)
旁注 - 为什么已终止的子进程被称为僵死进程
在民间传说中,僵尸是活着的尸体,一种半生半死的实体。僵死进程已经终止了,而内核仍保留着它的某些状态直到父进程回收它为止,从这个意义上说它们是类似的。
如果一个父进程终止了,内核会安排 init 进程成为它的孤儿进程的养父。init 进程的 PID 为 1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排 init 进程去回收它们。不过,长时间运行的程序,比如 shell 或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。
一个进程可以通过调用 waitpid 函数来等待它的子进程终止或者停止。
1 |
|
waitpid 函数有点复杂。默认情况下(当 options=0 时),waitpid 挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么 waitpid 就立即返回。在这两种情况中,waitpid 返回导致 waitpid 返回的已终止子进程的 PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。
判定等待集合的成员
等待集合的成员是由参数 pid 来确定的:
- 如果 Pid>0,那么等待集合就是一个单独的子进程,它的进程 ID 等于 pid。
- 如果 Pid=-1,那么等待集合就是由父进程所有的子进程组成的。
waitpid 函数还支持其他类型的等待集合,包括 Unix 进程组,对此我们将不做讨论。
修改默认行为
可以通过将 options 设置为常量 WNOHANG,WUNTRACED 和 WCONTINUED 的各种组合来修改默认行为:
- WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止的同时,如果还想做些有用的工作,这个选项会有用。
- WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止,返回的 PID 为导致返回的已终止或被停止子进程的 PID。默认的行为是只返回已终止的子进程。当你想要检査已终止和被停止的子进程时,这个选项会有用。
- WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到 SIGCONT 信号重新开始执行。(8.5 节会解释这些信号。)
可以用或运算把这些选项组合起来。例如:
- WNOHANG | WUNTRACED:立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为 0;如果有一个停止或终止,则返回值为该子进程的 PID。
检查已回收子进程的退出状态
如果 statusp 参数是非空的,那么 waitpid 就会在 status 中放上关于导致返回的子进程的状态信息,status 是 statusp 指向的值。wait.h 头文件定义了解释 status 参数的几个宏:
- WIFEXITED(status):如果于进程通过调用 exit 或者一个返回(return)正常终止,就返回真。
- WEXITSTATUS(status):返回一令正常终止的子进程的退出状态。只有在 WIFEXITED() 返回为真时,才会定义这个状态。
- WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,. 那么就返回真。
- WTERMSIG(status):返回导致子进程终止的信号的编号。只有在 WIFSIGNALED() 返回为真时,才定义这个状态。
- WIFSTOPPED(status):如果引起返回的子进程当前是停止的,那么就返回真。
- WSTOPSIG(status):返回引起子进程停止的信号的编号。只有在 WIFSTOPPED() 返回为真时,才定义这个状态。
- WIFCONTINUED(status):如果子进程收到 SIGCONT 信号重新启动,则返回真。
错误条件
如果调用进程没有子进程,那么 waitpid 返回 -1,并且设置 errno 为 ECHILD。如果 waitpid 函数被一个信号中断,那么它返回 -1,并设置 errno 为 EINTR。
wait
1 |
|
进程休眠
1 |
|
如果请求的时间量已经到了,sleep 返回 0,否则返回还剩下的要休眠的秒数。后一种情况是可能的,如果因为 sleep 函数被一个信号中断而过早地返回。我们将在 8.5 节中详细讨论信号。
我们会发现另一个很有用的函数是 pause 函数,该函数让调用函数休眠,直到该进程收到一个信号。
1 | \ |
加载并运行程序
execve 函数在当前进程的上下文中加载并运行一个新程序。
1 |
|
execve 函数加载并运行可执行目标文件 filename,且带参数列表 argv 和环境变量列表 envp。只有当出现错误时,例如找不到 filename,execve 才会返回到调用程序。所以,与 fork—次调用返回两次不同,execve 调用一次并从不返回。
参数列表是用下图中的数据结构表示的。argv 变量指向一个以 null 结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0] 是可执行目标文件的名字。
环境变量的列表是由一个类似的数据结构表示的,如图 8-21 所示。envp 变量指向一个以 null 结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如 “name=value” 的名字—值对。
在 execve 加载了 filename 之后,它调用 7.9 节中描述的启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型
1 | int main(int argc, char **argv, char **envp); |
或者等价的
1 | int main(int argc, char *argv[], char *envp[]); |
确认一下你理解了程序和进程之间的区别。程序是一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。如果你想要理解 fork 和 execve 函数,理解这个差异是很重要的。fork 函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。execve 函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的 PID,并且继承了调用 execve 函数时已打开的所有文件描述符。
利用fork或者execve运行程序
像 Unix shell 和 Web 服务器这样的程序大量使用了 fork 和 execve 函数。shell 是一个交互型的应用级程序,它代表用户运行其他程序。最早的 shell 是 sh 程序,后面出现了—些变种,比如 csh、tcsh、ksh 和 bash。shell 执行一系列的读/求值(read/evaluate)步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
下面展示了一个简单 shell 的 main 例程。shell 打印一个命令行提示符,等待用户在 stdin 上 输入命令行,然后对这个命令行求值。
1 |
|
1 | /* eval - Evaluate a command line */ |
1 | /* parseline - Parse the command line and build the argv array */ |
注意,这个简单的 shell 是有缺陷的,因为它并不回收它的后台子进程。修改这个缺陷就要求使用信号,我们将在下一节中讲述信号。
信号
到目前为止对异常控制流的学习中,我们已经看到了硬件和软件是如何合作以提供基本的低层异常机制的。我们也看到了操作系统如何利用异常来支持进程上下文切换的异常控制流形式。在本节中,我们将研究一种更高层的软件形式的异常,称为 Linux 信号,它允许进程和内核中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。比如,下图展示了 Linux 系统上支持的 30 种不同类型的信号。
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,如果一个进程试图除以 0,那么内核就发送给它一个 SIGFPE 信号(号码 8)。如果一个进程执行一条非法指令,那么内核就发送给它一个 SIGILL 信号(号码 4)。如果进程进行非法内存引用,内核就发送给它一个 SIGSEGV 信号(号码 11)。其他信号对应于内核或者其他用户进程中较高层的软件事件。比如,如果当进程在前台运行时,你键入 Ctrl+C(也就是同时按下 Ctrl 键和 C 键),那么内核就会发送一个 SIGINT 信号(号码 2)给这个前台进程组中的每个进程。一个进程可以通过向另一个进程发送一个 SIGKILL 信号(号码 9)强制终止它。当一个子进程终止或者停止时,内核会发送一个 SIGCHLD 信号(号码 17)给父进程。
序号 | 名称 | 默认行为 | 相应事件 |
---|---|---|---|
1 | SIGHUP | 终止 | 终端线挂断 |
2 | SIGINT | 终止 | 来自键盘的中断 |
3 | SIGQUIT | 终止 | 来自键盘的退出 |
4 | SIGILL | 终止 | 非法指令 |
5 | SIGTRAP | 终止并转储内存①^①① | 跟踪陷阱 |
6 | SIGABRT | 终止并转储内存①^①① | 来自 abort 函数的终止信号 |
7 | SIGBUS | 终止 | 总线错误 |
8 | SIGFPE | 终止并转储内存①^①① | 浮点异常 |
9 | SIGKILL | 终止②^②② | 杀死程序 |
10 | SIGUSR1 | 终止 | 用户定义的信号 1 |
11 | SIGSEGV | 终止并转储内存①^①① | 无效的内存引用(段故障) |
12 | SIGUSR2 | 终止 | 用户定义的信号 2 |
13 | SIGPIPE | 终止 | 向一个没有读用户的管道做写操作 |
14 | SIGALRM | 终止 | 来自 alarm 函数的定时器信号 |
15 | SIGTERM | 终止 | 软件终止信号 |
16 | SIGSTKFLT | 终止 | 协处理器上的栈故障 |
17 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
19 | SIGSTOP | 停止直到下一个SIGCONT②^②② | 不是来自终端的停止信号 |
20 | SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 |
21 | SIGTTIN | 停止直到下一个SIGCONT | 后台进程从终端读 |
22 | SIGTTOU | 停止直到下一个SIGCONT | 后台进程向终端写 |
23 | SIGURG | 忽略 | 套接字上的紧急情况 |
24 | SIGXCPU | 终止 | CPU 时间限制超出 |
25 | SIGXFSZ | 终止 | 文件大小限制超出 |
26 | SIGVTALRM | 终止 | 虚拟定时器期满 |
27 | SIGPROF | 终止 | 剖析定时器期满 |
28 | SIGWINCH | 忽略 | 窗口大小变化 |
29 | SIGIO | 终止 | 在某个描述符上可执行 I/O 操作 |
30 | SIGPWR | 终止 | 电源故障 |
术语
传送一个信号到目的进程是由两个不同步骤组成的:
发送信号。内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送信号可以有如下两种原因:
- 内核检测到一个系统事件,比如除零错误或者子进程终止。
- 一个进程调用了 kill 函数(在下一节中讨论),显式地要求内核发送一个信号给目的进程。
一个进程可以发送信号给它自己。
接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。给出了信号处理程序捕获信号的基本思想。
发送信号
Unix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组(process group)这个概念的。
进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组 ID 来标识的。getpgrp 函数返回当前进程的进程组 ID:
1 |
|
默认地,一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用 setpgid 函数来改变自己或者其他进程的进程组:
1 |
|
setpgid 函数将进程 pid 的进程组改为 pgid。如果 pid 是 0,那么就使用当前进程的 PID。如果 pgid 是 0,那么就用 pid 指定的进程的 PID 作为进程组 ID。例如,如果进程 15213 是调用进程,那么
setpgid(0, 0);
会创建一个新的进程组,其进程组 ID 是 15213,并且把进程 15213 加入到这个新的进程组中。
用/bin/kill发送信号
/bin/kill 程序可以向另外的进程发送任意的信号。比如,命令
linux> /bin/kill -9 15213
发送信号 9(SIGKILL)给进程 15213。一个为负的 PID 会导致信号被发送到进程组 PID 中的每个进程。比如,命令
linux> /bin/kill -9 -15213
发送一个 SIGKILL 信号给进程组 15213 中的每个进程。注意,在此我们使用完整路径 /bin/kill,因为有些 Unix shell 有自己内置的 kill 命令。
从键盘发送信号
Unix shell 使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和 0 个或多个后台作业。比如,键入
1 | linux> ls | sort |
会创建一个由两个进程组成的前台作业,这两个进程是通过 Unix 管道连接起来的:一个进程运行 ls 程序,另一个运行 sort 程序。shell 为每个作业创建一个独立的进程组。进程组 ID 通常取自作业中父进程中的一个。比如,图 8-28 展示了有一个前台作业和两个后台作业的 shell。前台作业中的父进程 PID 为 20,进程组 ID 也为 20。父进程创建两个子进程,每个也都是进程组 20 的成员。
用 kill 函数发送信号
进程通过调用 kill 函数发送信号给其他进程(包括它们自己)
1 |
|
如果 pid 大于零,那么 kill 函数发送信号号码 sig 给进程 pid。如果 pid 等于零,那么 kill 发送信号 sig 给调用进程所在进程组中的每个进程,包括调用进程自己。如果 pid 小于零,kill 发送信号 sig 给进程组 |pid|(pid 的绝对值)中的每个进程。下图展示了一个示例,父进程用 kill 函数发送 SIGKILL 信号给它的子进程。
1 |
|
用alarm函数发送信号
进程可以通过调用 alarm 函数向它自己发送 SIGALRM 信号
1 |
|
alarm 函数安排内核在 secs 秒后发送一个 SIGALRM 信号给调用进程。如果 secs 是零,那么不会调度安排新的闹钟(alarm)。在任何情况下,对 alarm 的调用都将取消任何待处理的(pending)闹钟,并且返回任何待处理的闹钟在被发送前还剩下的秒数(如果这次对 alarm 的调用没有取消它的话);如果没有任何待处理的闹钟,就返回零。
接收信号
当内核把进程 p 从内核模式切换到用户模式时(例如,从系统调用返回或是完成了一次上下文切换),它会检查进程 p 的未被阻塞的待处理信号的集合(pending &~blocked)。如果这个集合为空(通常情况下),那么内核将控制传递到 p 的逻辑控制流中的下一条指令(I~next~)。然而,如果集合是非空的,那么内核选择集合中的某个信号 k (通常是最小的 k),并且强制 p 接收信号 k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回 p 的逻辑控制流中的下一条指令(I~next~)。每个信号类型都有一个预定义的默认行为,是下面中的一种:
- 进程终止。
- 进程终止并转储内存。
- 进程停止(挂起)直到被 SIGCONT 信号重启。
- 进程忽略该信号。
进程可以通过使用 signal 函数修改和信号相关联的默认行为。唯一的例外是 SIGSTOP 和 SIGKILL,它们的默认行为是不能修改的。
1 |
|
signal 函数可以通过下列三种方法之一来改变和信号 signum 相关联的行为:
如果 handler 是 SIG_IGN,那么忽略类型为 signum 的信号。
如果 handler 是 SIG_DFL,那么类型为 signum 的信号行为恢复为默认行为。
否则,handler 就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为 signum 的信号,就会调用这个程序。通过把处理程序的地址传递到 signal 函数从而改变默认行为,这叫做设置信号处理程序(installing the handler)。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void sigint_handler(int sig) /* SIGINT handler */
{
printf("Caught SIGINT!\n");
exit(0);
}
int main()
{
/* Install the SIGINT handler */
if (signal(SIGINT, sigint_handler) == SIG_ERR)
unix_error("signal error");
pause(); /* Wait for the receipt of a signal */
return 0;
}
非本地跳转
C 语言提供了一种用户级异常控制流形式,称为非本地跳转(non local jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用—返回序列。非本地跳转是通过 setjmp 和 longjmp 函数来提供的。
1 |
|
setjmp 函数在 env 缓冲区中保存当前调用环境,以供后面的 longjmp 使用,并返回0。调用环境包括程序计数器、栈指针和通用目的寄存器。岀于某种超出本书描述范围的原因,setjmp 返回的值不能被赋值给变量:
1 |
|
longjmp 函数从 env 缓冲区中恢复调用环境,然后触发一个从最近一次初始化 env 的 setjmp 调用的返回。然后 setjmp 返回,并带有非零的返回值 retval。
第一眼看过去,setjmp 和 longjmp 之间的相互关系令人迷惑。setjmp 函数只被调用一次,但返回多次:一次是当第一次调用 setjmp,而调用环境保存在缓冲区 env 中时,一次是为每个相应的 longjmp 调用。另一方面,longjmp 函数被调用一次,但从不返回。
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。
图 8-43 展示了一个示例,说明这可能是如何工作的。main 函数首先调用 setjmp 以保存当前的调用环境,然后调用函数 foo,foo 依次调用函数 bar。如果 foo 或者 bar 遇到一个错误,它们立即通过一次 longjmp 调用从 setjmp 返回。setjmp 的非零返回值指明了错误类型,随后可以被解码,且在代码中的某个位置进行处理。
1 |
|
操作进程的工具
Linux 系统提供了大量的监控和操作进程的有用工具。
- STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用 -static 编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
- PS:列出当前系统中的进程(包括僵死进程)。
- TOP:打印出关于当前进程资源使用的信息。
- PMAP:显示进程的内存映射。
- /proc:一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输入 “cat/proc/loadavg”,可以看到你的 Linux 系统上当前的平均负载。