Post

6.S081|第三章-页表

6.S081|第三章-页表

页式硬件

RISCV里执行的指令中的地址都是使用的虚拟地址(Q:为什么要用虚拟地址?A:为了隔离性和保护性),但是机器的物理内存是由物理地址索引的,所以我们需要一种映射机制来完成由虚拟地址到物理地址的转换。

虚拟地址空间是概念,页表是实现这个概念的数据结构。

在开始下面内容之前,我们需要知道xv6的内存管理机制是Sv39 RISC-V,这意味着我们只使用底部的39位,尽管虚拟地址是64位的(因为这是64位机器,寄存器也是64位的)。

一般来说一个分页是4KB大小,也就是4096个字节,想要索引这4096个字节,我们需要12位,因为$2^{12} = 4096$,又因为Sv39不使用高25位,所以39 - 12 = 27,所以我们除了用于寻址的12位之外,还有多出了一个27位的空间可以使用。那么这27位是用来干嘛的呢?

答案是:这27位是用来寻址页表项的(Page Table Entries/PTE),每个PTE包含一个44位的物理页码(Physical Page Number/PPN)和一些标志位(标志位占10位)(Q: PTE格式中有空间让物理地址长度再增长10个比特位。RISC-V 的设计者根据技术预测选择了这些数字。)。分页硬件通过使用虚拟地址39位中的前27位索引页表,以找到该虚拟地址对应的一个PTE,然后生成一个56位的物理地址(Q:为什么物理地址是56位?A:RISC-V 的设计者根据技术预测选择了这些数字。),其前44位来自PTE中的PPN,其后12位来自原始虚拟地址

6.S081|第三章-页表-20250122.png

如下图所示,实际的转换分三个步骤进行。页表以三级的树型结构存储在物理内存中。该树的根是一个4096字节的页表页,其中包含512个PTE(因为一个PTE是8B),每个PTE中包含该树下一级页表页的物理地址。这些页中的每一个PTE都包含该树最后一级的512个PTE(也就是说每个PTE占8个字节,正如图3.2最下面所描绘的)。分页硬件使用27位中的前9位在根页表页面中选择PTE,中间9位在树的下一级页表页面中选择PTE,最后9位选择最终的PTE。

通俗来说,PPN是用来索引下一级页表的地址,而虚拟地址中的9位是用来索引找到的页表中的页表项。

6.S081|Lab5-Copy-on-Write Fork for xv6-20250122.png

如果转换地址所需的三个PTE中的任何一个不存在,页式硬件就会引发页面故障异常(page-fault exception),并让内核来处理该异常(参见第4章)。

为什么要用三级页表设计?

主要是为了解决单级页表资源浪费的问题

如果使用单级页表来映射整个64位虚拟地址空间,会导致非常高的内存开销。假设我们使用4KB的页大小,每个页表项占用8字节,那么对于64位地址空间($2^{64}$ 个地址),页表的大小将非常庞大:

  • 单级页表需要映射整个虚拟地址空间,即需要 $\frac{2^{64}}{4KB} = 2^{52}$ 个页表项。

  • 这意味着单级页表的大小为 $2^{52} \times 8$ 字节,约为32 PB(32 Petabytes)的内存。

举个例子,如果一个应用程序只使用了一个页面,那么顶级页面目录将只使用条目0,条目 1 到 511 都将被忽略,因此内核不必为这511个条目所对应的中间页面目录分配页面,也就更不必为这 511 个中间页目录分配底层页目录的页。 所以,在这个例子中,三级设计仅使用了三个页面,共占用 $3\times4096$个字节。

Q:计算机是动态分配页表的吗,要是没有东西 就不会有页表项吗?我有点不能理解,假如又开了一个程序,没有预留空间的话,岂不是要移动所有地址

A:计算机的页表是动态分配的,但并不意味着地址会因为新程序启动而移动。让我们一步步来解释:

1. 动态分配页表

页表的动态分配意味着,当程序使用某个虚拟地址时,操作系统才会为该地址分配物理页,并创建或更新相应的页表条目。未使用的虚拟地址不会有页表项,也不需要分配任何内存。因此,页表项是在需要时动态生成的,而不是提前为所有可能的虚拟地址预留空间。

