XV6操作系统实验二(Page Table)满分通关指南:深入理解页表与内存映射
本文详细讲解了XV6操作系统的页表实验核心内容,包括Sv39三级页表机制和系统调用优化。实验分为两个关键任务:1)实现vmprint函数递归打印三级页表结构,通过遍历PTE并判断标志位完成;2)优化getpid系统调用,通过在进程创建时分配USYSCALL共享页面存储PID,建立只读映射提升性能。文章提供了完整的代码实现和关键注意事项,如PTE_V有效性检查、内存释放处理等,帮助读者深入理解虚拟内
继上一篇系统调用(Syscall)实验顺利通关后,我们迎来了《操作系统》课程中最令人头秃、也最核心的实验:页表 (Page Table)。
XV6 的页表实验要求我们深入理解 RISC-V 架构的 Sv39 分页机制。很多同学在这里被虚拟地址(VA)、物理地址(PA)和页表项(PTE)的互相转换绕晕。本文将带你拨开迷雾,用最清晰的逻辑和满分代码,一次性拿下 vmprint 和 ugetpid 两个核心任务!
🧠 一、 核心机制与踩坑预警
在动手写代码之前,必须牢记 XV6 的页表设计规则,否则你的内核会无限重启(Panic)。
踩坑点 1:Sv39 的三级树状页表
RISC-V 的 Sv39 分页机制使用了三级页表结构。一个虚拟地址由 3 个 9 位的索引(Index)和一个 12 位的页内偏移(Offset)组成。 避坑指南: 这意味着你在遍历页表时,必须使用递归或嵌套循环。最高层页表指向中层页表,中层指向底层,底层才真正指向物理内存地址(Physical Address)。
踩坑点 2:PTE标志位 (Flags)
每个页表项(PTE)不仅仅是一个地址,它的最低几位是标志位(如 PTE_V 表示有效,PTE_R 表示可读,PTE_U 表示用户可访问)。 避坑指南: 在顺藤摸瓜往下找的时候,必须先判断 pte & PTE_V 是否为真!如果尝试访问一个无效的页表项,内核会直接崩溃。此外,如果 (pte & (PTE_R|PTE_W|PTE_X)) == 0,说明它指向的是下一级页表;如果不为 0,说明它指向的是最终的物理页。
🖨️ 二、 任务一:打印页表 (vmprint)
需求: 写一个 vmprint() 函数。当系统启动并执行第一个用户进程(init)时,打印出该进程完整的 3 级页表结构。这对后续的调试极其重要!
完整操作步骤:
-
编写核心打印函数 打开
kernel/vm.c,在文件最末尾添加递归遍历和打印函数。满分代码如下:
// 辅助递归函数:按层级打印页表
void
_vmprint(pagetable_t pagetable, int level)
{
// 遍历这一层页表的 512 个 PTE
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];
// 如果 PTE 是有效的
if(pte & PTE_V){
// 按层级打印前缀的点点点
for(int j = 0; j <= level; j++){
printf(".. ");
}
// 获取下一级页表或物理页的地址
uint64 child = PTE2PA(pte);
printf("..%d: pte %p pa %p\n", i, pte, child);
// 如果这不是最后一层页表(即不可读、不可写、不可执行),则继续向下递归
if((pte & (PTE_R|PTE_W|PTE_X)) == 0){
_vmprint((pagetable_t)child, level + 1);
}
}
}
}
// 主调函数
void
vmprint(pagetable_t pagetable)
{
printf("page table %p\n", pagetable);
_vmprint(pagetable, 0);
}
-
在头文件中声明 打开
kernel/defs.h,在// vm.c这一块下面添加声明,让其他内核文件能找到它:
void vmprint(pagetable_t);
-
在 exec 中调用 打开
kernel/exec.c,找到int exec(char *path, char argv)函数的末尾(大概在return argc;的上方)。插入以下代码,使得在执行第一个进程init时打印页表:
if(p->pid == 1){
vmprint(p->pagetable);
}
return argc;
⚡ 三、 任务二:加速系统调用 (ugetpid)
需求: 每次调用 getpid() 都要陷入内核态,开销很大。实验要求我们在每个进程创建时,分配一个只读的共享页面(映射到固定的虚拟地址 USYSCALL),把 PID 存进去。这样用户态直接读内存就能获取 PID,无需系统调用!
完整操作步骤:
-
给进程结构体增加一块小内存 打开
kernel/proc.h,找到struct proc,在里面加一个指针记录我们分配给它的特殊页:
struct proc {
// ... 其他已有代码 ...
struct usyscall *usyscall; // 新增:指向共享的系统调用数据页
};
-
在进程出生时分配物理页 打开
kernel/proc.c,找到allocproc函数。在里面找到分配p->trapframe的地方,在下面紧接着分配我们的新页面并存入 PID:
// (已有代码) Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// 👇 --- 新增代码开始 --- 👇
// 分配一个 USYSCALL 页面
if((p->usyscall = (struct usyscall *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// 把 PID 写进这块内存里
p->usyscall->pid = p->pid;
// 👆 --- 新增代码结束 --- 👆
-
建立页表映射 (核心难点) 继续在
kernel/proc.c里找proc_pagetable函数。在这里我们要把刚刚那块物理内存,映射到用户态固定的虚拟地址USYSCALL上,权限必须设置为只读和用户态可访问 (PTE_R | PTE_U)!
// (已有代码) map the trapframe...
if(mappages(pagetable, TRAPFRAME, PGSIZE,
(uint64)(p->trapframe), PTE_R | PTE_W) < 0){
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmfree(pagetable, 0);
return 0;
}
// 👇 --- 新增代码开始 --- 👇
// 将物理地址 p->usyscall 映射到虚拟地址 USYSCALL
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;
}
// 👆 --- 新增代码结束 --- 👆
-
进程死亡时的收尸工作 (防止内存泄漏) 有借有还,再借不难。找到
kernel/proc.c中的proc_freepagetable函数,解除映射:
// 解除映射,注意最后一个参数是 0,表示只解除虚拟映射,不释放物理内存
uvmunmap(pagetable, USYSCALL, 1, 0);
最后,找到 freeproc 函数,真正归还物理内存:
if(p->trapframe)
kfree((void*)p->trapframe);
p->trapframe = 0;
// 👇 --- 新增代码开始 --- 👇
if(p->usyscall)
kfree((void*)p->usyscall);
p->usyscall = 0;
// 👆 --- 新增代码结束 --- 👆
🎉 测试与验收
在终端执行编译并运行打分脚本:
make clean
make qemu
# 在 qemu 中运行 pgtbltest
或者直接在主机运行一键打分脚本:
./grade-lab-pgtbl

如果看到满屏的绿色 OK,恭喜你!你已经成功跨越了操作系统中最复杂的内存管理大山,成功理解了指针的终极魔法——页表。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)