【xv6操作系统】页表与写时拷贝解析及相关实验设计
页表
页表概念
deepseek说:
页表(Page Table)是操作系统中用于实现虚拟内存管理的核心数据结构,它负责将进程的虚拟地址(Virtual Address)转换为实际的物理地址(Physical Address)。通过分页机制,操作系统能够更高效、安全地管理内存资源。
1. 页表的核心作用:
-
地址映射: 每个进程“看到”的是一个连续的虚拟地址空间(如0x0000 ~ 0xFFFF)。实际上,物理内存可能分散且不连续。页表记录了 虚拟页号 → 物理页框号 的映射关系,由CPU的 内存管理单元(MMU) 自动完成地址转换。
-
内存保护: 页表中的每个条目(页表项)包含权限标志位,如:
- 读/写(R/W):保护进程无法误操作只读数据或代码段
- 用户/内核(U/S):阻止用户程序访问内核内存(避免崩溃或攻击)
- 存在位(Present Bit):标记该页是否已加载到物理内存
-
简化内存管理: 以分页的方式管理内存,支持动态内存分配和释放。当某个虚拟页未在物理内存中时,触发触发缺页中断(Page Fault),操作系统会从磁盘(如Swap分区)加载所需页面到内存,并更新页表。这使得物理内存成为磁盘的缓存,突破了内存容量限制。
-
实现虚拟内存: 页表是虚拟内存管理的基础支持。借助页表,操作系统能够将程序需要的内存部分保存在磁盘上(例如交换空间),并在需要时动态加载到内存中,从而使得程序可以使用更大的内存空间,提升系统的扩展能力和性能。
虚拟内存和物理内存通常被划分为固定大小的块,称为“页”(Page)和 “页框号”(Frame)。每个页面由相同数量的连续字节组成,常见的页面大小有4KB、8KB等。
2. 页表的结构: 页表主要包含每个 虚拟页号(VPN) 对应的 物理页框号(PPN) 及一些控制信息,以下是页表的主要组成部分:
- 页表项(PTE):每一项记录了一个虚拟页和一个物理页的映射关系,通常包含:
- 物理页框号(PPN): 指向物理内存中的页面框。
- 有效位: 指示该页是否在内存中有效。
- 访问权限: 指示对该页的访问权限(如可读、可写、可执行等)。
- 其他信息: 如引用位、修改位等,用于页面置换算法。
3. 地址转换过程:在访问内存时,CPU会将虚拟地址划分为页号和页内偏移(offset),具体步骤如下:
- 获取虚拟页号: 从虚拟地址中提取页号部分。
- 查找页表: 使用虚拟页号查询相应的页表项,获取物理页框号。
- 构建物理地址: 将物理页框号与原虚拟地址中的页内偏移结合,生成物理地址,访问实际的内存。
4. 多级页表: 由于页表可能非常大,操作系统通常采用多级页表结构,分层存储以减少内存占用。例如,二级或三级页表可以将页表分解为多个层次,只有在需要时才分配内存。
5. TLB(Translation Lookaside Buffer): CPU缓存频繁使用的页表项,加速地址转换。若TLB命中,省去多级页表访问步骤,TLB 的主要优势如下:
- 提高地址转换速度: 通过缓存频繁使用的页表项,减少了内存访问延迟。
- 降低内存带宽消耗: 减少对页表的访问频率,减轻内存负担。
- 提高系统性能: 尤其在涉及大量内存访问的应用程序中,TLB 的使用显著提高了系统的整体性能。
整理一些基本名词,不熟悉的先留个印象,后续都比较重要
- 页表page table: 记录从VPN→PPN的映射关系表
- 页表项 PTE: 页表中的每一项,具体内容如上述中的解释
- 虚拟页号VPN: virtual page number
- 物理页号PPN/PFN: physical page number
- 页内偏移量offset: 偏移量的位数决定了一页的大小,一般是12位,即4KB
- 虚拟地址va: virtual address相当于VPN与offset的组合
- 物理地址pa: physical address相当于PFN/PPN与offset的组合
- 物理页帧: page frame

xv6页表
- XV6基于Sv39 RISC-V运行,这意味着它只使用64位虚拟地址的低39位,高25位不使用。虚拟地址的前27位用于索引页表,页表在逻辑上视作由 227个 页表条目(PTE) 组成的数组,每个PTE中包含一个44位的物理页码(PPN),以及一些控制和状态的标志位。
- 地址转换过程:处理一个虚拟地址时,使用该虚拟地址的前27位在页表中查找对应的PTE后。找到PTEPTE中的44位PPN与虚拟地址的低12位组合(即页内偏移),生成一个 56位的物理地址
- 页表允许操作系统控制虚拟地址到物理地址的映射,并且以 4096字节(4KB)的页 为单位进行转换和管理,以 4096(212)字节的对齐块的粒度控制虚拟地址到物理地址的转换


但实际在Sv39中,页表是一个三级树型结构,根页表是这棵树的根节点,它是一个4KB(4096字节)的页,每个页有512个PTE(每个PTE大小是8个字节(64位),总共可以有512个条目,4096字节: 8字节)。每个PTE记录了下一级页表的位置(也就是下一级页表的物理地址)。虚拟地址使用39位,其中的前27位被用来在三级页表结构中进行查找。在找到第三级的PTE之后,PTE中会有物理页号(PPN),这个物理页号提供了物理地址的高44位,虚拟地址的最后12位作页内偏移。实际的转换如下图所示:

