Lab-2实验报告

Lab-2实验报告

​ 20373915-朱文涛

实验思考题


Thinking 2.1
请你根据上述说明,回答问题:

  • 在我们编写的程序中,指针变量中存储的地址是虚拟地址还是物理地址?
  • MIPS 汇编程序中lw, sw使用的是虚拟地址还是物理地址?

由于我们的R3000 CPU 只会发出虚拟地址,故程序中的指针变量中存储的地址、MIPS 汇编程序中lw, sw使用的地址均为虚拟地址,根据虚拟地址到物理地址的映射关系到实际物理地址中去进行数据访存(一般高级程序语言编程使用的是虚拟地址)。

​ 下面为实验中的虚拟地址到物理地址的映射规则:

  • 若虚拟地址处于 0x80000000~0x9fffffff (kseg0),则将虚拟地址的最高位置 0得到物理地址,通过 cache 访存。这一部分用于存放内核代码与数据结构。
  • 若虚拟地址处于 0xa0000000~0xbfffffff (kseg1),则将虚拟地址的最高 3 位置0 得到物理地址,不通过 cache 访存。这一部分用于映射外设。
  • 若虚拟地址处于 0x00000000~0x7fffffff (kuseg),则需要通过 TLB 来获取物理地址,通过 cache 访存。

Thinking 2.2

  • 请从可重用性的角度,阐述用宏来实现链表的好处。
  • 请你查看实验环境中的 /usr/include/sys/queue.h,了解其中单向链表与循环链表的实现,比较它们与本实验中使用的双向链表,分析三者在插入与删除操作上的性能差异。

宏定义和函数都是为了提高代码复用率的手段。对于链表,无论是创建、插入、删除、遍历都不如简单的访问数组、变量那样方便,因此使用宏定义和实现链表的各种操作可以节约代码量,减少重复逻辑,提高程序效率,同时在后续链表形式稍微变动,或者需要在其他系统上复现时,宏都具有良好的适应性。

宏的一个本身的特性就是可重用,跟函数一样,可以将一段代码封装成一条语句。当这段代码的具体实现需要更改时,只需要改宏这一处就行。宏相比函数也更加轻便,可以用于结构体定义等,由于是字符串的替换,因此不必进行地址的跳转和栈的保存,但值得注意的是在编写宏的时候需要着重注意语法是否有漏洞。

​ 此外在这里简要介绍一下宏和函数在效率方面的差异:

  • 函数,因为要有函数调用,增加了执行时的开销,效率不如宏,但是可代码重用。函数属于用空间换时间。
  • 宏虽然没有函数调用,但是因为在使用这个宏的地方,代码会被展开编译,增加了程序文件的大小。总起来说。宏属于用时间换空间。

我们先观察一下三种链表的基本结构,用图表示:

  • 双向链表

image

  • 单向链表
点击查看图片来源
  • 单向循环链表
点击查看图片来源

假定当前的链表长度为n,链表头为head。

  1. 我们先分析插入操作,插入操作有INSERT_HEAD、INSERT_BEFORE、INSERT_AFTER、INSERT_TAIL四种
  • 对于双向链表:
    • INSERT_HEAD复杂度o(1),
    • INSERT_BEFORE可以通过p_prev找到插入位置,复杂度o(1),
    • INSERT_AFTER可根据p_next直接找到插入位置,复杂度为o(1),
    • INSERT_TAIL由于没有专门标记链表结尾的位置,需要遍历到链尾,复杂度o(n);
  • 对于单向链表:
    • INSERT_HEAD复杂度o(1),
    • INSERT_BEFORE无法通过p_prev找到插入位置,需要从head开始遍历去寻找插入位置复杂度为o(n),
    • INSERT_AFTER可根据p_next直接找到插入位置,复杂度为o(1),
    • INSERT_TAIL由于没有专门标记链表结尾的位置,需要遍历到链尾,复杂度o(n);
  • 对于单向循环链表:
    • INSERT_HEAD复杂度o(1),
    • INSERT_BEFORE无法通过p_prev找到插入位置,需要从head开始遍历去寻找插入位置复杂度为o(n),
    • INSERT_AFTER可根据p_next直接找到插入位置,复杂度为o(1),
    • INSERT_TAIL由于链表的循环特征,可以直接插入,然后转换head位置即可,复杂度o(1);
  1. 我们在分析删除操作,删除操作主要看LIST_REMOVE
  • 对于双向链表LIST_REMOVE删除指定元素可以更具其p_prev、p_next定位,所以复杂度为o(1),
  • 对于单向链表和循环链表都LIST_REMOVE无法快速定位到删除元素的位置,需要遍历元素,复杂度o(n),
  1. 综上我们发现,大多数情况下,双向链表对于元素的插入和删除操作的效率都很高

Thinking 2.3

请阅读 include/queue.h 以及 include/pmap.h, 将 Page_list 的结构梳理清楚,选择正确的展开结构。

C:
struct Page_list{
    struct {
        struct {
            struct Page *le_next;
            struct Page **le_prev;
        } pp_link;
        u_short pp_ref;
    }* lh_first;
}

首先我们可在include/pmap.h中观察到Page(与真实物理页面相区别,而Page管理一个页面):

struct Page {
    Page_LIST_entry_t pp_link;  /* free list link */
    u_short pp_ref;
};

