南京大学 软件分析 课程总结

课程网站在线评测课程视频silverbullettt

知难而进,贵在坚持。省察体悟,贵于改过。

Course Introduction

概念

程序设计语言(Programming Languages,PL),静态程序分析(Static Program Analysis),莱斯定理(Rice’s Theorem),可靠性(Soundness)和完备性(Completeness)。

莱斯定理说明,不存在完美的静态分析,满足 Sound(Overapproximate)和 Complete(Underapproximate)。Sound 允许误报(false positives),但不允许漏报(false negatives),Complete 相反。

静态分析可以使用两个词概括:Abstraction + Safe-approximation(Transfer functions,Control flows)。首先对数据做抽象,然后对数据流做近似。近似涉及到 Transfer functions 如何设计和 Control flows 如何合并。Safe-approximation 的含义取决于使用场景,在大多数场景 Sound 就是 Safe-approximation,而小部分场景 Complete 才是。

问题

Q:关于静态分析,思考 What、Why、How?

Q:如何使用一句话概括静态分析?什么是有用的静态分析?

A:在确保 Safe-approximation 的前提下,在精度(precision)和速度(speed)之间权衡。

参考

Intermediate Representation

概念

编译器(Compiler),中间表示(Intermediate Representation,IR),三地址码(Three-Address Code,3AC),控制流图(Control Flow Graph,CFG),基本块(Basic Block,BB)。

问题

Q:如何基于 3AC 构建 BB?如何基于 BB 构建 CFG?

参考

Data Flow Analysis - Applications

概念

数据流分析的种类:may analysis(over-approximation),must analysis(under-approximation)。

数据流分析的应用:Reaching Definitions Analysis,Live Variables Analysis,Available Expressions Analysis。

问题

Q:为什么 Reaching Definitions Analysis 的算法最终会停止?

A:所有的 OUT 对应的 Bit Vector,它包含的 1 的数量都是单调递增的,最后必定会到达一个上界。

Q:为什么 Live Variables Analysis 使用 Backward Analysis,而不是 Forward Analysis?(算法设计更方便)

参考

Data Flow Analysis - Foundations

概念

不动点(Fixed point),偏序集(Partially ordered set),上确界和下确界(LUB and GLBJoin and meet),格(Lattice),不动点定理(Knaster–Tarski theorem),抽象解释(Abstract interpretation)。

Data Flow Analysis Domain Direction May/Must
Constant Propagation Analysis Set of pairs (variable, value) Forwards Must

问题

Q:什么是 Meet-Over-All-Paths Solution(MOP)?MOP 和 Iterative Algorithm 的精度有什么关系?

Q:为什么 Constant Propagation Analysis 的 Transfer function 是不可分配的(Non-distributivity)?

Q:如何理解可能存在多个不动点,以及 Iterative Algorithm 求到的是最小/最大不动点?为什么在 Constant Propagation Analysis 中,MOP 的精度大于 Iterative Algorithm 的精度?MOP 是否满足不动点定理?

A:对于函数 \(F\) 而言,可能有多个 \(x\) 使得 \(F(x)=x\),所以可能存在多个不动点。由于 Iterative Algorithm 满足不动点定理,对于特定的函数 \(F\),如果存在多个不动点,我们求到的必然是最小/最大不动点。MOP 和 Iterative Algorithm 的函数 \(F\) 不同,所以 MOP 的精度可以更大,不会违背不动点定理。MOP 应该是满足不动点定理的,因为 Transfer function 和 Join/Meet function 相比 Iterative Algorithm 没有变化,只是它们组成的函数 \(F\) 有变化。

参考

Interprocedural Analysis

问题

Q:什么是 CHA?如何基于 CHA 构建 Call Graph?

Q:如何实现 Interprocedural Constant Propagation Analysis?

参考

Pointer Analysis

问题

Q:Pointer Analysis 和 Alias Analysis 的区别?

A:Pointer Analysis 回答一个指针能指向哪些对象,Alias Analysis 回答两个指针是否能指向同一个对象。

Q:各个 Factor 的方案该如何选择,精度和速度的权衡?

A:课程选择 Allocation-site,Context-sensitive,Flow-insensitive,Whole-program。

参考

Pointer Analysis - Foundations

问题

Q:什么是 Pointer Flow Graph(PFG)?如何构建 PFG?

