mit6.s081 chapter3

未分类
3.6k 词

问题

为什么需要虚拟内存?

早期在操作系统只能同时运行一个程序,操作系统只是一组库函数,在物理内存里存在,例如占据[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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
pagetable_t
kvmmake(void)
{
pagetable_t kpgtbl;

kpgtbl = (pagetable_t) kalloc();
memset(kpgtbl, 0, PGSIZE);

// uart registers
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

// virtio mmio disk interface
kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

// PLIC
kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

// map kernel text executable and read-only.
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

// map kernel data and the physical RAM we'll make use of.
kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

// map kernel stacks
proc_mapstacks(kpgtbl);

return kpgtbl;
}

结构很清晰:上面映射设备部分(恒等映射),下面构造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的实现相关的东西才够难度。因为这个题非常容易稀里糊涂做出来了。