前言:
我们日常开发中创建的类,调用的函数,在函数中定义的局部变量以及 new 出来的数据容器(Map,List,Set .....等)都需要存储在物理内存中的某个角落。
而我们在程序中编写业务逻辑代码的时候,往往需要引用这些创建出来的数据结构,并通过这些引用对相关数据结构进行业务处理。
当程序运行起来之后就变成了进程,而这些业务数据结构的引用在进程的视角里全都是虚拟内存地址,因为进程无论是在用户态还是在内核态能够看到的都是虚拟内存空间,物理内存空间被操作系统所屏蔽进程是看不到的。
进程通过虚拟内存地址访问这些数据结构的时候,虚拟内存地址会在内存管理子系统中被转换成物理内存地址,通过物理内存地址就可以访问到真正存储这些数据结构的物理内存了。随后就可以对这块物理内存进行各种业务操作,从而完成业务逻辑。
那么到底什么是虚拟内存地址 ????
1.什么是虚拟内存
在计算机操作系统中,虚拟内存是对主存储器(DRAM)与磁盘之间的一个变相的缓存方式,通过虚拟内存的方式将磁盘中的数据缓存到主存中,同时,虚拟内存为每个进程提供了一个大的、统一的和私有的地址空间,可以理解为对每个进程的内存映射关系(见下图),这一方面是统一的有效管理了各个进程内部的内存,另一方面是每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够提供内存地址空间的隔离,极大地扩展了可用空间。其实,任何一个虚拟内存里所存储的数据,本质上还是保存在真实的物理内存里的。只不过内核帮我们做了虚拟内存到物理内存的这一层映射,将不同进程的虚拟地址和不同内存的物理地址映射起来。
当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址(换句话说,就是映射是单独针对每个进程进行的),这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。
那么,为什么虚拟内存是对主存储器(DRAM)的抽象,而不是高速缓存(SRAM)的抽象呢?
-
容量和成本考虑:主存储器(DRAM)通常拥有大容量但速度较慢,适合用于存储大量数据和地址空间管理,而高速缓存(SRAM)容量较小但速度更快,更适合存储近期频繁访问的数据。
-
访问延迟和速度差异:高速缓存(SRAM)用于提供快速访问速度,而虚拟内存技术涉及整个地址空间的管理,包括将数据从磁盘加载到主存储器中,这需要处理大量的地址映射和页面置换操作,与主存储器(DRAM)的访问速度更匹配。
-
抽象层次:虚拟内存提供了对地址空间的抽象,使得进程感觉自己拥有连续的地址空间,而不必关心实际的物理内存地址。这种抽象更适合用于主存储器(DRAM),因为它是整个系统内存的主要存储区域,与处理器的高速缓存子系统的低层缓存层次不同。
虚拟内存有以下三个重要功能:
- 将主存储器作为磁盘的缓存,只保留主存中的活跃区域并根据需要不断地在两者之间传输数据(Linux中使用的是分页交换机制);
- 为每个进程提供统一的地址空间,从而简化内存管理;
- 保护每个进程的地址空间不被其他进程所破坏。
2.为什么需要虚拟内存
在第八章我们了解了进程的概念。在计算机系统中,多个进程会共享CPU和内存,当某个进程需要过多的内存空间(例如下图的某个进程所需的内存大小超出了内存可分配的大小),那么另外的某个进程可能就会因为无法获得足够的内存空间而无法运行。此外,当某个进程不小心把数据写入另一个进程的内存空间,就会造成令人头疼的问题。虚拟内存能有效避免以上问题。
通常我们的机器的物理内存都是有限,例如 16GB、32GB 等。但可以运行的进程是无限多的,如果每开启一个进程,都要独占一部分物理内存,那么只要不停的打开程序,无论物理内存有多大,都不够用。这时候虚拟内存通过映射方式就可以解决这个问题了。
3.虚拟内存是如何工作的
先引入两个概念:
- 物理地址
物理地址是直接指向计算机物理内存(RAM)某个位置的地址。它是硬件层面上实际用于存取数据的地址。也就是数据真实存储的地方。
- 虚拟地址
虚拟地址是操作系统为程序提供的抽象地址,其可以通过内存管理单元(MMU)转换为物理地址,然后再访问实际的物理内存。
虚拟内存作为缓存的工具
从概念上讲,可以将虚拟内存视为存储在磁盘上的字节序列,然后存储在磁盘上的虚拟内存的内容缓存在DRAM中,它在主存中缓存虚拟页,就像任何缓存一样,数据被分解成块(见下图),虚拟内存系统的那些块称为页面,它们通常比缓存块大得多,因此,从概念上讲,虚拟内存可以被视为存储在磁盘上的一系列页面。
这些页面都会有一个标记,如下图展示了虚拟内存和物理内存的真实关系(虚拟页VP存储在磁盘上,物理页PP缓存在DRAM中),其中每个虚拟页面VP都会有一种状态(未分配的、已缓存的、未缓存的),如VP0是未分配的,VP1是已缓存的,并且VP1在物理内存中对应PP1。
同任何缓存一样,虚拟内存系统必须有某种方法来判断某个虚拟页是否已经缓存在了DRAM中,并且还要进一步去确定这个虚拟页在DRAM中的那个位置,而以上这些功能是由一个常存储在主存中的页表来实现的。
如下图,页表在内存中是一种数据结构,它能跟踪虚拟页面存储的位置,操作系统内核负责维护页表的内容,以及在磁盘和DRAM之间来回传送页,每个进程都有自己的页表。
页表就是一个页表条目(Page Table Entry)(PTE)的数组,其中PTR k包含DRAM中物理页面k的物理地址。页表条目由有效位和一个 n 位地址字段组成的,如果有效位为1说明在内存中,如果为0则是其他情况。
如果设置了有效位,那么地址字段就表示 DRAM 中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。(也就是说,地址字段在有效位为1的时候映射的是物理页的起始位置,反之有效位为0的时候地址字段映射的是虚拟页磁盘的起始位置)。
页命中
和缓存命中差不多的概念,本质上是通过页表的有效位来进行判定页表是否已经映射了物理内存。如下图,考虑一下当 CPU 想要读包含在 VP2 中的虚拟内存的一个字时会发生什么???
VP 2 被缓存在 DRAM 中。地址翻译硬件将虚拟地址作为一个索引来定位 PTE 2,并从内存中读取它。因为设置了有效位,那么地址翻译硬件就知道 VP 2 是缓存在内存中的了。所以它使用 PTE 中的物理内存地址(该地址指向 PP 1 中缓存页的起始位置),构造出这个字的物理地址。
缺页(页不命中)
虚拟内存的习惯说法中,DRAM 缓存不命中称为缺页(page fault)(本质上就是如果页表中的有效位为0的时候,并且已经分配了,那么此时就需要从虚拟内存-磁盘中拿过来对应的虚拟页面VP的指针索引0-虚拟地址,然后将其替换掉物理内存中已经有了的指针索引)。
举个例子,如下图,CPU 需要引用 VP 3 中的一个字,但是VP 3 并未缓存在 DRAM 中。地址翻译硬件MMU从内存中读取 PTE 3,从有效位推断出 VP 3 未被缓存(PTE3的有效位为0),并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在 PP 3 中的 VP 4。如果 VP 4 已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改 VP 4 的页表条目,反映出 VP 4 不再缓存在主存中这一事实。
接下来,内核从磁盘复制 VP 3 到内存中的 PP 3,更新 PTE 3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP 3 已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。如下图展示了在缺页之后我们的示例页表的状态(更改了两个地方:页表的PTE3的有效位为1且地址字段为物理内存中的指针索引、物理内存中的PP3从VP4替换成了VP3)。
在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。页从磁盘换入(或者页面调入)DRAM 和从 DRAM 换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的这种策略称为按需页面调度(demand paging)
分配页面
下图展示了当操作系统分配一个新的虚拟内存页时对我们示例页表的影响,例如,调用 malloc 的结果。在这个示例中,VP5 的分配过程是在磁盘上创建空间并更新 PTE 5,使它指向磁盘上这个新创建的页面。
局部性
这里再次强调下局部性的重要性!!!
这其实算是个计算机的优化思想,局部性原理是指CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。这个区域通常是比较活跃或者是刚用完的区域,这样的做法有利于减少流量开销。
虚拟内存通过“局部性”原则,让程序可以有效利用内存,即使程序所需的总数据量超过了物理内存的容量。局部性意味着程序在某一时刻主要使用的是一小部分数据,这部分数据称为“工作集”或“常驻集合”。当工作集被加载到内存后,程序对这部分数据的访问会很高效,减少了对磁盘的频繁访问。
然而,如果程序的工作集超过了物理内存的容量,程序就可能陷入“抖动”状态,此时内存和磁盘之间的数据交换变得频繁,导致程序运行速度大幅下降。因此,虽然虚拟内存通常能提高效率,但如果程序运行非常缓慢,可能是因为发生了抖动。
DRAM 缓存的组织结构
我们原来使用的Cache是位于主存和CPU之间的高级缓存(SRAM)结构,而这里使用的是基于DRAM的主存和硬盘之间的缓存结构。显而易见的,主存和硬盘结构的读取速度远远远远慢于cache和主存结构的读取速度,那么当发生不命中情况时产生的开销将是巨大的,所以DRAM缓存的组织结构完全是由巨大的不命中开销驱动的为了削弱这份巨大的开销,采取以下措施。
- 虚拟页尽量大,通常是4KB~2MB。
- DRAM缓存是全相联的,即任何虚拟页都可以放置在任何物理页中。
- 不命中时采取更复杂精密的替换算法。
- 因为对磁盘的访问时间很长,DRAM缓存总是使用写回,而不是直写。
虚拟内存作为内存管理的工具
虚拟内存极大地简化了内核的各种内存管理。在我们上述过程中,都只讨论了一个页表,但是其实现实中有很多页表,每个页表代表了操作系统给每个进程单独提供了一个独立的虚拟内存空间,如下图,操作系统为进程i和j分别提供了一个独立的虚拟地址空间,这印证了我们之前讲的:让每个进程(应用程序)误以为在独享整个内存,同时也使得内存管理更为方便。
虚拟内存还有利于以下几个方面:
- 简化共享:
在不同时间,相同的虚拟页面可以在不同的时间存储在不同的物理页面中,它提供了最灵活的调度自由度。
对于多个进程共享某些代码或数据,这是一种非常简单的直接方式,你所做的只是这些不同进程中的页表条目只需指向相同的物理页面即可。
在进程1和进程2的每个页表中,虚拟页面2都指向物理页面6,这就是共享库的实现。
所以lib.c与系统上运行的每个进程的代码相同,lib.c只需要一次加载到物理内存中,然后想要访问lib.c中的函数和数据的映射,虚拟地址空间中的页面指向实际加载lib.c的物理页面,现在系统中只有一个lib.c的副本,但每个进程都认为它有自己的副本
-
简化链接:
链接器现在可以假设每个程序都将在完全相同的位置加载,所以链接器提前知道所有内容将会是什么,然后它可以解决它可以相应地重新定位所有这些引用,现在它确实使加载变得简单。
- 简化加载:
要把.text和.data节加载到一个新创建的进程中,Linux加载器为代码和数据段分配虚拟页,把它们标记为无效的,将页表条目指向目标文件中适当的位置,这样加载器就不需要从磁盘复制任何数据到内存中,只需要等页面初次使用时,触发未命中,自动调入数据页。
虚拟内存作为内存保护的工具
操作系统并不能让用户任意的去读取私有内存或者是修改只读区域,所以是有必要去通过某种手段控制对内存系统的访问。虚拟内存能做到这一点。
虚拟内存通过在PTE上添加额外位来实现访问控制,这保证了内存安全,也能区分不同进程的私有内存,不同进程只能根据访问权限访问修改虚拟页面。
如下图,SUP表示是否运行在内核模式下才能访问该页,READ和WRITE位控制对页面的读和写访问。
例如,如果进程i运行在用户模式下,那么它有读VP0和读写VP1的权限,然而不能访问VP2。
如果有指令违反了这些许可条件,那么CPU触发一个一般保护故障,将控制传给内核中的异常处理程序,Linux shell一般将这种异常报告为段错误。
地址翻译
先展示下常用的符号,见下图。
其实地址翻译就是虚拟地址和物理地址之间的一个映射关系,现有M个物理地址和N个虚拟地址,我们有一个map函数可以将映射从V映射到P,对于虚拟地址a,如果对应的数据在物理地址中,则映射为a’(指向物理地址的指针索引);同样如果虚拟地址a处的数据不在物理地址中(磁盘中或未分配)则映射为空集。
下图就展示了MMU(地址翻译器)是如何利用页表来实现这种映射。
如下图,虚拟地址中有两个部分:虚拟页号VPN和虚拟页偏移量VPO。CPU中的寄存器先通过生辰的虚拟地址的VPN来选择页表条目PTE。然后根据某种算法得到物理地址。并且物理地址的偏移量和虚拟地址的偏移量是一样的。
4.CPU和虚拟内存之间的交互
页命中时CPU硬件的操作步骤
如下图,1.CPU先生成一个虚拟地址发送给MMU,2.MMU生成PTEA(指向页表条目PTE的指针索引)并发送PTEA给高速缓存/内存,3.当高速缓存/内存中的页表知道后,就会将PTE返回给MMU,4.MMU接收到发送过来的PTE,通过我们之前学的虚拟内存的映射(页命中、缺页)处理后(就是虚拟内存与物理内存还有页表之间的操作,但是此时的状态是页命中,就是说页表已经存储了所求的物理地址,就不需要请求磁盘缓存了),最终得到了所求数据的物理地址PA,然后把PA发送给高速缓存/内存,5.高速缓存/内存知道CPU所求内存的物理地址后就把数据字返回给CPU。
缺页时CPU硬件的操作步骤
见下图,与页命中的差别在于要通过转移到内核态的缺页异常处理程序从磁盘中进行页面调度缓存物理地址,然后将其当成页命中后来处理即可。
5.虚拟内存的缓存(TLB)
每次CPU产生一个虚拟地址,MMU都需要查询一次PTE,多次查询仍会产生很多开销,然而TLB能缓存PTE。
CPU生成一个虚拟地址通过MMU,MMU并不是查看内存然后直接转到页表条目,它首先将虚拟页面VPN发送给TLB,确定这个虚拟页面是否存在对应的PTE,如果确实如此,则TLB返回命中,然后它返回MMU可用于构造物理地址的页表条目,发送到缓存和内存系统,最终发回数据。
TLB命中和不命中
见下图。MMU检查这个VPN的TLB,它未命中,所以MMU必须像以前一样去内存,然后一切都是相同的,内存将PTE返回到MMU,并将其存储在TLB中,如果PTE已被修改,则必须将其写回,最终,MMU使用它来构建物理地址,然后将数据发回。
6.多级页表
虚拟内存虽好,但是当有100个进程(真实情况不可能)创造了100个独立的虚拟内存空间的话,就会导致很多空闲的、未分配的虚拟内存空间浪费掉,反而还占着许多内存。而多级页表能有效解决这个问题。
多级页表的主要思想是:首先,将页表分成页大小的单元。然后,如果整页的页表项 PTE 无效,就完全不分配该页的页表。为了追踪页表的页是否有效(以及如果有效,它在内存中的位置),使用了名为页目录的新结构。页目录因此可以告诉你页表的页在哪里,或者页表的整个页不包含有效页。
换句话说,多级页表只是让线性页表的一部分消失(释放这些帧用于其他用途),并用页目录来记录页表的哪些页被分配。
参考下图,左侧是原本的一级页表,也就是线性表,右侧则是二级页表的应用。
7.页表条目的学习
- 页表条目中绿色的40位指向下一级页表的地址
- p为有效位,标识了页面是否在内存中
- R/W位控制是否可以读取或写入该页表
- U/S表明用户是否需要在内核模式下运行
- WT表明应该使用写回还是写入,选写回,因为有巨大的未命中惩罚
- A是一个参考位,在MMU读或写时设置,表示读写对应的页表
- PS表示页面大小是4KB还是4MB
- XD标识能不能从这个PTE可访问的所有页中去执行指令,这就是堆栈现代系统如何保护堆栈免受代码注入攻击
- 页表条目中绿色的40位指向某一页物理地址基地址
- D脏位(修改位),由MMU读写时设置,用于告诉操作系统,当这一页作为牺牲页时,该页内容是否修改过要写回硬盘
8.存储管理:分页、分段
分页:
分页就是我们之前讲的:“虚拟内存可以被视为存储在磁盘上的一系列页面。”中的页面。分页是将物理内存划分为固定大小的页框(物理内存中的实际存储块),并将进程的虚拟地址空间划分为与页框大小相同的页(虚拟内存中的逻辑块)。通过页表的映射,操作系统可以将进程的虚拟内存地址转换为物理地址。
其实分页就是将物理内存和虚拟内存分成多个大小相同的页框或页。
分页的特点:
• 虚拟地址和物理地址之间的映射:虚拟内存中的每一页都可以映射到物理内存中的任意页框,无需连续存储。通过页表,操作系统可以管理虚拟内存和物理内存之间的映射关系。
• 内存分配效率:分页解决了内存碎片化的问题,因为内存以固定大小的页框分配,即使不同进程需要不同大小的内存,操作系统仍能灵活分配物理内存。
• 地址空间分离:每个进程都有自己的独立地址空间,使得进程之间互不干扰,增强了安全性。
分页的优点:
• 消除了外部碎片。
• 内存分配和管理更加简单,因为每个页框大小相同。
• 支持虚拟内存技术,可以在物理内存不足时将部分内存交换到磁盘上。
分页的缺点:
• 引入了页表,增加了内存的开销。
• 存取速度稍慢,因为需要通过页表进行地址转换。
分段:
分段是将程序的地址空间划分为不同的逻辑段,每个段代表一个相对独立的内存区域,如代码段、数据段、BSS段、堆段、栈段。每个段的大小不固定,由程序的逻辑结构决定。
其实分段就是进程虚拟内存的映射关系,见下图。同时也是
分段的特点:
• 按逻辑划分内存:内存以逻辑方式划分为不同段,每个段对应程序中的某一功能部分,比如代码段、数据段等。每个段都有段号和段内偏移量。
• 段表管理:每个进程都有一个段表,段表记录了每个段在物理内存中的起始地址和长度。当进程访问某个段时,段表会将段号和段内偏移量转换为物理地址。
• 不同段的大小可以不同:分段技术允许内存分配更加灵活,段可以根据实际需要动态分配,解决了分页的固定大小限制。
分段的优点:
• 更符合程序的逻辑结构,例如代码段、数据段和栈段的划分清晰。
• 每个段可以有不同的大小,允许动态内存分配,避免了内存浪费。
• 支持共享和保护机制。不同的进程可以共享同一个段,并为每个段设置不同的访问权限。
分段的缺点:
• 易产生外部碎片,因为段的大小不固定,随着内存使用,会产生不连续的空闲内存块。
• 需要管理段表,增加了复杂性。
存储管理与分页、分段的结合:
在实际操作系统中,分页和分段可以结合使用,即所谓的段页式管理(Segmented Paging),其特点是:
• 先将地址空间划分为若干个段,每个段再分页。这样可以同时享受分段和分页的优点,既能够进行逻辑上的内存划分,又能利用分页解决外部碎片问题。
• 段页式管理允许段的大小可变,并且段内的地址空间可以通过分页映射到物理内存。这使得内存管理既有逻辑性又具备高效的内存分配能力。
参考:
1.https://hansimov.gitbook.io/csapp/part2/ch09-virtual-memory/9.6-address-translation