Q:为什么构建 PFG 和在 PFG 上传播指向信息,两个过程相互依赖?以及构建 PFG 和构建 Call Graph 相互依赖?

A:首先需要有 PFG,才能在 PFG 上传播指向信息。而 PFG 节点获得指向信息之后,可能会在 PFG 中添加边。主要原因是 PFG 使用抽象对象的字段 \(o_{i}.f\) 来表示实例字段 \(x.f\),其中 \(o_{i}\in pt(x)\),它是动态变化的。为什么不直接使用 \(x.f\) 作为节点,在最开始将所有边建好?个人理解,如果 \(x\) 和 \(y\) 的指向信息存在交集,当更新 \(x.f\) 时需要部分更新 \(y.f\),这样很难使用图上的算法来实现 Pointer Analysis,所以需要做更细粒度的划分。构建 PFG 和构建 Call Graph 相互依赖,也是取决于 Call Rule 的设计,从而在构建 Call Graph 的过程中会在 PFG 中添加边。

参考

Pointer Analysis - Context Sensitivity

问题

Q:为什么 Instance fields 不需要限定上下文?

A:明确限定上下文的作用是什么,区分对同一个方法的多次调用,也就是区分多次调用中的变量和对象。而对于 Instance fileds 不需要区分什么,它从属于某个对象,该对象已经被上下文限定。

参考

Static Analysis for Security

参考

实验作业

如何减少和检查代码的错误?

① 编写代码前:可以先使用注释梳理逻辑,确定算法步骤。重要!如果遗漏某种情况,之后找错会花费更多时间。遇到不清楚的地方要及时(使用 TODO)标出来,不要想当然。② 编写代码时:不要过早优化。在必要的地方添加断言。当找不到想要的 API 时可以查看继承关系,目标 API 可能在子类中。每完成一个功能,就编写相应的测试用例,尽早发现问题。③ 代码出错时:首先检查逻辑是否有误/遗漏,以及边界条件的处理是否有误/遗漏。然后检查 API 是否存在误用,例如对 API 的功能理解有误,或者误用其他类的相同名称的 API。最后检查文档表述是否存在歧义,或者不清楚的地方,从而在实现细节的理解上存在偏差。④ 找不到错时:如果可以的话,找到正确代码,然后对拍测试。休息一下,或许之后能找到。真的找不到,重写一遍,或许能够发现遗漏的地方。真的真的找不到,寄。

Java 的 ArrayDeque 不能存储 null,而 LinkedList 可以。Deque 的文档有说明:

While Deque implementations are not strictly required to prohibit the insertion of null elements, they are strongly encouraged to do so. Users of any Deque implementations that do allow null elements are strongly encouraged not to take advantage of the ability to insert nulls. This is so because null is used as a special return value by various methods to indicate that the deque is empty.

CSAPP 第 6 章 + OSTEP 持久性

存储技术

随机访问存储器

随机访问存储器(Random-Access Memory,RAM)分为两类:静态的和动态的。静态 RAM(SRAM)比动态 RAM(DRAM)更快,但也更贵。SRAM 通常用来作为高速缓存存储器,DRAM 通常用来作为主存以及图形系统的帧缓冲区。

静态 RAM

SRAM 将每个位存储在一个双稳态的(bistable)存储器单元里。每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同的电压配置(configuration)或状态(state)之一。由于 SRAM 存储器单元的双稳态特性,只要有电,它就会永远地保持它的值。即使有干扰(例如电子噪音)来扰乱电压,当干扰消除时,电路就会恢复到稳定值。

动态 RAM

DRAM 将每个位存储为对一个电容的充电。这个电容非常小,通常只有大约 \(30\times 10^{-15}\) 法拉(femtofarad)。DRAM 存储器单元对干扰非常敏感,当电容的电压被扰乱之后,它永远都不会恢复。内存系统必须周期性地通过读出,然后重写来刷新内存每一位。有些系统也使用纠错码,其中计算机的字会被多编码几位(例如 64 位的字可能用 72 位来编码),这样一来,电路可以发现并纠正一个字中任何单个的错误位。

非易失性存储器