又知道Page_LIST_entry_t在宏定义typedef LIST_ENTRY(Page) Page_LIST_entry_t;中声明,查看LIST_ENTRYqueue.h中声明如下:

#define LIST_ENTRY(type)                                                    \
        struct {                                                                \
                struct type *le_next;   /* next element */                      \
                struct type **le_prev;  /* address of previous next element */  \
        }

现在可以确定Page的结构:

struct Page {
    struct {                                                               
                struct type *le_next; 
                struct type **le_prev;
        } pp_link;
    u_short pp_ref;
};

pmap.h中声明的IST_HEAD(Page_list, Page)完成了Page_list的构建如下:

struct Page_list { 
    struct Page *lh_first;                      
}

可知这个lh_first相当于链表头head,综上Page_list的结构如C选项所示。


Thinking 2.4

请你寻找上述两个 boot_* 函数在何处被调用。

第一个函数boot_pgdir_walk在boot_map_segment中被调用,第二个函数boot_map_segment在mips_vm_init中被调用。

  • boot_map_segment:
    • 使用 alloc 函数为物理内存管理所用到的 Page 结构体数组按页分配物理内存,同时将分配的空间映射到内核页表中,对应的起始虚拟地址为 UPAGES,此时使用boot_map_segment将虚拟空间与物理空间做映射
    • 为进程管理所用到的 Env 结构体按页分配物理内存,将分配的空间映射到上述的内核页表中,对应的起始虚拟地址为 UENVS,此时使用boot_map_segment将虚拟空间与物理空间做映射。
  • boot_pgdir_walk
    • boot_map_segment时,需要获取一级页表基地址 pgdir 对应的两级页表结构中,va 这个虚拟地址所在的二级页表项,因此使用boot_pgdir_walk去获取。

Thinking 2.5 请你思考下述两个问题:

  • 请阅读上面有关 R3000-TLB 的叙述,从虚拟内存的实现角度,阐述 ASID 的必要性
  • 请阅读《IDT R30xx Family Software Reference Manual》的 Chapter 6,结合 ASID 段的位数,说明 R3000 中可容纳不同的地址空间的最大数量

ASID的必要性:

  • 为了提高TLB的性能,将TLB分成Global和process-specific。global 是指常驻在TLB中不会被刷出的,例如内核空间的翻译,process-specific 是指每个进程独有的地址空间,当发生进程切换的时候,这部分TLB可以被刷出,为了支持process-specific的tlb,arm提出了ASID(Adress Space ID)的硬件解决方案,这样TLB就可以识别出这个 TLB 页表项是属于哪一个进程的,这样就不用每次切换进程都要 flush 所有 TLB
  • 在 MIPS 中,每一个TLB表项会有一个ASID,标识这个表项是属于哪一个进程的。例如有多个进程都需要使用这个虚拟地址,但若该虚拟地址对应的数据不是共享的,此时该表项不是global项且ASID与CP0_EntryHi的ASID也不一样,表明它们指向的是不同物理地址,需要使此次访问缺失,以保护相应的地址空间。

可容纳不同地址空间的最大数量:64个,参考原文如下:

Instead, the OS assigns a 6-bit unique code to each task’s distinct address space. Since the ASID is only 6 bits long, OS software does have to lend a hand if there are ever more than 64 address spaces in concurrent use; but it probably won’t happen too often.


Thinking 2.6 请你完成如下三个任务:

  • tlb_invalidate 和 tlb_out 的调用关系是怎样的?
  • 请用一句话概括 tlb_invalidate 的作用
  • 逐行解释 tlb_out 中的汇编代码

tlb_invalidate函数内部回调用tlb_out函数。

tlb_invalidate函数就是用tlb_out()把虚拟地址va对应的tlb页表项清空。

tlb_out该函数根据传入的参数(TLB 的 Key)找到对应的 TLB 表项,并将其清空

先了解有关TLB的相关命令

tlbr:以 Index 寄存器中的值为索引,读出 TLB 中对应的表项到 EntryHi 与 EntryLo。

tlbwi:以 Index 寄存器中的值为索引,将此时 EntryHi 与 EntryLo 的值写到索引指定的 TLB 表项中。

tlbwr:将 EntryHi 与 EntryLo 的数据随机写到一个 TLB 表项中(此处使用 Random 寄存器来“随机”指定表项,Random 寄存器本质上是一个不停运行的循环计数器)。

tlbp:根据 EntryHi 中的 Key(包含 VPN 与 ASID),查找 TLB 中与之对应的表项,并将表项的索引存入 Index 寄存器(若未找到匹配项,则 Index 最高位被置 1)。

