rCore-Tutorial-Book-v3学习笔记(四)

概述

第四个部分是实现系统的内存管理,核心当然就是虚拟地址和物理地址空间的管理了。xv6实验三让我有了心理阴影,所以在实验前我先整理了一下源代码,把项目的结构弄清晰,后面有错的时候方便查找。然后这一章节主要分成四个部分,第一部分是给内核代码添加动态内存分配支持,使得内核可以用上链表、可变数组等数据结构;第二部分是物理页帧管理,这个比较简单,主要是一个空闲页帧分配池的实现;第三部分是虚拟地址管理,就是实现虚拟页号到物理页号的映射、页表的查找和虚拟内存和物理内存之间数据的传递,这里我对rcore中的实现进行了很多简化;第四部分是地址空间管理机制和分时管理系统的融合,这一步最麻烦,需要对用户程序的虚拟地址空间进行划分和管理,以及解析elf文件和修改陷入前后、任务切换相关数据的操作。

内容

新的项目结构如下:

  • kernel文件夹用来存放内核代码,把处理陷入的mod.c改名为trap.c了
  • user文件夹用来存放用户代码
  • common文件夹用来存放公有文件,即内核和用户都可以使用的常量和代码。common.c包含了printf函数的实现(还是来自xv6),这样用户态也可以用上格式化输出了,而printf最终调用的输出字符的consputc函数则只保留了定义,由两个态各自实现,由链接器把不同的consputc函数链接进去。从这个角度来讲,我觉得这也算一种“多态”,虽然不是面向对象里的多态,但是这种同一写法,在不同的地方呈现不同的行为,也正符合“多态”这个词本身的意义。此外还包含了一些memset、memcpy这种工具函数。common.h包含了common.c里的函数定义和之前的一些类型定义。syscall.h里包含了系统调用号,放在一起以后增添系统调用时就不用改两次了,filec.h里包含文件相关的常数,目前只有FD_STDOUT。
  • 新建了一个build文件夹,在makefile中设置将输出的二进制文件都放在这个文件夹,使得项目更整洁,也可以很方便地使用make clean命令清除。

接下来是给内核代码添加动态内存分配支持,教程中使用了一个伙伴算法的库,我则使用了xv6的2019年版实验三alloc中的伙伴算法代码(buddy.c)。和alloc实验本身的要求不同,该实验使用伙伴算法管理内核空间以外的所有物理空间,本教程中则是在内核中额外声明了一个静态大数组(放在内核二进制文件中的bss段,运行时属于内核空间的一部分)专门用来动态分配,所以我也使用该代码管理这段空间。

然后是物理页帧管理(frame.c),管理的对象是内核空间以外的物理空间,这段空间在用的时候基本上都是按页分配,所以只需要用一个编号记录当前待分配的新页就行了。额外的是需要一个空闲分配池,当释放页帧的时候,可以直接将页号放入池中;分配页帧的时候,则需要分配池中有没有空闲页号,有的话直接拿来用,没有就令待分配新页自增1,然后返回新页即可。教程中使用了可变数组(vector)来实现这个分配池,不过不难看出这个分配池其实就是个栈,也可以使用链表实现,刚好xv6的伙伴算法代码中还包含了一个链表实现,就拿来用了。

PhysPageNum current, end;
struct rnode {
    struct list lnode;
    usize ppn;
};
struct list *recycled;                                                                                   
void frame_init() {
    extern char ekernel;
    current = CEIL((usize)&ekernel);
    end = FLOOR(MEMORY_END);
    recycled = bd_malloc(sizeof(struct list));
    lst_init(recycled);
    frame_num = end - current;
}
PhysPageNum frame_alloc() {
    int ret; PhysPageNum ppn;
    if (! lst_empty(recycled)) {
        struct rnode *x = lst_pop(recycled);
        ppn = x->ppn; bd_free(x);
    } else {
        if (current == end) panic("frame_alloc: no free physical page");
        else ppn = current++;
    }
    memset((void *)PPN2PA(ppn), 0, PAGE_SIZE);
    frame_num--;
    return ppn;
}
void frame_dealloc(PhysPageNum ppn) {
    if (ppn >= current) goto fail;
    for (struct list *p = recycled->next; p != recycled; p = p->next) {
        if (((struct rnode *)p)->ppn == ppn) goto fail;
    }
    struct rnode *x = bd_malloc(sizeof(struct rnode));
    x->ppn = ppn; lst_push(recycled, x);
    frame_num++;
    return;
fail: panic("frame_dealloc failed!");
}

原链表节点(struct list)中只包含了指向前驱和指向后继的指针,但通过把链接节点和数据组合成一个新结构体,配合C语言灵活的类型指针转换,使得该链表也能起到泛型的功能。

