6.S081-6缺页异常Page Fault
这一节课,可以帮我们完成2个实验:
题目要求链接:Lab: xv6 lazy page allocation
对应做法链接:6.S081 Lab4 Lazy allocation
题目要求链接:Lab: Copy-on-Write Fork for xv6
对应做法链接:
文章目录
1. page fault都需要什么(处理page fault都需要哪些输入信息)
- (内存)虚拟地址。引发page fault的虚拟地址 (存储在STVAL寄存器)
- 原因(类型)。对不同场景的page fault 有不同的相应。(比如,到底是由于load 还是 store 或者 jump指令出发的)
- 指令地址(PC)。触发page fault的指令的地址。从上节课可以知道,作为trap处理代码的一部分,这个地址存放在SEPC(Supervisor Exception Program Counter)寄存器中,并同时会保存在trapframe->epc中。 - 因为能够恢复因为page fault中断的指令运行是很重要的。所以需要保存出错的PC。
page fault和其他的异常使用与系统调用相同的trap机制来从用户空间切换到内核空间。如果是因为page fault触发的trap机制并且进入到内核空间,STVAL寄存器和SCAUSE寄存器都会有相应的值。
2. 内存分配allocation
(0) sbrk
sbrk是XV6提供的系统调用,它使得用户应用程序能扩大自己的heap。当一个应用程序启动的时候,sbrk指向的是heap的最底端,同时也是stack的最顶端。这个位置通过代表进程的数据结构中的sz字段表示,这里以p->sz表示。
如果看不到下图可以看图片链接
当调用sbrk时,它的参数是整数,代表了你想要申请的字节的数量。
用户程序倾向于申请多于自己需求的内存数量,sbrk的内存申请是eager allocation(一旦调用,就会去申请空间,让用户的heap变大)——但是很多内存可能永远都用不上。
(1) Lazy page allocation
使用lazy allocation——思想:sbrk的时候,do nothing,只是p->sz会增加相应的page数量,但是并没有真正的分配内存。之后的某个时间段,用户如果用到了那部分并没有被真正分配的内存,就会触发page fault,因为这个时候还没有把新的内存映射到page table。——如果我们发现,我们调用的page地址并不存在(page fault),并且这个地址小于p->sz(因为p->sz是目前真正拥有地址 + n ,即理应拥有的page 数量),这个时候我们希望OS可以为我们分配一个内存page,并重新开始执行指令。
过程:
if (page fault) {
if (stack < page fault vm_address < p -> sz) {
page fault handler {
kalloc 1 page and initialize it to 0;
map this page to user page table;
return to the page fault address;
}
}
}
<1> 修改sys_brk() —— 触发page fault
原始代码👇:
uint64
sys_sbrk(void)
{
int addr;
int n;
if(argint(0, &n) < 0)
return -1;
addr = myproc()->sz;
if(growproc(n) < 0)
return -1;
return addr;
}
修改函数,让她不分配内存,只是将p->sz增加n👇
uint64
sys_sbrk(void)
{
int addr;
int n;
if(argint(0, &n) < 0)
return -1;
addr = myproc()->sz;
myproc -> sz = myporc() -> sz + n;
// if(growproc(n) < 0)
// return -1;
return addr;
}
shell 执行echo hi,得到page fault👇(熟悉的panic)
$echo hi
usertrap(): unexpected scause 0x000000000000000f pid = 3
sepc=0x00000000000012a4 stval=0x0000000000004008
panic: uvmunmap: not mapped
-
SCAUSE寄存器 = 15,说明这是一个store page fault
-
pid = 3,这个应该是shell的pid
-
sepc寄存器的值 : 0x12a4
-
出错的虚拟内存地址(stval) 0x4008
page fault是出现在malloc的实现代码中。这也非常合理,在malloc的实现中,我们使用sbrk系统调用来获得一些内存,之后会初始化我们刚刚获取到的内存,在0x12a4位置,刚刚获取的内存中写入数据,但是实际上我们在向未被分配的内存写入数据。
<2> Lazy allocation 的实现:如何处理page fault
首先打开usertrap函数👇(由于我20年曾经做过这个实验,所以可能和原始代码有出入,即else 里面的注释部分):
//
// 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->tf->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->tf->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
// printf("page down:%d\n",PGROUNDDOWN(r_stval()));
// printf("r :%d\n",r_scause());
// int sz = 0;
// while( !(r_stval()>=sz && r_stval()<sz+4096) )
// {
// sz = sz + 4096;
// }
// printf("sz:%d\n",sz);
p->killed = 1;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
在6.S081 附加Lab1 用户执行系统调用的过程(Trap)中已经提到,r_scause() == 8是系统调用,所以这里增加一个trap的处理即r_scause() == 15:此时我们需要一些定制化的处理。
什么处理?
检查p->sz是否大于STVAL,如果大于就分配物理内存。
else if(r_scause() == 15)
{
uint64 va = r_stval();
printf("page fault %p\n", va);
uint64 ka = (uint64)kalloc();
if (ka == 0) {
p -> killed = 1;
} else {
memset((void *)ka, 0, PGSIZE);
va = PGROUNDDOWN(va);
if (mappages(p -> pagetable, va, PGSIZE, ka, PTE_W | PTE_U | PTE_R) != 0) {
kfree((void *)ka);
p -> killed = 1;
}
}
}
增加上面的代码,会分配一个物理page,如果ka == 0,说明没有足够的物理内存,需要kill当前进程。如果有物理内存,首先会将内存内容设置为0,之后将物理内存page指向用户地址空间中合适的虚拟内存地址。具体来说,我们首先将虚拟地址向下取整,这里引起page fault的虚拟地址是0x4008,向下取整之后是0x4000。之后我们将物理内存地址跟取整之后的虚拟内存地址的关系加到page table中。对应的PTE需要设置常用的权限标志位,在这里是u,w,r bit位。
接下来继续echo hi,发现报错信息反而变多了👇。 —— unmap的是之前lazy allocated,但是又还没有用到的地址。——因为都是调用sbrk实现的所以对于这个内存,并没有对应的物理内存。所以在uvmunmap函数中,当PTE的v标志位为0并且没有对应的mapping,这并不是一个实际的panic,这是我们预期的行为。
$ echo hi
page fault 0x0000000000004008
page fault 0x0000000000013f48
panic: uvmunmap: not mapped
// Remove mappings from a page table. The mappings in
// the given range must exist. Optionally free the
// physical memory.
void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 size, int do_free)
{
uint64 a, last;
pte_t *pte;
uint64 pa;
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;){
if((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
if((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
if(PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
if(do_free){
pa = PTE2PA(*pte);
kfree((void*)pa);
}
*pte = 0;
}
}
找到👇(此时不再是panic,而是do nothing)
if((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
可以修改为
if((*pte & PTE_V) == 0)
continue;
接下来,我们再重新编译XV6,并执行“echo hi”。——仍然有page fault,但是我们成功执行了。
$ echo hi
page fault 0x0000000000004008
page fault 0x0000000000013f48
hi
——这就是最基本的lazy allocation的实现。
3. Zero Fill On Demand
(0) 思想 (和大致做法流程)
这是一个很类似于lazy allocation的思想。思想:很多内存都被设置为0,那么我们有很多全0的page table——实际上,我们并不需要分配那么多全0的pagtable,只需要一个就可以了(但是不允许对这个page 进行写操作)。只有当对这个页执行写入(store)指令的时候,进行一定的操作就行了。——当执行store的时候,首先触发page fault,然后申请一个全0的pagetable,并且将它的写flag置为1(允许写)。
(1) 好处 + 坏处
<1>好处 (节省内存,初始化变快)
- 节省内存;
- exec的内容减少了,程序可以启动的更快,因为只需要分配一个全0的pagetable就可以。(当然了write(update)操作会变慢)
<2>坏处 (trap有代价)
-
每次更新的时候,都需要page fault(也就是每次都涉及user mode和kernel mode的切换,user和kernel页表的切换等)
——其实就是trap的代价。
4. Copy On Write Fork
同样类似于Lazy allocation & Zero fill on demand
(1)背景:
当shell执行指令的时候会fork,shell子进程的第一件事就是调用exec,执行我们想要执行的命令。如果fork创建了shell地址空间的完整copy,而exec的第一件事就是丢弃这个空间,这样太浪费了 (前面的课程中讲过,通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。)。
(2) 思想:(具体流程)
-
fork之后,我们直接让父进程的物理内存page被共享 —— 让子进程的PTE指向父进程对应的物理内存page。——为了保证隔离性,可以将父、子进程的PTE标志位都设置成只读。
-
当我们需要更改内存的时候,就会触发page fault (因为现在正在向一个只读PTE的进行写数据的操作)。
-
page fault后的处理:
- page fault之后重新分配一个物理内存page;
- 然后copy page fault对应的物理内存的内容到新建的page;
- 并将新分配的page映射到子进程。
- 注意:这时候新分配的子进程的page和之前父进程的page对应的PTE都设置成可读可写。
(3) 实验链接
6.S081 Lab5 Copy-on-Write Fork for xv6
5. Demand paging
这里的意思是参考前面的lazy的思想,直接OS会加载的text,data区域,以lazy的方式映射到pagetable(如果为被用到,就不去真正分配)。
如果内存不够用了,怎样决定哪个page被换出?
LRU;
是选择dirty-page (一个page只要最近被写过,那么就会是dirty的) 还是 no-dirty-page 被撤回?
选择的是no-dirty-page —— 没被写过,直接去掉map就行,如果被写过,就必须先map回去,写入,然后再删除这个map
6. Memory Mapped Files (mmap)
—— 将一个文件映射到page上,这样可以使用load / store操作这些数据。
mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。mmap在用户空间映射调用系统中作用很大。
mmap (va, len, protection, …)
这里也是用lazy的思想。
学生提问:有没有可能多个进程将同一个文件映射到内存,然后会有同步的问题?
Frans教授:好问题。这个问题其实等价于,多个进程同时通过read/write系统调用读写一个文件会怎么样?
这里的行为是不可预知的。write系统调用会以某种顺序出现,如果两个进程向一个文件的block写数据,要么第一个进程的write能生效,要么第二个进程的write能生效,只能是两者之一生效。在这里其实也是一样的,所以我们并不需要考虑冲突的问题。
的大小之和,最后一个页不被使用的空间将会清零。mmap在用户空间映射调用系统中作用很大。