Files
912-notes/thu_os/lab2_report.md
2019-08-28 14:13:54 +08:00

19 KiB
Raw Blame History

Lab 2 Report

实验目的

  • 理解基于段页式内存地址的转换机制
  • 理解页表的建立和使用方法
  • 理解物理内存的管理方法

实验内容

本次实验包含三个部分。首先了解如何发现系统中的物理内存;然后了解如何建立对物理内存的初步管理,即了解连续物理内存管理;最后了解页表相关的操作,即如何建立页表来实现虚拟内存到物理内存之间的映射,对段页式内存管理机制有一个比较全面的了解。本实验里面实现的内存管理还是非常基本的,并没有涉及到对实际机器的优化,比如针对 cache 的优化等。如果大家有余力,尝试完成扩展练习。

练习

为了实现lab2的目标lab2提供了3个基本练习和2个扩展练习要求完成实验报告。

对实验报告的要求:

  • 基于markdown格式来完成以文本方式为主
  • 填写各个基本练习中要求完成的报告内容
  • 完成实验后请分析ucore_lab中提供的参考答案并请在实验报告中说明你的实现与参考答案的区别
  • 列出你认为本实验中重要的知识点以及与对应的OS原理中的知识点并简要说明你对二者的含义关系差异等方面的理解也可能出现实验中的知识点没有对应的原理知识点
  • 列出你认为OS原理中很重要但在实验中没有对应上的知识点

文件架构与执行流程

lab2的文件架构相对lab1产生了一些区别在后面可以看到这些区别的存在的必要的都是为了操作系统可以正常地运行。下面简述一下lab2中各个文件执行的流程。

  • 首先自然是bios的程序,在机器上电时被加载到0xffff0地址处,完成相关设备的初始化工作,并且到硬盘的主引导扇区读取bootloader0x7c00地址处。
  • 此后机器的控制权就交给了bootloader,首先是bootasm.S中的汇编代码开始执行在其中完成CPU从实模式到保护模式的转换为此需要首先使能A20地址线初始化段表等这些都是lab1里面就完成了的工作。与lab1不同的是bootasm.S中,还进行了对物理内存的探测工作,是通过bios系统调用来完成的。完成段表的建立与各个段寄存器的初始化后就建立起了C语言的运行环境程序转入bootmain.c中开始执行。
  • bootmain.c::bootmain函数功能非常单一,即将存在于硬盘扇区中的操作系统内核读入内存中,并且通过分析elf格式文件找到内核的入口地址从而转入内核开始运行。这里与lab1都是相同的所不同的是这里并非直接转入kern/init/init.c中,而是转入kern/init/entry.S中运行。
  • kern/init/entry.S中,更新了之前建立的段机制,从而可以将虚拟地址va映射到物理地址va - 0xC000000,保证了操作系统可以正常地运行。此后程序才进入kern/init/init.c::kern_init中运行。
  • kern_init中进行各种设备的初始化工作,这里主要讨论pmm_init函数,即物理内存管理的初始化。
  • pmm_init首先进行了物理内存管理器physical memory manager的初始化工作利用之前对物理内存的探测工作对所有的空闲物理内存通过一个free_area链表进行管理,这里对应了page_init函数。
  • 此后就是完成了页表的建立(对应了boot_map_segment)函数,并且再次更新了段机制,完成了段页式的地址映射机制。这里的逻辑相对复杂,将在后面详细说明。

下面就将按照lab2中各个文件执行的步骤来详细说明其中的具体操作相关功能的实现以及这样实现的原因。

物理内存的探测

lab2的实验目的是要建立起对物理内存的管理机制包括连续的内存分配算法first fit)以及非连续的段页式管理机制。但是在实现这些管理机制之前,首先需要知道当前的机器有多少的内存可用,它们的分布是怎么样的,从而将所有的空闲内存组织起来统一进行管理。所以就首先需要进行物理内存的探测工作。

探测物理内存是通过bios的中断调用实现的,由于bios中断调用只能在实模式下进行,所以相应的工作就需要在boot/bootasm.S中,完成实模式向保护模式的转换之前进行。具体探测的方法是调用参数为e820hINT 15hbios中断。bios通过系统内存映射地址描述符Address Range Descriptor格式来表示系统物理内存布局该地址描述符分为三个字段即内存的起始地址连续内存的大小以及内存的状态或者类型可用或者保留。具体的细节其实查看INT 15系统调用的输入与输出来确定啊,这里就不详述了。下面是探测物理内存的汇编代码:

probe_memory:
    movl $0, 0x8000
    xorl %ebx, %ebx
    movw $0x8004, %di
start_probe:
    movl $0xE820, %eax
    movl $20, %ecx
    movl $SMAP, %edx
    int $0x15
    jnc cont
    movw $12345, 0x8000
    jmp finish_probe
cont:
    addw $20, %di
    incl 0x8000
    cmpl $0, %ebx
    jnz start_probe
finish_probe:

这段代码的功能就是利用bios系统调用INT 15来实现物理内存的探测,将返回的地址描述符放在es:di起始的内存处,这里即是0x8004处。此外可以看到,在地址0x8000处还存放了一个变量来保存探测到的物理内存块的数量,这样,在0x8000内存地址处,就存放了一个与下面结构体相对应的变量:

struct e820map {
    int nr_map;
    struct {
        uint64_t addr;
        uint64_t size;
        uint32_t type;
    } __attribute__((packed)) map[E820MAX];
};

kern/mm/pmm.c::page_init函数中,即是通过这个结构体来建立起物理内存的页管理机制的。这个函数还将在后面详述。

第一次更新段映射机制

上面谈到在lab2中boot/bootmain.c::bootmain执行完后并非是像lab1一样直接跳转到kern/init/init.c::kern_init函数执行,而是首先进入kern/init/entry.S,再转入init.c::kern_init函数。实际上,这样的操作是必要的,倘若没有经过这样的转换,操作系统内核将不能正常运行。

与lab1中一样在lab2中操作系统内核也是被加载到物理地址为0x00100000的内存区段。所不同的是lab2中的操作系统设定了虚拟地址空间其中内核的起始地址为0xC0100000,这点在tools/kernel.ld文件中有所体现,该链接脚本文件是用于规定链接时的输出文件在程序地址空间的布局的,当然还有其他的功能。可以看到:

...
ENTRY(kern_entry)

