我先来说一下我自己对于虚拟内存或者页表的认知吧。当我还是个学生并第一次听到学到这个词时,我认为它还是很直观简单的。这能有多难呢?无非就是个表单,将虚拟地址和物理地址映射起来,实际可能稍微复杂一点,但是应该不会太难。可是当我开始通过代码管理虚拟内存,我才知道虚拟内存比较棘手,比较有趣,功能也很强大。
- 存在某种形式的映射关系;
- 并且映射关系对于实现隔离性来说有帮助。
地址空间 Address Spaces
当然一切操作的目的都是为了实现操作系统的隔离性。如果我们不做任何工作,默认情况下我们是没有内存隔离性的,内存泄露问题很容易发生。所以,我们想要某种机制,能够将不同程序之间的内存隔离开来,这样类似的事情就不会发生。
一种实现方式是地址空间(Address Spaces)。给包括内核在内的所有程序专属的地址空间。所以,当我们运行cat时,它的地址空间从0到某个地址结束。当我们运行Shell时,它的地址也从0开始到某个地址结束。内核的地址空间也从0开始到某个地址结束。
如果 cat 程序想要向地址1000写入数据,那么 cat 只会向它自己的地址1000,而不是 Shell 的地址1000写入数据。所以,基本上来说,每个程序都运行在自己的地址空间,并且这些地址空间彼此之间相互独立。在这种不同地址空间的概念中,cat 程序甚至都不具备引用属于 Shell 的内存地址的能力。 这是我们想要达成的终极目标,因为这种方式为我们提供了强隔离性,cat 现在不能引用任何不属于自己的内存。
所以现在我们的问题是如何在一个物理内存上,创建不同的地址空间,因为归根到底,我们使用的还是一堆存放了内存信息的DRAM芯片。
页表 Page Table 创建不同的地址空间
创建不同的地址空间的最常见的方法,同时也是非常灵活的一种方法就是使用页表(Page Tables)。页表是在硬件中通过处理器和内存管理单元(Memory Management Unit)实现。
CPU 正在执行指令,对于任何一条带有地址的指令,其中的地址应该认为是虚拟内存地址而不是物理地址。虚拟内存地址会被转到内存管理单元(MMU,Memory Management Unit)。内存管理单元会将虚拟地址翻译成物理地址。之后这个物理地址会被用来索引物理内存,并从物理内存加载,或者向物理内存存储数据。
为了能够完成虚拟内存地址到物理内存地址的翻译,MMU 会有一个表单,表单中,一边是虚拟内存地址,另一边是物理内存地址。通常来说,内存地址对应关系的表单也保存在内存中。所以 CPU 中需要有一些寄存器用来存放表单在物理内存中的地址。 现在,在内存的某个位置保存了地址关系表单,我们假设这个位置的物理内存地址是0x10。那么在 RISC-V 上一个叫做 SATP 的寄存器会保存地址0x10。这样,CPU 就可以告诉 MMU,可以从哪找到将虚拟内存地址翻译成物理内存地址的表单。
MMU 并不会保存 page table,它只会从内存中读取 page table,然后完成翻译!
每个应用程序都有自己独立的表单,并且这个表单定义了应用程序的地址空间。所以当操作系统将CPU从一个应用程序切换到另一个应用程序时,同时也需要切换SATP寄存器中的内容,从而指向新的进程保存在物理内存中的地址对应表单。
表单是如何工作的
以 page 为粒度
对于每个虚拟地址,在表单中都有一个条目,如果我们真的这么做,表单会有多大?原则上说,在 RISC-V 上会有多少地址,或者一个寄存器可以保存多少个地址?
寄存器是 64 bit 的,所以有多少个地址呢?是的,2^64 个地址,所以如果我们以地址为粒度来管理,表单会变得非常巨大。
实际情况不可能是一个虚拟内存地址对应 page table 中的一个条目。
为每个page创建一条表单条目,所以每一次地址翻译都是针对一个page。而RISC-V中,一个page是4KB,也就是4096Bytes。这个大小非常常见,几乎所有的处理器都使用4KB大小的page或者支持4KB大小的page。
虚拟内存地址都是64bit,这也说的通,因为 RISC-V 的寄存器是64bit 的。但是实际上,在我们使用的 RSIC-V 处理器上,并不是所有的64bit 都被使用了,也就是说高25bit 并没有被使用。这样的结果是限制了虚拟内存地址的数量,虚拟内存地址的数量现在只有2^39个,大概是512GB。当然,如果必要的话,最新的处理器或许可以支持更大的地址空间,只需要将未使用的25bit 拿出来做为虚拟内存地址的一部分即可。
在剩下的39bit中,有27bit被用来当做index,12bit被用来当做offset。offset必须是12bit,因为对应了一个page的4096个字节。page中的每个字节都可以被offset索引到。
物理内存地址是56bit,其中44bit是物理page号(PPN,Physical Page Number),剩下12bit是offset完全继承自虚拟内存地址(也就是地址转换时,只需要将虚拟内存中的27bit翻译成物理内存中的44bit的page号,剩下的12bitoffset直接拷贝过来即可)。
每个 page 表应该有多大?
这个 page table 最多会有2^27个条目(虚拟内存地址中的 index 长度为27),如果每个进程都使用这么大的 page table,进程需要为 page table 消耗大量的内存,很快物理内存就会耗尽。所以实际上,硬件并不是按照这里的方式来存储 page table。从概念上来说,你可以认为 page table 是从0到2^27,但是实际上并不是这样。实际中,page table 是一个多级的结构。
虚拟内存地址中的27bit 的 index,实际上是由3个9bit 的数字组成(L2,L1,L 0)。每个数字都负责索引一个级别的 page directory。一个directory是4096Bytes,就跟page的大小是一样的。Directory中的一个条目被称为PTE(Page Table Entry)是64bits,就像寄存器的大小一样,也就是8Bytes。所以一个Directory page有512个条目。
索引流程
SATP寄存器会指向最高一级的page directory的物理内存地址,之后我们用虚拟内存中index的高9bit用来索引最高一级的page directory,这样我们就能得到一个PPN,也就是物理page号。这个PPN指向了中间级的page directory。
当我们在使用中间级的page directory时,我们通过虚拟内存地址中的L1部分完成索引。接下来会走到最低级的page directory,我们通过虚拟内存地址中的L0部分完成索引。在最低级的page directory中,我们可以得到对应于虚拟内存地址的物理内存地址。
在最高级的page directory中的PPN,包含了下一级page directory的物理内存地址,依次类推。在最低级page directory,我们还是可以得到44bit的PPN,这里包含了我们实际上想要翻译的物理page地址,然后再加上虚拟内存地址的12bit offset,就得到了56bit物理内存地址。
从某种程度上来说,与之前一种方案还是很相似的,除了实际的索引是由3步,而不是1步完成。这种方式的主要优点是,如果地址空间中大部分地址都没有使用,你不必为每一个 index 准备一个条目。举个例子,如果你的地址空间只使用了一个 page,4096Bytes,现在,你需要多少个page table entry,或者page table directory来映射这一个page?