现在我们仔细研究一下tlb_out函数:

 #include <asm/regdef.h>
 #include <asm/cp0regdef.h>
 #include <asm/asm.h>
 LEAF(tlb_out)
     nop
     //首先将CP0_ENTRYHI中原有的值写入k1寄存器
     mfc0    k1,CP0_ENTRYHI
     //将传入的参数(待清空表项的key)写到CP0_ENTRYHI中
     mtc0    a0,CP0_ENTRYHI
     nop
     //根据 EntryHi 中的 Key,查找 TLB 中与之对应的表项,并将表项的索引存入 Index 寄存器,(若未找到匹配项,则 Index 最高位被置 1)。
     tlbp
     //防止冲突
     nop
     nop
     nop
     nop
     //取出CP0_INDEX的值
     mfc0    k0,CP0_INDEX
     //如果是Index 最高位被置 1,表明没找到,跳到 NOFOUND 标签处,(因为此时相当于已经完成了清空工作)
     //如果找到了,Index 最高位为0,继续执行
     bltz    k0,NOFOUND
     nop
     //为清空做准备,CP0_ENTRYHI,CP0_ENTRYLO全部填入0
     mtc0    zero,CP0_ENTRYHI
     mtc0    zero,CP0_ENTRYLO
     nop
     //根据找到的Index将此时 EntryHi 与 EntryLo 的值写到索引指定的 TLB 表项中,即完成清空
     tlbwi
 NOFOUND:
 		 //复原现场(将原来的key写会CP0_ENTRYHI)
     mtc0    k1,CP0_ENTRYHI
     //跳回被调用的地方
     j   ra
     nop
 END(tlb_out)

Thinking 2.7

在现代的 64 位系统中,提供了 64 位的字长,但实际上不是 64 位页式存储系统。假设在 64 位系统中采用三级页表机制,页面大小 4KB。由于 64 位系统中字长为 8B,且页目录也占用一页,因此页目录中有 512 个页目录项,因此每级页表都需要 9 位。因此在 64 位系统下,总共需要 3 × 9 + 12 = 39 位就可以实现三级页表机制,并不需要 64 位。现考虑上述 39 位的三级页式存储系统,虚拟地址空间为 512 GB,若记三级页表的基地址为 PTbase ,请你计算:

  • 三级页表页目录的基地址
  • 映射到页目录自身的页目录项(自映射)

\[三级页表页目录的基地址:PD_{base} = PT_{base} \space | \space PT_{base} \space >>\space9 \space |\space PT_{base}\space>>18\\ 映射到页目录自身的页目录项: PDE_{self\_mapping} = PT_{base} \space | \space PT_{base} \space >>\space9 \space | \space PT_{base} \space >>\space18\space | PT_{base} \space>>\space27 \]


Thinking 2.8 任选下述二者之一回答:

  • 简单了解并叙述 X86 体系结构中的内存管理机制,比较 X86 和 MIPS 在内存管理上的区别。
  • 简单了解并叙述 RISC-V 中的内存管理机制,比较 RISC-V 与 MIPS 在内存管理上的区别。

X86用到三个地址空间的概念:物理地址、线性地址和逻辑地址。而MIPS只有物理地址和虚拟地址两个概念。相对而言,段机制对大量应用程序分散地使用大内存的支持能力较弱。所以Intel公司又加入了页机制,每个页的大小是固定的(一般为4KB),也可完成对内存单元的安全保护,隔离,且可有效支持大量应用程序分散地使用大内存的情况。x86体系中,TLB表项更新能够由硬件自己主动发起,也能够有软件主动更新。

\[分段机制和分页机制都启动:逻辑地址\rightarrow段机制处理\rightarrow线性地址\rightarrow页机制处理\rightarrow物理地址 \]

RISC-V提供三种权限模式(MSU),而MIPS只提供内核态和用户态两种权限状态。RISC-V SV39支持39位虚拟内存空间,每一页占用4KB,使用三级页表访存。

实验难点


Exercise 2.2 链表宏的书写和引用

难点主要在于理解双向链表的链表的结构和各种操作的原理。

根据指导书,我们可以清晰的了解整个链表的结构:

lab2-macro-list.svg

每一个page结构体中包含Page_LIST_entry_t类型的pp_link结构体和一个pp_ref,其中pp_link中包含两个指针,(Page*)le_next指向链表中下一个结构体,(Page **)le_prev指向链表中上一个结构体的le_next。这是一个伪双链表,le_prev指向上一个结构体的le_next,但是不能够由此访问到上一个结构体,所以只能单向遍历。

这种定义最大的好处是简化了节点删除操作。在一般单链表中删除一个节点必须要知道它的上一个节点,所以要从头节点遍历,增大了节点删除的时间复杂度。在上述的结构下,通过le_prev访问并修改上一个节点的le_next,就可以实现删除,不需要遍历。

现在我们分析一下实验中需要填写的 LIST_INSERT_AFTERLIST_INSERT_TAIL函数。

  • LIST_INSERT_AFTER(listelm, elm, field),将 elm 插到已有元素 listelm 之后。
#define LIST_INSERT_AFTER(listelm, elm, field) do {     \
                        LIST_NEXT((elm),field) = LIST_NEXT((listelm),field); \
                        if (LIST_NEXT((listelm),field) != NULL) {   \
                                LIST_NEXT((listelm),field)->field.le_prev = &LIST_NEXT((elm),field); \
                        } \
                        LIST_NEXT((listelm),field) = (elm);  \
                        (elm)->field.le_prev = &LIST_NEXT((listelm),field);  \
                } while (0)

LIST_INSERT_AFTER和LIST_INSERT_BEFORE操作分别如\(\textcolor{red}{红线}\)\(\textcolor{blue}{红线}\)所示:

6421649265902_.pic_hd
  • LIST_INSERT_TAIL(head, elm, field),将 elm 插到头部结构体 head 对应链表的尾部。

    流程和代码如下:

    img
