lab3 pgtbl

lab3 pgtbl_第1张图片

Pre

在这个lab中,你将探索页表,并且修改它们以简化从用户空间拷贝数据到内核空间的函数

在开始之前,需要完成

  1. 阅读xv6 book的第3章
  2. kern/memlayout.h 有关内存的布局
  3. kern/vm.c 包含大部分虚拟内存的代码
  4. kernel/kalloc.c 分配和释放虚拟内存的代码

内存布局

lab3 pgtbl_第2张图片

Print a page table

task

  1. 定义一个叫做vmprint(pagetable_t)的函数,用下面的格式打印页表

  2. 添加if(p->pid==1) vmprint(p->pagetable)exec.creturn argc语句前面,这将会打印第一个进程的页表

  3. 通过make gradepte printout进行测试,也可以

    ./grade-lab-pgtbl pte printout
    

打印的格式如下

  1. 首先从根页表开始从上到下地递归地打印,并且只打印有效的页表项
  2. 具体到每一个页表项
    1. 加入是第x级的页表,那就先打印x..
    2. 然后打印这个页表项在这个页表中的索引
    3. pte后面紧跟着的内容是这个页表项的所有内容,总共64bit
    4. pa后面紧跟着的就是这个页表项指向的地址,假设pte页表项的内容是val,那么pa=((val>>10)<<12),xv6中已经定义了一个宏来实现
page table 0x0000000087f6e000
..0: pte 0x0000000021fda801 pa 0x0000000087f6a000
.. ..0: pte 0x0000000021fda401 pa 0x0000000087f69000
.. .. ..0: pte 0x0000000021fdac1f pa 0x0000000087f6b000
.. .. ..1: pte 0x0000000021fda00f pa 0x0000000087f68000
.. .. ..2: pte 0x0000000021fd9c1f pa 0x0000000087f67000
..255: pte 0x0000000021fdb401 pa 0x0000000087f6d000
.. ..511: pte 0x0000000021fdb001 pa 0x0000000087f6c000
.. .. ..510: pte 0x0000000021fdd807 pa 0x0000000087f76000
.. .. ..511: pte 0x0000000020001c0b pa 0x0000000080007000

hints

  1. kernel/vm.c中完成这个函数
  2. 使用kernel/riscv.h中定义的宏
  3. freewalk将给你灵感
  4. 记得在kernel/defs.h中声明你的函数,这样才可以在exec函数中使用它
  5. 使用%p去打印64bit的pte和address

思路

  1. 先看看freewalk中是如何遍历这三级页表的,思路就比较清晰了
  2. 这里用了一个depth控制当前位于第几级页表,方便打印和控制递归的终点
int depth = 0;
void print_prefix(int i) {
    for (int i = 0; i <= depth; i++) {
        printf("..");
        if (i < depth) {
            printf(" ");
        }
    }
    printf("%d: ", i);
}
void vmprint(pagetable_t pagetable) {
    printf("page table %p\n", pagetable);
    for (int i = 0; i < 512; i++) {
        pte_t pte = pagetable[i];
        // 如果这一项有效
        if (pte & PTE_V) {
            print_prefix(i);
            printf("pte %p ", pte);
            printf("pa %p", PTE2PA(pte));
            printf("\n");
            if (depth < 2) {
                depth++;
                vmprint((pagetable_t)PTE2PA(pte));
                depth--;
            }
        }
    }
}

image-20230817200840273

A kernel page table per process

这个task和下一个task的目标就是使得内核可以直接解引用进程传递的指针

task

  1. 在这个task中,你先修改kernel,使得每个进程在内核态的时候都可以用它自己对内核页表的拷贝
  2. 修改struct proc为每个进程都维护一个内核页表
  3. 修改scheduler使得进程切换的时候切换内核的页表
  4. 完成到这一步时,每个进程中的内核页表都应该和现在的全局内核页表相同
  5. 如果你能通过usertests就说明完成了这个task

