前言
一个本硕双非的小菜鸡,备战24年秋招。打算尝试6.S081,将它的Lab逐一实现,并记录期间心酸历程。
代码下载
官方网站:6.S081官方网站
安装方式:
通过 APT 安装 (Debian/Ubuntu)
确保你的 debian 版本运行的是 “bullseye” 或 “sid”(在 ubuntu 上,这可以通过运行 cat /etc/debian_version 来检查),然后运行:
sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu
(“buster”上的 QEMU 版本太旧了,所以你必须单独获取。
qemu-system-misc 修复
此时此刻,似乎软件包 qemu-system-misc 收到了一个更新,该更新破坏了它与我们内核的兼容性。如果运行 make qemu 并且脚本在 qemu-system-riscv64 -machine virt -bios none -kernel/kernel -m 128M -smp 3 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 之后出现挂起
,
则需要卸载该软件包并安装旧版本:
$ sudo apt-get remove qemu-system-misc
$ sudo apt-get install qemu-system-misc=1:4.2-3ubuntu6
在 Arch 上安装
sudo pacman -S riscv64-linux-gnu-binutils riscv64-linux-gnu-gcc riscv64-linux-gnu-gdb qemu-arch-extra
测试您的安装
若要测试安装,应能够检查以下内容:
$ riscv64-unknown-elf-gcc --version
riscv64-unknown-elf-gcc (GCC) 10.1.0
...
$ qemu-system-riscv64 --version
QEMU emulator version 5.1.0
您还应该能够编译并运行 xv6: 要退出 qemu,请键入:Ctrl-a x。
# in the xv6 directory
$ make qemu
# ... lots of output ...
init: starting sh
$
虚拟内存提供了一定程度的间接寻址:内核可以通过将PTE标记为无效或只读来拦截内存引用,从而导致页面错误,还可以通过修改PTE来更改地址的含义。在计算机系统中有一种说法,任何系统问题都可以用某种程度的抽象方法来解决。Lazy allocation实验中提供了一个例子。这个实验探索了另一个例子:写时复制分支(copy-on write fork)。
问题
xv6中的fork()系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程较大,则复制可能需要很长时间。更糟糕的是,这项工作经常造成大量浪费;例如,子进程中的fork()后跟exec()将导致子进程丢弃复制的内存,而其中的大部分可能都从未使用过。另一方面,如果父子进程都使用一个页面,并且其中一个或两个对该页面有写操作,则确实需要复制。
PS:有点问题我回头查查
解决方案
copy-on-write (COW) fork()的目标是推迟到子进程实际需要物理内存拷贝时再进行分配和复制物理内存页面。
COW fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。COW fork()将父进程和子进程中的所有用户PTE标记为不可写。当任一进程试图写入其中一个COW页时,CPU将强制产生页面错误。内核页面错误处理程序检测到这种情况将为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE指向新的页面,将PTE标记为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。
COW fork()将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
一、Implement copy-on write (hard)
您的任务是在xv6内核中实现copy-on-write fork。如果修改后的内核同时成功执行cowtest和usertests程序就完成了。
为了帮助测试你的实现方案,我们提供了一个名为cowtest的xv6程序(源代码位于user/cowtest.c)。cowtest运行各种测试,但在未修改的xv6上,即使是第一个测试也会失败。因此,最初您将看到:
$ cowtest
simple: fork() failed
$
“simple”测试分配超过一半的可用物理内存,然后执行一系列的fork()。fork失败的原因是没有足够的可用物理内存来为子进程提供父进程内存的完整副本。
完成本实验后,内核应该通过cowtest和usertests中的所有测试。即:
$ cowtest
simple: ok
simple: ok
three: zombie!
ok
three: zombie!
ok
three: zombie!
ok
file: ok
ALL COW TESTS PASSED
$ usertests
...
ALL TESTS PASSED
$
这是一个合理的攻克计划:
- 修改uvmcopy()将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W标志。
- 修改usertrap()以识别页面错误。当COW页面出现页面错误时,使用kalloc()分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W。
- 确保每个物理页在最后一个PTE对它的引用撤销时被释放——而不是在此之前。这样做的一个好方法是为每个物理页保留引用该页面的用户页表数的“引用计数”。当kalloc()分配页时,将页的引用计数设置为1。当fork导致子进程共享页面时,增加页的引用计数;每当任何进程从其页表中删除页面时,减少页的引用计数。kfree()只应在引用计数为零时将页面放回空闲列表。可以将这些计数保存在一个固定大小的整型数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,您可以用页的物理地址除以4096对数组进行索引,并为数组提供等同于kalloc.c中kinit()在空闲列表中放置的所有页面的最高物理地址的元素数。
- 修改copyout()在遇到COW页面时使用与页面错误相同的方案。
提示:
- lazy page allocation实验可能已经让您熟悉了许多与copy-on-write相关的xv6内核代码。但是,您不应该将这个实验室建立在您的lazy allocation解决方案的基础上;相反,请按照上面的说明从一个新的xv6开始。
- 有一种可能很有用的方法来记录每个PTE是否是COW映射。您可以使用RISC-V PTE中的RSW(reserved for software,即为软件保留的)位来实现此目的。
- usertests检查cowtest不测试的场景,所以别忘两个测试都需要完全通过。
- kernel/riscv.h的末尾有一些有用的宏和页表标志位的定义。
- 如果出现COW页面错误并且没有可用内存,则应终止进程。
解析
按照提示一步一步实现写时复制。
首先修改uvmcopy() ,需要做的就是不要立即分配子进程的内存(把映射map到物理内存),而是只修改PTE_W和PTE_COW,前者修改为不可写(父、子进程),后者将这一页改为COW页(子进程)。
//vm.c
int
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); //将页面表项pte中的地址转换成物理地址pa
flags = PTE_FLAGS(*pte); //提取页面表项pte中的权限和状态标志
//不分配内存,而是设置标记
//if((mem = kalloc()) == 0)
// goto err;
if (flags & PTE_W) //如果页面是可写的,那么进入条件块
{
flags = (flags | PTE_COW) & (~PTE_W); //子进程PTE_W不可写,PTE_COW置1
*pte = PA2PTE(pa) | flags; //父进程PTE_W为不可写
}
//memmove(mem, (char*)pa, PGSIZE);
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
//kfree(mem);
goto err;
}
// 增加内存的引用计数
kaddrefcnt((char*)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
接下来就是修改trap.c中的usertrap() 函数,利用trap机制来处理这个缺页中断。
当COW页面出现页面错误时,使用kalloc()分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W。
还记得上一个实验中判断号是13或15嘛?其实跟上一个实验中修改usertrap()的很像。
//trap.c
void
trapinithart(void)
{
w_stvec((uint64)kernelvec);
}
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if (r_scause() == 13 || r_scause() == 15) {
// 处理页面错误
uint64 virtual_address = r_stval();
if(PGROUNDDOWN(p->trapframe->sp) > virtual_address && virtual_address >= p->sz)
p->killed = 1;
else if (uvmcowcopy(p->pagetable, va) != 0) //判断页是否是COW页,并且为其实际分配内存
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;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
在kalloc.c中添加数组,定义引用计数的全局变量ref,其中包含了一个自旋锁和一个引用计数数组,由于ref是全局变量,会被自动初始化为全0。
//kalloc.c
struct ref_stru {
struct spinlock lock;
int cnt[PHYSTOP / PGSIZE]; // 引用计数
} ref;
修改kalloc和kfree函数(也在kalloc.c中),在kalloc中初始化内存引用计数为1,在kfree函数中对内存引用计数减1,如果引用计数为0时才真正删除。
//kalloc.c
void
kfree(void *pa)
{
struct run *r;
if(--refNum[(uint64)pa/PGSIZE] > 0)
return;
//当一个内存页被分配给某个进程时,对应的引用计数会增加;当内存页被释放时,引用计数会减少
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
//kalloc.c
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
if(r) {
refNum[(uint64)r/PGSIZE] += 1;
memset((char*)r, 5, PGSIZE); // fill with junk
}
return (void*)r;
}
在kalloc.c中添加几个函数
//用于判断一个页面是否为COW页面:检查一个虚拟地址是否存在于分页表中,并且检查对应的页表项是否有效
int cowpage(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;
return (*pte & PTE_F ? 0 : -1);
}
//用于分配页面,实现写时复制
void* cowalloc(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); // 获取对应的PTE
if(krefcnt((char*)pa) == 1) {
// 只剩一个进程对此物理地址存在引用
// 则直接修改对应的PTE即可
*pte |= PTE_W;
*pte &= ~PTE_F;
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_F) != 0) {
kfree(mem);
*pte |= PTE_V;
return 0;
}
// 将原来的物理内存引用计数减1
kfree((char*)PGROUNDDOWN(pa));
return mem;
}
}
//获取内存的引用计数
int krefcnt(void* pa) {
return ref.cnt[(uint64)pa / PGSIZE];
}
//增加内存的引用计数
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;
}
在kalloc.c中修改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);
}
}
kernel/riscv.h中选取PTE中的保留位定义标记一个页面是否为COW Fork页面的标志位
//riscv.h
// 记录应用了COW策略后fork的页面
#define PTE_F (1L << 8)
修改vm.c的copyout
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if (r_scause() == 13 || r_scause() == 15) {
// 处理页面错误
uint64 virtual_address = r_stval();
if(fault_va >= p->sz
|| cowpage(p->pagetable, fault_va) != 0
|| cowalloc(p->pagetable, PGROUNDDOWN(fault_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;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
最后在defs.h添加函数声明
pte_t* walk(pagetable_t, uint64, int);
总结
这个实验实现了写时分配,是接着上一道的。