每一页页表的属性主要包含以下标志位:
PTE_V:
是否预配置 PTEPTE_R:
是否可读PTE_W:
是否可写PTE_X:
是否可执行
页表是分页机制的关键部分,负责记录虚拟页到物理页的映射关系;操作系统负责对页表进行配置
xv6 TLB
- 对于一个虚拟内存地址的寻址,需要读三次内存,代价有点高。实际中,几乎所有的处理器都会对最近使用过的虚拟地址的翻译结果有缓存。这个缓存被称为:Translation Lookside Buffer,TLB。这就是Page Table Entry的缓存,也就是PTE的缓存。
- 当处理器第一次查找一个虚拟地址时,硬件通过3级page table得到最终的PPN,TLB会保存虚拟地址到物理地址的映射关系。这样下一次访问同一个虚拟地址时,处理器可以査看TLB,TLB会直接返回物理地址,而不需要通过page table得到结果。
- 如果切换了page table,操作系统需要告诉处理器当前正在切换page table,处理器会清空TLB。
内核地址空间
xv6为每个进程维护一个页表,用以描述每个进程的用户地址空间,外加一个单独描述内核地址空间的页表。内核配置其地址空间的布局,以允许自己以可预测的虚拟地址访问物理内存和各种硬件资源。
- 包括从物理地址0x80000000开始并至少到0x86400000结束的RAM(物理内存),xv6称结束地址为
PHYSTOP
- 操作系统启动时,会从地址0x80000000开始运行,左侧低于PHYSTOP的虚拟地址,与右侧使用的物理地址是一样的(直接映射),直接映射简化了读取或写入物理内存的内核代码
有几个内核虚拟地址不是直接映射:
- 蹦床页面(
trampoline
page)。它映射在虚拟地址空间的顶部;用户页表具有相同的映射。一个物理页面(持有蹦床代码)在内核的虚拟地址空间中映射了两次:一次在虚拟地址空间的顶部,一次直接映射。 - 内核栈页面。每个进程都有自己的内核栈,它将映射到偏高一些的地址,这样xv6在它之下就可以留下一个未映射的保护页(
guard page
)。保护页的PTE是无效的(也就是说PTE_V没有设置),所以如果内核溢出内核栈就会引发一个异常,内核触发panic。如果没有保护页,栈溢出将会覆盖其他内核内存,引发错误操作。恐慌崩溃(panic crash)是更可取的方案。(注:Guard page不会浪费物理内存,它只是占据了虚拟地址空间的一段靠后的地址,但并不映射到物理地址空间。)
实验1:加速系统调用(easy)
- 通过在用户空间和内核之间共享只读区域的数据来加速某些系统调用,消除在执行这些系统调用时需要进行内核切换的必要性
- 当每个进程被创建时,在USYSCALL(在memlayout.h中定义的虚拟地址)处映射一个只读页面。在该页面的开头,存储一个struct usyscall(同样在memlayout.h中定义),并初始化它以存储当前进程的PID。
- 本实验核心在于对页的布局、分配与创建及释放的理解与程序实现
实验要求:实现优化getpid(),创建一个页 将p->pid存入新的页中
1. 创建每个进程后,在 USYSCALL(在 memlayout.h 中定义的 VA)中映射一个只读页面,内存分配布局图
- 其中
MAXVA
定义如下:9 + 9 + 9 +12表示三级页表 + 12位偏移 啊#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

