Give it to u——Ucore Lab2 with challenge

Give it to u——Ucore Lab2 with challenge

水平有限,欢迎指正
注:在本文中,申请,释放内存块在实现的过程中实际上操作的是内存块的管理结构,但由于表达方便,所以直接以分配/释放/取出/插入内存块来代替。
本文对地址的称谓按照地址转换的阶段分为 逻辑地址、线性地址、物理地址
老规矩:先贴参考资料

从内核漏洞看内存分配 https://blog.csdn.net/qq_54218833/article/details/127218102?spm=1001.2014.3001.5502
linux 页框分配 https://www.cnblogs.com/tolimit/p/4551428.html
slab概述 https://www.cnblogs.com/tolimit/p/4566189.html
slab代码参考:https://www.cnblogs.com/ECJTUACM-873284962/p/11282683.html
Linux内核伙伴系统分析 薛峰 安徽师范大学 https://www.zhihu.com/xen/market/remix/paid_magazine/1394602850941566976
伙伴系统代码参考:https://zhuanlan.zhihu.com/p/463134188

0、读取物理内存分布分析及ucore实际内存分布统计

0.1 物理内存读取

lab2的代码中,bootasm.S中增加了统计物理内存的代码,代码如下

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

上述代码通过BIOS中断获取内存可调用参数为e820h的INT 15h中断来将内存段的信息加载到内存。在正式调用int 15h之前,我们要对寄存器初始化进行参数准备。

  • eax:e820h:INT 15的中断调用参数
  • edx:534D4150h (即4个ASCII字符“SMAP”)
  • ebx:如果是第一次调用或内存区域扫描完毕,则为0。 如果不是,则存放上次调用之后的计数值
  • ecx:保存地址范围描述符的内存大小,应该大于等于20字节;
  • es:di:指向保存地址范围描述符结构的缓冲区,BIOS把信息写入这个结构的起始地址。

结果如下:

e820map:
  memory: 0009fc00, [00000000, 0009fbff], type = 1.
  memory: 00000400, [0009fc00, 0009ffff], type = 2.
  memory: 00010000, [000f0000, 000fffff], type = 2.
  memory: 07ee0000, [00100000, 07fdffff], type = 1.
  memory: 00020000, [07fe0000, 07ffffff], type = 2.
  memory: 00040000, [fffc0000, ffffffff], type = 2.

其中type=1的内存表示操作系统可以使用的内存,type=2是不能使用的物理内存空间。注意, 2中的"不能使用"指的是这些地址不能映射到物理内存上, 但它们可以映射到ROM或者映射到其他设备,比如各种外设等等。
在这里,有一件很奇怪的事情,我们看到kernel.ld中出现了如下内容

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

也就是说,kernel的text节将从0xc0100000开始,然而对照上面的物理内存分配表,该地址处在OS不能使用的位置,这是怎么回事?其实这与后续的段页式管理有关,0xC0100000这个地址肯定要被映射到物理内存可用的地方,而这也让我们确定了在进入内核之前ucore会设置好页式存储的机制,这些我们后面再说,为了弄清内核会被加载到哪里,这时只需要把目光移到读取磁盘的bootmain.c中:

for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

在参照ELF的programme header从磁盘读取各个节时,程序对于最重要写到的物理地址保留了低24位,所以kernel实际被加载的地址是0x100000。
对make生成的二进制文件kernel进行简单的逆向,我们可以得到简单的物理内存分配如下。
注:如果在后续添加代码增大了kernel的大小,有些需要地址对齐的结构的位置也会发生变化,比如page dir等

--------------------------------------   低地址
|                                    |
--------------------------------------  
|            stack of boot           | 
|------------------------------------|
|               bootblock            |  0x7c00
|------------------------------------|  0x7e00
|                                    |
|-------------kernrl header----------|  0x10000  
|                                    |
|------------------------------------|--0x100000----
|                kernel              |             |
|                                    |              \
|------------------------------------|  0x116000     \
|             kernel stack           |                \
|------------------------------------|  0x118000       |ucore
|------------------------------------|  0x119000       |kernel
|             page dir and           |                /
|              page tables           |               /
|------------------------------------|  0x11B000    /
|------------------------------------|--0x11BF28---|   
|             内存管理结构            |             
|           可分配的空闲内存          |  
|                                    |
--------------------------------------   高地址

写到这里,我不得不提醒自己一下,已经快忘记了ucore对内存的映射机制做出了什么样的改变了,鉴于Lab2的代码相较Lab1做出了不少的改变,还是让我们在创造奇迹之前一起来看一看吧,如果你很熟悉代码可以跳到第一部分:

0.2 ucore内存管理现状

首先,在bootasm.S中,我们打开了保护模式,利用引导扇区中初始的gdt将逻辑地址映射到物理地址,除了增加了段寄存器外,此时32位的偏移与物理地址相同。

lgdt gdtdesc
gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel
                                  ……
gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

之后,我们在bootmain中读入了elf格式的内核,读入时由于ds对应的段基地址为0,所以写入的物理地址等于逻辑地址的偏移。此处映射关系没有改变,不再赘述。
在loader把权限交给内核时,入口点从kernel_init变成了kern_entry,其中就率先开启了分页机制。
很重要的一点就是,在设置了cr0的PG位之后,我们的所有寻址都要经过两次转换,从逻辑地址到线性地址的转换在不涉及特权级转换的时候相对透明,而页式存储带来的地址转换很多时候要以显式的方式出现在程序中。

movl $REALLOC(__boot_pgdir), %eax 
    movl %eax, %cr3 ;开启分页机制

    movl %cr0, %eax
    orl $(CR0_PE | CR0_PG | CR0_AM | CR0_WP | CR0_NE | CR0_TS | CR0_EM | CR0_MP), %eax
    andl $~(CR0_TS | CR0_EM), %eax
    movl %eax, %cr0