第三是虚拟地址管理(pagetable.c),这里我对教程中的实现进行了大幅度的简化。教程中使用MapArea对象管理一段连续虚拟地址空间的所有页号和权限等信息,然后使用MapSet对象管理一张页表中的所有MapArea。事实上实现了对页表的封装,更有利于调试和用好Rust语言的各项特性;但是在C语言中没有这些特性,没有必要过度封装,像xv6直接用一个个简洁的函数操作页表更符合C语言过程式的风格。(加上教程作者已经排好雷了,跟在大佬后面走不用调试)。所以我这里借鉴了xv6的实现,以页表为操作单位,底层是find_pte、map、unmap三个操作,上层是map_area、unmap_area、copy_area三个操作,加上递归释放页表的free_pagetable、专门映射跳表的map_trampoline和内核地址空间初始化的kvm_init,九个函数实现虚拟地址管理。这里主要给出map_area、unmap_area、copy_area三个函数:

void map_area(PhysPageNum root, VirtAddr start_va, VirtAddr end_va, PTEFlags flags, int alloc) {
    VirtPageNum start_vpn = FLOOR(start_va), end_vpn = CEIL(end_va);
    for (VirtPageNum i = start_vpn; i < end_vpn; i++) {
        PhysPageNum ppn = alloc ? frame_alloc() : i;
        map(root, i, ppn, flags);
    }
}
void unmap_area(PhysPageNum root, VirtAddr start_va, VirtAddr end_va, int dealloc) {
    VirtPageNum start_vpn = FLOOR(start_va), end_vpn = CEIL(end_va);
    for (VirtPageNum i = start_vpn; i < end_vpn; i++) {
        PhysPageNum ppn = unmap(root, i); if (dealloc) frame_dealloc(ppn);
    }
}
void copy_area(PhysPageNum root, VirtAddr start_va, void *data, int len, int to_va) {
    char *cdata = (char *)data; VirtPageNum vpn = FLOOR(start_va);
    while (len) {
        usize frame_off = start_va > PPN2PA(vpn) ? start_va - PPN2PA(vpn) : 0;
        usize copy_len = PAGE_SIZE - frame_off < len ? PAGE_SIZE - frame_off : len;
        PageTableEntry *pte_p = find_pte(root, vpn, 0);
        if (to_va) memcpy((void *)PPN2PA(PTE2PPN(*pte_p)) + frame_off, cdata, copy_len);
        else memcpy(cdata, (void *)PPN2PA(PTE2PPN(*pte_p)) + frame_off, copy_len);
        len -= copy_len; cdata += copy_len; vpn++;
    }
}

map_area的参数没有给出待映射的物理页号。因为这个函数的调用只有两种情况,一种是映射内核空间,这种情况下虚拟页号等于物理页号,所以自己映射自己就行,对应alloc参数为0;一种是映射用户空间,这种情况随映射随创建就行,对应alloc为1。同理,unmap_area也是两种情况,一种是解映射时物理空间要保留,另外一种是解映射的时候顺便将对应的物理页帧释放掉。

而copy_area主要实现虚拟地址里的数据和物理地址里的数据传递。这个函数也是两用的,当to_va为1时,意味着将data里的数据传给root页表对应的start_va虚拟地址开始的空间,比如在初始化用户程序的时候,需要将内核内存里用户程序的数据段复制到用户页表里;当to_va为0时,意味着将root页表对应的start_va虚拟地址开始的数据传给data,比如说在用户程序write调用的时候,将一个缓冲区地址传给内核,这个地址时虚拟地址,需要传到一段连续的物理空间中才能被内核使用。这个函数的难点就是在于连续的虚拟页号对应的物理页号不一定连续,所以需要将data指向的空间切分成小区域。对于每个虚拟页号,需要先进行一番find_pte找到物理页号,然后要计算当前要复制的小区域在物理页帧中的偏移量和复制长度,如果该小区域位于data的两端会出现不对齐的情况,这时偏移量可能大于0,复制长度小于页宽,需要进行条件判断。

最后是新的地址管理和原来程序的融合。任务切换这一块不用怎么变,因为没有涉及到页表的切换,TaskContext也像前面一样放在内核栈底部。只有在初始化的时候需要将TaskContext中的ra的值由__restore改为trap_return函数。