#define LIST_INSERT_TAIL(head, elm, field) do { \
     if (LIST_FIRST((head)) == NULL) { \
         LIST_INSERT_HEAD((head),(elm),field); \
     }else { \
         LIST_NEXT((elm),field) = LIST_FIRST((head)); \
         /* should save the last listelm */ \
         while(LIST_NEXT(LIST_NEXT((elm),field),field) != NULL) {  \
            LIST_NEXT((elm), field) = LIST_NEXT(LIST_NEXT((elm),field),field); \
         } /* now LIST_NEXT((elm),head) iss the lastest listelm */  \
      	 LIST_NEXT(LIST_NEXT((elm), field), field) = (elm); \
         (elm)->field.le_prev = &LIST_NEXT(LIST_NEXT((elm),field),field); \
         LIST_NEXT((elm),field) = NULL; \
         /* aslo can use LIST_INSERT_AFTER((LIST_NEXT((elm), field)),elm,field); */ \
    } \
} while (0)

LIST_INSERT_TAIL需要找到队尾元素,由于没有特殊记录,我们需要循环遍历,并将当前链表项记录于elm->next中,最后插入即可。


各种地址转换宏或函数的理解

//获取Page pp的页号,例如&a[1] - a = 0,这种操作返回的就是&a[1]对应为第几项,即第一项
static inline u_long page2ppn(struct Page *pp)
{
        return pp - pages;
}

//先获取Page pp的页号,然后根据页号获取Page pp的物理实地址,页号 << PGSHIFT就是pp所管理的页面首地址
static inline u_long page2pa(struct Page *pp)
{
        return page2ppn(pp) << PGSHIFT;
}

//获取物理页地址为pa的Page pp地址,PPN(pa)获取物理地址对应的物理页框号,&pages[i]获取页框号为的Page结构体地址
static inline struct Page *pa2page(u_long pa)
{
        if (PPN(pa) >= npage) {
                panic("pa2page called with invalid pa: %x", pa);
        }

        return &pages[PPN(pa)];
}

//获取Page pp的虚地址,先获取Page *pp对应的物理地址,由于处于内核态,直接使用KADDR(pa)即可获取对应的va
static inline u_long page2kva(struct Page *pp)
{
        return KADDR(page2pa(pp));
}

// page number field of address
//PPN(pa)获取物理地址对应的物理页框号
#define PPN(va)         (((u_long)(va))>>12)
#define VPN(va)         PPN(va)

// translates from kernel virtual address to physical address.
//内核地址中虚拟地址和物理地址的互相撞转换
#define PADDR(kva)                                              \
        ({                                                              \
                u_long a = (u_long) (kva);                              \
                if (a < ULIM)                                   \
                        panic("PADDR called with invalid kva %08lx", a);\
                a - ULIM;                                               \
        })

// translates from physical address to kernel virtual address.
#define KADDR(pa)                                               \
        ({                                                              \
                u_long ppn = PPN(pa);                                   \
                if (ppn >= npage)                                       \
                        panic("KADDR called with invalid pa %08lx", (u_long)pa);\
                (pa) + ULIM;                                    \
        })

Exercise 2.3 Page_init的流程

Page_init:(free_pahe_list页面的初始化)

void page_init(void)
{
   //首先利用链表相关宏初始化 page_free_list
   LIST_INIT(&page_free_list);
  
   /* Step 2: Align `freemem` up to multiple of BY2PG. */
   freemem = ROUND(freemem,BY2PG);
    
   struct Page *used,*noused;
  
   //freemem以下部分包括内核代码和Page数组:pages[16k]
   //接着将 mips_vm_init() 中使用过的空间对应的物理页面的内存控制块的引用次数全部标为 1,使用函数page2kva(used)获取页used对应的虚拟地址
   for (used = pages;page2kva(used) < freemem;used++) {
        used -> pp_ref = 1;
   }
  
  //将其它未使用的页面对应的Page结构体中的 pp_ref 标为 0,同时使用链表宏 LIST_INSERT_HEAD 将其插入空闲链表。(有趣的是,这样一来,初始化的page_free_list中各表项的页框号顺序是由大到小的。)
  for (noused = pa2page(PADDR(freemem));page2ppn(noused) < npage;noused++) { 
     		//pa2page need pa,so freemem -> PADDR(freemem)
        noused -> pp_ref = 0;
        LIST_INSERT_HEAD(&page_free_list,noused,pp_link);
   }
}

注意一些细节:

//hint 1
noused = pa2page(PADDR(freemem))
1.PADDR:将虚拟地址freemem映射为实际地址
2.pa2page(pa) = &pages[PPN(pa)],PPN获取页pa对应的页号(即freemem << 12)这里因为freemem已经页对齐了,所以pa2page(PADDR(freemem))一定可以获取到第一个没被用的页(freemem向下空闲空间形成碎片),&pages[i]获取第i个Page的地址,因为这里的noused是指针类型
  
//hint 2
page2ppn(noused) < npage即初始化剩余被未初始化页面,
page2ppn(&pages[i]-pages),即获取i即页框号
  
#include <stdio.h>
int main() {
	int a[10] = {1,100};
	int *b = &a[1];
	printf("%d\n",b-a);
	
}
//output : 1
这是编译器的工作

Exercise 2.9 page_insert函数的流程理解

