继上一篇系统调用(Syscall)实验顺利通关后,我们迎来了《操作系统》课程中最令人头秃、也最核心的实验:页表 (Page Table)

XV6 的页表实验要求我们深入理解 RISC-V 架构的 Sv39 分页机制。很多同学在这里被虚拟地址(VA)、物理地址(PA)和页表项(PTE)的互相转换绕晕。本文将带你拨开迷雾,用最清晰的逻辑和满分代码,一次性拿下 vmprintugetpid 两个核心任务!

🧠 一、 核心机制与踩坑预警

在动手写代码之前,必须牢记 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 级页表结构。这对后续的调试极其重要!

完整操作步骤:

  1. 编写核心打印函数 打开 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);
}
  1. 在头文件中声明 打开 kernel/defs.h,在 // vm.c 这一块下面添加声明,让其他内核文件能找到它:

void            vmprint(pagetable_t);
  1. 在 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,无需系统调用!

完整操作步骤:

  1. 给进程结构体增加一块小内存 打开 kernel/proc.h,找到 struct proc,在里面加一个指针记录我们分配给它的特殊页:

struct proc {
  // ... 其他已有代码 ...
  struct usyscall *usyscall; // 新增:指向共享的系统调用数据页
};
  1. 在进程出生时分配物理页 打开 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;
  // 👆 --- 新增代码结束 --- 👆
  1. 建立页表映射 (核心难点) 继续在 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;
  }
  // 👆 --- 新增代码结束 --- 👆
  1. 进程死亡时的收尸工作 (防止内存泄漏) 有借有还,再借不难。找到 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,恭喜你!你已经成功跨越了操作系统中最复杂的内存管理大山,成功理解了指针的终极魔法——页表。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