虚拟内存系统(一)—— 从物理到虚拟

在编译程序的汇编代码的时候,使用了很多内存位置。可是一台计算器只有一个物理内存,编译器怎么确保编译时使用的内存地址不会和其它程序冲突呢?
实际上,每个程序完全不用担心自己使用的内存地址和其它程序产生冲突,因为它们所使用的内存地址都是专属于自己的虚拟地址,在实际寻址的过程中再由操作系统翻译成真正的物理内存地址。
也就是说,虽然物理内存只有一个,但是被划分为多个彼此互不干扰的虚拟内存空间。
那这是如何实现的呢?虚拟内存空间寻址又是如何转换成物理内存空间寻址的呢?

一、总设计思路

为了把一个大小为 N 的物理内存空间分配给若干个大小为 M(M > N) 的虚拟内存空间,设计者利用了这样一个事实:虽然电脑上同时运行着多个进程,但是同一时刻不是所有进程都要求立刻使用满虚内存空间的。于是,虚拟内存空间实际都被分配在硬盘上,而物理内存空间被当做了虚拟内存空间的一个高速缓存。
当程序向虚拟内存空间寻址时,首先确认这个地址有没有被缓存到物理内存空间。如果缓存了,就把它翻译成物理内存空间的地址,对其进行读写操作。如果没缓存,就从磁盘上找到它,再把它加载到物理内存空间上,记录好虚拟内存地址和物理内存的地址,然后对物理内存进行读写。如果把它加载到物理内存时,物理内存空间满了,就把物理内存空间中一些地址的值写入到磁盘上,把这些物理内存地址空出来给它使用。
为了提高缓存的效率,我们可以提高缓存的最小单元。虚拟内存空间和物理内存空间就被分割成了一个一个缓存单元大小的块,称之为。这个最小单元大小,被称之为页大小。把内存空间划分为页大小的行为,叫做分页。虚拟内存页->物理内存页以及虚拟内存页->磁盘位置的对应关系,记录在分页表中。利用分页表从虚拟内存地址获取物理内存地址的过程被称为地址翻译。在物理页中找到了虚拟页的现象被称为页命中,在物理页中没有找到虚拟页的现象被称为缺页。从磁盘读取数据到物理页或者从物理页写数据到磁盘的行为被称为页面调度,而把物理页写到磁盘以腾出空间再把虚拟页缓存到物理页的过程被称为换页。换页涉及两个页面调度操作,属于比较耗时的行为。程序在运行过程中频繁出现换页行为的现象被称为抖动。很显然,抖动非常影响程序的性能。

好了,我们已经知道虚拟内存空间运作的总体设计了,接下来我们逐个来看看具体的细节。

二、对内存空间的分页行为

2.1 内存空间的大小

物理内存空间大小显然由硬件决定。
虚拟内存空间大小在实际上受物理内存空间(作为其缓存)和磁盘空间(作为其载体)的限制,但是理论上它的上限由操作系统的字大小决定。因为操作系统使用内存地址来读写内存空间中的数据,而内存地址作为一个程序变量有它的取值范围。32 位操作系统能使用的最大值是 0xffffffff,这也是内存地址能使用的最大值。这限定了虚拟内存空间的上限为 2 ^ 32 字节,即 4 GB。

2.2 确定页大小

为了把内存空间分页,首先要确定的就是页大小。页大小总是 2 的 N 次方。那么页大小到底是多大呢
页越大,每一个缓存操作覆盖的虚拟内存地址范围就越大,读写内存时页命中的概率就越高。但是每次缓存操作拿来的数据不一定就是马上就需要的,如果也把它们缓存起来,就浪费了物理内存空间。如果物理内存空间不够大的话,就会导致更多的换页行为。所以页大小具体取多大是一个可以配置的变量,一般和物理内存空间大小成正比。推荐的范围是 4 kb ~ 2 mb。
虚拟内存页大小和物理内存页大小都一样的,一个物理页缓存一个虚拟页。
对于一个大小为 M 且 M = 2 ^ m 的虚拟内存空间,假定其页面大小为 P 且 P = 2 ^ p。那么虚拟内存空间就被分成了 m - p 个分页。而对于任何虚拟内存地址 D,其 m 位二进制表示的高 m-p 位可以理解为该内存地址所在的分页,称之为 VPN(virtual page number)。其低 p 位可以理解为该内存地址在其分页中的偏移,称之为 VPO(virtual page offset)。
对于一个大小为 N 且 N = 2 ^ n 的物理内存空间,其页面大小也是 P。此时有 n - p 个物理页面。对于地址 D 来说,其低 p 位仍然代表 D 在物理页中的偏移(PPO)。而其所在的物理页,则由 D 的高 n - p 位决定(PPN)。
这为我们把虚拟地址翻译成物理地址提供了思路。VPO = PPO,只要能由 VPN 找到 PPN 就好了。

2.3 分配页面