int page_insert(Pde *pgdir, struct Page *pp, u_long va, u_int perm),这个函数的作用是将一级页表基地址 pgdir 对应的两级页表结构中 va 这一虚拟地址映射到内存控制块 pp 对应的物理页面,并将页表项权限为设置为 perm。

int page_insert(Pde *pgdir, struct Page *pp, u_long va, u_int perm)
{
        u_int PERM;
        Pte *pgtable_entry;
        PERM = perm | PTE_V;
   		  int ret;

        /* Step 1: Get corresponding page table entry. */
        pgdir_walk(pgdir, va, 0, &pgtable_entry);

        if (pgtable_entry != 0 && (*pgtable_entry & PTE_V) != 0) {
          			//检查映射到地址是否为pp对应的的物理页,如果不是,需要取消当前地址映射(替换)
                if (pa2page(*pgtable_entry) != pp) {
                  			//if page table is not this element,then remove
                        page_remove(pgdir, va);
                //否则,建立映射关系*pgtable_entry -> Page *pp对应的物理页首地址,设置权限位为PERM
                } else {
                  			//使用 tlb_invalidate 函数可以实现删除特定虚拟地址的映射,每当页表被修改,就需要调用该函数以保证下次访问该虚拟地址时诱发 TLB 重填以保证访存的正确性。
                        tlb_invalidate(pgdir, va);
                        *pgtable_entry = (page2pa(pp) | PERM);
                        return 0;
                }
        }

        //更新TLB,
    		tlb_invalidate(pgdir, va);
  			//再次使用pgdir_walk,无映射有2种可能,page_remove后无映射,原本无映射,此时可以继续建立映射
    		if ((ret = pgdir_walk(pgdir,va,1,&pgtable_entry)) < 0) return ret;
  			/* Step 3.2 Insert page and increment the pp_ref */
    		*pgtable_entry = page2pa(pp) | PERM;
    		pp -> pp_ref++;
  			return 0;
}

实际上这个函数是这样一个流程:

未命名文件(7)

此外需要注意:

  • 开始判断va是否有对应页表项的时候是不允许创建新的映射页面的,

  • page_insert处理将同一虚拟地址映射到同一个物理页面上不会将当前已有的物理页面移除掉,但是需要修改掉permission

  • 只要对页表有修改,都必须tlb_invalidate一下,否则后面紧接着对内存的访问很有可能出错。这就是为什么直接使用了pgdir_walk而没有page_insert产生错误的原因。


TLB重填流程的理解

前提:

我们的 MOS 中,对这一过程进行了简化,一旦物理页框全部被分配,进行新的映射时并不会进行任何的页面置换,而是直接返回错误,这对应 page_alloc 函数中返回的 -E_NO_MEM

所以在lab2中,对于一个进程触发的重填而言,只有可能是va无对应的页表项所导致的。(存疑)

过程详细解释(相较于教程增加自己的理解):

  1. 确定此时的一级页表基地址:mCONTEXT 中存储了当前进程一级页表基地址位于 kseg0 的虚拟地址;

    通过自映射相关知识,可以计算出对于每个进程而言,0x7fdff000 这一虚拟地址也同样映射到该进程的一级页表基地址,但是重填时处于内核 态,如果使用 0x7fdff000 则还需要额外确定当前属于哪一个进程,使用位于 kseg0 的虚拟地址可以通过映射规则直接确定物理地址。

  2. 从 BadVaddr 中取出引发 TLB 缺失的虚拟地址, 并确定其对应的一级页表偏移量(高 10 位);

  3. 根据一级页表偏移量, 从一级页表中取出对应的表项:此时取出的表项由二级页表基地址的物理地址与权限位组成;

  4. 判定权限位: 若权限位显示该表项无效(无 PTE_V ), 则调用 page_out ,page_out函数实际上就是被动申请一页物理页面,与对应虚拟地址形成映射关系,随后回到第一步;

  5. 确定引发 TLB 缺失的虚拟地址对应的二级页表偏移量(中间 10 位),与先前取得的二级页表基地址的物理地址共同确认二级页表项的物理地址;

  6. 二级页表项物理地址转为位于 kseg0 的虚拟地址(高位补 1),随后页表中取出对应的表项:此时取出的表项由物理地址与权限位组成

  7. 判定权限位: 若权限位显示该表项无效(无 PTE_V),则调用 page_out ,随后回到第一步;(PTE_COW 为写时复制权限位,将在 lab4 中用到,此时将所有页表项该位视为 0 即可)

  8. 将物理地址存入 EntryLo , 并调用 tlbwr 将此时的 EntryHi 与 EntryLo 写入到 TLB 中。需要注意的是,这里的物理地址指的是上述步骤6所取出的有效物理地址,虚拟地址指的是触发TLB缺失的虚拟地址


多级页表与页目录自映射

页表基址 PTbase
页目录基地址 PDbase
自映射页目录项 PDEself-mapping

(4M对齐指的是页表起始地址够被4MB整除。也就是说PTbase低22位全为0。采用4MB对齐不是必须的,但是用了会方便计算。)

自映射机制可以通过如下图示加以说明:

img4MB对齐的

通过计算可以知道\(PD_{base}和PD_{self\_mapping}\)为:

