Lab 5: Xv6 Lazy Page Allocation

1. 实验介绍

操作系统通过页表可以实现很多有趣的功能,其中一个便是对用户空间堆内存的延迟分配。用户态程序通过 sbrk 系统调用来请求堆内存。 在原始版本的xv6内核中, sbrk 会直接分配物理内存并将其映射到当前进程的虚拟地址空间。要知道,对于一个大内存的请求,物理内存和分配和映射是相当耗时的。 所以,为了让 sbrk 能够更快地完成堆内存的请求,我们将在此实验中,实现xv6 page fault 的特性,从而达到xv6堆内存延迟分配的功能。

2. 代码实现及思路

Eliminate allocation from sbrk()

原始的 sbrk 的实现如下代码所示:

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;
}

可以看到,在 sys_sbrk 中调用了 growproc 来增加或缩减堆内存, growproc 的实现如下:

int growproc(int n)
{
    uint sz;
    struct proc *p = myproc();

    sz = p->sz;
    if(n > 0){
        if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0){
            return -1;
        }
    } else if(n < 0){
        sz = uvmdealloc(p->pagetable, sz, sz + n);
    }
    p->sz = sz;
    return 0;
}

growproc 中,根据堆内存是要增加还是缩减,分别调用 uvmallocuvmdealloc 来实现物理内存的分配或销毁,然后更新页表。 在此实验中,我们只考虑堆内存增加的情况,只增加进程的内存大小,不再调用 growproc 来进行实际的物理内存分配和映射,来观察xv6的运行状态。 由此,我们把 sys_sbrk 中对 growproc 的调用注释掉,直接增加进程的内存空间。

uint64 sys_sbrk(void)
{
    int addr;
    int n;

    if(argint(0, &n) < 0)
        return -1;
    addr = myproc()->sz;
    myproc()->sz = addr + n;
    //if(growproc(n) < 0)
    //    return -1;
    return addr;
}

运行xv6,敲入 echo hi , 出现以下错误:

$ echo hi
usertrap(): unexpected scause 0x000000000000000f pid=3
        sepc=0x00000000000012ac stval=0x0000000000004008
panic: uvmunmap: not mapped

由上述的错误信息可以看到, scause 寄存器的值为 0xf ,查询 riscv手册 可以看到其对应的是 store page faultpid=3 意味着发生错误的进程号为3。 sepc 对应的是产生错误的执行代码的内存地址,查看 user/sh.asm 可以看到, 在 0x12ac 处的汇编代码是 sw s6,8(a0) ,确实是一个store命令。 stval 则对应的是访存错误的虚拟内存地址, 0x4008 即对应着此处的内存访问触发了 store page fault 的错误。

Lazy allocation

在上个实验中,我们仅仅只是在 sys_sbrk 里改变了进程的内存大小,并没有对出现缺页错误进行处理。 在本实验中,我们将在 usertrap 里针对缺页错误的内存地址分配物理内存,并添加映射关系,从而让程序能够继续正常地运行。

根据前两条提示,我们需在 usertrap 里对 r_scause() 添加新的判断条件,即当其为13 load page fault 和15 store page fault 时,我们进行缺页处理。 在缺页处理中,我们参考 uvmalloc 函数,为发生缺页错误的虚拟地址分配 PGSIZE 的物理内存,并将新分配的物理内存地址与虚拟内存地址进行映射。 代码的实现如下:

void usertrap(void)
{
    // omit the unimportant code
    if(r_scause() == 8){
        //...
    } else if((which_dev = devintr()) != 0){
        // ok
    } else if(r_scause() == 13 || r_scause() == 15){
        // code to handle load/store page fault
        uint64 va = r_stval();
        printf("faulting va addr: %p\n", va);
        // allocate one physical page
        char *mem = kalloc();
        if(mem == 0)
            panic("usertrap: no more physical mem!");
        // zero the physical page
        memset(mem, 0, PGSIZE);
        // add the mappings
        if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_U) != 0){
                    kfree(mem);
                    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;
    }
}

再次运行xv6,敲入 echo hi , 出现以下错误:

$ echo hi
faulting va addr: 0x0000000000004008
faulting va addr: 0x0000000000013f48
panic: uvmunmap: not mapped