如果断电,DRAM 和 SRAM 会丢失它们的信息,从这个意义上说,它们是易失的(volatile)。另一方面,非易失性存储器(nonvolatile memory)即使是在关电后,仍然保存着它们的信息。现在有很多种非易失性存储器。由于历史原因,虽然 ROM 中有的类型既可以读也可以写,但是它们整体上都被称为只读存储器(Read-Only Memory,ROM)。ROM 是以它们能够被重编程(写)的次数和对它们进行重编程所用的机制来区分的。

存储在 ROM 设备中的程序通常被称为固件(firmware)。当一个计算机系统通电以后,它会运行存储在 ROM 中的固件。一些系统在固件中提供了少量基本的输入和输出函数,例如 PC 的 BIOS(基本输入/输出系统)例程。复杂的设备,像图形卡和磁盘驱动控制器,也依赖固件翻译来自 CPU 的 I/O(输入/输出)请求。

总线事务

数据流通过称为总线(bus)的共享电子电路在处理器和 DRAM 主存之间来来回回。每次 CPU 和主存之间的数据传送都是通过一系列步骤来完成的,这些步骤称为总线事务(bus transaction)。读事务(read transaction)从主存传送数据到 CPU。写事务(write transaction)从 CPU 传送数据到主存。

总线是一组并行的导线,能携带地址、数据和控制信号。取决于总线的设计,数据和地址信号可以共享同一组导线,也可以使用不同的。同时,两个以上的设备也能共享同一总线。控制线携带的信号会同步事务,并标识出当前正在被执行的事务的类型。例如,当前事务是到主存还是到诸如磁盘控制器这样的其他 I/O 设备,事务是读还是写,总线上的信息是地址还是数据项。

图 6-6 展示了一个示例计算机系统的配置。主要部件是 CPU 芯片、我们将称为 I/O 桥接器(I/O bridge)的芯片组(其中包括内存控制器),以及组成主存的 DRAM 内存模块。这些部件由一对总线连接起来,其中一条总线是系统总线(system bus),它连接 CPU 和 I/O 桥接器,另一条总线是内存总线(memory bus),它连接 I/O 桥接器和主存。I/O 桥接器将系统总线的电子信号翻译成内存总线的电子信号。I/O 桥也将系统总线和内存总线连接到 I/O 总线,像磁盘和图形卡这样的 I/O 设备共享 I/O 总线。

磁盘存储

磁盘构造

磁盘是由盘片(platter)构成的。每个盘片有两面或者称为表面(surface),表面覆盖着磁性记录材料。盘片中央有一个可以旋转的主轴(spindle),它使得盘片以固定的旋转速率(rotational rate)旋转,通常是 5400 ~ 15000 转每分钟(Revolution Per Minute,RPM)。磁盘通常包含一个或多个这样的盘片,并封装在一个密封的容器内。

图 6-9a 展示了一个典型的磁盘表面的结构。每个表面是由一组称为磁道(track)的同心圆组成的。每个磁道被划分为一组扇区(sector)。每个扇区包含相等数量的数据位(通常是 512 字节),这些数据编码在扇区上的磁性材料中。扇区之间由一些间隙(gap)分隔开,这些间隙中不存储数据位。间隙存储用来标识扇区的格式化位。

磁盘是由一个或多个叠放在一起的盘片组成的,它们被封装在一个密封的包装里,如图 6-9b 所示。整个装置通常被称为磁盘驱动器(disk drive),我们通常简称为磁盘(disk)。有时,我们会称磁盘为旋转磁盘(rotating disk),以使之区别于基于闪存的固态硬盘(SSD),SSD 是没有移动部分的。

磁盘制造商通常用术语柱面(cylinder)来描述多个盘片驱动器的构造,这里,柱面是所有盘片表面上到主轴中心的距离相等的磁道的集合。例如,如果一个驱动器有三个盘片和六个面,每个表面上的磁道的编号都是一致的,那么柱面 k 就是 6 个磁道 k 的集合。

磁盘操作

磁盘用读/写头(read/write head)来读写存储在磁性表面的位,而读写头连接到一个传动臂(actuator arm)一端,如图 6-10a 所示。通过沿着半径轴前后移动这个传动臂,驱动器可以将读/写头定位在盘面上的任何磁道上。这样的机械运动称为寻道(seek)。一旦读/写头定位到了期望的磁道上,那么当磁道上的每个位通过它的下面时,读/写头可以感知到这个位的值(读该位),也可以修改这个位的值(写该位)。有多个盘片的磁盘针对每个盘面都有一个独立的读/写头,如图 6-10b 所示。读/写头垂直排列,一致行动。在任何时刻,所有的读/写头都位于同一个柱面上。

