虚拟内存是什么呢?举个例子,在学习c语言的时候我们尝试输出过一个指针的地址,其实这里打印出来的地址就是虚拟地址,也就是说我们好像从未真正的了解过计算机,从未见过他的真实地址在内存的映射,如果你对它有兴趣,不妨继续读下去。
本篇内容结合《操作系统导论》和《深入理解计算机系统》在看完这两本书之后的一个回顾,《操作系统导论》更丰富的让我了解到虚拟内存的演变形式及代码实现,《深入理解计算机系统》则是通过与硬件、内存的联系让我对虚拟内存有了更立体的了解。
操作系统的主要内容分为三个部分,分别是虚拟化、并发和持久性,在虚拟化中包括对CPU的虚拟化和对内存的虚拟化,CPU的虚拟化我们这里不提,仅讨论内存的虚拟化。
为了让系统易于使用,操作系统为每个进程提供一种假象——好像进程在独立的使用整个CPU以及有着连续的物理内存。实质上这正是在虚拟化CPU和内存,每个进程访问自己私有的虚拟地址空间,操作系统通过地址转换将虚拟地址映射到机器的物理内存上,从而找到想要的信息。
在这之前,首先给进程下一个非正式定义——进程就是运行中的程序。
基本概念
地址空间
操作系统需要提供一个物理内存抽象,这个抽象就叫做地址空间。(没错,是你想到的CSAPP中应该刻进DNA的内存图)
一个进程的地址空间包含运行程序的所有内存状态。当程序运行时,利用栈来保存当前函数调用信息,分配空间给局部变量,传递参数和函数返回值;最后,堆用于管理动态分配的、用户管理的内存。当然还有其他东西,但现在我们假设只有这三个部分:代码、堆、栈(至于栈为什么存储局部变量等,堆为什么会存储全局变量等,后面会有详细的解释)。如图是我们给代码区、堆、栈分配的一个简单空间。
地址转换
在实现CPU虚拟化时,遵循的原则为受限直接访问(用户模式和内核模式下),当然在虚拟化内存时我们借鉴这个思想,只在关键点时由操作系统介入确保正确执行,那么为了“高效和灵活”的使用虚拟化内存,我们利用了一种技术——地址转换,硬件对每次内存访问进行处理,将指令中的虚拟地址转换为数据实际存储的物理地址。因此,在每次内存引用的时候,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。
当然,仅靠硬件不足以完成地址转换,操作系统必须设置好硬件且管理内存,记录被占用和空闲的内存位置,并明智谨慎的介入。
基址+界限寄存器(动态重定位)
从内存来看,早期的机器并没有提供多少抽象给用户,所以我们先将所有条件放宽,再一点点补充完善。假设用户的地址空间必须连续的放在物理内存中,现在通过时分共享将内存分配给三个很小的进程,如下图,每个进程拥有从物理内存中切出来给它们的一小部分内存,假定只有一个CPU,那么在运行A的时候,B和C则需要在队列中等待运行。
当然这会导致很多问题,我知道你猜到了,(你可以写在本子上,后续将一一解决这些问题)但在这里我们先解决一个致命的问题,如果进程A在运行中试图修改进程B的中的值,我们不希望一个进程可以影响到其他进程,所以“保护”成为了重要的问题。
此时基址+界限寄存器诞生,又被称为动态重定位。具体来说每个CPU都需要两个硬件寄存器:基址+界限寄存器,这组寄存器让我们能将地址空间放在物理内存的任何位置,同时保证进程只能访问自己的地址空间。当程序运行时,将起始地址存在基址寄存器中,界限寄存器提供访问保护,如果进程需要访问超过这个界限或为负数的虚拟地址,CPU将触发异常。
通过基址寄存器和虚拟地址,我们可以得到实际的物理地址,即
实际物理地址 = 虚拟地址 + 基址寄存器
但简单的动态重定位技术使得内存区域中大量的空间被浪费,由于我们之前放宽了条件认为进程很小,我们需要分配比进程大一点的固定空间,但这种方式将导致出现内部碎片——已经分配的内存单位内部有未使用的空间,所以我们需要更为复杂的机制来解决这个问题。
分段
那我们思考一下如何用更好的方式将基址+界限寄存器泛化(提示:每个地址空间的堆栈之间的有一大块空闲的空间)
前面是将一个进程放入一个固定空间,给定一对基址+界限寄存器,那基于上面的提示相信你已经想到了,我们给每一个地址空间内的每个逻辑段一对基址+界限寄存器。
至此,分段的概念提出,一个段只是地址空间里的一个连续定长的区域,在上述(基本概念)里的地址空间里有三个逻辑段:代码段、堆和栈,分段的机制使得操作系统能够将不同的段放入不同的物理内存,从而避免了虚拟地址空间中未使用的内存,具体如下图所示。
硬件在地址转换时使用段寄存器,此时地址转换有一种显式的方式,通过虚拟地址的开头两位确定在哪个段(用两位区分段,但实际上只有三个段,因此有一个段被浪费,所以部分系统使用一位来确定,即将堆和栈放在一个段中)。比如(仅仅举个例子,不代表真实情况)00代表在代码段,01代表堆段,10代表栈段,如果我们用14位虚拟的前两位来标识,那么后12位表示段内偏移。偏移量与基址寄存器相加得到了真正的偏移地址,硬件通过段地址和段内偏移地址得到了最终的物理地址(汇编内容)。
当然随着分段的不断改进,我们为了提升效率,节省内存,有时会在地址空间之间共享某些内存段(比如在c语言中的printf函数,我们经常使用)。为了支持共享,我们需要一些额外的硬件——保护位,为每个段增加几个位,标识程序能否能够读写该段,或执行其代码。当然,有了保护位后,前面提到的硬件算法也要改进,除了检查虚拟地址是否越界之外,还需检查访问是否允许,如果用户进程尝试写入只读段,或从非执行段执行指令,硬件将触发异常,由操作系统的异常处理程序处理。
现在想必你已经了解了分段的基本原理,在系统运行的时候,地址空间中的不同段被重定位到物理内存中,且堆和栈之间没有使用的区域就不需要多余的分配内存。但思考一下,这样真的是最优的解法吗?我们引出分段的概念,将内存分出各个块,由于段大小不同,导致物理内存中出现了许多空闲的小空间,那么当一个很大的段想加入进来,怎么办?我们把这种问题称为外部碎片,下面让我们思考一下如何解决。
空闲空间管理
第一个想法就是当一个大的段想加入的时候,紧凑物理内存,重新安排所有的段。例如操作系统先终止运行的进程,将其数据复制到连续的内存中,改变其段寄存器,指向新的物理地址。但这样做,很容易想到的一个问题就是对时间、资源的浪费,且这种浪费是很大的,不可取。
那第二个想法就是利用空闲列表管理算法,试图保留大的内存块用于分配。在深入策略细节前,我们先介绍大多数分配程序的通用机制——分割与合并,追踪已分配空间、嵌入空闲列表。
分割与合并
顾名思义,空闲列表包含一组元素,记录堆中那些空间还没被分配,假设有30字节的堆,这个堆对应的空闲列表有两个元素,两个都是描述空闲字节的区域,如下图所示。
因为被分成两个十字节的空间,所以想必你也能猜到,任何大于十字节的地址分配请求都会失败。那我们尝试分配小于十字节的空间看看会发生什么。
从上图可以看出,我们分配了一个字节的区域,此时分配程序会执行“分割”动作,找到一块可以满足请求的空闲空间,将其分割,第一块返回给用户,第二块留在空闲列表中。
当然有分割就有合并,还是前面的例子,如果我们将中间被占用的空间free()掉,且没用合并动作,会发生什么呢?
问题来了,尽管整个堆空闲,但它被分割成了三个10字节的区域,如果我们想申请20字节的区域将会失败。为了避免上述问题,分配程序会在释放一块内存时合并可用空间——在归还空闲内存时,仔细查看要归还的内存块的地址以及邻近的空闲空间块,如果与原有空闲块相邻,则合并为一个较大的块,如下图所示。
追踪已分配空间
当然free()接口没有块大小的参数,因此他是假定对于给定的指针,内存分配库可能很快确定要释放空间的大小,从而将它放回空闲列表。为了完成这个任务,大多数程序都会在头块中保存一点额外信息,它在内存中通常就在返回的内存块之前。所以当用户请求N字节的内存时候,库不是为其寻找N字节的空闲块,而是寻找N加上头块大小的空闲块。
当然基于以上的底层原理,我们为提高效率衍生出一些管理空闲空间的基本策略,这里略写如果有兴趣可以自行了解。
- 最优匹配
- 最差匹配
- 首次匹配
- 下次匹配
其他方式:
1.分离空闲列表,基本思想是如果某个应用程序经常申请一种大小的内存空间,那就用一个独立的列表,只管理这样大小的对象,其他大小的请求都交给更通用的内存分配程序。
2.伙伴系统,准确的说是二分伙伴系统,运用二分的思想分配固定的内存。但提到固定内存,我们很容易想到之前提出的很可怕的结果——内部碎片。
我们采用上述聪明的算法会缓解外部碎片的问题,但这个问题很根本,无法避免。还有一个很重要的问题,分段不足以支持更一般化的稀疏地址空间。比如如果有一个很大但很稀疏的堆,都在一个段中,整个堆仍必须完整的加载到内存中,换言之,现在利用地址空间的方式不足以解决我们目前的问题,我们尝试思考一个新的形式来解决这个问题。
分页
刚刚提到了二分思想,那我们不妨继续深想下去。由于前面我们解决空间管理问题的方法是将空间分隔成不同长度的分片,就像分段,但这个方法存在固有的问题——会出现碎片,无论是外部碎片还是内部碎片这都是无法避免的。
因此,我们尝试思考第二种方式——将空间分隔成固定长度的分片,在虚拟内存中,我们将这种思想叫做分页。你可能会想,那这和早期的计算机系统有什么差别?其实分页不是将一个进程的地址空间分隔成几个不同长度的逻辑段,而是分隔成固定大小的单元,其中每个单元称为一页,相应的,我们把物理内存看成定长槽块的阵列,称作页帧。
那么至此,我们的挑战是,如何通过页实现虚拟内存?
先举个例子,如下图,物理内存由一组固定大小的槽块组成,若操作系统希望将64字节的小地址空间放入8页的物理地址空间中,只需找到4个空闲页即可,在这里操作系统将地址空间的虚拟页0放在物流页帧3,虚拟页1放在物理页帧7......
好,其实我们上面说的一系列浪费时间空间的问题,其实都是操作系统需要去优化的管理问题,那我们继续优化这个想法。
操作系统为了记录地址空间的每个虚拟页放在物理内存中的位置,它通常会为每个进程保存一个数据结构,称为页表。页表的主要作用是为地址空间的每个虚拟页面保存地址转换,从而让我们知道每个页在物理内存中的位置。实质上由于任何数据结构都可以采用,那这里我们将页表采用最简单的线性页表的形式。
重要的地址转换来了,这里的思想跟分段的相似,前几位是虚拟页面号(VPN),后几位是页内偏移(offset),我们假设进程的虚拟地址空间是64字节,那我们虚拟地址需要六位。因此,虚拟地址可以表示如下
上面的例子中,页面大小为16字节,那么页内偏移占4位 ( ),虚拟页号占两位。
我们尝试一下,假设加载虚拟地址21,将21转化为二进制为“010101”,我们检查一下这个虚拟地址,看它如何分解
那上述意思其实是找到虚拟页1所在的物理页面,那我们看上图可以知道虚拟页1所对应的物理页面是页帧7,转化成二进制位111,则可得到物理地址
页表其实非常大,举个例子,一个典型的32位地址空间,带有4kb的页,这个虚拟地址分成20位的VPN和12位的偏移量,那么20位的VPN代表操作系统必须为每个进程管理个地址转换,假设每个页表条目需要4个字节,那么每个页表就需要4MB的内存,这其实很可怕,如果有100个进程同时进行的话,这意味着我们需要400MB的内存——仅仅为了地址转换。
虽然分页不会导致之前分段的碎片问题,但也会导致其他问题,比如空间和时间的浪费等,因此可以思考一下如何优化分页这个形式。
提示:分别从优化时间和优化空间的两个方向思考,关于时间的优化可以联想一下cache其实这里是相似的;关于空间的优化希望你多思考一下,前面页表的空间都浪费在哪里了?什么形式可以减少这些浪费?
快速地址转换(TLB)
因为分页的映射信息(页表)在内存中,且在转换虚拟地址的时候要访问页表,所以要多访问一次内存,这将导致时间的浪费。
为了让他更快,我们参考存储器的层次结构,增加地址转换旁路缓冲存储器(TLB),它就是频繁发生虚拟地址到物理地址转换的硬件缓存(cache),因此我们也叫它地址转换缓存。借助存储器的层次结构,对每次内存访问,硬件先检查TLB,看其中是否有期望的转换映射,有就完成转换,不用访问页表。
那么TLB是如何进行地址转换的呢?
首先从虚拟地址中提取页面号(VPN),检查TLB是否有该VPN的转换映射,如果有,我们称为TLB命中,接下来我们只需要从TLB中取出页帧号,与原本的偏移量组合形成真正的物理地址,并访问内存。
那当TLB未命中的时候,就要老老实实的去页表中找映射关系了,但找完不代表结束,找完后将该映射关系更新到TLB,系统重新运行该指令,此时从TLB中取出转换映射。在这个过程中由硬件全权处理TLB未命中,为了做到这点,硬件必须知道页表在内存中的确切位置,以及页表的确切格式。当发生未命中时,硬件系统会抛出一个异常来暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序,当映射更新到TLB后,从陷阱返回,重试当前指令。
当然当TLB满了的时候肯定会产生替换问题,比如常见的最近最少、随机替换等,这里不多赘述,但大家在思考的时候别忘记时间和空间的局部性。
如果在进程之间发生上下文切换时,上个进程在TLB中的地址映射对下个进程来说是无意义的,那么我们如何解决这个问题呢?第一个最简单的想法就是将TLB清空,但很明显浪费时间和空间,不可取。为了减少开销,一些系统增加了硬件支持,实现跨上下文切换的TLB共享,比如有的系统在TLB中添加了一个地址空间标识符,TLB可以缓存不同进程的地址空间映射。
关于时间的问题我们解决了,下面思考一下在空间上我们如何优化?
在生活中每当有两种合理但不同的方法时,你应该总是探索两者的结合,看看能否将其中和形成最优解,我们称这种组合叫做杂合。
分段和分页
那我们可不可以尝试将分页和分段相结合,以减少页表的内存开销。看下图的例子,就可以理解为什么这可能有用。假设我们有一个地址空间,其中堆和栈的使用部分很小,使用一个16KB的小地址空间和1KB的页,假设单个代码页(VPN 0)映射到物理页10,单个堆页(VPN 4)映射到物理页23,地址空间另一端的两个栈页(VPN 14和15)被分别映射到物理页28和4。
从图中可以看到大部分页表都没有使用,充满了无效的(invalid) 项。仅仅由1KB 的页和16KB的地址空间,就会导致这么多的浪费。
所以我们的将分段和分页结合方法不是为进程的整个地址空间提供单个页表,而是为每个逻辑分段提供一个(其实在前面的优化中也提到过),在这个例子中有3个页表——代码、堆和栈各一个。在分段中,有一个基址寄存器,告诉我们每个段在物理内存中的位置,还有一个界限寄存器,告诉我们该段的大小,在这个方案中,我们仍然拥有这些结构,但使用的基址寄存器不是指向段本身,而是保存该段页表的物理地址,界限寄存器用于指示页表有多少有效页。
在硬件中,假设有3个基址界限寄存器对,代码、堆和栈各一个。当进程运行时,每个段的基址寄存器都包含该段页表的物理地址,所以系统中的每个进程都有3个与其关联的页表。这将导致在上下文切换时,必须更改这些寄存器,以反映新运行进程的页表的位置。
在TLB未命中时,硬件使用分段位来确定要用哪个基址和界限对。然后硬件将其中的物理地址与VPN结合起来,形成页表项的地址。
将分页和分段组合的与原来的区别在于,每个段都有自己的基址界限寄存器,这种方式明显比线性页表节省了内存,因为堆栈之间未分配的页不在占用页表的空间。
但这种方式并非没有问题,他要求分段,分段其实不像我们想象中的灵活,因为假设地址空间有一定的使用模式。还是原来的例子,出现大而稀疏的堆的时候很难解决,其次由于分段仍然导致了碎片的产生,尽管部分内存是以页的方式管理的。
出于上述原因,我们继续探寻更优的解决方案。
多级页表
可以思考如何去掉页表中的所有无效区域,而不是将所有页表全部保存在内存中,那这里就要提出多级页表。
多级页表的思想很简单,首先将页表分成页大小的单元,如果整页的页表项无效,则不分配该页的页表。为了追踪页表是否有效,页目录可以告诉你页表的页在哪,或者页表的整个页不包含有效页。
希望通过图可以直观的看出多级页表的工作方式——让线性页表的一部分消失,用页目录记录页表的哪些页被分配。
多级页表分配的页表空间,与使用的地址空间内存量成比例。有了多级结构,我们增加了一个间接层,使用了页目录,它指向页表的各个部分,这种间接方式,让我们能将页表页放在物理内存的任何地方。
但在这里不得不提示,当我们优化空间的时候,是要牺牲部分时间的,其实这跟算法相似如果想优化空间就要牺牲部分时间以及提高复杂度,如果想优化时间就要牺牲空间。那这里当TLB未命中时,需要从内存中加载两次,一次用于页目录、一页用于页表项本身。
讲到这想必你已经对虚拟内存有了一定的了解,下面是我个人认为是本文章比较难理解的内容——多级页表的计算,希望我讲的足够清楚
先不考虑多级页表,那么所有的计算其实上面有过讲解,假设一个大小为16KB的小地址空间,其中包含64个字节的页。因此,我们有一个14位的虚拟地址空间,那此时偏移量有6位,页表索引8位(VPN,14-6 = 8),那么即使此时只有一部分空间正在使用,线性页表也会有个项,如下图。
虚拟页0和1用于代码,4、5用于堆,254和255用于栈,其余页未使用,好那我们为他构建一个二级页表
在这个例子中,每个线性页表有256(看上面计算)个项,假设每个页表项大小为4字节,因此我们页表的大小为1KB
256 * 4 = 1024byte = 1KB
由于页是64字节(前面给的前提条件),所以1KB的页表可以分成16个页,每个页可以容纳16个页表项
1024 / 64 = 16,64 / 4 = 16
这里的页表项、页表、页大家不要搞混了,可以再梳理一下。
用页存放数据信息,那根据上面的计算和条件,我们可以知道一个页有64字节
页表存放的是页的地址转换信息,通过页表可以查找到具体页的地址转换。但页表也是需要放入物理地址空间内的,那么页表多大呢,我们知道页表共有256个项,每个项4字节,所以大小为1KB。由于一个页64字节,所以1KB的页表能分成16个页,可以存放16个页表项。
大小为1KB的页表也需要索引,索引叫做页目录,一共16页页表,所以页目录索引需要在VPN中占前4位,通过页目录索引,我们就可以得到页目录项的地址,从而得到页目录,如下图:
如果页目录标记无效,则为无效访问,从而触发异常。
如果有效,需要从页目录指向的页表的页中获取页表项,要找到它,我们必须使用VPN剩余的位索引到页表。最后得到的物理地址:
PTEAddr = (PDE.PFN << SHIFT) + (PTIndex * sizeof(PTE))
为了准确的求出物理地址,我们在举一个例子
在该表中,最左侧作为页目录,可以看到每个页目录项都描述了有关地址空间页表的内容,这个例子中, 地址空间里有两个有效区域,以及一些无效的映射。
在物理页100中(页表的第0页物理帧号),有一页包含16个页表项,记录了地址空间中的前六个VPN,所以VPN0和1是有效的,4、5也是,其他项标记无效。
页表的另一个有效页在101中,可以看到该页最后16个VPN有效。
希望你可以看出在本例子中运用多级索引页表的好处,可以节省多少空间,在这个情况下,我们不需要分配16个完整的页表,只需要分配3个——一页用于页目录,两页用于页表的有效映射块
最后尝试一下地址转换,给出一个地址,在101页中,指向VPN254的第0个字节:0x3F80(11 1111 1000 0000)
先看一下这个虚拟地址是怎么来的
上面提到使用VPN的前4位索引页目录,1111对应第15个页目录项,也就是第101页(第一列查找表,最后一行)
使用VPN的下4位索引页表并找到所需的页表项,1110是页面中的倒数第2条(第14个)页表项,此时我们找到了它,看他对应55(110111)的地址页,且偏移量为0(占六位)
那么由此可以得出物理地址空间为
PhysAddr = (PTE.PEN <<SHIFT) + offset = 11 0111 << 6 + 00 0000 = 0xDC0
学到这里,相信你已经对多级页表有了一点了解,但刚刚我们使用的是二级页表,也就是说只有两层映射关系,下面我们来尝试三级页表的转换
举个例子,假设我们有一个30位的虚拟地址空间和一个小的(512 字节)页。因此虚拟地址有一个21位的虚拟页号和一个9位的偏移量。在构建多级页表时的目标——使页表的每一部分都能放入一个页,但到目前为止,我们只考虑了页表本身。但是如果页目录太大,该怎么办?
要确定多级表中需要多少级才能使页表的所有部分放入一页,首先要确定多少页表项可以放入一页。由于页大小为512字节,并且假设页表项大小为4字节,所以可以在单个页上放128个页表项。如下图,当我们索引页表时,我们需要VPN的最低有效位7位 作为索引:
你可能注意到,我们留给了页目录索引14位,如果我们的页目录有个项,那么它不是一个页,而是128个页( / 128 = 128,因为每个页可以放128个页表项),因此让多级页表的每一一部分放入一页的目标失败了。为了解决这个问题,我们为树再加一层,将页目录本身拆成多个页,然后在其上添加另一个页目录,指向页目录的页。我们可以按如下方式分割虚拟地址:
现在,当索引上层页目录时,我们使用虚拟地址的最高几位(PD索引0),该索引用于从顶级页目录中获取页目录项,如果有效,则通过组合来自顶级PDE的物理帧号和VPN的下一部分来查阅页目录的第二级;最后如果有效,则可通过使用二级页目录项的地址组合的页表作业形成页表项。
至此,关于多级页表的计算结束,如果没看懂希望你可以再看几遍,这一块弄懂了我相信你可以对多级页表有更深的理解。
现在已经看到了如何构建真正的页表,不一定使用线性数组,而是更为复杂的数据结构(这里用到了树),这样的页体现了时间和空间的折中。有了软件管理的TLB、数据的多种形态给了操作系统新的解决问题的方式和灵感,希望你在看完文章后也可以多思考用新结构解决问题,这也正是这篇文章的价值所在。
总结
从基址界限寄存器到分段,再到分页,段页结合,多级页表和TLB的结合,在学习的过程中不断的优化,增加复杂性,更深入的了解虚拟内存,当然我们现在了解的只是皮毛,关于TLB相信你把CSAPP第六章存储器的层次结构看完才会有更深的了解。
在读完深入理解计算机系统之后,我本以为我会对计算机有很深的理解,但当读操作系统导论的时候才发现自己知之甚少,这两本书都是很好的书,如果你有时间,希望你可以静下心来慢慢看。
希望我的内容可以帮助到你🌹