计算机系统——异常控制流
异常控制流
控制转移序列叫做处理器的控制流( flow of control 或 control flow )。
现代系统通过使控制流发生突变来对这些情况做出反应 。一般而言,我们把这些突变称为异常控制流 (Exceptional Control Flow, ECF ) 。
异常控制流发生在计算机系统的各个层次。
理解 ECF 很重要 ,这有很多原因 :
理解 ECF 将帮助你理解重要的系统概念。
理解 ECF 将帮助你理解应用程序是如何与操作系统交互的 。
理解 ECF 将帮助你编写有趣的新应用程序。
理解 ECF 将帮助你理解并发。
理解 ECF 将帮助你理解软件异常如何工作。
异常
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。
异常( exception) 就是控制流中的突变,用来响应处理器状态中的某些变化。
基本的思想:
当异常处理程序完成处理后, 根据引起异常的事件的类型,会发生以下 3 种情况中的一种:
(1)处理程序将控制返回给当前指令
I
c
u
r
r
I_{curr}
Icurr ,即当事件发 生时正在执行 的指令。
(2) 处理程序将控制返回 给
I
n
e
x
t
I_{next }
Inext , 如果没有发生异常将会执行的下一条指令。
(3) 处理程序终止被中断的程序。
异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负 整数的异常号( exception number )。其中一些号码是由处理器的设计者分配的, 其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目K包含异常k的处理程序的地址 。
异常号是到异常表中的索引, 异常表的起始地址放在一个叫做异常表基址寄存器( exception table base register )的特殊 CPU 寄存器里。
异常的类别
异常可以分为四类: 中断 ( interrupt ) 、陷阱( trap) 、故障(fault) 和终止( abort )。
1. 中断
硬件中断的异常处理程序常常称为中断处理程序(interrupt handler) 。
2. 陷阱和系统调用
陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
3. 故障
4. 终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误, 比如 DRAM 或者SRAM 位被损坏时发生的奇偶错误 。终止处理程序从不将控制返回给应用程序。
Linux/x86-64系统中的异常
1. Linux/ x86-64 故障和终止
除法错误。 当应用试图除以零时,或者当一个除法指令的结果对于目标操作数来说太大了的时候,就会发生除法错误(异常 0 )。
一般保护故障。 许多原因都会导致不为人知的一般保护故障(异常 13 ) , 通常是因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段,Linux 不会尝试恢复这类故障。
缺页(异常 14) 是会重新执行产生故障的指令的一个异常示例。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这条产生故障的指令。
机器检查。 机器检查(异常 18 ) 是在导致故障的指令执行中检测到致命的硬件错误时发生的。机器检查处 理程序从不返回控制给应用程序。
2. Linux/ 86-64 系统调用
进程
异常是允许操作系统内核提供进程( process ) 概念的 基本构造块 ,进程是计算机科学中最深刻、最成功的概念之一 。
进程的经典定义就是一个执行中程序的实例。
系统中的每个程序都运行在某个进程的上下文 ( context ) 中。上下文是由程序正确运行所需的状态组成的 。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程提供给应用程序的关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
逻辑控制流
用调试器单步执行程序 ,我们会看到一系列的程序计数器( PC) 的值, 这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个 PC 值的序列叫做逻辑控制流,或者简称逻辑流。
并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流( concurrent flow) , 这两个流被称为并发地运行 。
多个流并发地执行的一般现象被称为并发 (concurrency) 。
一个进程和其他进程轮流运行的概念称为多任务(multitasking )。
一个进程执行它的控制流的一部分的每一时间段叫做时间片 ( timeslice) 。因此,多任务也叫做时间分片 ( time slicing )。
如果两个流并发地运行在不同的处理器核或者计算机上, 那么我们称它们为并行流( parallel flow) , 它们并行地运行 ( running in parallel) , 且并行地执行( parallel execution) 。
私有地址空间
进程也为每个程序提供一种假象 ,好像它独占地使用系统地址空间 。
进程为每个程序提供它自己 的私有地址空间。一般言, 和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
用户模式和内核模式
当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction), 比如停止处理器、改变模式位,或者发起一个 I/O 操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。
当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。
处理程序运行在内核模式中, 当它返回到应用程序代码时,处理器就把模式从内核摸式改回到用户模式。
Linux 提供了一种聪明的机制 , 叫做 /proc 文件系统, 它允许用户模式进程访问内核数据结构的内容。
/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。
上下文切换
操作系统内核使用一种称为上下文切换 ( context switch) 的较高层形式的异常控制流来实现多任务。
上下文就是内核重新启动一个被抢占的进程所需的状态。
它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
上下文切换:
1 ) 保存当前进程的上下文,
2 ) 恢复某个先前被抢占的进程被保存的上下文 ,
3 ) 将控制传递给这个新恢复的进程。
进程控制
获取进程 ID
每个进程都有一个唯一的正数(非零)进程 ID ( PID ) 。
getpid 函数返回调用进程的 PID。
getppid 函数返回它的父进程的 PID( 创建调用进程的进程)。
getpid 和 getppid 函数返回一个类型为 pid_t 的整值,在 Linux 系统上它在 types.h 中被定义为 int 。
创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一 :
运行。 进程要么在 CPU 上执行,要么在等待被执行且最终会被内核调度。
停止。 进程的执行被挂起( suspended ) , 且不会被调度。
终止。 进程永远地停止了 。
进程会因为三种原因终止:
- 收到一个信号,该信号的默认行为是终止进程,
- 从主程序返回,
- 调用 exit 函数。
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中, 直到被它的父进程回收( reaped ) 。
一个终止了但还未被回收的进程称为僵死进程(zombie) 。
一个进程可以通过调用 waitpid 函数来等待它的子进程终止或者停止 。
让进程休眠
sleep 函数将一个进程挂起一段指定的时间。
pause 函数,该函数让调用函数休眠,直到该进程收到一个信号 。
加载并运行程序
execve 函数在当前进程的上下文中加载并运行一个新程序。
main 函数有 3 个参数:
- argc,它给出 argv [ ] 数组中非空指针的数量,
- argv,指向 argv [ ]数组中的第一个条目,
- envp,指向 envp [ ] 数组中的第一个条目。
Linux 提供了几个函数来操作环境数组 :
getenv 函数在环境数组中搜索字符串 " name =value " 。如果找到了,它就返回一个指向 value 的指针,否则它就返回 NULL。
如果环境数组包含一个形如 " name = oldvalue " 的字符串, 那么 unsetenv 会删除它, 而 setenv 会用 newvalue 代替 oldvalue , 但是只有在 overwirte 非零时才会这样。如果 name 不存在, 那么 setenv 就把 " name = newvalue " 添加到数组中。
信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
信号术语
传送一个信号到目的进程是由两个不同步骤组成的:
发送信号。 内核通过更新目的进程上下文的某个状态,发送(递送)一个信号给目的进程。
接收信号。 当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了进程。
进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号。
一个发出而没有被接收的信号叫待处理信号(pending signal)。
发送信号
1. 进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组 ID 来标识的。
getpgrp 返回当前进程的进程组ID。
setpgid 改变自己或者其他进程的进程组。
2. 用/bin/kill 程序发送信号
/bin/kill 程序可以向另外的进程发送任意的信号。
linux> /bin/kill -9 -15213
一个负的PID会导致信号被发送到进程组PID中的每个进程。
3. 从键盘发送信号
Unix shell 使用作业(job)这个抽象概念来表示为对一条命令求值而创建的进程。
在任何时刻,至多只有一个前台作业和0个或多个后台作业。
在键盘上输入 Ctrl + C 会导致内核发送一个SIGINT信号到前台进程中的每个进程。 默认,结果是终止前台作业。
输入 Ctrl + Z 会导致内核发送一个SIGTSTP信号到前台进程中的每个进程。 默认,结果是停止(挂起)前台作业。
4. 用 kill 函数发送信号
进程通过调用 kill 函数发送信号给其他进程(包括它们自己)。
5. 用 alarm 函数发送信号
进程可以通过调用 alarm 函数向他自己发送SIGALARM信号。
接收信号
每种信号类型都有一个预定义的默认行为,是下面的一种:
进程终止。
进程终止并转储内存。
进程停止(挂起)直到被SIGCONT信号重启。
进程忽略该信号,
进程可以通过使用 signal 函数修改和信号相关联的默认行为。
signal 函数可以通过下列三种方法之一来改变和信号 signum相关联的行为:
如果handler是SIG_IGN,那么忽略类型为signum的信号。
如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为。
否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,
只要进程接收到一个类型为signum的信号,就会调用这个程序。
通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序(installing the handler)。
调用信号处理程序被称为捕获信号。
执行信号处理程序被称为处理信号。
阻塞和解除阻塞信号
隐式阻塞机制。 内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
显示阻塞机制。 应用程序可以使用 sigprocmask 函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
编写信号处理程序
编写安全、正确和可移植的信号处理程序的一些基本规则:
1. 安全的信号处理
信号处理程序很麻烦是因为它们个主程序以及其他信号处理程序并发地运行。
如果处理程序和主程序并发地访问同样的全局数据结构,那么结构可能就不可预知,而且经常是致命的。
处理程序能安全并发运行的原则:
G0. 处理程序要尽可能简单。
G1. 在处理程序中只调用异步信号安全的函数。
G2. 保存和恢复 errno。
G3. 阻塞所有的信号,保护对共享全局数据结构的访问。
G4. 用 volatile 声明全局变量。
G5. 用 sig_atomic_t 声明标志。
2. 正确的信号处理
信号的一个与直觉不符的方面是未处理的信号是不排队的。
不可以用信号来对其他进程中发生的事件计数。
修改SIGCHLD 的处理程序,使得每次SIGCHLD处理程序被调用时,回收尽可能多的僵死子进程。
3. 可移植的信号处理
Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。
signal 函数的语义各有不同。
Signal 包装函数设置了一个信号处理程序,其信号处理语义如下:
只有这个处理程序当前正在处理的那种类型的信号被阻塞。
和所有信号实现一样,信号不会排队等待。
只有可能,被中断的系统调用会自动重启。
一旦设置了信号处理程序,它就会一致保持,直到Signal 带着 handler 参数为 SIG_IGN 或者 SIG_DFL被调用。
系统调用可以被中断。
像 read 、write 和 accept 这个月的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。
同步流以避免讨厌的并发错误
流可能交错的数量与指令的数量呈指数关系。
基本的问题是以某种方式同步并发流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。
显示地等待信号
有时主程序需要显式地等待某个信号处理程序运行。
非本地跳转
非本地跳转: 一种用户级异常控制流形式,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用—返回序列。
非本地跳转是通过setjmp 和 longjmp函数来提供的。
非本地跳转的一个重要应用就是运行从一个深层嵌套的函数调用中立即返回,通常是由简称到某个错误情况引起的。
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。
操作进程的工具
监控和操作进程的有用工具:
STRACE: 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
PS: 列出当前系统中的进程(包括僵死进程)。
TOP: 打印出关于当前进程资源使用的信息。
PMAP: 显示进程的内存映射。
/proc: 一个虚拟文件系统,以ASCII 文本格式输出大量内个数据结构的内容,用户程序可以读取这些内容。
学习参考资料:
《深入理解计算机系统》 第3版