xv6操作系统优化页表
xv6拥有一个单一的内核页表,当内核运行时都会使用它。这个内核页表是一个直接映射到物理地址的映射关系,因此内核虚拟地址 x 会映射到物理地址 x。由于是单一的内核页表,所以保证了所有的内核代码和数据都可以在任何时刻被内核访问,无论当前正在运行哪个进程。这样,内核在处理中断、系统调用、进程切换等场景时,不需要切换页表,也不会因为页表切换导致内核自身不可访问。
Xv6 还为每个进程维护一个单独的用户地址空间页表,它只包含该进程用户内存的映射,并且这些映射从虚拟地址 0 开始。由于内核页表并不包含这些用户地址的映射,因此在内核中,用户地址是无效的。因此,当内核在处理系统调用时需要使用用户传入的指针(例如,write() 系统调用中传入的缓冲区指针),就必须先将这个用户指针转换为物理地址。而且用户进程一旦进入内核态(例如使用了系统调用),则切换到内核页表(通过修改 satp 寄存器,trampoline.S)。
我们需要为每个进程创建一个属于自己的内核页表,在内核页表中添加当前进程的用户内存映射。这样在内核态下,也可以直接访问用户地址。所以我们的目标是每个内核页表包含:原有的内核地址映射(虚拟地址 = 物理地址,直接映射)和当前进程的用户空间映射。
这里主要讲思路,代码可以我这里参考了这篇博客的设计。
我们首先需要在proc结构体下为进程添加一个内核页表的字段pagetable_t kernelpgtbl;,有了这个字段之后,还是需要一个总的内核页表。因为在内核初始化时期,还没有任何进程运行,所以需要一个总的内核页表,而且有时进程崩溃,内核还需要这个页表处理崩溃对应的逻辑。
之前的内核页表初始化流程在kvminit()函数中,这个函数为内核页表添加一些固定存在的内存映射,例如 UART 控制、硬盘界面、中断控制等。而 kvminit 原本只为全局内核页表 kernel_pagetable 添加这些映射,所以我们需要修改映射函数,使其可以为其他页表进行映射。
在修改了映射之后,由于每个进程现在都有了自己的内核页表,所以每个进程的内核空间就都已经独立了。原本的 xv6 设计中,所有处于内核态的进程都共享同一个页表,即意味着共享同一个地址空间。由于 xv6 支持多核/多进程调度,同一时间可能会有多个进程处于内核态,所以需要对所有处于内核态的进程创建其独立的内核态内的栈,也就是内核栈,供给其内核态代码执行过程。
xv6 在启动过程中,会在 procinit() 中为所有可能的 64 个进程位都预分配好内核栈 kstack,具体为在高地址空间里,每个进程使用一个页作为 kstack,并且两个不同 kstack 中间隔着一个无映射的 guard page 用于检测栈溢出错误。这里可以参考课程资料中关于xv6设计的memory layout。
现在每一个进程都会有自己独立的内核页表,并且每个进程也只需要访问自己的内核栈,而不需要能够访问所有 64 个进程的内核栈。所以可以将所有进程的内核栈 map 到其各自内核页表内的固定位置。所以我们需要修改之前的为进程分配内核栈的逻辑,在创建进程的时候,为进程分配独立的内核页表,以及内核栈。
用户页表与内核页表的切换会发生在trap过程中和内核scheduler选择进程运行的过程中,在trap过程中还是使用总的内核页表,它映射了所有需要的内核空间,内核就能正常工作。在scheduler过程中使用的是进程的内核页表,当调度器切换到另一个进程时,需要让 CPU 看到新进程的内存空间,包括内核空间的映射。如果不切换页表,可能会访问到上一个进程的内核数据,造成安全隐患和数据混乱。所以还需要修改scheduler函数切换到对应线程的内核页表。之后,每个进程执行的时候,就都会在内核态采用自己独立的内核页表了。
最后需要做的事情就是在进程结束后,应该释放进程独享的页表以及内核栈,回收资源,否则会导致内存泄漏。
经过上述操作之后,每一个进程都拥有独立的内核态页表了,之后我们还需要继续完善,在进程的内核态页表中维护一个用户态页表映射的副本,这样使得内核态也可以对用户态传进来的指针(逻辑地址)进行解引用。这样做相比原来 copyin 的实现的优势是,原来的 copyin 是通过软件模拟访问页表的过程获取物理地址的,而在内核页表内维护映射副本的话,可以利用 CPU 的硬件寻址功能进行寻址,效率更高并且可以受快表加速。
之前的copyin函数用于把用户空间的数据拷贝到内核空间,先遍历用户空间虚拟地址,再根据这个地址调用 walkaddr(pagetable, srcva),查找用户虚拟地址 srcva 在用户页表中的物理地址 pa。由于全局内核页表已经把用户空间的物理页映射进来了,所以内核可以直接通过物理地址 pa 访问用户数据。
要实现这样的效果,我们需要在每一处内核对用户页表进行修改的时候,将同样的修改也同步应用在进程的内核页表上,使得两个页表的程序段(0 到 PLIC 段)地址空间的映射同步。在每个修改到进程用户页表的位置,都将相应的修改同步到进程内核页表中。一共要修改:fork()、exec()、growproc()、userinit()。这里还是参考上面链接中的代码,在此我就不给出了。