五、内存管理系统:makefile、整页分配

来源:互联网 发布:天涯社区知乎 编辑:程序博客网 时间:2024/06/05 18:51

makefile

大多数情况下,我们修改程序只是修改了某些文件,那么编译时候就把依赖于这些文件的相关文件编译即可,用不着编译全部文件。所以我们要直到哪些文件之间存在依赖。文件的依赖关系是定义在一个叫 makefile 的文件中,用 make 命令来控制的。make 和 makefile 并不是用来编译程序的,它只负责找出哪些文件有变化,根据依赖关系找出受影响的文件,然后执行在makefile中定义的命令规则即可。

makefile的基本语法

目标文件:依赖文件[Tab]命令
在Linux中,每个文件有三种时间,分别是 atime、mtime、ctime。
atime:access time,表示访问文件数据部分时间,每次读取文件数据部分都会更新该时间,eg:cat、less 命令查看文件都会更新,但是 ls 不会
ctime:change time,表示文件属性或数据的修改时间,
mtime:modify time,表示文件数据部分的修改时间。
我们只关注数据部分,所以我们只看 mtime,对比依赖文件的 mtime和 mtime 是否比目标文件的 mtime 新,就知道是否要执行规则中的命令,make + makefile 的目的是执行规则中命令。
makefile 的文件名也并非固定,可以 make -f 参数来指定。默认情况下,make 会先去找为 GNUmakefile 的文件,若不存在,再去找 makefile 的文件,若不存在,再去找Makefile
的文件。

跳到目标处执行

makefile 中有很多目标时,我们可以用目标名称作为 make 的参数: make+目标名称,单独执行目标名称处的规则。这只会执行目标名称处的规则,后面的目标不会执行。不过命令的执行必须要看依赖文件的 mtime 是否比目标文件要新。

伪目标

当我们希望不考虑 目标文件和依赖文件的 mtime,而总是去执行下面命令时,需要用到伪目标。

当规则中不存在依赖文件时,目标文件就称为伪目标。伪目标所在的规则就变成了存粹的执行下面一行的命令,只有 make+ 伪目标 ,就直接跳转到目标规则中的命令处直接执行。

需要注意的是:伪目标 不能和 同一文件中的真实目标名字一样。所以我们用关键字 ” .PHONY “ 来修饰伪目标: .PHONY :伪目标名。

make:递归式推到目标

在 makefile 中的目标,是以递归方式 逐层向上 查找目标的。当有很多目标文件时,首先先去找最底层的目标文件,看看是否可以执行,若可以执行,执行完后然后上一层。若因为依赖文件找不到不能执行,则去上一层找依赖文件。目标文件不存在,但是依赖文件文件存在时,可以直接执行下一行的命令。

eg:如果 test1 调用了 test2 的函数。

test2.o:test2.cgcc -c -o test2.o test2.c   test1.o:test1.cgcc -c -o test1.o test1.c   test.bin:test1.o test2.ogcc -o test.bin test1.o test2.o   all:test.bin@echo"done"

自定义变量与系统变量

变量定义的格式:变量名=值(字符串),多个值之间用空格分开。make程序在处理时候用空格将值打散,然后遍历每一个值。

变量引用格式:$(变量名)。每次引用变量时,变量名就会被其值(字符串)代替。

除了用户自定义的变量外,make还自行定义了一些系统级的变量。这些变量都有默认值

隐含规则

对于一些频率使用非常高的规则,make 把它们当作是默认的,不需要显示的写出来。

1,一行写不下,行尾添加  \  表示下一行内容也是这一行的内容

2,# 来单行注释

等等

自动化变量

自动化变量代表一组文件名,无论是目标文件名还是依赖文件名。也就是说自动化变量相当于对文件名集合循环遍历一遍。

$@ 表示规则中的目标文件名集合,当文件中有多个目标文件时候,该符号代表每一个目标文件

$< 表示规则中依赖文件中的第一个文件。

$^ 表示规则中所有依赖文件的集合,有重复的将会去重。

$? 表示规则中所有比目标文件mtime 更新的依赖文件集合。

注意 ” 规则 “的意思是指 ” 一个规则 “,而不是所有的命令规则。

规则模式

模式其实就是指 字符串模子,用于匹配符合规则的字符串。

%通常用在规则中的目标文件中,以用来匹配 该文件中 所有的目标文件。%用在依赖文件中时,所匹配的文件名要以目标文件为准。