.globl __boot_pgdir
    # map va 0 ~ 4M to pa 0 ~ 4M (temporary)
    .long REALLOC(__boot_pt1) + (PTE_P | PTE_U | PTE_W)
    .space (KERNBASE >> PGSHIFT >> 10 << 2) - (. - __boot_pgdir) # pad to PDE of KERNBASE
    # map va KERNBASE + (0 ~ 4M) to pa 0 ~ 4M
    .long REALLOC(__boot_pt1) + (PTE_P | PTE_U | PTE_W)
    .space PGSIZE - (. - __boot_pgdir) # pad to PGSIZE

.set i, 0
__boot_pt1:
.rept 1024
    .long i * PGSIZE + (PTE_P | PTE_W)
    .set i, i + 1
.endr

这段代码乍看十分困难,但是请不要着急。首先,__boot_pgdir处为页目录,其每个表项为每个页表的物理地址,页目录的数量取决于地址长度,页表大小,页目录大小,在32位机器上,页表大小2^12 byte,页目录大小也为2^12 byte。所以:
(KERNBASE >> PGSHIFT)描述了从0到kernelbase需要的页表数目。
(KERNBASE >> PGSHIFT >> 10) 描述了从0到kernelbase需要的页目录表项的条数。
(KERNBASE >> PGSHIFT >> 10 << 2) 描述了从0到kernelbase需要的页目录表项的空间。
因此,__boot_pgdir前半部分对应的是0到kernelbase的空间,后半段对应了kernelbase到4G的空间。接下来的__boot_ptl就是映射物理地址0到4M的页表了,之后,entry.S将页目录的第一条和kernel对应的第一条指向了映射物理地址0到4M的页表,并且开启了cr0的PG位,自此ucore开始了段页式内存管理。
操作系统在这之后进入了熟悉的kern_init,其中调用了一些屏幕初始化,打印信息等等函数,这些都与内存分配无关,直到pmm_init函数。

pmm_init(void) {
    boot_cr3 = PADDR(boot_pgdir);
    //初始化内存管理结构
    init_pmm_manager();
    //初始化页表
    page_init();
    //分配空间检查
    check_alloc_page();
    //检查页目录情况
    check_pgdir();
    ……
}

首先是init_pmm_manager();它将全局结构体指向了一个默认的处理函数表,并调用了其中的init函数,而这个默认的函数是把全局的双向链表的前向后向指针全部指向自身。(双向链表很正常的初始化形式)

for (i = 0; i < npage; i ++) {
        SetPageReserved(pages + i);
    }
//给地址管理结构的表项全部初始化成无法使用的内存(注,不是改页表)
    uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);

    for (i = 0; i < memmap->nr_map; i ++) {
        uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
        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);
        //将页表中的这些项目初始化变成可用的内存
                }
            }
        }
    }

接下来的page_init也相当重要,它将之前在物理地址0x8000的物理内存信息输出并且统计总内存和最高的物理地址。在这之后,它在比内核更高的地址初始化很多Page结构体,这些结构体都是带有双向链表的,它们相当于记录了物理内存的状态,之后我们会用他们进行内存管理。
接下来,我们观察init_memmap调用的默认初始化管理代码:

//初始化从base开始指向的管理结构,连续n个物理页的空闲空间
default_init_memmap(struct Page *base, size_t n) {
    assert(n > 0);
    struct Page *p = base;
    //连续初始化n个管理结构
    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;
    //将头链入双向链表
    list_add(&free_list, &(base->page_link));
}

看到这里已经快结束了,现在进入冲刺,分析一下ucore实现的简单的入链操作。

static inline void
__list_add(list_entry_t *elm, list_entry_t *prev, list_entry_t *next) {
    prev->next = next->prev = elm;
    elm->next = next;
    elm->prev = prev;
}

分析一下,图示大概如此:

 ---------------------------------------
 |  ------------       ------------    |
 -->|   next   |------>|   next   |-----
 ---|   prve   |<------|   prve   |<----
 |  ------------       ------------    |
 ---------------------------------------
free_list(函数中prve)  old(函数中的next)
 ----------------------------------------------------------
 |  ------------       ------------       ------------    |
 -->|   next   |------>|   next   |------>|   next   |-----
 ---|   prve   |<------|   prve   |<------|   prev   |<----
 |  ------------       ------------       ------------    |
 ----------------------------------------------------------
 free_list头           new待插入节点        old(函数中的next)

到这里辛苦阅读的你了,我们终于完成了代码审计,勾勒出了ucore的内存分配前置代码,接下来,我们的工作正式开始。

1、实现 first-fit 连续物理内存分配算法

很幸运的是,default_pmm.c中已经实现了 default_init, default_init_memmap, default_alloc_pages, default_free_pages这几个函数,我们没有必要重新开始对这些部分进行构思与创建。但不幸的是,我们的default_check函数ucore代码也给写好了,这就代表着我们要以「问题发现者」的眼光审视这几个函数,来找到问题。
对症下药,我们先来运行内核来看看default_check会在哪里出现问题。
注:我们这里之所以不去按照EXERCISE 1的提示来写是因为提示中的大部分内容在其中已经实现,

Booting from Hard Disk..(THU.CST) os is loading ...
……
kernel panic at kern/mm/default_pmm.c:277:
    assertion failed: (p0 = alloc_page()) == p2 - 1
……

