Lab:page tables
在这个lab中6.1810 / Fall 2025,要求我们先阅读xv6课本的Chapter 3 Page tables(第三章)。要求我们探索xv6当中关于页表的内容。并且要求我们实现一些页表相关功能的实现(例如:虚地址和物理地址的映射/解除映射,页表的创建和释放等)。
并且官网也给出了提示:
- 在kernel/memlayout.h当中存放了内存布局,页表大小相关的常量就在此。
- 在kernel/vm.c当中是页表相关逻辑的实现,接下来的大部分lab内容就在此实现。
- 在kernel/kalloc.c当中存放的时内存分配相关的逻辑,在新建/删除页表时会用到这里的函数。
Speed up system calls (简单)
在这个lab当中,要求我们在 xv6 中添加一个新的 用户可读的只读内存映射(USYSCALL),用来让用户态程序在不陷入内核的情况下,直接读取部分内核数据(如 pid),并正确处理其 创建、映射、访问与释放的完整生命周期。
如何将一个用户可读的只读内存映射(USYSCALL)添加到进程页表内?以及如何删除该映射?
前言和注意事项:在xv6当中的有关进程的创建/释放,进程页表的创建/释放的过程都在kernel/proc.h,并且按照官网的说法,我们需要将进程的pid存放到内存当中,这样在调用gitpid系统调用时,则直接选择从内存空间当中读取该pid,大大提高了执行效率,并且不用陷入到内核态;这就意味着我们需要在进程的结构体当中添加一个成员用于指向存放当前进程的pid的空间,为了之后的读取。
一、分配物理内存:
前面提到过,进程的结构体成员当中有指向进程pid的指针(struct usyscall *),因此,我们需要先给他分配物理内存(由内核分配)。- p->usyscall = (struct usyscall *)kalloc(); //分配物理内存
复制代码 二、初始化内容:
将当前进程的pid存放到刚才的指针p->usyscall所指向的空间中。- p->usyscall->pid = p->pid;
- // 以下是xv6提前写好的,改进后的ugetpid方法
- int
- ugetpid(void)
- {
- struct usyscall *u = (struct usyscall *)USYSCALL; //通过虚拟地址USYSCALL访问特点内存
- return u->pid;
- }
复制代码 为什么我们必须通过struct usyscall *来访问,而不是直接返回进程结构体当中的pid呢?
答:首先,xv6有内核页表和用户页表,并且用户态下的进程只能看得见内存。因为进程的结构体存放在内核页表当中,在用户态下我们只能访问到用户页表,所以准确来说我们只能通过虚拟内存搭配页表机制的方式来访问存放在该物理空间当中内容。我们在内核态下通过p->usyscall = (struct usyscall *)kalloc(); 分配的内存似乎也是被内核所管理,但是我们将USYSCALL这个虚拟地址和物理地址相映射了起来,因此我们可以通过在用户态下访问该虚拟地址的方式下访问到具体的物理地址当中的值。
三、创建用户页表:
众所周知,OS当中的进程采用页表机制来将进程的虚地址映射到物理地址上,所以说无论我们是否要添加映射到页表中,我们都必不可免地要创建一个用户页表。- p->pagetable = proc_pagetable(p);
复制代码 四、建立虚拟地址 到 物理地址映射:
说白了就是在用户页表中添加一个新的页表项,所以这一步的操作要在页表的相关逻辑当中进行,该页表项用于映射到刚才分配的物理内存。在kernel/defs.h当中,我们可以看到mappages的声明(该函数用于添加映射到页表)。
注意:页表机制是将进程的虚拟地址映射为内存中真实的物理地址,所以在添加新的映射时,要一并给出这些参数以及映射大小和权限。- // 映射 USYSCALL
- if(mappages(pagetable,
- USYSCALL, //虚拟地址
- PGSIZE, // 映射大小
- (uint64)p->usyscall, //物理地址
- PTE_R | PTE_U | PTE_V) < 0){ // 官网要求设置的权限
- uvmfree(pagetable, 0);
- return 0;
- }
复制代码 xv6的权限(添加权限的目的是防止“篡改”,“非法访问”等等操作):
位含义PTE_R用户可读PTE_W防止用户写PTE_X防止执行PTE_U用户态可访问PTE_V映射有效 五、删除/释放映射:
首先在页表释放的相关逻辑当中进行释放映射的操作,在kernel/defs.h当中,我们可以看到uvmunmap的声明(该函数用于删除/释放映射到页表)。- uvmunmap(pagetable, USYSCALL, 1, 0); //释放USYSCALL
复制代码 之后在进程释放的相关逻辑进行释放之前访问的物理空间的操作,在kernel/defs.h当中,我们可以看到kfree的声明(该函数用于释放分配的内存)。 六、深入了解进程和页表的底层逻辑:
函数(kernel/proc.c)负责什么allocproc分配“进程资源”(pid、usyscall、trapframe、kstack)(第一,二,三步在此进行)freeproc释放“进程资源” (第五步后半部分在此进行)proc_pagetable构造页表结构 (第五步前半部分在此进行)proc_freepagetable拆除页表结构 (第四步在此进行) 由此我们可以得知页表的生命周期几乎伴随整个进程。
代码的相关内容:
- /* kernel/proc.c */
- static struct proc*
- allocproc(void)
- {
- struct proc *p;
- for(p = proc; p < &proc[NPROC]; p++) {
- acquire(&p->lock);
- if(p->state == UNUSED) {
- goto found;
- } else {
- release(&p->lock);
- }
- }
- return 0;
- found:
- p->pid = allocpid();
- p->state = USED;
- // 分配物理内存
- p->usyscall = (struct usyscall *)kalloc();
- if(p->usyscall == 0){
- freeproc(p);
- release(&p->lock);
- return 0;
- }
- // 初始化内容
- p->usyscall->pid = p->pid;
- // Allocate a trapframe page.
- if((p->trapframe = (struct trapframe *)kalloc()) == 0){
- freeproc(p);
- release(&p->lock);
- return 0;
- }
- // An empty user page table.
- p->pagetable = proc_pagetable(p);
- if(p->pagetable == 0){
- freeproc(p);
- release(&p->lock);
- return 0;
- }
- // Set up new context to start executing at forkret,
- // which returns to user space.
- memset(&p->context, 0, sizeof(p->context));
- p->context.ra = (uint64)forkret;
- p->context.sp = p->kstack + PGSIZE;
- return p;
- }
- // free a proc structure and the data hanging from it,
- // including user pages.
- // p->lock must be held.
- static void
- freeproc(struct proc *p)
- {
- // 释放之前分配的物理内存
- if(p->usyscall){
- kfree((void*)p->usyscall);
- p->usyscall = 0;
- }
- if(p->trapframe)
- kfree((void*)p->trapframe);
- p->trapframe = 0;
- if(p->pagetable)
- proc_freepagetable(p->pagetable, p->sz);
- p->pagetable = 0;
- p->sz = 0;
- p->pid = 0;
- p->parent = 0;
- p->name[0] = 0;
- p->chan = 0;
- p->killed = 0;
- p->xstate = 0;
- p->state = UNUSED;
- }
- // Create a user page table for a given process, with no user memory,
- // but with trampoline and trapframe pages.
- pagetable_t
- proc_pagetable(struct proc *p)
- {
- pagetable_t pagetable;
- // An empty page table.
- pagetable = uvmcreate();
- if(pagetable == 0)
- return 0;
- // 映射 USYSCALL(也是关键部分)
- if(mappages(pagetable,
- USYSCALL, //虚拟地址
- PGSIZE, // 映射大小
- (uint64)p->usyscall, //物理地址
- PTE_R | PTE_U | PTE_V) < 0){ // 权限
- uvmfree(pagetable, 0);
- return 0;
- }
-
- // map the trampoline code (for system call return)
- // at the highest user virtual address.
- // only the supervisor uses it, on the way
- // to/from user space, so not PTE_U.
- if(mappages(pagetable, TRAMPOLINE, PGSIZE,
- (uint64)trampoline, PTE_R | PTE_X) < 0){
- uvmfree(pagetable, 0);
- return 0;
- }
- // map the trapframe page just below the trampoline page, for
- // trampoline.S.
- if(mappages(pagetable, TRAPFRAME, PGSIZE,
- (uint64)(p->trapframe), PTE_R | PTE_W) < 0){
- uvmunmap(pagetable, TRAMPOLINE, 1, 0);
- uvmfree(pagetable, 0);
- return 0;
- }
- return pagetable;
- }
- // Free a process's page table, and free the
- // physical memory it refers to.
- void
- proc_freepagetable(pagetable_t pagetable, uint64 sz)
- {
- uvmunmap(pagetable, USYSCALL, 1, 0); //释放/删除USYSCALL对应的映射
- uvmunmap(pagetable, TRAMPOLINE, 1, 0);
- uvmunmap(pagetable, TRAPFRAME, 1, 0);
- uvmfree(pagetable, sz);
- }
复制代码 Print a page table (简单)
这个lab要求我们实现一个打印页表的函数,同时也能帮助我们理解xv6当中,页表是如何实现的。在本次实验前,这门课程的作者已经将kpgtbl()这个系统调用添加到内核当中了,现在我们要做的就是完善kernel/vm.c当中的vmprint()函数,这个函数接收一个pagetable_t(页表类型)的参数。
xv6当中的页表是怎样的?
零、专业词汇阐述
名称含义VA虚拟地址(CPU 使用)PTE页表项(映射 + 权限)PA物理地址(RAM 索引)PPN物理页号(PA 的高位) 一、虚拟地址va的结构和xv6当中的三级页表
根据本课程对应的课本xv6 book 当中的第三章,我们可以得知在xv6当中,虚拟地址va的位数为64位,并且我们只使用低39位,高25位用于扩展。相信你在看到这里时肯定学过操作系统这门课程,在任何一本操作系统的教科书当中,对于虚拟地址va的构成的描述都是低n位是页内偏移地址,用于定位某页内的页表项,剩下的高位都是索引,用于定位到某一页。
在xv6当中,页表的每页大小为4096B,每个页表项(PTE)的大小为8B,所以一个页表的当中有4096/8 = 512个PTE。所以39位的虚拟地址va当中,低12位为页内偏移量,省下的27位用于索引页表。
xv6采用三级页表,也就是说27位的索引地址,每9位构成一个层级,类似一个树。以下内容是39位虚拟地址的构成。
PS:(床图网站随时可能失效,所以下面我尽量使用文字来进行描述)。- |VPN[2] | VPN[1] | VPN[0]|页内偏移|
- 9 9 9 12 共39位
- 一级索引 二级索引 三级索引 页内偏移量 总位数
- 根 叶子
复制代码 寻址时,先访问VPN[2]当中的某个PTE,该PTE指向VPN[1],之后从VPN[1]中选取新的PTE,再次通过新的PTE寻址VPN[0],用VPN[0]获得最终的PTE后即可获得PNN(物理页号)。最后通过对PNN操作得到PA(物理地址)。整个过程类似寻找树的叶子结点那样,一层一层向下寻找。
二、为什么xv6采用三级页表?
进程在创建之初,必须且至少拥有一个页表。
如果采用一级页表设计,为了满足这一必须的条件,操作系统必须一次性分配一张覆盖整个虚拟地址空间的页表,即使进程只使用其中极小的一部分(大部分内存空间会浪费掉),也必须遵守该规定。
而在采用三级页表的设计中,进程创建时只需要分配一个 4KB 的根页表页,其余页表页在虚拟地址空间被实际使用时才按需分配。
二、PTE的内容
已知每个PTE的大小为8B,即一共64位。其中低10位(90位)为flags(权限位/标记),剩下的高位(5310位共44bit)为PNN(物理页框号,分配内存之时,OS从空闲页框表当中的表头取下来的)。最后的10位(63~54位)暂时未用,置为0。
flags的内容:
位含义V是否有效R可读W可写X可执行U用户可访问A/D硬件访问/修改标记 当一个 PTE 的 R/W/X 任一位为 1 时,该 PTE 是叶子结点,指向真实物理页;
若 R/W/X 全为 0 且 V=1,则该 PTE 指向下一级页表。
页表的本质除了指明虚拟地址映射到哪里外,还可以决定这个地址是否可读,是否可写,是否可执行,是否可用户态访问/执行。
三、PNN如何转为物理地址
xv6当中规定物理地址的位数为56位,由PNN和va的低12位拼接而成,具体操作手法如下:
1、首先讲PTE右移10位,这样低10位的flags会消失。
2、之后讲PTE左移12位,这样低12位的空白正好可以由虚拟地址的低12位偏移量进行填补。
3、我们现在需要将虚拟地址va的第12位进行填补,所以我们将va和0xFFF相与,这样va就只剩下了第12位的偏移量。
4、将PTE和va相加或着进行“逻辑或”操作,这样就拼接好了一个完整的物理地址。
注意:在xv6当中,以上的操作都有着对应的宏,在编码时可以直接使用宏操作。
该lab的实现和代码相关内容
一、个人的解析和官网提示
- 打印格式:第一行显示 vmprint 的参数。之后,每个页表项(PTE)对应一行,包括那些指向树中更深层次页表页的页表项。每个页表项行都缩进若干个 “..”,以表示其在树中的深度。每个页表项行都会显示其虚拟地址、页表项位以及从该页表项中提取的物理地址。不要打印无效的页表项。
- 在kernel/riscv.h的文件末尾,有关于va转pa的宏。
- freewalk这个函数也许会带来启发。
- 在printf调用中使用%p,以官网上示例所示的方式打印完整的64位十六进制页表项(PTE)和地址。
二、代码相关内容- ##在kernel/vm.c文件内:
- static void
- vmprint_walk(pagetable_t pagetable, int level, uint64 va){
- //每个页表521个PTE
- for(int i = 0; i < 512; i++){
- pte_t pte = pagetable[i];
- // pte有效 并且 V位为1则不是叶子结点
- if((pte & PTE_V) == 0)
- continue;
- // 将传入的PA物理地址(此时PA第12位为空)和偏移量相加合并为完整的物理地址
- uint64 newva = va | ((uint64)i << (12 + 9 * level));
- // 打印层级, depth = 2 - level
- for(int d = 0; d < 2 - level; d++)
- printf(" ..");
- printf("%p: pte %p pa %p\n",
- (void*)newva,
- (void*)pte,
- (void*)PTE2PA(pte));
- // 不是叶子结点则向下递归
- if((pte & (PTE_R | PTE_W | PTE_X)) == 0){
- // PTE2PA是将pte转为了物理地址PA(此时低12位为空)
- pagetable_t child = (pagetable_t)PTE2PA(pte);
- vmprint_walk(child, level - 1, newva);
- }
- }
- }
- #if defined(LAB_PGTBL) || defined(SOL_MMAP) || defined(SOL_COW)
- void
- vmprint(pagetable_t pagetable) {
- // your code here
- // 打印第一行,之后递归进行遍历
- printf("page table %p\n", pagetable);
- vmprint_walk(pagetable, 2, 0);
-
- }
- #endif
复制代码 5、修改mappages函数。- |VPN[2] | VPN[1](包含VPN[0])|页内偏移|
- 9 9 9 12 共39位
- 一级索引 二级索引 页内偏移量 总位数
- 根 叶子
- ===============================
- level-2 (512GB)
- |
- level-1 (2MB) ← ★ superpage 在这里(第一层)
- |
- level-0 (4KB) ← 普通的页面在这里(第0层)
复制代码 8、添加uvmcopy函数。- struct {
- struct spinlock lock;
- struct run *freelist;
- struct run *superfreelist; // 仿照上面的freelist
- } kmem;
- void
- freerange(void *pa_start, void *pa_end)
- {
- char *p;
- p = (char*)PGROUNDUP((uint64)pa_start);
- #ifndef LAB_PGTBL
- for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
- kfree(p);
- #else
- int superpg_num = 10;
- // 计算超级页的起始地址,从 pa_end 向下对齐到超级页边界
- char *superp = (char*)SUPERPGROUNDUP((uint64)pa_end - superpg_num * SUPERPGSIZE);
- // 先释放普通页面部分
- for(; p + PGSIZE <= superp; p += PGSIZE)
- kfree(p);
- // 再释放超级页部分
- for(; superp + SUPERPGSIZE <= (char*)pa_end; superp += SUPERPGSIZE)
- superfree(superp);
- #endif
- }
- #ifdef LAB_PGTBL
- // 超级页释放函数
- void
- superfree(void *pa)
- {
- struct run *r;
- // 参数验证:确保 pa 对齐到超级页大小且在合法内存范围内
- if(((uint64)pa % SUPERPGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
- panic("superfree");
-
- memset(pa, 1, SUPERPGSIZE);
- r = (struct run*)pa;
- //加锁
- acquire(&kmem.lock);
- r->next = kmem.superfreelist;
- // 将超级页插入空闲链表头部
- kmem.superfreelist = r;
- //解锁
- release(&kmem.lock);
- }
- // 超级页分配函数
- void *
- superalloc(void)
- {
- struct run *r;
- acquire(&kmem.lock);
- // 从空闲链表中取出一个超级页
- r = kmem.superfreelist;
- if(r)
- kmem.superfreelist = r->next;
- release(&kmem.lock);
- if(r)
- memset((char*)r, 5, SUPERPGSIZE);
- // 返回分配的超级页地址
- return (void*)r;
- }
- #endif
复制代码 写在后面
这一lab,尤其是最后的用户页表lab确实非常难,一开始花费了好长时间都没做出来,好在网络上有很多大佬对该lab进行了讲解和提供了成品代码,使得本人在后续的研究中才得以明白该lab的底层逻辑。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |