内存是计算机中一个重要的资源,需要被认真管理。理想的存储器是私有的、容量无穷的、速度极快的的且非易失的,不过显然这样的存储器是不存在的,但是人们提出了 分层存储器体系 使得我们可以近乎得到这个理想存储器。
在这个体系中,越接近 CPU 的容量越小但速度更快且易失,越远离的容量越大速度更慢但非易失。一般来说只有相邻的两层间会有数据交换。通过这一体系能保证有足够容量存储数据,在被使用时一步步数据被传递到高层以保证速度,从而接近于我们理想中存储器的性能。
操作系统的工作就是将这一体系抽象为一个模型并加以管理。
存储器抽象
操作系统对于存储器的管理有两个阶段发展,经历了两种不同的抽象。
- 无存储器抽象:最简单的存储器抽象。在这一情况下所有程序都可以直接访问物理内存,一个程序很容易就会影响到另一个程序的内存甚至是操作系统,从而使得程序运行十分危险,运行多个程序变得不可能,除非加入诸多复杂的机制以限制。
- 地址空间:导致无存储器抽象诸多问题的原因就在于程序可以直接访问物理内存,所以提了一个新的存储器抽象 地址空间。这一概念使得每个进程都有自己可寻址的内存地址集合,独立于其他程序的地址空间。这一抽象的关键在于如何给每个程序独立的地址空间。
虚拟内存
实现地址空间有过许多解决方案,例如基址寄存器和界限寄存器、交换技术等,但都有其缺陷,现在主流解决的方案称为 虚拟内存 。
分页
在使用虚拟内存的时候,虚拟地址一般被送到 内存管理单元(Memory Management Unit, MMU),映射到物理地址再送入总线。虚拟地址空间一般按照固定大小被分成若干页,x86-64 支持 4KB、2MB 和 1GB 的页面。
如果没有对应的映射就说明这部分数据仍在硬盘中,即上图中 -
的情况,此时称为 缺页错误(page fault)。这时 CPU 会陷入到操作系统,由操作系统装入再重新执行引起中断的指令。
页表
页表(Page Table)记录了到页框的映射。一个典型的页表项如下图所示。
- 有效位:指明该表项是否在内存中。在初始化时会置为无效。
- 保护位:指明允许什么类型的访问,简单的只有一位表明是否可写,或者用三位分别指明是否可读、可写、可执行。
- 修改位:指明这一页是否被修改过。如果被修改过那么放弃该页时就需写回硬盘,如果没有直接舍弃即可,所以这一位也有是称为脏位(dirty bit)。
- 访问位:指明这一页最近是否有被访问过,用于在挑选被替换页面时的参考,因为最近没被使用过的相比被使用过的更适合被替换。
- 高速缓存禁止位:有些时候并不希望获得一个高速缓存中的副本通过标记这一位实现。
有了页表就可以研究 MMU 映射的过程。
上图是从 64KB 虚拟地址映射到 32KB 物理地址的过程。中间是由数个页表项组成的页表。虚拟地址可以被分为两部分,虚拟页号和偏移地址。虚拟页号作为索引可以找到对应的页表项,如果有效就得到了页框号,与偏移地址组合即得到了物理地址。
为什么高几位可以作为索引找到对应的页表项?可以类比十进制,如果一个块是 100 个,那个编号 015 就是第 0 块中第 15 个,编号 249 就是第 2 块中的 第 49 个。这也说明了为什么页大小必定为 2 的次方,否则就不能通过某几位快速得到结果而必须计算,引入不必要的开销。
如何确定偏移地址是几位?这个与页大小有关,这里页大小为 4KB,所以一个页中元素可以用 12 位( \(2^{12} B = 4KB\) )表示,所以低 12 位作为偏移地址,剩余的作为索引。因为页表要为所有可能的映射做准备,所以剩余位数所能组成的数量就是页表大小,这里还有 4 位所以页表就是 16 项,。
值得注意的是,如果位无效,页表中不会记录对应的磁盘地址,因为页表只负责将虚拟地址映射到物理内存地址,发生缺页负责的是操作系统,这些信息由操作系统负责管理。
加速分页过程
转换检测缓冲区
虚拟地址到物理地址的映射是十分频繁的,所以必须要十分快。然而页表通常也存放于内存,这样访问一次内存实际要访问两次,那么访存性能就会降低一半,显然不可取。
但是有了局部性原理,即一个位置被访问了,这个位置附近最近也会被访问,所以很少一部分页表会被反复读取,绝大部分很少被访问,所以在计算机内部,通常是 MMU,加了一个小型硬件负责存储这一小部分页表,称为 转换检测缓冲区(Translation Lookaside Buffer,TLB),也称为 快表。
有了 TLB 后,在进行转换时会首先在 TLB 查找对应的映射,如果有就直接用,如果没有就正常通过页表,并替换其中一个记录。
针对大内存的页表
之前提到页表要为所有可能的块做准备,那么对于 64 位系统,如果一个页 4KB,那么页表就需要 \(2^{52}\) 项,那么页表可能比程序本身还大,而且每个程序都有个自己的页表,消耗的内存大小极大。
其实其中绝大部分其实并没有被使用,所以一个可用的方案是 多级页表。
可以将地址分为更多部分,第一部分对应的是一级页表索引,第二部分对应的二级页表索引,这样可以节省绝大部分空闲的页表部分。而且以此类推可以有三级、四级甚至更多的页表。
页面置换算法
发生缺页时,操作系统需要选择一个页将其换出,至于挑选哪一个会影响到性能,因为如果调出一个很快会被使用的就会导致又一次缺页,而缺页的代价是极大的。
最优页面置换算法
最理想的情况是置换一个最晚会被再次使用的页。一个页在500万条指令后再会被使用,另一个页在 300 万条指令后被使用,显然是置换前者更好。然而这一算法并无法实现,因为并不知道之后会运行哪些。
尽管如此,这一算法是最优的情况,作为标杆,如果我们能与该算法只差 1% 的性能就不必花更多精力只为提高 1%。
最近最少使用算法
由局部性原理,最近使用的很有可能在接下来仍被使用,那么很久都没使用过的在未来一段时间很可能仍不被使用,这一思想揭示了这一算法。
这一算法可实现,但有很高的代价,因为需要记录每一页被使用的次数,发生缺页就找到最少使用的替换,但很少有硬件拥有这类功能,更多的用软件近似实现这一思想。
总结
虚拟内存的基本思想是将每个程序的地址空间分割成数个块,每一块称为 页(page),这些页能在被使用时映射到物理内存。
当程序试图访问一个内存地址时,提供的是其地址空间中虚拟地址,MMU 会通过页表进行映射,从而访问物理内存。如果物理内存中没有这一页,会发生缺页错误,操作系统就会从硬盘中装入这一页,并对页表进行替换,之后再重新执行导致缺页的指令。