可以看到,程序打印出了对应发生缺页错误的虚拟地址,但是出现了 uvmunmap: not mapped 的错误。在 kernel/vm.c 里查看 uvmunmap 的定义,我们可以看到,函数会释放 p->sz 中所有的页表及物理内存。 所以当我们使用延迟堆内存分配的策略时,对应必然会有在 p->sz 虚拟地址空间内的虚拟地址没有与物理内存映射的情况。在 uvmunmap 中,我们只需忽略上述情形即可。修改后的 uvmunmap 如下所示:

void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
    uint64 a;
    pte_t *pte;

    if((va % PGSIZE) != 0)
        panic("uvmunmap: not aligned");

    for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
        if((pte = walk(pagetable, a, 0)) == 0)
            panic("uvmunmap: walk");
        if((*pte & PTE_V) == 0)
            continue;
        if(PTE_FLAGS(*PTE) == PTE_V)
            panic("uvmunmap: not a leaf");
        if(do_free){
            uint64 pa = PTE2PA(*pte);
            kfree((void*)pa);
        }
        *pte = 0;
    }
}

再次运行xv6, 敲入 echo hi 命令,却得到以下错误:

$ echo hi
faulting va addr: 0x0000000000004008
faulting va addr: 0x0000000000013f48
panic: freewalk: leaf

检查代码逻辑,没有发现问题。看到实验中的第四条提示,恍然大悟,发生缺页错误的虚拟地址需要在映射前向下圆整,所以 mappages 里的va应该替换成 PGROUNDDOWN(va) 。 再次运行xv6,显示 echo hi 运行正确。

$ echo hi
faulting va addr: 0x0000000000004008
faulting va addr: 0x0000000000013f48
hi

具体实现代码可参考 链接1

最后,我们可以添加 page table 实验中的 vmprint 函数,来对比缺页错误处理前后进程的页表状况。

$ echo hi
page table 0x0000000087f75000
..0: pte 0x0000000021fdc801 pa 0x0000000087f72000
.. ..0: pte 0x0000000021fd9401 pa 0x0000000087f65000
.. .. ..0: pte 0x0000000021fdc05f pa 0x0000000087f70000
.. .. ..1: pte 0x0000000021fd98df pa 0x0000000087f66000
.. .. ..2: pte 0x0000000021fdc40f pa 0x0000000087f71000
.. .. ..3: pte 0x0000000021fd68df pa 0x0000000087f5a000
..255: pte 0x0000000021fdd001 pa 0x0000000087f74000
.. ..511: pte 0x0000000021fdcc01 pa 0x0000000087f73000
.. .. ..510: pte 0x0000000021fd90c7 pa 0x0000000087f64000
.. .. ..511: pte 0x0000000020001c4b pa 0x0000000080007000
page fault: 0x0000000000004008
page table 0x0000000087f75000
..0: pte 0x0000000021fdc801 pa 0x0000000087f72000
.. ..0: pte 0x0000000021fd9401 pa 0x0000000087f65000
.. .. ..0: pte 0x0000000021fdc05f pa 0x0000000087f70000
.. .. ..1: pte 0x0000000021fd98df pa 0x0000000087f66000
.. .. ..2: pte 0x0000000021fdc40f pa 0x0000000087f71000
.. .. ..3: pte 0x0000000021fd68df pa 0x0000000087f5a000
.. .. ..4: pte 0x0000000021fd6417 pa 0x0000000087f59000
..255: pte 0x0000000021fdd001 pa 0x0000000087f74000
.. ..511: pte 0x0000000021fdcc01 pa 0x0000000087f73000
.. .. ..510: pte 0x0000000021fd90c7 pa 0x0000000087f64000
.. .. ..511: pte 0x0000000020001c4b pa 0x0000000080007000
page table 0x0000000087f75000
..0: pte 0x0000000021fdc801 pa 0x0000000087f72000
.. ..0: pte 0x0000000021fd9401 pa 0x0000000087f65000
.. .. ..0: pte 0x0000000021fdc05f pa 0x0000000087f70000
.. .. ..1: pte 0x0000000021fd98df pa 0x0000000087f66000
.. .. ..2: pte 0x0000000021fdc40f pa 0x0000000087f71000
.. .. ..3: pte 0x0000000021fd68df pa 0x0000000087f5a000
.. .. ..4: pte 0x0000000021fd64d7 pa 0x0000000087f59000
..255: pte 0x0000000021fdd001 pa 0x0000000087f74000
.. ..511: pte 0x0000000021fdcc01 pa 0x0000000087f73000
.. .. ..510: pte 0x0000000021fd90c7 pa 0x0000000087f64000
.. .. ..511: pte 0x0000000020001c4b pa 0x0000000080007000
page fault: 0x0000000000013f48
page table 0x0000000087f75000
..0: pte 0x0000000021fdc801 pa 0x0000000087f72000
.. ..0: pte 0x0000000021fd9401 pa 0x0000000087f65000
.. .. ..0: pte 0x0000000021fdc05f pa 0x0000000087f70000
.. .. ..1: pte 0x0000000021fd98df pa 0x0000000087f66000
.. .. ..2: pte 0x0000000021fdc40f pa 0x0000000087f71000
.. .. ..3: pte 0x0000000021fd68df pa 0x0000000087f5a000
.. .. ..4: pte 0x0000000021fd64d7 pa 0x0000000087f59000
.. .. ..19: pte 0x0000000021fd6017 pa 0x0000000087f58000
..255: pte 0x0000000021fdd001 pa 0x0000000087f74000
.. ..511: pte 0x0000000021fdcc01 pa 0x0000000087f73000
.. .. ..510: pte 0x0000000021fd90c7 pa 0x0000000087f64000
.. .. ..511: pte 0x0000000020001c4b pa 0x0000000080007000
hi