例如:

  • 如果一个程序刚启动并且只使用了一个页面,那么只会为该页面创建相关的页表项。

  • 如果这个程序后来需要更多的虚拟地址空间(比如分配了新的内存页),操作系统会动态分配新的页表项。

2. 虚拟地址和物理地址的关系

虚拟内存系统的一个重要特性是,虚拟地址与物理地址是独立的。每个程序都有自己的虚拟地址空间,而操作系统负责将这些虚拟地址映射到物理内存。这种映射关系保存在页表中。

即使有新的程序启动或旧的程序释放了内存,虚拟地址空间的布局并不会因此变化。每个程序都有自己的独立虚拟地址空间,不会影响其他程序的虚拟地址布局。

3. 为什么不需要移动地址?

你提到“没有预留空间”的担忧是针对物理内存的情况。在虚拟内存系统中,并不需要提前为程序预留物理内存空间,具体原因如下:

  • 虚拟内存的独立性:每个程序有自己独立的虚拟地址空间,它们之间互不干扰。即使物理内存紧张,虚拟地址也不会改变或移动。

  • 按需分配物理内存:当程序需要更多内存时,操作系统会按需分配物理页并更新对应的页表项。这种分配是动态的,但不会影响已经分配的虚拟地址,也不会影响其他程序。

  • 交换(Swap)机制:如果物理内存不足,操作系统可以将不常用的页面移到硬盘(即“交换”到swap空间),释放出物理内存供其他程序使用。这不会影响程序的虚拟地址。

4. 新程序启动时不会影响地址布局

假设启动了一个新程序:

  • 操作系统会为该程序分配一个新的虚拟地址空间,通常从一个标准的起始地址(比如用户空间的某个区域)开始。

  • 新程序的虚拟地址不会影响其他已经在运行的程序的虚拟地址,也不需要移动现有的地址布局。

  • 如果需要分配物理内存,会为该程序按需分配物理页,并更新相应的页表。其他程序的地址映射不会因此而移动或改变。

TLB的引入

因为 CPU 在执行转换时会在硬件中遍历三级结构,所以缺点是 CPU 必须从内存中加载三个 PTE 以将虚拟地址转换为物理地址。为了减少从物理内存加载 PTE 的开销,RISC-V CPU 将页表条目缓存在 Translation Look-aside Buffer (TLB) 中。

标志位

每个PTE包含标志位,这些标志位告诉分页硬件允许如何使用关联的虚拟地址。PTE_V指示PTE是否存在:如果它没有被设置,对页面的引用会导致异常(即不允许)。PTE_R控制是否允许指令读取到页面。PTE_W控制是否允许指令写入到页面。PTE_X控制CPU是否可以将页面内容解释为指令并执行它们。PTE_U控制用户模式下的指令是否被允许访问页面;如果没有设置PTE_U,PTE只能在管理模式下使用。图3.2显示了它是如何工作的。标志和所有其他与页面硬件相关的结构在(kernel/riscv.h)中定义。

satp寄存器

satp寄存器是用来存放根页表在物理内存中的地址的。每个CPU都有自己的satp,因此不同的CPU可以运行不同的进程。

tips

关于术语的一些注意事项。物理内存是指DRAM中的存储单元。物理内存以一个字节为单位划为地址,称为物理地址。指令只使用虚拟地址,分页硬件将其转换为物理地址,然后将其发送到DRAM硬件来进行读写。与物理内存和虚拟地址不同,虚拟内存不是物理对象,而是指内核提供的管理物理内存和虚拟地址的抽象和机制的集合。

内核地址空间

xv6使用两种不同的页表:

  1. 一个内核页表(每个CPU一个):所有进程共享这个页表

  2. 每个进程一个用户页表:每个进程有自己独立的页表(进程创建的时候被分配),所以不同的进程可以访问一个相同的虚拟地址,但是因为页表不同的缘故,可以找到其对应的物理页,再根据偏移找到对应的地址。以下是程序实例

1
2
3
4
5
6
7
8
9
10
11
// 创建一个新进程时
uint64 sz = 0;
// 为进程分配一个页表
p->pagetable = proc_pagetable(p);

