内存虚拟化
本文是《OSTPE》的笔记,由浅入深介绍计算机中的内存虚拟化技术。
为了实现隔离性以及编程的简单性,操作系统提供内存虚拟化技术,给每个进程制造自己在独占内存的假象。
基址、界限寄存器
假设:
- 为所有进程分配同样大小的内存空间
- 该空间小于物理内存空间
- 进程地址空间保存在连续的内存中
在这三个假设的前提下,可以把物理内存分成若干槽位,下面的图将物理内存分为16KB大小的槽位,操作系统在0——16KB运行,一个进程在32——48KB运行,16——32、48——68KB是空闲的。
重定向地址&检测访问越界
这里采取的办法和进程调度中的办法一样,就是硬件提供支持。CPU提供两个额外的寄存器,基址寄存器和界限寄存器,在操作系统决定要开启一个进程时,它会:
- 确定好该进程放置在物理内存的哪个槽位中
- 将该槽位的起始地址写到基址寄存器
- 将该槽位的结束地址写到界限寄存器
当CPU执行进程的访存指令时,它会:
- 将访存指令中携带的内存地址取出
- 与基址寄存器中的做加法,得到物理地址(称为地址转换,CPU中负责地址转换的单元叫MMU)
- 检查该物理地址是否在基址寄存器和界限寄存器的范围内
- 如果一切正常,按物理地址访问
请注意这里面操作系统和CPU各自的职责
操作系统还应该做什么
- 维护物理内存槽位的未使用列表
freelist
- 在CPU调度其它进程(换出当前进程)时,操作系统的时钟中断处理程序应该保存进程的基址寄存器或界限寄存器
- 启动时必须向硬件注册内存管理相关的异常处理程序表
执行示意
注意,因为进程指令也在内存中,所以每次取值也需要将虚拟地址转换成物理地址
优缺点
优点:
- 简单,优雅
缺点:
- 所有进程空间一致,即使它没有使用,该空间也分配出去了,栈和堆之间的空闲空间不能分配给其它进程(内部碎片严重)
- 所有进程空间一致,当它不够用时,没法动态扩缩容
- 这个实现可以移动进程在物理空间的位置,修改基址和界限寄存器就行,但必须整体移动
分段
分段主要解决上一个解决办法中的堆栈之间有很大的内部碎片的问题。
分段即将进程地址空间分为多个逻辑段,比如分为栈段、堆段和代码段,每个段映射到物理内存空间的不同区域。
需要什么
- 每个段一个基址寄存器,一个界限寄存器
- 一个确定进程当前访问的内存地址在哪个段中的方式
- 显式方式:占用虚拟地址中的几位来区分
- 隐式方式:通过地址的产生方式区分,若地址来自PC,则找代码段
- 不同的地址映射逻辑(因为栈是反向增长的)
说下第三点
假设这个栈段被分配到物理地址的26KB——28KB,对于反向增长的栈来说,它的起始地址是28KB,而结束地址是26KB,所以操作系统在对栈进行地址转换时应该做一些额外的处理。
段共享
假设你运行两个WORD程序,它们两个的代码段应该是一模一样的,这时,操作系统可以只在物理内存中保存一个代码段,然后将两个WORD的代码段的基址寄存器和界限寄存器设置成相同的。
保护位
硬件应该为每个段增加保护位来确定进程对某些段的访问权限,比如:
段错误(Segmentation Fault)
即你访问进程所有的段外的内存地址。
优缺点
优点:
- 没有内部碎片
缺点:
- 实现较之前稍微复杂
- 具有大量外部碎片(看采用的freelist管理算法)
- 书上好像没提到段是否能动态扩容,如果不能,那堆栈内部不还是有已分配但没用上的空间嘛
分页
不同于分段思想,分页是将空间切成相同大小的页面,这样可以避免外部碎片的发生,但是这种处理方式较为复杂,可能需要一些额外的内存记录页到实际物理内存的映射。
概念
- 地址空间:可用于寻址的范围,它是一个虚拟概念,和实际的物理内存大小无关。比如\(2^6=64\),所以我们就可以用长度为6的地址在64B的地址空间中进行寻址。所以32位系统无论你装多大内存都只能用4GB,因为地址空间只有\(2^{32}=4GB\)。
- 页:分割成的内存管理单元,具有相同的大小
- 页帧:真实的物理内存中,承载一个虚拟页的空间
- 页表:用于保存虚拟页到物理页帧的映射,每个进程一个
从上图我们可以看到,页的映射比较灵活(所以它也需要更复杂的数据结构——“页表”来维护这种映射),它可以映射到物理内存的任意一个页帧上,而不要求一定连续啥的。而且在上图中,物理内存比地址空间要大,这也意味着有一些物理内存中的页帧将始终处于空闲状态,得不到利用。
地址转换
地址转换是将虚拟地址空间中的地址映射到物理内存中,在分页里,也可以说是将虚拟页中的某个字节映射到物理内存中对应页帧的某个字节上,这个转换必须通过地址来完成。
所以,虚拟地址必须能够确定页号以及所访问字节在页内的偏移量,物理地址必须能够确定页帧号以及所访问字节在页内的偏移量。偏移量可以在虚拟地址和物理地址间通用,而虚拟页到物理页帧则必须需要某种转换(使用页表)。
在我们的例子中,64B的地址空间被分为4个页,所以用两位来标识虚拟页号就可以在4个页中进行选择,而每个页是16B,使用4个位即可在页中的每个字节里进行选择。
页表的样子
现在我们假设页表就存在内存中,每个进程一个。
只要能够将虚拟页号映射到实际物理页帧的数据结构都可以做页表,一个简单的实现是线性表,如:
PTE即页表中的一个页表项,如你选择第0页,那么PTE0中包含的信息可以让你找到对应的物理页帧号。下图是x86中的页表项结构:
地址的高位被留下一部分足以用来选择物理页帧的位,然后,低位中会保存一些控制位,这些控制位可能在支持虚拟内存时有用。比如P
代表存在位,它表示该页是否已经被换出到磁盘,D
代表脏位,它表示该页被带入内存后是否被修改过,R/W
是读写控制位。
这是页表在虚拟页和物理页帧之间进行映射的一个简单示意
当前页表设计的不足
- 由于当前页表在内存中保存,那么无论是访存还是指令读取都需要一次额外的页表读取
- 页表的大小随地址空间的改变呈指数级增长
假如页大小为4KB,地址空间为32位,也就是4GB大小,那就是能存\(2^{32}-2^{12}=2^{20}\)个页,操作系统需要提供\(2^{20}\)个页表项,假设每个页表项占用4字节,每个页表就需要4MB空间,如果有100个正在运行进程,维护页表就要400MB。而如果地址空间变成64位,那可不是翻一倍的问题,是指数级增长,别指望内存能存下这些东西。
TLB(转换旁路缓冲)
就是在硬件上的一个缓冲,或者说是cache,它缓存进程虚拟页号到物理页(实际上是和PTE差不多的一个东西)的映射。由于离CPU的距离很近,TLB的访问要比内存快很多,根据局部性原理,TLB往往能带来不错的性能提升。
挺简单的,总结来说就是TLB命中就走TLB,否则就走页表查询,并加到TLB中,重新走一遍TLB。
TLB未命中的处理方式:软件 or 硬件
CISC架构的处理器更加倾向于硬件处理,RISC(精简指令集)架构的处理器更加倾向于软件处理。
硬件全权处理TLB未命中时,它必须知道页表的位置(通过页表基址寄存器)以及页表的确切格式(这限制了操作系统实现页表的方式)。
软件处理TLB未命中时,当发现未命中,硬件会发起一个异常,这会暂停当前指令流,转换为内核模式,跳转至操作系统定义的陷阱处理程序。这个陷阱处理程序有些不同:
- 结束后返回到陷入的位置(一半情况下是返回到陷入的下一条指令)
- 该处理程序的内存访问不可以未命中,这会出现循环调用。解决方式是让它不走虚拟内存或者在TLB中保存一些永久项。
TLB如何识别TLBEntry来自于哪个进程
TLB作为全局共享的一个硬件缓存,所有进程都使用它进行虚拟地址到物理地址映射的缓存,但现在它还没法确定一个TLBEntry属于哪个进程。这样,一个进程可能通过TLB访问到另一个进程的内存。
两个解决办法:
- 上下文切换时清空TLB(这不现实)
- 在TLBEntry中保存能够识别进程的标识符
MIPS R4000的TLBEntry格式
- VPN:虚拟页号
- G:全局位,该页是否全局进程共享
- ASID:地址空间ID,用来区分不同的地址空间(区分不同进程)
- PFN:物理页帧号
- C:一致性位
- D:脏位(该页是否被写入新数据)
- V:有效位(该地址映射是否有效)(它只用来指出TLBEntry是否是有效的地址映射,而页表项中的有效位则表示该虚拟页号是否可以被进程使用(进程可能尚未申请这块内存))
TLB如何解决页表过大的问题
更大的页
一般系统中的页大小是4KB,很多操作系统支持多种大小的页面,比如8KB、16KB。
使用更大的页可以让页表大小线性缩减,但它随着地址空间的增长却还是指数级的,所以它解决不了根本问题。
更大页的主要用途是在某些需要大量读写数据的应用中(如数据库系统),尽量让页表更大可以减少TLB未命中的次数。
混合段页结构
- 将一个进程的地址空间拆分为多个独立的段,如堆、栈、代码
- 为每个段建立一个页表,段的基址寄存器指向页表开始位置,界限寄存器指向页表的末尾
- 页表大小可以不固定,比如只分配三个页,这可以通过基址寄存器和界限寄存器来设置
优点:
- 堆栈间未使用页不占用页表项空间
缺点:
- 页表可以是任意大小,外部碎片出现
- 如果有大而稀疏的堆,仍然可能出现大量页表项浪费
多级页表
-
将页表分割成页大小
-
引入新组件——页目录
-
使用页目录和页表构建树形结构
上图,一个多级页表形式,在根部的是一个页目录,PDBR(页目录基址寄存器)指向这个根页目录的起始位置,目录中的每一个项目指向一个被分成页大小的页表(在更多级的页表中也有可能指向其它的页目录),这样的话,物理页帧202和204没有被分配。在之前的线性页表中,它们即使没被使用也必须被分配空间。
多级页表会在TLB未命中时增加内存访问成本,有几层就增加几次
要解决的问题:
- 将虚拟地址(VPN)映射到页目录的项目上
- 将页目录中的项目映射到实际的页表的页上
多级页表示例
二级页表的一个示例
假设:
- 地址空间:\(16KB\)
- 页大小:\(64B\)
- PTE大小:\(4B\)
那么:
- 页数量:\(256\)
- VPN位数:\(log_2(256)=8\)
- 偏移量位数:\(log_2(64)=6\)
- 总虚拟地址位数:\(8 + 6 = 14\)
- 页表可容纳PTE数:\(64/4=16\)
- 页表总大小:\(256 \times 4 = 1KB\)
有了这些,就可以开始构建二级页表:
- 首先就是将整个页表按页大小分割,也就是\(1024 / 64 = 16\)个页表页
- 然后构建页目录,它作为一个页也能容纳16个PTE,但在页目录中,我们使用的是PDE
- 然后页目录的每一个PDE指向一个页表页
虚拟地址索引到页目录
回忆一下,在(之前的使用线性表作为页表的)分页的内存虚拟化策略下,用户的虚拟地址被分成VPN和offset,即虚拟页号和偏移量,虚拟页号用于选中页表中的PTE,offset用于指明用户要访问的字节在页中的位置。
而在多级页表中,虚拟地址的VPN必须有一部分拿出来用于索引到页目录的PDE,上面的例子中使用\(log_2(16)=4\)个位来索引到页目录的一个PDE上。
然后,在页目录中,VPN的剩余部分需要用来找到下一级的页表页
我关于虚拟内存和页表的额外两篇文章:
更多级别的页表
二级页表的限制就是——页目录有容量限制,因为只有一个根节点作为页目录,一旦它无法容纳更多的页表项,就必须加大页大小。上面我们的例子中一个页只能容纳16个页表项。
解决办法就是把页目录页分成多个页,然后把VPN分为更多的层级结构。这在我上面那篇文章里有介绍。