\[页目录的基地址:PD_{base} = PT_{base} \space | \space PT_{base} \space >>\space10 \\ 映射到页目录自身的页目录项地址: PDE_{self\_mapping} = PT_{base} \space | \space PT_{base} \space >>\space10 \space | \space PT_{base} \space >>\space20 \]

第一个公式的分析

\[PT_{base}相对于0有多少页(4KB大小)PT_{base} / 4KB = PT_{base} / 2的12次方 B = PT_{base} >> 12\\ PT_{base}相对于0有多少页 和 PD_{base}相对于PT_{base}有对少个页表项 有对应关系\\ PT_{base}有对少个页表项(4B大小)即在右移12位的基础上再乘四,乘四相当于左移2位。\\ 先右移12位,再左移2位,最终是右移10位\\ \]

第二个公式的分析

\[PDE_{self-mapping} 是在PD_{base}的基础上再偏移\\ PT_{base} / 4MB = PT_{base} / 2的22次方 B = PT_{base} >> 22\\ PT_{base}相对于0 偏移了多少4MB 和 PDE_{self-mapping}相对于PD_{base}偏移了多少4B 有对应关系\\ (因为,一个页目录项大小为4B,对应了4MB的内存)\\ PDE_{self-mapping}相对于PD_{base}有对少个页目录项(4B大小)即在右移22位的基础上左移2位。\\ 先右移22位,再左移2位,最终是右移20位\\ \]

(此处学习理解时借助课程ppt和一学长的blog,详情可以查阅:点击这里,了解更多有关页目录自映射的知识)


伙伴系统的数组实现

伙伴系统简介

伙伴系统是一种物理内存管理方式,也被用于linux的物理内存管理。

在这种系统中,空闲空间首先从概念上被看成大小为 [公式] 个物理页的 大空间。当有一个大小为 [公式] 页的内存分配请求时,空闲空间被递归地一分为二成两个伙伴块,直到成为大小为 [公式] 刚好可以满足请求的块,即 [公式] ,需要注意:

  • 优先选择地址较小的块为目标块

下面图片的例子,展示了一个64KB大小的空闲空间被切分,以便提供7KB的块:
image

在一个块被释放后,分配器会找到其伙伴块,若伙伴块也处于空闲的状态,则讲这两个伙伴块进行合并,形成一个大一号的空闲块,然后继续尝试向上合并。

  • 这里需要注意,合并时要求被合并的2块空间都是空闲的,并且他们要是伙伴(即是从同一个大物理空间划分出的2个子块)
  • 需要一直尝试合并,直到有伙伴不是空闲块的或者当前块大小已经等于初始块大小(即不能再向上合并)

基于此我们可以基本了解伙伴系统。

算法描述

内存区间的初始化

  • 伙伴系统将高地址 32 MB 划分为数个内存区间,每个内存区间有两种状态:已分配未分配

  • 每个内存区间的大小只可能是 \(4*2^i KB\),其中 i 是整数且 0≤𝑖≤10。

  • 初始,共有 8 个 4 MB 大小的内存区间,状态均为未分配。

可用下表表示初始内存空间:

32-35MB 36-39MB 40-43MB 44-47MB 48-51MB 52-55MB 56-59MB 60-63MB
未分配 未分配 未分配 未分配 未分配 未分配 未分配 未分配

内存区间的分配

  • 每次通过伙伴系统分配 \(x B\)

的空间时,找到满足如下三个条件的内存区间:

  1. 该内存区间的状态为未分配

  2. 其大小不小于\(x B\)

  3. 满足上面两个条件的前提下,该内存区间的起始地址最小。

如果不存在这样的内存区间,则本次分配失败;否则,执行如下步骤:

  1. 设该内存区间的大小为\(y B\),若\(𝑦/2<𝑥或𝑦=4*𝐾\),则将该内存区间的状态设为已分配,将该内存区间分配并结束此次分配过程。

  2. 否则,将该内存区间分裂成两个大小相等的内存区间,状态均为未分配

  3. 继续选择起始地址更小的那个内存区间,并返回步骤 1。

内存区间的释放

当一个内存区间使用完毕,通过伙伴系统释放时,将其状态设为未分配

我们称两个内存区间 xy可合并的,当且仅当它们满足如下两个条件:

  1. xy 的状态均为未分配
  2. xy 是由同一个内存区间一次分裂所产生的两个内存区间,即xy是一对伙伴。

若存在两个可合并的内存区间,则将两个内存区间合并,若合并后仍存在两个可合并的内存区间,则继续合并,直到不存在两个可合并的内存区间为止。

算法实现

数据结构简介

为了尽量节约空间,我只使用一个大小为1<<13的int型数组去管理页面的分配情况。buddy[1 << 13]表示高32MB的1<<13个页面的分配状态,页面𝑖

状态主要有以下四种:

  • Buddy[i] = -1,表示该页面未被分配,且该页面的下一个页面和该页面处于同一个内存块中,即不属于右边界页面
  • Buddy[i] = -2,表示该页面未被分配,且该页面的下一个页面和该页面处于不同个内存块中,即属于右边界页面
  • Buddy[i] = 1,表示该页面已被分配,且该页面的下一个页面和该页面处于同一个内存块中,即不属于右边界页面
  • Buddy[i] = 2,表示该页面已被分配,且该页面的下一个页面和该页面处于不同个内存块中,即属于右边界页面