刚才说,虚拟内存保存在磁盘上,缓存在物理内存上。这个说法需要一点修正。虚拟内存保存在磁盘上,但是并不是每创建一个虚拟内存空间,就在磁盘上分配了那么大的空间来保存虚拟内存分页。
操作系统执行一个程序时,会加载其可执行文件和动态链接库文件,把其中的代码和数据映射到内存空间。对于这些代码和数据,操作系统把它们所在的虚拟空间分页关联到它们所在的磁盘文件位置上,并设置为一个未缓存的状态。对于那些还未使用的虚拟空间分页,操作系统不会立马就把它们和磁盘空间关联起来,而是设置为一个未分配的状态。程序开始运行时,会发现一个分页是未缓存或者未分配的状态,触发页面调度操作。对于前者,操作系统会把这个分页缓存到物理页面中并把状态改为已缓存,对于后者则为它分配一个物理页并把状态改为已缓存。这种只有到缺页时才分配页面的行为,被称之为按需页面调度

三、分页表

分页表是从虚拟地址找到物理地址的关键。

3.1 分页表条目

每一个虚拟分页在分页表中都有一个条目(PTE, page table entry),第 N 个分页的记录保存在分页表的第 N 项。虚拟地址在哪个分页,可以由其地址值的高 m - p 位得知。
PTE 主要有三个部分:标记位、许可位和地址值。标记位用来区别该条目对应的虚拟页是否被缓存了,它也会影响地址值的解释方式。
如果标记位为 1,说明该虚拟页已缓存,地址值记录的是物理地址。如果标记位为 0,且地址值不为空,说明该虚拟页未缓存,地址值是磁盘位置。如果标记位为 0 且地址值为空,则说明该虚拟页未分配。
许可位包含三个标记:SUP、READ 和 WRITE。进程在用户模式下只能访问 SUP 为 0 的分页,而在内核模式下可以访问任何页面。READ 和 WRITE 标志是否可以读和写。如果一条指令尝试访问不允许访问的页面,或者尝试进行不允许的操作,就会触发 Segmentation Fault。

3.2 分页表大小

对于一个 32 位操作系统来说,如果使用 4 kb 的分页大小,那就需要 2 ^ 20 个 PTE。每个 PTE 有四个 flag 和一个地址值,总共 1 字节加 4 字节,对其要求是 4 字节,因此每个 PTE 需要 8 字节。那么分页表的大小就为 8 * 2 ^ 20 字节,即 8 MB。而对于一个 64 位操作系统来说,使用 4kb 的分页大小,就需要 2 ^ 52 个 PTE,每个 PTE 需要 16 字节,分页表总大小为 64 PB !这不可能。
况且,既然要通过分页表才能从虚拟地址找到物理地址,那分页表本身就必须从进程开始就被加载并常驻物理内存中。如果连分页表自己都要通过分页表去寻址,那不就是一个死循环吗?
所以必须想办法减小分页表的大小
怎么做呢?最直接的办法是增加分页的大小,从而减少 PTE 的数量。如果我们把分页大小增加到 4 MB,那 32 位操作系统的分页表大小就只有 8 KB 了,而 64 位操作系统的大小是 2 ^ 16 GB。emmmm… 这看起来还是不理想。如果继续增加分页大小到… 4 GB… 这当然行不通
这个时候,分层思想又来帮忙了。使用分级的分页表,原本一个一级分页表里面的 PTE 指向一个内存页记录,但是我们可以把它设定为指向二级子分页表。怎么实现呢?原本地址的高 m - p 位是 VPN,我们只需要 VPN 的高 x 位作为第一级分页表的 PPN。假如 m - p 的值是 10,原本需要 2 ^ 10 共 1024 个 PTE。我们可以只用 4 位作为第一级分页表的 VPN,此时只需要 2 ^ 4 共 16 个 PTE,而每一个 PTE 指向一个二级页表。10 位中剩下的 6 位作为二级页表中的偏移,因此二级页表需要 2 ^ 6 共 64 个 PTE,每个 PTE 记录一个 VA 到 PA 的映射。只有一级分页表需要常驻内存,二级(甚至更多级)可以需要时再加载,因此节省了大量的内存。不过,这样似乎只是减少了常驻内存的页表大小,所有层级的页表加起来的总大小并没有减少对不对?非也。对于一级分页表,如果其中某个 PTE 是未分配的状态,那其对应的第二级分页表就不需要被创建出来。而在实际工程中,总是有大量的未分配页的,能够使用 2 ^ 64 字节内存的程序少之又少,而且早就超出了硬件和磁盘的支持上限。这样,分页表的总体积就大大减少了。
但是使用多级分页表会降低性能,为了减少对性能的损耗,又有其它的页表缓存手段(TLB)被设计出来了。使用 TLB 后,使用多级页表的性能表现并没有比一级页表低多少了。
在下一节中我们介绍 TLB。

四、虚拟地址寻址