可以看到,对于 0x4008 虚拟地址的缺页错误处理后,进程的页表多了一条条目 4: pte 0x0000000021fd6417 pa 0x0000000087f59000 。 对于 0x13f48 虚拟地址的缺页错误处理后,进程的页表多了一条条目 19: pte 0x0000000021fd6017 pa 0x0000000087f58000

Lazytests and Usertests

在本实验中,我们将进一步地完善缺页错误处理的代码,从而能够通过 lazytestsusertests 测试。

根据第一条提示,我们首先处理 sbrk() 传入负数参数的情况。 对于 sbrk 参数n大于0的情况,我们可以采用 lazy allocation 的策略。 但对于n是负数,即缩减堆内存的情况,如果我们采用 lazy deallocation 的策略,可能意味着进程执行过程中访问已经不再是进程 p->sz 的堆内存,但也不会报错。 所以,针对n是负数的情况,我们还是采取原先的策略。 sys_sbrk 的实现如下所示:

uint64 sys_sbrk(void)
{
    int addr;
    int n;
    struct proc *p = myproc();

    if(argint(0, &n) < 0)
        return -1;
    addr = p->sz;
    // if n >= 0, do lazy allocation.
    // if n < 0, do eager deallocation.
    if(n >= 0){
        p->sz = addr + n;
    } else {
        if(growproc(n) < 0)
            return -1;
    }
    return addr;
}

查看 user/lazytests.c ,发现其有三个测试用例:

  • sparse_memory

  • sparse_memory_unmap

  • oom

三个用例可根据xv6命令行参数单独执行,但是每个用例的描述字符串中间有空格,做以下修改:

struct test{
    void (*f)(char *);
    char *s;
} tests[] = {
    { sparse_memory, "lazy_alloc"},
    { sparse_memory_unmap, "lazy_unmap"},
    { oom, "out_of_memory"},
    {0, 0},
};

这样,就可以在xv6里敲入 lazytests lazy_alloc 单独执行第一个测试用例了。

$ lazytests lazy_alloc
lazytests starting
running test lazy_alloc
panic: uvmunmap: walk

执行结果如上,出现 panic: uvmunmap: walk 错误。查看 uvmunmap 的定义,使用 walk 来查询va对应的pte。 但如果我们采用堆内存延迟分配的策略,对应进程里的虚拟地址空间就会出现pte为0的正常情况,此时我们只需忽略此错误,继续遍历虚拟地址即可。

再次执行 lazytests lazy_alloc ,此测试用例通过。

$ lazytests lazy_alloc
lazytests starting
running test lazy_alloc
test lazy_alloc: OK
ALL TESTS PASSED

继续执行第二个测试用例, lazytests lazy_unmap ,出现以下错误:

$ lazytests lazy_unmap
lazytests starting
running test lazy_unmap
panic: uvmcopy: page not present

