内存管理与TLB
我们倾向于直接从最底层引入本书中的大部分主题进行探讨,对于一本关注计算机底层体系结构的书而言,这似乎是自然而然的。然而,为了说清楚内存管理硬件,我们得从MIPS R2000所寻求实现的unix风格的虚拟存储系统开始讲起。本章的后面我们还会讨论一下相同的硬件如何在其他环境下工作。
早期的MIPS CPU定位于支持运行在UNIX工作站与服务器上的应用程序,因此内存管理硬件被构想为一个最小化的能帮助BSD UNIX——一个经过完善设计并拥有充分多虚拟存储需求的操作系统的典型——提供内存管理功能的硬件。很明显的是,这些设计者们十分熟悉DEC VAX小型机,并且在从这种体系结构中获取了众多思路的同时,也摒弃了许多复杂设计。尤其是许多VAX使用微代码来解决的问题,在MIPS中被交由软件处理。
本章中我们将从MIPS的设计起点开始,面对着一个unix类型的操作系统以及它的虚存系统的众多需求。我们将会展示一下MIPS的硬件是如何满足这些需求的。结尾时,我们会讨论一下在不能像通常一样使用内存管理硬件的嵌入式系统中,您可以采取的几种使用方式。
内存地址转译硬件(下面我们将称其MMU,全称为memory management unit)有几类不同用途:
n 重定位(Relocation):程序的函数方法和预先声明的数据地址均在编译期间决定,MMU允许程序在任何物理地址运行。
n 为程序分配内存:MMU可以从物理内存里许多零散的页中创建连续的程序空间,使我们能从一个充满固定大小页面的池里分配内存。如果我们不停分配释放大小不一的内存块,就会碰上内存碎片问题:我们不得不止步于一个布满“小孤岛”的内存空间,无法满足对较大块内存的申请要求,哪怕此时所有的空闲空间之和是足够的。
n 隐藏和保护:用户级程序只能访问kuseg内存区域(较低的程序地址)内的数据。这类程序只能在操作系统所许可的内存区域中取得数据。
此外,每一页可以独立的指定为可写权限或者写保护权限;操作系统甚至可以停止一个意外的写覆盖代码空间的应用程序。
n 扩展地址空间: 有些CPU不能直接访问它们拥有的全部物理空间。尽管MIPS I 系列CPU是真正的32位体系结构,它们却布局了地址映射,使得未被映射的地址空间窗口kseg0和kseg1(它们不依赖MMU进行地址转换)落在了物理内存的开头的512M内。如果你要访问更高地址,则必须通过MMU。
n 内存映射对程序的适应化:在MMU的帮助下,你的程序能够去使用适合它的地址。同一段程序的许多份拷贝可能会同时运行在一个庞大的操作系统里,令它们去使用相同的程序地址变得更容易。
n 调页能力:程序可以好像已经得到它们所申请分配的所有资源一样正常的运行,而操作系统实际上只分配给它们当前所需的资源。访问未分配空间的程序会导致一个交由操作系统处理的异常(exception),操作系统此时才在这块内存中装入适当数据并令应用程序继续运行。
UNIX内存管理工作的本质是为了能运行众多不同的任务(即multitasking——多进程),并且每个任务各自拥有自己的内存空间。如果这个工作圆满完成,那么各任务的命运将彼此独立开来(操作系统自身也因此得以保护):一个任务自身崩溃或者错误的做某些事不会影响整个系统。显然,对一个使用分布终端来运行学生们程序的大学而言,这是一个很有用的特性;然而不仅如此,甚至是要求最严格的商业系统环境也需要能够在运行的同时支持实验软件或原型软件一并进行调试和测试。
MMU并不仅仅为了建立巨大而完备的虚拟存储系统,小的嵌入式程序也能在重定位和更有效的内存分配里受益。如果能把应用程序观念上的地址映射到任何可获得的物理地址,系统在不同时刻运行不同程序就会更加容易。
多进程和隔离不同进程地址空间一直都在向更小的计算机上移植,目前在个人电脑以及英特网服务器端都已经十分普通。
嵌入式应用中常常会明确的运用多进程机制,但几乎没有多少嵌入式操作系统使用隔离的地址空间。或许这归咎于这种机制在嵌入式CPU以及它们上面的操作系统上用处不大并且带来不稳定性,因而显得不那么重要。
MIPS这种如此之必要以致于导致在1986年时工作站CPU变的廉价起来的简单机制,或许也可以被证实跟90年代后期嵌入式系统的兴起有一定关系。甚至是很小的应用,也被迅速增长的代码大小所困扰,需要使用所有已知的手段来控制软件的复杂度;这种由MIPS首创的灵活的基于软件的方法看来能提供任何所需空间。仅仅几年前,CPU的厂商们在定位嵌入式市场时还很难确定MMU是否值得包括进去;然而到1997年,微软推出的无法在没有内存管理硬件的环境下运行的Windows/CE,已被视为针对嵌入式所面对的各种困难的一个成功解决方案。
6.1 大型计算机上的内存管理
或许从一个类似unix系统的内存管理系统的整个工作开始讨论是最容易的(选择unix作为研究是因为:尽管它体积庞大,却比PC上的操作系统简单的多)。在图6-1中展示了其典型特征。
6.1.1 基本的进程空间布局和保护
图6-1中最宽的分隔线是在低半部分——标明“用户程序可访问”的那部分——以及剩余部分之间的。程序的空间中的用户可访问部分就是我们在2.8节所描述的通常在MIPS内存映射中称为“kuseg”的部分。所有的高位地址内存都保留给操作系统。从操作系统的角度来看,地址低半部分是一个用户程序可以随心所欲使用的安全的“沙盒”(sandbox)。假如程序运行错误并且毁坏了自己所有的数据,其他程序并不用担心受到影响。
从应用程序的角度来看,这块区域可以随意用来创建独享的复杂数据结构来继续自己的工作。
在用户区域内部,也就是在“沙盒”的内部,操作系统给有需求的程序提供更多的栈空间(由于栈在暗中向下增长)。同时也提供一个系统调用,用来从一个以“预先声明数据区域”(declared data)最高地址为起始地址并且不断增长的地址空间——人们称之为“堆”(heap)——当中来获取更多数据空间。“堆”用来实现诸如malloc()这样的用于给应用程序提供大块额外内存的库函数。
用来构建堆和栈的内存块应该小到足以使系统节约内存,但同时也必须大到可以避免过多的系统调用或者访存异常的产生。不过,在每一次系统调用或访存异常时,操作系统会有机会监督应用程序的内存消耗。操作系统可以增强限制以确保应用程序不会获取过多的内存以致于威胁到系统中关键的运行活动。
在unix类型的系统中,进程在操作系统内核中拥有自己的识别符;为了确保应用程序只能做它们被允许的事情,绝大多数内核服务以特殊子函数的形式(即系统调用)提供出来,应用程序调用时也必须遵从一定的特殊规定。
图6.1
操作系统自己的代码和数据显然不能被用户空间的程序访问到。在某些系统里,这是通过把它们完全安置于一个隔离的地址空间内来完成的;在MIPS上运行的操作系统则与用户程序共享同一地址空间,当CPU运行在用户级权限下时,访问内核的空间是非法的,将会导致一个异常产生。
需要注意的是,尽管每个进程的用户空间会映射到专属于本进程的物理存储空间上去,操作系统的空间却往往是共享的。大部分的操作系统代码以及资源在所有进程看来位于相同的地址——操作系统内核内部是一个多线程单地址空间的——而每个进程的用户地址空间则位于专属自己的隔离空间。应用程序发出的系统调用在内核里的运行过程是完全可信赖的,而应用程序则根本无须被信任。
用户空间的有效部分是被分开的,栈位于空间顶端,而代码和静态编译的数据位于底部。这就使得栈可以向下增长(这是隐式的,由于程序的运行中函数参数的累积)而数据空间能够向上增长(这是显式的,由于程序调用了分配内存的库函数)。操作系统能够为栈或数据空间分配更多的内存并映射到合适的地址上去。
请注意,为了使程序能够使用庞大的数据空间,通常会让栈从用户空间所允许的最高地址开始向下增长。地址转换方案中必须要妥善应对这种在大跨度的范围内使用地址空间(在被使用空间中有一个巨大空洞)的地址映射特征。
实时系统中为了寻求效率和更多的共享函数,机制更加复杂化。大多数系统把应用程序的代码映射为“只读”(read-only),这意味着这些代码可以被许多进程安全的进行共享——许多进程运行同一应用程序的情况也很常见。
许多系统不仅仅共享整个应用程序,还可以共享通过库调用来访问的程序段(共享库)。目前我们还是先暂不讨论这所引发的另外一大堆问题吧。
6.1.2 把进程空间映射到物理内存
支持这个模型需要什么机制呢?
MIPS体系结构或多或少地要求程序(不管是应用程序还是内核方法)的地址空间在编译连接期间固定下来。这意味着应用程序在构建时不可能明确使用不同的地址——在我们希望运行同一应用程序的不同拷贝时也是如此。因此,当程序运行时,它的地址会映射到一个在程序装入时就已经由操作系统所固定下来的物理地址上。
尽管在进程上下文切换时更新所有的地址映射信息是可行的,但其效率相当之低。替代办法是:我们给每个进程一个编号(在unix里称作“进程ID”,但更准确的叫法应该是“地址空间ID”或者简称为“ASID”)。每个进程里的任何地址都在暗中被进程的ASID扩展后产生一个唯一的待转换地址。ASID需要在进程被新调度执行时装入CPU的一个寄存器,使得硬件可以来使用它。
映射机制还令操作系统能够把用户空间的不同部分区分对待:应用程序的某些空间(一般是代码部分)被映射为只读,而其它某些部分则可以暂不映射并且对其访问能引起“陷入”(trapped),这意味着一个胡乱运行的程序可以更早被停住。
进程地址空间中的内核部分通常是被所有进程共享的,而且这一部分绝大多数内容映射为常驻的操作系统代码和数据。由于这些代码连接为在这些地址运行,不需要灵活的映射机制,大部分MIPS上运行的内核会把它们的绝大部分代码置于这块在这一体系结构里具有固定映射的地址空间中。
6.1.3页映射最佳
为了映射地址人们尝试过很多特殊办法,通常使用“基地址/范围”二元组来保证地址的正确性。然而如果以提供给程序恰好其所需大小的内存的方式来进行内存映射,尽管这很明显为应用程序提供了最优服务,却会迅速导致可用内存变为零散的具有难以使用的大小的内存碎块。所有的实际系统都对内存以页(page,一种固定大小的内存块)进行映射。页通常是2的幂次大小,4K大小得到了压倒性优势的使用频率。
在4K的情况下,一个CPU地址可以简单的映射为这样:
“页内地址”(图中Address within page部分)的几位不需要转译,因此内存管理器件只需要去处理地址高位的转译,即把通常称作“虚页号”(图中Virtual page number,简称VPN)的部分转译为实际物理地址的高位(即physical frame number,或简称PFN,没人能想得起来为啥不叫PPN)。
6.1.4 我们真正想要的
映射机制必须使一个程序能断言某个地址在其自己的进程空间或地址空间内,并且能够高效的将其转换为真实的物理地址以访问内存。
一个好主意是使用一个含有整个空间内所有页的入口(entry)的表(即页表),每个入口包含这个页的正确物理地址。这很明显是个相当大的数据结构,因而不得不存放于主存之中。不过这带来两个严重问题。
第一,每次取出或存入数据我们都需要访问两次内存,就性能而言这显然是没什么好指望的。大概您已预见到这样一个答案:我们可以使用一个快存(cache)来存储这些入口,仅仅在我们未命中快存时才去访问常驻内存的表。由于每个快存的入口覆盖了4KB的内存空间,我们似乎可以就这样得到一块未命中率出奇低而自身又相当小的快存。(现在要介绍一下,快存很稀少而且有时被称作“查找缓存”(lookaside buffers),因此内存转译快存就被称作“转译查找缓存”(translation lookaside buffers)或简称为TLB;这个缩写更加常用)。
第二个问题是页表的大小;对一个划分为4KB大小页面的32位应用程序的空间,将产生100万个入口,这会占去将近4MB的内存。我们有必要找些办法让这个表小一点,否则就剩不下多少内存给程序使用了。
我们将在这样一个前提下讨论不同的解决方案:真实运行中的程序会在地址空间中留有巨大的地址空洞,如果我们能有一种方法,避免用物理内存来存储表中的这些空洞,情况看上去会好的多。
现在我们有解决办法了,本质上,这来自于DEC在VAX小型机上所使用的内存转译系统,它给绝大多数的并发体系结构带来了深远的影响。图6-2中对其做了概括。
图6.2
硬件的处理过程大致上是这样的:
n 一个虚地址被分割为两部分,低半部分地址位(通常为12位)不经转译直接通过,因此转译结果总是落在一个页内(通常4KB大小)。
n 高半部分地址位,也就是VPN,会在前面拼接上当前运行进程的ASID以形成一个独一无二的页首地址。
n 我们在TLB中查找是否有一个本页的转译项在里面。如果有,那么我们将得到对应的物理地址的高位,最终得到可用地址。TLB是一个做特殊用途的存储器件,可以运用各种有效的方法来匹配地址。它可以参考一个全局的标志位来在查找某些入口时忽略ASID位,因此这些TLB入口可以用来映射所有进程中的某一段共享的虚地址空间。
类似的,VPN也可以在存储的时候使用某些掩码位,使VPN中某些位在匹配过程中被排除在外,这使得TLB入口能够映射更大范围的虚地址。
在某些MIPS MMU中这两种特殊机制均被采纳。
n 通常在PFN中还存储了一些额外的位信息(flags)以用于控制哪些访问可以被允许——最明显的,允许读操作而不允许写操作。我们会在6.2节中讨论MIPS体系结构的标志位。
n 如果在TLB中入口匹配失败,那么系统必须定位或者分配一个适当的入口(使用常驻内存页表的相应信息)并将其装入TLB,然后再次进行一次转译过程
在VAX小型机中,这个过程是被微代码(microcode)所控制的,对程序员而言整个过程完全是自动进行的。
6.1.5 MIPS如此设计的起源
为了在尽可能少使用硬件的前提下提供一套与VAX相同的功能,MIPS的设计者们需要找些好办法。由微代码控制的TLB重装入(refill)是不能接受的,因此他们勇敢的迈出了一步:把这个工作交给软件来完成。
这意味着除了有一个寄存器用来存放当前的ASID,MMU器件仅仅是个TLB而已,也就是一个简单的高速、定长的转译表。系统软件可以(通常也就是如此)把TLB作为一个快存来面向常驻内存的页表,然而TLB硬件本身并不能把自己当作快存来使用,而只能这样:当某一个地址无法进行转译时,TLB会触发一个特殊的异常(TLB重装入异常)来引发调用软件程序。不过,TLB的细节设计和相应的控制寄存器上都作了十分周密的考虑,以帮助软件更加富有效率的运行。
6.2 MIPS TLB的特点
MIPS TLB通常都是在芯片上实现的:即使在快存命中的情形下,内存转译也这一步仍然必须进行,因此在机器上这是一个十分重要的“关键路径”(critical path)。这意味着它必须很小,尤其在早期那个年代,因此,把它的规模控制的很小就十分明智。
基本上这是块全相连的存储单元。每个入口都是一块拥有键值(key)域和数据域的关联存储器;当你提供某个键值后,由硬件来进行匹配并给出匹配成功的入口内的数据。通常全相连存储器效率很高但在硬件上过于浪费,而MIPS系列的TLB含有32到64个入口不等;这种规模的存储量在芯片设计中是相对容易处理的。
R4000风格的CPU至今都在使用这样一种TLB:每个入口内容被扩大为2倍以容纳2个各自映射独立物理页的连续的VPN。这种成对的入口仅增添了很少的硬件逻辑但却加倍了TLB可以装入的映射页,避免了对TLB的设计进行大幅度的调整。
您可以看到为何被称为“全相连”,这强调了所有的键值实际上是并行对输入值进行比较的。
图6.3
TLB的入口如图6-3中所示(您可以在后面的6.5节中找到其详细的编程信息)。TLB的键值包含了以下内容:
n VPN:虚地址的高位(即虚页地址)。在双入口TLB内被称作VPN2,这是为了强调如果每个物理页都是4KB,那么一个虚地址将会去掉低位(这些低位用来选择左边的输出域或右边的输出域)来进行比较以选出一对入口。
n PageMask:这只有近来的CPU才有。它用来控制使用虚地址的多少位来跟VPN进行比较并决定多少位被通过后加入实地址;使用越少的位达成的匹配映射的空间越大。MIPS CPU能够设置一个入口映射最大达16MB的内存。在使用各种页大小的情况下,都是用被掩位中的最高位来选中奇数号或偶数号入口。
n ASID:标记这个转译过程属于某一个特定进程,因此除非CPU的当前ASID与之相吻合,否则匹配是不会成功的。“C”这一位如果被置起为1,则关闭ASID的匹配,这标志着本转译可以在所有的进程空间内进行(因此地址映射中的这一部分是被所有空间共享的),ASID位在早期CPU中为6位长度,而在近期的CPU中则为8位。
TLB的输出部将会给出物理页号和一批数量不大但足够使用的标志位。
n 物理页号(PFN):32位的CPU仅仅有一个N(noncacheable,不可缓存)位——0表示可以缓存,1表示不可缓存。
而64位CPU则提供了一个3位的C域来表示一个更大范围的取值,可以来通知多处理器硬件在访问与其他处理器共享的页面数据时遵循何种协议。不具备硬件上的快存一致性协议特征的64位CPU保留了这样的TLB入口的格式;在所有R4000类型的CPU中只有2个取值来表示可被缓存(3)或者不可缓存(2),后者在R4000类型CPU中为标准默认值。现代的嵌入式CPU可以使用不同的取值来选择不同的快存策略:一对是写透(write through)与写回(write back),另一对为写分配(write allocate)与写不命中的非缓存策略(uncached write on miss)。详情请查阅您的CPU用户手册。
n 写控制位(D):置为1则允许数据写入相应的页。“D”来源于其全称“dirty”;请在6.8节中寻找原因。
n Valid位(V):如果是0,则相应的入口是不可使用(不可用于地址转译)的。看上去好像这没什么意义:既然我们不想转译机制工作,干嘛要把相应的纪录存入TLB呢?这是因为进行重装入动作的软件为了优化速度,不希望去检查特例。当需要在程序能够使用一个内存常驻页表中的页之前对该页进行某些处理时,相应的入口可以先被标记为无效(invalid)。当TLB重装入完成后,这会引发一个不同类型的陷入,调用特殊的程序来进行处理,而不必在每一个重装入事件中都进行测试。
现在转译一个地址就变得很简单了,我们可以把上面所描述的过程充实如下:
n CPU产生一个程序地址:无论是取指令,还是装入和写回数据,只要这些不处于MIPS地址空间中特殊的未映射区间时就会进行转译。
低12位被分离开来,剩下的处于EntryHi的VPN和ASID相拼作为TLB的键值,TLB入口中的PageMask位与C位对这个值有修改效果。
n TLB进行键值匹配:匹配成功的入口被选出。PFN被粘贴在程序地址的低位之前以产生一个完整的物理地址。
n 地址有效吗?V位和D位被参考。如果地址为无效或者正试图写一个D位为0的页,CPU产生一个陷入动作。在所有的转译陷入操作中BadVaddr寄存器都会装入引发陷入的程序地址;而在任何的TLB异常中,TLB的EntryHi寄存器将被预先装入引发陷入的程序地址的VPN。
请不要在TLB不命中处理以外的情况下使用便利寄存器Context(64位CPU中为XContext),在其他时候它们可能用来追踪BadVaddr之类的东西,或者干脆不用,两者都是允许的。
n 是否被缓存?如果C位被置起,那么CPU就到快存中去查找物理地址中的数据拷贝;假如数据不在那里,那么就会到内存中去取并且留一份拷贝到快存中。对于C位未被置起的入口,CPU既不查找快存也不把相应地址的数据装入快存。
当然,TLB的入口数量只能帮助你转译相对较小的程序地址空间——大致在几百KB左右。对大多数系统来说这远远不够,TLB也几乎总是作为一个软件来维护的面向一个更加巨大的转译集合的快存来使用。
当TLB中一次地址查找失败后,会陷入一个TLB重装入异常。系统软件需要做如下工作:
n 判断是否存在一个正确的转译;如果不存在,这个陷入会被派发到用于处理地址错误的程序中去。
n 假如存在一个正确的转译,那么创建一个用于实现转译的TLB入口。
n 假如TLB已经装满(在运行中的系统中它基本上也总是满的),软件要选择一个可以丢弃的入口。
n 软件把新的入口内容填入TLB。
请翻阅第6.7节来看一下这些是如何处理的,但这里请注意,尽管有特殊的CPU特性来帮助实现这一类重装入动作,软件还是可以用任意方式来进行TLB的重装入。
6.3 MMU的寄存器
现在我们终于可以把这一套自顶向下的方法收起来了,让我们深入到MIPS的实现细节中去吧。我希望您有足够的知识背景来挖掘文中的内容;一旦我们陈述了细节内容,我们就会展示一下相应的器件是如何工作的。
就像MIPS CPU中所有其它部分一样,MMU的控制是受少量的额外指令和一小部分协处理器中0号集里的寄存器影响的。表6.1列出了控制寄存器,我们还需要用到6.4节中所使用的指令。
寄存器助记符 |
CP0寄存器号 |
描述 |
EntryHi EntryLo/ EntryLo0 EntryLo1 PageMask
|
10 2
3 5 |
这些寄存器共同装下了一个TLB入口所需的所有信息。所有对TLB的读写操作都必须通过它们。EntryHi含有VPN和ASID;EntryLo含有PFN以及一些标志。 事实上EntryHi(ASID)域有2个职责,因为它还负责了记住当前活跃的ASID。 在某些CPU里(至今为止还都是那些64位CPU)每个入口会映射2个连续的VPN到不同的物理页,独立由被称为EntryLo0和EntryLo1的2个寄存器所指明。 EntryHi在64位CPU中增加到64位,但是为不需要用到长地址的软件保留了32位的格式。 PageMask可以用来创建能映射超过4KB的页的入口;参见6.3.1节。 |
Index |
0 |
用来在使用适当的指令时决定读或写哪一个TLB入口 |
Random |
1 |
这个伪随机值(实际上是一个自由计数的计数器)用来让tlbwr写入新的TLB入口到一个随机选择的位置。为那些使用随机替换的软件在陷入TLB重装入异常时的处理节省了时间(或许也没有其他合适的替代方法)。 |
Context XContext |
4 20 |
这些是很有用的寄存器,用来加速TLB重装入的过程。它们的高位可读写,低位从不可转译的VPN中得来。 寄存器的域这样布置使得如果您使用了合适的内存转译纪录的内存拷贝的安排方式,那么紧跟在TLB重装入陷入后Context会装有一个指向用来映射触发异常地址的页表纪录的指针。参见6.3.5节。 XContext在处理超过32位有效地址时做了相同的工作;由于受操作结果的数据结构的大小影响,对Context寄存器格式的直接扩展是行不通的。某些64位CPU上的软件也乐意使用32位虚地址,但是当这些不够的时候64位CPU装备了“模式位”SR(UX)和SR(KX),它们置起后能导致一个替代的对TLB重装入处理方法被调用;同时这个处理方法可以使用XContext来支持一个更加巨大且可管理的页表格式。 |
表6.1
6.3.1 EntryHi,EntryLo和PageMask寄存器
图6.4展示了这些寄存器,这些也是程序员对TLB仅有的可见部分,它们的设计也被一起进行了最佳化的考虑。
EntryHi的内容域解释如下:
n VPN,VPN2(虚页号):这些是一个程序地址的高位部分(忽略0-11位的剩余部分)。VPN2把12位也忽略掉,因为相应TLB的入口会映射一对4KB虚页。在重装入异常产生后这个域会自动用来匹配无法进行转译的程序地址。当你想写入一个不同的TLB入口时,或想要检查一下TLB内容,你必须要手动来进行设置。64位的系统(迄今为止)并不真正意义上支持像上面所隐含表示的一样巨大的虚地址空间。VPN2在R4X00 CPU中事实上是一个27位的域,以适应40位的程序地址空间。VPN2的高位必须被写为全1或者全0,匹配相应的EntryLo寄存器的最高一位;同时,高位全为1代表访问的是内核地址空间,否则高位全为0。
如果您只使用32位的指令集这些是自动完成的,因为当您在这种方式下工作时所有的寄存器值都包含对32位数的64位符号扩展。
n ASID(地址空间标识):这通常用来保存操作系统所看到的当前地址空间的标示。异常不会改变它,因此在一个重装入异常发生后,它依然为当前运行的进程保存着正确的标识信息。
绝大部分软件系统会蓄意把这个域写入当前的地址空间。然而,当你使用tlbr来检视TLB入口时必须要小心;这个操作会覆盖写入整个EntryHi,因此在这之后您必须恢复写入正确的当前ASID。
n R:这是地址空间标识。您可以仅仅把它看为EntryHi(VPN2)的另一部分地址位;实际上它也只是64位MIPS虚地址的最高位。不过,如果您能回忆起64位MIPS的扩展内存映射(参见2.8节中的图2.2),您可以发现这些高位把内存区域区分出不同的访问权限。同时,它们又与VPN2的高位有所不同,因为实际上它们可以使用不同的取值——EntryHi的那部分在实现中定义的高位必须是全1或者全0。
图6.4
EntryLo的域解释如下:
n PFN:这是相应EntryHi的VPN所转译出的物理地址的高位。
n N(不可缓存标志):置为0允许相应地址的数据访问被快存所缓存,1则不可缓存。
n C:对R4000及以后的CPU而言,内存访问有更丰富的快存策略可供选择,这被编码在一个3位长度的域中。不过除了“不可缓存”(2)和“多处理器下非信号缓存”(3)之外的其他取值在具有快存一致性能力的多处理器和更晚出现的嵌入CPU中都有不同的使用方式。
n D(dirty位):这用来作为一个“写允许”位。置为1表示允许写回,置为0则导致任何使用本转译来进行的写操作被陷入。请参见6.8节中对术语“dirty”的解释。
n V(valid位):如果置为0,那么任何一个匹配本入口的地址访问将导致一个异常。这既可以用来表示一个页当前不可访问(在一个纯虚地址系统中),也可以用来表示位于EntryLo的一对转译都不可用。
n G(global位):当一个TLB入口的G位被置起时,TLB入口将只使用VPN域来进行匹配,而不顾TLB入口的ASID域是否与EntryHi内的值一致。这就使得我们可以实现一部分地址空间在所有进程中共享,而不需要增加额外的页表。
n 0域:这些域一直都为0,但与许多保留域(reserved field)不同,它们不需要被写为0(写入数据也不会有任何事发生)。这很重要;这意味着在重装入TLB时用于产生EntryLo的内存常驻数据可以在这些域里保留一些软件来解释的数据,TLB硬件会忽略这些字段而不需要浪费宝贵的CPU时间来把这些位屏蔽掉。
PageMask寄存器至今为止已经在所有的64位CPU内实现。当前的mask域在TLB入口创建时被拷贝进去,置为1的位会起到导致虚地址的对应位在匹配TLB入口时被忽略的效果(并导致那一位不做修改地传到最终物理地址上),这有效的帮助了匹配一个更大容量的页面。地址中被屏蔽的位也同样被直接拷贝到物理地址上。
没有一款MIPS CPU允许在Mask中使用任意的位排列样式。绝大多数都是允许页大小从4KB到16MB之间,页大小是以4倍关系递增的:
NEC的Vr4200 CPU只支持4KB和16MB两种页,不过相应编码都是在硬件中固定好的。
6.3.2 Index寄存器
Index寄存器是当你有意去写一个特定的TLB入口时用来指定那个入口的,还可以用来在你使用tlbp查找某个转译后返回相应的TLB索引。
图6.5中可以看到,Index不仅仅是一个数字。P域在tlbp指令查找一个合法转译失败后被置起;由于它是寄存器的最高位,因此这个结果看起来就像是产生了一个32位的负数,检查起来是很容易的。
请注意,早期MIPS CPU各域具有不同位置,而且有效位只有6个(最多定位64个TLB入口)。
图6.5
6.3.3 Random寄存器
Random寄存器在TLB里保有一个在CPU执行每条指令时进行计数(是递减的,如果这个特征对您来说重要的话)而得的索引,它在写TLB入口的tlbwr指令执行时作为TLB的索引,以此支持写TLB入口的随机替换策略。
通常情况下您永远不需要去读写Random寄存器(图6.6所示),不过在诊断过程中它可能是有用的。我们可能会期望在系统重启(reset)时把硬件的Random域置为最大值——相当于选择最大序号的TLB入口,并且每个时钟周期它都会递减,直到达到某个基值(floor value),然后数值回卷(wrap back)变为63,重新开始递减。
因此从0号到小于基值的TLB入口不受随机替换的影响,操作系统可以使用这些槽作为永久的转译入口——在MIPS上操作系统中这被称作“绑定的”(wired)。
在早期CPU中基值固定为8,不过对这个常数设置有点霸道的做法招致了一些抱怨,因此64位的CPU引入了Wired寄存器,允许更改基值,因而也就可以让Random寄存器的取值范围有所变化。
图6.6
6.3.4 Wired寄存器
这只是一个数值,不过里面的绑定值超过TLB最高索引时的就不会带来有意义的效果。当您写入Wired寄存器后,Radom寄存器就会自动重置为指向TLB的最大序号入口。
6.3.3 Context寄存器及XContext寄存器
当某个转译不在TLB中而导致CPU引发一个异常时,不能完成这次转译的虚地址就会存入BadVaddr寄存器,而且VPN(TLB所关心的部分)也已经存入EntryHi寄存器。很明显,这已经做了足够多的工作,然而为了加速异常的处理过程,Context和XContext寄存器会以某种格式把相同的信息重新打包一下,使得形成一个立刻可以用于内存页表的指针值。
图6.7展示了这些寄存器,各个域的描述如下:
n PTEBase:这是一个存储您写入的定位信息的部分。为了实现“标准”的重装入处理过程,这应该是内存常驻页表起始地址的高位。起始地址必须选择一个第20位及以下的位为0的地址,因为Context寄存器会用来做一个“逻辑或”运算,而不是做加法。这就限制了内存保留的页表必须在虚地址上以一个1MB(疑点,照图中看来似乎应该是2MB)边界的地址起始——这大概也不是什么了不起的麻烦。
n Bad VPN / Bad VPN2:在一个地址异常之后这里会装入相应地址的高位(疑点,在图6.4中VPN是20位长,而图6.7中Bad VPN是19位长),与BadVaddr寄存器中的高位完全一样。为什么是VPN2?如果您CPU的TLB成对存放入口,那么地址的第12位就不属于TLB键值域。
VPN和VPN2的值会事先进行左移位,用来预先计算好一个入口长度大于1字节的结构指针。32位CPU中2位的移位允许4字节长度的入口,这足以装入充分多的信息以填充用于构造TLB入口另一半的EntryLo寄存器。64位CPU不仅仅有64位的EntryLo0和EntryLo1寄存器,还必须各有2个,因为每个TLB入口映射2个页;因此页表就必须期望有16字节长的入口,VPN就会左移4位。
n 0域:这些域的值永远为0
图6.7
6.4 MMU的控制指令
下面2条指令
tlbr # 根据index寄存器读TLB入口
tlbwi # 根据index寄存器写TLB入口
用来在index寄存器所选出的TLB入口和EntryHi与EntryLo寄存器中交换MMU相关数据。
您不会经常需要读一个TLB入口;当您这样做时,请记住您会覆写EntryHi(ASID)域,这被设为与当前运行进程的地址映射相关。所以请把它写回原值。
指令
tlbwr # 根据Random寄存器写入TLB入口
会拷贝EntryHi(包括ASID域),EntryLo和PageMask寄存器的内容到Random寄存器指明的TLB入口中去——如果您使用随机替换策略这将节省一些时间。实际使用中,tlbwr会在一个TLB重装入异常处理中用来写入一个新的TLB入口,其他任何情形下则使用tlbwi。
指令
tlbp # TLB查找
在TLB中查找虚页号和ASID跟EntryHi寄存器中内容相匹配的入口,并把相应入口的索引值存入Index寄存器。如果匹配失败,Index寄存器的p位被置起——这使得产生一个负数值,很容易检测。
如果超过一个入口匹配成功,任何事情都可能发生。这是个严重错误,正常情形下是永远不会出现的。
注意tlbp指令并不从TLB中取数据;您必须在其后运行tlbr(根据索引读取TLB内容)指令来做这件事.
TLB内部是流水化的,这些用于管理和检查的指令表面上掩盖了这一点。许多实现中要求tlbp指令后不能紧跟存取存储器操作。
6.5 TLB相关编程
TLB入口是通过向EntryHi和EntryLo寄存器填入所需的域并通过tlbwr或tlbwi指令把这个入口拷贝入TLB适当位置而设置起来的。
当您在处理一个TLB重装入异常时,您会发现EntryHi的内容已经为您设置好了。
请当心不要创建2个匹配同一个“地址 / ASID”组合的入口。如果TLB包含重复的入口,试图转译这个地址或查找这个地址潜在上有破坏CPU芯片的可能。有些CPU在这些情形下通过关闭TLB来保护自己,表现为SR(TS)位被置起。从此时开始直到硬件重启前,TLB不会进行任何匹配。
系统软件通常根本不需要读TLB入口。不过如果您想读它们,可以使用tlbp指令来查找一个装有特定地址的TLB入口并看到结果反映在Index寄存器。不要忘记先保存好EntryHi寄存器并在查找之后重新恢复它,因为它的ASID域可能是很重要的。
您可以使用tlbr来读取TLB入口中的内容到EntryHi和EntryLo中去。
您可以发现CPU的文档中为了进行对指令和数据地址的转译而把TLB结构划分成ITLB和DTLB;这些都是微小的硬件控制快存,它们的操作对软件是完全透明的。
6.5.1 重装入是如何产生的
当程序访问任何一个转译好的地址区域(通常在一个被保护的操作系统下,kuseg为应用程序使用,而kdeg2对应内核特权的映射)时,如果TLB中没有相应的转译纪录,CPU就会引发一个TLB重装入异常。
TLB只能映射现代的服务器或工作站物理内存空间的一小部分。规模庞大的操作系统会维护某种包含大量页转译信息的内存常驻页表,并使用TLB来作为最近所进行的转译的快存。大多情况下页表是一个可以立即使用的TLB入口的数组,您可以使用Context寄存器作为一个指向它的指针。
由于MIPS系统通常把操作系统内核置于非转译内存区kseg0,因此通常情形下是一个用户级的程序需要转译一个kuseg地址。有几个硬件特性就是为了加速这种通常情形下异常处理而设计的。首先,这些重装入异常会以向量形式置于不产生其他异常的内存低地址。第二,设计上使用的一系列精巧的小花招使得内存常驻页表被分配在内核虚地址(kseg2区域或者64位系统中的相应部分)中,这样物理内存空间就不需要存放页表中用来映射进程地址空间里的“空洞”部分。
最后,Context和XContext寄存器可以用来立即对内存常驻页表中的正确入口进行访问。
我们会在第6.7节详细剖析这个过程。但是在我们深入探讨之前,我们应该注意所有这些特性都是“非强制”的。在一个小的系统中,TLB可以用来创建一个固定的或很少更改的用以进行从程序(虚地址)到实地址转换的转译器件,这些情况中TLB甚至不需要作为一个快存来使用。
甚至在某些较大的虚存操作系统在MIPS上的实现中也都并不使用“标准”的页表。可移植的NetBSD内核的早期版本中组织了一个相对庞大的软件管理的转译信息的二级缓存,在通常的重装入代码中会进行查找;访问其转译不在二级缓存中的页面是很罕见的,这会转交给一个相对重量级的由c语言编写的处理过程,并从一个机器无关的页表中提取相关信息。
6.5.2 使用ASID
我们通过一个专门的ASID配置和把EntryLo的G位设为0来建立TLB入口,除非CPU的EntryHi(ASID)寄存器域与TLB入口的相应值相符,那些入口是永远不会与一个程序地址完全匹配的。这就允许你同时映射64或256个不同的地址空间,而不需要在进程切换时完全清除TLB。如果您不使用ASID,那么您就必须要遍历整个TLB并取消所有您不使用ASID的地址空间的映射。
6.5.3 Random寄存器与被绑定入口
硬件并不提供给您找出最近最常使用的TLB入口的手段。当您把TLB当作一个快存来使用并且需要装入一个新的映射时,唯一实用的策略就是随机替换一个入口。CPU通过维护一个在每个处理器周期都进行计数(实际中是递减的)的Random寄存器,使得做到这点十分容易。
随机替换听起来效率低的吓人;您可能恰恰牺牲了那个近来最频繁使用的入口,而那个入口几乎在很短时间内又有访问需要。但事实上当您拥有可观数量的可供选择的牺牲品时,这种情况并不会经常发生以致于带来真正的麻烦,况且大多数MIPS上的操作系统也至少留有40个可供牺牲。
不过,让某些TLB入口被确保留下来直到您主动选择将其移除常常也是很有用的。这对于映射那些您确知会经常使用的页面会很有好处,不过事实上这也非常重要,因为它们使您能够映射一些页并且可以严格确保在这些页的访问上不会产生任何重装入异常。
这些稳定的TLB入口被形容为“绑定的”:在R3000 CPU中它们由TLB的0到7号入口组成,而在R4X00以及后续的CPU中入口号的范围可以是从0到任何一个您编程写入Wired寄存器的值。TLB本身并不对这些入口做什么特殊处理;魔力来源于Random寄存器,它永远不会取从0到“绑定值减1”之间的值;它直接从“绑定值减1”开始到它的最大值之间循环变化。因此通常的随机替换就不会影响序号到从0到“绑定值减1”之间的TLB入口,这些入口被写入后直到明确被移除前都会一直存在。
6.6 内存转译:设置
下面的代码段用来初始化TLB以确保它不会对任何位于kuseg与kseg2的地址进行匹配。我们分别对R3000与R4000类型的TLB完成了这段初始化。下面就是一个简单的为R3000或相似CPU的TLB所进行的初始化动作:
#include <mips/r3kc0.h>
LEAF(mips_init_tlb)
mfc0 t0, C0_ENTRYHI # 保存ASID
mtc0 zero, C0_ENTRYLO # tlblo = valid
li a1, NTLBID<<TLBIDX_SHIFT # 索引
li a0, KSEG1_BASE # tlbhi = 不可能出现的VPN
.set noreorder
1: subu a1, 1<<TLBIDX_SHIFT
mtc0 a0, C0_ENTRYHI
mtc0 a1, C0_INDEX
addu a0, 0x1000 # 增长VPN,使所有入口都不同
bnes a1, 1b
tlbwi # 在跳转的delay slot中
.set reorder
mtc0 t0, C0_ENTRYHI # 恢复ASID
j ra
END(mips_init_tlb)
下面是一个R4000或类似CPU的TLB初始化的简单例子:
#include <mips/r4kc0.h>
LEAF(mips_init_tlb)
dmfc0 t0, C0_ENTRYHI # 保存ASID
li a1, NTLBID # 从TLB的顶部加1开始
li a0,KSEG1_BASE # tlbhi = 不可能出现的VPN
mtc0 zero, C0_ENTRYLO0 # 0是非合法值
mtc0 zero, C0_ENTRYLO1
1: subu a1, 1
dmtc0 a0, C0_ENTRYHI
dmtc0 a1, C0_INDEX
addu a0, 0x2000 # 增长VPN,使所有入口都不同
tlbwi
bnes a1, 1b
.set noreorder
nop # tlbwi后来会使用entryhi
dmtc0 t0, C0_ENTRYHI # 恢复ASID
.set reorder
j ra
END(mips_init_tlb)
让我们看一下TLB的初始化过程。
n 两个程序都从TLB的顶部开始(常数NTLBID可以在包含的头文件里,在算法里称为r3kc0.h或r4kc0.h)
n EntryLo0和EntryLo1中的0值意味着任何转译都不是合法的,不过这本身还不足以避免重复入口的麻烦。
n 注意R3000版本里的Index拥有一个需要移位的域,因此我们不能仅仅对其加1。
n 存放在每个入口中的VPN就是kseg1区域中的页的相应部分,这些被定义为非转译地址并且永远不进行查找。不过即使如此,我们依然保证所有的VPN是不相同的。
6.7 TLB异常代码示例
这段程序实现了毫无疑问是MIPS的体系结构设计师为UNIX类型的操作系统中用户地址而设计的转译机制。它依赖于为每个地址空间在内存中建立一个页表的机制。页表由入口的线性数组所构成,以VPN为索引,VPN的格式与EntryLo寄存器里的位域相同。R3000类型的单入口TLB需要给每个入口一个字的长度,而R4000类型的成对的TLB则需要4个字长(每个入口都由于为了适应更大的地址空间而增长)。
这种设计十分简单,不过却带来了其他的问题。由于用户空间中的每个4KB都需要占用一个4字节长的表空间,那么2GB的用户空间就需要2MB的表,这是个令人不安的巨大容量。当然,绝大多数用户地址空间只充满底部(代码和数据)和顶部(一个向下增长的堆栈),在中间有个巨大的间隙。MIPS所采取的解决方案是受VAX体系结构启发而来的,那就是把页表本身也放在kseg2区域的虚存空间内。这立刻简捷优雅地解决了2个问题:
n 节省了物理内存;由于页表中间未被使用的间隙永远不会被引用,实际上就没有物理内存被分配给那些入口。
n 这为上下文切换时重新映射一个新的用户页表提供了一个简单的机制,使得不需要必须在操作系统中找到足够的虚地址来立刻映射所有页表。您仅仅需要更改一下ASID的值,那么kseg2所指向的页表现在就自动重新映射到正确的页表。这简直是魔法!
当然,这看起来也导致陷入了一个致命的恶性循环,一个TLB重装入所需的东西(向kseg2装入页表映射)需要另一个TLB重装入来提供。我们同样可以解决这个问题:
n 并不是所有重装入异常中都会调用快速的TLB重装入程序;一个对页表的嵌套TLB不命中会被派发到一个通用的异常处理入口点。
n 提供一套受限的允许我们从用户的TLB不命中异常处理中来处理嵌套异常(内核TLB不命中)的机制。我们会在下面使用独立的范例来讨论这个,因为R4X00及后续的64位CPU使用了一些不同于R2000和32位CPU的小花招。
MIPS体系结构通过Context寄存器(或者为64位CPU的扩展地址而设的XContext寄存器)的格式来支持这种线性页表。
如果您令页表在1MB边界开始并且用页表起始地址的高位来设置Context的PTEBase域,那么随着用户的重装入异常处理,Context寄存器将会保有您需要重装入的入口地址,而不需要更多的计算。
6.7.1 32位R3000类型的用户TLB不命中异常处理
32位CPU拥有一个用来处理用户可访问地址的TLB不命中的异常处理入口点。由地址访问限制引起的TLB不命中被送到标准的异常处理入口。这里是一段典型的32位CPU的重装入程序:
.set noreorder
.set noat
TLBmissR3K:
mfc0 k1, C0_CONTEXT # (1)
mfc0 k0, C0_EPC # (2)
lw k1, 0(k1) # (3)
nop # (4)
mtc0 k1, C0_ENTRYLO # (5)
nop # (6)
tlbwr # (7)
jr k0 # (8)
rfe # (9)
.set at
.set reorder
UTLB的不命中异常是一小段很底层的代码,因此.set noreorder告诉汇编器(assembler)我们会负起令这段代码序列在CPU的流水线上正常执行的责任,而不需要汇编器来关心这个。.set noat告诉汇编器不允许使用at寄存器来综合指令——这很重要,因为我们是从一个任意性很强的异常中进入,at寄存器里还有未被保存过的用户状态。
k0和k1是确定交由我们来使用的,因此我们不用担心我们在里面所覆写掉的内容。
下面是对这段代码的逐行分析:
1) Context寄存器是页表的指针。mfc0指令并不对MIPS的5级流水线立即产生作用,因此我们在第(3)行之前还无法使用这个指针。
2) 某个时候我们会需要用到返回的地址;现在就在delay slot中做这件事。在页表本身也遭受到TLB不命中异常的情况下也需要做这个。
3) 运行到这一点时页表的入口地址本身或许在TLB中也没有合法的转译入口,这种情况下我们会在这里产生另一次异常。我们会在后面来处理这种情况。
4) 取存指令需要花费2个时钟周期,因此在能够使用页表的值之前我们需要等待一下。
5) 把新的值存入EntryLo。EntryHi(VPN)会由硬件在TLB不命中异常产生时自动设置,值代表未命中的地址。EntryHi中依然保留着我们先前存入的ASID值,假定操作系统曾经做过一次进程上下文切换。
6) 等待新的值到达EntryLo。
7) 向当前的Random寄存器恰好指向的TLB的位置写入内容,丢弃原先值… …管它哪个位置。不过没关系,这也正是随机替换的有趣之处呢。
8) 返回用户程序,不过每次跳转中delay slot里的指令会在我们跳到某个地址之前先去执行… …
9) rfe指令用来恢复在SR寄存器中所保存的异常产生之前的CPU状态。
这样我们使用了9条指令然后回到遭受TLB不命中的程序中去。最大的开销是在从页表中取数据时数据快存不命中而产生的。
不过我们保证过向您解释如果您不太走运地碰上页表入口地址在TLB中没有转译入口时会发生些什么。
有一点是不成问题的:像这样的双份转译失败并不常见,所以我们不必特别担忧效率问题。为页表(在特权地址空间内)的TLB不命中实现一套重量级的通用异常处理是可行的。
MIPS的异常实际上只做三件事情:
n 修改SR来关闭中断并令CPU转入内核模式
n 在EPC中保存重新开始的位置
n 被引导到异常处理程序处开始执行
为了允许第二次异常的发生并且能正确返回最初的程序中去,我们需要避免丢失原始的返回地址并且能够恢复SR成为上次异常的值。
没有硬件来支持保存返回地址,不过在上面您可以看到,异常处理程序已经把它保存在k0中;我们只需要确保通用的异常处理像大部分其他寄存器一样对待k0,并且保护它的值。
状态寄存器就复杂一些,不过在这里硬件确实会有些支持。起作用的2个标志位分别是中断使能位SR(IEc)和内核模式标志位SR(KUc)。状态寄存器实际上为这2个位提供了一个三层的栈,在异常产生时这2个位被压栈,而在异常结束的rfe指令时被弹栈,如图6.8中所示。
因为SR(Kux, IEx)构建了一个三层深的栈,即使在第二次异常产生,用户程序的值也依然安全的保存在SR(KUo, IEo)中,预备着被弹回正确的位置。
图6.8
6.7.2 R4x00类型CPU的TLB不命中异常处理
R4000和后来的CPU使用成对入口的TLB,处理双份异常的条件有所不同,这导致产生了这份不同的处理代码。
R4000拥有2个特殊的异常入口点。与R3000相同位置的入口只用来处理32位地址空间的转译;另一个入口则被提供给标记为使用可处理更大地址空间的64位指针的程序。
R4000的状态寄存器有三个域,SR(UX),SR(SX)和SR(KX),用来根据发生失败转译时CPU所处的特权等级来选择使用哪个异常处理。
R4000在决定TLB不命中何时使用特殊的处理入口以及何时把它派发到通用的异常处理程序有着不同的标准。除非R4000已经在处理一个异常——也就是说,除非SR(EXL)被置起——,否则它总是会使用特殊的处理入口。处理双份异常时就像上面所说;不过由于内核地址的不命中通常会通过与用户地址不命中相同的TLB处理程序,R4000的页表必须大得足以跨越内核的虚地址(也产生了更大的“空洞”)。
这里是R4000的32位地址空间TLB不命中的处理代码:
.set noreorder
.set noat
TLBmissR4K:
dmfc0 k1, C0_CONTEXT # (1)
nop # (2)
lw k0, 0(k1) # (3)
lw k1, 8(k1) # (4)
mtc0 k0, C0_ENTRYLO0 # (5)
mtc0 k1, C0_ENTRYLO1 # (6)
nop # (7)
tlbwr # (8)
eret # (9)
.set at
.set reorder
下面是对代码的逐行分析:
(1) 这有点怪,64位的搬运指令在这里好像并不必要:如果页表像通常一样位于kseg2,那么Context的页表基地址部分会被确保在高位全为1,因此在这里如果您使用32位的mfc0指令,k1寄存器会得到同样的值。
(2,7) 某些CPU(通常是流水线超过5级的CPU,比如R4000)在这种位置需要额外的nop指令。
(3-6) 在这里入口是成对出现的,不过EntryLo0和EntryLo1仍然只是32位的寄存器。然而,Context寄存器是为了16字节长的页表入口而设立的;这些CPU中EntryLo0和EntryLo1并没有不关心的位,软件程序需要一些页表空间来保留一些仅供软件使用的信息。
这里并不需要nop指令,因为我们使用了第2个load指令,因此在每一对load和mtc0指令间总是有一条其他指令来隔开它们。
就像以前一样,如果页表入口的地址在TLB中没有合法转译,我们可能会碰上另一个异常。这次我们还是在后面讨论这一点。
(7) 这里可能在某些CPU上需要一个nop指令,在长流水线的R4000上您就需要一个。
(8) 这里跟以前讨论的一样,是一个随机替换。
(9) MIPS III和后续的CPU有eret指令来从异常返回,并且恢复由于异常而改变的SR(对MIPS III CPU而言,一个异常对SR所做的所有事情就是置一下SR(EXL)位)。
在这些后来出现的CPU中,如果出现另一次TLB不命中会发生些什么事情呢?像以前一样,第二次不命中会被送到一个通用的异常处理入口中,不过这一次是由于SR(EXL)被置起而产生的(表示正在处理一个异常)。
产生的结果也与R3000有所不同。当SR(EXL)置起时允许产生第二个异常,不过这不会导致替换异常返回地址寄存器EPC。
在效果上,内核TLB不命中异常导致控制权转入通用异常处理入口,同时把cause寄存器和所有的用于指示TLB不命中的页表入口地址的寄存器都设置好,然而EPC却依然指向最初用户空间导致异常的那条指令。内核页表不命中将会被妥善解决(如果可以的话),然后通用异常处理会返回用户程序。显然,我们还没有做任何事情来处理最初的用户地址空间TLB不命中,因此这会立即再产生一次不命中。不过这一次,所需的内核转译内容已经可用,用户的不命中也可以成功的完成。
6.7.3 XTLB不命中处理
通过设置适当的位(一般就是SR(UX)),TLB不命中会被送到一个不同的处理向量中去,在那里我们应该有一段用于大地址空间的转译装入的程序。处理代码看起来与先前是相同的,除了使用XContext寄存器代替了Context:
.set noreorder
.set noat
TLBmissR4K:
dmfc0 k1, C0_XCONTEXT # (1)
nop # (2)
lw k0, 0(k1) # (3)
lw k1, 8(k1) # (4)
mtc0 k0, C0_ENTRYLO0 # (5)
mtc0 k1, C0_ENTRYLO1 # (6)
nop # (7)
tlbwr # (8)
eret # (9)
.set at
.set reorder
不过请注意,结果所得的内核虚存中的页表结构会比以前大得多,我们需要对内核虚存映射和转译代码做些细小的改动来适应这点。
6.8 跟踪被修改页(模拟“Dirty”位)
为应用程序提供页面的操作系统常常需要跟踪这个页面自从上次操作系统获取它(可能从磁盘上或网络上)或保存它的拷贝之后有没有被修改过。未被修改(“clean”)的页可以直接被丢弃,因为下次需要它们时可以从文件系统中简单的恢复它们。
在操作系统的说法中那些被修改过的页面被称为“dirty”,操作系统必须密切注意它们,直到应用程序退出或者这些dirty页被保存回去而就此清除掉。为了帮助实现这个过程,CISC CPU通常在内存常驻页表中维护一个位来指示这个页上发生过一个写操作。MIPS CPU不支持这个特性,甚至在TLB入口中也不。页表的D位是一个写允许位,当然也是用来标志只读页的。
这里是技巧所在:
n 当一个可写的页第一次装入内存时,它的页表入口的D位并不被改动(也就是让它成为只读的)。
n 当任意一个尝试对这个页写动作出现时会产生一个陷入;系统软件会识别这是个合法的写动作但是却通过这个事件在常驻页表中设置一个修改位——由于它在EntryLo(D)的位置,这就允许将来的写操作顺利进行而不会产生一个异常。
n 您也许希望通过设置TLB页表中的D位来允许写操作的进行,但是由于TLB入口是随机且不可预测的进行替换的,因此这个方法对记住被修改状态是没有帮助的。
6.9 内存转译和64位指针
当MIPS体系结构被发明时,32位CPU已经出现了一段时日,而且最大的程序数据也已经达到了100MB——地址空间只剩余4位左右可以用来分配。因此我们必须合理使用32位空间并且要避免它被肆意挥霍的碎片所侵蚀;这也是为什么应用程序(在用户级运行)会为自己保留31位的地址空间。
当MIPS III指令集在1991年引入64位寄存器时它是业界领先的,而且就象我们在2.7节所讨论的那样,MIPS超前了32位地址限制所真正产生压力的时候大约4到6年。双倍的寄存器大小只是必须产生出少量的额外地址位来适应未来的需要;小心对待操作系统潜在的爆炸性增长的数据结构比有效使用所有的地址空间变得更加重要。
由基本的64位地址映射而造成的实际地址空间的限制在一段时间内依然是无法解决的;它们允许被映射的用户空间或其他空间不加识别的增长到61位地址大小。然而,XContext(VPN2)域“只有”27位,这就限制了可映射的用户虚地址为40位。这样的话我们如何来实现一个40位的用户空间呢?
一个与XContext相容的页表拥有 个入口(每个对应R/VPN2的一个值,16字节长)。那就是8GB空间,超过了kseg0,kseg1和kseg2结合起来所能表示的整个空间大小。幸运的是,在R4x00以及后继的CPU中拥有另一个 字节大小,内核级权限,从0xC000 0000 0000 0000起始的映射区域可供使用。这个表中的大部分是空的,这是因为40位的用户程序地址空间(对它来说R == 0)在栈与数据段中间拥有一个极广大的间隙,在特权区域中用到的甚至更少。页表中与这个间隙相关联的部分永远不会被访问到,根本不需要映射到物理内存中去。很明显,使用某种相对紧凑的数据结构来映射内核权限地址是很有用的,不过这就牵扯到了操作系统的设计,超出了这本书的范围。
6.10 MIPS TLB的日常使用
如果您正在使用一个庞大的操作系统,那么它会使用TLB而且您几乎不会看到它。如果不是,您可能会怀疑它是否有用。由于MIPS TLB提供了一个相对通用的地址转译服务,在许多方面您或许可能从中受益。
TLB机制使您可以转换地址(在页粒度上)到任何物理地址上,这样可以重定位程序的地址空间到您机器上任何地址映射中去。如果您的映射需求有限,可以在TLB中进行所有的转译,那么就不需要支持TLB重装入异常或另外的内存常驻页表。
TLB也允许您定义某些地址为暂时或永久不可用,因此对那些地址的访问会引起一个异常,引发某些操作系统服务程序。通过使用用户级程序您可以让某些软件仅能访问那些您希望它们可以访问的地址,而且通过在那些地址中使用用户地址空间ID,您可以有效的管理多个互相不可互相访问的用户程序。您可以写保护某一部分内存。
TLB的应用不仅仅是这些,这里有个列表来指明了它可应用的范围:
n 访问不方便的物理地址范围:MIPS系统的硬件寄存器绝大部分方便地位于实地址的0-512MB范围,您可以使用一个kseg1区域内的相应指针来访问这个范围。而拥有不在这个期望区域内的硬件的地址,您可以映射它的实地址高位到一个方便的映射区域内,比如kseg2。这个转译的TLB标志位应该设置为不可缓存,不过接下来书写程序时就可以当作这些地址在方便的位置那样。
n 异常处理程序的内存资源:假设您希望运行一个异常处理程序时不使用保留的k0/k1位来保存现场。这样的话,您就可能碰上麻烦,因为通常来说MIPS CPU并没有地方能让您保存寄存器时不覆写其他至少一个寄存器。您可以使用zero寄存器作为基地址来进行load或store动作,但是配上一个正的偏移值(offset)后这些地址会位于kuseg的第一个32KB内,而配上一个负的偏移则会位于kseg2的最后一个32KB内。不使用TLB的话,这些是没什么意义的。有了TLB后,您可以映射这个区域的一页或多页到可读写内存区域内,然后使用0基址的store动作来保存上下文从而挽救了您的异常处理。
n 非虚存系统中可伸展的栈和堆:甚至在您没有磁盘并且没有支持完整的分页需求时,根据监视应用程序的栈和堆的增长情况来扩大它们也是很有用的。在这个情况下您需要TLB来映射栈或堆的地址,然后根据TLB不命中事件来决定是否分配更多内存或者应用程序已经失控。
n 模拟硬件:如果您有一些某些时候可用而某些时候不可用的硬件,那么通过映射区域来访问其寄存器可以在适当配置的系统中直接与硬件建立联系,而访问不可用硬件时就会导致调用起一个软件的异常处理。
总而言之,拥有所有这些符合大操作系统规范的的精巧设计的TLB,是一个很有用并且能直接面向编程者的通用资源。
6.11 非UNIX操作系统的内存管理
为桌面以外用途所设计的操作系统一般称为实时操作系统(RTOS),这盗用了一个曾用来表示某些真正实时的术语。这一章第一部分所列出的unix类操作系统拥有您所能在小操作系统中所有能找到的元素,不过许多RTOS会更加简洁。
这个领域很新,并没有事实上的标准。领域内的先锋可能是Microsoft的Windows/CE,而且那个操作系统的内部描述目前还不能自由获取。所以我们将把话题限制在几个方面内。
非桌面系统更倾向于提供一套简单而紧密集成的函数方法;而不需要支持一个变化较大的范围内的程序,这包括第三方或者用户编写的程序,进程的保护不显得特别重要。我们期望较小的操作系统有能许可更多的操作,这是由于应用程序的编写者影响力变得更强。这样做究竟是不是很好目前还并不明确,不过早先的RTOS根本就没有保护机制。
作为装入程序的一种方式,调页就很有意义,因为您不需要做装入并不需要的那部分程序的工作。没有磁盘的系统或许并不会换出含有dirty数据的页,然而,调页在没有它的情况下依然很有用。
当您正试图理解一个新的内存管理系统,第一件事应该是理解内存映射,包括为软件提供的虚映射和系统的实地址映射。是概念上很简单的虚地址映射使得unix的内存管理变得直观。但是定位嵌入式应用的系统通常并不把根基建立在内存管理相关的硬件上,进程的内存映射常常有未映射内存隐藏在其中。用铅笔、纸和耐心就足以解决它了。