异常控制流
控制转移
控制流
系统必须能对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获,也不一定和程序的执行相关。
现代系统通过使控制流 发生突变对这些情况做出反应。我们称这种突变为异常控制流
( Exceptional Control Flow,ECF
)
异常控制流
发生在系统的各个层次。
理解ECF
很重要
- 理解
ECF
将帮助你理解重要的系统概念。 - 理解
ECF
将帮助你理解应用程序如何与操作系统交互- 通过陷阱(
trap
)或者系统调用(system call
)的ECF形式,向操作系统请求服务。
- 通过陷阱(
- 理解
ECF
将帮助你编写有趣的应用程序 - 理解
ECF
将帮助你理解并发 - 理解
ECF
将帮助你理解软件异常如何工作。
这一章你将理解如何与操作系统交互,这些交互都围绕ECF
8.1 异常
异常是异常控制流的一种,一部分由硬件实现,一部分由操作系统实现。
异常
(exception)就是控制流的突变,用来响应处理器状态的某些变化。- 状态变化又叫做
事件
(event)- 事件可能与当前执行指令有关
- 存储器缺页,算数溢出
- 除0
- 也可能与当前执行指令无关
- I/O请求
- 定时器产生信号
- 事件可能与当前执行指令有关
- 通过
异常表
(exception table)的跳转表,进行一个间接过程调用,到专门设计处理这种事件的操作系统子程序(异常处理程序
(exception handler)) - 异常处理完成后,根据事件类型,会有三种情况
- 返回当前指令,即发生事件时的指令。
- 返回没有异常,所执行的下一条指令
- 终止被中断的程序
8.1.1 异常处理
- 为每个异常分配了一个非负的
异常号
(exception number)- 一些号码由处理器设计者分配
- 其他号码由操作系统内核的设计者分配。
- 系统启动时,操作系统分配和初始化一张称为
异常表
的跳转表。- 条目k包含异常k的处理程序的地址。
异常表
的地址放在叫异常表基址寄存器
的特殊CPU寄存器中。)异常
类似过程调用
,不过有以下不同- 过程调用,跳转到处理程序前,处理器将返回地址压入栈中。对于异常,返回地址是当前,或下一跳指令。
- 处理器会把额外的处理器状态压入栈中。
- 如果控制一个用户程序到内核,那么所有这些项目会被压入内核栈中,而是用户栈。
- 异常处理程序运行在内核模式下,这意味他们对所有系统资源有完整访问权限。
8.1.2 异常的类别
异常分为一下四类:中断
(interrupt),陷阱
(trap),故障
(fault)和终止
(abort)。
- 中断
中断
是异步发生,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成,从这个意义上它是异步的。- 硬件中断的异常处理程序通常称为中断处理程序(interrupt handle)
- I/O设备通过向处理器芯片的一个引脚发信号,并将异常号放到系统总线上,以触发中断。
- 在当前指令执行完后,处理器注意到中断引脚的电压变化,从系统总线读取异常号,调用适当的中断处理程序。
- 当处理程序完成后,它将控制返回给下一条本来要执行的指令。
-
剩下的异常类型(陷阱,故障,终止)是同步发生,执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction).
- 陷阱和系统调用
陷阱
是有意的异常,是执行一个指令的结果。也会返回到下一跳本来要执行的指令。陷阱
最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用- 用户程序经常需要向内核请求服务。
- 读文件(read)
- 创建进程(fork)
- 新的程序(execve)
- 终止当前进程(exit)
- 为了运行对这些内核服务的受控访问,处理器提供了一条特殊的
syscall n
的指令 - 系统调用是运行在内核模式下,而普通调用是用户模式下。
- 用户程序经常需要向内核请求服务。
- 故障
- 故障由错误引起,可能被故障处理程序修正。
- 如果能被修正,返回引起故障的指令。
- 否则返回
abort
例程,进行终结。
- 故障由错误引起,可能被故障处理程序修正。
- 终止
- 终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM和SRAM被损坏。
- 终止处理程序从不将控制返回给应用程序。返回一个
abort
例程。
8.1.3 Linux/IA32 系统中的异常
- 有高达256种不同的异常
- 0~31 由Intel架构师定义的异常,对任何IA32系统都一样。
- 23~255 对应操作系统定义的中断和陷阱。
- Linux/IA32 故障和终止
- 除法错误
- 一般保护故障
- 缺页
- 机器检查
- Linux/IA32 系统调用
- 在IA32系统中,系统调用是通过一条称为
int n
的陷阱指令完成,其中n可能是IA32异常表256个条目中任何一个索引,历史中,系统调用是通过异常128(0x80)提供的。 - C程序可用
syscall
函数来直接调用任何系统调用- 实际上没必要这么做
- C库提供了一套方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用号陷入内核,然后将系统调用的返回状态传递回调用函数。
- 我们将系统调用与他们相关联的包装函数称为系统级函数。
研究程序如何使用int指令直接调用Linux 系统调用是很有趣的。所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递。
惯例
- %eax 包含系统调用号
- %ebx,%ecx,%edx,%esi,%edi,%ebp包含六个任意的参数。
- %esp不能使用,进入内核模式后,内核会覆盖它。
- 系统级函数写的hello world
int main() { write(1,"hello,world\n",13); exit(0); }
- 汇编写的hello world
string: "hello world\n" main: movl $4,%eax movl $1,%ebx movl $String,%ecx movl $len,%edx int $0x80 movl $1,%eax movl $0,%ebx int $0x80
8.2 进程
异常
是允许操作系统提供进程
的概念的基本构造快,进程
是计算机科学中最深刻,最成功的概念之一。- 假象,觉得我们的程序是系统中唯一运行着的程序。我们的程序好像独占处理器和存储器。
- 这些假象都是通过进程概念提供给我们的。
进程
经典定义:一个执行中的程序实例.- 系统中每个程序都是运行某个进程的
上下文
中的。- 上下文是由程序正确运行所需的状态组成。
- 这个状态包括存储器中的代码和数据,它的栈,通用目的寄存器,程序计数器,环境变量等。
- 系统中每个程序都是运行某个进程的
进程
提供的假象- 一个独立的
逻辑控制流
。 - 一个私有的
地址空间
。
- 一个独立的
8.2.1 逻辑控制流
- PC值的序列叫做
逻辑控制流
,或者简称逻辑流
8.2.2 并发流
逻辑流
也有不同的形式。- 异常处理程序,进程,信号处理程序,线程和Java进程都是逻辑流的例子。
- 一个逻辑流的执行在执行上与另一个流重叠,称为
并发流
,这两个流被称为并发地运行。- 更准确地说,流X和Y互相并发。
- 多个流并发执行的一般现象称为
并发
。- 一个进程和其他进程轮流执行的概念称为
多任务
。 - 一个进程执行它的控制流的一部分的每一时间段叫做
时间片
。 - 因此,多任务 又叫
时间分片
- 一个进程和其他进程轮流执行的概念称为
并发
的思想与流运行的处理器核数与计算机数无关。- 如果两个流在时间上重叠,即使运行在同一处理器,也是并发。
- 并行流是并发流的一个真子集。
- 两个流并发地运行在不同的处理器核或者计算机上,我们称为
并行流
。 - 它们并行地运行,且并行地执行
- 两个流并发地运行在不同的处理器核或者计算机上,我们称为
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持
并发
也不支持并行
。你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持
并发
。你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持
并行
。
并发
的关键是你有处理多个任务的能力,不一定要同时。
并行
的关键是你有同时处理多个任务的能力。
8.2.3 私有地址空间
进程
为个程序好像独占了系统地址空间。
- 一个
进程
为每个程序提供它自己的私有地址空间。 - 不同系统一般都用相同的结构。
8.2.4 用户模式和内核模式
处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式
和内核模式
。
- 处理器通过控制寄存器中的一个
模式位
来提供这个功能。- 该寄存器描述了进程当前享有的特权。
- 设置了
模式位
后,进程就运行在内核模式中(有时也叫超级用户模式
)- 内核模式下的进程可以执行指令集的任何指令,访问系统所有存储器的位置。
- 没有设置
模式位
时,进程运行在用户模式。- 用户模式不允许程序执行特权指令。
- 比如停止处理器,改变模式位,发起一个I/O操作。
- 不允许用户模式的进程直接引用地址空间的内核区代码和数据。
- 任何尝试都会导致
保护故障
。 - 用户通过
系统调用
间接访问内核代码和数据。
- 用户模式不允许程序执行特权指令。
- 设置了
- 进程从用户模式转变位内核模式的方法
- 通过中断,故障,陷入系统调用这样的异常。
- 在异常处理程序中会进入内核模式。退出后,又返回用户模式。
- 该寄存器描述了进程当前享有的特权。
- Linux提供一种聪明的机制,叫
/proc
文件系统。- 允许用户模式访问内核数据结构的内容。
/proc
文件将许多内核数据结构输出为一个用户程序可以读的文本文件的层次结构。- 如CPU类型(
/proc/cpuinfo
) - 特殊进程使用的存储器段(‘/proc//maps’)
- 如CPU类型(
- 2.6 版本引入Linux内核引入
/sys
文件系统。- 输出关于系统总线和设备的额外的底层信息。
8.2.5 上下文切换
操作系统内核使用一种称为上下文切换的 较高层次 的异常控制流来实现多任务。
- 上下文切换机制建立在之前讨论的较低层次异常机制上的。
内核为每个进程维护一个上下文。
- 上下文就是重新启动一个被抢占的进程所需的状态。
- 由一些对象的值组成
- 通用目的寄存器
- 浮点寄存器
- 程序计数器(PC)
- 用户栈
- 状态寄存器
- 内核栈
- 各种内核数据结构
- 描绘地址空间的页表
- 包含当前进城信息的进程表
- 进程已打开文件信息的文件表
- 由一些对象的值组成
- 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(
shedule
),由内核中称为调度器(scheduler
)的代码处理的。- 当内核选择一个新的进程运行时,我们就说内核调度了这个进程。
- 当调度进程时,使用一种
上下文切换
的机制来控制转移到新的进程- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
- 什么时候会发生上下文切换
- 内核代表用户执行系统调用。
- 如果系统调用因为某个事件阻塞,那么内核可以让当前进程休眠,切换另一个进程。
- 或者可以用
sleep
系统调用,显式请求让调用进程休眠。 - 即使系统调用没有阻塞,内核可以决定执行上下文切换
- 中断也可能引发上下文切换。
- 所有系统都有某种产生周期性定时器中断的机制,典型为1ms,或10ms。
- 每次定时器中断,内核就能判断当前进程运行了足够长的时间,切换新的进程。
- 内核代表用户执行系统调用。
高速缓存污染和异常控制流
一般而言,硬件高速缓存存储器不能和诸如中断和上下文切换这样的异常控制流很好地交互,如果当前进程被一个中断暂时中断,那么对于中断处理程序来说高速缓存器是冷的。如果处理程序从主存访问足够多的表项,被中断的进程继续的时候,高速缓存对于它来说也是冷的,我们称中断处理程序污染了高速缓存。使用 上下文切换也会发生类似的现象。
8.3 系统调用错误处理
- 当Unix系统级函数遇到错误时,他们典型地返回-1,并设置全局变量
errno
来表示什么出错了。if((pid=fork()<0){ fprintf(stderr,"fork error: %s\n", strerror(errno)); exit(0); }
- strerror 函数返回一个文本串,描述了个某个errno值相关联的错误。
8.4 进程控制
8.4.1 获取进程ID
#include<sys/types.h>
#include<unistd.h>
pid_t getpid(void);
pid_t getppid(void);
- PID是每个进程唯一的正数。
getpid()
返回调用进程的PID,getppid()
返回它的父进程的PID。- 返回一个类型
pid_t
的值,在Linux系统下在type.h被定义为int
8.4.2 创建和终止进程
进程总是处于下面三种状态
- 运行。进程要么在CPU中执行,要么等待执行,最终被内核调度。
- 停止。进程的执行被挂起,且不会被调度。
- 收到
SIGSTOP
,SIGTSTP
,SIDTTIN
或者SIGTTOU
信号,进程就会停止。 - 直到收到一个
SIGCONT
信号,在这个时刻,进程再次开始运行。 信号
是一种软件中断的形式。
- 收到
- 终止。进程永远停止。
- 收到一个信号。信号默认行为是终止进程。
- 从主程序返回
- 调用exit函数
- exit函数以
status退出状态
来终止进程(另一种设置方式在main中return )
- exit函数以
子进程
父进程通过调用fork
函数创建一个新的运行子进程
#include<sys/types.h>
#include<unistd.h>
pid_t fork(void);
返回:子进程返回0,父进程返回子进程的PID,如果出错,返回-1;
新创建的子进程几乎但不完全与父进程相同。
- 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝。
- 包括文本,数据和bss段,堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。
- 意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
- 父进程和新创建的子进程之间最大的区别在于有不同的PID 。
fork()
函数会第一次调用,返回两次,一次在父进程,一次在子进程。- 返回值用来明确是在父进程还是在子进程中执行。
- 调用一次,返回两次。
- 对于具有多个fork实例的需要仔细推敲了
- 并发执行
- 父进程和子进程是并发运行的独立进程。
- 内核可能以任意方式觉得执行他们的顺序。
- 不能对不同进程中指令的交替执行做任何假设。
- 相同但是独立的地址空间
- 在刚调用时,几乎什么都是相同的。
- 但是它们都有自己的私人空间,之后对x的改变是相互独立的。
- 共享文件
- 父进程和子进程都把他们的输出显示在屏幕上。
- 子进程继承了父进程所有打开的文件。
画进程图会有帮助。
8.4.3 回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终结的状态,知道被它的父进程 回收(reap
)。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。
一个终止了但还未被回收的进程叫做僵死进程
如果父进程没有回收,而终止了,那么内核安排init
进程来回收它们。
init
进程的的PID位1,在系统初始化时由内核创建的。- 长时间运行的程序,如shell,服务器,总是应该回收他们的僵死子进程
一个进程可以通过调用waitpid函数来等待它的子进程终止或停止
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1.
waitpid
函数有点复杂。默认(option=0
)时,waitpid
挂起调用进程的执行,知道它的等待集合中的一个子进程终止,如果等待集合的一个子进程在调用时刻就已经终止,那么waitpid
立即返回。在这两种情况下,waitpid
返回导致waitpid
返回的已终止子进程的PID
,并且将这个已终止的子进程从系统中去除。
- 判断等待集合的成员
等待集合的成员通过参数pid确定
- 如果
pid>0
,那么等待集合就是一个独立的子进程,它的进程ID
等于PID
- 如果
pid=-1
,那么等待集合就是由父进程所有的子进程组成的。 waitpid
函数还支持其他类型的等待集合,包括UNIX进程组等,不做讨论。
- 如果
- 修改默认行为(此处书中有问题,作用写反了)
可以通过将
options
设置为常量WHOHANG
和WUNTRACED
的各种组合,修改默认行为。- WHOHANG: 如果等待集合中的任何子进程都还没有终止,那么立即返回(返回值为0)
- 默认的行为返回已终止的子进程。
- 当你要检查已终止和被停止的子进程,这个选项会有用。
- WUNTRACED:挂起调用进程的执行,知道等待集合中的一个进程变为已终结或被停止。
- 返回的PID为导致的已终止或被停止的子进程
PID
· - 默认的行为是挂起调用进程,直到有子进程终止。
- 返回的PID为导致的已终止或被停止的子进程
- WHOHANG|WUNTRACED: 立即返回,如果等待集合中没有任何子进程被停止或已终止,那么
- WHOHANG: 如果等待集合中的任何子进程都还没有终止,那么立即返回(返回值为0)
- 检查已回收子进程的退出状态