// 为用户程序分配物理内存并建立映射
sz = uvmalloc(p->pagetable, sz, sz + 2*PGSIZE);

// 当切换到这个进程运行时
w_satp(MAKE_SATP(p->pagetable)); // 加载进程的页表到SATP寄存器
sfence_vma();                    // 刷新TLB缓存

Q:内核看见的虚拟地址空间和用户进程虚拟地址空间到底有什么关系??这个内核虚拟地址空间到底是干嘛的??

在现代操作系统中,每个程序(用户进程)都有它自己的虚拟地址空间。而操作系统内核本身也运行在虚拟地址空间中——这就是内核虚拟地址空间

它的主要作用是:

  1. 保护内核代码和数据不被用户程序访问或破坏

  2. 为内核提供一个统一的、高地址范围的运行环境,避免内核也必须适应每个进程不同的地址空间。

  3. 便于管理和访问内核资源(如设备寄存器、页表、栈等)。

内核如何访问应用的数据?

应用应该不能直接访问内核的数据,但内核可以访问应用的数据,这是如何做的?由于内核要管理应用,所以它负责构建自身和其他应用的多级页表。如果内核获得了一个应用数据的虚地址,内核就可以通过查询应用的页表来把应用的虚地址转换为物理地址,内核直接访问这个地址(注:内核自身的虚实映射是恒等映射),就可以获得应用数据的内容了。

6.S081|第三章-页表-20250122-2.png

QEMU模拟了一台计算机,它包括从物理地址0x80000000开始并至少到0x86400000结束的RAM(物理内存),xv6称结束地址为PHYSTOP。QEMU模拟还包括I/O设备,如磁盘接口。QEMU将设备接口作为内存映射控制寄存器暴露给软件,这些寄存器位于物理地址空间0x80000000以下。内核可以通过读取/写入这些特殊的物理地址与设备交互;这种读取和写入与设备硬件而不是RAM通信。第4章解释了xv6如何与设备进行交互。

Free Memory区域的作用

  • 为用户进程分配内存: 当用户进程需要更多内存(例如,通过 sbrk() 扩展堆或栈增长)时,内核会从这个 “Free memory” 池中分配物理页面,并将其映射到用户进程的虚拟地址空间中。
  • 为内核自身分配内存: 内核在运行过程中也需要动态分配内存来存储各种数据结构,例如进程控制块 (PCB)、页表、文件系统缓冲区、网络缓冲区等。这些内存通常也是从 “Free memory” 区域中分配的物理页。
  • 页表管理: xv6 使用页表进行虚拟地址到物理地址的转换。创建和管理页表需要物理内存,这些物理页也来自 “Free memory”。

对于地址的设置,这是由硬件(开发版)决定的,主板的设计人员决定了,在完成了虚拟到物理地址的翻译之后,如果得到的物理地址大于0x80000000会走向DRAM芯片,如果得到的物理地址低于0x80000000会走向不同的I/O设备。这是由这个主板的设计人员决定的物理结构。如果你想要查看这里的物理结构,你可以阅读主板的手册,手册中会一一介绍物理地址对应关系。

地址0x1000是boot ROM的物理地址,当你对主板上电,主板做的第一件事情就是运行存储在boot ROM中的代码,当boot完成之后,会跳转到地址0x80000000,操作系统需要确保那个地址有一些数据能够接着启动操作系统。

  • Kernel text (内核代码段/内核文本段): 这部分包含了内核的可执行代码。换句话说,就是 xv6 内核本身的所有指令,包括各种函数、系统调用处理程序、中断处理程序以及内核运行所需的所有代码。如图所示,它具有 读和执行 (R-X) 的权限。

  • Kernel data (内核数据段): 这部分包含了内核的全局变量和静态变量,分为已初始化的和未初始化的两类。

    • 已初始化数据: 指的是在内核源代码中被赋予了初始值的全局和静态变量。
    • 未初始化数据 (通常称为 BSS 段): 指的是在源代码中没有显式初始化的全局和静态变量。这些变量在内核加载时通常会被清零初始化。 如图所示,它具有 读和写 (RW-) 的权限。