磁盘以扇区大小的块来读写数据。对扇区的访问时间(access time)有三个主要的部分:寻道时间(seek time)、旋转时间(rotational latency)和传送时间(transfer time)。

逻辑磁盘块

正如我们看到的那样,现代磁盘构造复杂,有多个盘面,这些盘面上有不同的记录区。为了对操作系统隐藏这样的复杂性,现代磁盘将它们的构造呈现为一个简单的视图,一个 B 个扇区大小的逻辑块的序列。磁盘封装中有一个小的硬件/固件设备,称为磁盘控制器,维护着逻辑块号和实际(物理)磁盘扇区之间的映射关系。当操作系统想要执行一个 I/O 操作时,例如读一个磁盘扇区的数据到主存,操作系统会发送一个命令到磁盘控制器,让它读某个逻辑块号。控制器上的固件执行一个快速表查找,将一个逻辑块号翻译成一个(盘面,磁道,扇区)的三元组,这个三元组唯一地标识了对应的物理扇区。控制器上的硬件会解释这个三元组,将读/写头移动到适当的柱面,等待扇区移动到读/写头下,将读/写头感知到的位放到控制器上的一个小缓冲区中,然后将它们复制到主存中。

局部性原理

局部性通常有两种不同的形式:时间局部性(temporal locality)和空间局部性(spatial locality)。在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来再被多次引用。在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。

存储器层次结构

存储器层次结构的中心思想是,对于每个 k,位于 k 层的更快更小的存储设备作为位于 k + 1 层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层都缓存来自较低一层的数据对象。数据总是以块大小为传送单元(transfer unit)在第 k 层和第 k + 1 层之间来回复制的。虽然在层次结构中任何一对相邻的层次之间块大小是固定的,但是其他的层次对之间可以有不同的块大小。

高速缓存存储器

早期计算机系统的存储器层次结构只有三层:CPU 寄存器、DRAM 主存储器和磁盘存储。不过,由于 CPU 和主存之间逐渐增大的差距,系统设计者被迫在 CPU 寄存器文件和主存之间插入 SRAM 高速缓存存储器。

缓存不命中的种类:强制性不命中/冷不命中、冲突不命中和容量不命中。

高速缓存的种类:直接映射高速缓存、组相联高速缓存和全相联高速缓存。

写的策略:直写 + 非写分配、后写 + 写分配。

组织结构

考虑一个计算机系统,其中每个存储器地址有 \(m\) 位,形成 \(M=2^{m}\) 个不同的地址。如图 6-25a 所示,这样一个机器的高速缓存被组织成一个有 \(S=2^{s}\) 个高速缓存组(cache set)的数组。每个组包含 \(E\) 个高速缓存行(cache line)。每个行是由一个 \(B=2^{b}\) 字节的数据块(block)组成的,一个有效位(valid bit)指明这个行是否包含有意义的信息,还有 \(t=m-(b+s)\) 个标记位(tag bit)(是当前块的内存地址的位的一个子集),它们唯一地标识存储在这个高速缓存行中的块。

CSAPP 第 8 & 9 章 + OSTEP 虚拟化

异常控制流 + CPU 虚拟化

从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列 \(a_{0},a_{1},\cdots,a_{n-1}\) 其中,每个 \(a_{k}\) 是某个相应的指令 \(I_{k}\) 的地址。每次从 \(a_{k}\) 到 \(a_{k+1}\) 的过渡称为控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流(flow of control,control flow)。

最简单的一种控制流是一个“平滑的”序列,其中每个 \(I_{k}\) 和 \(I_{k+1}\) 在内存中都是相邻的。这种平滑流的突变(也就是 \(I_{k+1}\) 与 \(I_{k}\) 不相邻)通常是由诸如跳转、调用和返回这样一些熟悉的程序指令造成的。这样一些指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化做出反应。

但是系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如,一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器后,必须存放在内存中。程序向磁盘请求数据,然后休眠,直到被通知说数据已就绪。当子进程终止时,创造这些子进程的父进程必须得到通知。

