虚拟内存技术

缘起

最开始,操作系统的内存管理非常简陋。只是简单把物理内存分为两部分:低地址部分,64K分给操作系统,高地址部分分给用户程序。而且每次只执行一个程序。执行完后要重新装载程序,再执行其他程序。接着,由于那是机器非常昂贵,人们希望能够充分利用机器资源。出现了多任务的操作系统。这时操作系统能够支持一次自动执行多个任务,在某些程序执行IO的时候,CPU能够执行其他程序,这样就提升了机器资源的利用率。后来,又有了希望能够和程序及时交互。这样就能方便知道程序执行的状态了,这个对程序员来说太重要了。不然,每次都要等程序执行完才能知道结果。于是有了分时的操作系统,也就是每个进程执行一小部分时间。但是,这里就出现了安全问题,一个进程随意的读写另外一个进程的数据,甚至是操作系统的数据!由此,内存的虚拟化就出现了,它为每个程序提供一个统一抽象的地址空间,程序看到的地址又叫虚拟地址。它的目标包括:

  1. 透明。对程序透明,每个程序只需要认知地址空间就行,不需要考虑自己的数据、代码是放在物理内存的哪个位置。
  2. 高效。包括空间和时间两方面。
  3. 安全。进程隔离,保证不能任意访问其他进程的数据。

地址转换

简单来说,把进程的虚拟地址转换成物理地址。同时程序的每次内存访问都在操作系统的控制之下,确保安全。另外,通过硬件的支持保障性能以及地址转换对用户透明。它是内存虚拟化的核心机制,我们遇到的很多技术细节都是为了解决这个问题。

策略

动态重定位

这里包括软件实现的静态重定位(static relocation)和基于硬件的动态重定位(dynamic relocation)。

软件的实现通过一个叫loader的软件,把应用程序中的引用的地址都加上一个偏移地址就变成物理地址了。显然,这里最大的问题是不安全。

硬件就不一样了,它有两个寄存器一个基地址寄存器,一个地址范围限制寄存器。当访问一个内存的时候,先把地址加上基地址寄存器中的值,这个结果就是物理地址了。同时还会对比物理地址和地址范围寄存器比较,如果超过就触发异常。这个硬件一般叫做内存管理单元(MMU)。

因此,硬件需要提供以下几个功能:

  1. 特权模式。因为硬件有MMU的操作指令,这些指令只有操作系统才能使用,用户程序是不能直接使用的。
  2. 基地址寄存器和地址范围寄存器。用于虚拟地址到物理地址的转换以及地址合法性的检查。
  3. 地址转换以及检查地址是否在范围内的能力。
  4. 更新基地址寄存器和地址范围寄存器指令。操作系统运行一个进程时,需要把进程的基地址和地址范围设置到到相应的寄存器。
  5. 提供特权指令注册异常处理程序。操作系统告诉硬件具体异常的处理程序地址。
  6. 触发异常的能力。非法访问内存地址时触发异常。

同时,操作系统需要提供以下几个功能:

  1. 内存管理。为新进程分配内存,回收执行结束的进程使用的内存,通常是一个free list来管理。
  2. 基地址和地址范围管理。进程切换时设置当前执行的进程的基地址和地址范围到相应的寄存器。
  3. 异常处理能力。

操作系统和硬件的交互,主要包含在启动和执行程序的时候。

启动

操作系统(kernel mode)                     硬件
================================+=================================
 初始化异常表

                                       保存系统调用地址
                                       定时处理程序地址
                                       非法内存访问处理程序地址
                                       非法指令异常处理程序
 启动定时器中断
                                       开启定时器
 初始化进程表
 初始内存空闲列表

程序执行

