Page tables
# 1. 课程内容简介
今天的主题是 Virtual Memory(VM),首先会先介绍 Page tables,在后面的课程会介绍 virtual memory 的其他相关内容。
隔离性是我们讨论 VM 的主要原因,尤其是当我们通过代码来管理 VM 之后,就可以真正理解他的作用。
今天的内容主要是 3 个部分:
- Address Space
- 支持 VM 的硬件。这里介绍的是 RISC-V 相关的硬件,其他现代处理器都类似。
- 过一下 XV6 中 VM 的代码,并看一下内核地址空间和用户地址空间的结构。
# 2. Address Space
我们创造 VM 的一个出发点就是实现强隔离性,那我们期望从隔离性中得到什么样的效果呢?
我们期望每个用户程序都被装进一个盒子里,这样他们就不会彼此影响了。同时他们与 OS kernel 相互独立,如果某个程序出现了问题,也不会影响到 OS。这是我们对隔离性的期望。
今天的课程主要关注的是内存的隔离性。如果我们不做任何工作,默认情况下我们是没有内存隔离性的,所有程序的指令和数据都存在同一个内存中,一旦某个程序存在 bug 导致内存溢出,就会覆盖其他程序的数据,从而引发问题。所以我们想要某种机制,能够将不同程序之间的内存隔离开来,一种实现方式就是 Address Space。
Address Space 的基本概念是:我们给包括内核在内的所有程序专属的地址空间,每个地址空间都是从 0 开始到某个的地址结束。
但问题是如何在同一个物理 DRAM 芯片上创建出不同的地址空间。
问答:
- 虚拟内存可以比物理内存更大,也有可能更小。
- 物理内存是有可能耗尽的。
# 3. Page Table
如何在一个物理内存上,创建不同的地址空间?
最常见也是最灵活的一种方法就是使用页表(Page Tables)。Page table 是在硬件中通过 CPU 和 MMU 实现的。
MMU:Memory Management Unit,内存管理单元
# 3.1 地址翻译的过程
下图是执行一条指令所发生的状况:
- CPU 接收到一条指令:
sd $7, (a0)
,假设寄存器 a0 中的地址是 0x1000,这是一个虚拟内存地址 - CPU 将虚拟地址 0x1000 发给 MMU,MMU 中有一个表单,记录了虚拟地址和物理地址的映射关系,从而将虚拟地址(VA)转为物理地址(PA)
- 之后根据物理地址就可以完成内存访问
通常来说,内存地址映射关系的表单也保存在内存中,所有 CPU 中需要有一些寄存器来存放表单在物理内存中的地址。在 RSIC-V 中,会有一个叫做 SATP 的寄存器来保存地址映射关系的表单的物理地址。这样,CPU 就可以告诉 MMU 从哪找到这个映射关系的表单了。
由于每个应用程序都有自己独立的表单,所以当 OS 将 CPU 从一个进程切换到另一个进程时,同时也需要切换 SATP 寄存器中的内容。
注意点:
- MMU 并不会保存 page table,它只会从内存中查看 page table。
- 内核负责写 SATP 寄存器的内容,这是一个特权指令。
# 3.2 以 page 为粒度的地址翻译
前面只是最基本的介绍,还存在很多不合理的地方。我们不可能会为每个地址都提供一个映射关系,否则这会让表单变得无比庞大。实际情况是内存以 page 为粒度,每一次地址翻译都针对一个 page。在 RISC-V 中,一个 page 是 4KB,几乎所有的处理器都使用 4KB 大小的 page 或者支持 4KB 大小的 page。
现在内存地址的翻译方式略微不同了,对于一个虚拟地址,我们把它划分为两部分:index 和 offset,index 用来查找 page,offset 对应的是一个 page 中的哪个字节。
在 MMU 做地址翻译时,通过读取 VA 的 index 就可以知道物理内存中的 page 号,再用 offset 加上 page 的起始地址,就可以得到物理内存地址。
在 RISC-V 中,VA 都是 64bit,其寄存器也是 64bit 的。但 RSIC-V 处理器并没有把所有 64bit 都使用了,高 25bit 并没有使用,这样就限制了虚拟内存地址的数量,现在虚拟内存地址数量只有
offset 必须是 12bit,对应了一个 page 的 4096 字节的大小。
在 RISC-V 中,PA 是 56bit。其实大多数主板还不支持
PA 是 56bit,其中高 44bit 是物理 page 号(PPN),剩下 12bit 是 offset,这个 offset 直接拷贝自 VA。
问答:
- Stu:一个 page 在物理内存中是连续的吗?
- Prof:是,物理内存是以 4096 为粒度使用的。
- Stu:56bit 是根据什么确定的?
- Prof:这是由硬件设计人员决定的。所以RISC-V的设计人员认为56bit的物理内存地址是个不错的选择。可以假定,他们是通过技术发展的趋势得到这里的数字。比如说,设计是为了满足5年的需求,可以预测物理内存在5年内不可能超过
这么大。 - Stu:因为这是一个64bit的机器,为什么硬件设计人员本可以用64bit但是却用了56bit?
- Prof:选择 56bit 而不是 64bit 是因为在主板上只需要 56 根线。
现在,我们的地址转换表是以 page 为粒度了,所以现在这个转换表可以称为 page table 了,但目前这个设计还不能满足实际需求。
# 3.3 多级 page table
如果每个进程都有自己的 page table,那么每个 page table 表会有多大呢?
这个page table最多会有2^27个条目(虚拟内存地址中的index长度为27),这是个非常大的数字。如果每个进程都使用这么大的page table,进程需要为page table消耗大量的内存,并且很快物理内存就会耗尽。所以实际上,硬件并不是按照这里的方式来存储page table。
实际中,page table 是一个多级的结构。下图是一个真正的 RISC-V page table 结构和硬件实现:
之前我们说的 VA 中 27bit 的 index,实际上是由 3 个 9bit 的数字组成的(L2、L1、L0),前 9 bit 用来索引最高级的 page directory,在最高级 page directory 中能得到一个 PPN,这个 PPN 指向了中间级的 page directory,再从 L1 index 从中间级 page directory 中检索找到最低级的 page directory,然后再从 L0 index 从最低级 page directory 中得到最终的地址翻译结果,即 VA 对应的 PA。
一个 page directory 的大小与一个 page 是一样的,也是 4096 Bytes,directory 的一个条目被称为 PTE(Page Table Entry),大小为 64bits,也就是 8Bytes,所以一个 page directory 有 512 个 PTE。
这种方案下,实际的索引是由三步完成的,这样的主要优点是:如果地址空间中大部分地址都没有使用,你不必为每一个 index 准备一个条目。假如你只用了一个 page,那就只需要 3 个 page directory,而前一个方案中,虽然我们只使用了一个 page,但还是需要
相比之下,多级 page table 的方案所需的空间大大减少了。这也是实际上采用这种层次化的 3 级 page table 结构的主要原因。
# 3.4 一条 PTE 的内容
一个 PTE 是 64 bit,其中 PPN 占用了 44 bit,在剩下的 20 bit 中,有 10 bit 用来存 Flags,10 bit 未被使用:
我们看一下其中的 Flags,它们很重要。每个 PTE 的低 10 bit 都是一堆标志位:
- 第一个标志位是 Valid。如果 Valid bit 位为1,那么表明这是一条合法的 PTE,你可以用它来做地址翻译。
- 下两个标志位分别是 Readable 和 Writable。表明你是否可以读/写这个 page。
- Executable 表明你可以从这个page执行指令。
- User 表明这个 page 可以被运行在用户空间的进程访问。
- 其他标志位并不是那么重要,他们偶尔会出现,前面5个是重要的标志位。
# 4. 页表缓存(Translation Lookaside Buffer)
根据多级 page table 的结构,每一次 VM 的翻译都需要读三次内存,这个代价有点高。所以实际中,几乎所有的处理器都会对于最近使用过的 VM 的翻译结果有缓存。这个缓存被称为:Translation Lookside Buffer(通常翻译成页表缓存),经常被缩写为 TLB。基本上来说,TLB 就是 PTE 的缓存。
每次虚拟地址翻译的结果,TLB 会保存这个虚拟地址到物理地址的映射关系,这样下一次访问同一个虚拟地址时,就可以直接从 TLB 返回结果。
有多种方法来实现 TLB,对于 OS 最重要的是知道 TLB 的存在,具体实现是处理器的逻辑,对于 OS 来说是不可见的,OS 也不需要知道 TLB 是如何工作的。OS 需要知道 TLB 存在的唯一原因是:如果你切换了 page table,那 OS 需要告诉处理器当前正在切换 page table,处理器会清空 TLB。在 RISC-V 中,清空 TLB 的指令是 sfence_vma。
page table 提供了从虚拟地址到物理地址的映射的抽象,这个映射关系完全由 OS 控制。因为 OS 对于这里的地址翻译有完全的控制,它可以实现各种各样的功能。比如,当一个 PTE 是无效的时候,硬件会返回一个 page fault,对于这个 page fault,操作系统可以更新 page table 并再次尝试指令。所以,通过操纵 page table,在运行时有各种各样可以做的事情。现在只需要记住,page table 是一个无比强大的机制,它为操作系统提供了非常大的灵活性。
# 5. Kernel Page Table
现在看一下 xv6 中 page table 是如何工作的。
首先我们看一下 kernel page 的分布,下图是内核中地址的对应关系,左边是内核的虚拟地址,右边上半部分是物理内存(DRAM),右边下半部分是 I/O 设备。接下来会首先介绍右半部分,然后介绍左半部分。
# 5.1 物理内存分布
图中右半部分的结构完全由硬件设计者决定。当操作系统启动时,会从地址 0x80000000 开始运行,这个地址其实也是由硬件设计者决定的。下图是一个主板:
中间是 RISC-V 处理器,我们现在知道了处理器有 4 个核,每个核都有自己的 MMU 和 TLB,处理器旁边是 DRAM。
主板的设计人员决定了,在完成了虚拟到物理地址的翻译之后,如果得到的物理地址大于 0x80000000 会走向 DRAM 芯片,如果得到的物理地址低于 0x80000000 会走向不同的 I/O 设备。这是由这个主板的设计人员决定的物理结构。在主板手册中详细介绍了物理地址与 IO 设备的对应关系,比如:
表示地址 0x10090000 对应以太网。
再看最初那张图的右侧,即物理地址的分布。可以看到最下面是未被使用的地址,这与主板文档内容是一致的(地址为0)。地址0x1000是 boot ROM 的物理地址,当你对主板上电,主板做的第一件事情就是运行存储在 boot ROM 中的代码,当 boot 完成之后,会跳转到地址 0x80000000,操作系统需要确保那个地址有一些数据能够接着启动操作系统。
这里还有一些其他的 IO 设备:
- PLIC 是中断控制器(Platform-Level Interrupt Controller)我们下周的课会讲。
- CLINT(Core Local Interruptor)也是中断的一部分。所以多个设备都能产生中断,需要中断控制器来将这些中断路由到合适的处理函数。
- UART0(Universal Asynchronous Receiver/Transmitter)负责与 Console 和显示器交互。
- VIRTIO disk,与磁盘进行交互
地址 0x02000000 对应 CLINT,当你向这个地址执行读写指令,你是向实现了 CLINT 的芯片执行读写。这里你可以认为你直接在与设备交互,而不是读写物理内存。
问答:
- Stu:确认一下,低于 0x80000000 的物理地址,不存在于 DRAM 中,当我们在使用这些地址的时候,指令会直接走向其他的硬件,对吗?
- Prof:是的。高于 0x80000000 的物理地址对应DRAM芯片,对于例如以太网接口,也有一个特定的低于 0x80000000 的物理地址,我们可以对这个叫做内存映射 I/O(Memory-mapped I/O)的地址执行读写指令,来完成设备的操作。
# 5.2 虚拟地址分布
接下来切换到最初那张图的左半部分,这就是 xv6 的虚拟内存地址空间。当机器刚刚启动时,还没有可用的 page,XV6 操作系统会设置好内核使用的虚拟地址空间,也就是这张图左边的地址分布。
因为我们想让 xv6 尽可能的简单易懂,所以这里的虚拟地址到物理地址的映射,大部分是相等的关系。比如说内核会按照这种方式设置 page table,虚拟地址 0x02000000 对应物理地址 0x02000000。这意味着左侧低于 PHYSTOP 的虚拟地址,与右侧使用的物理地址是一样的。
所以,这里的箭头都是水平的,因为这里是完全相等的映射。
除此之外,这里还有两件重要的事情:
第一件事情是,有一些page在虚拟内存中的地址很靠后,比如kernel stack在虚拟内存中的地址就很靠后。这是因为在它之下有一个未被映射的Guard page,这个Guard page对应的PTE的Valid 标志位没有设置,这样,如果kernel stack耗尽了,它会溢出到Guard page,但是因为Guard page的PTE中Valid标志位未设置,会导致立即触发page fault,这样的结果好过内存越界之后造成的数据混乱。立即触发一个panic(也就是page fault),你就知道kernel stack出错了。同时我们也又不想浪费物理内存给Guard page,所以Guard page不会映射到任何物理内存,它只是占据了虚拟地址空间的一段靠后的地址。
同时,kernel stack 被映射了两次,在靠后的虚拟地址映射了一次,在 PHYSTOP 下的 Kernel data 中又映射了一次,但是实际使用的时候用的是上面的部分,因为有 Guard page 会更加安全。
这是众多你可以通过 page table 实现的有意思的事情之一。你可以向同一个物理地址映射两个虚拟地址,你可以不将一个虚拟地址映射到物理地址。可以是一对一的映射,一对多映射,多对一映射。XV6 至少在 1-2 个地方用到类似的技巧。这的 kernel stack 和 Guard page 就是 XV6 基于 page table 使用的有趣技巧的一个例子。
第二件事情是权限。例如 Kernel text page 被标位R-X,意味着你可以读它,也可以在这个地址段执行指令,但是你不能向 Kernel text 写数据。通过设置权限我们可以尽早的发现 Bug 从而避免 Bug。对于 Kernel data 需要能被写入,所以它的标志位是 RW-,但是你不能在这个地址段运行指令,所以它的 X 标志位未被设置。(注,所以,kernel text 用来存代码,代码可以读,可以运行,但是不能篡改,kernel data 用来存数据,数据可以读写,但是不能通过数据伪装代码在 kernel 中运行)
# 6. xv6 代码介绍
这里不再详细记录,可以参考原视频或文字稿 (opens new window)。