现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制流(Exceptional Control Flow,ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

异常

异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。因为它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。然而,对于每个系统而言,基本的思想都是相同的。

异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。图 8-1 展示了基本的思想。在图中,当处理器状态中发生一个重要的变化时,处理器正在执行某个当前指令 \(I_{curr}\)。在处理器中,状态被编码为不同的位和信号。状态变化称为事件(event)。事件可能和当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除以零。另一方面,事件也可能和当前指令的执行没有关系。比如,一个系统定时器产生信号或者一个 I/O 请求完成。

在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的异常处理程序(exception handler)。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下 3 种情况中的一种:

  • 处理程序将控制返回给当前指令 \(I_{curr}\),即当事件发生时正在执行的指令。
  • 处理程序将控制返回给 \(I_{next}\),如果没有发生异常将会执行的下一条指令。
  • 处理程序终止被中断的程序。

异常处理

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部 I/O 设备的信号。

在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目 k 包含异常 k 的处理程序的地址。图 8-2 展示了异常表的格式。在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目 k,转到相应的处理程序。图 8-3 展示了处理器如何使用异常表来形成适当的异常处理程序的地址。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊 CPU 寄存器里。

异常分类

中断

中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。

陷阱

陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用

故障

故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序。例如:缺页异常。

终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。处理程序将控制返回给一个 abort 例程,该例程会终止这个应用程序。

进程

进程就是运行中的程序。最简单的方式是一次只运行一个进程,等到它运行完成之后再运行下一个。但是,通常人们希望同时运行多个进程。解决方案是,使用时分共享 CPU 技术,并发地运行多个进程。要高效、可控的实现时分共享,需要使用操作系统来对进程进行管理,而操作系统进行管理的基础就是需要始终保留对 CPU 的控制权。如果没有控制权,进程可以无限制的使用 CPU,访问任意地址。使用异常机制结合用户/内核模式,使得 CPU 的控制权总会在特定的情况从进程转移给操作系统,从而能够高效、可控的实现时分共享。

创建进程

要运行可执行目标文件 prog,我们可以在 Linux shell 的命令行中输入它的名字 ./prog。因为 prog 不是一个内置的 shell 命令,所以 shell 会认为 prog 是一个可执行目标文件,shell 进程会生成一个子进程,它是父进程的一个复制。子进程通过 execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。

最后,加载器跳转到程序的入口点,也就是 _start 函数的地址。这个函数是在系统目标文件 ctrl.o 中定义的,对所有的 C 程序都是一样的。_start 函数调用系统启动函数 __libc_start_main,该函数定义在 libc.so 中。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回给内核。

除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制,直到 CPU 引用一个被映射的虚拟页时才会进行复制。此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

Linux 系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。在 Linux x86-64 系统中,代码段总是从地址 0×400000 处开始,后面是数据段。运行时堆在数据段之后,通过调用 malloc 库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址(\(2^{48}-1\))开始,向较小内存地址增长。栈上的区域,从地址 \(2^{48}\) 开始,是为内核(kernel)中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分。

上图只是简化图。实际上,由于.data 段有对齐要求,所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布局随机化(ASLR)。虽然每次程序运行时这些区域的地址都会改变,但它们的相对位置是不变的。

用户模式和内核模式

为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。

处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个 I/O 操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。

运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

上下文切换

内核为每个进程维持一个上下文(context),上下文就是内核重新启动一个被抢占的进程所需的状态。包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在较低层异常机制之上的。在进程执行的某些时刻,内核可以决定抢占当前进程,并使用上下文切换来将控制转移到新的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。上下文切换的步骤:① 保存当前进程的上下文,② 恢复某个先前被抢占的进程被保存的上下文,③ 将控制传递给这个新恢复的进程。

图 8-14 展示了一对进程 A 和 B 之间上下文切换的示例。在这个例子中,进程 A 初始运行在用户模式中,直到它通过执行系统调用 read 陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的 DMA 传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。

调度策略

调度指标:

  • 周转时间:\(T_{周转时间}=T_{完成时间}-T_{到达时间}\)。(性能指标)
  • 响应时间:\(T_{响应时间}=T_{首次运行}-T_{到达时间}\)。(公平指标)

护航效应(convoy effect),一些耗时较少的潜在资源消费者被排在重量级的资源消费者之后,使得平均周转时间增大。

如果你愿意不公平,你可以运行较短的工作直到完成,但是要以响应时间为代价。如果你重视公平性,则响应时间会较短,但会以周转时间为代价。

先进先出(FIFO)

最短任务优先(SJF)

最短完成时间优先(STCF)

时间片轮转(RR)

时间片越短响应时间越好,但是时间片太短会导致频繁的上下文切换,从而影响系统的整体性能。因此,系统设计者需要权衡时间片的长度,使其足够长,以便摊销(amortize)上下文切换成本,而又不会使系统不及时响应。

请注意,上下文切换的成本不仅仅来自保存和恢复少量寄存器的操作系统操作。程序运行时,它们在 CPU 高速缓存、TLB、分支预测器和其他片上硬件中建立了大量的状态。切换到另一个工作会导致此状态被刷新,且与当前运行的作业相关的新状态被引入,这可能导致显著的性能成本。

多级反馈队列(MLFQ)

关键问题:没有工作长度的先验(priori)知识,如何设计一个能同时减少响应时间和周转时间的调度程序?

MLFQ 中有许多独立的队列(queue),每个队列有不同的优先级(priority level)。任何时刻,一个工作只能存在于一个队列中。MLFQ 总是优先执行较高优先级的工作(即在较高级队列中的工作)。当然,每个队列中可能会有多个工作,因此具有同样的优先级。在这种情沉下,我们就对这些工作采用轮转调度。

因此,MLFQ 调度策略的关键在于如何设置优先级。MLFQ 没有为每个工作指定不变的优先顺序,而是根据观察到的行为调整它的优先级。例如,如果一个工作不断放弃 CPU 去等待键盘输入,这是交互型进程的可能行为,MLFQ 因此会让它保持高优先级。相反,如果一个工作长时间地占用 CPU,MLFQ 会降低其优先级。通过这种方式,MLFQ 在进程运行过程中学习其行为,从而利用工作的历史来预测它未来的行为。

  • 规则 1:如果 A 的优先级 > B 的优先级,运行 A(不运行 B)。
  • 规则 2:如果 A 的优先级 = B 的优先级,轮转运行 A 和 B。
  • 规则 3:工作进入系统时,放在最高优先级(最上层队列)。
  • 规则 4:一旦工作用完其在某一层中的时间配额(无论中间主动放弃了多少次 CPU),就降低其优先级(移入低一级队列)。
  • 规则 5:经过一段时间 \(S\),就将系统中所有工作重新加入最高优先级队列。

关于 MLFQ 调度算法还有一些问题,其中一个大问题是如何配置一个调度程序。例如:配置多少队列?每一层队列的时间片配置多大?为了避免饥饿问题以及进程行为改变,应该多久提升一次进程的优先级?这些问题都没有显而易见的答案,因此只有利用对工作负载的经验,以及后续对调度程序的调优,才会导致令人满意的平衡。

虚拟内存 + 内存虚拟化

从内存层面来说,实现时分共享最简单的方式是,进程独占内存的使用,切换时将其保存到磁盘中,然后加载另一个进程。为了提高效率,我们让多个进程同时使用内存,但是如何实现进程内存之间的隔离呢?所以引入虚拟内存的概念,让操作系统管理内存实现虚拟化。

虚拟寻址

计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address,PA)。使用虚拟寻址时,CPU 通过生成一个虚拟地址(Virtual Address,VA)来访问主存。之后 CPU 上被称为内存管理单元(Memory Management Unit,MMU)的专用硬件,利用存放在主存中的查询表来动态的将虚拟地址翻译为物理地址,该表的内容由操作系统管理。