操作系统(kernel mode)                           硬件                        程序(user mode)
================================+=======================================+=====================
 启动进程A:
  进程表中添加进程
  分配进程需要的内存
  设置基地址和地址范围寄存器
  执行进程A的代码
                                       恢复进程A的寄存器,进入user mode
                                       跳转到进程A的起始指令
                                                                              获取指令
                                       转换指令的物理地址,获取指令内容
                                                                              执行指令
                                       如果是保存或者获取数据,检查内存
                                       地址是否合法
                                                                              继续执行。。。
                                       定时器时间到了,进入kernel mode
                                       执行定时器处理程序
 停止执行进程A,保存进程A的上
 下文到进程控制块:
  当前执行的指令地址以及基地址
  寄存器和地址范围寄存器的值
  加载进程B的寄存器以及指令地址
  执行进程B的代码
                                       恢复进程B的寄存器,进入user mode
                                       跳转到进程B的指令
                                                                              访问非法地址
                                       地址越界,触发异常进入kernel mode
 执行非法访问地址异常代码,终止
 进程B,回收进程B的内存,释放
 进程表中有关进程B的内存

分段

把整个地址空间映射到物理空间如下图(假设地址空间大小是16K):

              0KB    +----------------+
                     |                |
              1KB    +  program code  +
                     |                |
              2KB    +----------------+
                     |     ///////    |
              3KB    +      free      +
                     |     ///////    |
              4KB    +----------------+
                     |                |
              5KB    +                +
                     |     heap       |
              6KB    +                +
                     |                |
              7KB    +----------------+
                     |        |       |
                     |        V       |
                     |     ///////    |
                     |     ///////    |
                     |      free      |
                     |     ///////    |
                     |     ///////    |
                     |        ^       |
                     |        |       |
             14KB    +----------------+
                     |                |
             15KB    +     stack      +
                     |                |
             16KB    +----------------+

这样的会有两个问题

  1. 因为物理地址必须连续,而没有被使用的空间也会占用物理内存,造成内存浪费。
  2. 因为物理内存必须是连续,不支持地址空间比物理内存大情况。

注意到地址空间中主要有个三部分数据:代码、堆、栈。代码大小是固定,而堆和栈不是固定的,而且往往使用的比较少。如果把它们分成三个独立的地址空间,独立映射到物理地址,可以把它们映射到不同的物理地址空间,也不要求物理地址连续。通过减少堆和栈的大小这样用不到的地址空间就可以不用映射了,从而节省了物理内存。这就叫分段

为了支持分段,硬件需要提供额外支持:

  1. 多对基寄存器和地址范围寄存器。
  2. 还需要区分地址是属于哪个段。
  3. 因为栈是从高地址到低地址生长的,不能通过简单的基地址加上偏移来搞定。
  4. 支持共享只读的内存进一步节省内存。

同样,操作系统也需要做一些事情:

  1. 上下文切换的时候需要保存和恢复不同的段的基地址寄存器和地址范围寄存器。
  2. 需要提供段扩容和缩容的系统调用。
  3. 管理空闲内存,这个是最重要,因为有了分段以后,不同段大小不一样,不同程序分配和释放以后物理内存就会存在空洞,这时就会存在一种外部碎片的问题,就是总的内存足够,但是因为不连续不分配一个块小于总内存的连续物理内存,导致分配失败。相比以前物理内存被分割成大小(地址空间大小)相等的几个块,分配会回收都用同样的大小,在映射整个地址空间的情况下,不会存在内存足够但是无法分配的情况。

空闲内存管理

空闲内存管理主要目标是高效的处理变长内存空间的请求和减少内存碎片(外部碎片和内部碎片)。有几个基本的机制:

  1. 分裂和合并。从一块大内存开始,分配的时候分裂内存,释放的时候合并相邻的内存。
  2. 追踪被分配内存的大小。
  3. 连接不相邻的空闲内存。
  4. 扩大堆的大小。

在效率和内存碎片的权衡中又有多种不同的分配策略:

  1. 大小最合适。取个大小最小的空闲内存。
  2. 大小最不合适。取个大小最大的空闲内存。
  3. 第一次合适。取第一个满足要求的空闲内存。
  4. 下一次合适。在第一次合适的基础上,加个查询指针每次都是从这个指针开始查找第一个满足要求的空闲内存。
  5. 分段列表。把内存组织成多个块。每个块负责处理某种大小的内存请求。
  6. 伙伴算法。优势加速合并相邻内存,但是有内部碎片。

分页

在之前的技术中有两个问题:

  1. 分段的时候如果段很大,但是空间使用又很稀疏,而且使用又在开始和结尾处,那么这段内存就无法收缩,导致内存浪费。
  2. 在处理空闲内存管理的时候,把内存分成不同的串时,无法避免外部碎片。选择时机