根据assert断言找到出问题的位置,发现在default_pmm.c:277行,我们把前后代码段贴出:

    //252行开始,此时free_list中只有一个完整的未被分配的大块
    //声明三个指向内存管理结构的指针,其中一个引用刚申请的五块内存
    struct Page *p0 = alloc_pages(5), *p1, *p2;
    //得要申请成功并且p0的空闲头要被清空
    assert(p0 != NULL);
    assert(!PageProperty(p0));
    //暂存free_list和nr—_free_store,重新初始化free_list
    list_entry_t free_list_store = free_list;
    list_init(&free_list);
    assert(list_empty(&free_list));
    assert(alloc_page() == NULL);

    unsigned int nr_free_store = nr_free;
    nr_free = 0;
    //从第三个内存块开始释放三个(覆盖第一次申请的3、4、5号块)
    free_pages(p0 + 2, 3);
    //现在free_list中空闲块的只有3个,所以申请不了4个块
    assert(alloc_pages(4) == NULL);
    //验证这三个块确实已经被释放
    assert(PageProperty(p0 + 2) && p0[2].property == 3);
    //再把它们申请回来,现在free_list又变为空的
    assert((p1 = alloc_pages(3)) != NULL);
    assert(alloc_page() == NULL);
    assert(p0 + 2 == p1);

    p2 = p0 + 1;
    //----- ----- ----- ----- -----
    //|  1  |  2  |  3  |  4  | 5  |
    //p0    p2    p1
    //将1和3、4、5都释放,并检验
    free_page(p0);
    free_pages(p1, 3);
    assert(PageProperty(p0) && p0->property == 1);
    assert(PageProperty(p1) && p1->property == 3);

    //现在free_list里面应该有两个内存块,一个大小为1,一个大小为3,而这个检查要求取出来的块是大小为1的块
    assert((p0 = alloc_page()) == p2 - 1);

首先贴上双向链表表项的代码,偏移0处为前向指针,偏移4处为后向指针,实际运行代码到free_page(p0); free_pages(p1, 3);两句进行动态调试。

struct list_entry {
    struct list_entry *prev, *next;
};
────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────
In file: /home/emiya/桌面/tmp/lab2/kern/mm/default_pmm.c
   268     assert(alloc_page() == NULL);
   269     assert(p0 + 2 == p1);
   270 
   271     p2 = p0 + 1;
   272     free_page(p0);
 ► 273     free_pages(p1, 3);
   274     assert(PageProperty(p0) && p0->property == 1);
   275     assert(PageProperty(p1) && p1->property == 3);
   276 
   277     assert((p0 = alloc_page()) == p2 - 1);
   278     free_page(p0);
──────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> x/20wx &free_area
0xc011bf1c <free_area>:	0xc011e2bc	0xc011e2bc	0x00000001	0x00010010
……
pwndbg> x/20wx 0xc011e2bc
0xc011e2bc:	0xc011bf1c	0xc011bf1c	0x00000000	0x00000000
……
pwndbg> 

free(p0)后,可以看到双向链表中只有一项在0xc011e2bc,该项前后指针都指向全局链表头free_area。
继续释放p1开始的3个结构

In file: /home/emiya/桌面/tmp/lab2/kern/mm/default_pmm.c
   269     assert(p0 + 2 == p1);
   270 
   271     p2 = p0 + 1;
   272     free_page(p0);
   273     free_pages(p1, 3);
 ► 274     assert(PageProperty(p0) && p0->property == 1);
   275     assert(PageProperty(p1) && p1->property == 3);
   276 
   277     assert((p0 = alloc_page()) == p2 - 1);
   278     free_page(p0);
   279     assert((p0 = alloc_pages(2)) == p2 + 1);
──────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> x/20wx &free_area
0xc011bf1c <free_area>:	0xc011e2bc	0xc011e2e4	0x00000004	0x00010010
……
pwndbg> x/20wx 0xc011e2e4
0xc011e2e4:	0xc011bf1c	0xc011e2bc	0x00000000	0x00000000
……
pwndbg> x/20wx 0xc011e2bc
0xc011e2bc:	0xc011e2e4	0xc011bf1c	0x00000000	0x00000000
……
pwndbg> 

从free_area一直沿着next指针(偏移为4)检查,可以看到,新的项0xc011e2e4被插到了链表头和0xc011e2bc的中间。
最后,调试到出错的断言处,

