MIT6828 学习笔记 007 (book::c3)
page table (分页表)是操作系统给每个进程提供私有的地址空间和内存的最流行的方式。 page table 决定了内存地址的意义以及物理内存可被访问的部分, Xv6 就是通过这个机制在一个物理内存上实现了多个进程的私有地址空间的提供。 page table 之所以是一种流行的设计是因为它提供了一定的间接性,使操作系统能实现一些技巧,比如 Xv6 :在一些地址空间映射同一块内存(一个 trampoline page );用一个没有映射的 page 保护内核栈和用户栈,后面会详细展开。
1. paging hardware (分页硬件)
前情提要:虚拟地址由 RISC-V 指令(包括用户指令和内核指令)操控;机器的 RAM 或者说物理内存是用物理地址索引的, RISC-V 的分页表硬件通过给每一个虚拟地址映射一个物理地址来连接这两种地址。
Xv6 运行在 Sv39 RISC-V 也就是说虚拟地址的 64 个 bit 只用底部的 39 个,在这种配置下,一个 RISC-V page table (分页表)逻辑上是一个由
在 Sv39 RISC-V 中,虚拟地址的顶部 25 个 bit 没被用来转换(到物理地址),物理地址也有一定空间可以扩展,比如 PTE 可以给物理分页数再让出 10 个 bit 。这样的选择是基于 RISC-V 设计者对科技的预测:
如上图, RISV-V 处理器把一个虚拟地址转换成物理地址需要三步,实际上从分页表取出 PPN 还可以细分为三步(如下图)。分页表以三级树的形式保存在物理内存里,每一级都是一个包含了 512 个 PTE (每个 PTE 都指向一个下一级分页表分页),大小为 4096-byte 的分页表的分页。分页硬件先用索引 27 个 bit 的顶部 9 个 bit 从根分页表分页取出一个 PTE 、然后用中间 9 个 bit 从下一级分页表分页取出一个 PTE 、最后用底部 9 个 bit 最后一级分页表分页取出最后一个 PTE (上面提到的 Sv48 就是在这基础上再加一级:
分页硬件对一个不存在的地址进行转换会引发 page-fault exception ,来让内核处理这个异常(后面一章会展开)。
通常情况下很多虚拟地址都没有到物理地址的映射(可能物理内存一般没有那么大,不需要那么多虚拟地址来映射),所以这种三级的结构在记录 PET 方面比上面只有一级的结构更能节省内存空间,比如一个应用程序只用地址kernel/riscv.h) 告诉分页硬件应该怎样使用被关联:
| flag | 作用 |
|---|---|
PTE_V | PTE 是否有效,引用无效 PTE 会引起一个异常 |
PTE_R | 分页是否可读 |
PTE_W | 分页是否可写 |
PTE_X | 分页的内容是否可以被 CPU 看作指令来执行 |
PTE_U | 分页是否允许被用户模式的指令访问 |
内核必须把根分页表分页的物理地址写进 satp 寄存器才能让 CPU 使用分页表, CPU 会对后面的指令生成的地址用自己的 satp 寄存器指向的分页表进行转换,每个 CPU 都有自己的 satp ,这样一来每个 CPU 可以运行自己的进程,且每个 CPU 都有一个用自己的分页表描述的地址空间。
内核通常把所有物理内存都映射进它的分页表里,这样它才能用加载或保存指令读写物理内存的所有位置。因为分页目录 (page directory) 保存在物理内存中,所以内核可以对虚拟地址使用标准保存指令来实现对分页目录的任意 PTE 的内容进行编程
- 物理内存指 DRAM 里的存储单元,上面的每个 byte 都有一个地址,称为物理地址。
- 指令只使用虚拟地址,分页硬件把这个地址转换成对应的物理地址后发给 DRAM 硬件来读写存储单元。
- 虚拟内存和物理内存、虚拟地址的概念不同,不是一个物理上的物体,而是一系列内核提供的,用于管理物理内存和虚拟地址的抽象/方法
2. 内核的地址空间
Xv6 内核为每个进程维护一个描述每个进程用户地址空间的分页表,再加上内核自己的描述内核地址空间的一个分页表。内核对自己地址空间的布局进行配置(如下图),使自己能够在可预测的虚拟地址上访问物理内存以及各种硬件资源 (kernel/memlayout.h) 。
QEMU 模拟一台计算机,这台计算机包含从物理地址 0x80000000 到至少 0x88000000 (Xv6 称其为 PHYSTOP) 的 RAM (物理内存)和例如磁盘接口的 I/O 设备, QEMU 将设备接口作为物理地址中位于 0x80000000 下面的内存映射控制寄存器 (memory-mapped control register) 暴露给软件,内核就通过读写这些特殊的物理地址访问设备而不是与 RAM 通信(下一章会详细展开)。
内核通过直接映射 (direct mapping) 访问 RAM 和内存映射设备寄存器 (memory-mapped device register) 也就是说把资源映射到和物理地址相同的虚拟地址,比如内核本身位于 KERNBASE=0x80000000 ,这个地址既是虚拟地址也是物理地址,直接映射简化了内核读写物理内存的代码,比如 fork 分配用户内存给子进程,分配器返回这块内存的物理地址, fork 在复制父进程的用户内存给子进程时直接把这个地址当作虚拟地址。有一些内核虚拟地址也不是直接映射的:
- Trampoline page ,被映射到内核虚拟地址空间的顶部(在用户分页表中同样被映射到顶部,第四章会详细描述 Trampoline page 的作用),这里一个物理分页(保存 trampoline 代码)在内核的虚拟地址空间被映射了两次:一次在虚拟地址的顶部、一次直接映射(内核代码是直接映射的)
- 内核栈分页,每个进程都有自己的内核栈,这个内核栈被映射到高位, Xv6 会在它的下面留下未映射的保护分页 (guard page) ,保护分页的 PTE 是无效的 (
PTE_V == 0) ,所以内核一旦溢出了内核栈就会引起异常,内核就会 panic 。如果没有保护分页的话溢出的栈会覆盖其他的内核内存,造成错误的操作,所以相比之下 panic 崩溃更好一些。
内核栈也可以被直接映射,但这种设计下想要实现保护分页就涉及到了虚拟地址映射的取消,如果没有保护分页的话访问虚拟地址就相当于访问了同样位置的物理地址,所以这种设计不是很好用
内核对 trampoline 和 kernel text 的映射带有 PTE_R 和 PTE_X 权限,所以内核可以从这些虚拟地址读取并执行指令;对其他分页的映射带有 PTE_R 和 PTE_W ,所以内核可以读写这些分页的内存;保护分页的映射是无效的。
3. 代码:创建一个地址空间
操控地址空间和分页表的代码在 kernel/vm.c ,核心的数据结构是 pagetable_t (aka uint64_t*) ,指向 RISC-v 的根分页表分页,一个 pagetable_t 要么指向内核分页表,要么指向进程分页表。核心方法有 walk (模拟分页硬件,返回虚拟地址对应的 PTE 并按需创建)
点击展开:walk 代码分析
1 | // 返回分页表 pagetable 里虚拟地址 va 对应的 PTE ,如果 |
说实话这不到 20 行代码我看了一个小时才理清,我实在是 dinner
和 mappages (为新的映射安装 PTE )
点击展开:mappages 代码分析
1 | // 给从 va 开始的虚拟地址创建指向从 pa 开始的物理地址的 PTE |
以 kvm (Kernel Virtual Memory?) 开头的函数用于操控内核分页表, uvm (User Virtual Memory?) 开头的则用于操控用户分页表,剩下的其他函数操控两边。 copyout 把数据从内核写进用户内存空间
点击展开:copyout 代码分析
1 | // 从内核复制数据给用户,从 src 复制 len 个字节到分页表 pagetable 里的虚拟地址 dstva |
copyin 从用户内存复制数据进内核
点击展开:copyin 代码分析
1 | // 从用户内存复制数据进内核 |
Xv6 启动时 main (kernel/main.c:20) 调用 kvminit (kernel/vm.c:53) 来创建内核的分页表,其内部使用 kvmmake (kernel/vm.c:19) 。这个调用发生在 Xv6 启用 RISC-V 的分页功能之前,所以所有地址都是物理地址, kvmmake 首先分配一个物理内存的分页来保存根分页表分页 (kernel/vm.c:24) ,然后调用 kvmmap 配置好映射 (kernel/vm.c:27-44)(包括内核指令及数据、最高到 PHYSTOP 的物理内存以及实际为设备的内存区域)然后 (kernel/vm.c:47) 调用 proc_mapstacks (kernel/proc.c:32) , proc_mapstacks 给每个进程分配一个内核栈
点击展开:proc_mapstacks 代码分析
1 | // 在虚拟地址高位给每个进程分配内核栈,每个内核栈之间用保护栈隔开 |
上面的代码依赖于物理地址被直接映射到内核的虚拟地址空间,比如说 walk 处理下一个 level 的分页表时要从 PTE 提取下一个 level 的分页表的物理地址,然后把这个物理地址作为虚拟地址获取再下一个 level 的分页表。然后 main (kernel/main.c:21) 调用 kvminithart (kernel/vm.c:61) 来配置内核分页表, kvminithart 会把根分页表分页的物理地址写进寄存器 satp ,之后 CPU 就会用这个内核分页表来进行地址转换,因为内核代码是直接映射的,所以下一个指令的虚拟地址会被映射到对应的物理内存地址。
RISC-V 的每个 CPU 都会把 PTE 缓存进一个 TLB (Translation Look-aside Buffer) 里, Xv6 更改了一个分页表后必须让 CPU 检查 TLB 里对应的缓存,如果不这样做的话被映射到的物理地址有可能已经是其他进程分配的了, RISC-V 有 sfence.vma 指令来刷新当前 CPU 的 TLB , Xv6 在 kvminithart 时写入 satp 寄存器之后会使用这个指令; kernel/trampoline.S:89 在回到用户空间之前也会使用这个指令。在修改 satp 之前调用 sfence.vma 也可以等待外部的存取结束来保证修改之前的指令都会使用旧的分页表(而不是新的分页表)。
为了避免一刷新就要刷新整个 TLB , RISC-V CPU 可能支持 ASIDs (Address Space IDentifiers ,地址空间标识) ,这样内核可以之刷新指定地址空间的 TLB 条目,然而 Xv6 并不利用这个特性
4. 物理内存分配
内核必须在运行时对分页表、用户内存、内核栈和 pipe 缓冲区分配/释放物理内存, Xv6 把内核代码的末端到 PHYSTOP 之间的内存用于运行时分配,每次分配/释放都向分页对齐 (4096 byte) ,并通过一个连接这些分页的链表记录这些分页,分配内存时从链表移除分页;释放内存时把分页重新添加回链表。
5. 代码:物理内存分配器
相关代码在 kernel/kalloc.c ,核心数据结构是 kmem ,里面包含一个保护自己的 spinlock (第 6 章会详细展开)和一个链表 struct run* freelist ,链表的每个元素都代表一个可用分页,每个元素都有 struct run* next 指向下一块可用分页(这个 next 就保存在自己代表的分页里,实际上不会影响使用,因为如果这个分页被分配了之后就不再是 struct run 了,所以 next 不会影响这个分页的使用)。main (kernel/main.c:19) 调用 kinit (kernel/kalloc.c:26) 对分配器进行初始化,把内核代码的末端到 PHYSTOP 之间的空闲分页装进 freelist
内核应该解析硬件提供的配置信息来计算物理内存的大小,但 Xv6 假定了物理内存为 128 MB (
PHYSTOP,kernel/memlayout.h:48)
6. 进程的地址空间
每个进程都有一个独立的分页表, Xv6 在切换进程时同时也会变更分页表。进程地址空间的详细分配如下图,一个进程的虚拟地址从 0 开始,可以最大到 MAXVA (kernel/riscv.h:360) 允许进程使用理论上最大 256 GB 的内存。
仔细设置每个 PTE 的权限 flags 有助于加强用户进程的安全性,比如如果进程的 text 部分带有 PTE_W 的话进程就可以修改自己的代码了,或者如果进程的 data 部分带有 PTE_X 的话进程就可以跳进这个地址里。
Xv6 在
stack下面放了一个没有PTE_U的保护分页来预防用户栈溢出,溢出时表现为硬件生成一个 page-fault (分页错误)异常;而现代的操作系统可能会自动分配更大的用户栈。
当进程向 Xv6 请求更多的用户内存时, Xv6 对进程的 heap 进行扩展:先 kalloc 分配物理分页,然后在用户的分页表里给新分配的物理页创建 PTE ,这些 PTE 带有 PTE_W, PTE_R, PTE_U 和 PTE_V , Xv6 还会把进程用不到的 PTE 去掉 PTE_V 。到这里可以看到一些分页表的作用:
- 不同进程的分页表把用户地址转换成不同物理内存分页地址,这样每个进程都有自己的用户内存
- 每个进程都有连续的、从
0开始的连续地址,而对应的物理地址可以不是连续的 - 内核给每个用户地址空间的顶部都映射同一个包含 trampoline 代码的分页,且不带
PTE_U,这样每个地址空间都有一个 PTE 映射同一个物理内存分页,但只有内核可以使用
7. 代码: sbrk
sbrk (kernel/sysproc.c:39) 是用于扩展/缩小进程自己的内存的系统调用,其内部调用 growproc (kernel/proc.c:259) 来实现功能, growproc 内部调用 uvmalloc (kernel/vm.c:225)
点击展开:uvmalloc 代码分析
1 | // 分配 PTE 和物理内存使进程从 oldsz 扩展到 newsize (不必与分页大小对齐) |
和 uvdealloc (kernel/vm.c:255)
点击展开:uvdealloc 代码分析
1 | // 释放进程的分页使进程的大小从 oldsz 缩减到 newsz |
来实现具体功能。 Xv6 的进程分页表不仅告诉了硬件如何映射用户虚拟地址,还是进程分配物理内存的唯一记录
8. 代码: exec
sys_exec(kernel/sysfile.c:434) 只是把exec(kernel/exec.c:22) 的参数准备好了,但有几个比较有意思的点:
sys_exec为exec的char* argv[MAXARG]的每一个char* arg分配空间时用的kalloc,统一 4096 个 byte 可以说利用率极低(原因在末尾)MAXPATH(128,kernel/param.h:13) 规定文件目录名不能超过 128 个 byteMAXARG(32,kernel.param.h:8) 规定exec的argv大小不能超过 32- 取
argv时和想象中的一样先取到用户传来的char** argv,然后遍历argv的每个char* arg,遇到arg == 0表示结束
点击展开:exec 代码分析
1 | int exec(char *path, char **argv) { |
是不是铸币?
目前没弄明白的是之前讨论进程的地址空间的时候说 argv, argc, 返回 PC 都在进程的栈里,但 exec 代码好像把这些放进 struct proc::trapframe 里了,甚至计算把 argv 里的内容压进栈里会不会溢出的时候都没管 argv 指针本身、 argc 和返回 PC 要占的空间,等以后明白了再来改吧。
明白了, RISC-V 手册里说了,参数可以通过栈传递,也可以通过参数寄存器
a0-a7传递, Xv6 的exec(kernel/exec.c:22) 把argv的每个char*包括自己char*[]都放进了栈里,然后把argv的地址放在了寄存器a1,返回到syscall(kernel/syscall.c:146) 后由syscall把argc放进寄存器a0。
点击展开:图示进程内存空间分配过程
下面用图表示进程内存空间分配过程,行号对应上面 exec 代码分析, 34 行创建了根分页表:
52-70 行把程序读进了内存:
81-84 行又创建了两个分页:
86 行使下面的分页变成保护分页,上面的分页用作用户栈:
90-102 行把 argv 里的每个字符串 arg 的内容装进用户栈:
104-110 把 argv 数组(每个字符串的起点)装进用户栈:
9. 联系现实
现代操作系统一般会设计有更复杂的分页和 page-fault 异常设计(将在第四章展开), Xv6 依赖物理内存大小的假定和 QEMU 的特性(如物理内存地址在 0x80000000 ),现实的操作系统需要解析设备树来确保硬件物理内存分布未知情况下也能正常工作。 RISC-V 在物理地址层面支持保护,但 Xv6 没有使用这个特性。现代机器支持 super-page 来降低操控分页的成本,但 Xv6 只使用(假定有) 128MB 内存,所以没有也没必要有这种设计。 Xv6 没有现代操作系统的类 malloc 函数来分配小块内存,只能通过 kalloc 一次性分配 4096 个 byte 。
内存分配一直是个很重要的话题,比如:有效地利用有限的内存、为以后可能存在的特性做准备。现在人们比起空间效率更加注重速度。