根据第三条提示,我们可以初步判断问题出现在 fork 中父子进程间的内存拷贝。 查看 uvmcopy ,同理,对应 panic 的部分全部改为 continue ,再次运行测试用例,出现以下错误:

$ lazytests lazy_unmap
lazytests starting
running test lazy_unmap
panic: freewalk: leaf

用gdb调试xv6,并将断点设置在 freewalk 函数的 panic 处,运行测试用例,敲入 bt 查看函数调用栈。

(gdb) bt
#0  freewalk (pagetable=0x87ecc000) at kernel/vm.c:286
#1  0x0000000080001536 in freewalk (pagetable=0x87ecd000) at kernel/vm.c:283
#2  0x0000000080001536 in freewalk (pagetable=pagetable@entry=0x87ed1000) at kernel/vm.c:283
#3  0x0000000080001590 in uvmfree (pagetable=pagetable@entry=0x87ed1000, sz=sz@entry=12288) at kernel/vm.c:299
#4  0x0000000080001c60 in proc_freepagetable (pagetable=0x87ed1000, sz=12288) at kernel/proc.c:195
#5  0x0000000080001c96 in freeproc (p=p@entry=0x80012308 <proc+1440>) at kernel/proc.c:143
#6  0x00000000800023ba in wait (addr=12012) at kernel/proc.c:429
#7  0x0000000080002d4e in sys_wait () at kernel/sysproc.c:38
#8  0x0000000080002c90 in syscall () at kernel/syscall.c:140
#9  0x0000000080002918 in usertrap () at kernel/trap.c:67
#10 0x00000000000000d6 in ?? ()

可以看到上述错误发生在销毁子进程,释放子进程内存空间的过程中。 再次查看 lazytests.c 中第二个测试用例函数 sparse_memory_unmap ,函数在父进程通过 sbrk 增加了 REGION_SZ 的堆内存。 然后基于 PGSIZE * PGSIZE 的步长利用缺页错误对延迟分配的部分堆内存进行赋值和添加映射。 完成上述步骤后,再基于上述步长每次 fork 一个子进程,缩减子进程 REGION_SZ 的堆内存空间,然后再访问子进程从父进程拷贝过来的 REGION_SZ 中之前赋值的堆内存地址。

但因为我们对 sbrk 传入的参数为负数的情况,采取的是 eager deallocation 的策略,所以函数 sparse_memory_unmap 的子进程执行 *(char **)i = i 应该在 usertrap 中做错误处理。 根据第三和第六条提示,我们对函数 usertrap 进行以下修改:

void usertrap(void){
    //...
    else if(r_scause() == 13 || r_scause() == 15){
        uint64 va = r_stval();

        // if va > p->sz or va < user stack,
        // kill the process
        if(va > p->sz || va < 0x3000){
        p->killed = 1;
        goto killed;

    //...
    killed:
        if(p->killed)
            exit(-1);
    }
}

再次执行 lazytests lazy_unmap ,此测试用例通过。

$ lazytests lazy_unmap
lazytests starting
running test lazy_unmap
test lazy_unmap: OK
ALL TESTS PASSED

此时我们已经通过 lazytests 中的前两个测试用例。回头看代码实现,我觉得 usertrap 里关于缺页错误的处理可以单独设计一套API,这样逻辑也会更加清晰。

int is_lazyaddr(uint64 va)
{
    struct proc *p = myproc();
    // va should lower than p->sz but higher than user stack
    if(va < p->sz && va >= p->trapframe->sp)
        return 1;
    else
        return 0;
}

int lazyalloc(struct proc *p, uint64 va)
{
    uint64 ka = (uint64)kalloc();
    if(ka == 0){
        return 0;
    } else {
        memset((void *)ka, 0, PGSIZE);
        if(mappages(p->pagetable, PGROUNDDOWN(va), PGSIZE, ka, PTE_W|PTE_R|PTE_U) != 0){
        kfree((void *)ka);
        return 0;
        }
    }
    return 1;
}

这样,我们在 usertrap 里处理缺页错误就可以调用上述两个函数实现。

void usertrap(void)
{
    //...
    } else if(r_scause() == 13 || r_scause() == 15){
        uint64 va = r_stval();

        if(is_lazyaddr(va) == 1){
            if(lazyalloc(p, va) == 0)
                p->killed = 1;
        } else {
            p->killed = 1;
        }
}

执行 lazytests out_of_memory ,出现以下错误:

$ lazytests out_of_memory
lazytests starting
running test out_of_memory
panic: walk

panic: walk 出现的地方是在 walk 函数中,当va的地址大于 MAXVA 时。我们在 panic 前加一行打印信息,打印出错误的va值。

$ lazytests out_of_memory
lazytests starting
running test out_of_memory
va: 0xffffffff80003000
panic: walk

可以看到错误的va的值为 0xffffffff80003000 ,猜测肯定是地址计算时值出现了溢出错误。 再理解以下 lazytests.c 中的 oom 函数,发现其调用定义在 user/umalloc.c 中的 malloc 函数来分配堆内存。而 malloc 最终调用的还是 sbrk 系统调用。 oom 函数里通过 while((m2 = malloc(4096*4096)) != 0) 不断扩展堆内存,并将前一次的地址赋值给新一次分配的堆内存上。子进程理应在某次访问中出现错误,所以永远不会执行 exit(0) 。 这样父进程捕捉到子进程的退出码不为0,即父进程退出码 exit(xstatus == 0) 也不为0。

理解了 oom 函数的执行逻辑,再结合前面猜测的溢出错误,我们回到 sys_sbrk 中,加入 addraddr + n 的打印信息。

$ lazytests out_of_memory
lazytests starting
running test out_of_memory
addr: 0x000000007f0037f0
addr + n: 0xffffffff80003800
addr: 0xffffffff80003800
addr + n: 0xffffffff81003810
va: 0xffffffff80003000
panic: walk

可以看到, addr 值为 0x7f0037f0 时,加上 n 的值就发生了溢出。这是因为对应 addrn 的值都是 int 类型, 而我们的 p->szva 都是 uint64 类型。 所以我们需对 addr + n 出现溢出的情况进行处理,即返回原始的 addr 的值即可。对应va大于 p->sz 的情况就会在 is_lazyaddr 中得到相应的处理。

uint64 sys_sbrk(void)
{
    //...
    // addr + n might overflow.
    // when it happens, just return the original addr.
    if((addr + n) < 0){
        return addr;
    }
    //...
}

再次执行 lazytests out_of_memory ,此测试用例通过。

$ lazytests out_of_memory
lazytests starting
running test out_of_memory
test out_of_memory: OK
ALL TESTS PASSED

再运行 usertests ,发现 usertests 中的 sbrkarg 出现错误:

$ usertests sbrkarg
usertests starting
test sbrkarg: sbrkarg: write sbrk failed
FAILED
SOME TESTS FAILED

结合题目第四条提示,我们需对 walkaddr 做相应的修改。

uint64 walkaddr(pagetable_t pagetable, uint64 va)
{
    pte_t *pte;
    uint64 pa;

    if(va >= MAXVA)
        return 0;

    // lazy allocation
    if(is_lazyaddr(va)){
        if(lazyalloc(va) == 0)
            return 0;
    }

    pte = walk(pagetable, va, 0);
    if(pte == 0)
        return 0;
    if((*pte & PTE_V) == 0)
        return 0;
    if((*pte & PTE_U) == 0)
        return 0;
    pa = PTE2PA(*pte);
    return pa;
}

同时,对于 is_lazyaddr 函数,我们还需增加va是否会 remap 的判断。

再次运行 usertests ,测试用例全部通过。

具体实现代码可参考此 链接2

实验最终结果

实验最后还需要添加 time.txt 文件记录实验所花费的时间。另外 lazytests.c 中的三个用例的描述需改回同原来一样,评分的脚本是通过字符匹配来确定测试用例通过。

敲入 make grade 命令,可看到实验得分满分。

../_images/lab5_lazy_score.png

3. 实验总结

本次实验卡在了两个问题,一个是 oom 实验中出现的虚拟地址溢出的问题,一个是 usertestssbrkarg 出错的问题。 其中第三部分的第四条提示也让我纠结理解了很久。

Handle the case in which a process passes a valid address from sbrk() to a system call such as read or write, but the memory for that address has not yet been allocated.

这次实验看上去很简单,但实际做起来还是有很多坑,而且最可怕的是调试了半天发现是自己写的坑。还是那句话,魔鬼在细节!