原文链接: http://www.informit.com/articles/article.aspx?p=29961&seqNum=5
当一个进程要访问一个虚拟页,而这个虚拟页对应的PTE不在页表中, 或者这个PTE在某种方式上禁止访问,比如这页不存在或者访问模式跟这页的访问权限冲突,那么就会触发一个页错误。 页错误由CPU触发,page_fault_handler处理。
因为Linux使用按需调页,还有像写时复制的基于页错误的优化,页错误在普通的操作中发生并且 不一定是错误。因此当执行页错误处理函数时, 首先要做的就是确定页错误是不是访问了正确的页面。如果不是, 页错误处理程序就简单的向出错的进程发送一个段错误信号并返回。否则,执行下面可能的动作:
这些动作或者创建一个新页,或者更新一页, 处理函数还要相应的在页表中创建或更新PTE。因为页表也是按需创建的, 安装一个新的PTE,可能要求分配并且初始化一个中间的和一个PTE目录 (全局目录保证是存在的)。页错误处理函数可以使用pmd_alloc()和pte_alloc()函数来执行这些操作。在处理函数安装PTE之前,也会更新accessed和dirty位。因为页错误本身就标识了正在访问这一页,就使用 pte_mkyoung()无条件的打开accessed位。从另一方面说,只有在因为写访问导致的页错误时才使用pte_mkdirty()设置dirty位。在更新页表之后,页错误函数返回,之前出错的程序接着执行。这样之前导致错误的指令就会重新执行,由于更新过的页表现在可以正常的完成执行。
上面的描述暴露了两个重要的问题:内核怎么判断一个访问是否是合法的,还有,如果是的话, 怎么确定应该执行哪个动作?
为了验证访问的合法性, 页错误处理程序搜索vm-area列表(或者是AVL树)来找出包含了要访问的页的vm-area。如果vm-area存在并且访问权限标识(VM_READ, VM_WRITE,和VM_EXEC)允许这个访问, 那么这个访问就是合法的。第二个问题的答案取决于页表在进入处理程序时存在的状态:
到目前为止描述的页错误处理是一个复杂的操作:可能会涉及到从交换空间读取页数据,从任何一种文件系统,甚至是远程主机读取。幸运的是,就像在4.5.2节看到的, 内核中平台相关代码只需要处理对平台相关的就可以了,因为其他所有的工作都由Linux页错误处理函数做了。
想要更清楚的理解页错误处理函数是怎么跟内核配合的,看一下Linux是怎么处理写时复制页的。 假设有一个进程A,它有一个单独的虚拟内存区域,这个区域是可写的,地址空间从0x8000到0xe000。再进一步假设只有在0xa000地址的页驻留在虚拟内存中。 图4.35 (a)描述了这个情况。在左上角,有task结构体task A。task结构体包含了一个指向mm结构体的指针,标识这个进程的地址空间。mm框里面,有一个vm-area链表指针,用mmap标识,还有一个pgd标识的指向页表的指针。在真实环境中,pgd指向页表全局目录。这只是为了简化图表。
图 4.35. 写时复制操作的例子
Page table展示为线性表。因为这个进程在虚拟内存中只有一个单独的区域,vm-area链表就只有一条数据,在vm-area这个框里展示了这个信息。为了减少复杂性,vm-area仅仅显示了区域的起止地址和访问权限(RW,读写)。因为假设了0xa000页是常驻的,页表中有一个数据项,并且映射了一些页帧。在图中,假设这个虚拟页对应的物理页帧是100。因为折页既可以读又可以写,所以这页对应的PTE的权限位是RW。
现在假设进程A调用了clone2()系统调用,没有设置CLONE_VM 标识。在传统的UNIX系统上, 与调用fork()一样然后会创建一个调用者副本的新进程。clone2()返回后将状态如图4.35 (b)描述。如图所示,新进程拥有自己的task结构体,就是task B,还有它自己的mm,vm-area和页表的副本。这两个进程是完全相同的,注意clone2()怎么关闭的父进程和子进程PTE的写权限。它对每个可写pte执行此操作, 以尽可能长时间地延迟可写页面的复制。写时复制的第一步是关闭可写页的PTE写权限;这可以保证不触发页错误就不能修改页数据。注意vm-area结构体仍然保留了RW的访问权限。
那么当其中一个进程试图向虚拟页0x8000写数据时会发生什么?假设进程A先向这页写数据。因为PTE只允许读访问,这样写就会触发一个页错误。页错误处理函数执行完前面章节描述的步骤,然后先定位到对应的vm-area。然后检查vm-area是否有写权限。因为进程A的vm-area仍然有RW访问权限,所以写入是可以的。然后页错误处理函数检查PTE是否在页表中,PTE的present位是否设置了。因为这页是常驻的,所以这两个检查都会通过。最后一步,页错误处理函数检查是否正在处理一个页的写访问操作,但是它的PTE没有写访问权限。对这个例子来说,处理函数检测出来现在就是写时复制页的情况了。现在开始检查100页帧的页帧描述符,看当前有多少个进程正使用这一页。因为进程B仍然在使用这一页,计数就是2,页错误处理函数确定必须复制这个页帧。首先分配一个空闲的页帧,比如是页帧131,将原先的页帧复制到新页帧上,将进程A的PTE指向页帧131。因为现在进程A有了一份这页的私有备份,这个PTE的访问权限就可以设置成RW了。页错误处理函数就返回了,后面所有的写访问都不会有错误了。图4.35 (c)描述了当前存在的状态。
注意,进程B上的PTE还是没有写权限,即使现在只有它自己在使用100页帧。这个状态一直保留到这个进程试图执行一个写操作。当发生这个事情的时候,页错误处理函数再次调用,和进程A一样的步骤再来一次。然而,在检查100页帧描述符的时候,发现没有其它人在用了,就不用复制一个备份,直接给PTE增加写权限就行。
Linux内核提供了平台无关代码page_fault_handler (handle_mm_fault()在mm/memory.c文件中) 会处理好页错误的大部分情况。平台相关代码负责解释CPU可能发起的任何虚拟内存相关的错误,必要时调用页错误函数。Linux页错误处理函数接口如下:
int handle mm fault( mm, vma, addr, access type);
这个函数有4个参数:mm是异常发生的进程地址空间mm结构体指针,vma是包含了要访问页的vm-area指针,addr是导致发生错误的虚拟地址,Access_type标志访问类型(读,写或执行)。返回值表明页错误处理的结果。返回1表示错误处理成功结束并且是一个minor fault。意思是这页已经在内存中了,比如已经有其它进程在使用这页了。返回值2表示错误也成功处理了,但是表示是major fault,因为这页数据必须从磁盘中读取,比如是从文件或交换空间。返回值0表示页错误不能正确处理,内核还会给进程发送一个总线错误信号(SIGBUS)。最后,负值表示内核已经完全没有内存,错误处理会被立即终止。
在调用 handle_mm_fault()之前, 平台相关的代码应该先执行这些步骤:
如果这些全部成功执行,Linux页错误处理程序就可以执行了。在错误处理程序返回后,平台相关的代码负责统计这次错误是minor还是major,就是返回值是1还是2(这个统计信息保存在task结构体中)。如果返回值是0,会给进程发送一个总线错误信号,如果是负数,进程会被立即终止(好像调用了 exit())。
如果上面的步骤其中一个失败了,平台相关的代码应该做什么?答案取决于哪一步失败了。一般前两个步骤不会失败(确定导致错误的地址和访问类型)。
如果第三步失败了,页错误发生在内核模式下。这通常表示发生了一个内核BUG,会导致panic(内核停止运行)。然而,Linux内核可能在从用户/内核边界时间复制数据时正常的引起内存错误。这样的话,内核模式下发生页错误时,平台相关代码必须检查是否是因为这样的一次复制导致的,如果这样的话,要发起一个对应的恢复动作。这些在第5章, Kernel Entry and Exit, 详细描述。
如果第四步失败了,没有vm-area包含这个要访问的页。通常是因为要访问不存在的虚拟内存。如果这个错误发生,平台相关代码会给进程发送一个段错误信号。然而有两个特殊情况:如果要访问的页刚好在设置了VM_GROWSUP的vm-area上面或者是刚好在设置了VM_GROWSDOWN标识的vm-area下面,平台相关代码必须扩展对应的vm-area,让它包含要访问的页,然后用扩展的vm-area完成页错误。这个机制是为了实现栈自动扩展,并且只会在扩展后栈大小不会超过栈大小限制(RLIMIT_STACK)时执行,并且不会超过setrlimit()简历的虚拟空间限制(RLIMIT_AS)。
如果第五步和最后一步失败了,进程正在用一种非法的方式访问地址空间(比如,执行只读的页),平台相关代码会给进程发送段错误信号。
当IA-64上一个虚拟内存相关的错误发生时,第2章 IA-64架构写的中断处理程序已经初始化了。 重新执行,除其它外,处理程序会将CPU切换到最大权限执行层级(level 0),关闭中断,激活寄存器r16 to r31的bank 0, 将控制权交给中断向量表中(IVT)对应的处理函数。
这个架构一共有13种虚拟内存相关的错误,每种都有自己的处理函数。6个处理TLB未命中和另外7个。3个主要的控制寄存器包含这些处理函数的信息:
错误处理函数首先查询这些寄存器来确定怎么处理这个错误。bank 0寄存器这时候已经激活了,所以错误处理程序可以使用r16到r31寄存器。这样做可以避免保存CPU状态到内存里面。实际上,处理函数会尽可能在只使用bank 0寄存器来完成整个错误处理。然而,因为bank 0寄存器只有ic关闭的时候才能用,有一些更复杂的错误处理,特别是需要调用Linux页错误处理函数的错误处理,强制关闭快速通道,然后强制将必要的CPU状态用一个pt-regs结构体保存在内存中(请看第3章,Processes, Tasks, and Threads)。一旦这些状态保存下来后,就可以将控制权移交给Linux页错误处理函数。
表 4.4. IA-64 TLB 未命中错误
错误 |
描述 |
ITLB FAULT |
指令TLB中指令访问未命中。 |
DTLB FAULT |
数据TLB中数据访问未命中。 |
VHPT TRANSLATION FAULT |
数据TLB中VHPT访问未命中。 |
ALTERNATE ITLB FAULT |
指令TLB中指令访问未命中并且VHPT遍历器禁用。 |
ALTERNATE DTLB FAULT |
数据TLB中数据访问未命中并且VHPT遍历器禁用。 |
DATA NESTED TLB FAULT |
中断关闭时TLB中数据访问未命中。 |
表4.4 列出了6种类型TLB相关的未命中错误。如表所示,IA-64 按照未命中原因将TLB错误分类。针对不同的触发类型,这有单独的处理函数:指令获取(执行访问), 数据访问 (读写访问), 和VHPT遍历访问。ITLB和DTLB未命中是当访问禁用VHPT的区域时触发的 (VHPT禁用是区域寄存器中某一个位来控制的)或者是VHPT完全禁用时(由页表地址寄存器位pta.ve控制)。DATA NESTED TLB FAULT 在当中断采集禁用时发生了一个TLB未命中错误时触发 (ic 位关闭), 例如正在处理另一个错误。嵌套错误通常是一个内核BUG。然而就像下面看到的,Linux/ia64利用嵌套TLB未命中错误支持虚拟映射线性页表的随机(nonspeculative,不可推测的)访问。
先观察一下 DATA NESTED TLB FAULT 是怎么工作的。这是一个高效率的辅助函数,在可以找到对应PTE的情况下,将虚拟地址addr转换为物理地址pte_paddr。因为这个错误只在ic关闭的情况下发生,CPU不会更新控制寄存器,保留原始错误信息继续。不幸的是,控制寄存器中的信息不足以让嵌套DTLB未命中函数完成它的工作。这样,Linux/ia64 利用三个bank 0寄存器在原始错误和嵌套DTLB未命中处理函数间传递额外的信息。第一个寄存器用来传递虚拟地址addr,第二个传递rlabel,在嵌套DTLB处理函数结束时返回到哪里执行。第三个寄存器用于保存结果,返回 pte_paddr。
嵌套DTLB未命中处理函数通过关闭数据转换位psr.dt然后遍历第三层页表来操作。因为在页表遍历的时候使用了物理数据访问,所以不会有TLB未命中的风险。页表遍历的起点由访问的地址addr所在区域来决定。如果访问区域5,会使用内核页表;否则 (访问区域0到4), 会使用当前进程的页表。如果遍历完全成功,找到的PTE物理地址就会放到结果寄存器里面,然后跳转到rlabel将控制权交给原先的处理函数。如果在遍历过程中出现任何错误(比如因为虚拟地址没有映射过), 控制权就移交给Linux页错误处理函数。
在解释了嵌套DTLB未命中处理函数的操作之后,描述ITLB和DTLB未命中处理程序就很容易了。TLB未命中处理函数第一步读取ifa控制寄存器,确定错误地址addr。在第二步,设置了bank 0寄存器,这样在后面发生 DATA NESTED TLB FAULT时,嵌套DTLB处理函数就可以找到必要的信息了。错误地址 addr和返回地址 rlabel放在了bank 0寄存器,就是为了这个目的。第三步,真正的工作就可以开始了:处理函数使用thash指令将出错地址addr转换成pte_vaddr,addr地址对应的PTE可以在虚拟映射线性页表中找到。页错误处理函数尝试通过在这个地址加载一个字内存(word)来读取这个PTE。如果 pte_vaddr的TLB数据存在,成功加载后,这个PTE就可以安装到ITLB(对于ITLB未命中来说)或者DTLB (对于DTLB未命中来说)。相反,如果 pte_vaddr对应的TLB数据条目没有找到,就触发一个 DATA NESTED TLB FAULT,CPU将控制权限转交给内嵌DTLB未命中处理函数。按照前面描述的,这个处理函数以物理模式遍历页表,如果存在addr的映射,将这个PTE的物理地址pte_paddr放到结果寄存器中。嵌套未命中处理函数返回,将控制权还给原始的处理函数,这时数据转换位dt还是关闭的。原始的处理函数现在就拥有了这个PTE的物理地址pte_paddr,因为数据访问仍然在物理模式,处理函数可以直接从这个地址加载内存而不会有任何其他错误的风险。这时就可以给出期望的PTE,然后安装到对应的TLB中 (ITLB或DTLB)。
使用这种方式能够这么优雅的处理TLB未命中错误,依赖于一旦PTE地址确定下来,虚拟和物理访问场景都有相同的执行序列。唯一的不同点就是在物理层还是在虚拟模式下加载PTE。这就意味着rlabel地址也可以是尝试从虚拟映射线性页表读取PTE的加载指令的地址。如果这个虚拟的访问失败了,加载指令会在嵌套DTLB未命中处理函数返回后重新执行,不过现在是在物理模式。注意,要想使用这个技术的话,加载指令的地址寄存器必须和嵌套DTLB处理函数使用的相同,用来返回PTE物理地址的结果寄存器相同。
当在开启了VHPT遍历的区域中发生了一个TLB未命中错误时,遍历器首先会尝试自己处理这个未命中错误。从概念上讲,它使用thash指令将出错的地址 addr转换为pte_vaddr,可以在虚拟映射的线性页表中找到addr的PTE的地址。如果pte_vaddr对应的条目在TLB中,那VHPT遍历器(通常)可以自己处理这个未命中错误。另一方面,如果这个TLB条目也没有命中,CPU就发起一个VHPT TRANSLATION FAULT,并将addr放在ifa控制寄存器,pte_vaddr放在iha。
一旦VHPT未命中处理函数开始执行,它先从ifa中提取原始出错地址addr,然后在物理模式下遍历页表,跟嵌套DTLB未命中处理函数类似。一旦找到了这个PTE的物理地址,就将这个PTE加载出来然后安装到对应的TLB (DTLB:原始访问是数据访问,否则ITLB)。而且,VHPT构造并且向DTLB中插入一个映射了包含iha的地址的转换信息。这就可以确保将来附近地址的TLB未命中可以通过虚拟映射线性页表来处理。
因为这个处理函数在TLB中插入了两个转换数据,即使两个中的一个成功了,也必须确保CPU可以正常运行。幸运的是,在这个特殊的场景中,插入的顺序并不重要,因为不管是哪种方式,CPU都可以正常执行:如果原始出错地址的转换数据已经存在,访问引起的错误可以完全成功,而不会引起TLB未命中错误。相反,如果虚拟映射线性表的转换数据存在, VHPT遍历器再次遇到原始错误地址的TLB未命中错误,或者CPU发起一个常规的ITLB或DTLB错误,哪一种都可以处理。
注意对一个完美的VHPT遍历器来说,CPU只会抛出VHPT TRANSLATION FAULT(VHPT转换异常)—通常ITLB 或DTLB FAULT不会抛出。然而,IA-64架构可以让CPU设计者选择实现一个不是太完美的VHPT遍历器,或者完全忽略它。如果VHPT遍历器不能处理部分TLB未命中错误,CPU必须发起一个ITLB或DLTB异常,这样就可以做到这个灵活性了。一个极端的场景,VHPT遍历器不存在,这就意味着不会发生VHPT转换错误,只会发生ITLB或DTLB错误。比较现实的场景,VHPT遍历器可以处理大部分TLB未命中错误,除了一些非常极端的场景,必须发起一个ITLB或DTLB错误来处理。例如,只要在第二层缓存中可以找到PTE,Itanium VHPT 就可以处理虚拟页表访问。如果这个PTE没有缓存,遍历器就放弃了,然后发起一个ITLB或DTLB错误。
当发生一个TLB未命中且禁用了VHPT遍历器,CPU就会分发给备用ITLB和DTLB处理函数。因为Linux/ia64中的虚拟地址空间区域6和7做了相同的映射,它们没有关联的页表,这两个区域VHPT遍历器也被禁用。换句话说,就是访问区域6和7中发生的TLB未命中,总会导致调用备用TLB未命中处理函数。
对其它区域来说,VHPT遍历器通常都是打开的。然而,有时候为了做性能测试和调试,也可能会关闭VHPT。底线是访问区域6和7总是由备用未命中处理函数处理,访问区域0到5只是有时候在这里处理。这样在做其它事情之前,备用TLB未命中处理函数先检查导致异常的访问是不是区域6或7。如果不是的话,未命中错误会重定向到之前描述的普通ITLB/DTLB未命中处理函数。如果是这样的话,处理函数可以直接从访问的地址计算出需要的PTE。这样就不需要遍历页表。通过这种方式计算的PTE,只允许在内核中读写执行,映射到物理地址,与虚拟地址的最不重要的61位(the least significant bits,低61位)相同。dirty和accessed位设置为1,内存属性从访问的区域继承:区域6使用uncacheable属性,区域7使用cacheable属性。
计算PTE非常简单,不过还是要考虑一些边边角角的场景。首先,在用户层访问区域6和7会导致段错误。这看起来比较奇怪,因为插入的转换数据的权限位, 为了应对此类访问将在所有方式上阻止用户级访问。然而,必须重定向的原因是IA-64架构不允许相同物理地址有冲突的内存属性映射。假设某个应用程序访问同一个特定物理地址,先通过区域6再通过区域7。两个都会触发一个段访问信号,但是应用程序可以解释并跳过这个错误指令。现在,如果不阻拦用户层访问,映射的这两个访问会是同一个物理地址,两个都是缓存或者没有缓存,这样就破坏了架构,某些CPU上还可能会引起错误。因为这是不能接受的风险,备用TLB未命中处理函数必须在第一个位置插入来阻止这样的转换。拒绝用户层访问区域6和7就非常容易做到这一点。
内核中预加载会引起一个相关问题。如果TLB未命中没有延迟(dcr.dm是0),内核中的预加载可能会导致任意地址的TLB未命中。如果这个地址正好落到区域6或7中,预测加载会触发一个备用TLB错误。这会再次引起插入与内存属性冲突的转换数据的风险。为了防止这个事情的发生,备用DTLB未命中处理函数也会检查这个错误访问是否由预测加载导致的,如果是这样的话,就开启异常推迟(deferral)位(psr中的ed),而不是插入转换数据。所有在区域6和7的预加载都产生NaT值,除非TLB中已经存在了正在访问的内存页的转换信息。这个方法有时候可能会产生一个不必要的NaT,还会导致一点点性能损失,但是不影响内核的正确运转。这个方法还有这样的优势,就是预测加载不会用不必要的转换数据干扰TLB。
现在把注意力放在PTE相关的错误上。如表4.5所示,有7个相关错误。INSTRUCTION ACCESS-BIT FAULT 和DATA ACCESS-BIT FAULT 发生在位accessed(A)关闭的时候,抓取指令或者数据访问。Linux使用这个位来执行页替换算法,这些错误的处理函数会开启PTE的这个位,更新TLB信息然后返回。类似于DTLB/ITLB未命中处理函数,使用虚拟映射的线性页表来访问PTE,在必要时会陷入到内嵌的DTLB未命中处理函数。
Table 4.5. IA-64 PTE 错误.
Fault |
Description |
INSTR. ACCESS-BIT FAULT |
指令访问accessed位清零的页 |
DATA ACCESS-BIT FAULT |
数据(读或写)访问accessed位清零的页。 |
DIRTY-BIT FAULT |
写入dirty位清零的页。 |
PAGE NOT PRESENT FAULT |
访问present位清零的页。 |
INSTR. ACCESS RIGHT FAULT |
访问没有执行访问权限的页。 |
DATA ACCESS RIGHTS FAULT |
访问与访问权限冲突的数据(读写)的页。 |
KEY PERMISSION FAULT |
访问违反保护键值权限的页。 |
当写入一个PTE上dirty(D)位清零的页时会触发DIRTY-BIT错误。这个错误跟 DATA ACCESS-BIT FAULT处理方式很像,不过要同时设置dirty和accessed位。只设置dirty位也正确,但不是最好的,因为处理函数返回后,会触发低优先级的DATA ACCESS-BIT FAULT。换句话说,在这个处理函数设置了这两个位,就可以在一个错误函数中处理两个错误,这样就有更好的性能。
当PTE中的present位清零或者访问一个页的方式与PTE中的权限位冲突时,会发起PAGE NOT PRESENT FAULT, INSTRUCTION ACCESS RIGHT FAULT和DATA ACCESS RIGHTS FAULT。IVT自己不能处理任何一个错误。这些错误处理无条件的转移到Linux页错误处理,由合适的函数解决。这些通常都会改变PTE。如果这样的话,Linux通常都是刷新旧的TLB数据。然而,Linux假设present位清零的PTE从来不安装TLB,所以交换一页进来,设置present位后不会刷新TLB。因为IA-64没有遵循这个假设,PAGE NOT PRESENT FAULT(页不存在错误)处理程序必须在调用Linux页错误处理函数之前刷新TLB数据。
最后一个PTE相关的错误是KEY PERMISSION FAULT。 这个错误只会在保护键检查开启时才会发生 (psr.pk 是 1)。因为Linux/ia64没有使用这个保护键寄存器,所以这个检查禁用了,因此这个错误不会发生。
全局TLB清理指令和软件TLB未命中处理函数会造成竞争。假设有两个CPU, P0 和P1,P0 访问虚拟地址a,P1更新这个地址的页表项。可以在图4.36 中看到事件发生的序列。 P0 访问虚拟地址 a, 这可能会触发一个TLB未命中。TLB未命中处理函数就开始执行然后从页表中读取对应的PTE。紧接着,P1 可能更新这个PTE, 为了确保所有的CPU都能知道这个修改,可能会使用ptc.ga刷新地址a的页对应的TLB数据。对 P0 来说,这个指令没有什么效果,因为地址a对应的TLB数据还不存在。然而在TLB刷新完成后,P0 可能就完成了TLB未命中处理函数,并且执行一个itc 指令插入步骤3中读取的PTE。这意味着在步骤6之后,P0 会包含一个旧的转换数据:地址a会使用在第3步中读取的旧PTE来转换,而不是使用第4步更新后的!
图 4.36. CPU P0的TLB未命中处理与CPU P1上TLB刷新之间的竞态示例
为了避免这个问题,TLB未命中处理函数可以在第6步再次从页表中读取PTE,然后检查是否改变了。如果确实改变了,就有两个选择:处理函数可以从头开始,或者在第6步刷新数据之后返回。后面,内存访问会再次执行,但是因为地址a的转换项目仍然不存在,TLB未命中处理函数会再次执行。最终未命中处理函数可以没有冲突的执行,地址a对应正确的PTE也会插入到TLB中。
注意虽然使用了TLB未命中为例来描述这个竞态(race condition), 但是任何基于页表内容插入TLB转换数据的错误处理函数都可以引起这个问题。同时注意这个问题,这个竞态是由错误处理跟ptc.ga指令不是原子的导致。如果全局TLB刷新是使用处理器间中断的方式实现的,那么这个错误处理就是原子的,也不需要重新检查PTE。
通过前面讨论的内容可以清楚地看到有很多场景要求Linux页错误处理函数来辅助。然而在能够调用平台无关代码handle_mm_fault()之前,IA-64平台代码必须定位到对应的vm-area结构体,验证访问模式与vm-area结构体中的权限不冲突。因为当控制权转移到Linux页错误处理时这些动作就必须执行完成,所以就在一个叫做ia64_do_page_fault()的通用函数中实现了。这个函数执行的大多数动作都是明确的,按照本章前面描述的步骤走。
首先,注意由平台相关代码来负责支持自动扩展有VM_GROWSUP或VM_GROWDOWN标志的vm-area。对大部分平台来说,栈增长要么是往高地址要么往低地址,这样导致这些平台只支持标记出来栈增长的方向。IA-64比较特殊,因为它要同时支持两个方向增长,寄存器栈向高地址增长,内存栈向低地址增长。如图图4.37 描述, 这个需求引入了不确定性。假设vm-area 包含地址空间 0x4000到0x6000 并且往高地址增长,vm-area 2包含地址0xa000到0xe000 向低地址增长。现在如果有一个进程访问地址 0x6100, vm-area 1或 vm-area 2应该扩展吗? 不知道进程的访问意图就没办法在保证正确的前提下解决这个不确定性。然而,因为寄存器栈引擎(register stack engine, RSE)按照严格的顺序方式访问寄存器后备存储,对于设置了VM_GROWSUP标识位的vm-area,Linux/ia64只有在引起页错误时访问的内存是刚好在vm-area上面一个字时才会扩展。这就意味着图中描述的场景可以通过扩展vm-area 2来解决。只有地址 0x6000 访问过 vm-area 1才会扩展。这个策略是为了保证正确性,VM_GROWSUP仅用于映射寄存器到后备存储内存。
不同增长方向的vm-area引起的两意性
第二个问题是怎么处理由于预测加载导致的异常 (更多关于预测加载信息请看第 2章, IA-64 Architecture)。IA-64 架构定义了两种预测模型: recovery 和no-recovery [76]。recovery 模式要求预测加载总是有对应的恢复代码。顾名思义,no-recovery 不要求有恢复代码。为了支持这个模式,预测错误不能产生NaT,除非可以保证非预测加载相同的地址也会失败。
Linux/ia64在用户层不支持不可恢复模式,因为这可能会引起程序不可预期的错误。来看看为什么,假设一个应用程序实现了一个分布式共享内存系统(DSM),利用段错误信号来检测哪页的数据需要从远程主机上获取。TreadMarks DSM 就是一个这样的例子[3]。如果这个程序执行了一个没有映射过内存的预测加载,并且这块代码使用no-recovery模式,Linux页错误处理函数面对一个困难的选择:是返回一个NaT还是发送一个段访问错误信号。如果返回一个NaT,预测加载的是一个DSM页,信号处理函数会从远程主机抓取数据,这样就会导致一个错误。另一方面,如果它发送了这个信号,那么如果这个预测加载访问的是一个非法地址的话,就会造成错误(error)。如果应用程序知道预测加载,并且可以确定是否要产生一个NaT的话,就可以解决这个问题。不过因为这会导致应用程序不能对接到其它架构,Linux/ia64选择不支持不可恢复模式。
可恢复模式不会存在这个问题,因为恢复代码可以确保返回一个NaT是安全的。这个动作也解答了预测加载导致的页错误应该怎么处理:因为Linux/ia64不支持no-recovery模式,通过在产生中断的进程的处理器状态寄存器(psr)设置ed位来处理这次访问。 在从页错误处理函数返回后,预测加载重启时,这个动作让CPU在目标寄存器上设置一个NaT值。在这个DSM例子中,可能会导致多余的调用应用程序的恢复代码,虽然损失了一点性能,但是不会产生错误。