每个 CPU 都有个 MMU(Memory Management Unit) 负责虚拟地址寻址。

4.1 利用页表查询 PPN

之前已经提到,物理分页和虚拟分页大小都是 P(2 ^ p) 次方。对于 m 位虚拟地址 V,其地址的高 (m - p) 位用做 PTE 在分页表中的编号(VPN),其地址的低 p 位作为虚拟地址在分页中的偏移(VPO)。VPO 和 PPO 相等,从虚拟地址找到物理地址的关键在于从 VPN 找到 PPN,而这一对应关系保存在分页表中。
在一级分页表情况下,PTE 就是分页表中的第 VPN 项,PTE 记录着 PPN 的值。
在多级分页表的情况下,最后一级分页表中的 PTE 记录着 PPN 的值,其它各级的 PTE 记录着下一级分页表的虚拟地址。VPN 本身也被划分成多个段位,从左到右数的第 N 个段位的值作为第 N 级分页表的 PPN。
如果 PTE 中没有记录 PPN,就会触发缺页故障,从而开始相应的处理。此时操作系统会判断虚拟地址是否合法以及对地址的操作是否合法,如果任何一个不合法,就出抛出 Segmentation Fault 并终止程序。如果都合法,则把页面换入。
那怎么知道一级分页表的地址的呢?一级页表的物理地址保存在寄存器里面。那如何从二级页表的虚拟地址找到 PTE?会不会二级页表的虚拟地址处于未分配状态?不会,因为必然是先有二级页表(和其地址),才会有其在一级页表中的记录。

4.2 利用 TLB 缓存加速查询 PPN

在 N 级页表的情况下,每个虚拟地址都要有 N 次虚拟地址到物理内存地址的查询,其中前 N - 1 次是从后 N - 1 级分页表中查询下一级分页表地址的操作,最后一次才是查到物理地址的操作。为了减少查询次数,TLB 缓存了近期的 VPN 和 PPN 的对应关系。由于程序代码的局部性,对虚拟地址的寻址操作通常都集中在接近的虚拟地址范围内,而这个范围也通常包括在一个页大小之内。因此,TLB 缓存一个 VPN 到 PPN 查询的结果,就能加速多个内存寻址操作
那 TLB 缓存是如何设计的呢?
可以把 TLB 理解成一个二维表格,这个表格有 2 ^ t 行,每行有 C 列,每个格子保存有三个值,分别是 TLBT、PPN 和有效位 flag。假设 VPN 共有 s 位,TLB 利用 VPN 的低 t 位 r 作为行索引(TLBI),根据 r 找到所在行后,逐个去判断每个格子。如果格子的 TLBT 和 VPN 的高 s - t 位相同,则判断格子的有效位是否为 1,如果是,则缓存命中,返回格子中的 PPN。
所以,当 CPU 需要寻址某个虚拟地址时,MMU 首先利用 TLB 缓存查找 PPN,如果缓存命中,则使用 PPN 加 VPO 得到物理地址。如果缓存未命中,则再使用分页表查询 PPN。

4.3 利用 L1 缓存加速物理地址取值

当 MMU 从虚拟地址获取到物理地址后,在去物理内存取值之前,会首先尝试在 L1 缓存中查找。L1 缓存也被设计得物理内存地址接近的值保存在一起,因为接近的地址通常也会集中地被读写。
L1 缓存是如何设计得呢?
和 TLB 类似,L1 缓存被设计为一个二维的表格。表格有 2 ^ t 行,如果 PPO 有 p 位,则每行有 2 ^ (p - t) + 2 列。对于每行,其前两格分别为 CT 和有效位 flag,后面 2 ^ (p - t) 格每格保存一个字节的值。对于物理地址 D,以其 PPO 的高 t 位 r 作为行索引(CI)。以 r 找到所在行后,对比 PPN 和 CT,如果相等,则对比有效位 flag。如果有效位 flag 也是 1,则再利用 PPO 的低 p - t 位作为偏移,查找该行的第 2 + p - t 格,以其值作为地址 D 的首字节值。
当然,在 L2 和 L3 中也可以做缓存来加速物理地址取值。

五、总结

虚拟内存空间保存在磁盘上,以物理内存空间作为其缓存。为了提高缓存的效率,内存空间被划分成等额大小的页,缓存以页作为基本单位。
虚拟内存页只有在需要时才会被加载到物理内存页上,如果物理内存空间不够,虚拟页会被写出到磁盘上以腾出空间。这种按需求来把虚拟页加载或者写出的行为被称为按需页面调度。
分页表被用来记录虚拟页到物理页的映射。分页表需要常驻内存,为了减少分页表的体积,多级分页表被设计出来。只有第一级和常用的第二级分页表常驻内存。
为了减少使用分页表时的虚拟地址转换次数,TLB 被用来缓存 VPN 和 PPN 的映射。
在获得物理地址后,会首先尝试从高速缓存中获取数据,再尝试从物理地址中获取数据。