► 0xc0104230 <default_alloc_pages+140>    cmp    dword ptr [ebp - 0xc], 0      <bootstack+7820>
……
────────────────────────────────────────[ SOURCE (CODE) ]─────────────────────────────────────────
In file: /home/emiya/桌面/tmp/lab2/kern/mm/default_pmm.c
   132         if (p->property >= n) {
   133             page = p;
   134             break;
   135         }
   136     }
 ► 137     if (page != NULL) {
   138         list_del(&(page->page_link));
……

此处刚刚进行了p对链表的遍历,拿出了合适的Page放到page(ebp-0xc,对照上方的汇编代码)中,查看该处内存为0xc011e2d8

pwndbg> x/20wx 0xc0117e98-0xc
0xc0117e8c:	0xc011e2d8	0xc011e2bc	0xc011bf1c	0xc0117ec8
……

而根据Page的结构判断,其双向链表结构的偏移为0xc,所以地址为0xc011e2e4,与p1的地址相同,而断言让我们拿出的是p2-1,即p0。
由上面分析可知,alloc_pages要求拿出的不是刚刚放进free_list中的内存块,而是刚刚好适合或者说最小能符合申请要求的内存块,所以我们的alloc_pages和free_pages在链入内存块时要做到有序链入。
现在来进行代码审计,需要进行链入的有两个地方,第一个地方是default_alloc_pages对大内存进行切割时会将切割后的剩余部分重新链入,如下:

static struct Page *
default_alloc_pages(size_t n) {
    assert(n > 0);
    //没有空闲空间了
    if (n > nr_free) {
        return NULL;
    }
    struct Page *page = NULL;
    list_entry_t *le = &free_list;
    //找到大于等于申请要求的第一个内存块,first-fit
    while ((le = list_next(le)) != &free_list) {
        struct Page *p = le2page(le, page_link);
        if (p->property >= n) {
            page = p;
            break;
        }
    }
    struct list_entry_t* it;
    //如果找到了,先将这个块从free_list中拿出,再判断是否要切割
    if (page != NULL) {
        list_del(&(page->page_link));
        //切割代码
        if (page->property > n) {
            struct Page *p = page + n;
            //计算切割后的长度
            p->property = page->property - n;
            //***********添加代码***********
            //找到第一个比切割剩余要大的内存块并把剩余块插到它之前
            for(it=list_next(&free_list);it!=&free_list && le2page(it,page_link)->property < p->property;it=list_next(it));
            list_add(list_prev(it), &(p->page_link));
            //***********添加代码***********
        }
        //更新剩余总空间和清楚free标志的操作
        nr_free -= n;
        ClearPageProperty(page);
    }
    return page;
}

另一个是释放内存块的时候入链的操作:

static void
default_free_pages(struct Page *base, size_t n) {
    assert(n > 0);
    //检查即将free的每个内存块知否有已经在链表中的
    struct Page *p = base;
    for (; p != base + n; p ++) {
        assert(!PageReserved(p) && !PageProperty(p));
        p->flags = 0;
        set_page_ref(p, 0);
    }
    //在连续的空闲内存的第一个块设置头标志
    base->property = n;
    SetPageProperty(base);
    list_entry_t *le = list_next(&free_list);
    //遍历整个free_list,寻找有没有可以前向或者后向合并的内存块集合
    while (le != &free_list) {
        p = le2page(le, page_link);
        le = list_next(le);
        //后向合并(向高地址合并)
        if (base + base->property == p) {
            base->property += p->property;
            ClearPageProperty(p);
            list_del(&(p->page_link));
        }
        //前向合并(向低地址合并)
        else if (p + p->property == base) {
            p->property += base->property;
            ClearPageProperty(base);
            base = p;
            list_del(&(p->page_link));
        }
    }
    nr_free += n;
    //将合并完的结果按序插入free_list
    //************添加代码************
    struct list_entry_t* it;
    for(it=list_next(&free_list);it!=&free_list && le2page(it,page_link)->property < base->property;it=list_next(it));
    list_add(list_prev(it), &(base->page_link));
    //************添加代码************
}

结果如下,通过了default_check:

emiya@emiya-virtual-machine:~/桌面/OS/lab2$ (THU.CST) os is loading ...
……
check_alloc_page() succeeded!
kernel panic at kern/mm/pmm.c:478:
    assertion failed: get_pte(boot_pgdir, PGSIZE, 0) == ptep
……

2、页表管理

2.1 获得页表项

在x86页式存储中,页表的大小为2^12字节,刚好和一个页一样大。在寻址时,利用高10位寻找合适的页目录项,向下10位寻找页表项,最后12位当作页中的偏移,仿照访存的过程,我们整理一下书写思路:

  • 找到页目录项
    • 判断该项是否存在,如果不存在申请一个页表用来填写页目录项,在申请的页表中寻找页表项
    • 如果存在,那么在该页表中直接寻找

在寻找时,各个地址的转换尤其令人头疼,在这里来做一个总结,

  • 页表,页目录的内容是物理地址,访问页表的时候如果还需要再次地址翻译那就会无限递归访问下去,所以页表中的地址必须是物理地址。
  • 修改页目录,页表时要转换成线性地址,因为在运行过程中分页机制已经启动,我们需要将页表中的物理地址转化才能在kernel中访问页表。
pte_t *
get_pte(pde_t *pgdir, uintptr_t la, bool create) {
    //得到对应的pde下标
    uint32_t pde_idx = PDX(la);
    //找到对应pte
    uint32_t *pde_tar = (uint32_t)pgdir + (pde_idx<<2);
    uint32_t pte_idx = NULL;
    uint32_t *pte_tar = NULL;
    uint32_t exi_page;

    //观察是否存在表项
    if (!(*pde_tar & PTE_P))
    {
        //如果需要创建
        if (create)
        {
            //申请一个二级页表的空间,并且设置引用数
            struct Page *new_page = alloc_page();
            if(new_page==NULL)
            	return NULL;
            set_page_ref(new_page, 1);
            //获得该二级页表所对应的物理地址,初始化二级页表后,将其填入pde
            uintptr_t pt_addr = page2pa(new_page);
            memset(KADDR(pt_addr),0x0,PGSIZE);
            *pde_tar = pt_addr| PTE_P | PTE_W | PTE_U;
            pte_idx = PTX(la);
            pte_tar = KADDR(pt_addr) + (pte_idx << 2);
        }
        else
        {
            return NULL;
        }
    }
    else
    {
        //获得二级页表的线性地址
        exi_page = (uint32_t)KADDR(*pde_tar) & 0xfffff000;
        //计算对于pte的偏移
        pte_idx = PTX(la);
        pte_tar = exi_page + (pte_idx << 2);
    }
    return (pte_t*)pte_tar;
}

2.2 移除/重置页表项

移除页表项就没有那么的困难了,本质上是查看页表项对应的page是否没有引用,如果引用为0的话清除页表项并且flush tlb即可,不用清除页目录。

static inline void
page_remove_pte(pde_t *pgdir, uintptr_t la, pte_t *ptep) {
    if (*ptep & PTE_P) {
        struct Page *page = pte2page(*ptep);
        if (page_ref_dec(page) == 0) {
            free_page(page);
        }
        *ptep = 0;
        tlb_invalidate(pgdir, la);
    }
}

3、Challenge1 Buddy system

代码大部分来源:https://zhuanlan.zhihu.com/p/463134188

注:十分感谢和佩服提供该代码的大神,这版代码的可读性强于树形的buddy system,更加接近malloc的组织形式。然而,参考代码存在一些bug,并且使用十分危险的goto,笔者在调试时对该代码进行了错误矫正和优化,并通过了参考代码作者的check,代码可以放心使用。

3.1 从问题开始——什么是Buddy System

利用我们在前两节实现的ucore代码来举例,在内核中,有一个全局的双向链表头free_area,串联所有的空闲空间,在申请内存块时,内核遍历从free_area开始的双向链表找到合适(我们的代码中为first-fit)的内存块取出;在释放内存块时检查前后合并,然后将对应的Page放入链表。
Buddy System是在此基础上发展出的管理模式,它将free_area变成了链表头的数组,每个链表头连接的是一系列大小相同的连续内存块,并且大小只能是2的幂次方个Page。在分配时,我们从最合适(最小能够满足需求的)大小的链表中取出内存块,如果找不到正好符合的,那么就往更大的链表寻找;在释放时检查前后合并,在合并后存放到相应大小的链表。
在这样的分配模式下,因为不用遍历总链表,寻找目标内存块的时间大幅减少。

3.2 初始化部分

首先,就是要将我们管理结构中的链表free_list从一个变成多个,这是buddy与free_area的唯一区别。而链表的数目取决于物理内存的大小。

typedef struct 
{
    unsigned int max_order;
    list_entry_t free_array[MAX_BUDDY_ORDER + 1];
    unsigned int nr_free;
} free_buddy_t;

free_buddy_t buddy_s;

然后,对于每一个链表的初始化,我们仿照default_pmm进行初始化

static void buddy_init()
{
    for(int i=0;i<MAX_BUDDY_ORDER;i++)
    {
        list_init(&buddy_array[i]);
    }
    max_order=0;
    nr_free=0;
    return;
}

然后在把管理结构与页描述符Page挂钩的时候,我们选择把最接近物理内存总量的,2的幂数量的Page 串起来放到相应的(由该幂2的对数决定)链表中。
此处有两个简化:一是page_init函数中的要求仅仅有一段物理内存满足,所以可以预见到buddy_init_memmap只会被调用一次,所以初始化按照一次的来进行。二是符合要求的物理内存页总数有30000余个但不到2^15,为了实现伙伴系统,这里使用FixDown削减掉了近乎一半的物理内存。

static void buddy_init_memmap(struct Page* base,size_t n)
{
    assert(n>0);
    size_t pcnt=FixDown(n);
    size_t order=GetOrder(pcnt);
    struct Page* p=base;
    for(;p!=base+pcnt;p++)
    {
        assert(PageReserved(p));
        p->flags=0;
        p->property=-1;
        set_page_ref(p,0);
    }
    max_order=order;
    nr_free=pcnt;
    list_add(&(buddy_array[max_order]),&(base->page_link));
    base->property=max_order;
    return;
}

初始化完成后链表内容如下(由代码打印):

0 order:
1 order:
2 order: 
3 order: 
4 order: 
5 order: 
6 order: 
7 order: 
8 order: 
9 order: 
10 order: 
11 order:  
12 order:  
13 order: 
14 order: 16384
---------------------------
[*]Core: nr_free: 16384

3.3 分裂与申请

在初始化之后,所有的内存都被串在了最大order的list上,如果我们现在开始申请内存,那么这个大块内存一定要被分裂。这就引出了第一个问题:分裂函数传入的参数是内存(描述符)还是待申请内存大小呢?答案是很明确的,分裂的时候应该传入待分裂大小,因为在申请内存时,我们并不能指定我们需要的内存,我们只能指定申请大小,分配器只管从对应链表中找到可以分裂的大内存进行分裂即可。
分裂代码如下:

static void buddy_split(size_t n) 
{
    assert(n > 0 && n <= max_order);
    assert(!list_empty(&(buddy_array[n])));
    cprintf("[*]Core: SPLITTING!\n");
    struct Page *page_a;
    struct Page *page_b;
//从对应的大小的list中取出第一个内存块,取其中点作为分裂后另一半的头
    page_a = le2page(list_next(&(buddy_array[n])), page_link);
    page_b = page_a + (1 << (n - 1));
    page_a->property = n - 1;
    page_b->property = n - 1;
//将原来分裂前的内存取出,将新的两个内存链入更小的链表
    list_del(list_next(&(buddy_array[n])));
    list_add(&(buddy_array[n-1]), &(page_a->page_link));
    list_add(&(page_a->page_link), &(page_b->page_link));
    return;
}

那么实现了分裂,我们的申请就简单了:

  • 检查申请大小,寻找比size大的最近的2的幂记为pnum
  • 只要刚好符合pnum大小的链表为空
    • 向上逐级找到非空的链表
    • 分裂其中的第一个内存块
  • 直到存在大小符合pnum的内存块,将其从链表中拿出,设置头位,将头Page返回。

代码如下:

static struct Page *buddy_alloc_pages(size_t n) 
{
    assert(n > 0);
    if (n > nr_free) 
    {
        return NULL;
    }
    struct Page *page = NULL;
    size_t pnum = FixUp(n);
    size_t order = 0;
    order = GetOrder(pnum);
    while(list_empty(&buddy_array[order]))
    {
        for(int i=order;i<max_order+1;i++)
        {
            if(!list_empty(&buddy_array[i]))
            {
                buddy_split(i);
                break;
            }
        }
    }
    page = le2page(list_next(&(buddy_array[order])), page_link);
    list_del(list_next(&(buddy_array[order])));
    SetPageProperty(page);
    show_buddy_array();
    nr_free -= pnum;
    return page;
}

3.4 伙伴与释放

buddy的概念可以说是伙伴系统中最重要的部分,它决定了什么样的内存块之间可以进行合并,并且在合并后内存块依然保持伙伴系统的要求。在这里,我们引用一些比较理论化的定义:

Linux内核伙伴系统分析 薛峰 安徽师范大学 https://www.zhihu.com/xen/market/remix/paid_magazine/1394602850941566976

其中,作者对「buddy」的概念进行了细致的分析,摘抄如下:

互为buddy的条件:(1)二者在内存中相邻且不重叠;(2) 二者具有相同的阶;(3)假设二者的阶都为 k则合并后 形成一个k+1阶空闲内存块,该内存块的首页索引恰好能够被2^(k+1)整除。
定理 1.假设待回收内存块的阶为k,其首页索引为P,其伙伴的首页索引为b,则有下式成立:
b = p ⊕ 2 k b=p⊕2^k b=p2k
在这里可以举一个例子

M1(2) M2(2) M3(2) M4(2)

这里M1-M4都是大小为2个页框的页,M2的buddy为M1,因为其合并后首地址为0可被4整除,而M3的buddy为M4,也是因为M3,M4合并之后内存首地址为4,可以被4整除。如果M2 M3合并,那么内存首地址为2,不满足,这代表着大小为2的M1将作为不能与更大内存块合成的碎片。
buddy_find实现如下

static struct Page* buddy_find(struct Page *page) 
{
    unsigned int order = page->property;
    //异或加法计算伙伴的序号
    unsigned int buddy_ppn = first_ppn + ((1 << order) ^ (page2ppn(page) - first_ppn));
    //找到具体的page,如果地址较高就在后方,利用指针加减法寻找buddy Page
    if (buddy_ppn > page2ppn(page))
    {
        return page + (buddy_ppn - page2ppn(page));
    }
    else 
    {
        return page - (page2ppn(page) - buddy_ppn);
    }
}

其中,first_ppn是记录内核掌管的第一个可以被用来分配的page的序号,也是上文的「该内存块的首页索引恰好能够被2^(k+1)整除。」中的起始索引,在pmm.c page_init()检索物理内存初始化Page时记录。

if (begin < end) 
{
    first_ppn = page2ppn(pa2page(begin));
    init_memmap(pa2page(begin), (end - begin) / PGSIZE);
}

了解完了这些,我们来整理一下思路,在释放时我们需要干什么:

  • 对待释放块进行检查
  • 按照异或运算,寻找待释放块的伙伴
    • 如果该伙伴也为释放状态,则将其拿出链表合并,并插入上级页表
    • 回到寻找伙伴的步骤,直到没有伙伴或者伙伴还在已申请状态
  • 设置最终释放块的标志位等等

代码实现如下

static void buddy_free_pages(struct Page *base, size_t n) 
{
    assert(n > 0);
    unsigned int pnum = 1 << (base->property);
    //这里要限制释放大小必须是对应头内存块所记录的大小
    assert(FixUp(n) == pnum);
    struct Page* left_block = base;
    struct Page* buddy = NULL;
    struct Page* tmp = NULL;
    //寻找伙伴,先将待释放内存放入对应的链表中
    buddy = buddy_find(left_block);
    list_add(&(buddy_array[left_block->property]), &(left_block->page_link));
    //如果buddy是free的,并且被合并的内存块小于最大的内存块单位
    while (!PageProperty(buddy) && left_block->property < max_order) 
    {
        //找到被释放块和buddy中左边的那个作为合并后连续块的头
        if (left_block > buddy)
         {
            left_block->property = -1;
            ClearPageProperty(left_block);
            //redefine the "left"
            tmp = left_block;
            left_block = buddy;
            buddy = tmp;
        }
        //将原来的块从链表中拿出,设置新的大小,放到上级链表中
        list_del(&(left_block->page_link));    
        list_del(&(buddy->page_link));
        left_block->property += 1;
        list_add(&(buddy_array[left_block->property]), &(left_block->page_link));
        //递推寻找伙伴
        buddy = buddy_find(left_block);
    }
    //全部合并结束后设置最后块的flag位,表示其已经被释放
    ClearPageProperty(left_block);
    nr_free += pnum;
    return;
}

简单的check与结果,show_buddy_array打印各级链表的结果,可以通过它观察分割与合并是否成功,也可以间接通过ucore之后的check_pgdir来检验,毕竟如果这两个函数不对会影响页表和页目录的分配。

static void basic_check(void) 
{
    struct Page *p0, *p1, *p2;
    p0 = p1 = p2 = NULL;
    assert((p0 = alloc_page()) != NULL);
    assert((p1 = alloc_page()) != NULL);
    assert((p2 = alloc_page()) != NULL);
    free_page(p0);
    free_page(p1);
    free_page(p2);
    show_buddy_array();

    assert((p0 = alloc_pages(4)) != NULL);
    assert((p1 = alloc_pages(2)) != NULL);
    assert((p2 = alloc_pages(1)) != NULL);
    free_pages(p0, 4);
    free_pages(p1, 2);
    free_pages(p2, 1);
    show_buddy_array();

    assert((p0 = alloc_pages(3)) != NULL);
    assert((p1 = alloc_pages(3)) != NULL);
    free_pages(p0, 3);
    free_pages(p1, 3);

    show_buddy_array();
    cprintf("buddy_check success!");
}

结果如下(举了个递推分裂的例子):

……
13 order: 
14 order: 16384 
---------------------------
[*]Core: Allocating 1-->1 = 2^0 pages ...
[*]COre: Buddy array before ALLOC:
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: SPLITTING!
[*]Core: Buddy array after ALLOC NO.974 page:
[*]Core: Print buddy array:
0 order: 1 
1 order: 2 
2 order: 4 
3 order: 8 
4 order: 16 
5 order: 32 
6 order: 64 
7 order: 128 
8 order: 256 
9 order: 512 
10 order: 1024 
11 order: 2048 
12 order: 4096 
13 order: 8192 
14 order: 
……
buddy_check success!check_alloc_page() succeeded!
[*]Core: Allocating 1-->1 = 2^0 pages ...
……

4、Challenge2 Small Slab

代码参考:https://www.cnblogs.com/ECJTUACM-873284962/p/11282683.html

注:也十分感谢这位大神的代码,这个代码的思路十分清晰,然而,鉴于我和作者在实现buddy system时无论是Page结构体的标识还是实现方式都不同(原作者的slab管理结构复用了释放状态的Page),我将这位作者的代码重写了一部分,并且修复了一些bug。

4.1 slab结构介绍

在linux系统中,slab可以分配任意大小空间,而不是以4k页为单位而分配,而这套slab系统是运行在buddy system之下的,这就需要它与页框内存分配的系统进行对接,下面简述一下slab的结构。
简化的slab具有两层管理结构,一层是kmem_cache,我们接下来简称其为仓库,用来管理slab,另一层是slab,用来管理页框内存。slab按照其固定分配内存的大小而分类,而仓库按照其中存储的slab的类型而分类。
下面给出仓库的定义:仓库中有三个链表,存储的都是不同slab——已经满的slab,未满的slab,空slab,objsize记录了该仓库slab分配内存的大小,num记录了每个slab中能够拥有多少个对象。ctor和dtor以函数指针的方式传递了slab空间的构造函数和析构函数。最后,这些仓库也需要被串在链表中动态管理,所以存在双向链表成员。

struct kmem_cache_t 
{
    list_entry_t slabs_full;
    list_entry_t slabs_partial;
    list_entry_t slabs_free;
    uint16_t objsize;
    uint16_t num;
    void (*ctor)(void*, struct kmem_cache_t *, size_t);
    void (*dtor)(void*, struct kmem_cache_t *, size_t);
    char name[CACHE_NAMELEN];
    list_entry_t cache_link;
};

既然slab需要动态申请,并且申请出来的每个slab掌管着一定的空间(这里简化为一页),与Page结构的功能极为相似,那么我们是否可以将其与Page复用,或者在Page中加入slab字段呢?由于我们的Page在buddy system中其标志位总是有意义的,所以这里不采用复用,而采用添加的方式。

struct Page {
    int ref;    
    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                   
    struct kmem_cache_t *cachep;     //所属的仓库         
    uint16_t inuse;                 //对象个数
    uint16_t free;                  //free指针
    list_entry_t slab_link;         //被链接在仓库中的链表
};

4.2 初始化

我们首先定义一个全局的仓库,cache_cache它是管理仓库的仓库,因为仓库的空间也需要我们去申请,然后我们再定义了串联仓库的链表和几个待初始化的固定大小的仓库指针sized_caches

static list_entry_t cache_chain;
static struct kmem_cache_t cache_cache;
static struct kmem_cache_t *sized_caches[SIZED_CACHE_NUM];

然后进行kmem_init

void kmem_int() 
{
    cache_cache.objsize = sizeof(struct kmem_cache_t);
    cache_cache.num = PGSIZE / (sizeof(int16_t) + sizeof(struct kmem_cache_t));
    cache_cache.ctor = NULL;
    cache_cache.dtor = NULL;
    memcpy(cache_cache.name, cache_cache_name, CACHE_NAMELEN);
    list_init(&(cache_cache.slabs_full));
    list_init(&(cache_cache.slabs_partial));
    list_init(&(cache_cache.slabs_free));
    list_init(&(cache_chain));
    list_add(&(cache_chain), &(cache_cache.cache_link));
    for (int i = 0, size = 16; i < SIZED_CACHE_NUM; i++, size *= 2)
        sized_caches[i] = kmem_cache_create(sized_cache_name, size, NULL, NULL); 
    check_kmem();
}

其中,我们初始化了“仓库的仓库”,并且对八个默认仓库调用kmem_cache_create进行初始化。
创造仓库,需要申请仓库大小的slab,而仓库大小的slab,其父仓库为cache_cache,这就与我们分配一般内存统一起来了,下面给出创建仓库的代码:

struct kmem_cache_t * kmem_cache_create(const char *name, size_t size, void (*ctor)(void*, struct kmem_cache_t *, size_t), void (*dtor)(void*, struct kmem_cache_t *, size_t)) 
{
    assert(size <= (PGSIZE - 2));
    struct kmem_cache_t *cachep = kmem_cache_alloc(&(cache_cache));
    if (cachep != NULL) {
        cachep->objsize = size;
        cachep->num = PGSIZE / (sizeof(int16_t) + size);
        cachep->ctor = ctor;
        cachep->dtor = dtor;
        memcpy(cachep->name, name, CACHE_NAMELEN);
        list_init(&(cachep->slabs_full));
        list_init(&(cachep->slabs_partial));
        list_init(&(cachep->slabs_free));
        list_add(&(cache_chain), &(cachep->cache_link));
    }
    return cachep;
}

是先通过在cache_cache仓库中管理的slab申请内存空间,然后在这个内存空间里初始化仓库,让后进行链表操作。那么,我们还在初始化的时候,又如何能够进行申请呢?答案是,我们的父仓库为全局变量,已经初始化完毕,而我们又有可以分配内存的buddy_system,所以单单是申请仓库大小的空间已经可以了。
下面就是申请内存的代码,该代码首先在仓库的三个链表中寻找空闲的slab,如果没找到,就调用kmem_cache_grow用我们实现的alloc_page申请一页,将其作为一个空的slab来利用,这样就有slab可以分配内存啦。

//从slab中获取对应大小的对象(小内存块)
void * kmem_cache_alloc(struct kmem_cache_t *cachep) 
{
    list_entry_t *le = NULL;
    // Find in partial list 
    if (!list_empty(&(cachep->slabs_partial)))
        le = list_next(&(cachep->slabs_partial));
    // Find in empty list 
    else 
    {
        if (list_empty(&(cachep->slabs_free)) && kmem_cache_grow(cachep) == NULL)
            return NULL;
        le = list_next(&(cachep->slabs_free));
    }
    //将非满slab取出
    list_del(le);
    //slab其实就是Page的一部分,可对应到页框地址
    struct Page *slab = le2page(le, slab_link);
    void *kva = page2kva(slab);
    //free表部分
    int16_t *bufctl = kva;
    //对象/待分配部分
    void *buf = bufctl + cachep->num;
    //根据free位来决定分配的对象
    void *objp = buf + slab->free * cachep->objsize;
    // Update slab
    slab->inuse ++;
    slab->free = bufctl[slab->free];
    if (slab->inuse == cachep->num)
        list_add(&(cachep->slabs_full), le);
    else 
        list_add(&(cachep->slabs_partial), le);
    return objp;
}
//创建新的slab
static void * kmem_cache_grow(struct kmem_cache_t *cachep) 
{
    struct Page *page = alloc_page();
    list_init(&(page->slab_link));
    void *kva = page2kva(page);
    page->cachep = cachep;
    page->inuse = page->free = 0;
    list_add(&(cachep->slabs_free), &(page->slab_link));

    int16_t *bufctl = kva;
    for (int i = 1; i < cachep->num; i++)
        bufctl[i-1] = i;
    bufctl[cachep->num-1] = -1;

    void *buf = bufctl + cachep->num;
    if (cachep->ctor) 
        for (void *p = buf; p < buf + cachep->objsize * cachep->num; p += cachep->objsize)
            cachep->ctor(p, cachep, cachep->objsize);
    return page;
}

这里有一个很重要的问题,slab是如何在比页框更小的范围内进行申请和释放的呢?如图所示,slab对应的一页内存被分为了两个部分,一部分是free表,另一部分才是待分配内存区,待分配区按照大小等份划分,被划分的小内存块就叫做“对象”,free表由16位整数组成,free位指示着可以被分配的对象的序号。一旦出现分配,slab会拿出free序号所对应的对象,然后free位会去取出free位对应free表中的值作为下一个待释放的值;一旦出现释放,slab会在待释放块对应的free表中填入当前free位的值,然后将free位置为刚刚释放的对象的序号。

申请时
free=0
-----------------------------------------------
| 1 | 2 | 3| 4 |............|obj0|obj1|........|
申请后
target_obj=obj[free]
free=freetable[0]=1
下一个就会申请1

释放时
free=1
free(obj0)
释放后
freetable[0]=free=1
free=idx_of_free_obj=0
-----------------------------------------------
| 1 | 2 | 3| 4 |............|obj0|obj1|........|

void kmem_cache_free(struct kmem_cache_t *cachep, void *objp) 
{
    // Get slab of object 
    void *base = page2kva(pages);
    void *kva = ROUNDDOWN(objp, PGSIZE);
    struct Page *slab =  &pages[(kva-base)/PGSIZE];
    // Get offset in slab
    int16_t *bufctl = kva;
    void *buf = bufctl + cachep->num;
    int offset = (objp - buf) / cachep->objsize;
    // Update slab 
    list_del(&(slab->slab_link));
    bufctl[offset] = slab->free;
    slab->inuse --;
    slab->free = offset;
    if (slab->inuse == 0)
        list_add(&(cachep->slabs_free), &(slab->slab_link));
    else 
        list_add(&(cachep->slabs_partial), &(slab->slab_link));
}

我们已经实现了slab中的核心功能了。
在写kmalloc和kfree的时候,我们只需要找到对应申请大小的仓库传入kmem_cache_free/kmem_cache_malloc即可:

void * kmalloc(size_t size) 
{
    assert(size <= SIZED_CACHE_MAX);
    return kmem_cache_alloc(sized_caches[kmem_sized_index(size)]);
}
void kfree(void *objp) 
{
    void *base = page2kva(pages);
    void *kva = ROUNDDOWN(objp, PGSIZE);
    struct Page *slab = &pages[(kva-base)/PGSIZE];
    kmem_cache_free(slab->cachep, objp);
}

5、写点什么

感谢能够有耐心看到这里的你,既然已经看到这里,我也对努力学习的你说一声辛苦了。
这次的实验代码在报告中贴的极其琐碎,我会在适当时候将完整的源码上传至gitee。
操作系统的学习极其复杂繁琐,在实验的过程中要遇到大量的调试和代码审计,或许是个人性格的原因,我能够耐得住这种寂寞和单调。然而,也就是在这个过程中,我们找到了努力探索的意义,可能我们在某一瞬间醍醐灌顶,也可能在一瞬间和当年写出这些代码的作者对上了脑洞。我们在完成这样浩大的工程时往往讶异于自己的耐心和创造力,但其实这样的人早就有了——可能就是我“剽窃”的这些代码的作者,这让我感觉到自己前进的路上没有那么孤单,并且乐此不疲。
“冰冻三尺,非一日之寒。”

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值