2. 在 proc.c/proc_pagetable
中对新插入的SYSCALL页表进行映射,并设置只读权限,分配一个只读页
-
模仿 TRAMPOLINE 页与 TRAPFRAME 页进行页表映射,并在进程结构体中添加页usyscall页
-
注意的是这里不能只设置PTE_R位,因为用户需要访问该物理页,所以还需要设置PTE_U位。
// Create a user page table for a given process, // with no user memory, but with trampoline pages. pagetable_t proc_pagetable(struct proc *p) { ... // map the trapframe just below TRAMPOLINE, 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; } // 分配USYSCALL页 if(mappages(pagetable, USYSCALL, PGSIZE,(uint64)(p->usyscall) , PTE_R | PTE_W | PTE_U) < 0){ uvmunmap(pagetable, TRAMPOLINE, 1, 0); uvmunmap(pagetable, TRAPFRAME, 1, 0); uvmfree(pagetable, 0); return 0; } return pagetable; }
3. 在 proc.c/allocproc
函数中为新的syscall页申请内存空间和初始化,同样仿照 TRAPFRAME
页进行申请内存空间和初始化,并把进程的 pid 放到 syscall 页中去
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc*
allocproc(void)
{
struct proc *p;
struct usyscall u;
...
found:
p->pid = allocpid();
p->state = USED;
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Allocate a usyscall page.
if((p->usyscall = (uint64)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// 把进程pid放进usyscall页
u.pid = p->pid;
*(struct usyscall*)(p->usyscall) = u;
// An empty user page table.
...
}
4. 释放内存
-
释放页表
static void freeproc(struct proc *p) { if(p->trapframe) kfree((void*)p->trapframe); p->trapframe = 0; // 释放usyscall页 if(p->usyscall) kfree((void*)p->usyscall); p->usyscall = 0; if(p->pagetable) proc_freepagetable(p->pagetable, p->sz); p->pagetable = 0; ... }
-
取消映射
// kernel/proc.c // Free a process's page table, and free the physical memory it refers to. void proc_freepagetable(pagetable_t pagetable, uint64 sz) { uvmunmap(pagetable, TRAMPOLINE, 1, 0); uvmunmap(pagetable, TRAPFRAME, 1, 0); uvmunmap(pagetable, USYSCALL, 1, 0); uvmfree(pagetable, sz); }
总结:通过在用户空间和内核之间共享只读的 usyscall
页面,可以加速某些系统调用并减少内核切换的必要性。具体实现包括定义 struct usyscall
,分配内存和初始化 usyscall
页面,映射该页面到用户空间的 USYSCALL
地址,并在需要时直接访问该页面中的信息。这样可以提高系统调用的效率,减少上下文切换的开销。
实验2:打印三级页表(easy)
- 该实验需要实现一个打印页表内容的函数,以题目所示的格式打印传进的
页表。 - 在Sv39模式下,页表是一个三级树型结构,根页表是这棵树的根节点它是一个4KB(4096字节)的页,每个页有512个PTE,每个PTE记录了下级页表的位置(也就是下一级页表的物理地址,最后一级页表的PTE指向的是最终映射的物理地址)
- 需要模拟查询页表的过程,对三级页表进行遍历并打印。在虚拟内存相的 kernel/vm.c 中的 freewalk()函数已经实现了递归遍历页表并将其释放,所以只要模仿其逻辑实现打印功能即可。代码:
void print_page(pagetable_t pagetable, int level) { // there are 2^9 = 512 PTEs in a page table. // 遍历页表项 for(int i = 0; i < 512; i++) { pte_t pte = pagetable[i]; // 如果页表项存在,并且可读,可写可执行 if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0) { uint64 child = PTE2PA(pte); for (int i = 0; i < level; i++) { printf(".. "); } printf(".."); printf("%d pte: %p pa:%p\n", level, pte, child); print_page((pagetable_t)child, level+1); } else if(pte & PTE_V) { uint64 child = PTE2PA(pte); for (int i = 0; i < level; i++) { printf(".. "); } printf(".."); printf("%d pte: %p pa:%p\n", level, pte, child); } } } void vmprint(pagetable_t pagetable) { printf("page table %p\n", *pagetable); print_page(pagetable, 0); }
- 记得在defs.h中添加其函数声明
void vmprint(pagetable_t);
,并在 exec.c:execreturn argc;
之前插入代码//kernel/defs.h void vmprint(pagetable_t);
// kernel/exec.c-exec() if(p->pid==1) vmprint(p->pagetable); return argc; // this ends up in a0
实验3:检测已访问的页表(hard)
- 为 xv6 添加一个新功能,该功能通过检查 RISC-V 页表中的访问位来检测并报告这一信息给用户空间
- 检测多个页是否被访问,返回多个页被访问情况的位掩码
检测页访问流程图如下图左,返回值是一个按位表示,如下图右
sys_pgaccess
函数实现流程图如下:

程序实现:
-
主逻辑实现
// sysproc.c/sys_pgaccess int sys_pgaccess(void) { uint64 addr; int len; int bitmask; // 获取用户参数 if(argaddr(0, &addr) < 0 || argint(1, &len) < 0 || argint(2, &bitmask) < 0) return -1; if(len > 32 || len < 0) return -1; int res = 0; // 核心 计算res struct proc *p = myproc(); for (int i = 0; i < len; i++) { // 获取每个页表的虚拟地址 uint64 va = addr + i * PGSIZE; // 计算虚拟内存va对应的页内的PTE_A的flag是否为1 int abit = pgaccess(p->pagetable, va); res = res | abit << i; } // Copy from kernel to user. if(copyout(p->pagetable, bitmask, (char *)&res, sizeof(res)) < 0) return -1; return 0; }
-
核心检测函数,计算虚拟内存va对应的页内的PTE_A的flag是否为1
// vm.c/pgaccess // 计算虚拟内存va对应的页内的PTE_A的flag是否为1 int pgaccess(pagetable_t pagetable, uint64 va) { pte_t *pte; if(va >= MAXVA) return 0; // 返回页表中PTE的地址 pte = walk(pagetable, va, 0); if(pte == 0) return 0; if(*pte & PTE_A) { *pte = *pte & (~PTE_A); // 清空pte的第六位 return 1; } return 0; }
-
记到定义访问标志位 define PTE_A in kernel/riscv.h,页面已访问表示位,访问后硬件会自动置1,但需要软件清零
实验4:每个进程一个内核页表(hard)
当前xv6操作系统中,在用户态下的每个用户进程都使用各自的用户态页表。一旦进入了内核态(例如系统调用)就会切换到内核态页表。然而这个内核态页表是全局共享的,所有进程进入内核态之后都会共用一个内核态页表,优点减少了内核代码同时方便数据共享。
共享一个内核页表有什么弊端呢?
然而进程可能会意外或恶意地访问其他进程的内核数据。如果一个进程因为 bug 或恶意操作访问了内核中的敏感数据,它可能会影响其他进程或系统的整体稳定性。每次创建或删除进程时,都需要小心更新共享的页表条目,以确保不同进程之间的内存不会冲突或被错误覆盖。这会增加系统的复杂性,并且在多核系统中,这种全局共享的管理会增加同步开销和冲突的可能性。
如果每个进程进入内核态之后,都能有自己独立的内核页表,可以避免很多麻烦,这就是这个实验的目的。

一、创建内核页表
step 1: 在进程的结构体proc中添加一个新的内核页表属性,用来存储进程独享的内核态页表
// kernel/proc.h
struct proc {
...
// A kernel page table for pre process.
pagetable_t kernel_pagetable;
}
step 2: 内核进程需要依赖内核页表内一些固定的映射才能正常工作,例如UART 控制、硬盘界面、中断控制等,创建一个页表并初始化映射,使其他进程也可以创建独享的内核页表。
// kernel/vm.c
// 创建一个页表,并初始化映射
pagetable_t
kvminit_newpgtble()
{
pagetable_t pgtbl = (pagetable_t) kalloc();
memset(pgtbl, 0, PGSIZE);
kvm_map_pagetble(pgtbl);
return pgtbl;
}
// 内核页表映射, 普通进程都可以创建自己的内核页表了
void kvm_map_pagetble(pagetable_t pgtbl)
{
// uart registers
kvmmap(pgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
kvmmap(pgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// CLINT
kvmmap(pgtbl,CLINT,CLINT,0x10000, PTE_R | PTE_W);
// PLIC
kvmmap(pgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
kvmmap(pgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
kvmmap(pgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(pgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}
现在普通进程也可以通过调用 kvm_map_pagetble
函数来创建自己的内核页表了,此时在内核态中就有两种页表:一种是内核进程独享的页表,另一种是其他进程各自独享的页表。所以关于内核页表处理的-些函数需要做一些改动。这样可以创建进程间相互独立的内核页表了。
step 3: 重写虚拟地址与物理地址映射函数,使之可以处理每个内核进程的页表,将虚拟地址翻译成物理地址。
// kernel/vm.c
//kvmpa 将虚拟地址翻译为物理地址(添加第一个参数)
uint64
kvmpa(pagetable_t pgtbl, uint64 va)
{
uint64 off = va % PGSIZE;
pte_t *pte;
uint64 pa;
pte = walk(pgtbl,va,0);
if(pte == 0)
panic("kvmpa");
if((*pte &PTE_V) == 0)
panic("kvmpa");
pa = PTE2PA(*pte);
return pa + off;
}
二、重映射内核栈
原本的 xv6 设计中,所有处于内核态的进程都共享同一个页表,即意味着共享同一个地址空间。由于 xv6 支持多核/多进程调度,同一时间可能会有多个进程处于内核态,所以需要对所有处于内核态的进程创建其独立的内核态内的栈,也就是内核栈,供给其内核态代码执行过程。
在已经添加的新修改中,每一个进程都会有自己独立的内核页表。而现在需要每个进程只访问自己的内核栈,所以可以把每个进程的内核栈映射到各自内核页表的固定位置(不同页表内的同一逻辑地址,指向不同物理内存)
step 1: 取消原先为每个进程分配在共享空间中的内核栈,为了后序映射到各自页表中
// initialize the proc table at boot time.
void
procinit(void)
{
struct proc *p;
initlock(&pid_lock, "nextpid");
initlock(&wait_lock, "wait_lock");
for(p = proc; p < &proc[NPROC]; p++) {
initlock(&p->lock, "proc");
// 取消原有的内核栈映射
// p->kstack = KSTACK((int) (p - proc));
}
}
step 2: 在创建进程时候创建专属的内核页表并重映射内核栈到固定位置
// kernel.c/allocproc
static struct proc*
allocproc(void)
{
......
// An empty user page table.
p->pagetable = proc_pagetable(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// 为新进程创建独立的内核页表,并将内核所需的各种映射添加到新页表上
p->kernel_pagetable = kvminit_newpgtble();
// 分配一个物理页, 作为新进程的内核栈使用
char* pa = kalloc();
if(pa == 0)
panic("allocproc kalloc");
uint64 va = KSTACK((int)0); // 将内核栈映射到固定的逻辑地址上
kvmmap(p->kernel_pagetable, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va; // 记录内核栈的虚拟地址
// 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;
}
现在进程的内核页表就创建完成了,但是进程进入内核态时还是会使用全局的内核进程页表,需要在 kernel/proc.c 中的 scheduler
函数进行相关修改。在调度器将 CPU 交给进程执行之前,加载进程的内核页表到 SATP
寄存器,切换到该进程对应的内核页表。
step 3:在进程执行之前,scheduler 加载进程的内核页表
// kernel.c/proc.c
void
scheduler(void)
{
...
for(;;)
{
// Avoid deadlock by ensuring that devices can interrupt.
intr_on();
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
// Switch to chosen process. It is the process's job
// to release its lock and then reacquire it
// before jumping back to us.
p->state = RUNNING;
c->proc = p;
//切换到进程独立的内核页表
w_satp(MAKE_SATP(p->kernel_pagetable));
sfence_vma(); // 清除快表缓存,刷新TLB, 以确保地址转换表的更改生效
// 调度执行
swtch(&c->context, &p->context);
// 切换回全局内核页表
kvminithart();
// Process is done running for now.
// It should have changed its p->state before coming back.
c->proc = 0;
}
release(&p->lock);
}
}
}
三、资源释放
在进程结束后,应该释放进程独享的页表以及内核栈,回收资源,否则会导致内存泄漏。原本释放内存的函数在 kernel/proc.c中,在此修改。这里按创建的顺序反着来,先释放进程的内核栈,再释放进程的内核页表。
static void
freeproc(struct proc *p)
{
......
p->chan = 0;
p->killed = 0;
p->xstate = 0;
//释放进程的内核栈
void* kstack_pa = (void*)kvmpa(p->kernel_pagetable, p->kstack);
kfree(kstack_pa);
p->kstack = 0;
//不能使用 proc_freepagetable释放页表,因为其不仅会释放页表本身,还会把页表内所有的叶节点对应的物理页释放掉
// 这会导致内核运行所需要的关键物理页被释放,造成内核崩溃。
//递归释放进程独享的页表,释放页表本身所占用的空间,但不释放页表指向的物理页
kvm_free_kernelpgtbl(p->kernel_pagetable);
p->kernel_pagetable = 0;
p->state =UNUSED;
}
注意: 这里不能使用 proc_freepagetable
函数直接释放页表,因为该函数会释放掉内核进程必要的映射,导致内核崩溃。这里释放的只是内核页表中的所有映射,不释放其指向的物理页。因为物理页的资源不是这一个进程独享的,即不同的进程的内核页表都映射同一部分的物理资源,所以不能释放。
递归释放一个内核页表中的所有映射,但是不释放其指向的物理页
// kernel/vm.c: kvm_free_kernelpgtbl
// 递归释放一个内核页表中的所有映射,但是不释放其指向的物理页
void kvm_free_kernelpgtbl(pagetable_t pagetable)
{
for(int i = 0; i < 512; ++i)
{
pte_t pte= pagetable[i];
uint64 child = PTE2PA(pte);
if((pte & PTE_V) && (pte &( PTE_R | PTE_W | PTE_X)) == 0)
{
kvm_free_kernelpgtbl((pagetable_t)child); // 如果该页表项指向更低一级的页表
pagetable[i]=0; // 递归释放低一级页表及其页表项
}
}
kfree((void*)pagetable); // 释放当前级别页表所占用空间
}
实验5:简化copyin / copyinstr (hard)
为了简化 copyin 和 copyinstr 函数,并利用 CPU 的硬件寻址功能来提高效率,我们需要将用户页表中的映射同步到每个进程的内核页表中。这样,内核可以直接使用硬件页表机制来访问用户空间的内存,而不需要手动解析页表。
要实现这样的效果,我们需要在每一处内核对用户页表进行修改的时候,将同样的修改也同步应用在进程的内核页表上,使得两个页表的程序段(0 到 PLIC 段)地址空间的映射同步
1. 页表复制和缩减内存函数
-
首先在 kernel/vm.c 实现一个复制页表函数,将 src 页表的一部分页映射关系拷贝到 dst 页表中。只拷贝页表项, 不拷贝实际的物理页内存
//kernel/vm.c: 将 src 页表的一部分页映射关系拷贝到 dst 页表中。只拷贝页表项, 不拷贝实际的物理页内存 int kvm_copymappings(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz) { pte_t* pte; uint64 pa, i; uint flags; // PGROUNDUP:将地址向上取整到页边界,防止重新映射已经映射的页,特别是在执行growproc操作时 for (i = PGROUNDDOWN(start); i < start + sz; i += PGSIZE) { if((pte = walk(src, i ,0)) == 0) { panic("kvm_copymappings: pte should exist"); } if((*pte & PTE_V) == 0) { panic("kvm_copymappings: page not present in source page table"); } pa = PTE2PA(*pte); //&~PTE U表示将该页的权限设置为非用户页 // 必须设置该权限,因为RISC-V 中内核是无法直接访问用户页的 flags = PTE_FLAGS(*pte) & ~PTE_U; if(mappages(dst, i, PGSIZE, pa, flags) != 0) goto err; } return 0; err: //解除目标页表中已映射的页表项 uvmunmap(dst,PGROUNDUP(start),(i-PGROUNDUP(start)) / PGSIZE, 0); return -1; }
-
再实现一个缩减内存函数,用于内核页表和用户页表内存映射的同步,
//kernecl/vm.c: 与 uvmdealloc 功能类似,将程序内存从 oldsz 缩减到 newsz, 但是不释放实际内存 uint64 kvm_dealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz) { if(newsz >= oldsz) return oldsz; if(PGROUNDDOWN(newsz) < PGROUNDDOWN(oldsz)) { int num_pages = (PGROUNDUP(oldsz) - PGROUNDDOWN(newsz)) / PGSIZE; uvmunmap(pagetable, PGROUNDDOWN(newsz), num_pages, 0); } return newsz; }
2. 处理内存映射捏的冲突
- xv6内核中,用于映射程序内存的地址范围是[0,PLIC),需要把进程的程序内存映射到其内核页表的这个范围。 在xv6手册中,可以看到这个范围中有一个
CLINT
(核心本地中断器)的映射,这个映射和刚才说的程序内存映射有冲突了。

-
不过在手册中也可知,CLINT映射只在内核启动的时候需要使用,在内核态的用户进程并不需要使用这个映射。所以可以在上一个实验中的
kvm_map_pagetble
函数中修改一下,把CLINT这个映射去掉:// kervel/vm.c // 内核页表映射, 普通进程都可以创建自己的内核页表了 void kvm_map_pagetble(pagetable_t pgtbl) { ...... // virtio mmio disk interface kvmmap(pgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W); // CLINT // kvmmap(pgtbl,CLINT,CLINT,0x10000, PTE_R | PTE_W); // PLIC kvmmap(pgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W); ...... }
-
这样进程的内核页表中就不会有程序内存映射和CLINT映射冲突的问题了。但是这个映射是内核启动所必须的,所以可以在全局内核页表初始化中加上这个映射:
// kernel/vm.c // 初始化一个内核页表 // Initialize the one kernel_pagetable void kvminit(void) { // kernel_pagetable = kvmmake(); // 全局内核页表仍然使用kvminit函数来初始化 kernel_pagetable = kvminit_newpgtble(); //全局内核页表仍需要映射 CLINT kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W); }
-
接下来在kerne1/exec.c 中的exex()中加入检查,防止程序内存超过 PLIC:
int
exec(char *path, char **argv)
{
......
// Load program into memory.
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
if(ph.memsz < ph.filesz)
goto bad;
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
if(sz1 >= PLIC) // 防止程序内存大小超过 PLIC
goto bad;
sz = sz1;
if((ph.vaddr % PGSIZE) != 0)
goto bad;
if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
.......
}
3. 用户态页表修改,同步数据到内核页表: 涉及到用户态页表的修改,都要把相应的修改同步到进程的内核页表中,包括:fork
、exec
、growproc
、userinit
// kernel/proc.c
int
fork(void)
{
......
// Copy user memory from parent to child.
// 加入kvm_copymappings, 将新进程用户页表映射拷贝一份到新进程内核页表中
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0 ||
kvm_copymappings(np->pagetable, np->kernel_pagetable, 0, p->sz) < 0){
freeproc(np);
release(&np->lock);
return -1;
}
np->sz = p->sz;
......
}
// kernel/exec.c
int
exec(char *path, char **argv)
{
......
// Save program name for debugging.
for(last=s=path; *s; s++)
if(*s == '/')
last = s+1;
safestrcpy(p->name, last, sizeof(p->name));
// 清除内核页表中对程序内存的旧映射,然后重新建立映射
uvmunmap(p->kernel_pagetable, 0, PGROUNDDOWN(oldsz) / PGSIZE, 0);
kvm_copymappings(pagetable, p->kernel_pagetable, 0, sz);
// Commit to the user image.
oldpagetable = p->pagetable;
p->pagetable = pagetable;
p->sz = sz;
p->trapframe->epc = elf.entry; // initial program counter = main
p->trapframe->sp = sp; // initial stack pointer
proc_freepagetable(oldpagetable, oldsz);
......
}
// kernel/proc.c
int
growproc(int n)
{
uint sz;
struct proc *p = myproc();
sz = p->sz;
if(n > 0)
{
uint64 newsz;
if((newsz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
return -1;
}
// 内核页表中的映射同步扩大
if(kvm_copymappings(p->pagetable, p->kernel_pagetable, sz, n) != 0){
uvmdealloc(p->pagetable, newsz, sz);
}
sz = newsz;
} else if(n < 0){
sz = uvmdealloc(p->pagetable, sz, sz + n);
// 内核页表中的映射同步缩小
sz = kvm_dealloc(p->kernel_pagetable, sz, sz + n);
}
p->sz = sz;
return 0;
}
// kernel/proc.c
// Set up first user process.
void
userinit(void)
{
......
// allocate one user page and copy init's instructions
// and data into it.
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
// 同步程序内存映射到进程内核页表中
kvm_copymappings(p->pagetable, p->kernel_pagetable, 0, p->sz);
......
}
惰性分配机制
页面错误异常:
- 加载页面错误: 当加载指令访问的虚拟地址找不到对应的物理地址时触发。
- 存储页面错误: 当存储指令访问的虚拟地址找不到对应的物理地址时触发。
- 指令页面错误: 当指令获取的虚拟地址找不到对应的物理地址时触发
这些页面错误信息保存在 RISC-V 的两个寄存器中:
scause:
指示页面错误的类型(加载、存储或指令)stval:
保存无法转换的虚拟地址。
page fault(页面错误) 可以让地址映射关系变得动态起来。通过pagefault,内核可以更新page table。当发生page fault时,内核需要什么样的信息才能够响应page fault:
- 需要出错的虚拟地址,或者是触发page fault的源。当出现page fault的时候,XV6内核会打印出错的虚拟地址,并且这个地址会被保存在STVAL寄存器中。所以,当一个用户应用程序触发了page fault,page fault会使用trap机制,将程序运行切换到内核,同时也会将出错的地址存放在STVAL寄存器中。
- 出错的原因:需要对不同场景的page fault有不同的响应。RlSC-V文档在SCAUSE(Supervisorcause寄存器,保存了trap机制中进入到supervisor mode的原因)寄存器的介绍中,有多个与page fault相关的原因。比如,13表示是因为load引起的page fault; 15表示是因为store引起的page fault; 12表示是因为指令执行引起的page fault。所以第二个信息存在SCAUSE寄存器中,其中总共有3个类型的原因与page fault相关,分别是读、写和指令。ECALL进入到supervisor mode对应的是8。基本上来说,page fault和其他的异常使用与系统调用相同的trap机制来从用户空间切换到内核空间。如果是因为pagefault触发的trap机制并且进入到内核空间,STVAL寄存器和SCAUSE寄存器都会有相应的值
- 触发page fault的指令的地址。作为trap处理代码的一部分,这个地址存放在SEPC(Supervisor Exception Program Counter)寄存器中,并同时会保存在trapframe->epc中
所以,从硬件和XV6的角度来说,当出现了pagefault,有3个极其有价值的信息,分别是:
- 引起page fault的内存地址 STVAL寄存器
- 引起page fault的原因类型 SCAUSE寄存器
- 引起page fault时的程序计数器值,这表明了page fault在用户空间发生的位置
惰性分配
当应用程序请求额外内存时,比如通过 sbrk系统调用增加地址空间内核调整进程的地址空间范围,但会把新地址标记为无效。在应用程序实际访问这些无效地址时,CPU 会因为找不到对应的物理地址而触发页面错误内核捕获到异常,分析错误地址属于之前 sbrk 增加的范围,说明这是惰性分配引发的页面错误,于是内核会分配一个新的物理页面,并将该虚拟地址映射到新页面上,更新页表中的该地址条目为有效状态,并重新执行触发异常的指令。应用程序往往请求比实际需要更多的内存,通过性分配,系统仅在真正使用内存时才进行分配,避免了大量内存浪费。
接下来修改 usertrap 函数,处理缺页异常。r_scause
可以获取异常原因,其中13表示 page load fault
,15表示 page write fault
,stval
表示引发缺页异常的虚拟地址,缺页异常就可以由(rscause() ==13 || rscause() == 15)
表示。
先判断发生错误的虚拟地址是否位于栈空间之上,进程大小之下(虚拟地址从0开始,进程大小可以表示进程的最高虚拟地址),然后为其分配物理内存并添加映射:
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
// kernel/.trap.c
void
usertrap(void)
{
......
else if((which_dev = devintr()) != 0){
// ok
}
// 惰性分配导致的缺页异常
else if(r_scause() == 13 || r_scause() == 15) {
uint64 fault_va = r_stval(); // 获取引发页面错误地址
char* pa = 0; // 分配的物理地址
// 判断fault va是否在进程栈空间之中
if(PGROUNDDOWN(p->trapframe->sp) - 1 < fault_va && fault_va < p->sz && (pa == kalloc()) != 0)
{
// 物理内存映射
memset(pa, 0, PGSIZE);
if(mappages(p->pagetable,PGROUNDDOWN(fault_va),PGSIZE, (uint64)pa,PTE_R | PTE_W | PTE_X | PTE_U)!= 0){
printf("lazy alloc:failed to map page\n");
kfree(pa);
p->killed = 1;
}
}
}
else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
......
}
惰性分配刚开始并未实际分配内存,解除映射关系时应直接跳过这部分内存,不然会导致系统崩溃
//kernel/vm.c
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
......
for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
if((pte = walk(pagetable, a, 0)) == 0)
// panic("uvmunmap: walk"); // 惰性分配、遇到不存在的页表项就跳过
continue;
if((*pte & PTE_V) == 0)
// panic("uvmunmap: not mapped"); // 同上
continue;
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
......
}
懒加载测试与用户测试
写时拷贝
在原始的XV6中,fork函数是通过直接对进程的地址空间完整地复制一份来实现的。但是,拷贝整个地址空间是十分耗时的,并且在很多情况下,程序立即调用 exec 函数来替换掉地址空间,导致 fork 做了很多无用功。
该实验的改进:
-
COW fork()
为子进程创建一个页表,让子进程和父进程都一起映射到父进程的物理页 -
禁止写权限,因为一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因此需要将这里的父进程和子进程的PTE的标志位都设置成只读的
-
当任一进程试图写入其中一个COW页时,CPU将强制产生页面错误。内核页面错误处理程序检测到这种情况,将为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE指向新的页面,将PTE标记为可写。
-
👉
-
当页面错误处理程序返回时,用户进程将能够写入其页面副本。
-
COW fork()
将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放,所以针对这个问题需要有引用计数机制。
页的生命周期进行管理,确保页没有被任何进程使用的时候才释放:
原本的xv6中,一个物理页只会在一个进程中有映射,kalloc用来分配物理页,kfree 用来回收物理页。在COW机制中,一个物理页会在多个进程中有映射,所以要在最后一个映射释放的时候,才真正释放回收该物理页,结合这些,需要实现以下操作:
kalloc
:分配物理页,将其引用数置为1krefpage
:创建物理页的一个新映射的时候,引用数+1kcopy_n_deref
:将原物理页的数据复制到一个新物理页上(引用数为 1),返回得到的新物理页;并将原物理页的引用数 -1kfree
:释放物理页的一个映射,引用数减 1;如果引用数变为 0,则释放回收物理页
实验实现
步骤一: 如何判断获得的 va
是一个 cow_fault,记录每个PTE是否是COW映射,可以使用RISC-V PTE中的 RSW
(reserved for software,即为软件保留的)位来实现此目的。

- 定义 PTE_COW 标志
步骤二: 修改 uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W
标志,并增加内存的引用计数 kaddrefcnt
,该函数在引用计数中涉及。

uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
for(i = 0; i < sz; i += PGSIZE){
if((pte = walk(old, i, 0)) == 0)
panic("uvmcopy: pte should exist");
if((*pte & PTE_V) == 0)
panic("uvmcopy: page not present");
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
// 仅对可写页面设置COW标记
if (flags & PTE_W){
flags = (flags | PTE_COW) & ~PTE_W;
*pte = PA2PTE(pa) | flags;
}
// 直接将pa地址拷贝给new
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
goto err;
}
// 增加内存的引用计数
kaddrefcnt((char*)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
步骤三: 修改 usertrap()
以识别页面错误。当COW页面出现页面错误时,使用kalloc()分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W。

if(r_scause() == 8){
...
}
else if((which_dev = devintr()) != 0){
...
}
else if (r_scause() == 15 || r_scause() == 13) {
// 获取出错的虚拟地址
uint64 va = r_stval();
if(va >= p->sz ||
is_cow_fault(p->pagetable, va) != 0 ||
cow_alloc(p->pagetable, PGROUNDDOWN(va)) == 0)
p->killed = 1;
}
else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
在vm.c最后定义 is_cow_fault
与 cow_alloc
函数,分别用于判断是否为cow_fault以及为创建新的页,注意必要的函数在defs.h中声明
-
is_cow_faul:
判断是否为 cow 错误//判断是否为cow_fault 0是 -1不是 // param: 执行查询的页表 虚拟地址 int is_cow_fault(pagetable_t pagetable, uint64 va) { if(va >= MAXVA) return -1; // 获取页表页 pte_t *pte = walk(pagetable, va,0); //前置判断 if(pte == 0) return -1; if((*pte & PTE_V) == 0) return -1; //判断页表条目的PTE_COW位是否为1 return (*pte & PTE_COW ? 0 : -1); }
-
cow_alloc:
分配内存// 为 cow_fault 分配实际物理内存 // param: 执行查询的页表 虚拟地址 // 分配后va对应的物理地址,如果返回0则分配失败 void* cow_alloc(pagetable_t pagetable, uint64 va) { if(va % PGSIZE != 0) return 0; // 获取页表地址 uint64 pa = walkaddr(pagetable, va); // 获取对应的物理地址 if(pa == 0) return 0; // 获取页表页 pte_t *pte = walk(pagetable, va,0); if (krefcnt((char*)pa) == 1) { // 只剩一个进程对此物理地址存在引用 // 则直接修改对应的PTE即可 *pte |= PTE_W; *pte &= ~PTE_COW; return (void*)pa; } else { // 多个进程对物理内存存在引用 // 需要分配新的页面,并拷贝旧页面的内容 char *mem = kalloc(); if (mem == 0) return 0; // 复制旧页面内容到新页 memmove(mem, (char*)pa, PGSIZE); // 清除PTE_V,否则在mappagges中会判定为remap *pte &= ~PTE_V; // 为新页面添加映射 if(mappages(pagetable, va, PGSIZE, (uint64)mem, (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW) != 0) { kfree(mem); *pte |= PTE_V; return 0; } // 将原来的物理内存引用计数减1 kfree((char*)PGROUNDDOWN(pa)); return mem; } }
步骤四: 在copyout中处理相同的情况,如果是COW页面,需要更换pa0指向的物理地址

while(len > 0){
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
// 处理COW页面的情况
if(cowpage(pagetable, va0) == 0) {
// 更换目标物理地址
pa0 = (uint64)cowalloc(pagetable, va0);
}
if(pa0 == 0)
return -1;
...
}
步骤五:“引用计数” 在 kalloc.c
下修改
-
定义引用计数的全局变量
ref
,其中包含了一个自旋锁和一个引用计数数组,由于ref
是全局变量,会被自动初始化为全0。//定义引用计数的全局变量ref struct ref_stru { struct spinlock lock; int cnt[PHYSTOP / PGSIZE]; // 引用计数 } ref;
-
在
kinit
中初始化ref的自旋锁void kinit() { initlock(&kmem.lock, "kmem"); initlock(&ref.lock, "ref"); // kinit中初始化ref的自旋锁 freerange(end, (void*)PHYSTOP); }
-
修改
kalloc
和kfree
函数,在kalloc中初始化内存引用计数为1,在kfree函数中对内存引用计数减1,如果引用计数为0时才真正删除void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP) panic("kfree"); // 只有当引用计数为0了才回收空间 // 否则只是将引用计数减1 acquire(&ref.lock); if(--ref.cnt[(uint64)pa / PGSIZE] == 0) { release(&ref.lock); r = (struct run*)pa; // Fill with junk to catch dangling refs. memset(pa, 1, PGSIZE); acquire(&kmem.lock); r->next = kmem.freelist; kmem.freelist = r; release(&kmem.lock); } else { release(&ref.lock); } } // 初始化内存引用计数为1, void *kalloc(void) { struct run *r; acquire(&kmem.lock); r = kmem.freelist; if(r){ kmem.freelist = r->next; acquire(&ref.lock); ref.cnt[(uint64)r / PGSIZE] = 1; // 将引用计数初始化为1 release(&ref.lock); } release(&kmem.lock); if(r) memset((char*)r, 5, PGSIZE); // fill with junk return (void*)r; }
-
添加如下2个函数,分别用于获取内存的引用计数与增加内存的引用计数
// 获取内存的引用计数 在 cow_alloc 中获取引用计数判断进程数 int krefcnt(void* pa) { return ref.cnt[(uint64)pa / PGSIZE]; } // 增加内存的引用计数 成功返回0 失败返回 -1 在 uvmcopy 中调用 int kaddrefcnt(void* pa) { if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP) return -1; acquire(&ref.lock); ref.cnt[(uint64)pa / PGSIZE]++; release(&ref.lock); return 0; }
-
修改
freerange
void freerange(void *pa_start, void *pa_end) { char *p; p = (char*)PGROUNDUP((uint64)pa_start); for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) { // 在kfree中将会对cnt[]减1,这里要先设为1,否则就会减成负数 ref.cnt[(uint64)p / PGSIZE] = 1; kfree(p); } }