来自 深入浅出计算机组成原理
在操作系统中,程序执行的时候并不是直接访问物理内存去取指令。实际的物理内存被分为了固定大小(一般4KB)的内存页,然后通过虚拟内存的转换找到实际的物理内存地址(内存管理策略),再拿到程序所需要的数据。所以计算机程序"看到的地址"都是虚拟内存地址,那虚拟内存地址是如何转换成实际的物理内存地址呢?
最简单直观的方式是通过增加一张维持双方地址信息的映射表,里面存放着虚拟内存地址的页以及虚拟内存地址页对应的实际物理内存地址页。这个表在计算机里面叫做页表(Page Table)。将一个内存地址(包括虚拟内存和实际的物理内存)分为页号和偏移量。
在32位的操作系统里面,内存地址的高位20位表示页号,低位12位表示偏移量,表示数据在该页中的相对位置。所以对于一个内存地址转换,就是这样的一些步骤:
- 程序读取虚拟内存地址的页号(高位)和偏移量(低位)。
- 从页表中找到虚拟内存地址对应的实际物理地址的页。
- 根据读取的偏移量找到对应的数据。
这样子做看是流程很合理,但自习想想确实是有问题的,就像我们常用的Mysql数据库的索引,每次建立一个索引都需要生成单独的B+树,索引越多,对应的需要的索引空间就会越大。同理,计算机的每个程序都独占进程,每个进程都需要有自己的虚拟内存,所以每个程序都要维护一张页表。而且不管程序是几十K还是几百M的程序,都需要一张独立的页表。
以32位的操作系统为例,表一共需要记录 2^20 个到物理页号的映射关系。这个存储关系,就好比一个 2^20 大小的数组。一个页号是完整的 32 位的 4 字节(Byte),这样一个页表就需要 4MB 的空间。当运行的程序一多,占用的内存空间就非常的大了。如果是64位的系统,就更加的大了。
多级页表(以时间换空间)
其实大部分的程序运行的时候所需要的内存都是有限的,并不是要用上全部的物理内存地址,所以只需要维护用到的内存地址之间的映射就好了。
进程的内存地址如何分配
进程的内存地址分配,通常都是两头实,中间空,在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。所以进程的堆和栈都是连续的地址空间,而这种连续的地址空间分布,非常适合多级页表。
所谓的多级页表其实就是将直接映射(类数组)的高位拆分成更细粒度的部分,比如拆分成三部分即三级页表。举个例子:
如上图,是一个32位操作系统的二级页表的实例图,这里的每一级页都是用4个bits来表示,对于虚拟地址0x01ABC来转换成物理地址,由于12个低位表示偏移,所以ABC就是物理地址实际页的偏移量,0一级页表的相对位置,1是二级页表的相对位置(这里的0和1是十六进制值)。所以先从0找到二级页表是哪个表,定位好了二级页表那么从1这个位置参数找到具体的物理地址了。
这个过程就像树的查找过程,所以多级页表也称为页表树:
我们可以一起来测算一下,一个进程如果占用了 8MB 的内存空间,分成了 2 个 4MB 的连续空间。那么,它一共需要 2 个独立的、填满的 2 级索引表,也就意味着 64 个 1 级索引表,2 个独立的 3 级索引表,1 个 4 级索引表。一共需要 69 个索引表,每个 128 字节,大概就是 9KB 的空间。比起 4MB 来说,只有差不多 1/500。
但是这是一种以时间来换取空间的策略,虽然空间占用很小了,但是类数组的映射只需要访问一次内存,页表树就要访问多次了内存了。对于加速的性能,就需要了解 TLB。
TLB 地址变换高速缓冲
TLB 其实就是一块缓存芯片,缓存了之前已经进行过内存地址转换的结果,也是里局部性原理,这样,当同样的虚拟地址需要进行地址转换的时候,我们可以直接在 TLB 里面查询结果,而不需要多次访问内存来完成一次转换。TLB的原理和CPU高速缓存的原理是一样的。缓存同步,一级二级缓存以及写回策略。
而且为了性能,整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。