分页就可以解决这两个问题。分页把地址空间和物理内存都分成大小固定的块,这些块就叫页。这个时候,即使虚拟地址是连续的,但是它映射的物理地址是可以不连续的。所以,不使用的内存可以不分配物理内存。这样就解决了第一个问题。显然,物理内存分配单元大小固定又不需要连续,那就不会有外部碎片了。

地址映射怎么解决呢?先来看下分页以后物理地址的内容。有了页以后,我们需要有一部分信息来寻找地址所在的页,同时还需要一部分信息来寻找在页中的位置,因此物理地址就分成了两部分:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10| 9| 8| 7| 6| 5| 4| 3| 2| 1| 0|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|_______________________page index__________________________|____________page offset____________|

假设物理内存是4G=2^32,每页大小是4K=2^12 。那么,页内偏移(page offset)就需要12位信息。页索引(page index也叫PFN/physical frame number)需要20位。这样物理地址=页索引<<12 + 页内偏移

那虚拟地址怎么组织呢?页索引和物理地址是一样的,这样就简化问题了,通过虚拟地址的其他部分信息找到物理页索引。这个寻找过程,通过叫页表的数据结构。它保存了物理内存的页索引。最简单的页表是一个一维数组(线性页表,linear page table),每个页表项(PTE)保存了物理内存的页索引,大小是4个字节。假设地址空间是32位大小,页大小是4K,那么页索引是20位。虚拟地址结构是这样的:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10| 9| 8| 7| 6| 5| 4| 3| 2| 1| 0|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|_______________________page index__________________________|____________page offset____________|

虚拟地址的页索引叫做VPN(virtual page number)。

虚拟地址到物理地址的转换过程:

  1. 通过页表基地址寄存器(PTBR),找到页表的起始物理地址
  2. VPN*页表项大小+页表的起始物理地址,找到页表项
  3. 从页表项的内容,取出物理地址的页索引
  4. 物理地址=虚拟地址中的偏移+物理地址的页索引*页大小

页表项内容除了包含PFN,一般会有包含以下一些信息:

  1. 合法标识,表明这页内存是否合法,反问非法的内存会报错。
  2. 保护标识,表明这页内存是可读可写可执行。
  3. 出现标识,表明这页内存是否在物理内存中。
  4. 访问标识,表明这页内存是否被访问过,主要用户页替换。

问题又来了!这样的方式有两个问题:

  1. 相比分段或者简单的全部映射,通过页表基地址寄存器和VPN找到页表项,然后再通过页表项里面保存的PFN和页内偏移找到具体的物理地址,这里多了一次内存访问,性能变慢了。
  2. 这时页表大小是2^20 * 4=4M。每个进程页表都需要4M内存,有点大了。而且,这样也浪费了物理内存,因为很多时候很多页表项是不用的。

TLB

为了解决地址转换慢的问题,引入例如TLB,全称tanslation-lookaside buffer,本质上是一块缓存,是硬件部分,包含在MMU当中。既然是缓存,那么只有在命中率高的情况下才有效。好在大部分程序在时间和空间上都有局部性,所以能够起到加速效果。引入了TLB会带来什么问题呢?

TLB失效,谁来处理?硬件还是操作系统?

都可以。像x86是硬件来处理的。MIPS R10k是操作系统来处理的。

选择时机进程上下文切换的时候,TLB怎么处理?

  1. 每个TLB项中加入进程标识。
  2. 切换时刷掉TLB。

TLB满时如何替换?

LRU或者随机。

更小的页表

在线性页表的实现中,每个进程页表太大了,需要4M。怎么破呢?

大页

增加页的大小,比如2M,在32位的地址空间当中,页表索引就只有11位了,页表大小2^11*4=8K。大页还有另外一个好处是提升TLB的命中率。问题是:较多的内部碎片。

混合分段和分页

在分段的基础上,给每个段用页表来保存地址映射。因为,每个段彼此独立,为每个段可以分配不同大小的页表,从而节省空间。问题是:分段的问题依旧,如果段大小很大但是只引用到了较少的部分,导致页表的内存浪费;每个段大小不一请求分配页表大小也不一样,导致外部碎片

多级页表