页面缓存

概念上而言,虚拟内存被组织为一个由存放在磁盘上的 N 个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。VM 系统通过将虚拟内存分割为固定大小的虚拟页(Virtual Page,VP),每个虚拟页的大小为 \(P=2^{p}\) 字节。类似地,物理内存被分割为物理页(Physical Page,PP),大小也为 \(P\) 字节。

虚拟页面的集合被分为三个不相交的子集:

  • 未分配的:VM 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。

  • 已缓存的:已缓存在物理内存中的已分配页。

  • 未缓存的:未缓存在物理内存中的已分配页。

图 9-3 的示例展示了一个有 8 个虚拟页的小虚拟内存。虚拟页 0 和 3 还没有被分配,因此在磁盘上还不存在。虚拟页 1、4 和 6 被缓存在物理内存中。页 2、5 和 7 已被分配,但是当前并未缓存在主存中。

页表

同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在 DRAM 中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到 DRAM 中,替换这个牺牲页。

这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和存放在物理内存中被称为页表(page table)的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与 DRAM 之间来回传送页。

图 9-4 展示了一个页表的基本组织结构。页表就是一个页表条目(Page Table Entry,PTE)的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个 PTE。目前,我们将假设每个 PTE 是由一个有效位(valid bit)和一个 n 位地址字段组成的。有效位表明该虚拟页当前是否被缓存在 DRAM 中。如果设置了有效位,那么地址字段就表示 DRAM 中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个 null 地址表示这个虚拟页还未被分配,否则这个地址就指向该虚拟页在磁盘上的起始位置(即已分配但未缓存)。

