案例研究:
在我们结束对虚拟内存的研究之前,让我们更仔细地研究一下在VAX/VMS操作系统中发现的一个特别干净和出色的虚拟内存管理器。
在本文中,我们将讨论这个系统,以说明在一个完整的内存管理器中,前面章节中提出的一些概念。
23.1背景
VAX-11微型计算机体系结构是由数字设备公司(DEC)在1970年代末引入的。DEC在微型计算机时代是计算机行业的一个巨大的参与者;不幸的是,一系列糟糕的决定和个人电脑的出现慢慢地(但肯定地)导致了他们的灭亡[C03]。该架构在许多实现中实现,包括VAX-11/780和功能较弱的VAX-11/750。
系统的操作系统被称为VAX/VMS(或者只是普通的vm),其中一个主要的架构师是Dave Cutler,他后来领导了开发微软Windows NT的工作[C93]。VMS有一个普遍的问题,它将在广泛的机器上运行,包括非常便宜的VAXen(是的,这是正确的复数),在相同的体系结构家庭中使用非常高端和强大的机器。因此,操作系统必须拥有在这一庞大系统中工作(并且运行良好)的机制和策略。
难点:如何避免一般性的诅咒
操作系统通常有一个被称为通用性的问题,他们的任务是广泛支持广泛的应用程序和系统。诅咒的基本结果是操作系统不太可能支持任何一个安装。在VMS的情况下,这个诅咒是非常真实的,因为VAX-11架构是在许多不同的实现中实现的。
虽然操作系统常常依赖硬件来构建高效的抽象和幻象,但有时硬件设计者并不完全正确;在VAX硬件中,我们将看到一些这样的例子,以及VMS操作系统如何构建一个有效的工作系统,尽管存在这些硬件缺陷。.
23.2内存管理硬件
VAX-11为每个进程提供了一个32位的虚拟地址空间,分为512字节的页面。因此,一个虚拟地址由一个23位的VPN和一个9位的偏移量组成。此外,利用VPN的上两部分来区分页面的大小;因此,系统是分页和分割的混合,就像我们以前看到的那样。
地址空间的下半部分称为进程空间,每个进程都是惟一的。在进程空间的前半部分(称为P0)中,找到了用户程序,以及向下扩展的堆。在进程空间的后半部分(P1)中,我们找到了堆栈,它是向上增长的。地址空间的上半部分称为系统空间(S),尽管只使用了其中的一半。受保护的操作系统代码和数据驻留在这里,操作系统以这种方式共享整个进程。
vm设计器的一个主要问题是VAX硬件(512字节)中页面的大小非常小。由于历史原因,这样的大小有一个基本的问题,就是使简单的线性页表过大。因此,vm设计人员的首要目标之一是确保vm不会用页表压倒内存。该系统通过两种方式将压力页表放在内存中。首先,通过将用户地址空间分割为两个,VAX-11为每个流程提供了一个页面表(P0和P1);因此,在堆栈和堆之间的地址空间中未使用的部分不需要页表空间。基础和边界寄存器使用如您所期望的;一个基本寄存器保存该段的页表的地址,并且边界保持它的大小(即。页表条目数。其次,通过在内核虚拟内存中放置用户页表(对于P0和P1,因此是两个进程),操作系统可以进一步降低内存压力。因此,在分配或增加一个页面表时,内核会在内存中分配自己的虚拟内存空间。如果内存受到严重的压力,内核就可以将这些页表的页面交换到磁盘,从而使物理内存可供其他用途使用。
将页表放在内核虚拟内存中意味着地址转换更加复杂。例如,为了在P0或P1中转换一个虚拟地址,硬件必须首先在其页面表中查找该页面的页表条目(该进程的P0或P1页表)。但是,在这样做的时候,硬件可能首先需要查阅系统页表(它存在于物理内存中);通过完成这个转换,硬件可以学习页表的页面地址,然后最终了解所需内存访问的地址。幸运的是,所有这些都是由VAX的硬件管理的TLBs实现的,它通常(希望)可以绕过这个费力的查找。
23.3真实地址空间。
研究VMS的一个简单的方面是,我们可以看到一个真实的地址空间是如何构建的(图23.1)。到目前为止,我们已经假定了一个简单的地址空间,只包含用户代码、用户数据和用户堆,但是正如我们可以看到的,一个真实的地址空间显然更加复杂。
另外:为什么空指针访问会导致SEG错误
现在您应该很好地理解了在空指针引用上发生了什么。通过这样做,一个进程生成一个0的虚拟地址
硬件试图在TLB中查找VPN(也为0),并遭受TLB错误。页面表被查询,而VPN 0的条目被发现是无效的。因此,我们有一个无效的访问,它将控制转移到操作系统,这可能会终止进程(在UNIX系统上,进程被发送一个信号,使它们能够对这样的错误作出反应;然而,如果未被捕获,这个过程就会被杀死。
例如,代码段从不在第0页开始。相反,这个页面被标记为不可访问,以便为检测空指针访问提供一些支持。因此,在设计地址空间时需要考虑的一个问题是支持调试,这是不可访问的零页面以某种形式提供的。也许更重要的是,内核虚拟地址空间(即:,它的数据结构和代码)是每个用户地址空间的一部分。在上下文切换中,操作系统更改P0和P1寄存器,以指向即将运行的进程的适当页表;但是,它并没有改变S基础和绑定寄存器,因此相同的内核结构被映射到每个用户地址空间。
由于许多原因,内核被映射到每个地址空间。这种结构使得内核的使用更加轻松。例如,当OS从一个用户程序(例如,在write()系统调用)中传递一个指针时,很容易从该指针复制数据到它自己的结构。操作系统是自然编写和编译的,无需担心其访问的数据来自何处。如果相反,内核完全位于物理内存中,那么将页表的交换页面转换为磁盘将非常困难;如果内核被赋予了自己的地址空间,那么在用户应用程序和内核之间移动数据将再次变得复杂和痛苦。使用这种结构(现在广泛使用),内核几乎可以作为应用程序的库,尽管是受保护的。
关于这个地址空间的最后一点是关于保护的。显然,操作系统不希望用户应用程序读取或写入操作系统数据或代码。因此,硬件必须支持不同的页面保护级别来启用这个功能。VAX通过在页表的保护位中指定CPU必须处于何种特权级别才能访问特定页面。因此,系统数据和代码被设置为比用户数据和代码更高的保护级别;试图从用户代码中访问这些信息将会在操作系统中生成一个陷阱,并且(您猜测)可能会终止这个过程。
23.4页面置换
VAX中的页表条目(PTE)包含以下部分:有效位、保护字段(4位)、修改(或脏)位、为OS使用预留的字段(5位),最后一个物理帧编号(PFN)存储物理内存中页面的位置。精明的读者可能会注意到:没有参考位!因此,vm替换算法必须在没有硬件支持的情况下决定哪些页面是活动的。开发人员还担心内存占用,这些程序占用大量内存,使其他程序难以运行。迄今为止,我们所看到的大多数政策都很容易受到这种束缚;例如,LRU是一个全局策略,它不公平地在进程之间共享内存。
分段FIFO
为了解决这两个问题,开发人员提出了分段FIFO替换策略[RL81]。这个想法很简单:每个进程有一个最大的页面数,它可以保存在内存中,也就是它的驻留集大小(RSS)。每个页面都保存在FIFO列表中;当一个进程超过它的RSS时,第一个页面被逐出。FIFO显然不需要来自硬件的任何支持,因此很容易实现。当然,纯粹的FIFO并没有表现得特别好,正如我们之前看到的。为了提高FIFO的性能,VMS引入了两个第二次机会列表,其中页面被放置在被逐出内存之前,特别是一个全球清洁页面自由列表和dirty-page列表。当一个进程P超过它的RSS时,一个页面将从它的每个进程FIFO中删除;如果清洗(未修改),则放置在清洁页面列表的末尾;如果脏(修改),它被放置在dirty-page列表的末尾。如果另一个进程Q需要一个空闲页面,它将从全局清除列表中获得第一个空闲页面。但是,如果在该页面被回收之前的原始进程P错误,P就会从空闲的(或脏的)列表中重新声明它,从而避免了昂贵的磁盘访问。这些全局第二次机会列表越大,FIFO算法对LRU的执行越紧密[RL81]。
集群在大多数现代系统中被使用,因为在交换空间内放置页面的自由可以让操作系统组页面,执行更少和更大的写入,从而提高性能。
23.5其他整洁的VM技巧。
VMS还有另外两种现在标准的方法:要求零和复制。我们现在来描述这些延迟优化。为了更好地理解这一点,让我们考虑在您的地址空间中添加一个页面的例子,比如在您的堆中。在一个简单的实现中,操作系统响应一个请求,通过在物理内存中找到一个页面,将页面添加到您的堆中(安全性要求;否则,您将能够在其他进程使用它时看到页面上的内容!),然后将其映射到您的地址空间(即:,将页表设置为所需的物理页面。但是这种简单的实现可能代价高昂,特别是如果该页面不被流程使用的话。随着需求的减少,当页面被添加到你的地址空间时,操作系统会做的非常少;它在页表中添加了一个条目,该条目标记了无法访问的页面。如果进程读取或写入页面,则会出现一个陷阱。在处理这个陷阱时,操作系统会注意到(通常是通过在页面表条目的OS部分预留的一些字节),这实际上是一个需求为零的页面;此时,操作系统需要找到一个物理页面,将其调零,并将其映射到进程的地址空间中。如果进程从不访问页面,那么所有的工作都将被避免,因此需求的优点是零。
懒惰在生活和操作系统中都是一种美德。懒惰可以推迟到后来,这在一个操作系统中是有益的,有很多原因。首先,推迟工作可能会减少当前操作的延迟,从而提高响应能力;例如,操作系统经常报告给文件的写操作立即成功,并且只在后台将它们写入磁盘。其次,更重要的是,懒惰有时会使我们完全不需要做这项工作;例如,在删除文件之前延迟写,这样就不需要进行写操作了。懒惰在生活中也很好:例如,通过推迟你的操作系统项目,你可能会发现你的同学们已经发现了项目规范bug。但是,这个类项目不太可能被取消,所以太懒可能会有问题,导致项目延迟,糟糕的成绩,和一个悲伤的教授。不要让教授伤心。
在VMS中发现的另一个很酷的优化(实际上,在所有现代操作系统中)-写入时复制
(短的牛)。这个想法至少可以追溯到TENEX操作系统[BB+72],很简单:当操作系统需要从一个地址空间复制一个页面到另一个地址空间时,它可以将它映射到目标地址空间,并在两个地址空间中标记它。如果两个地址空间只读取页面,则不会采取进一步的操作,因此操作系统在不实际移动任何数据的情况下实现了快速复制。
但是,如果其中一个地址空间确实试图写入页面,那么它将陷进操作系统中。OS将会注意到页面是一个牛页面,因此(lazily)分配一个新页面,填充数据,并将这个新页面映射到错误进程的地址空间中。然后这个过程继续,现在有了它自己的页面的私有副本。
的用处有很多。当然,任何类型的共享库都可以被映射到许多进程的地址空间中,节省宝贵的内存空间。当然,任何类型的共享库都可以被映射到许多进程的地址空间中,节省宝贵的内存空间。在UNIX系统中,由于fork()和exec()的语义,COW更加重要。您可能记得,fork()创建了调用者的地址空间的精确副本;有了很大的地址空间,这样的拷贝是缓慢的和数据密集的。更糟糕的是,大部分的地址空间都被随后的exec()调用所覆盖,而exec()将调用进程的地址空间覆盖到即将执行的d程序。通过执行copy-on-write fork(),操作系统避免了大量不必要的复制,从而在提高性能的同时保留了正确的语义。
现在您已经看到了对整个虚拟内存系统的全面审查。希望大多数细节都很容易理解,因为您应该已经对大多数基本机制和策略有了很好的理解。列维和利普曼的优秀(和简短)论文中有更多的细节[LL82];我们鼓励大家阅读它,这是一种很好的方式来看看这些章节背后的原始材料是什么样子的。
在可能的情况下,您还应该通过阅读关于Linux和其他现代系统的知识来了解更多关于该技术的状态。那里有很多的原始资料,包括一些合理的书籍[BC05]。有一件事会让你大吃一惊:在VAX/VMS上的旧论文中发现的经典思想仍然影响着现代操作系统的构建。