大页和混合分段和分页的方式是从整体页表大小的角度来减少页表大小。其实,在页表里面还有很多没用的页表项,比如某块虚拟地址就没有被访问过,就不会有物理地址和它映射,它占据的页表项程序根本不会用到。多级别页表就是通过减少这些页表项来降低页表的大小。

多级页表在简单的线性页表基础上把VPN分成了多个部分,为了方便说明假设是2个部分,分成了页表目录索引和页表索引,如下图页表目录索引和页表索引都是10位。

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10| 9| 8| 7| 6| 5| 4| 3| 2| 1| 0|
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|____page directory index_____|____page table index_________|____________page offset____________|

虚拟地址到物理地址的转换过程:

  1. 通过页表目录基地址寄存器(PDBR),找到页表目录的起始物理地址
  2. 页表目录索引*页表目录项大小+页表目录的起始物理地址,找到页表目录项
  3. 从页表目录项的内容,取出页表所在的物理页索引
  4. 页表项=页表索引 * 页表索引项大小 +页表所在的物理页索引 * 页大小
  5. 从页表项的内容,取出物理地址的页索引
  6. 物理地址=虚拟地址中的偏移+物理地址的页索引*页大小

多级页表的问题就是在转换的时候多了几次内存访问。想起TLB!

反向页表(Inverted Page Tables)

维护一个全局的物理地址到虚拟地址的映射。当然,映射的值中还要包含这个虚拟地址所属进程。这样页表的大小就是固定了。问题是:查找耗时。再搞个虚拟地址到物理地址的映射;)

小结

最佳方式分段加多级页表!x86就是这么干的。

超越物理内存

当进程运行时需要的总物理内存大小超过实际物理内存大小时,进程就会因为内存申请失败跑不起来了,这时可能是一个进程也可能是多个进程。为了解决这个问题,操作系统需要把当前物理内存中不常用的数据放到外面去。放哪里呢?答案是磁盘。在内存层次磁盘是最下面一层。用来存放内存数据的磁盘空间,又叫交换空间(swap space)。

这样就有几个问题需要解决:

  1. 数据不在内存中,在磁盘中。在PTE中加入标识位来做区分
  2. 如果数据在磁盘中,内存访问的时候会产生缺页错误(Page fault),OS负责处理这个错误,从磁盘中把数据加载到内存
  3. 当物理内存不够的时候,需要从物理内存中选择一块内存,把它的数据交换到交换空间。这时需要一个替换策略来选择哪块内存

策略

问题1和2都比较明确,简单。关键是第3个问题,在内存层次上看内存相对与磁盘来说可以算是数据的缓存。命中率越高越好!因此,“选择物理内存,写到交换空间,再把数据写入内存”这个过程的目标是让内存访问的有高命中率。

选择时机

当内存满的时候再去替换内存,会影响当前的内存访问。因此,尽量保证物理内存足够满足当前的需要是最理想的。这方面操作系统会维持空闲物理内存两个水位,高水位和低水位。当空闲物理内存低于低水位时就会根据替换策略把数据替换到交换空间,直到空闲物理内存达到高水位。

替换策略

主要策略包括简单的FIFO和随机方式,以及考虑历史访问特性的LRU、 LFU,因为实现这些标准的考虑历史特性的算法空间和时间效率不高,又产生了一些近视算法,比如时钟算法,以及考虑脏页的时钟算法。

选择策略

选择需要写入的数据时,操作系统可以预判哪些数据将会被使用,这样就可以先把这些数据加载到内存,当访问的时候就可以命中了。像linux内核中就有这样的参数去访问文件。

写入策略

因为磁盘操作慢,每次替换就写磁盘也是影响内存访问的时间,考虑批量写回数据可以缓解这个问题。

所有的策略都有很多的复杂度,最简单的就是买更多的内存:)

问题

交换空间还有一个潜在的问题是程序缺页频繁导致系统抖动。linux处理的方式选择一个内存使用比较大的,kill掉!

小结

有了swap空间,地址空间大小限制就小了,程序的空间就大了,简单方便!但是,到磁盘读数据的代价是很大,最简单的方式是买更大的内存!

← 简单设计的4个原则 《A Philosophy of Software Design》笔记 →
存档 关于