0. 简介
在大学期间以及第一份工作的前几个月,我鼓捣过单片机,那玩意没有虚拟内存的概念,每次写完代码,通过工具烧录进去,然后就直接运行,单片机没有操作系统,其CPU是直接操作内存的物理地址的。
但是对操作系统来说,同时运行两个相同的程序(假设没有公共唯一资源的竞争,比如端口),那么直接使用物理地址肯定是行不通的,两个程序必将争夺相同的内存。为了解决这个问题,我们需要把进程所使用的地址隔离开来,而隔离的方式就是使用虚拟内存
的概念,每个进程都有一套自己的虚拟地址,至于虚拟地址怎么映射到物理地址,对进程而言是透明的,大家各玩各的,这样就解决了问题。
1. 虚拟寻址
一个使用虚拟内存的系统寻址大致如上所示,CPU通过一个生成的虚拟地址来访问主存,这个虚拟地址在被送到主存之前先转换成适当的物理地址。这个地址翻译的工作由CPU上名为MMU(Memory Management Unit)
的专用硬件和操作系统合作完成。
2. 内存分页
在CPU缓存中,我们使用Cache Line作为最小的读取单元,我的系统中,L1 Cache中Cache Line的大小是64个字节。但是主存缓存不命中的代价要比CPU Cache缓存不命中的代价高的多,因为主存的下一级就是硬盘,而硬盘的访问速度比主存慢100000
倍左右,这个代价是巨大的。
因为大的不命中处罚,虚拟页往往很大,一般在4KB~2MB
之间。可以认为主存的缓存是全相联的,即任何虚拟页都可以放在任何物理页中。
3 页表
前面说过,系统必须知道虚拟页对应的物理页实际在哪个位置,也就是需要有地方存放这种映射关系,而页表就是一个直接由系统管理的,在主存上的,用于管理虚拟内存—物理内存映射关系的数据结构。
可以想象,上图基本上就是我们进行主存寻址的流程,在虚拟地址中,前一部分作为虚拟地址页号,后一部分作为偏移量,在32为Linux系统中,一般页号占据20位,业内偏移量是12位,那是因为一页的大小是4KB = 。根据虚拟页号从页表中查找物理页号,然后再加上偏移量,即可寻址。
但是以上的寻址方式还是会有一定的性能问题:
- 时间上:每次寻址都需要查找页表和最终寻址两次访问主存,消耗性能。
- 空间上:在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有
4MB
的内存来存储页表。一个进程需要4MB
,1024个进程就需要4GB
了,这都超了总内存了,是绝对不能容忍的。
4. TLB
为了解决时间上的问题,我们使用一贯的思路:在不能改变方案的前提下,加缓存!
得益于程序执行的局部性,即在一段时间内,整个程序的执行仅限于程序中的某一部分,所执行的存储空间也应该局限于某个内存空间,所以缓存方案应该能够起作用。
即在MMU中包括了一个小的缓存,称为翻译后备缓冲器(TLB, Translation Lookaside Buffer)。
5. 多级页表
如果只有一级页表,通过前面的计算,我们知道一个32位的系统,每个进程需要4MB
的页表,这太大了。
如果我们将页表分级,以32位地址为例,将20位的页号分为两级,第一级页表有个页表项,每个一级页表项指向一个二级页表,每个二级页表项也有1024个页表项。
如果要将所有的4G内存都映射出来,那么一级页表项(4KB)+二级页表项(4MB)的大小貌似比原来还大了。那多级页表是如何实现缩减页表大小的呢?
那就不得不提计算机中无处不在的局部性原理了。每个进程有4GB的虚拟内存,但是其使用的内存远远没有达到4GB,那么没有使用到的二级页表,可以完全不用分配,这样就可以大大减小页表的大小了。
那为什么单级页表无法做到呢?那是因为页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项。
推广来看,在64位系统中,实际只使用了48位地址,12位的偏移量,36位的虚拟页号被分为了4级页表,每个页表只有9位,占据的大小。
6. 地址翻译和CPU高速缓存
在既使用虚拟内存,又使用CPU高速缓存的系统中,应该使用虚拟地址还是物理地址来访问高速缓存呢?
一般而言,是使用物理寻址,也就是说MMU的翻译发生在CPU和高速缓存之间,即先发生地址翻译,再进行缓存寻址。这样做有以下的好处:
- 保证寻址的一致性,即高速缓存和主存等存储器的寻址逻辑一致;
- 多个进程共享相同的物理内存地址块成为可能,提升了多进程/线程之间的缓存利用率;
- 高速缓存无需处理页面保护的问题,访问权限的检查直接在MMU中完成。
7. 参考文献
《CSAPP》