缺页

在虚拟内存的习惯说法中,DRAM 缓存不命中称为缺页(page fault)。图 9-6 展示了在缺页之前我们的示例页表的状态。CPU 引用了 VP3 中的一个字,VP3 并未缓存在 DRAM 中。地址翻译硬件将虚拟地址作为索引来定位 PTE3,并从内存中读取它。从有效位推断出 VP3 未被缓存,从而触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在 PP3 中的 VP4。如果 VP4 已被修改,那么内核会将它复制回磁盘。无论哪种情况,内核都会修改 VP4 的页表条目,反映出 VP4 不再缓存在主存中这一事实。

之后,内核从磁盘复制 VP3 到内存中的 PP3,更新 PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3 已被缓存在主存中,那么页命中也能由地址翻译硬件正常处理。图 9-7 展示了在缺页之后我们的示例页表的状态。

内存管理

到目前为止,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。实际上,操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。图 9-9 展示了基本思想。注意,多个虚拟页面可以映射到同一个物理页面上(页面共享)。

内存保护

任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。不应该允许一个用户进程修改它的只读代码段。而且也不应该允许它读或修改任何内核中的代码和数据结构。不应该允许它读或者写其他进程的私有内存,并且不允许它修改任何与其他进程共享的虚拟页面,除非所有的共享者都显式地允许它这么做(通过调用明确的进程间通信系统调用)。

每次 CPU 生成一个虚拟地址时,地址翻译硬件都会读一个 PTE,所以可以在 PTE 上添加一些额外的许可位来控制对一个虚拟页面内容的访问。图 9-10 展示了基本思想。在这个示例中,每个 PTE 中添加了三个许可位。SUP 位表示进程是否必须运行在内核(超级用户)模式下才能访问该页。READ 位和 WRITE 位控制对页面的读写访问。如果一条指令违反了这些许可条件,那么 CPU 就触发一个一般保护故障,将控制传递给一个内核中的异常处理程序。Linux shell 一般将这种异常报告为“段错误(segmentation fault)”。

地址翻译

CPU 中的一个控制寄存器,页表基址寄存器(Page Table Base Register,PTBR)指向当前页表。\(n\) 位的虚拟地址包含两个部分:一个 \(p\) 位的虚拟页面偏移(Virtual Page Offset,VPO)和一个 \((n - p)\) 位的虚拟页号(Virtual Page Number,VPN)。MMU 利用 VPN 来选择适当的 PTE。将页表条目中物理页号(Physical Page Number,PPN)和虚拟地址中的 VPO 串联起来,就得到相应的物理地址。

快表

每次 CPU 产生一个虚拟地址,MMU 就必须查阅一个 PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。如果 PTE 碰巧缓存在 L1 中,那么开销就下降到 1 个或 2 个周期。然而,许多系统都试图消除即使是这样的开销,它们在 MMU 中包括了一个关于 PTE 的小缓存,称为快表(Translation Lookaside Buffer,TLB)。

多级页表

到目前为止,我们一直假设系统只用一个单独的页表来进行地址翻译。但是如果我们有一个 32 位的地址空间、4KB 的页面和一个 4 字节的 PTE,那么即使应用所引用的只是虚拟地址空间中很小的一部分,也总是需要一个 4MB 的页表驻留在内存中。对于地址空间为 64 位的系统来说,问题将变得更复杂。使用多级页表,时间换空间。