下面用一张图简单介绍一下:

image

图中共有8个相邻的页,其中页面1、页面2处于一个块中,页面3、页面4处于一个块中,页面5、页面6、页面7、页面8处于一个块中。块1和块3已被分配,故页面1状态显然为1,页面2已被分配且属于右边界页面,故状态为2,页面3未被分配且不属于右边界页面,故状态为-1,页面4未被分配且属于右边界页面,故状态为-2,以此类推。

为了实现上述功能,我们需要实现如下的三个函数:

  • 初始化函数

    buddy_init

    • 函数原型为: void buddy_init(void)
    • 调用此函数后,为 MOS 中 64 MB 物理内存的高地址 32 MB 初始化伙伴系统。初始化结束后,伙伴系统中仅有只有 8 个 4 MB 的待分配内存区间。
void buddy_init(void) {
    int i;
  	//所有页面都未被使用,状态赋值为-1
    for (i = 0;i < 8192;i++) {
        buddy[i] = -1;
    }
  	//初始只有8个4MB的物理块,故需要将8个右边界页面赋值为-2
    for (i = 1023;i <= 8192;i += 1024) {
        buddy[i] = -2;
    }
}
  • 分配函数buddy_alloc
    • 函数原型为: int buddy_alloc(u_int size, u_int *pa, u_char *pi)
    • 调用此函数后,通过伙伴系统分配大小不小于 size 字节的空间,分配逻辑见上述描述。如果分配失败,返回 −1。否则,将 pa 指向所分配内存区间的起始地址,设所分配内存区间的大小为 4×2^i 𝐾𝐵*,令 *pi = i ,并返回 0。
int buddy_alloc(u_int size, u_int *pa, u_char *pi) {
    int length = 0;       //申请的物理块的页面数
    int suceess_find = 0; //是否申请成功的标志
    int start = 0;        //申请的物理块的初始页面号
    int end = 0;          //申请的物理块的结束页面号
    int i = 0;
    for (i = 0;i < 8192;i++) { //开始寻找符合条件的块空间,用start、end标记选择的块信息
        if (buddy[i] < 0) length++; //页面未被分配才可以进行选择
        if (buddy[i] > 0) {  //页面已分配,需要更新start为下一个页面,重新遍历选择
            length = 0;
            start = i +1;
            continue;
        }
        if (buddy[i] == -2 && length * 4096 >= size) { //如果选择的块空间大于size 字节,则选择成功
            end = i;
            suceess_find = 1;
            break;
        }
        if (buddy[i] == -2 && length * 4096 < size) { //否则,更新start为下一个页面,重新遍历选择
            start = i + 1;
            length = 0;
            continue;
        }
    }

    if (!suceess_find) return -1; //如果选择失败,返回-1
    else {   //否则,尝试将空闲空间被递归地一分为二成两个伙伴块,直到块大小刚好可以满足请求
        while (1) {
          	//不能再分割时,选择当前块,更新块内页面状态为已被分配
            if (length == 1 || (length / 2) * 4096 < size){ 
                for (i = start;i < end;i++) buddy[i] = 1;
                buddy[end] = 2;
                break;
            //否则接着分割,选取左边的块为选择块,更新选择块信息(start,end),同时标记分割信息(buddy[end] = -2;)
            } else {
                length = length / 2; //分割后长度减半
                end = start + length -1; //更新新选择块的信息
                buddy[end] = -2; //标记分割信息,buddy[end]属于右边界页面,更新其状态
             }
         }
    }
    *pa = 0x2000000 | (start * 4096); //写回分配块的首地址
    int pow = 0; //分配内存区间的大小为 4×2^i KB,令 *pi = i,并返回0。
    while (length > 0) {
        pow ++;
        length = length >> 1;
    }
    *pi = pow - 1;
    return 0;
}
  • 释放函数 buddy_free

    • 函数原型为: void buddy_free(u_int pa)

    • 调用此函数后,通过伙伴系统释放一个状态为已分配的内存区间,其起始地址为 pa 。释放后的合

      并逻辑见上述描述。

//此函数最为麻烦,望耐心观看理解
void buddy_free(u_int pa) {
    int start = (pa - 0x2000000) / 4096; //获取需要被释放空间的首页面地址
    int end = 0; //被释放空间的尾页面地址
    int length = 1; //被释放空间的页面数
    int i = 0;
    //get length of region
    for (i = start;buddy[i] != 2;i++) length++; //获取需要被释放空间的长度
    //release region
    for (i=start;buddy[i] != 2;i++) buddy[i] = -1; //释放需要被释放空间,注意标记尾页面状态为右边界页面
    buddy[i] = -2;
    end = i;

    int flag = 0; //另一个伙伴是否为空闲空间的标志位
    while(1) { //尝试循环合并伙伴块空间
        flag = 0;
        if (length >= 1024){ //如果当前待合并空间长度大于等于1024,即空间大小大于等于4MB,就停止合并
            break;
        } else if (start % (2 * length) == 0) { 
          //这一步实际目的是寻找当前块的伙伴块位于当前块的左边还是右边
          //解释:一个父块分裂为2个子伙伴块时,左边的块的物理首页面号一定是父块长度的整数倍,这是由分配块策略决定的,因为每次分配块大小都是2的正整数次幂,块左边的页面数必然为当前块大小的整数倍,所以分裂后左子块的首页面号必然是父块长度的整数倍,根据这个特性,可以准确的知道当前块的伙伴块的位置
          //例如父块[1024,1025,1026,1027],分裂为子块A[1024,1025],B[1026,1027],则必有1024%4=0,1026%4!=0
            for (i = end + 1;i <= end + length;i++) { //判别2伙伴块是否都为空闲块,如果不是则停止合并
                if (buddy[i] > 0) flag = 1;
            }
            if (flag == 1) break;
            end = end + length; //如果均为空闲块,合并块,更新块信息
            length = length * 2;
        } else {
            for (i = start - length;i <= start - 1;i++) {//判别2伙伴块是否都为空闲块,如果不是则停止合并
                if (buddy[i] > 0) flag = 1;
            }
            if (flag == 1) break; //如果均为空闲块,合并块,更新块信息
            start = start - length;
            length = length * 2;
        }
        for (i = start;i < end;i++) buddy[i] = -1; //最终更新可合并的最大块内部页面状态,块尾页面设置为未被引用的右边界页面
        buddy[i] = -2;
    }   
}