如:%.o:%.c,当%.o匹配到目标文件的a.o 和 c.o 时,依赖文件的 %.c 分别匹配到 a.c b.c

实现assert断言

断言意思是,程序员断定程序运行到此处时候,某数据的值 一定为多少。我们把程序该有的条件状态传给它,让它帮咱们监督,一旦条件不符合就报错。

断言很有必要,在内核运行过程中,有严重错误时候会及时发现。

ASSERT(条件表达式)

ASSERT是用宏定义的,其原理是判断传给ASSERT的表达式是否成立,不成立则会报错。
我们用宏函数来实现assert的功能: # 的功能是让预处理器把CONDITION转换成字符串常量。

 #define ASSERT(CONDITION)   if (CONDITION) {} else {  PANIC(#CONDITION);}
 #define PANIC(...) panic_spin (__FILE__, __LINE__, __func__, __VA_ARGS__) //其中 ... 是可变参数。__FILE__, __LINE__, __func__, __VA_ARGS__

预处理器提供的默认标识符:__FILE__就代表的是该文件名。 __LINE__行号, __func__代表的是函数名, __VA_ARGS__代表的是可变参数。

void panic_spin(char* filename,       \        int line,       \const char* func,      \const char* condition) \{   intr_disable();// 因为有时候会单独调用panic_spin,所以在此处关中断。   put_str("\n\n\n!!!!! error !!!!!\n");   put_str("filename:");put_str(filename);put_str("\n");   put_str("line:0x");put_int(line);put_str("\n");   put_str("function:");put_str((char*)func);put_str("\n");   put_str("condition:");put_str((char*)condition);put_str("\n");   while(1);}

要记得关中断,出现了panic,我们要让出错信息一直显示在屏幕上,不能出现中断让kernel去执行其他中断程序,所以我们要把中断关闭,实现循环。

另外当我们调试结束,不想让 ASSERT 发挥作用时,还可以加入宏来将 ASSERT 取消调

#ifdef NDEBUG   #define ASSERT(CONDITION) ((void)0)

在程序开头添加: #define NDEBUG   ,那么之前在程序中的 ASSERT() 将全部变成空函数,什么都不显示,将不发挥作用,这也是C语言中调试经常用到的技巧。在调试时候,我们让程序每一个代码块都输出信息,然后看看哪个地方出错了,当程序没有错误了,这些输出也没有必要了,就 #define,这些ASSERT 就失效。

内存管理系统

位图

位图整体是以字节为单位,细节上面是以位为单位的。对于位图这个结构,元素有位图长度和指向位图起始地址的指针。位图的索引是具体到位图的位的。

struct bitmap{uint32_t btmp_byte_len;//位图字节大小uint8_t* bitmap_address;    //位图起始地址};

判断位图的索引下标是否为1,位图是以位为单位的,所以下标要具体到某个位

bool bitmap_scan_test(struct bitmap *btmp, uint32_t bit_index){uint32_t byte_line = bit_index / 8;uint8_t bit_column = bit_index % 8;return (btmp->bitmap_address[byte_line] &((uint8_t)1<<bit_column));}

在位图中申请连续的cnt个空闲位,并且返回起始位位下标:先判断位图哪一个字节有空闲位,如果发现了字节有空闲位,就找到这个位然后逐步判定这个位前面是否连续有cnt个空闲位。如果有,就返回空闲位的起始下标。

int bitmap_scan(struct bitmap* btmp, uint32_t cnt){uint32_t index_byte;uint32_t index = -1;//坐标值,若找不到直接返回-1for (index_byte = 0; index_byte< btmp->btmp_byte_len; index_byte++){if (btmp->bitmap_address[index_byte]!=0xff)//发现某字节有空闲位,就判断该位的起始坐标{int index_bit;for (index_bit=0; index_bit < 8; index_bit++)//判断是否具体哪位空闲{if ((btmp->bitmap_address[index_byte]&((uint8_t)1<<index_bit))==0)//说明该位是空闲位{uint32_t index = index_byte * 8 + index_bit;//该空闲位的具体坐标值break;}}int num=0; //记录index起始位开始还有多少个空闲位while (bitmap_scan_test(btmp,index)==bool && index< btmp->btmp_byte_len*8) //判断空闲位前面是否有连续的坐标值{num++;if (num==cnt){return index;}index++;}}index = -1;}return index;}

将位图的bit_idx位设置为value

void bitmap_set(struct bitmap* btmp, uint32_t bit_index, int8_t value){uint32_t byte_line = bit_index / 8;uint8_t bit_column = bit_index % 8;if (value==1){btmp->bitmap_address[byte_line] = btmp->bitmap_address[byte_line] | (uint8_t)value << bit_column;}btmp->bitmap_address[byte_line] = btmp->bitmap_address[byte_line] & (~ (uint8_t)value << bit_column);}

虚拟地址池

每个任务都有各自的4GB的虚拟地址空间,即每个任务都可以用相同的虚拟地址而不会引发冲突。当内核为了完成某些工作,需要申请内存,就要通过内存管理系统先在内核自己的虚拟地址池中分配内存虚拟地址,然后再从内核自己的物理池中申请分配物理内存,然后建立好映射关系。当用户申请内存时,先从用户进程自己的虚拟地址池中分配空闲虚拟地址,然后再从用户自己的物理内存池中申请内存,建立好映射关系即可。

struct virtual_addr{struct bitmap vaddr_bitmap; //用位图表示虚拟地址的大小和使用情况uint32_t vaddr_start;  //虚拟地址的起始地址};struct virtual_addr kernel_vaddr; //内核的虚拟地址池

物理地址池

内核线程和用户进程都有自己的物理地址池。由于物理内存是有限的,所以结构中要包括:位图来表示地址池的大小和使用情况,起始地址表示物理内存池的起始地址,字节容量表示物理内存池的的容量。 

struct pool{struct bitmap pool_bitmap;  //代表使用情况uint32_t phy_addr_start;  //起始地址uint32_t pool_size; //内存容量大小};struct pool kernel_pool, user_pool;

现在我们的内存的使用情况:0x0 - 0x10 0000 低1M储存了我们的kernel,0x10_0000以上是我们的页目录和页表,其中页目录 4K,页目录项有第 0 项和 第 (0xc000_0000)768 项指向第一个页表,第 1023项指向页目录自己,第 769 项-第 1022 项指向第二、三、四。。。个页表,所以页目录和页表总共占了 256 个 页框,1M大小。所以剩下的内存都是空闲的,我们将空闲的内存分为两部分,一部分作为内核的物理内存池,一部分作为用户进程的物理内存池。

主线程main的PCB从 0xc009e000 到 0xc009f000。位图一个位表示4K,那么一页大小的位图可以管理128M的内存,4页大小的可以管理512M的内存。我们的位图地址安排在 0xc009a000 开始到 0xc009e000。依次存放 内核的物理内存池位图 、用户的物理内存池位图、 内核的虚拟内存池的位图。其中内核的物理内存池的容量与内核的虚拟内存池的容量相等,也就是说他们的位图大小相同,最好做到虚拟地址和物理地址一一映射(一个页表项表示4K的物理内存(中间10位),如果页表项中填的是相同的物理内存,则不是一一映射的关系):从内核的虚拟地址池中申请的虚拟地址可以映射到从内核物理地址池中申请的物理地址,并且不会重复。实际上也只能一一映射,因为每从虚拟地址池和内存地址池中申请一页大小内存,相应的位图就会值1,所以这样做到一一映射。

static void mem_pool_init(uint32_t all_mem){uint32_t page_table_size = 256 * 4096;uint32_t used_mem = page_table_size + 0x10_0000;uint32_t free_mem = all_mem - used_mem;uint32_t all_free_page = free_mem / 4096;uint16_t kernel_free_pages = all_free_pages / 2;uint16_t user_free_pages = all_free_pages / 2;uint32_t kbm_length = kernel_free_page / 8;uint32_t ubm_length = kernel_free_page / 8;uint32_t kp_start = used_mem;uint32_t up_start = used_mem + kernel_free_page * 4096;//内核物理内存池和用户物理内存池的起始地址kernel_pool.phy_addr_start = kp_start;user_pool.phy_addr_start = up_start;//内核物理内存池和用户物理内存池的容量大小kernel_pool.pool_size = kernel_free_page * 4096;user_pool.pool_size = user_free_page * 4096;//内核物理内存池位图大小和用户物理内存池位图大小kernel_pool.pool_bitmap.btmp_byte_len = kbm_length;user_pool.pool_bitmap.btmp_byte_len = ubm_length;//内核物理内存池位图起始地址和用户物理内存池位图起始地址kernel_pool.pool_bitmap.bits = (void*)0xc009a000;user_pool.pool_bitmap.bits = (void*)0xc009a000+ kbm_length;//内核虚拟内存池的起始地址和位图的大小和位图的起始地址kernel_vaddr.vaddr_start = 0xc0100000;kernel_vaddr.vaddr_bitmap.btmp_byte_len = kbm_length;  //内核的虚拟地址池和内核物理地址池一样大小kernel_vaddr.vaddr_bitmap.bits= (void*)0xc009a000 + kbm_length+ ubm_length;}

这样设计是因为我们把虚拟地址 0xc000_0000-0xc00f_ffff 映射到 物理地址 0x0000-0xf_ffff。为了保持虚拟地址的连续性,我们只能让虚拟地址从 0xc010_0000 开始,但是物理地址的 0x10_0000开始已经分配给了页表了,所以我们不能做到 数值上面的一一对应了。当我们申请了 起始地址为 0xc010_0000 的虚拟页时,将虚拟位图中对应的位置1,然后申请物理内存池的一页,将对应的位图位置1,最后通过写页目录项和页表项建立映射关系。此时起始物理地址并不是 0x10_0000,很有可能是跳过页表地址的 0x20_0000。

内存管理系统第一步:在虚拟地址池和物理地址池分配页内存

这里只讨论整页分配,可以申请 4K*n 的内存大小。

这里的工作为:在内核的虚拟内存池中申请连续的 n 个页,成功后返回虚拟起始页的起始地址。在内核的物理内存池中申请 1 个页,成功返回物理起始地址。因为虚拟地址需要连续,但是物理地址不需要连续,只要做好映射即可。

申请页的工作很简单,因为内存池的页数目和位图的位数目是一一对应的,只要依靠位图的操作,即可找到空闲的页,然后将位图的该位置1,然后将该位的索引乘以 页框 大小加上物理内存池的起始地址即可。

//从内核虚拟内存池中找到 n 个连续的虚拟页,放回起始地址static void* vaddr_get(enum pool_flags pf, uint32_t pg_cnt){int vaddr_start = 0;int index = -1;if (pf==PF_KERNEL){index = bitmap_scan(&kernel_vaddr.vaddr_bitmap,pg_cnt);if (index==-1){return -1;}int a = pg_cnt;while (a){a--;bitmap_set(&kernel_vaddr.vaddr_bitmap,index+a,1);}vaddr_start = kernel_vaddr.vaddr_start + index * 4096; //虚拟地址的页和位图中的位一一对应着}}//从物理内存池中申请一页的内存,并返回起始地址static void* palloc(struct pool* m_pool){int index = -1;index = bitmap_scan(&m_pool->pool_bitmap, 1);if (index == -1){return -1;}bitmap_set(&m_pool->pool_bitmap, index, 1);uint32_t addr = m_pool->phy_addr_start + index * 4096;return addr;}

内存管理系统第二步:找到虚拟地址的页目录项和页表项的地址

我们页表中的页目录项的最后一项是指向页目录自己,目的就是通过此页目录项编辑页表。一个虚拟地址要想找到物理地址,就要通过前10位,中10位,后12位来找,所以我们想要建立 虚拟地址页 和 物理地址页 的映射关系,只要将 虚拟地址页的起始地址 对应住 物理地址页起始地址 即可。因此我们需要在 PTE 和 PDE 中设置合适的数值:在这个虚拟地址的中间10位的 PTE 中的值设为 物理地址的值,然后在 虚拟地址的前 10 位的 PDE 中值设为合适的值。因此,关键是要找到这个虚拟地址的 PDE 和 PTE 的指针(这个指针也是虚拟地址)。

获取虚拟地址的 PDE 地址:本质就是虚拟地址前10位的值乘以4,加上基址就是 PDE 的地址。也就是说只要前10位移到最后12位,然后让他在页目录中寻址,即可实现。想要最后在页目录中寻址就要将前10位和中间10位都设为 0xfffff,让其一直指向页目录的最后一项,然后循环在页目录中。

获取虚拟地址的 PTE 地址:本质就是中间10位的值乘以4,加上该页表基址就是 PTE 的地址。也就是说中间10位移到最后12位,然后让在页表中寻址,即可实现。想要最后在页表中寻址,就要把前10项全部为1,然后指向页目录项的最后一个,然后将原来前10项的值移到到中间10项,这样就寻到PDE的值,里面就是页表的基址,然后将原来中间10项的值移到最后12项,这样就可以在页表中寻到。

对于一个虚拟地址来说,PDE的地址直接可以找到,因为PDE只需要前10位和基址即可。但是PTE的地址需要在PDE中设置合适的页表值才能找到。所以一个间接,一个直接。需要注意的是,这些指向 PDE 和 PTE 的地址也是虚拟地址。

因为PDT是存在的,所以PDE的指针一定可以确定,如果PDE有值,说明PDE所指的页表存在,PTE的指针也存在。但是如果PDE没有值,那就说明页表不存在,此时PTE的指针当然也不存在了,需要申请物理页作为页表,然后才能确认PTE的指针。所以想要映射成功的关键是在 PDE 中设置合理的PTE,因为只要在 PTE 中设为物理页的起始地址即可。

//将前10位移到后12位,然后前20位设为1uint32_t* pde_ptr(uint32_t vaddr){uint32_t addr = 0xfffff000 + ((vaddr&0xffc00000) >>20);return addr;}// 将前10位移到中10位,中10位移到后12位,然后前10位设为1uint32_t* pte_ptr(uint32_t vaddr){uint32_t addr = 0xfffff000 + ((vaddr & 0xfffff000) >> 10);return addr;}

内存管理系统第三步:完成虚拟地址和物理地址的映射

现在我们有了需要申请的 虚拟页的起始地址 、需要映射的物理页的起始地址,虚拟页的的 PDE 和 PTE 的指针,最后我们只需要在 PDE 中和 PTE 中添加合适的值,完成映射即可。

目前现在我们完成映射有:页目录项有 1K 个,只有后四分之一里面有值,即 PDE 0 、PDE 768 --PDE 1023这些里面有值,并且也只有这四分之一的 PDE 中的页表真实存在。也就是说:目前 PDT 中后四分之一已经有值,页表在物理内存中真实存在 255 个,但这些页表中只有第一个页表的前四分之一中赋值了。

所以我们申请一个虚拟地址时候,要分情况讨论:PDE有值(页表一定存在):PTE有/无值;PDE无值:页表不存在,这三种情况讨论

1,PDE有值,PTE无值(PDE中有值,所指向的页表一定存在):一个 PDE 指向一个页表,页表有 1K 个 PTE,代表了4M 的物理内存。我们一次只申请几个虚拟页时,极大可能这个PDE中已经赋值,只是指向的页表中 PTE 只用了几个而已。所以PDE中如果有值(P位为1),就直接去对应的页表中找到虚拟地址的 PTE指针 赋上物理页的起始地址即可。实际上,我们的 PDT 中的后四分之一的 PDE 都已经赋值了,代表了高3G的虚拟地址,它们指向的页表都已经存在了,有 255 个页表,不重复映射物理地址的话可以映射1G 的物理地址,虽然我们也不到 1G 的物理地址。低 三分之一 的 PDE 并没有存在,所以它们对应的页表也肯定不存在。

2,PDE有值,PTE有值(PTE中有值,所指向的物理页一定正在被使用):如果PTE中有值,说明之前已经申请过该页框了。,那么所指向的 物理页 一定是正在被使用的。所以程序出错了。

3,PDE无值,页表肯定不存在:当我们申请了低3G的虚拟内存时候,PDT 中前四分之三的 PDE 并没有值,所以它们对应的 页表 也肯定不存在。 我们需要在内核物理内存池中申请一个 物理页  作为页表,然后将这个物理页的起始地址赋值给 PDE,然后找到新的页表 中的页表项,进行赋值。一定注意:低3G 虽然是用户地址,但是页表 也要在内核中申请。

这样我们就建立起来了虚拟地址和物理地址的映射关系。本质很简单:就是把虚拟地址池想象成为一个 4G 的内存池,单位是 标准页4K,然后专门有 位图 来描述这么多的 标准页的使用情况,使用了位图置1 ,没有使用位图 置0。我们通过 虚拟地址申请函数 来申请 n个 连续的虚拟地址页,并返回起始地址(虚拟地址要连续着)函数的本质实现就是根据位图的使用情况来实现的,位的索引和虚拟地址页的索引一一对应着,连续的0申请即可。然后我们把空闲的物理地址分成 内核物理内存池 和 用户物理内存池,我们需要内存页时候,就通过 物理地址申请函数 来申请,返回一个物理页的起始地址,只能一次申请一页物理页,因为物理页可以不连续,只要映射好即可。现在得到了 n 个连续的虚拟地址页的起始地址, 和 n 个 不连续的物理页 的 各自的起始地址。通过映射函数来构建它们的关系,根据虚拟地址页的高10位,低10位来找到 PDE 和 PTE 的指针,然后PDE的P位为1,说明页表存在,直接在PTE中赋值即可。若 PDE 的 P位为0,说明页表不存在,需要在 内核物理内存池 中申请一个物理页作为页表,然后起始地址填入PDE中,然后填好 PTE,这样就映射好了一个的虚拟地址页和一个物理地址页。我们有 n 个要创建,还是这样的套路,我们创建第二个虚拟页和物理页的映射。。。

//创建一个虚拟地址页和一个物理页的映射关系,即填好页表即可static void page_table_add(void* vaddr, void* page_phyaddr){//首先得到虚拟地址的PDE和PTE的指针uint32_t* pde = pde_ptr(vaddr);  //PDT已经有了,判断PDE存在不存在uint32_t* pte = pte_ptr(vaddr);//如果PDE已经存在了,那么所指的页表也存在if (((*pde)&0x00000001)==1){ASSERT((*pte & 0x00000001)==0); //若成立则继续执行//在判断PTE是否存在,如果不存在,赋值if (((*pte) & 0x00000001) == 0){*pte = page_phyaddr | PG_US_U | PG_RW_W | PG_P_1;}else //PTE 已经存在了,说明这个物理地址正在使用中,我们不能破坏{return -1; //程序出错}}//如果PDE不存在,说明所指向的页表也没有,需要我们从内核物理池中申请else{uint32_t pt_phyaddr = (uint32_t)palloc(&kernel_pool);memset((void*)((int)pte & 0xfffff000), 0, PG_SIZE);//清零*pde= pt_phyaddr | PG_US_U | PG_RW_W | PG_P_1; //建立好了PDE后,pte的指针才可以确定。ASSERT((*pte & 0x00000001)==0);*pte = page_phyaddr | PG_US_U | PG_RW_W | PG_P_1;}}

所以汇总在一起为:

1,通过vaddr_get在虚拟内存池中申请 n 个连续的虚拟地址页,得到起始的虚拟地址

2,通过palloc在物理内存池中循环连续申请 1 个物理页,申请 n 次,得到 n 个起始地址

3,通过page_table_add将以上得到的虚拟地址和物理地址在页表中完成映射

/* 申请pg_cnt个虚拟页空间,成功则返回起始虚拟地址,失败时返回NULL */void* malloc_page(enum pool_flags pf, uint32_t pg_cnt){ASSERT(pg_cnt > 0 && pg_cnt < 3840);void* vaddr_start= vaddr_get(pf, pg_cnt); //首先申请n个连续的虚拟地址if (vaddr_start==NULL){return NULL;}struct pool* mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool;//确定是在内核物理池中申请//因为虚拟地址是连续的,但物理地址可以是不连续的,所以逐个做映射while (pg_cnt-- > 0){void* page_phyaddr = palloc(mem_pool);//申请一个物理地址if (page_phyaddr == NULL){  // 失败时要将曾经已申请的虚拟地址和物理页全部回滚,在将来完成内存回收时再补充return NULL;}page_table_add((void*)vaddr_start, page_phyaddr); // 在页表中做映射 vaddr_start += PG_SIZE; // 下一个虚拟页}return vaddr_start;}
现在我们都是在内核中做任务,所以单独写一个函数,功能是:从内核虚拟地址池中申请 n 个虚拟页,完成映射,返回虚拟起始地址

/* 从内核物理内存池中申请pg_cnt页内存,成功则返回其虚拟地址,失败则返回NULL */void* get_kernel_pages(uint32_t pg_cnt){void* vaddr = malloc_page(PF_KERNEL, pg_cnt);if (vaddr != NULL) {   // 若分配的地址不为空,将页框清0后返回memset(vaddr, 0, pg_cnt * PG_SIZE);}return vaddr;}