hints

  1. struct proc中增加一个字段表示这个进程独有的内核页表

  2. 为一个新进程创造一个内核页表的合理的方法是

    实现一个kvminit的新版本,这个新版本会创建一个新的页表,而不是修改已有的页表

    你需要在allocproc中调用这个新的函数

  3. 保证每个进程的内核页表都有一个映射,这个映射可以找到进程的内核栈

    在未修改的xv6中,所有的内核栈都在procinit中被创造

    你需要去移动procinit中的部分或者全部到allocproc

  4. 修改scheduler()去将进程的内核页面加载到satp寄存器, 可以通过kvminithart学习这个的用法

    不要忘记在调用w_satp之后调用sfence_vma

  5. 调度器应该在没有进程运行时使用kernal_pagetable

  6. freeproc中释放一个进程的内核页表

  7. 你将需要一个方法,这个方法在释放页表的同时不会释放真正的物理页面

  8. vmprint可以在debug页表的时候办法

思路

可以说,跟着hint一步一步走,就成功了,但是我个人觉得hint或者说这个文档没有说的非常清楚,导致有一点歧义,接下来一个hint一个hint分析

  1. proc中增加一个字段,这个就不用说了
pagetable_t kernel_pgtbl;
  1. 这是创建内核页表的关键。我之前以为是要把现在的内核页表给复刻一遍,结果写了个递归函数去copy当前的内核页表。但是实际上,只需要我们创建一个和kvminit创建出来的的内核页表一样的就行了,也就是最原始的那种,只有内核的代码和数据以及一些外设,这些代码都在kvminit代码中,所以直接抄一份就行了

通过这个代码可以发现,创建一个最原始的内核页表,就三步

  • 先申请一个物理页
  • 将这个物理页清空
  • 写入固定的一系列地址到页表中(大量的kvmmap操作)

最后在allocproc中进程被正确创建之后,给这个进程的内核页表赋值p->kernel_pgtbl = new_kernel_pgtbl();