SECTIONS {
    /* Load the kernel at this address: "." means the current address */
    . = 0xC0100000;

    .text : {
...

这里规定了内核的入口地址为kern_entry,即entry.S中的主函数,并且规定了操作系统是被加载到起始虚拟地址为0xC0100000的内存区段上。而操作系统被加载到的物理地址,则是由bootloader决定的。在boot/bootmain.c::bootmain中,可以看到:

...
	// load each program segment (ignores ph flags)
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    eph = ph + ELFHDR->e_phnum;
    for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
...

这里readseg函数的第一个参数,就是bootloader将内核加载到内存中的地址,由于此时建立的段表还是一对一对等映射,所以这里的加载地址就是物理地址。其中,ph->p_va就是在kernel.ld中设定的虚拟地址,所以操作系统内核实际上是被加载到起始物理地址为ph->p_va & 0xFFFFFF = 0x00100000的内存中。

从上面的讨论可以看出lab2内核的物理地址与虚拟地址是不同的它们之间的对应关系满足

virt addr - 0xC0000000 = phy addr

因此,如果直接从bootmain函数跳转进入内核的init.c::kern_init函数,由于此时的段机制还是对等映射,内核将不能正确得到要运行的代码与数据,此时显然是不可以运行的。所以在进入内核之前,我们首先需要在kern/entry.S中更新段机制,将虚拟地址映射到正确的与之对应的物理地址,此后才能进入内核。

需要注意的是,由于entry.S也是内核代码的一部分,因此其中涉及的内存地址都是虚拟地址,在访存时需要手动进行虚拟地址向物理地址的转化,才能访问到正确的内存空间,具体的代码如下:

#define REALLOC(x) (x - KERNBASE)

.text
.globl kern_entry
kern_entry:
    # reload temperate gdt (second time) to remap all physical memory
    # virtual_addr 0~4G=linear_addr&physical_addr -KERNBASE~4G-KERNBASE 
    lgdt REALLOC(__gdtdesc)
    movl $KERNEL_DS, %eax
    movw %ax, %ds
    movw %ax, %es
    movw %ax, %ss

    ljmp $KERNEL_CS, $relocated

relocated:

    # set ebp, esp
    movl $0x0, %ebp
    # the kernel stack region is from bootstack -- bootstacktop,
    # the kernel stack size is KSTACKSIZE (8KB)defined in memlayout.h
    movl $bootstacktop, %esp
    # now kernel stack is ready , call the first C function
    call kern_init
...
__gdt:
    SEG_NULL
    SEG_ASM(STA_X | STA_R, - KERNBASE, 0xFFFFFFFF)      # code segment
    SEG_ASM(STA_W, - KERNBASE, 0xFFFFFFFF)              # data segment
__gdtdesc:
    .word 0x17                                          # sizeof(__gdt) - 1
    .long REALLOC(__gdt)

实际上,这里的REALLOC(x)宏,就是实现虚拟地址向物理地址转换的工作的。可以看到,在entry.S中,重新更新了段表,使得虚拟地址能被映射到正确的物理地址。此后,就可以毫无禁忌地直接使用虚拟地址了。

练习1实现 first-fit 连续物理内存分配算法(需要编程)

在实现first fit内存分配算法的回收函数时,要考虑地址连续的空闲块之间的合并操作。提示:在建立空闲页块链表时,需要按照空闲页块起始地址来排序,形成一个有序的链表。可能会修改default_pmm.c中的default_initdefault_init_memmapdefault_alloc_pages default_free_pages等相关函数。请仔细查看和理解default_pmm.c中的注释。

请在实验报告中简要说明你的设计实现过程。请回答如下问题:

  • 你的first fit算法是否有进一步的改进空间

空闲物理内存的页式管理

在进行对物理内存的分配与回收之前,肯定首先需要知道当前的空闲物理内存有哪些,从而才可以选择某一区段的内存进行分配啊。也就是说,首先我们是需要管理所有的空闲物理内存。通过之前的物理内存探测,我们已经可以知道哪些区域的物理内存是可用的,接下来,我们要建立页式物理内存管理机制,将物理内存组织成一个个固定大小的页帧来进行管理,并且建立起一个链表结构将这些空闲的内存块组织起来,进而实现内存的分配与回收工作。这部分的工作都是在kern/mm/pmm.c::page_init函数中实现的。

首先,需要将物理内存组织成一个个固定大小的页帧,这里页帧的大小固定为4K。为此,我们就需要知道物理内存的起始点,从而确定可以划分的页帧的数目,对于每一个物理页,都有一个与之对应的struct Page结构体与之对应,来表示该物理页的状态,如是否可用或者被操作系统保留。这部分的工作对应了page_init函数的前半部分:

/* *
 * struct Page - Page descriptor structures. Each Page describes one
 * physical page. In kern/mm/pmm.h, you can find lots of useful functions
 * that convert Page to other data types, such as phyical address.
 * */
struct Page {
    int ref;                        // page frame's reference counter
    uint32_t flags;                 // array of flags that describe the status of the page frame
    unsigned int property;          // the num of free block, used in first fit pm manager
    list_entry_t page_link;         // free list link
};

static void
page_init(void) {
    struct e820map *memmap = (struct e820map *)(0x8000 + KERNBASE);
    uint64_t maxpa = 0;

    cprintf("e820map:\n");
    int i;
    for (i = 0; i < memmap->nr_map; i ++) {
        uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
        cprintf("  memory: %08llx, [%08llx, %08llx], type = %d.\n",
                memmap->map[i].size, begin, end - 1, memmap->map[i].type);
        if (memmap->map[i].type == E820_ARM) {
            if (maxpa < end && begin < KMEMSIZE) {
                maxpa = end;
            }
        }
    }
    if (maxpa > KMEMSIZE) {
        maxpa = KMEMSIZE;
    }

    extern char end[];

    npage = maxpa / PGSIZE;
    pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);

    for (i = 0; i < npage; i ++) {
        SetPageReserved(pages + i);
    }
    uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);
...
}

关于这段代码,有一些要说明的地方:

  • 可以看到,函数一开始就是获得前面bios中断调用得到的地址描述符结构体,这里主要是利用它的信息来获得最大的物理地址maxpa,从而可以确定页面的数量。这里的KMEMSIZE宏是代表了当前机器的最大内存量。
  • 程序的后半部分,就建立起了结构数组Struct Page* pages,对于每一个物理页面,pages数组都有一项的Struct Page与之对应,并且首先将所有的页面都初始化为[保留的],对应于SetPageReserved(pages + i);
  • 这里的全局变量char end[]并非是在代码文件里面定义的,而是在kernel.ld
...
/* The data segment */
    .data : {
        *(.data)
    }

    PROVIDE(edata = .);

    .bss : {
        *(.bss)
    }

    PROVIDE(end = .);
