问题
为什么需要虚拟内存?
早期在操作系统只能同时运行一个程序,操作系统只是一组库函数,在物理内存里存在,例如占据[0,64KB)
的位置,然后运行的程序可以直接操作从64KB
开始的物理内存。如果需要切换运行的用户程序,只需要将用户程序加载到64KB
开始的位置,然后执行指令即可。
但是随着同时运行多个用户程序的需求产生,直接操作物理内存的弊端就露出来了:在之前只有一个用户程序的时候,损坏了操作系统部分的内存坏了就坏了,重启可以解决。如果有多个程序,错误地读(甚至写)操作系统或者其他用户程序的内存部分,就是一个很麻烦的事情了。
所以我们希望有隔离性:用户程序与用户程序之间(当然操作系统本身也是)不能互相覆盖。但是过强的隔离性也不行,用户程序之间应该有交流的方式。
其次就是易用性:虚拟内存空间是给用户程序单独所见的,所以他的排布方式可以是固定的,并且可以确定从0开始到一个最大值。虚拟内存空间不需要在物理内存里保持连续。
为什么需要有三级页表?
首先给虚拟内存空间机制下一个定义,他可以看作是一个函数:f(va)=pa
,将一个虚拟地址转换为一个物理地址。
所以最简单的实现方式就是:记录一个很大的kv表,每个虚拟内存地址唯一的映射到一个物理地址。这样做的缺点就是他需要的空间太大了:用3x9=27
位来描述虚拟地址的话,需要$2^{27}$个表项。
使用三级页表,此时表项数就不再是满的幂次,一个满的三级页表最多的表项数是$512512512=2^{27}$,其实并没有减少。好处是一整个连续的空间如果没有被使用,那么他可以被忽略(不存在二级节点或者三级节点),坏处是定位一个页表的代价比一级多两次。
分页机制
分页机制是一个软硬件协作的结果:
- 硬件:每个cpu上有一个寄存器
satp
,用于保存一级页表的物理地址。 - 软件:在物理内存中构造三级页表。在分配内存时维护三级页表。
在kernel刚开始运行时,分页机制还没有打开,此时直接操作物理内存。当软件(kernel)向硬件(cpu)的satp
寄存器写时,分页机制就开启了。这里隐含一个问题:当软件写入satp
之后,接下来的任何内存操作都是虚拟内存,因此需要一定的技巧构造页表来方便软件后续的工作。
内核地址空间
内核在构造页表时,使用的是等价映射的方式来方便操作物理内存(即虚拟内存地址直接等于物理地址)。这样做的一个主要原因是设备使用了物理映射的方式:读写特定物理地址时,相当于操作了设备的寄存器。
但是也有例外:
trampoline
页,在虚拟内存的最高处有映射,每个用户程序的虚拟内存空间最高处也有一张一样的trampoline
页。对于内核空间来说,trampoline
映射了两次:一次是在最高处,一次是在恒等映射里。- 内核栈页:每个进程有自己的内核栈。内核栈页在高处分页,栈在riscv中向下增长,所以xv6在内核栈页下面放置了一个
guard page
,标记为无效页,如果内核栈溢出,就会走到无效页上导致panic。
xv6启动流程
三个步骤:kinit() -> kvminit() -> kvminithart()
kinit()
将从end
开始到PHYSTOP
的物理内存初始化,赋值垃圾,构造内存空闲列表。在这步操作之后kmem
就可以用于分配内存页了。
kvminit()
构造三级页表,将一级页表的地址写到全局变量kernel_pagetable
里。
kvminithart()
将kernel_pagetable
写入satp
寄存器,打开分页机制。这里还调用了一个指令sfence_vma
,用于刷新硬件的TLB机制。
其中构造三级页表部分,由K/vm.c#kvmmake
函数实现:
1 | pagetable_t |
结构很清晰:上面映射设备部分(恒等映射),下面构造trampoline
和内核栈页。最后将一级页表返回出去。
在构造过程里有这几个函数:
void kvmmap(pagetable_t, uint64 va, uint64 pa, uint64 size, int perm)
:映射从虚拟地址va
开始到pa
结束的大小为size
的页表,权限为perm | PTE_V
int mappages(pagetable_t, uint64 va, uint64 size, uint64 pa, int perm)
:上面kvmmap
实际工作的函数。特别的,由于地址不一定是页表对其的,所以会先对虚拟地址和物理地址向下取整(高地址在上,低地址在下,等价于将最后12
位置0)pte_t *walk(pagetable_t, uint64 va, int alloc)
,将虚拟内存va
维护到三级页表内,如果alloc=1
,则在找不到对应表项时分配一张页表(物理内存),否则触发page fault
,交由操作系统决定。页表可以看作是一个有512项PTE的数组。
内核中的trampoline
的分配没有什么特别的,只是计算出了虚拟内存空间的最高位然后映射到物理上(代码)的位置。内核栈的映射就是计算出了每个进程的内核栈所在位置,这里是分配了NPROC=64
个内核栈。没有映射的部分自然就是不合法的,因此只需要分配合法的位置就可以了。
TODO: 如果xv6里实际分配的进程个数已经超过了
NPROC=64
会触发panic?
用户程序的trampoline
分配在K/proc.c#allocproc
里,如果找到一个unused
的进程,会调用K/proc.c#proc_pagetable
来创建一个用户进程的页表,其中就通过mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) < 0
,来把虚拟地址空间中最高的位置映射到trampoline
物理位置上。
特别的,如果有多个cpu核,只有第一个cpu会构造三级页表,其他cpu只会写入satp
寄存器来打开分页机制,因为全局变量对多个核是可见的。
Lab3: page tables
Speed up system calls (easy)
如果只打开了PTE_R
会触发load fault
,因为这个测试是在用户空间访问这个页,所以需要打开PTE_W
。
Speed up system calls (easy)
也是个坑,我一开始用的2020的lab页面,这个测试里多一个页,因为上一个测试里添加了一张。
Detecting which pages have been accessed (hard)
题目描述有点看不懂,具体的就是问一个虚拟地址va
有没有被访问,访问的定义是当这个页不存在于TLB时,硬件会给页表项写一个bit位表示这个页刚刚被访问了(走过了完整的流程而不是cache)。注意读取完了之后要把表项上的bit清掉。
这个东西为什么是hard,感觉不如第一个实验难。我觉得要答出来读取参数,copyin/copyout
的实现相关的东西才够难度。因为这个题非常容易稀里糊涂做出来了。