在早期人们使用 DOS 或者更古老的操作系统的时候,序的规模很小,虽然那时候物理内存(Physical Memory)也很小,但这样的物理内存可以容纳下当时的程序但是随着图形界面的兴起,以及用户需求的不断增大,应用程序的规模也越来越大,于是就有一个难题出现了:应用程序太大以至于物理内存已经无法容纳下这样的程序了,于是通常的做法就是把程序分割成许多的片段,片段 0首先放到物理内存中执行,当它执行完时调用下一个片段,例如片段 1,虽然片段在物理内存中的交换是操作系统完成的但是程序员必须先把程序进行分割,这是一个费时费力的工作,相当枯燥,需要更好的方法来解放人力,于是就有了虚拟存储器(Virtual Memory)。它的基本思想是对于一个程序来说它的程序(code)数据(data)和堆栈(stack)的总大小可以超过实际物理内存的大小,操作系统把当前使用的部分内容放到物理内存中,而把其他未使用的内容放到更下一级存储器,如硬盘(disk)或闪存(flash)上。举例来说,一个大小为 32MB 的程序运行在物理内存只有 16MB 的机器上操作系统通过选择,决定各个时刻将程序中一部分16MB 的内容(或者更小)放在物理内存中而将其他的内容放到硬盘中,并在需要的时候在物理内存和硬盘之间交换程序片段,这样就可以把大小为 32MB 的程序放到物理内存为 16MB 的机器上运行,而且在运行之前也不需要程序员对程序进行分割。(占很多空间的程序要在小内存上运行,由此引出虚拟存储器)
有了上面的概念,此时就可以说,一个程序是运行在虚拟存储器空间的,它的大小由处理器的位数决定,例如对于一个 32 位处理器来说,其地址范围就是0~0XFFFF FFFF,也就是4GB;而对于一个64 位处理器来说,其地址范围就是0~0xFFFF FFFF FFFF FFFF这个范围就是程序能够产生的地址范围,其中的某一个地址就称为虚拟地址。和虚拟存储器相对应的就是物理存储器,它是在现实世界中能够直接使用的存储器,其中的某一个地址就是物理地址。物理存储器的大小不能够超过处理器最大可以寻址的空间,例如,对于32位的 x86 PC来说它的物理存储器(一般都简称为内存)可以是256MB即PM的范围是0~0xFFF FFFF,当然,也可以将物理内存增加到4GB,此时虚拟存储器和物理存储器这两个地址空间的大小就是相同的,在当前使用的 32 位 x86 PC 中不可能使用比4GB 更大的物理内存了。
在没有使用虚拟地址的系统中,处理器输出的地址会直接送到物理存储器中,这个过程如图 3.1所示。
而如果使用了虚拟地址,则处理器输出的地址就是虚拟地址了,这个地址不会被直接送到物理存储器中,而是需要先进行地址转换,因为虚拟地址是没有办法直接寻址物理存储器的,负责地址转换的部件一般称为内存管理单元(Memory Manage Unit,MMU),如图3.2所示。
使用虚拟存储器不仅可以便于程序在处理器中运行,还可以给程序的编写带来好处,在直接使用物理存储器的处理器中,如果要同时运行多个程序,就需要为每个程序都分配一块地址空间,每个程序都需要在这个地址空间内运行,这样极大地限制了程序的编写,而且不能够使处理器随便地运行程序,这样的限制对于应用领域比较专一的嵌人式系统来说,是没有问题的,例如一个 MP3 播放器,事先需要运行什么软件实现什么功能都是预先定义好的,因此可以直接使用物理地址;而对于扩展程度很高的复杂系统例如 PC 或者智能手机,需要能够随意地安装软件,显然是无法事先限制软件运行的地址空间的,这时候就需要虚拟存储器了。使用它之后,每个程序总是认为它占有处理器的所有地址空间,因此程序可以任意使用处理器的地址资源,这样在编写程序的时候,不需要考地址的限制,每个程序都认为处理器中只有自己在运行,当这些程序真正放到处理器中运行的时候,由操作系统负责调度,将物理存储器动态地分配给各个程序,将每个程序的虚拟地址转化为相应的物理地址,使程序能够正常地运行。
通过操作系统动态地将每个程序的虚拟地址转化为物理地址,还可以实现程序的保护。即使两个程序都使用了同一个虚拟地址,它们也会对应到不同的物理地址,因此可以保证每个程序的内容不会被其他的程序随便改写。而且,通过这样的方式,还可以实现程序间的共享,例如操作系统内核提供了打印(printf)函数,第一个程序在地址A使用了 printf 函数,第二个程序在地址 B使用了 printf 函数操作,系统在地址转换的时候,会将地址 A 和 B都转换为同样的物理地址,这个物理地址就是 printf 函数在物理存储器中的实际地址,这样就实现了程序的共享,虽然两个程序都使用了 printf 函数但是没必要真的使 printf 函数占用物理存储器的两个地方,因此,使用虚拟存储器不仅可以降低物理存储器的容量需求,它还可以带来另外的好处,如保护(protect)和共享(share)。
可以说,如果一个处理器要支持现代的操作系统,就必须支持虚拟存储器,它是操作系统一个非常重要的内容,本章重点从硬件层面来讲述虚拟存储器,涉及到页表(PageTable)程序保护和 TLB 等内容。
虚拟存储器从最开始出现一直到现在,有很多种实现的方式,本节只讲述目前最通用的方式一一基于分页(page)的虚拟存储器。当前大多数的处理器都使用了这种分页机制,虚拟地址空间的划分以页(page)为单位,典型的页大小为 4KB,相应的物理地址空间也进行同样大小的划分,由于历史的原因,在物理地址空间中不叫做页,而称为 frame,它和页的大小必须相等,例如,它们的典型值都是 4KB。当程序开始运行时,会将当前需要的部分内容从硬盘中搬移到物理内存中,每次搬移的单位就是一个页的大小,因此页和 frame 的大小也就必须相等了。由于只有在需要的时候才将一个页的内容放到物理内存中,这种方式就称为 demand page,它使处理器可以运行比物理内存更大的程序。对于一个虚拟地址 VA来说,VA[11:0]用来表示页内的位置,称为 page offset,VA 剩余的部分用来表示哪个页,也称为虚页号 VPN(Virtual Page Number)。相应的,对于一个物理地址 PA来说,PA[11:0]用来表示frame内的位置,称为 frame offset,而PA剩余的部分用来表示哪个frame,也称为 PFN(Physical Frame Number)。由于页和frame 的大小是一样的所以从VA到PA的转化实际上也就是从 VPN到 PFN的转化,offset 的部分是不需要变化的。
下面通过一个例子来说明虚拟地址到物理地址的转化过程。
在图 3.3 所示的例子中假设处理器是 16 位的,则它的虚拟地址范围是0~0xFFFF共64KB,页的大小是4KB,因此64KB 的虚拟地址包括了16 个页,即16个VPN;而这个系统中物理内存只有 32KB,它包括了8个PFN。现在有一个程序,它的大小大于32KB这个程序在执行的时候,不能够一次性调入内存中运行,因此这台机器必须有一个可以存放这个程序的下一级存储器(例如硬盘或者闪存),以保证程序片段在需要的时候可以被调用。在图3.3中,一部分虚拟地址已经被映射到了物理空间,例如 VPNO(地址范围0~4K)被映射为PFN2(地址范围8~12K);VPN1(地址范围4~8K)被映射为 PFN0(地址范围0~4K)等。
前面已经说过,在程序中使用的地址都是虚拟地址,虚拟地址会被送到处理器中专门负责地址转换的部件,即 MMU,被转换为物理地址之后,再去访问物理内存而得到真正的数据。为了便于理解,下面以 load 指令为例子进行描述,不过只考虑取数据的过程,其实每条load 指令自身的地址(或者称为 PC 值)也是虚拟地址,只是为了避免混淆,此时不考虑这个事情。对于从虚拟地址到物理地址的转换来说,只是对 VPN 进行操作,页内的偏移是不需要进行转化的,也就是说,页是进行地址转换的最小单位。例 1:
这条 load 指令在执行的时候,得到的取数据的虚拟地址是 R1+5=5,也就是地址5会被送到 MMU中,从图3.3 中可以看到,地址 5 落在了 page0 的范围之内(它的范围是0~4095),而 page0 被映射到物理空间中的 frame2(它的地址范围是 8192~12287)(注:图3.3中这个地址的映射有问题,画错映射到了frame3),因此MMU将虚拟地址5转换为物理地址 8192+5=8197,并把这个地址送到物理内存中取数据,物理内存并不知道 MMU 做了什么映射,它只是看到了一个对地址 8197 进行读操作的任务。例 2:
从图3.3中可以看出用来取数据的虚拟地址 20500 落在了 page5 内(它的地址范围是20480~24575),和页的起始地址有 20 个字节的距离,而且 page5 被映射到了物理内存中的frame3(它的地址范围是12288~16383),由于页内的偏移是不会变化的因此被映射的物
理地址是12288+20=12308。例3
从图3.3 中可以看出,虚拟地址 32780 落在了 page8 的范围内而 page8 并没有一个有效的映射,即此时 page8 的内容没有存在于物理内存中而是存在于硬盘中。MMU发现这个页没有被映射之后,就产生一个 Page Fault 的异常送给处理器,这时候处理器就需要转到 Page Fault 对应的异常处理程序中处理这个事情(这个异常处理程序其实就是操作系统的代码),它必须从物理内存的八个 frame 中找到一个当前很少被使用的,假设选中了frame1,它和 page2有着映射关系,所以首先将 frame1和 page2 解除映射关系,此时虚拟地址空间中 page2 的地址范围就被标记为没有映射的状态,然后把需要的内容(本例中是page8)从硬盘搬移到物理内存中 frame1的空间,并将page8 标记为映射到 frame1;如果这个被替换的 frame1是脏(dirty)状态的,还需要先将它的内容搬移到硬盘中,这里脏(dirty)的概念和 Cache 中是一样的,表示这个 frame 以前被修改过被修改的数据还没有来得及更新到硬盘中,关于这部分的详细内容会在后文进行介绍。处理完上述的内容,就可以从Page Fault 的异常处理程序中进行返回,此时会返回到这条产生 Page Fault 的 load 指令并重新执行,这时候就不会再产生异常,可以取到需要的数据了。
对于处理器来说,当它需要的页(page)不在物理内存中时,就发生了 Page Fault 类型的异常,需要访问更下一级的存储器,如硬盘,而硬盘的访问时间一般是以 ms 为单位的,这需要很长的一段时间,严重降低了处理器的性能,因此应该尽量减少 Page Fault 发生的频率这就需要优化页在物理内存中的摆放。如果允许一个页能够放到物理内存中任意一个frame 中,这时候操作系统就可以在发生 Page Fault 的时候将物理内存中任意一个页进行替换,具备这项功能之后,操作系统可以利用一些比较复杂的算法,将物理内存中那些最近一段时间都没有使用过的页进行替换,这样可以保证不会将一些当前很活跃的内容踢出物理内存也就是说,使用比较灵活的替换算法能够减少 Page Fault 发生的频率。
但是,这种在物理内存中任意的替换方法(类似于全相连结构的 Cache)直接实现起来不是很容易,所以在使用虚拟存储器的系统中,都是使用一张表格来存储从虚拟地址到物理地址(实际上是 VPN到 PFN)的对应关系,这个表格称为页表(Page Table,PT),这个表格放在什么地方呢?当然可以在处理器中使用寄存器来存储它,但是考虑到它会占用不菲的硬件资源,很少有人会这样做,一般都是将这个表格放在物理内存中,使用虚拟地址来寻址,表格中被寻址到的内容就是这个虚拟地址对应的物理地址。每个程序都有自己的页表,用来将这个程序中的虚拟地址映射到物理内存中的某个地址,为了指示一个程序的页表在物理内存中的位置,在处理器中一般都会包括一个寄存器,用来存放当前运行程序的页表在物理内存中的起始地址,这个寄存器称为页表寄存器(Page Table Register,PTR),每次操作系统将一个程序调入物理内存中执行的时候,就会将寄存器 PTR 设置好,当然,上面的这种机制可以工作的前提是页表位于物理内存中一片连续的地址空间内。
图3.4 表示了如何使用 PTR 从物理内存中定位到一个页表并使用虚拟地址来寻址页表,从而找到对应的物理地址的过程。其实,使用 PTR 和虚拟地址共同来寻址页表,这就相当于使用它们两个共同组成一个地址,使用这个地址来寻址物理内存。图 3.4 中仍然假设每个页的大小是 4KB,使用 PTR 和虚拟地址共同来寻址页表,找到对应的表项(entry),当这个表项对应的有效位(valid)是1时,就表示这个拟地址所在的4KB空间已经被操作系统映射到了物理内存中,可以直接从物理内存中找到这个虚拟地址对应的数据,其实,这时候访问当前页内任意的地址,就是访问物理内存中被映射的那个 4KB 的空间了。相反,如果页表中这个被寻址的表项对应的有效位是 0,则表示这个虚拟地址对应的 4KB 空间还没有被操作系统映射到物理内存中(为了节省物理内存的使用,操作系统只会把那些当前被使用的页映射到物理内存中),则此时就产生了 Page Fault 类型的异常需要操作系统从更下一级的存储器中(例如硬盘或者闪存)将这个页对应的4KB内容搬移到物理内存中。(注意,图3.4指的是虚拟地址对应的页表,上面写着PFN只是这个虚拟页表对应的物理地址,页表的Valid也指的是这个虚拟地址有没有被映射到物理地址)
在图 3.4中,使用了32 位的虚拟地址,页表在物理内存中的起始地址是用 PTR来指示的。虚拟地址的寻址空间是
2
32
2^{32}
232字节,也就是4GB;物理地址的寻址空间是
2
30
2^{30}
230字节,也就是1GB,这就像是目前使用的 32 位的 PC,虽然最大支持 4GB 的内存,但是很多时候,计算机实际安装的内存可能只有 1GB,甚至是 512MB,这就对应着实际物理地址的寻址空间在页表中的一个表项(entry)能够映射 4KB的大小,为了能够映射整个4GB 的空间,需要表项的个数应该是4GB/4KB=1M,也就是
2
20
2^{20}
220,因此需要 20 位来寻址,也就是虚拟地址中除了Page Offset 之外的其他部分,这个其实是很自然的,因为 32 位的虚拟地址能够寻址4GB的空间,将其人为地分为两部分,低 12 位用来寻址一个页内的内容,高 20 位用来寻址哪个页,也就是说,真正寻址页表的其实不是虚拟地址的所有位数,而只是 VPN 就可以了,从页表中找到的内容也不是整个的物理地址,而只是 PFN。从图 3.4 来看,页表中的每个表项似乎只需要 18 位的 PFN 和1位的有效位(valid)也就是总共19 位就够了。实际上因为页表是放到物理内存中,而物理内存中的数据位宽都是 32 位的,所以导致页表中每个表项的大小也是 32 位的,剩余的位用来表示一些其他的信息,如每个页的属性信息(是否可读或可写)等,这样页表的大小就是4BX1M =4MB也就是说,按照目前的讲述,一个程序在运行的时候,需要在物理内存中划分出4MB 的连续空间来存储它的页表,然后才可以正常地运行这个程序。
需要注意的是,页表的结构是不同于 Cache 的,在页表中包括了所有 VPN 的映射关系,所以可以直接使用 VPN 对页表进行寻址,而不需要使用 Tag。
在处理器中,一个程序对应的页表,连同 PC 和通用寄存器一起,组成了这个程序的状态,如果在当前程序执行的时候,想要另外一个程序使用这个处理器,就需要将当前程序的状态进行保存,这样就可以在一段时间之后将这个程序进行恢复,从而使这个程序可以继续执行。在操作系统中,通常将这样的程序称为进程(process),当一个进程被处理器执行的时候,称这个进程是活跃的(active),否则就称之为不活跃(inactive)。操作系统通过将一个进程的状态加载到处理器中,就可以使这个进程进入活跃的状态。可以说,进程是一个动态的概念,当一个程序只是放在硬盘中,并没有被处理器执行的时候,它只是一个由一条条指令组成的静态文件,只有当这个程序被处理器执行时,例如用户打开了一个程序,此时才有了进程,需要操作系统为其分配物理内存中的空间,创建页表和堆栈等,这时候一个静态的程序就变为了动态的进程,这个进程可能是一个,也可能是多个,这取决于程序本身。当然进程是有生命期的,一旦用户关闭了这个程序,进程也就不存在了,它所占据的物理内存也会被释放掉。
一个进程的页表指定了它能够在物理内存中访问的地址空间,这个页表当然也是位于物理内存中的,在一个进程进行状态保存的时候,其实并不需要保存整个的页表,只需要将这个页表对应的 PTR 进行保存即可。因为每个进程都拥有全部的虚拟存储器空间,因此不同的进程肯定会存在相同的虚拟地址,操作系统需要负责将这些不同的进程分配到物理内存中不同的地方,并将这些映射(mapping)信息更新到页表中(使用 store 指令就可以完成这个任务),这样不同的进程使用的物理内存就不会产生冲突了。
图3.5表示了在处理器中存在三个进程的情况,每个进程都有自己的页表,虽然在三个进程中都存在相同的虚拟地址 VA1,但是通过每个进程自己的页表,将它们的 VA 映射为物理内存中不同的物理地址。同理,虽然三个进程中存在不同的虚拟地址 VA2、VA3 和VA4,但是它们都是访问同一个函数,因此通过每个进程的页表将它们都映射到了物理内存中的同一个地址,通过这种方式,实现不同进程之间的保护和共享。
但是在之前说过,为了节省硅片面积,都会把页表放到物理内存中,这样要得到一个虚拟地址对应的数据,需要访问两次物理内存。第一次访问物理内存中的页表,得到对应的物理地址;第二次使用这个物理地址来访问物理内存,这样才能得到需要的数据,如图3.6 表示了这个过程。
按照图 3.6 的这种访问过程要执行一条 load 指而得到数据需要的时间肯定会很长,毕竟物理内存的访问速度和处理器的速度相比是很慢的。可以说,图 3.6 的这种访问数据的方法没有错误,但是效率不高,现实当中的处理器都会使用 TLB 和 Cache 来加快这个过程,这些内容将在后文介绍。
在前面已经计算过,如果一个 32 位处理器中页的大小是 4KB,并且页表中每个表项的大小是 4 字节(即表项的大小为32位,因为是32位处理器)的这种典型结构,在页表中需要的表项个数是4GB/4KB=22,所以页表的大小是4BX22=4MB。这也就是说,对于在处理器中运行的每个进程,都需要在物理内存中为其分配连续的 4MB 空间来存储它的页表。当然,如果处理器中只运行一个进程的话那么看起来问题不大,但是,如果一个处理器中同时运行着上百个进程时(这很常见,打开计算机的任务管理器就可以看到当前有多少进程在运行了)每个进程都要占用4MB 的物理内存空间来存储页表,那显然就不可能了。而且,对于64 位的处理器来说,仍旧有可能采用大小为 4KB 的页,可以计算出此时页表需要的空间是非常巨大的,大小是 4 B × ( 2 64 / 2 12 ) = 4 B × 2 52 = 2 54 B 4B×(2^{64}/2^{12}) =4B\times2^{52}=2^{54}B 4B×(264/212)=4B×252=254B,这显然已经不可能了。
事实上,一个程序很难用完整个 4GB 的虚拟存储器(因为32位处理器虚拟地址空间是 2 32 = 4 G B 2^{32}=4GB 232=4GB)空间,大部分程序只是用了很少一部分,这就造成了页表中大部分内容其实都是空的,并没有被实际地使用,这样整个页表的利用效率其实是很低的。
可以采用很多方法来减少一个进程的页表对于存储空间的需求,最常用的方法是多级页表(Hierarchical Page Table),这种方法可以减少页表对于物理存储空间的占用,而且非常容易使用硬件来实现,与之对应的,本节所讲述的页表就称为单级页表(Single PageTable),也被称为线性页表(Linear Page Table)。
在多级页表(Hierarchical Page Table)的设计中,将1.5.2.1节介绍的一个4MB 的线性页表划分为若干个更小的页表,称它们为子页表,处理器在执行进程的时候,不需要一下子把整个线性页表都放人物理内存中,而是根据需求逐步地放人这些子页表。而且,这些子页表不再需要占用连续的物理内存空间了,也就是说,两个相邻的子页表可以放在物理内存中不连续的位置,这样也提高了物理内存的利用效率。但是,由于所有的子页表是不连续地放在物理内存中,所以仍旧需要一个表格,来记录每个子页表在物理内存中存储的位置,称这个表格为第一级页表(Level1 Page Table),而那些子页表则为第二级页表(Level2 PageTable),如图 3.7 所示。
这样,要得到一个虚拟地址对应的数据,首先需要访问第一级页表,得到这个虚拟地址所属的第二级页表的基地址,然后再去第二级页表中才可以得到这个虚拟地址对应的物理地址,这时候就可以在物理内存中取出相应的数据了。
举例来说,对于一个 32 位虚拟地址、页大小为 4KB 的系统来说,如果采用线性页表,则页表中的表项个数是
2
32
/
2
12
=
2
20
2^{32}/2^{12}=2^{20}
232/212=220,将其分为 1024(
2
10
2^{10}
210)等份,每个等份就是一个第二级页表共有1024 个第二级页表,对应着第一级页表中的 1024 个表项。也就是说第一级页表需要 10位地址进行寻址。每个二级页表中,表项的个数是22/2=2个也需要 10 位地址才能寻址第二级页表,如图 3.8 所示为上述过程的示意图。
在图3.8中一个页表中的表项简称为 PTE(Page Table Entry),当操作系统创建一个进程时就在物理内存中为这个进程找到一块连续的4KB空间(4BX
2
10
2^{10}
210=4KB存放这个进程的第一级页表,并且将第一级页表在物理内存中的起始地址放到 PTR 寄存器中。通常这个寄存器都是处理器中的一个特殊寄存器例如ARM中的TTB寄存器x86中的CR3寄存器等。随着这个进程的执行,操作系统会逐步在物理内存中创建第二级页表,每次创建一个第二级页表,操作系统就要将它的起始地址放到第一级页表对应的表项中,如表 3.1所示为一个进程送出的虚拟地址和第一级页表第二级页表的对应关系。
TLB 只是加速了从虚拟地址到物理地址的转换,可以很快地得到所需要的数据(或指令)在物理内存中的位置,也就是得到了物理地址,但是,如果直接从物理内存(主存)中取数据(或指令),显然也是很慢的,因此可以使用在以前提到的 Cache 来缓存物理地址到数据的转换过程。实际上,从虚拟地址转化为物理地址之后,后续的过程就和前文讲述的内容是一样的了。因为这种 Cache使用物理地址进行寻址,因此称为物理 Cache(Physical Cache),使用TLB和物理Cache一起进行工作的过程如图3.26所示。
很显然,如果不使用虚拟存储器,处理器送出的地址会直接访问物理Cache,而现在需要先经过 TLB才能再访问物理 Cache,因此必然会增加流水线的延迟如果还想获得和以前一样的运行频率,就需要将访问 TLB 的过程单独使用一级流水线,但是这样就增加了分支预测失败时的惩罚(penaly),也增加了 load 指令的延迟,不是一个很好的做法。既然图 3.26 所示的过程,最终要从虚拟地址得到对应的数据,那么为什么不使用Cache来直接缓存从虚拟地址到数据的关系呢?当然是可以的,因为这个 Cache 使用虚拟地址来寻址,称之为虚拟 Cache(VirtualCache),用来和前面的物理 Cache 相对应。既然使用虚拟 Cache,可以直接从虚拟地址得到对应的数据,那么是不是就可以不使用 TLB了呢?当然不是这样,因为一旦虚拟 Cache 发生了缺失,仍旧需要将对应的虚拟地址转换为物理地址,然后再去物理内存中获得对应的数据,因此还需要 TLB来加速从虚拟地址到物理地址的转换过程。在使用虚拟 Cache 的这种方法中,如果能够从中得到数据,那么是最好的情况,否则仍旧需要经过 TLB 并访问物理内存,这个过程如图 3.27所示。
在图 3.27 中,如果使用虚拟地址,从虚拟Cache 中找到了需要的数据那么就不需要再访问 TLB 和物理内存,在流水线中使用这种虚拟 Cache,不会对处理器的时钟周期产生明显的负面影响,不过它会引入一些新的问题,需要耗费额外的硬件进行解决,这是由于虚拟地址的属性和物理地址是不同的,每个物理地址总是有且只有一个物理内存中的位置和它对应,两个不同的物理地址必然对应物理内存中两个不同的位置,因此,在前文中使用物理Cache时,不会有任何的问题,但是,直接使用虚拟 Cache 则会引入新的问题,主要可以概括为两方面。
同义问题(synonyms),也称做重名(aliasing),即多个不同的名字对应相同的物理位置在虚拟存储器的系统中,一个进程内或者不同的进程之间,不同的虚拟地址可以对应同一个物理内存中的位置,例如前文中提到的位于操作系统中的 printf 函数,许多进程都可以使用它。如果使用了虚拟 Cache,由于直接使用虚拟地址进行寻址,则不同的虚拟地址会占用Cache 中不同的地方,那么当很多虚拟地址都对应着一个物理地址时,就会导致在虚拟Cache中,这些虚拟地址虽然占据了不同的地方,但是它们实际上就是对应着同一个物理内存中的位置,这样会引起两方面的问题,一是浪费了宝贵的 Cache 空间,造成了 Cache 等效容量的减少,降低了整体的性能;二是当执行一条 store 指令而写数据到虚拟 Cache 中时只会将这个虚拟地址在 Cache 中对应的内容进行修改,而实际上,Cache 中其他有着相同物理地址的地方都需要被修改(这些位置的虚拟地址不同,但是都对应着同一个物理地址),否则,其他的虚拟地址读取 Cache 时,就无法得到刚才更新过的正确值了,上面描述的问题如图 3.28所示。
在图3.28中,当执行一条store 指令而向虚拟地址 VA1写入数据时,虚拟 Cache中VA1对应的内容会被修改,但是在 Cache 中,还存在虚拟地址 VA2也映射到同一个物理地址PA,这样一个物理地址在虚拟 Cache 中有两份数据,当一条load 指令使用虚拟地址 VA2读取虚拟 Cache时,就会得到过时的数据了。
并不是所有的虚拟 Cache 都会发生同义问题,这取决于页的大小和 Cache 的大小,而前面说过,虚拟地址转化为物理地址的时候,页内的偏移是不变的,也就是说,对于大小为4KB的页来说,虚拟地址的低12 位不会发生变化,如果此时有一个直接相连(direct mapped)结构的虚拟 Cache,而这个 Cache 的容量小于4KB,那么寻址 Cache 的地址就不会大于12位,此时即使两个不同的虚拟地址对应同一个物理位置,它们寻址虚拟 Cache 的地址也是相同的,因此会占用虚拟 Cache 中的同一个地方,不会存在不同的虚拟地址占用Cache 中不同位置的情况。相反,只有 Cache 的容量大于4KB时,才会导致寻址Cache的地址大于 12位,此时映射到同一个物理位置的两个不同的虚拟地址寻址虚拟 Cache 使用的地址也是不同的。它们会占用 Cache 内不同的地方,这样就会出现同义问题,如图 3.29所示为在大小为8KB直接相连结构的 Cache中出现同义问题的示意图。
由于在虚拟地址到物理地址的转换过程中,只有页内偏移保持不变,所以在图 3.29 中当两个虚拟地址映射到同一个物理地址时,两个虚拟地址的第 12 位可能是0也可能是1也就是说,此时在虚拟 Cache 中会有两个不同的地方存储着同一个物理地址的值,此时最简单的方法就是当一个虚拟地址写 Cache 时,将 Cache 中可能出现同义问题的两个位置都进行更新,这就相当于将它们作为一个位置来看待,要实现这样的功能,就需要使用物理地址作为 Cache 的 Tag 部分,并且能够同时将虚拟 Cache 中两个可能重名的位置都读取出来这就要在 Cache 中使用 bank 的结例如图329所的大小为8KB直接相连结构的Cache就需要两个bank,使用虚拟地址 VA[11:0]进行寻址。在写Cache时两个bank中对应的位置都需要更新,这样在读取 Cache 时也就会从两个bank 中得到同一个值了,但是这样的方法相当于将 Cache 的容量减少了一半,显然无法在实际当中使用。其实,利用这种bank 结构的Cache,可以在不减少 Cache 容量的前提下解决同义问题,这种方法如图 3.30所示。
在图3.30中,一个大小为8KB直接相连结构的 Cache,内部被分为了两个 4KB的bank,使用 VA[11;0]作为两个 bank 公用的地址,需要注意的是在 Cache 中的数据部分和Tag部分都使用了图3.30所示的bank 结构这种Cache的读写过程如下。
①读取Cache时两个 bank都会被 VA[110]寻址而得到对应的内容,这两个 bank的输出被送到一个由物理地址 PA[12]控制的多路选择器上,当物理地址 PA[12]为0时选择 bank0 输出的值当物理地址 PA[12]为1时选择 bank1 输出的值。由于物理地址需要经过TLB才可以得到所以当Cache 中两个 bank 输出的值送到多路选择器的时候物理地址可能还没有从 TLB 中得到,这样在一定程度上增加了处理器的周期时间。需要注意的是,由于 Cache 中的 Tag 部分也采用了这种 bank 结构,所以使用图 3.30 所示的方法可以得到 Cache 最终输出的 Tag 值,这个值会和 TLB输出的 PFN值进行比较,从而判断 Cache是否命中。
②写Cache 的时候,由于一条指令只有退休(retire)的时候,才会真正地写 Cache,此时的物理地址已经得到了,所以在写 Cache 时可以根据物理地址 PA[12]的值,将数据写到对应的 bank 中。
这种方法相当于将 PFN 为偶数(PA[12]为 0)的所有地址写到了 Cache 的 bank0 中将PFN 为奇数(PA[12]为 1)的所有地址写到了 bank1 中这样不会造成Cache 存储空间的浪费。当然缺点是增加了一些硬件复杂度,并且在 Cache 中 way 的个数不增加的前提下,随着 Cache 容量的增大,需要使用的硬件也越来越多例如使用大小为 16KB直接相连结构的Cache 时,就需要使用四个 bank,采用 PA[13:12]来控制多路选择器。由于采用了 bank结构,Cache 的输入需要送到所有的 bank,造成输入端的负载变得更大,而且每次读取Cache 时所有的 bank 都会参与动作,所以功耗相比普通的 Cache 也会增大,这些都是采用bank 结构的虚拟 Cache需要面临的缺点。
同名问题(homonyms)即相同的名字对应不同的物理位置在虚拟存储器中因为每个进程都可以占用整个虚拟存储器的空间,因此不同的进程之间会存在很多相同的虚拟地址。而实际上,这些虚拟地址经过每个进程的页表转化后,会对应不同的物理地址,这就产生了同名问题:很多相同的虚拟地址对应着不同的物理地址。当从一个进程切换到另一个进程时,新的进程使用虚拟地址来访问虚拟 Cache 的话(假设使用虚拟地址作为 Cache 中的 Tag 部分),从Cache 中可能得到上一个进程的虚拟地址对应的数据,这样就产生了错误。为了避免这种情况发生,最简单的方法就是在进程切换的时候将虚拟 Cache 中所有的内容都置为无效,这样就保证了一个进程在开始执行的时候,使用的虚拟 Cache 是干净的同理,对于 TLB 也是一样的,在进程切换之后,新的进程在使用虚拟地址访问 TLB 时,可能会得到上一个进程中虚拟地址的映射关系,这样显然也会产生错误,因此在进程切换的时候,也需要将 TLB 的内容清空,保证新的进程使用的 TLB 是干净的。当进程切换很频繁时,就需要经常将 TLB 和虚拟 Cache 的内容清空这样可能浪费了大量有用的值,降低了处理器的执行效率。
既然无法直接从虚拟地址中判断它属于哪个进程,那么就为每个进程赋一个编号,每个进程中产生的虚拟地址都附上这个编号,这个编号就相当于是虚拟地址的一部分,这样不同进程的虚拟地址就肯定是不一样了。这个编号称为 PID(Proess ID),当然更通用的叫法是ASID(Address Space IDentifier),使用ASID相当于扩展了虚拟存储器的空间,此时仍然是每个进程可以看到整个的4GB的虚拟存储器空间,而且每个进程的4GB都相不交叠如图3.31所示。
例如,当使用8位的ASID 时那么拟存储器就有 2 8 = 256 2^8=256 28=256个4GB 的空间就相当于此时虚拟存储器的空间为256X4GB=1024GB,也就是说使用ASID 就等于扩大了虚拟存储器的空间。
但是,使用 ASID也引人了一个新的问题,当多个进程想要共享同一个页时,如何实现这个功能呢?这就需要在ASID 之外再增加一个标志位,称之为 Global位,或简称为G位当一个页不只是属于某一个进程,而是被所有的进程共享时,就可以将这个 Global 位置为1,这样在查找页表的时候,如果发现 G 位是 那么就不需要再理会 ASID 的值这样就实现了一个页被所有进程共享的功能。
ASID 和原来的虚拟地址一起组成了新的虚拟地址,这就相当于使虚拟地址的位数增加了,例如在 32 位的处理器中采用8 位的 ASID则相当于虚拟地址是40 位,此时还可以使用两级的页表,将这 40 位的虚拟地址进行划分,假设仍旧使用大小为 4KB 的页查找第级页表使用 14 位查找第二级页表使用 14 位那么此时第一级页表和第二级页表的大小都是24X4B=64KB,过大的页表可能导致其内部出现碎片,降低页表的利用效率。为了解决这个问题,可以采取三级页表的方式。增加一个额外的页表,它使用 ASID进行寻址,这个页表中的每个PTE都存放着第二级页表的基地址,如图3.32所示此时仍旧需要使用PTR寄存器存放第一级页表的基地址。
当然,使用这样的方式,要从虚拟地址中得到需要的数据,相比于两级页表,需要多一次物理内存的访问,这会造成 TLB 缺失的处理时间变长,是使用 ASID带来的一个负面影响,尤其是 TLB缺失发生的频率很高时,这种负面影响更为严重。
在使用多级页表的系统中,只有第一级页表才会常驻物理内存中,第一级页表的基地址由处理器当中专用的寄存器指定,例如图 3.32 中的 PTR 存器。支持 ASID的处理器中还会有一个寄存器来保存当前进程的 ASID值每次操作系统创建一个进程时,就会给当前的进程分配 ASID值并将其写到ASID 存器中这个进程中所有的虚拟地址都会在前面被附上这个ASID的值,在 TLB 中ASID和VPN一起组成了新的虚拟地址,参与地址的比较,这样就在TLB中对不同的进程进行了区分。
当系统中运行的进程个数超过 ASID能够表示的最大范围时例如有多于 256个进程存在于8位ASID的系统中,此时就需要操作系统从已经存在的256个ASID中挑出一个不经常使用的值,将它在 TLB 中对应的内容清空,并将这个ASD分配给新的进程。由于此时新的进程会更新 PTR 寄存器,为了能够对旧的进程进行恢复,操作系统需要将被覆盖的 PTR寄存器的值保存起来,这样等到这个旧的进程再次被执行时,就可以知道它存在于物理内存的哪个位置了。总结起来就是说,如果使用了 ASID,那么操作系统就需要管理ASID的使用,尤其是对于那些已经退出的进程,要及时地回收它的 ASID值,以供新的进程使用。
在使用虚拟存储器的系统中,仍旧可以使用物理 Cache,这是最保守的一种做法,因为处理器送出的虚拟地址(VA)会首先被 TLB 转换为对应的物理地址(PA)然后使用物理地址来寻址 Cache,此时就像是没有使用虚拟存储器一样,直接使用了物理 Cache,并且使用物理地址的一部分作为Tag,因此将其称为 physically-indexed, physically-tagged Cache,这种设计的示意图如图 3.35 所示。
由于寻址 TLB 的过程也需要消耗一定的时间,为了不至于对处理器的周期时间造成太大的负面影响,可能需要将访问 TLB 的过程单独作为一个流水段,这样相当于增加了流水线的级数,对于 I-Cache 来说,增加一级流水线会导致分支预测失败时有更大的惩罚(penalty);而对于D-Cache来说增加一级流水线会造成load 指令的延迟(latency)变大。由于 load 指令一般都是在相关性的顶端,因此这种方法会对其他相关指令的唤醒(wakeup)造成一定的负面影响。这种设计方法在理论上是完全没有问题的,但是在真实的处理器中很少被采用,因为它完全串行了 TLB 和Cache 的访问。而实际上,这是没有必要的,因为从虚拟地址到物理地址的转换过程中,低位的 offset 部分是保持不变的在图3.35 所示的设计中,如果物理地址中寻址 Cache 的部分使用 offset 就足够的话那么就不需要等到从TLB 中得到物理地址之后才去寻址 Cache,而是直接可以使用虚拟地址的 offset 部分,这样访问TLB和访问 Cache 的过程是同时进行的,如图3.36所示。
这种设计在不影响流水线深度的情况下获得很好的性能,但是对 Cache 的大小有了限制,实际上,图3.36是 virtually-indexed, physically-tagged Cache 的一种情况,下面会对其进行介绍。
这种方式使用了虚拟 Cache,根据 Cache 的大小直接使用虚拟地址的一部分来寻址这个Cache(这就是 virtually-indexed),而在Cache中的 Tag 则使用物理地址中的 PFN(这就是physically-tagged),这种 Cache 是目前被使用最多的,大多数的现代处理器都使用了这种方式,如图 3.37 所示为在直接映射(direct-mapped)结构的 Cache 中使用这种方式的设计。
在这样的方法中,访问 Cache 和访问 TLB 是可以同时进行的假设在直接映射的Cache 中,每个 Cache line 中包括 2 b 2^{b} 2b字节的数据而 Cache set 的个数是 2 L 2^{L} 2L也就是说,寻址Cache 需要的地址长度是 L+b,它直接来自于虚拟地址,再假设页的大小是 2 k 2^{k} 2k字节,因此就有了图 3.37 中的三种情况。
从虚拟地址到物理地址的转换过程中,offset 部分(由图 3.37 中的k给出)是保持不变的,而在虚拟存储器中最小的单位就是页,图 3.37 中的前两种情况,也就是“k > L ”和“k = L + b”实际对应着一种情况,寻址 Cache 的地址此时虽然直接来自于虚拟地址,但是这个地址并没有超过 offset 部分,所以寻址 Cache 的地址在虚拟地址到物理地址的转换过程中是不会发生变化的,这个地址也可以认为是来自于物理地址,只不过它并行访问了TLB和 Cache,提高了处理器的执行效率。总结来看,图3.37 所示本质上对应着两种情况需要相应地采取不同的设计方法。
(1)情况1:k=L + b或k > L+ b
此时 Cache 容量小于或等于一个页的大小,直接使用虚拟地址中的
[
L
+
b
?
1
:
0
]
\left.\left[\begin{matrix}{L+b-1:0}\\\end{matrix}\right.\right]
[L+b?1:0?]部分来寻址 Cache,找到对应 Cache line 中的数据,并将这个 Cache line 的 Tag 部分和 TLB 转换得到的 PFN 进行比较,用来判断 Cache 是否命中。这种设计可以避免虚拟 Cache 中的重名问题,因为它本质上使用物理地址来寻址 Cache,相当于直接使用了物理 Cache。为什么会有这样的效果呢? 假设有几个不同的虚拟地址映射到同一个物理地址,那么这些虚拟地址的 offset 部分肯定是相同的,也就是这些虚拟地址用来寻址 Cache 的地址是一样的(虚拟地址
[
L
+
b
?
1
:
0
]
\left.\left[\begin{matrix}{L+b-1:0}\\\end{matrix}\right.\right]
[L+b?1:0?]来自于 offset),因此这些不同的虚拟地址必然会位于直接映射(directmapped)Cache中的同一个位置,这样出现重名的这些虚拟地址就不可能同时共存于 Cache中了,因为Cache 内只有一个位置可以容纳这些重名的虚拟地址。
(2)情况2:k < L + b
在上面的情况下想要使用容量比较大的 Cache,考虑到速度的限制,不可能在组相连结构的 Cache 中无限制地增加 way 的个数,因此只能够增加每个 way 的容量此时就会导致虚拟地址中寻址 Cache 的 index 位数的增加,这就变成了L+b 的值已经大于 offset 的位数k的情况,这种设计就是真正的 virtually-indexed,虽然这样可以在不增加 way 个数的情况下获得容量比较大的 Cache,但是会面临重名的问题:不同的虚拟地址会对应同一个物理地址。举例来说,在页大小为 4KB 的系统中,有两个不同虚拟地址 VA1和 VA2映射到同一个物理地址 PA,有一个直接映射结构的 Cache,容量为8KB需要13 位的index 才可以对其进行寻址,如图 3.39 所示。
此时不能保证 VA1和VA2寻址 Cache 的index是相同的因为它们的位[12]有可能不相同,如图 3.39 所示,此时的位[12]已经属于 VPN了正因为如此,才造成 VA1和VA2寻址 Cache 的 index 不同(注:理论上真实的物理地址12位应该是相同的。),它们会被放到不同的 Cache set 上,这时候问题就出现了:Cache中两个不同的位置对应着物理存储器中同一个物理位置,这样不但造成了 Cache 空间的浪费而且当Cache 中VA2对应数据被改变时(例如执行 store 指向 VA2中写入值)VA1的数据不会随着变化,因此就造成了一个物理地址在 Cache 中有两个不同的值,当后续的load 指令从地址 VA1读取数据时,就不会从Cache 中读取到正确的值了。
如何解决这个问题呢?在前面讲述虚拟Cache 时介绍了使用 bank 结构的Cache来解决这种重名的问题,将所有重名的虚拟地址都分门别类地放到 Cache 中指定的地方,当然方法不止这一种,还可以让这些重名的虚拟地址只有一个存在于 Cache 中,其他的都不允许在Cache中存在这样也可以避免上述的问题,如何实现这种方法呢?可以使用 L2 Cache来实现这个功能,使 L2 Cache 中包括所有 L1 Cache 的内容,也就是采用之前介绍的inclusive 的方式,如图3.40所示。
在这种方式中,直接缓存了从虚拟地址到数据的过程,它会使用虚拟地址来寻址 Cache(也就是 virtually-indexed),并使用虚拟地址作为 Tag(也就是 virtually-tagged)因此这种Cache 可以称得上是名副其实的 Virtual Cache 了。如果 Cache 命中,那么直接就可以从Cache 中获得数据都不需要访间 TLB;如果 Cache 缺失那么就仍旧需要使用 TLB来将虚拟地址转换为物理地址,然后使用物理地址去寻址 L2 Cache,从而得到缺失的数据(在现代的处理器中,L2及其更下层的 Cache 都是物理 Cache),这个过程如图 3.42 所示。
当然,使用这种方法,仍旧会遇到重名的问题,当存在多个虚拟地址对应同一个物理地址的情况时,L1 Cache 中也可能出现一个物理地址占据多个 Cache line 的问题解决它的方法还可以和上一节一样,让那些重名的虚拟地址只有一个可以存在于 L1 Cache 中,只是此时不能在 L2 Cache 中只存储虚拟地址的 a 部分了而是应该存储整个的虚拟地址。回想上一节的内容其实只需要存储虚拟地址的a 部分就可以在 L1 Cache中分辨出不同的虚拟地址了,而在本节的设计中只有将整个虚拟地址都存储在 L2 Cache 中才可以分辨出L1Cache 中不同的虚拟地址因为 L]Cache 是 virtually-tagged 的结构,会使用虚拟地址的-部分作为Tag。举例来说,当虚拟地址 VA1已经存在于L1 Cache 和 L2 Cache 之中时,和VA1重名的VA2访问L1 Cache 时会引起缺失时需要将 VA2经过TLB转化为物理地址(PA),然后访问 L2 Cache,此时发现这个物理地址已经存在于L2 Cache 当中而且对应的Cache line 中存储着 VA1,这就表示一个和 VA2重名的 VA1已经存在于 L1 Cache中。因此在将 VA2 对应的数据放到 L1 Cache 之前,首先需要将 L1 Cache中 VA1对应的那个Cache line 置为无效当然如果它是脏(dirty)的状态,那么首先需要进行 clean 操作,通过使用这种方法,就能够保证所有重名的虚拟地址只有一个存在于 L1 Cache 中。