然后就是陷入了,先讲用户态进入内核态,首先第一步是“取回自己在进入用户态时失去的记忆”,不然没法切换页表,也没法切换sp进行下一步操作。而这个“记忆”就是TrapContext,由于此时还没有切换页表,所以TrapContext必须映射到用户的页表中,这也是每个用户的虚拟地址空间包含TRAPCONTEXT这一段的原因,同时用来保存“进入内核态的第一份数据”的sscratch寄存器,存的也就是TrapContext的地址。一番操作后,从TrapContext取出内核页表地址和内核栈地址后,就可以切换页表和设置sp了,然后跳转到陷入处理程序。注意现在的跳转用的是jr指令,目标是之前存在TrapContext里的trap_handle的地址,为什么要改这个呢?原因是之前__restore里的代码不依赖参数(a0、a1寄存器的值)的,所以如果这里用call指令,陷入处理结束后就可以自然地往下继续执行__restore里的代码。现在__restore依赖参数了,在trap_return函数里调用,不需要依靠call指令修改ra寄存器的值,所以用的是jr指令。

接着时内核态到用户态,现在新加了一个trap_return函数,原因是现在__restore函数有参数了,调用前需要准备一下参数。__restore函数需要写TrapContext,因此得先切换到用户页表和获取TrapContext的虚地址,这些就由参数给出。一番操作后,将TrapContext的虚地址传到sscratch中,设置sp为用户栈地址(存在TrapContext)里了,就可以指向用户程序了。

因此初始化就需要对TrapContext和TaskContext的这些属性进行填充。这次还有一个大变化,就是直接读取elf格式的用户程序了,并在读取的过程中对程序的各段进行地址映射,并额外映射内核栈、用户栈、跳表代码和TrapContext。这里补充说一下跳表代码,其实就是trap.S里的汇编代码,因为里面涉及页表切换,需要保证切换前代码的虚拟地址和切换后代码的虚拟地址一样,所以才专门把跳表代码映射到所有页表的同一位置。

这里着重说一下elf文件格式的解析,教程中用了外部库,实际上elf文件格式还是很容易解析的,因为里面的所有头信息都是固定大小,也不存在嵌套关系,所以只需要定义和头信息格式一致的结构体,就可以获得头信息的各项属性了。我这里用的是xv6里的elf.h:

void from_elf(char *elf_data, PhysPageNum *user_pagetable, usize *user_sp, usize *entry_point) {
    PhysPageNum user_pgtb = frame_alloc(); map_trampoline(user_pgtb);
    // get elf header
    struct elfhdr *elf = (struct elfhdr *)elf_data;
    if (elf->magic != ELF_MAGIC) panic("from_elf: invalid elf file");
    int offset = elf->phoff; VirtPageNum max_end_vpn = 0;
    for (int i = 0; i < elf->phnum; i++) { // per program section
        struct proghdr *ph = (struct proghdr *)(elf_data + offset);
        if(ph->type != ELF_PROG_LOAD) continue;
        PTEFlags flags = U;
        if (ph->flags & ELF_PROG_FLAG_EXEC) flags |= X;
        if (ph->flags & ELF_PROG_FLAG_WRITE) flags |= W;
        if (ph->flags & ELF_PROG_FLAG_READ) flags |= R;
        // map and copy program data and code
        map_area(user_pgtb, ph->vaddr, ph->vaddr + ph->memsz, flags, 1);
        copy_area(user_pgtb, ph->vaddr, elf_data + ph->off, ph->filesz, 1);
        PhysAddr pa = PPN2PA(PTE2PPN(*find_pte(user_pgtb, FLOOR(ph->vaddr) + 1, 0)));
        if (CEIL(ph->vaddr + ph->memsz) > max_end_vpn)
            max_end_vpn = CEIL(ph->vaddr + ph->memsz);
        offset += sizeof(struct proghdr);
    }
    // map user stack(in user space)
    VirtAddr user_stack_bottom = PPN2PA(max_end_vpn) + PAGE_SIZE;
    VirtAddr user_stack_top = user_stack_bottom + USER_STACK_SIZE;
    map_area(user_pgtb, user_stack_bottom, user_stack_top, R | W | U, 1);
    // map trap context
    map_area(user_pgtb, TRAP_CONTEXT, TRAMPOLINE, R | W, 1);
    *user_pagetable = user_pgtb; *user_sp = user_stack_top; *entry_point = elf->entry;
}

elf格式的第一个头包含整个elf文件的信息,比如入口点、段头在文件中的地址什么的。根据这个地址可以找到各段头。注意elf格式中包含两种段头,一种叫program header(简称ph),一种叫section header(简称sh),section header和程序中实际的段(比如在链接脚本里看到的data段、text段、bss段这些)是一一对应的,而每个program header则描述了连续的同权限的多个段。因为我们的程序可以映射和复制连续且同一权限的一块数据,所以按program header进行映射和复制即可。

posted @ 2021-03-20 18:17  YuanZiming  阅读(402)  评论(0编辑  收藏  举报