void init_kernel_pgtbl(pagetable_t pgtbl) {
    memset(pgtbl, 0, PGSIZE);

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

pagetable_t new_kernel_pgtbl() {
    pagetable_t pgtbl = (pagetable_t)kalloc();
    init_kernel_pgtbl(pgtbl);
    return pgtbl;
}

/*
 * create a direct-map page table for the kernel.
 */
void kvminit() {
    kernel_pagetable = new_kernel_pgtbl();
}

需要注意的是,我们在这里修改了kvmmap函数的声明,因为之前它是默认使用内核页表的,现在需要用每个进程自己的内核页表,这里主要要修改两个函数,分别是kvmmapkvmpa,注意要将修改更新到defs.h文件以及所有用到这两个函数的地方,有个比较隐秘的是在virtio_disk.c

  1. 内核栈相关的修改

正常来说,内核栈是在procinit的时候对proc数组的所有进程进行初始化,然后将地址映射放到唯一的内核页表中

而我们现在只需要在allocproc中申请内存栈并将这个地址变换写到这个进程的内存页表即可

具体步骤如下

  • procinit函数中和内存栈相关的代码给剪切

  • 将代码复制到allocproc的合适位置,放在进程内核页表被初始化的后面就不错

    char *pa = kalloc();
    if (pa == 0)
        panic("kalloc");
    uint64 va = KSTACK((int)(0));
    kvmmap(p->kernel_pgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
    p->kstack = va;
  1. hints的第4和第5点一起考虑

第4点就是要求我们在进程获得cpu的时候把它自己的内核栈给切换上去,即修改寄存器satp

第5点则是要求在没有进程使用的时候,切换到内核唯一的那个页表,这个可以通过在进程执行完之后就切换satp寄存器为唯一的内存页表

具体实现如下,在swtch函数执行前后进行切换即可

w_satp(MAKE_SATP(p->kernel_pgtbl));
sfence_vma();

swtch(&c->context, &p->context);

kvminithart();
  1. 最后一步,在进程被终止的时候,回收这个进程的内核页表

首先补充一个hints没有说的,我们还需要回收这个进程的内核栈的那个页面,否则会造成内存浪费

freeproc函数中加入如下代码

    if (p->kernel_pgtbl) {
        free_kernel_stack(p->kernel_pgtbl, p->kstack);
        p->kstack = 0;
        free_kernel_pgtbl(p->kernel_pgtbl, 0);
        p->kernel_pgtbl = 0;
    }

其中free_kernel_stack就是通过栈的虚拟地址,经过内核页表,找到物理地址,将其free

void free_kernel_stack(pagetable_t pgtbl, uint64 stack_p) {
    void *real_p = (void *)kvmpa(pgtbl, stack_p);
    kfree(real_p);
}

其中free_kernel_pgtbl就复杂一些,需要递归地删除这个内核页表,并且不能真正地删除物理页面

void free_kernel_pgtbl(pagetable_t pgtbl, int depth) {
    if (depth == 2) {
        kfree((void *)pgtbl);
        return;
    }

    for (int i = 0; i < 512; i++) {
        pte_t *pte = &pgtbl[i];
        if (*pte & PTE_V) {
            free_kernel_pgtbl((pagetable_t)(PTE2PA(*pte)), depth + 1);
        }
    }
    kfree((void *)pgtbl);
}

至此,第二个task结束,可以运行./grade-lab-pgtbl usertests检查

这个检查的过程非常长,在我这运行了100s,一度以为是死锁了写错了

lab3 pgtbl_第3张图片

Simplify

task

  1. 将用户的映射都加入到进程的内核页表中
  2. vm.c中的copyin函数的函数体替换成对copyin_new的调用,对copyinstr也是一样的处理
  3. make grade通过就说明成功了
  4. 你需要修改xv6使得用户进程的虚拟地址不会超过PLIC寄存器的地址

hints

  1. 先确定copyin正确,再去尝试copyinstr

  2. 每次内核改变用户的映射时,都要同步修改到这个用户的内核页表

    包括fork exec sbrk

  3. 不要忘记了在userinit中将第一个进程的用户也更新到他的内核页表

  4. PTE_U不要也拷贝到了kernel的内核页表中

  5. 不要忘记了PLIC的限制

lab3 pgtbl_第4张图片

错误之路

  1. 如何控制用户进程申请的最大虚拟地址,在umalloc.c文件的morecore函数中对sbrk函数的返回值进行判断
  2. 修改fork函数,主要是修改uvmcopy函数,在它使用mappagesnew增加页表项时,成功后给kernal也增加页表项
  3. 修改exec函数,有好多地方需要修改,一个一个来
    1. 调用了proc_pagetable去创建一个只有顶部两个和trap相关的页面,其他的都为空,这应该相当于清空,内核该怎么办呢?也清空自己吗。目前是清空原有的内核页表,然后生成一个新的内核页表,最后将内核栈的映射加上去
    2. uvmalloc中增加对kernel的操作
    3. uvmclear中增加对kernel的操作,这个好像不需要

第二次尝试

  1. vm.c中创造函数copy_to_kernaldealloc_kernal,其中的copy函数会将用户标志位给取消

    copy_to_kernel函数如下

    有几个细节,或者说是有点坑的地方

    1. 不要忽略了mappages函数失败的情况,这个函数失败,说明walk失败,进一步说明是kalloc失败,这本质上就是没有空闲页面了。这时候也不应该用panic报错,而是返回一个特殊值,表示内存不够用了,并且将已经记录的地址映射删除。如果不处理这种情况,会在sbrkmuch这个测试点过不去。
    2. 为什么要用PGROUNDUP,可以不用吗?或者可以用PGROUNDDOWN
      1. 首先,如果每次给的地址都是页面大小的倍数,那用不用都可以
      2. 之所以使用向上取整,而不是向下取整,个人认为是因为oldsz在的那个页面本来就存在于内核页表中,不需要复制,所以就从向上取整的那个开始
    uint64 copy_to_kernal(pagetable_t user, pagetable_t kernel, uint64 oldsize, uint64 newsize) {
        uint64 va, pa;
        pte_t *pte;
        uint flags;
        for (va = PGROUNDUP(oldsize); va < newsize; va += PGSIZE) {
            pte = walk(user, va, 0);
            pa = PTE2PA(*pte);
            flags = PTE_FLAGS(*pte);
            flags &= ~PTE_U;
            if (mappages(kernel, va, PGSIZE, pa, flags) != 0) {
                uvmunmap(kernel, PGROUNDUP(oldsize), (va - PGROUNDUP(oldsize)) / PGSIZE, 0);
                return -1;
            }
        }
        return newsize;
    }
    

    dealloc_kernal函数如下,基本照抄uvmdealloc函数,只需要将uvmunmap最后的dofree参数改成0就行了

    这个函数的本质就是将用户进程的虚拟地址给free掉了,没有影响内核本身的那些外设和代码数据

    uint64 dealloc_kernal(pagetable_t kernel, uint64 oldsz, uint64 newsz) {
        if (newsz >= oldsz)
            return oldsz;
    
        if (PGROUNDUP(newsz) < PGROUNDUP(oldsz)) {
            int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
            uvmunmap(kernel, PGROUNDUP(newsz), npages, 0);
        }
    
        return newsz;
    }
    
  2. fork,在父进程拷贝内存到子进程之后,调用copy_to_kernel函数

    注意,也要判断是否失败

        // Copy user memory from parent to child. // 将child的用户态页表复制到child的内核页表
        if (uvmcopy(p->pagetable, np->pagetable, p->sz) < 0 || copy_to_kernal(np->pagetable, np->kernel_pgtbl, 0, p->sz) < 0) {
            freeproc(np);
            release(&np->lock);
            return -1;
        }
    

    exec,找个合适的位置(进程的页表被初始化完之后就行),先释放再拷贝

        dealloc_kernal(p->kernel_pgtbl, oldsz, 0);
        copy_to_kernal(p->pagetable, p->kernel_pgtbl, 0, sz);
    

    sbrk应该是在sys_sbrk中调用的growproc函数,分别在分配和释放的情况下调用函数。其中在分配的时候,如果我们的copy函数失败了,还需要将进程的用户态页表的映射给抹去再返回-1

        if (n > 0) {
            if ((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
                return -1;
            }
            // 内核页表
            if (copy_to_kernal(p->pagetable, p->kernel_pgtbl, oldsz, oldsz + n) < 0) {
                uvmdealloc(p->pagetable, sz, oldsz);
                return -1;
            }
        } else if (n < 0) {
            sz = uvmdealloc(p->pagetable, sz, sz + n);
            // 内核页表
            dealloc_kernal(p->kernel_pgtbl, oldsz, oldsz + n);
        }
    

    userinit,在uvminit之后调用拷贝函数

    copy_to_kernal(p->pagetable, p->kernel_pgtbl, 0, PGSIZE);
    
  3. PLIC的限制

    1. CLINT变成只有最初的内核页表才分配,后面申请的内核页表都不用,这样每个进程的内核页表就不会在PLIC下面还有虚拟地址了。至于这这个CLINT为什么在只需要在最初的内核页表需要,现在还不太清楚,好像后面会讲的,就当个黑盒子使用了

      具体实现就是将kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);从task2中定义的init_kernel_pgtbl中移到kvminit

    2. 控制用户进程的地址空间,不要超过了PLIC,我觉得这里需要在两个地方进行控制,第一个是exec函数,即进程初始的虚拟地址空间大小,第二个是sbrk函数,即进程在运行的过程中动态申请内存空间,也不能超过PLIC,这个就体现在sbrk调用的growproc函数了

      1. exec中,是在一个for循环里不断通过uvmalloc给这个进程的用户页表建议映射的,因此在这个函数后面加上一个判断即可

                if ((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
                    goto bad;
                if (sz1 >= PLIC) {
                    goto bad;
                }
        
      2. growproc中,先判断一下当前的大小加上n是否超过了PLIC

            if (PGROUNDUP(sz + n) >= PLIC) {
                return -1;
            }
        

    至此,硬核的内容结束了

    但是如果想拿到满分,还需要在项目根目录下创建两个txt文件,一个叫time.txt,一个叫answers-pgtbl,一个用来记录完成lab的总耗时,一个用来回答问题,可以直接乱填

你可能感兴趣的:(6.S081,linux,运维,服务器)