这里还有一些其他的I/O设备:

  • PLIC是中断控制器(Platform-Level Interrupt Controller)我们下周的课会讲。

  • CLINT(Core Local Interruptor)也是中断的一部分。所以多个设备都能产生中断,需要中断控制器来将这些中断路由到合适的处理函数。

  • UART0(Universal Asynchronous Receiver/Transmitter)负责与Console和显示器交互。

  • VIRTIO disk,与磁盘进行交互。

Q:为什么物理内存上方有一大片没有使用的地方? A:物理地址总共有2^56那么多,但是你不用在主板上接入那么多的内存。所以不论主板上有多少DRAM芯片,总是会有一部分物理地址没有被用到。实际上在XV6中,我们限制了内存的大小是128MB。

这一点说明了寻址范围不代表内存大小,就像WINDOWS XP最大内存容量为3.5GB,但是你可以只插一个2GB的内存条。

Q:当读指令从CPU发出后,它是怎么路由到正确的I/O设备的?比如说,当CPU要发出指令时,它可以发现现在地址是低于0x80000000,但是它怎么将指令送到正确的I/O设备? A:

  1. 地址解码:CPU 发出的地址会被系统的地址解码器解析。如果地址落在预先分配给某个 I/O 设备的地址范围内,解码器就会将这次访问路由到对应的设备。
  2. 设备响应:I/O 设备通常会监视地址总线上的信号。当检测到地址匹配自己的地址范围时,设备会响应这次访问,处理读写操作。

内核大部分时候使用“直接映射”获取内存和内存映射设备寄存器;也就是说,将资源映射到等于物理地址的虚拟地址。例如,内核本身在虚拟地址空间和物理内存中都位于KERNBASE=0x80000000。直接映射简化了读取或写入物理内存的内核代码。例如,当fork为子进程分配用户内存时,分配器返回该内存的物理地址;fork在将父进程的用户内存复制到子进程时直接将该地址用作虚拟地址。

但有几个内核虚拟地址不是直接映射:

  • 蹦床页面(trampoline page)。它映射在虚拟地址空间的顶部;用户页表具有相同的映射(即每个用户进程地址空间中,trampoline都在顶部,且都映射到物理内存中的同一个地方。trapframe虽然也是在虚拟地址空间的顶部,但是它会映射到物理内存的不同地方)。第4章讨论了蹦床页面的作用,但我们在这里看到了一个有趣的页表用例;一个物理页面(持有蹦床代码)在内核的虚拟地址空间中映射了两次:一次在虚拟地址空间的顶部,一次直接映射(可以看到trampoline被映射到的物理地址是kernel text段直接映射过去,所以又说明了trampoline是内核代码段/内核文本段)。(注:看的时候最好反过来看,即虽然我说的是物理页面在虚拟地址空间映射了两次,其实是虚拟地址空间里面的两个地方都指向了物理页面中的一个位置)

  • 内核栈页面。每个进程都有自己的内核栈,当进程执行用户指令时,只有它的用户栈在使用,它的内核栈是空的。当进程进入内核(由于系统调用或中断)时,内核代码在进程的内核堆栈上执行,它将映射到偏高一些的地址,这样xv6在它之下就可以留下一个未映射的保护页(guard page)。保护页的PTE是无效的(也就是说PTE_V没有设置),所以如果内核溢出内核栈就会引发一个异常,内核触发panic。如果没有保护页,栈溢出将会覆盖其他内核内存,引发错误操作。恐慌崩溃(panic crash)是更可取的方案。(注: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中运行)

对于不同的进程,会对应不同的内核栈

在现代操作系统中,如果多个进程将虚拟地址映射到同一个物理页面,操作系统可以通过共享该物理页面来节省内存资源。

进程地址空间分配

每个进程都有一个单独的页表,当xv6在进程之间切换时,也会更改页表。如图2.3所示,一个进程的用户内存从虚拟地址零开始,可以增长到MAXVA (kernel/riscv.h:348),原则上允许一个进程内存寻址空间为256G(因为xv6保留一位只用到38位),这一点在MAXVA的定义中可以得知。

6.S081|第三章-页表-20250122-3.png

This post is licensed under CC BY 4.0 by the author.