从给处理器加电,到断电为止,处理器做的工作其实就是不断地读取并执行一条条指令。这些指令的序列就叫做 CPU 的控制流(control flow)。最简单的控制流是“平滑的”,也就是相邻的指令在存储器中是相邻的。当然,控制流不总是平滑的,不总是一条接一条地执行,总会有出现改变控制流的情况。我们知道的程序内部状态改变的机制有两条:
- 跳转和分支
- 调用和返回
这些机制局限于程序本身的控制。当系统状态(system state)发生改变的时候,以上机制就不能很好地应对复杂的情况,例如:
- 数据从磁盘或者网络适配器到达
- 有一条指令执行了除以零的操作
- 用户按下 ctrl+c
- 系统内部的计时器到时间
现代系统通过使控制流发生突变来应对这些情况。这种机制叫做异常控制流(exceptional control flow)。异常控制流发生在计算机系统的各个层次。
- 在硬件层,硬件检测到的事件会触发控制转移到异常处理程序
- 在操作系统层,内核通过上下文转换将控制从一个进程转到另一个进程
- 在应用层,一个进程可以发送信号到另一个进程,接受者将控制突然转移到一个信号处理程序
学习异常控制流很重要:
- 可以帮助你理解重要的系统概念,例如 I/O、进程、虚拟存储器
- 可以帮助你理解应用程序和操作系统是如何交互的,例如陷阱、系统调用
- 可以帮助你编写进程相关的新应用程序,理解并发
- 理解软件异常如何工作
对于汇编语言、CPU、存储器的学习,可能已经使你初步了解应用是如何与硬件交互的。而学习异常控制流的重要性在于开始学习应用是如何与操作系统进行交互的。
异常
最底层的机制称为异常(Exception),更高层次的异常控制流包括进程切换(Process Context Switch)、信号(Signal)和非本地跳转(Nonlocal Jumps)。
异常是异常控制流的一种形式,它是由硬件和操作系统组合来实现的。异常就是控制流的突变,用来响应处理器状态的某种变化。状态变化被称为事件(event)。当处理器检测到时间的发生时,它就会通过一张叫做异常表(exception table)的跳转表,跳到一个专门用来处理这类事件的操作系统子程序——*异常处理程序(exception handler)
系统为每种类型的异常都分配了一个唯一的非负整数的异常号。当系统启动时,操作系统分配并初始化异常表,条目 k 包含异常 k 的处理程序的地址,异常表的起始地址存放在异常表基址寄存器中。当处理器检测到事件,并确定了异常号 k 后,处理器触发异常,执行间接过程调用。异常类似于过程调用,但有些许不同:
- 过程调用在跳转之前,要把返回地址压入栈中。然而,根据异常的类型,返回地址有可能是当前指令、下一条指令、也可能直接终止被中断的程序。
- 如果控制从用户态转到内核态,那么这些项目被压入内核栈而不是用户栈。
- 异常处理程序都运行在内核态下。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。异常处理程序处理完事件之后,它通过执行特殊的“从中断返回”的指令,可选地返回到被中断的程序,将适当的状态弹回到处理器的控制和寄存器中,如果中断的是一个用户程序,就将状态恢复为用户模式。最后将控制返回给被中断的程序。
异常的分类
异常分为四类:
类型 | 原因 | 同步or异步 | 返回行为 |
---|---|---|---|
中断(interrupt) | 来自 I/O 设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱(trap) | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障(fault) | 潜在的可恢复错误 | 同步 | 可能返回到当前指令 |
终止(abort) | 不可恢复的错误 | 同步 | 不返回 |
中断(interrupt)
中断是处理来自处理器外部的 I/O 设备的信号的结果。中断是异步(asynchronous)的,异步的意思就是是由处理器外面发生的事情引起的。对于执行程序来说,这种“中断”的发生完全是不知道什么时候会发生,CPU对其的响应也完全是被动的。
如下的例子就是中断:
- 计时器中断:计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器中断来从用户程序手上拿回控制权。
- I/O 设备的中断,这个种类就包含很多可能了。例如:用户按下 Ctrl+C、网络上一个数据包的到达、磁盘上的数据到达等等。
通过像处理器芯片上的一个引脚发信号,将异常号放到总线上,触发中断。异常号标识引起中断的设备。处理器意识到中断后,调用适当的中断处理程序。当处理程序返回时,将控制返回给下一条指令。就好像程序正常地执行,没有发生过中断一样。
陷阱(trap)
除了中断的其余三者(陷阱、故障和终止)是同步(synchronous)的,意思是执行当前指令的时候触发异常。
陷阱是有意的异常。陷阱是一类很重要的异常。有一个用途就是在用户程序和内核(kernel)之间提供一个接口,叫做系统调用(system call)。
系统调用看起来就像函数调用。通常,处理器设有两种模式:“用户模式”与“内核模式”,
然而某些功能是需要系统内核级别的支持才能完成,因此内核提供一系列具备预定功能的函数,通过一组称为系统调用的接口呈现给用户。系统调用把应用程序的请求、参数和控制传给内核,调用相应的的内核函数完成所需的处理,将处理结果返回给应用程序。
用户模式和内核模式
为了使操作系统内核得到保护,操作系统提供一个机制,限制应用程序可以使用的指令以及它可以访问的地址空间范围。处理器通常是使用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的。当设置了模式位,进程就运行在内核状态,可以执行指令集中的任何指令,访问任何存储器位置。相反,则运行在用户模式,不允许执行一系列特权指令,例如停止处理器、改变模式位、或者发起一个 I/O 操作,同时限制用户模式中的进程访问内核区的代码和数据,任何这样的尝试都会导致故障。进程从用户模式变为内核模式的方法就是通过注入中断、故障或陷入系统调用这样的异常。
故障(fault)
故障就是由错误情况引起的,它可能被故障处理程序修正,修正之后就可以将控制返回到引起故障的指令,并重新处理它。当然,也有可能是一个无法修复的故障,那么处理程序会返回到内核中的 abort 例程,会终止引起故障的程序。
故障的示例:
一、缺页(page fault),当指令引用一个虚拟地址,而虚拟地址对应的物理页面此时不在主存而在硬盘中,会发生缺页故障。缺页处理程序就从磁盘加载适当的页(一个页面就是一个虚拟存储器的连续的一个块,典型的是 4KB),然后将控制返回给引起故障的指令。因为相应的页面调度已经完成,在此执行指令时,就不会有缺页故障了。
二、非法的存储器地址引用(invalid memory reference)也是一种故障,只不过故障处理程序会将控制返回给 abort 例程,abort 例程会终止这个程序,并报“段错误”。
终止(abort)
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。终止处理程序从来不将控制返回给应用程序,而是返回给 abort 例程,终止这个应用程序。
从上面的例子中,我们可以看到异常的具体实现是依靠在用户代码和内核代码间切换而实现的,是比汇编中的跳转、返回更加底层的机制。
进程
异常是允许操作系统提供进程(process)概念的基本构造块。进程是计算机科学中最深刻最成功的概念之一。进程为每个程序提供一种假象,好像这个程序是计算机上唯一运行的程序,并独占处理器和存储器资源。而实际上在任何时间点,计算机上都有多个程序在运行。这种假象就是通过进程的概念提供给我们的。
进程的定义是:
A process is an instance of a running program.
进程就是计算机中一个执行中的程序的实例。程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。
用户下达运行程序的命令后,就会产生进程。操作系统如何实现进程、管理进程的细节超出了本书的讨论范围,我们将关注进程这个概念提供给程序的两种关键抽象:
- 逻辑控制流(logic control flow),提供一个假象——好像程序独占处理器。
- 私有地址空间,是通过虚拟内存(virtual memory)的机制,提供一个假象——好像程序独占存储器。
逻辑控制流
多进程的操作是怎样的呢?
现代处理器一般有多个核心,所以可以真正同时执行多个进程。而对每一个核心来说,还是有可能会切换执行不同的进程。在上图的多进程模型中,虚线部分可以认为是当前正在执行的进程,内存中需要另一块区域,当处理器去执行别的进程时,来保存当前的寄存器值。
如果用调试器单步执行程序,我们会看到一系列的程序计数器的值,值的序列就是逻辑控制流,每一个进程都有自己的逻辑流。在上图中,每条竖线都代表一部分逻辑流。多个进程轮流进行的概念叫做多任务(multitasking),操作系统中常用时间片(time slice)的机制来实现,因此多任务又称为时间分片(time slicing)。逻辑流如果在执行时间上与另一个逻辑流重叠,那么就叫并发流(concurrent flow),这两个流就是并发地执行(concurrency),否则叫做顺序流(sequential flow)。并发与处理器核心数无关,只需要满足两个流在时间上重叠,那就是并发执行。如果两个流并发地运行在不同的处理器核心上,那么就称为并行流(parallel flow),并行流是并发流的真子集。
上下文切换
切换进程时,内核会负责具体的调度,用到的机制称为上下文切换(context Switch)。上下文(context)是内核重启一个被抢占的进程所需要的状态,也可以理解为一个进程拥有的信息。包括代码、数据、堆栈、通用寄存器的内容、程序计数器、环境变量、内核数据结构,比如打开文件描述符的集合、描绘地址空间的页表、包含进程信息的进程控制块等等。
上下文切换是一种较高形式的异常控制流。在进程某些时刻,内核可以决定抢占当前执行的进程,重新开始一个被抢占的进程,这种行为叫做调度(schedule)。内核的做法是:
- 保存当前进程的上下文,
- 恢复某个先前被抢占的进程的上下文,
- 将控制传递给这个新恢复的进程。
所有的系统都有某种产生周期性定时器中断的机制,典型的就是每 1 毫秒或每 10 毫秒,内核判定某个进程已经运行了足够长的时间,要切换到另一个进程运行。
进程地址空间
在逻辑控制流中我们可以看到,整个过程中,CPU 交替执行不同的进程,而每个进程的内存大致都长一个样子。虚拟内存系统会负责管理地址空间,进程也给程序提供一种假象,好像它独占使用系统的地址空间。