深入理解计算机系统(第三版)第八章重要内容摘要
8.1异常
8.1.1异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部I/O设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目\(k\)包含异常\(k\)的处理程序的地址。图8-2展示了异常表的格式。
异常类似于过程调用,但是有一些重要的不同之处
- 过程调用时,在跳转到处理程序之前,处理器将返回地址压人栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下条指令(如果事件不发生,将会在当前指令后执行的指令)。
- 处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。比如,x86-64系统会将包含当前条件码的EFLAGS寄存器和其他内容压入栈中。
- 如果控制从用户程序转移到内核,所有这些项月都被压到内核栈中,而不是压到用户栈中。
- 异常处理程序运行在内核模式下(见8.2.4节),这意味着它们对所有的系统资源都有完全的访问权限。
8.1.2异常的分类
异常的类别。异步异常是由处理器外部的I/O设备中的事件产生的。同步异常是执行一条指令的直接产物
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
1.中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。
每执行完一条指令,CPU就查看中断请求引脚,若引脚的信号有效,则进行中断响应:将当前PC(断点)和当前机器状态保存到栈中,并“关中断”,然后,从数据总线读取中断类型号,根据中断类型号跳转到对应的中断服务程序执行。中断检测及响应过程由硬件完成。
中断服务程序执行具体的中断处理工作,中断处理完成后,再回到被打断程序的“断点”处继续执行。
2.陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的"syscall \(n\)”指令,当用户程序想要请求服务\(n\)时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。图8-6概述了一个系统调用的处理。
3.故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。图8-7概述了一个故障的处理。
4.终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图8-8所示,处理程序将控制返回给一个abort例程,该例程会终止这个应用程序。
8.1.3Linux/X86-64系统中的异常
- 有256种不同类型的异常和中断
- 每个异常和中断都有唯一编号,称之为中断类型号(也称向量号)。如类型0为“除法错”,类型2为“NMI中断”,类型14为“缺页”
- 每个异常和中断有与具对应的异常处理程序或中断服务程序,其入口地址放在一个专门的中断向量表或中断描述符表中。
- 前32个类型(0~31)保留给CPU使用,剩余的由用户自行定义(这里的用户指机器硬件的用户,即操作系统)
- 通过执行INT n(指令第二字节给出中断类型号n,n=32~255)使CPU自动转到OS给出的中断服务程序执行
- 实模式下,用中断向量表描述
- 保护模式下,用中断描述符表描述
1.Linux/X86-64故障和终止
除法错误。当应用试图除以零时,或者当一个除法指令的结果对于目标操作数来说太大了的时候,就会发生除法错误(异常0)。Unix不会试图从除法错误中恢复,而是选择终止程序。Linux shell通常会把除法错误报告为“浮点异常(Floating exception)"。
一般保护故障。许多原因都会导致不为人知的一般保护故障(异常13),通常是因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段。Linux不会尝试恢复这类故障。Linux shell通常会把这种一般保护故障报告为“段故障(Segmentation fault)”。
缺页(异常14)是会重新执行产生故障的指令的一个异常示例。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这条产生故障的指令。我们将在第9章中看到缺页是如何工作的细节。
机器检查。机器检查(异常18)是在导致故障的指令执行中检测到致命的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。
2.Linux/X86-64系统调用
Linux提供几百种系统调用,当应用程序想要请求内核服务时可以使用,包括读文件、写文件或是创建一个新进程。图8-10给出了一些常见的Linux系统调用。每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量。(注意:这个跳转表和异常表不一样.)
C程序用syscall函数可以直接调用任何系统调用。然而,实际中几乎没必要这么做,对于大多数系统调用,标准C库提供了一组方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用指令陷人内核,然后将系统调用的返回状态传递回调用程序。在本书中我们将系统调用和与它们相关联的包装函数都称为系统级函数,这两个术语可以互换地使用。
在x86-64系统上,系统调用是通过一条称为syscall的陷阱指令来提供的。研究程序能够如何使用这条指令来直接调用Linux系统调用是很有趣的。所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的。按照惯例,寄存器%rax包含系统调用号,寄存器%rdi、%rsi、%rdx、%r10、%r8和%r9包含最多6个参数。第一个参数在%rdi中,第二个在%rsi中,以此类推。从系统调用返回时,寄存器%rcx和%r11都会被破坏,%rax包含返回值。-4095到-1之间的负数值表明发生了错误,对应于负的errno。
8.2进程
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
关于操作系统如何实现进程的细节的讨论超出了本书的范围。反之,我们将关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
让我们更深人地看看这些抽象。
8.2.1逻辑控制流
进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程,对于一个运行在这些进程之一的上下文的程序,它看上去就像是在独占地使用处理器。
8.2.2并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrentflow),这两个流被称为并发地运行。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)o一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
如果两个流并发地运行在不同的处理器核或计算机上,那么就称其为并行流。
8.2.3私有地址空间
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台\(n\)位地址的机器上,地址空间是\(2^n\)个可能地址的集合,0,1,\(\cdots\),\(2^n一1\)。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
8.2.4用户模式和内核模式
- 为了使OS能够起到管理程序执行的目的,在一些时候处理器中必须运行内核代码
- 为了区分处理器运行的是用户代码还是内核代码,必须有一个状态位来标识,这个状态位称为模式位
- 处理器模式分用户模式(用户态)和内核模式(核心态)
- 用户模式(也称目态、用户态)下,处理器运行用户进程,此时不允许使用特权指令
- 内核模式(有时称系统模式、管理模式、超级用户模式、管态、内核态、核心态)下处理器运行内核代码,允许使用特权指令,例如:停机指令、开/关中断指令、Cache冲刷指令等。
8.2.5上下文切换
8.3系统调用错误处理
通过使用错误处理包装函数,我们可以更进一步地简化代码,Stevens在[110]中首先提出了这种方法。对于一个给定的基本函数foo,我们定义一个具有相同参数的包装函数Foo,但是第一个字母大写了。包装函数调用基本函数,检查错误,如果有任何问题就终止。比如,下面是fork函数的错误处理包装函数:
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
给定这个包装函数,我们对fork的调用就缩减为1行:
8.4进程控制
8.4.1获取进程ID
8.4.2创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
- 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
- 停止。进程的执行被挂起(suspended),且不会被调度。当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。(信号是一种软件中断的形式,将在8.5节中详细描述。)
- 终止。进程永远地停止了。进程会因为三种原因终止:1)收到一个信号,该信号的默认行为是终止进程,2)从主程序返回,3)调用e×让函数。
父进程通过调用fork函数创建一个新的运行的子进程。
#include<sys/types.h>
#include<unistd.h>
pid_t fork(vod);
返回:子进程返回0,父进程返回子进程的PID,如果出错,则为-1。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。
- 调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次——一次是返回到父进程,一次是返回到新创建的子进程。对于只创建一个子进程的程序来说,这还是相当简单直接的。但是具有多个fork实例的程序可能就会令人迷惑,需要仔细地推敲了。
- 并发执行。父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。在我们的系统上运行这个程序时,父进程先完成它的printd语句,然后是子进程。然而,在另一个系统上可能正好相反。一般而言,作为程序员,我们决不能对不同进程中指令的交替执行做任何假设。
- 相同但是独立的地址空间。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到两个进程的地址空间都是相同的。每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。因此,在我们的示例程序中,当fork函数在第6行返回时,本地变量x在父进程和子进程中都为1。然而,因为父进程和子进程是独立的进程,它们都有自己的私有地址空间。后面,父进程和子进程对x所做的任何改变都是独立的,不会反映在另一个进程的内存中。这就是为什么当父进程和子进程调用它们各自的printf语句时,它们中的变量x会有不同的值。
- 共享文件。当运行这个示例程序时,我们注意到父进程和子进程都把它们的输出显示在屏幕上。原因是子进程继承了父进程所有的打开文件。当父进程调用fork时,sdout文件是打开的,并指向屏幕。子进程继承了这个文件,因此它的输出也是指向屏幕的。
8.4.3回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie)。
如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排init进程去回收它们。不过,长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源。
一个进程可以通过调用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挂起调用进程的执行,直到它的等待集合(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函数
wait函数是waitpid函数的简单版本:
#include <sys/types.h>
#include <sys/wait.h>
pit_t wait(int *statusp);
返回:如果成功,则为子进程的PID,如果出错,则为-1。
调用wait(&status)等价于调用waitpid(-1,&status,0)。
8.4.4让进程休眠
8.4.5加载并运行程序
8.4.6利用fork和execve运行程序
8.5信号
8.5.1信号术语
8.5.2发送信号
1.进程组
2.用bin/kill程序发送信号
/bin/kill程序可以向另外的进程发送任意的信号。
linux> /bin/kill -9 15213
发送信号9(SIGKILL)给进程15213。
linux> /bin/kill -9 -15213
发送SIGKILL信号给进程组15213中的每个进程。
3.从键盘发送信号
4.用kill函数发送信号
5.用alarm发送信号
8.5.3接收信号
8.5.4阻塞和解除阻塞信号
8.5.5编写信号处理程序
1.安全的信号处理
2.正确的信号处理
信号的一个与直觉不符的方面是未处理的信号是不排队的。如果存在一个未处理的信号就表明至少有一个信号到达了。
不可以用信号来对其它进程中发生的事件计数
3.可移植的信号处理
8.5.6同步流以避免讨厌的并发错误
以某种方式同步并发流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。
8.5.7显式地等待信号
有时候主程序需要显式地等待某个信号处理程序运行。例如,当Linux shell创建一个前台作业时,在接收下一条用户命令之前,它必须等待作业终止,被SIGCHLD处理程序回收。
sigsuspend函数暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那么该进程不从sigsuspend返回就直接终止。如果它的行为是运行一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合。
8.6非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用一返回序列。非本地跳转是通过setjmp和longjmp函数来提供的。
#include<setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env,int savesigs);
返回:setjmp返回0,longjmp返回非零。
setjmp函数在env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0。调用环境包括程序计数器、栈指针和通用目的寄存器。出于某种超出本书描述范围的原因,setjmp返回的值不能被赋值给变量:
rc = setjmp(env);/*Wrong!*/
不过它可以安全地用在switch或条件语句的测试中[62]。
#include<setjmp.h>
void longjmp(jmp__buf env,int retval);
void siglongjmp(sigjmp_buf env,int retval);
从不返回。
longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。
longjmp允许它跳过所有中间调用的特性可能产生意外的后果。
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。
8.7操作进程的工具
Linux系统提供了大量的监控和操作进程的有用工具。
STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用-static编译你的程序,能得到一个更干 净的、不带有大量与共享库相关的输出的轨迹。
PS:列出当前系统中的进程(包括僵死进程)。
TOP:打印出关于当前进程资源使用的信息。
PMAP:显示进程的内存映射。
/proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输人"cat/proc/loadavg”,可以看到你的Linux系统上 当前的平均负载。