MIT 6.S081 Lab Pgtbl实验
本文是MIT课程6.S081操作系统学习笔记的一部分:
- Lab util: Unix utilities
- Lab syscall: System calls
- Lab pgtbl: Page tables
- Lab traps: Traps
- Lab cow: Copy-on-write fork
- Lab thread: Multithreading
- Lab net: Network driver
- Lab lock: Parallelism/locking
- Lab fs: File system
- Lab mmap: Mmap
相关的代码都放在了GitHub下了:RayZhang13/MIT-6.S081-2021
课程相关的资源、视频和教材见Lab util: Unix utilities开头。
xv6里头的细节有一点点多,跟着书上和上课的讲解看的还是有点迷… 水温有点起来了
突然意识到原来上课是要课前预习的啊(坏了,从大二后就没咋干过这种事情了)
这次的主要内容是页表和虚拟内存,我们要对页表和内存页进行相关的操作。
准备
对应课程
本次的作业是Lab Pgtbl,我们将探索页表的相关用法,并使用页表来加速特定的系统调用,并检测哪个页表刚刚被访问过了。
我们需要看掉Lecture 4,也就是 B站课程的P3,上课主要都在讨论虚拟内存和页表的相关概念,
另外请阅读教材第三章Page tables,了解虚拟内存和页表的相关概念。
系统环境
使用Arch Linux虚拟机作为实验环境~
环境依赖配置,编译使用make qemu
等,如遇到问题,见第一次的Lab util: Unix utilities
使用准备
参考本次的实验说明书:Lab: page tables
从仓库clone下来课程文件:
1 | git clone git://g.csail.mit.edu/xv6-labs-2021 |
切换到本次作业的pgtbl
分支即可:
1 | cd xv6-labs-2021 |
在作业完成后可以使用make grade
对所有结果进行评分。
题目
xv6虚拟内存简介
页表简介和walk
函数
在所有事情开始之前,我们先来简单地看一下xv6内部对于虚拟内存和页表的构造方式。首先我们意识到对于不同进程而言,其地址空间是相互独立的,通过虚拟内存的方式我们可以确保进程的内存空间不会被其他进程随意修改。为了完成虚拟内存到物理内存的映射,xv6为每一个进程实现了一张页表,用于根据虚拟内存地址查询对应的物理内存地址。
如上所示的就是xv6内部实现的三级页表,在xv6中,我们只使用39位作为地址,我们进一步将该39位地址分为4段,其中末尾12位为内存页地址偏移量,这也进一步验证了一个内存页的大小为4KB,即字节,另外三段分别是L0、L1、L2,构成了一个三级页表的结构。
当我们需要对页表进行查询时,我们首先根据寄存器satp
获得页表的内存地址(注意到satp
的修改是特殊的权限指令,用户程序不能随意更改,否则就会破坏隔离性),随后我们根据L2
段读出的数值计算index索引位置,从一级页表中获取到二级页表的地址,随后我们进入到二级页表,根据L1
段读出的数值计算index索引位置获得三级页表的位置,再进入三级页表,最后根据L0
的数值计算index索引位置,读取到最终的物理地址位置。
使用多级页表的好处是显而易见的,和单级页表相比可以节省出更多的内存,例如一级页表可以在部分二级页表的入口处标记为不可用,那就节省了该条目下二级页表和二级页表下更多三级页表的内存开销,在单级页表中,每一个内存页的虚拟地址都是要做映射的,这就消耗了大量的内存资源。一个典型的单级页表如下所示:
处理三级页表的过程,相关代码存储在kernel/vm.c
中,见walk
函数:
1 | // Return the address of the PTE in page table pagetable |
内核地址空间
内核地址空间的映射简单来看如图所示:
可以看到物理地址0x80000000往上到PHYSTOP
的空间属于RAM,都是直接映射到了内核地址空间。此外,QEMU还模拟了I/O设备,位于地址0x80000000下方的I/O设备控制寄存器也是直接进行映射的。
在内核地址空间顶部,有几个内核虚拟地址不是直接映射的:
- trampoline页。它被映射在虚拟地址空间的顶端;用户页表也有这个映射。第 4 章讨论了 trampoline 页的作用,但我们在这里看到了页表的一个有趣的用例;一个物理页(存放 trampoline 代码)在内核的虚拟地址空间中被映射了两次:一次是在虚拟地址空间的顶部,一次是直接映射。
- 内核栈页。每个进程都有自己的内核栈,内核栈被映射到地址高处,所以在它后面 xv6 可以留下一个未映射的守护页。守护页的 PTE 是无效的(设置 PTE_V),这样如果内核溢出内核 stack,很可能会引起异常,内核会报错。如果没有防护页,栈溢出时会覆盖其他内核内存,导致不正确的操作。
我们接下来稍微看一下内核地址空间是如何初始化的,在kernel/main.c
下的主函数下,在物理内存页初始化之后,我们调用了kvminit()
来创建内核页表。我们继续深入看位于kernel/vm.c
下的kvminit()
:
1 | // Initialize the one kernel_pagetable |
继续深入看kernel/vm.c
下的kvmmake()
,这里就是设置内核地址空间的核心代码了,我们可以看到内核代码对物理地址做了多次映射:
1 | // Make a direct-map page table for the kernel. |
其中kvmmap
的各个参数是分别是内核页表kpgtbl
,虚拟地址va
,物理地址pa
,映射大小sz
和权限标志位perm
:
1 | // add a mapping to the kernel page table. |
继续往下看mappages()
,就是设置映射最底层的实现了,参数分别为页表地址pagetable
,虚拟地址va
,映射大小sz
,物理地址pa
和访问标志位perm
:
1 | // Create PTEs for virtual addresses starting at va that refer to |
kernel/main.c
中在内核页表生成结束后,会调用kvminithart()
来映射内核页表,将根页表页的物理地址写入寄存器satp,随后CPU使用内核页表翻译的地址工作:
1 | // Switch h/w page table register to the kernel's page table, |
用户地址空间
相比之下,用户地址空间的构造情况如下:
可以看到,一个进程的用户内存从虚拟地址 0
开始,可以增长到 MAXVA
,原则上允许一个进程寻址 256GB 的内存。内核会映射带有 trampoline
代码的页,该 trampoline
处于用户地址空间顶端,因此,在所有地址空间中都会出现一页物理内存。栈内存只有一页,图中显示的是由 exec
创建的初始内容。字符串的值,以及指向这些参数的指针数组,他们都位于栈的最顶端。trapframe
用于保存所有用户寄存器,这点在后续章节会提及。
另外,为了检测用户栈溢出分配的栈内存,xv6 会在 stack 的下方放置一个无效的保护页。如果用户栈溢出,而进程试图使用栈下面的地址,硬件会因为该映射无效而产生一个页错误异常。
接下来看代码实现,在kernel/main.c
中,我们看到main()
下使用userinit()
来创建第一个用户进程,我们去kernel/proc.c
下查看,userinit()
使用allocproc()
来生成用户地址空间,同样fork()
在处理新的用户子进程时也是调用了allocproc()
。
那么我们继续看allocproc()
,这段代码也在kernel/proc.c
下:
1 | // Look in the process table for an UNUSED proc. |
首先其对进程列表进行遍历,查找到状态为UNUSED
的进程,然后我们进入到found
中,首先分配新的PID数值并修改进程状态,然后我们分配trapframe
内存页空间(获得的地址很显然是物理地址/内核地址空间地址),并把相关的信息更新到p
中,也就是一个叫proc
的结构体中表示进程相关信息,见kernel/proc.h
。
接下来,我们调用proc_pagetable()
,根据传入的p
,把信息拿出来开始构建我们的用户空间页表:
1 | // Create a user page table for a given process, |
可以看到先使用uvmcreate()
创建了页表的空间,然后把trampoline
和trapframe
做了映射… 大致就是这么个逻辑
实现系统调用的加速
要求和提示
在这个部分中,我们要通过调整页表的映射来实现对特定的系统调用的加速。
在部分操作系统(例如Linux中),会使用用户空间和内核空间之间的一块只读区域用来进行数据共享,以此来达到加速特定的系统调用的目的,这样就消除了与内核交互产生的开销。在本部分中,我们将实现对getpid
系统调用的优化。
当一个进程被创建时,我们需要将一个只读的内存页映射到USYSCALL
上,其中USYSCALL
是一个虚拟内存地址,见kernel/memlayout.h
。在该内存页上我们需要存储一个叫struct usyscall
的结构体,见kernel/memlayout.h
,然后将其初始化使其存储当前进程的PID。下面是memlayout.h
的节选部分:
1 |
|
在这个Lab中,系统在用户空间部分已经提供了ugetpid()
,并会自动使用USYSCALL
的映射,ugetpid
函数就是我们测试时的函数,为了使得他工作正常我们需要完成相关修改。这段见user/ulib.c
:
1 |
|
相关提示:
- 可以在
kernel/proc.c
下的proc_pagetable
中处理内存映射问题。 - 注意处理访问标志位使得该内存页对用户空间来说是只读的。
- 了解
mappages()
的使用方法会很有帮助。 - 不要忘记在
allocproc()
中分配和初始化内存页。 - 确保在
freeproc()
中正确地释放了内存页。
分配并初始化usyscall
算了,看了那么多,说点人话解释一下吧… 正常情况下如果我们需要获得当前进程的PID,我们需要执行系统调用,切到内核态,从proc
结构体里头把pid
读出来…
现在的意思是说我们可以在内存中开辟一块空间,准确的来说是一个struct usyscall
结构体,里面包装了一个pid
的整型变量。我们在内核中创建一个新的用户进程的时候,为这个结构体开辟相应的内存空间,把PID填进去,然后在对应的用户进程中把它直接映射到用户虚拟内存的某个位置… 这样子就可以在用户态随时访问PID信息了。
而这个位置呢,已经明示的不能再清楚了,也就是USYSCALL
,他指向的位置是TRAPFRAME - PGSIZE
,也就是TRAPFRAME
下面一个页的位置,我们不妨把这个内存页叫做usyspage
:
接下来思考我们要做的事情:
- 在创建用户进程时,额外开辟空间给
struct usyscall
,并初始化填入PID数据,制作成内存页。 - 在创建用户地址空间的时候,将内存页映射到
USYSCALL
。 - 释放进程数据的时候记得把
usyscall
也给释放了。
那现在考虑实现创建用户进程时,开辟struct usyscall
空间并初始化。
根据刚才的分析,我们把代码增加在allocproc()
中处理即可,照猫画虎。由于到时候proc_pagetable
是通过读取结构体p
来实现映射的…
我们需要对struct proc
添加一个额外的成员,在kernel/proc.h
中,我们修改为如下:
1 | // Per-process state |
接下来我们在kernel/proc.c
中的allocproc()
下分配并初始化struct usyscall
:
1 | // Look in the process table for an UNUSED proc. |
修改proc_pagetable
添加映射
接下来,我们要修改kernel/proc.c
下的proc_pagetable()
,将刚刚创建的struct usyscall
映射到用户地址空间的USYSCALL
位置:
1 | // Create a user page table for a given process, |
很简单的操作了属于是,把刚才创建的usyspage
的物理地址映射到用户空间的USYSCALL
上即可。
释放进程空间
释放进程空间的时候,首先需要考虑的就是清除干净struct proc
里面的东西,也就是调用freeproc()
的时候需要注意的,见kernel/proc.c
。我们在代码里面加入释放usyspage
的代码:
1 | // free a proc structure and the data hanging from it, |
同时注意到这里还调用了proc_freepagetable()
来清除映射和释放页表占用内存,我们这里需要加入一行,同时释放USYSCALL
地址上的映射,见kernel/proc.c
:
1 | // Free a process's page table, and free the |
实现页表打印
要求和提示
在本部分中,我们需要将RISC-V的页表可视化,也就是实现一个页表内容的打印功能,作为后续调试的辅助工具。
我们需要定义一个叫做vmprint()
的函数,这个函数应当传入一个类型为pagetable_t
的参数,也是页表的指针,然后将页表的全部有效信息打印出来。题目要求在kernel/exec.c
下的exec()
中,在最后的return argc
前加入一行(疯狂暗示,喂到嘴巴里):
1 | if(p->pid==1) vmprint(p->pagetable); |
来实现对第一个进程的页表的打印。
题目中给出了示例,当第一个进程刚完成exec()
执行了/init
时的页表状态:
1 | page table 0x0000000087f6e000 |
第一行表示了vmprint
传入的参数,也就是页表入口的地址。接下来的每一行都是PTE,以及PTE下可能存在的下级页表(不用我说你也能看出来这就是一个DFS递归)。
我们用..
来表示这个PTE的深度,最开始打印的数字是这个PTE在一个4KB内存页(共512个PTE)的编号,接下来会打印PTE的具体数值,这个数值嘛表面上看不出啥名堂,因为他是这样子组成的,所以打印出来就为了看个乐呵?
然后我们使用已经有的PTE2PA
转换函数(其实就是个位运算),把物理地址PA从PTE这堆乱糟糟的数字里读出来。
相关提示:
- 可以把
vmprint()
放在kernel/vm.c
里面 - 记得使用
kernel/riscv.h
最后添加的宏定义,包括但不限于PTE2PA
等 - 强烈建议参考
vm.c
文件中的freewalk
函数(疯狂暗示,喂嘴巴里了) - 记得在
kernel/defs.h
中定义vmprint
函数,以便在exec.c
中成功调用 - 在
printf
中使用%p
来打印完整的16进制64位地址和PTE信息
核心打印函数
众所周知这是个DFS递归,我们被给到了一个递归的入口页表地址,在一个内存页,我们共有512个PTE需要遍历。每次我们需要检查该PTE是否有效,也就是检查V
位,如果有效就需要我们打印。此外,我们检查R
,W
和X
位,如果这些位没有被置为有效,说明该PTE指向下一个下级页表,要开始套娃了(相关的代码可以观察freewalk
函数,实际上非常类似)。我们在vm.c
中加入如下代码即可:
1 | void pgtblprint(pagetable_t pagetable, int depth) { |
其他设置
为了能让结果运行起来,我们还需要遵循提示中给出的指示,例如在defs.h
中加入vmprint
定义:
1 | // vm.c |
在exec.c
的exec
函数中的return
前插入一行打印代码:
1 | if(p->pid==1) vmprint(p->pagetable); |
测试
在目录下执行make clean; make qemu
,可以看到在启动时打印出了页表的结构:
目测和给出样例一致
查询内存页访问情况
要求和提示
一些垃圾回收器可以根据内存页被访问情况的信息来进行工作。
在本部分中,我们将主要考虑为xv6实现一个新的功能,也就是检测用户空间的内存访问情况,并将信息返回给用户空间。我们需要通过检查RISC-V页表中的A
标识位来实现,RISC-V硬件每当尝试解决TLB未命中问题时,访问内存页的时候就会在页表中将对应PTE的标志位置为有效。
我们的目标是实现一个叫做pgaccess
的系统调用,以此来查看哪些内存页被访问过了。这个系统调用需要传入三个参数,第一个参数是开始的要查看的用户空间内存页地址,第二个参数是需要查看的内存页数量,最后传入的是一个用户地址空间,好让我们将结果写到用户地址空间,结果以bitmask的形式存储,每一位和各个内存页一一对应,第一个内存页对应最小的比特位。
相关提示:
- 考虑在
kernel/sysproc.c
下实现sys_pgaccess()
系统调用主要功能。 - 需要使用
argaddr()
和argint()
来获取系统调用传入的参数,这一块可以详见上次的Lab syscall: System calls。 - 由于我们需要将结果返回给用户态的地址,因此有必要使用内核态中的临时缓冲区进行结果的处理,然后使用
copyout()
函数将结果复制到用户态,这段也可见Lab Syscall中相关内容。 - 可以设置扫描的内存页数上限。
- 在
kernel/vm.c
下的walk()
函数会对找到正确的PTE很有帮助。 - 需要在
kernel/riscv.h
中额外定义一下PTE_A
,也就是访问标记的比特位。(不敢相信他们居然原来没有加) - 确保在扫描内存页结束后将标志位
PTE_A
清零,不然下次扫描这个页的时候你看到标志位就乱了,不知道有没有被访问过了。 - 在调试页表的时候可以使用
vmprint()
,活学活用了属于是。
添加PTE_A
定义
一直没想到,居然这都没加,还要我自己整,就很神奇。
那就看一下教材手册吧,貌似还是这张图:
可以看到Access标志位是第6位(从右到左,从0数起),所以在kernel/riscv.h
下面,我们就照猫画虎处理一下:
1 |
系统调用配置
在之前的Lab Syscall里面,我们已经大致了解了创建一个系统调用需要哪些流程,这里可以帮忙再回顾一下:
- 在
user/user.h
中添加系统调用的函数声明。 - 在
kernel/syscall.h
中添加新系统调用的编号。 - 在
user/usys.pl
中添加系统调用项,生成相关汇编代码,即给a7
寄存器赋值,使用ecall
指令陷入内核态。 - 在
kernel/syscall.c
的syscalls
表中添加新的映射关系,指向需要执行的函数(也需另外实现),例如fork
系统调用就实现在kernel/sysproc.c
和kernel/proc.c
中。
定睛一看,全帮忙处理好了。谢谢有被暖心到,不用自己加了。所以我们可以专注到系统调用的核心函数sys_pgaccess
怎么写了。
例行操作,我们在kernel/sysproc.c
下实现sys_pgaccess()
。可以看到,已经被填好模板了,不用额外创建了,暖心~
那么我们需要使用argaddr
和argint
来获取系统调用参数,随后从某个用户地址开始向后读取若干个内存页,并检查标志位信息,如果标志位有效读取结束需要清空。最后处理完后,将结果以bitmask的形式通过copyout()
写到用户空间地址。
在这里,我们不妨规定使用64位空间来存储bitmask,因此,我们最多可以扫描的页面数量是64。代码如下:
1 |
|
另外,我们还需要在kernel/defs.h
中添加一下walk()
函数的声明,不然貌似调用不起来:
1 | // vm.c |
总结
所有任务都完成之后,看一眼测试结果。
有几点需要注意下:
-
我们需要在目录下创建一个叫做
answers-pgtbl.txt
,根据官方的说法是用来填课后问题,但是这里目测没有hhh,随便填一点就好。 -
另外,还需要创建一个
time.txt
来表示你完成任务所花时间。 -
测试过程中
usertests
耗时略长,电脑机能不行的小伙伴可以适当将测试的超时延长一点点,请把主目录下grade-lab-pgtbl
文件里面的timeout
参数改大点,不然可能你的测试项目还没跑完他就跳出给你标个大大的 FAIL…
接下来,使用命令make grade
来进行结果测试,结果如下,没啥问题,睡觉~