使用分页作为核心机制来实现虚拟内存,可能会带来较高的性能开销。使用分页,就要将内存地址空间切分成大量固定大小的单元(页),并且需要记录这些单元的地址映射信息。因为这些映射信息一般存储在物理内存中,所以在转换虚拟地址时,分页逻辑上需要一次额外的内存访问。每次指令获取、显式加载或保存,都要额外读一次内存以得到转换信息,这慢得无法接受。因此我们面临如下问题:
关键问题:如何加速地址转换
如何才能加速虚拟地址转换,尽量避免额外的内存访问?需要什么样的硬件支持?操作系统该如何 支持?
硬件。增加所谓的地址转换旁路缓冲存储器(TLB 地址转换缓存),它就是频繁发生的虚拟到物理地址转换的硬件缓存。对每次内存访问,硬件先检查TLB,看看其中是否有期望的转换映射,如果有,就完成转换(很快),不用访问页表 (其中有全部的转换映射)。
一、TLB 的基本算法
图 19.1 展示了一个大体框架,说明硬件如何处理虚拟地址转换,假定使用简单的线性页表(即页表是一个数组)和硬件管理的 TLB(硬件承担许多页表访问的责任)。
硬件算法的大体流程如下:首先从虚拟地址中提取页号(VPN)(见图19.1第1行),然后检查 TLB 是否有该 VPN 的转换映射(第2行)。如果有,我们有了 TLB 命中(TLB hit),这意味着 TLB 有该页的转换映射。成功!接下来我们就可以从相关的 TLB 项中取出页帧号(PFN),与原来虚拟地址中的偏移量组合形成期望的物理地址(PA),并访问内存(第5~7 行),假定保护检查没有失败(第4行)。
如果 CPU 没有在 TLB 中找到转换映射(TLB未命中),我们有一些工作要做。在本例中,硬件访问页表来寻找转换映射(第11~12行),并用该转换映射更新 TLB(第 18 行),假设该虚拟地址有效,而且我们有相关的访问权限(第 13、15 行)。上述系列操作开销较大,主要是因为访问页表需要额外的内存引用(第12行)。最后,当 TLB 更新成功后,系统会重新尝试该指令,这时 TLB 中有了这个转换映射,内存引用得到很快处理。
TLB 和其他缓存相似,前提是在一般情况下,转换映射会在缓存中(即命中)。如果是这样,只增加了很少的开销,因为 TLB 处理器核心附近,设计的访问速度很快。如果 TLB 未命中,就会带来很大的分页开销。必须访问页表来查找转换映射,导致一次额外的内存引用(或者更多,如果页表更复杂)。因此,我们希望尽可能避免 TLB 未命中。
二、示例:访问数组
弄清楚 TLB 的操作,来看一个简单虚拟地址追踪,看看TLB如何提高性能。在本例中,假设有一个由10个4字节整型数组成的数组,起始虚地址是100。 进一步假定,有一个8位的小虚地址空间,页大小为16B。 我们可以把虚地址划分为 4 位的VPN(有16个虚拟内存页)和 4位的偏移量(每个页中有16个字节)。
图19.2 展示了该数组的布局,在系统的 16 个 16 字节的页上。数组的第一项(a[0])开始于(VPN=06, offset=04),只有 3 个 4字节整型数存放在该页。数组在下一页(VPN=07)继续,其中有接下来4项(a[3] … a[6])。 10 个元素的数组的最后3项(a[7] … a[9])位于地址空间的下一页(VPN=08)。
考虑一个简单的循环,访问数组中的每个元素,类似下面的 C 程序:
int sum=0;
for (i=0;i<10;i++){
sum+=a[i];
}
当访问第一个数组元素(a[0])时,CPU 会看到载入虚存地址 100。硬件从中提取 VPN(VPN=06),然后用它来检查 TLB,寻找有效的转换映射。假设这里是程序第一次访问该数组,结果是 TLB 未命中。
接下来访问 a[1],TLB 命中!因为数组的第二个元素在第一个元素之后,它们在同一页。因为我们之前访问数组的第一个元素时,已经访问了这一页,所以TLB 中缓存了该页的转换映射。因此成功命中。访问a[2]同样成功(再次命中),因为它和a[0]、 a[1]位于同一页。
遗憾的是,当程序访问 a[3] 时,会导致 TLB 未命中。但同样,接下来几项(a[4] … a[6]) 都会命中TLB,因为它们位于内存中的同一页。最后,访问 a[7] 会导致最后一次TLB未命中。系统会再次查找页表,弄清楚这个虚拟页在物理内存中的位置,并相应地更新TLB。最后两次访问(a[8]、a[9])受益于这次 TLB 更新,当硬件在 TLB 中查找它们的转换映射时,两次都命中。
我们来总结一下这 10 次数组访问操作中 TLB 的行为表现:未命中、命中、命中、未命中、命中、命中、命中、未命中、命中、命中。命中的次数除以总的访问次数,得到 TLB 命中率(hit rate)为 70%。即使这是程序首次访问该数组,但得益于空间局部性(spatial locality),TLB 还是提高了性能。数组的元素被紧密存放在几页中(即它们在空间中紧密相邻),因此只有对页中第一个元素的访问才会导致 TLB 未命中。
时间局部性:最近访问过的指令或数据项可能很快会再次访问。想想循环中的循环变量或指令,它们被多次反复访问。
空间局部性:当程序访问内存地址 x 时,可能很快会访问邻近 x 的内存。
三、处理 TLB 未命中
硬件或软件(操作系统)。以前的硬件有复杂的指令集(有时称为复杂指令集计算机,Complex-Instruction Set Computer,CISC)硬件全权处理 TLB 未命中。为了做到这一点,硬件必须知道页表在内存中的确切位置(通过页表基址寄存器, page-table base register,在图 19.1 的第 11 行使用),以及页表的确切格式。发生未命中时, 硬件会“遍历”页表,找到正确的页表项,取出想要的转换映射,用它更新 TLB,并重试该指令。这种“旧”体系结构有硬件管理的TLB,一个例子是x86架构,它采用固定的多级页表(multi-level page table)
更现代的体系结构,都是精简指令集计算机,Reduced-Instruction Set Computer,RISC,有所谓的软件管理 TLB(software- managed TLB)。发生 TLB 未命中时,硬件系统会抛出一个异常(见图19.3第11行),这会暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序(trap handler)。接下来你可能已经猜到了,这个陷阱处理程序是操作系统的一段代码,用于处理 TLB未命中。 这段代码在运行时,会查找页表中的转换映射,然后用特别的“特权”指令更新 TLB,并从陷阱返回。此时,硬件会重试该指令(导致TLB命中)。
首先,这里的从陷阱返回指令稍稍不同于之前提到的服务于系统调用的从陷阱返回。在后一种情况下,从陷阱返回应该继续执行陷入操作系统之后那条指令,就像从函数调用返回后,会继续执行此次调用之后的语句。在前一种情况下, 在从 TLB 未命中的陷阱返回后,硬件必须从导致陷阱的指令继续执行。这次重试因此导致该指令再次执行,但这次会命中 TLB。因此,根据陷阱或异常的原因,系统在陷入内核时必须保存不同的程序计数器,以便将来能够正确地继续执行。
第二,在运行 TLB 未命中处理代码时,操作系统需要格外小心避免引起 TLB 未命中的无限递归。有很多解决方案,例如,可以把 TLB未命中陷阱处理程序直接放到物理内存中 [它们没有映射过(unmapped),不用经过地址转换]。或者在 TLB 中保留一些项,记录永久有效的地址转换,并将其中一些永久地址转换槽块留给处理代码本身,这些被监听的(wired) 地址转换总是会命中 TLB。
软件管理的方法,主要优势是灵活性:操作系统可以用任意数据结构来实现页表,不需要改变硬件。另一个优势是简单性。从 TLB 控制流中可以看出(见图19.3的第 11 行, 对比图19.1 的第11~19 行),硬件不需要对未命中做太多工作,它抛出异常,操作系统的未命中处理程序会负责剩下的工作。
四、TLB 内容
硬件 TLB 中的内容。典型的 TLB 有 32 项、64 项或 128 项,并且是全相联的(fully associative)。基本上,这就意味着一条地址映射可能存在TLB中的任意位置,硬件会并行地查找TLB,找到期望的转换映射。一条TLB项内容可能像下面这样:
更有趣的是“其他位”。例如,TLB通常有一个有效(valid)位,用来标识该项是不是有效地转换映射。通常还有一些保护(protection)位,用来标识该页是否有访问权限。例如,代码页被标识为可读和可执行,而堆的页被标识为可读和可写。还有其他一些位,包括地址空间标识符(address-space identifier)、脏位(dirty bit)等。
五、上下文切换时对 TLB 的处理
有了TLB,在进程间切换时(因此有地址空间切换),会面临一些新问题。具体来说,TLB 中包含的虚拟到物理的地址映射只对当前进程有效,对其他进程是没有意义的。所以在发生进程切换时,硬件或操作系统(或二者)必须注意确保即将运行的进程不要误读了之前进程的地址映射。
来看一个例子,当一个进程(P1)正在运行时,假设 TLB 缓存了对它有效的地址映射,即来自 P1 的页表。对这个例子,假设 P1 的 10号虚拟页映射到了 100 号物理帧。
在该例中,假设还有一个进程(P2),操作系统不久后决定进行一次上下文切换,运行 P2。这里假定 P2 的 10 号虚拟页映射到170号物理帧。如果这两个进程的地址映射都在TLB中,TLB的内容如表 19.1 所示。
在上面的 TLB 中,很明显有一个问题:VPN 10 被转换成了 PFN 100(P1)和 PFN 170(P2),但硬件分不清哪个项属于哪个进程。所以还需要做一些工作,让 TLB 正确而高效地支持跨多进程的虚拟化。关键问题是:
关键问题:进程切换时如何管理TLB的内容
如果发生进程间上下文切换,上一个进程在 TLB 中的地址映射对于即将运行的进程是无意义的。 硬件或操作系统应该做些什么来解决这个问题呢?
一种方法是在上下文切换时,简单地清空(flush)TLB, 这样在新进程运行前TLB就变成了空的。如果是软件管理 TLB 的系统,可以在发生上下文切换时,通过一条显式(特权)指令来完成。如果是硬件管理 TLB,则可以在页表基址寄存器内容发生变化时清空TLB(注意,在上下文切换时,操作系统必须改变页表基址寄存器(PTBR) 的值)。不论哪种情况,清空操作都是把全部有效位(valid)置为0,本质上清空了TLB。
上下文切换的时候清空 TLB,这是一个可行的解决方案,进程不会再读到错误的地址映射。但是,有一定开销:每次进程运行,当它访问数据和代码页时,都会触发TLB未命中。如果操作系统频繁地切换进程,这种开销会很高。
为了减少这种开销,一些系统增加了硬件支持,实现跨上下文切换的 TLB共享。比如有的系统在 TLB 中添加了一个地址空间标识符(Address Space Identifier,ASID)。可以把 ASID 看作是进程标识符(Process Identifier,PID),但通常比 PID 位数少(PID一般32位, ASID 一般是8位)。
如果两个进程共享同一物理页(例如代码段的页),就可能出现这种情况。在上面的例子中,进程 P1 和进程 P2 共享101号物理页,但是 P1 将自己的10号虚拟页映射到该物理页,而P2将自己的50号虚拟页映射到该物理页。共享代码页(以二进制或共享库的方式) 是有用的,因为它减少了物理页的使用,从而减少了内存开销。
六、TLB 替换策略
TLB 和其他缓存一样,还有一个问题要考虑,即缓存替换(cache replacement)。具体来 说,向 TLB 中插入新项时,会替换(replace)一个旧项,这样问题就来了:应该替换哪一个?
关键问题:如何设计TLB替换策略
在向TLB 添加新项时,应该替换哪个旧项?目标当然是减小TLB未命中率(或提高命中率),从而改进性能。
一种常见的策略是替换最近最少使用(least-recently-used,LRU)的项。LRU 尝试利用内存引用流中的局部性,假定最近没有用过的项,可能是好的换出候选项。另一种典型策略就是随机(random)策略,即随机选择一项换出去。这种策略很简单,并且可以避免一种极端情况。例如,一个程序循环访问 n+1 个页,但TLB大小只能存放 n个页。 这时之前看似“合理”的LRU策略就会表现得不可理喻,因为每次访问内存都会触发TLB 未命中,而随机策略在这种情况下就好很多。
七、实际系统的 TLB 表项
真实的 TLB,来自 MIPS R4000,是一种现代的系统,采用软件管理 TLB。图 19.4 展示了稍微简化的 MIPS TLB 项。
MIPS R4000 支持 32 位的地址空间,页大小为4KB。所以在典型的虚拟地址中,预期会看到20位的VPN和12位的偏移量。但是,你可以在TLB中看到,只有19位的VPN。 事实上,用户地址只占地址空间的一半(剩下的留给内核),所以只需要19位的 VPN。VPN 转换成最大24位的物理帧号(PFN),因此可以支持最多有64GB(2^38)物理内存(2^24个4KB内存页)的系统。
MIPS TLB 还有一些有趣的标识位。比如全局位(Global,G),用来指示这个页是不是所有进程全局共享的。因此,如果全局位置为1,就会忽略ASID。我们也看到了8位的ASID,操作系统用它来区分进程空间(像上面介绍的一样)。这里有一个问题:如果正在运行的进程数超过 256(2^8)个怎么办?最后,我们看到 3 个一致性位(Coherence,C),决定硬件如何缓存该页(其中一位超出了本书的范围);脏位(dirty),表示该页是否被写入新数据(后面会介绍用法);有效位(valid),告诉硬件该项的地址映射是否有效。还有没在图 19.4 中展示的页掩码(page mask)字段,用来支持不同的页大小。后面会介绍,为什么更大的页可能有用。最后,64位中有一些未使用(图19.4中灰色部分)。
MIPS 的 TLB 通常有32项或64项,大多数提供给用户进程使用,也有一小部分留给操作系统使用。操作系统可以设置一个被监听的寄存器,告诉硬件需要为自己预留多少 TLB 槽。这些保留的转换映射,被操作系统用于关键时候它要使用的代码和数据,在这些时候,TLB未命中可能会导致问题(例如,在TLB未命中处理程序中)。
由于MIPS的 TLB 是软件管理的,所以系统需要提供一些更新TLB的指令。MIPS提供了4个这样的指令:TLBP,用来查找指定的转换映射是否在TLB中;TLBR,用来将 TLB 中的内容读取到指定寄存器中;TLBWI,用来替换指定的 TLB 项;TLBWR,用来随机替换一个TLB项。操作系统可以用这些指令管理 TLB 的内容。当然这些指令是特权指令。
八、小结
通过增加一个小的、芯片内的TLB作为地址转换的缓存,大多数内存引用就不用访问内存中的页表了。
但是,TLB 也不能满足所有的程序需求。具体来说,如果一个程序短时间内访问的页数超过了 TLB 中的页数,就会产生大量的 TLB 未命中,运行速度就会变慢。这种现象被称为超出 TLB 覆盖范围(TLB coverage),这对某些程序可能是相当严重的问题。解决这个问题的一种方案是支持更大的页,把关键数据结构放在程序地址空间的某些区域,这些区域被映射到更大的页,使 TLB 的有效覆盖率增加。对更大页的支持通常被数据库管理系统 (Database Management System,DBMS)这样的程序利用,它们的数据结构比较大,而且是随机访问。
另一个TLB问题值得一提:访问TLB很容易成为CPU流水线的瓶颈,尤其是有所谓的物理地址索引缓存(physically-indexed cache)。有了这种缓存,地址转换必须发生在访问该缓存之前,这会让操作变慢。为了解决这个潜在的问题,人们研究了各种巧妙的方法, 用虚拟地址直接访问缓存,从而在缓存命中时避免昂贵的地址转换步骤。