操作系统 MIT6.S081 Lab3 Page
实验原理
① Xv6 在 64bit Sv39 RISC-V 上运行:
-
RISC-V 的指令,不论是内核的还是用户的,操作的都是虚拟内存;
-
Sv39 的配置下,只使用了低 39 位地址,其中高 27 位用于查询页表,剩下的 12 位是线性地址,因此一页的大小是 4KB :
-
逻辑上页表是一个 2272^{27}227 项的数组,每一项称为 PTE,PTE 由 44bit PPN 和 10bit 标志位组成。标志位标明了用户对不同页的权限,是一种安全措施;
-
实际上页表被分为三级,每一级大小为 292^929 ,前两级的 PPN 包含的是下一级的物理地址,最后一级的 PPN 与 12 位的线性地址组成了物理地址。
-
-
Xv6 的内核使用直接映射(即虚拟地址和物理地址相同)获取与 RAM 和设备通信,简化了读取或写入物理内存的内核代码。
② 每个进程都有一个描述用户空间页表以及描述内核空间的单页表:
struct proc保存了一个pagetable_t,实际上是指向该进程根页表的指针;- 内核在用户地址空间的顶部映射一个带有
trampoline代码的页面,所有用户的trampoline都映射到同一片区域,而且只能由内核访问; - 一些操作系统 (例如Linux) 通过在用户空间和内核之间的只读区域共享数据来加速某些系统调用。
③ 实用函数:
walk()是最关键的函数,用于模拟硬件的分页工作,通过虚拟地址查找最后一级页表的物理地址,即能够查找到最终的物理地址;mappages()建立从某段虚拟内存到某段物理内存的映射;- copyout and copyin copy data to and from user virtual addresses provided as
system call arguments; copyin()和copyout()用于从用户提供的虚拟地址参数复制内容到内核中,或者从内核复制内容到用户提供的虚拟地址参数中;- 以
kvm开头的函数操作内核页表,以uvm开头的函数操作用户页表,其他函数可以作用于这两个页表。
Part A
实验目标: 加速系统调用 getpid() ,使其直接在用户态下完成,而不需要切换到内核态
实验步骤: 涉及到 usyscall 的一共有一下四个过程:为进程分配、释放内存时,也需要对 usyscall 分配、释放内存;为进程的虚拟地址和物理地址建立、解除映射时,也需要对 usyscall 所指空间进行建立和解除映射。于是要对实现这四个过程的函数进行修改:
① 共享空间中保存的数据结构为 usyscall ,其虚拟地址的起始位置在 kernel/memlayout.h 中定义为 USYSCALL :
// kernel/memlayout.h
#define USYSCALL (TRAPFRAME - PGSIZE)
struct usyscall {
int pid; // Process ID
};
在 struct proc 中新增一个 struct usyscall* ,指向内核态和用户态共享、用户态下只可读的内存空间,结构体中包含了用户态下完成系统调用需要用到的信息(这里需要保存 PID):
// kernel/proc.h
// Per-process state
struct proc {
...
struct usyscall* usyscall;
};
② 在 allocproc() 中,为进程分配内存时,也要为 usyscall 分配内存,并且初始化其中的变量:
// kernel/proc.c
static struct proc*
allocproc(void)
{
struct proc *p;
...
if((p->usyscall = (struct usyscall*)kalloc()) == 0) {
freeproc(p);
release(&p->lock);
return 0;
}
p->usyscall->pid = p->pid;
...
return p;
}
③ 在 proc_pagetable() 中为进程创建页表时,将 struct usyscall* usyscall 所指向的虚拟内存和物理内存映射起来:
- 要求用户态下可以访问且只读这片内存区域,因此权限设置为
PTE_U和PTE_R - 失败的情况下,要将已经映射好的
TRAPOLINE和TRAPFRAME都解除映射,并且释放已经分配给页表的内存(否则会造成内存泄漏)
// kernel/proc.c
pagetable_t
proc_pagetable(struct proc *p)
{
pagetable_t pagetable;
...
if(mappages(pagetable, USYSCALL, PGSIZE,
(uint64)(p->usyscall), PTE_R | PTE_U ) < 0) {
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmunmap(pagetable, TRAPFRAME, 1, 0);
uvmfree(pagetable, 0);
return 0;
}
return pagetable;
}
④ 在 freeproc() 中释放进程内存时,需要将 usyscall 所指的内存一并释放:
// kernel/proc.c
static void
freeproc(struct proc *p)
{
...
if (p->usyscall)
kfree((void*)p->usyscall);
p->usyscall = 0;
...
}
⑤ 在 proc_freepagetable() 中释放进程页表、解除映射时,需要解除 USYSCALL 位置上的映射(可以观察到,trapframe 和 usyscall 都是解除映射并且释放内存,而 trampoline 只解除映射,创建进程时也没有给 trampoline 分配空间,因为所有 trampoline 指向的是同一片内存空间):
// kernel/proc.c
void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
...
uvmunmap(pagetable, USYSCALL, 1, 0);
uvmfree(pagetable, sz);
}
测试: 可以看到通过了 getpid 测试,但是 pg_access 还没有实现,所以并未通过:
Part B
实验目标: 按照页表层级顺序格式化打印第一个进程的页表信息
实验步骤:
① 实现 vmprint() :
- 参考
freewalk(),采用递归的方法进行打印; - 由于一共有 3 级页表,故递归子程序使用变量
level检测递归深度; - 需要检测页表入口地址的
PTE_V标志位来判断此处页表是否有效
// kernel/vm.c
// auxiliary for implimentation of vmprint()
void aux_vmprint(pagetable_t pagetable, int level) {
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];
if(pte & PTE_V){
for (int i = 0; i < level; ++i) {
printf(".. ");
}
printf("..%d: pte %p pa %p\n", i, pte, PTE2PA(pte));
if (level != 2) {
uint64 child = PTE2PA(pte);
aux_vmprint((pagetable_t)child, level+1);
}
}
}
}
void
vmprint(pagetable_t pagetable) {
printf("page table %p\n", pagetable);
aux_vmprint(pagetable, 0);
}
② 在 defs.h 中声明 vm.print() ,以供其他文件调用:
// kernel/defs.h
// vm.c
void vmprint(pagetable_t);
③ 在 exec.c 中,当前进程 PID 为 1(即第一个进程)时,调用 vmprint() :
// kernel/exec.c
int
exec(char *path, char **argv)
{
...
if (p->pid == 1)
vmprint(p->pagetable);
...
}
测试: 可以观察到,在 shell 启动前,就已经打印了第一个进程的页表的相关信息:
Part C
实验目标: 实现系统调用 pgaccess() ,检测哪些页被访问过
实验步骤:
① 由 book-riscv-rev3 手册可知,标志 PTE_A 在页表地址的第 6 位,需要在 riscv.h 中添加相关宏定义:
// kernel/riscv.h
#define PTE_A (1L << 6) // accessed
② 实现 sys_pgaccess() :
- 用户态下调用系统调用的函数原型为
int pgaccess(void *base, int len, void *mask);,需要用argint()和argaddr()来获得这三个参数; - 使用
uint64存储掩码,因此检测的页数不能超过 64 个,要求len < 64; - 每个页的大小为
PGSIZE=4096,因此每次地址应当偏移 4096 ; - 使用
walk函数获得当前所指页的最低一级页表的入口地址,通过该入口地址的标志位判断当前所指页是否被访问过; - 每次检测到页表入口地址
pte的PET_A为 1 时,说明该页被访问过,需要将其对应掩码标志为 1,并且将页表入口地址的PET_A清零; - 先将获得的掩码存在局部变量
mask中,再使用copyout()函数复制到用户空间的变量中;
// kernel/sysproc.c
int
sys_pgaccess(void)
{
uint64 base;
int len;
uint64 pmask;
argaddr(0, &base);
argint(1, &len);
argaddr(2, &pmask);
if (len > 64)
return -1;
uint64 mask = 0;
for (int i = 0; i < len; ++i) {
pte_t* pte = 0;
if ((pte = walk(myproc()->pagetable, base + i * PGSIZE, 0)) == 0)
return -1;
if (*pte & PTE_A) {
mask |= (1 << i);
*pte ^= PTE_A;
}
}
if (copyout(myproc()->pagetable, pmask, (char*)&mask, sizeof(mask)) < 0)
return -1;
return 0;
}
测试: 可以观察到正确打印第一个进程的页表信息以及通过了 pgtbltest 测试:
问题回答
在 Part A 加速系统调用部分,除了 getpid() 系统调用函数,你还能想到哪些系统调用函数可以如此加速?
比如获取父进程的系统调用 getppid() 也可以通过类似的方法实现。
虚拟内存有什么用处?
① 虚拟内存使得每个进程无法直接访问到物理内存,防止恶意进程对内存的破坏,起到隔离作用;
② 虚拟内存便于编程,使得写程序与具体机器、具体内存设备、当前内存分配情况无关,而总是假定有一块新的、完整连续的空间可以使用;
③ 虚拟内存便于动态分配内存,不需要在创建进程时就分配一大块连续空间;
④ 虚拟内存可以使得进程所使用的地址范围比物理内存要大,可以借助与辅存的 swapping 来实现
为什么现代操作系统采用多级页表?
① 有利于节省内存,并不是每个进程都会用到虚拟内存的整个范围,因此还未用到的页表可以先不分配内存空间
② 便于动态分配,因为同一页表内的页表项必须是连续存储的,如果一级页表表示的地址范围过大,就会造成单个页表过大,不利于存储和分配。
简述 Part C 的 detect 流程
① 当每个页通过虚拟地址被查询(访问)时,硬件会将该页对应的三级页表路径上的页表项的 PTE_A 标志位都置成 1,代表该页被访问过;
② 用户态下调用系统调用 pgaccess() 时,依据 riscv 的 calling convention,三个函数参数按顺序放入寄存器 a0-2 ;
③ 接着, user/usys.S 根据 calling convention 将对应的系统调用号放入寄存器 a7 ;由 usys.pl 提供系统调用的入口,执行 ecall 指令进入内核态;
④ 内核中,syscall() 函数根据 a7 调用 syscalls[SYS_pgaccess] ,即 kernel/sysproc.c 中的 sys_pgaccess() 函数;
⑤ sys_pgaccess() 函数通过 argint() 和 argaddr() 来获得三个参数 base len pmask ;接着,以每个页的大小 PGSIZE=4096 为 base 的步长,循环 len 次,执行以下操作:
- 使用
walk函数获得当前所指地址对应的物理页的最低一级页表的入口地址,通过该入口地址的PTE_A标志位判断当前所指页是否被访问过; - 每次检测到页表入口地址
pte的PET_A为 1 时,说明该页被访问过,需要将其对应掩码标志为 1,并且将页表入口地址的PET_A清零; - 先将获得的掩码存在局部变量
mask中,再使用copyout()函数复制到用户空间的变量中。
⑥ 执行完后, syscall() 将 sys_pgaccess() 的返回值放入 a0 ,并执行指令 sret 返回用户态,用户态根据被修改的 a2 参数就可以得到掩码信息。
本文介绍如何加速getpid等系统调用,通过在用户态和内核态间共享数据来提升效率,同时详细解析了多级页表的工作原理,并展示了如何实现pgaccess系统调用来检测被访问过的页。
895

被折叠的 条评论
为什么被折叠?