...

可见,这里的end变量是代表bss段的结束地址,也就是操作系统内核被加载到内存中的结束地址。通过设置pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);,表示pages数组是被存放在内存中紧跟在操作系统内核后面,被圆整到一个页帧大小的整数倍的区域中,而后面的freemem变量则是表示存放完pages数组之后,内存中空闲的区域。这样,我们可以物理内存的结构分布图:

/* *
 * Physical memory map:                                                                                                       
 *
 *     						  +---------------------------------+
 *                            |                                 |
 *                            |         Free Memory (*)         |
 *                            |                                 |
 *     freemem -------------> +---------------------------------+
 *                            |   	  Struct Page *pages    	| 
 *     pages ---------------> +---------------------------------+ 
 *                            |        Invalid Memory (*)       | 
 *     end kern ------------> +---------------------------------+ 
 *                            |                                 |
 *                            |    		   KERNEL     			| 
 *                            |                                 |
 *     load addr -----------> +---------------------------------+ 0x00100000
 *                            |                                 |
 *                            |                                 |
 *                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~0x000000000
 *
 * */

在对物理内存进行分页之后,我们需要再次利用e820map的信息,找到所有空闲的页面,并将这些页面组织到一起集中进行管理,这也就是page_init后半部分进行的工作:

...
for (i = 0; i < memmap->nr_map; i ++) {
    uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
    cprintf("[begin, end]: [%08llx, %08llx]\n", begin, end);
    if (memmap->map[i].type == E820_ARM) {
        if (begin < freemem) {
            begin = freemem;
        }
        if (end > KMEMSIZE) {
            end = KMEMSIZE;
        }
        if (begin < end) {
            begin = ROUNDUP(begin, PGSIZE);
            end = ROUNDDOWN(end, PGSIZE);
            if (begin < end) {
                init_memmap(pa2page(begin), (end - begin) / PGSIZE);
            }
        }
    }
}

可以看到,这里的核心函数是init_memmap,它的作用是将起始地址为begin的若干个连续的页面,保存到kern/mm/default_pmm.c中定义的free_area变量中,表示该连续页面是空闲的,可用于后续的分配操作。这个free_area变量其实非常神奇,它是类似于一种链式数组,就是说它只保存各个连续的空闲页的起始页,通过pages数组就可以轻易地访问到该起始页后面连续的空闲页面,不连续的区段的起始页之间通过一个链表来互相连接。可见,free_area的组织方式与采用的页面分配算法息息相关,比如说,倘若采用best fit算法,就应该按空闲区段从小到大的顺序来组织起始页链表,worst fit算法则恰好相反,而倘若采用first fit分配算法,就应该按照起始页的地址顺序,由小到大组织。pmm_manager中的init_memmap, alloc_pages, free_pages实现的关键,都在于维护这种有序关系。

对于我们要实现的first fit算法而言,在调用init_memmap将新的空闲页加入到free_area当中时,就需要遍历当前的起始页链表,从而找到一个合适的位置插入新的空闲页,其实现如下:

/* search for a proper positon in free_list to place new memory block*/
static void 
insert2free_list(list_entry_t *elem){
    list_entry_t *le = &free_list;
    while((le = list_next(le)) != &free_list){
        if(elem < le){
            list_add_before(le, elem);
            break;
        }
    }
    if(le == &free_list) list_add_before(le, elem);
}

static void
default_init_memmap(struct Page *base, size_t n) {
    assert(n > 0);
    struct Page *p = base;
    for (; p != base + n; p ++) {
        assert(PageReserved(p));
        p->flags = p->property = 0;
        set_page_ref(p, 0);
    }
    base->property = n;
    SetPageProperty(base);
    nr_free += n;
    cprintf("current number of free pages: %d\n", nr_free);
    insert2free_list(&(base->page_link));
}

这里,我首先写了一个insert2free_list函数,来找到first fit算法中合适的插入位置,并且将新的空闲页插入其中。在default_init_memmap中,首先进行一些权限的设置,比如将当前页面设置为[可用的],而非继续被操作系统[保留的]等,然后就调用insert2free_list将新的页面插入到合适的位置。对于参考答案给出的这个代码,是直接将新的页面插入到页表的末端,可能是老师清楚INT 15的中断调用返回的e820map地址顺序是有序的,但是我并不清楚这个事实,所以我觉得我的实现也许还更严谨?