体会与感想


lab2整体难度陡然增加,但是由于实验推迟了一周给了我仔细研究的机会。

首先便是巧妙的链表,各种宏定义与特殊的指针使用非常频繁,让人乍一看无从下手。但是多阅读几遍之后,才发现其精妙之处,例如le_prev的设计可以减少访存次数。随着理解的不断深入,我发现阅读并理解一段优秀的代码也是一个程序员必须具备的本领,同时写出一份让别人伤赏心悦目的代码也是一个优秀的程序员所应该追求的目标。

然后是建立物理页面管理,各种page_*函数让我眼花缭乱,不知所措,好在课程组不吝自己的代码,放出成片的代码,只让我们填写几个小空,虽然可以快速完成任务,但是我还是在完成后对各种函数做了深入理解,了解其流程,做到知其然,知其所以然。

二级页表的引入让我回忆起了祭祖学习的峥嵘岁月。但是观看了指导书后,对于二级页表的实现和TLB的重填有了更深的理解,tlb_out让我回忆起了mips汇编语言(淘)。

页表的自映射机制难以理解,但是精髓并不是很复杂,通过教程和课件学习如何计算\(PD_{base} 和 PD_{self\_mapping}\),并了解了自映射机制的优点、所及页目录自映射机制。

lab2代码上贯下连性非常强,要多读代码对各种函数有较强的敏感度,在阅读时才能够减少障碍,快速定位到需要看的函数,通过不断阅读记住其中的细节,慢慢地才能够熟练应用。

实验指导遗难


  • usr/include/sys/queue.h中对于单向链表和双向链表都没有给出LIST_INSERT_BEFORE这个宏,但是实际上双向链表除了LIST_REMOVE有优势之外,在LIST_INSERT_BEFORE也有优势,因为单向链表和双向链表也需要遍历去实现LIST_INSERT_BEFORE,建议加上去,以单向链表为例,可以写出代码:
#define SLIST_INSERT_BEFORE(slistelm, elm, field) do {\
		if (SLIST_FIRST((head)) == NULL) { \
         SLIST_INSERT_HEAD((head),(elm),field); \
     }else { \
         SLIST_NEXT((elm),field) = SLIST_FIRST((head)); \
         while(SLIST_NEXT(SLIST_NEXT((elm),field),field) != slistelm) {  \
            SLIST_NEXT((elm), field) = SLIST_NEXT(SLIST_NEXT((elm),field),field); \
         } /* now SLIST_NEXT((elm),head) is ELM before slistelm */  \
      	 SLIST_NEXT(SLIST_NEXT((elm), field), field) = (elm); \
         SLIST_NEXT((elm), field) = (listelm); \
    } \
} while (/*CONSTCOND*/0)
  • 在链表宏实现过程中,为什么用单独的子结构体field字段封装*le_next和**le_prev以实现链表项的互连,这样实现的优势在哪里呢?

  • 第二部分开始,从二级页表直接跳跃到了TLB和tlb_invalidate,虽然之前学习过,但是衔接不够流畅,让我在刚开始阅读的时候产生了很多的误解。

  • 观察 pgdir_walk

if (create) {
	//如果页面申请失败,返回失败码
  //page_alloc(**p),传入指针地址,直接修改指针*p为申请到的页面首地址
  if ((ret = page_alloc(&ppage)) < 0) return ret;
  //如果申请成功,ppage就是申请页面的首地址
  //page2pa(ppage)返回Page *pp对应的物理页首地址
  //获取的物理地址填入*pgdir_entryp即可
  	*pgdir_entryp = (page2pa(ppage)) | PTE_V | PTE_R;
    ppage->pp_ref++;
  } else {
    *ppte = 0;
    return 0;
}

else部分为什么向*ppte写入0还可以正常返回?

  • 实验中花费了大量的笔墨书写页目录自映射相关的知识,可是我们的操作系统在二级页表实现中却没有利用该技巧,我不太清楚其中的缘由。

至此,lab2的实验报告完成,如释重负,也收获满满!


posted @ 2022-07-06 22:10  `Demon  阅读(3)  评论(0编辑  收藏  举报