操作系统——内存管理(十七)
操作系统——内存管理(十七)
2020-10-02 16:06:04 hawk
概述
这篇文章,我们将会接着前面的步骤,实现简单的内存管理。
字符串操作
实际上这里和内存管理关系并不是很大,但是这些字符串操作函数又确实是后面操作系统的基石,因此这里我们单独插入这一章,用来实现一下和字符串相关的操作函数。这里主要包括memset、memcpy、memcpy、strcpy、strlen、strcmp、strchr、strrchr、strcat和strchrs等操作。
这里稍微简单介绍一下这些函数的作用,如下表所示
函数原型 | 函数简介 |
void memset(void *dst_, uint8_t value, uint32_t size); | 用来将dst_起始的size个字节设置为value |
void memcpy(void *dst_, const void *src_, uint32_t size); | 将src_起始地址的size个字节复制到dst_处 |
int memcmp(const void *a_, const void *b_, uint32_t size); | 连续比较以地址a_和地址b_开头的size个字节,若相等则返回0。若a_大于b_。则返回1。否则返回-1 |
char *strcpy(char* dst_, const char* src_); | 将字符串从src_复制到dst_ |
uint32_t strlen(const char *str); | 返回字符串的长度 |
int8_t strcmp(const char *a, const char *b); | 比较两个字符串,若a_中的字符大于b_中的字符,返回1.若a_等于b_,返回0。否则,返回-1 |
char *strchr(const char *str, const uint8_t ch); | 从左到右查找字符串str中首次出现ch的地址,没找到的话返回NULL |
char *strrchr(const char *str, const uint8_t ch); | 从后往前查找字符串str中首次出现字符ch的地址,没找到的话返回NULL |
char *strcat(char *dst_, const char* src_); | 将字符串src_拼接到dst_后,返回拼接的字符串地址 |
uint32_t strchrs(const char *str, uint8_t ch); | 在字符串str中查找字符ch出现的次数 |
其相关的源代码如下所示
#include "string.h" #include "global.h" #include "debug.h" // 用来将dst_起始的size个字节设置为value void memset(void *dst_, uint8_t value, uint32_t size) { ASSERT(dst_ != NULL); //这里强制dst_不为NULL,否则直接中断 uint8_t *dst = (uint8_t*)dst_; while(size-- > 0) {*(dst++) = value;} } // 将src_起始地址的size个字节复制到dst_处 void memcpy(void *dst_, const void *src_, uint32_t size) { ASSERT(dst_ != NULL && src_ != NULL); const uint8_t *src = (const uint8_t*)src; uint8_t *dst = (uint8_t*)dst_; while(size-- > 0) {*(dst++) = *(src++);} } // 连续比较以地址a_和地址b_开头的size个字节,若相等则返回0。若a_大于b_。则返回1。否则返回-1 int memcmp(const void *a_, const void *b_, uint32_t size) { ASSERT(a_ != NULL && b_ != NULL); const uint8_t *a = (const uint8_t*)a_, *b = (const uint8_t*)b_; while(size-- > 0) { if(*a != *b) {return *a > *b ? 1 : -1;} ++a; ++b; } return 0; } // 将字符串从src_复制到dst_ char *strcpy(char *dst_, const char *src_) { ASSERT(dst_ != NULL && src_ != NULL); char *dst = dst_; const char *src = src_; while(*src) {*(dst++) = *(src++);} return dst_; } // 返回字符串的长度 uint32_t strlen(const char *str) { ASSERT(str != NULL); const char *s = str; while(*(s++)) {} return (s - str - 1); } // 比较两个字符串,若a_中的字符大于b_中的字符,返回1.若a_等于b_,返回0。否则,返回-1 int8_t strcmp(const char *a, const char *b) { ASSERT(a != NULL && b != NULL); while(*a && (*a == *b)) { ++a; ++b; } return *a > *b ? 1 : (*a < *b); } // 从左到右查找字符串str中首次出现ch的地址,没找到的话返回NULL char *strchr(const char *str, const uint8_t ch) { ASSERT(str != NULL); while(*str && *str != ch) { ++str;} return *str ? str : NULL; } // 从后往前查找字符串str中首次出现字符ch的地址,没找到的话返回NULL char *strrchr(const char *str, const uint8_t ch) { ASSERT(str != NULL); const char *res = NULL; while(*str) { if(*str == ch) {res = str;} } return (char*)res; } // 将字符串src_拼接到dst_后,返回拼接的字符串地址 char *strcat(char *dst_, const char *src_) { ASSERT(dst_ != NULL && src_ != NULL); char *dst = dst_; while(*(dst++)) {;} --dst; while(*(dst++) = *(src_++)) {;} return dst_; } // 在字符串str中查找字符ch出现的次数 uint32_t strchrs(const char *str, uint8_t ch) { ASSERT(str != NULL); uint32_t res = 0; while(*str) { if(*(str++) == ch) {++res;} } return res; }
都是一些比较简单的函数,这里就不过多赘述了。下面开始正式的内存管理的实现。
位图bitmap
首先简单介绍一下位图的概念——位图,即bitmap,是一种管理资源的方式、手段。
位图实际上包含两个概念,位和图。位是指1bit,即字节中的位,1字节中包含8个位。而图是指map,即一种映射关系。所以,实际上位图就是用字节中的1位去映射其他单位大小的资源,按位与资源之间是一对一的对应关系。
那么换算到我们的内存中,实际上就是位图中的每一位代表实际物理内存中的4KB,也就是一页——那么如果位图中每一位为0,则表示对应的位未分配,可以使用;否则表示已经分配出去浪了,在回收之前不可再分配了。
对于位图数据的定义,实际上很简单——就是一个字节数组和该数组长度组成的结构即可,其定义代码如下所示
#define BITMAP_MASK (1) /* 定义位图数据 */ typedef struct BITMAP { uint32_t bitmap_bytes_len; uint8_t *bits; } BitMap;
对于其操作,实际上也很简单,但对于我们已经够用了——初始化,判断、申请和置位,其代码如下所示
#include "bitmap.h" #include "stdint.h" #include "string.h" #include "print.h" #include "debug.h" #include "interrupt.h" /* 将位图bitmap进行初始化 */ void bitmap_init(BitMap *bitmap) { memset(bitmap->bits, 0, bitmap->bitmap_bytes_len); } /* 判断位图中第bit_idx位是否为1 若为1,返回true 否则,返回false */ bool bitmap_scan_test(BitMap *bitmap, uint32_t bit_idx) { uint32_t byte_idx = bit_idx >> 3, bit_ord = bit_idx & 0x7; return bitmap->bits[byte_idx] & (BITMAP_MASK << bit_ord); } /* 在位图中连续申请cnt个位, 如果成功返回起始位的下标 失败的话返回 -1 */ int bitmap_scan(BitMap *bitmap, uint32_t cnt) { // 这里直接使用暴力法进行查找 uint32_t byte_idx = 0, bit_start, bit_idx; while(byte_idx < bitmap->bitmap_bytes_len) { //首先获取第一个0位 bit_start = 0; while(bit_start < 8 && (bitmap->bits[byte_idx] & (BITMAP_MASK << bit_start))) {++bit_start;} if(bit_start >= 8) {break;} //此时找到了第1个0位,下面开始查找是否存在连续cnt个0位 bit_start += byte_idx * 8; bit_idx = bit_start + 1; //为了避免遍历超出位图的范围,需要给出最大的界限, bit_limit = MIN((bitmap->bitmap_bytes_len - byte_idx) * 8, cnt + bit_start) uint32_t bit_limit = (bitmap->bitmap_bytes_len * 8 < (cnt + bit_start) ? bitmap->bitmap_bytes_len * 8 : (cnt + bit_start)) - 1; while(bit_idx < bit_limit && !bitmap_scan_test(bitmap, bit_idx)) {++bit_idx;} if(bit_idx - bit_start == cnt) {return (int)bit_start;} byte_idx = (bit_idx / 8) + 1; } return -1; } /* 将位图bitmap中的bit_idx设置为value */ void bitmap_set(BitMap *bitmap, uint32_t bit_idx, uint8_t value) { ASSERT(value == 1 || value == 0); uint32_t byte_idx = bit_idx / 8, bit_ord = bit_idx & 0x7; if(value) { bitmap->bits[byte_idx] |= BITMAP_MASK << bit_ord; }else { bitmap->bits[byte_idx] &= ~(BITMAP_MASK << bit_ord); } }
整体上逻辑还是比较简单的,就是各种位操作而已。
内存管理
实际上,用户程序所占用的内存空间是由操作系统分配的,而内存具体是如何分配并且给用户进程分配多少字节,则是我们要实现的内存管理。下面我们首先简单的分析一下整体思路。
内存池规划
我们知道,内核和用户进程实际上分别运行在自己的地址空间中。在实模式下,程序的线性地址就是实际上的物理地址。而在保护模式下,由于开了分页机制,因此实际上线性地址变成了虚拟地址,最后CPU通过页表将虚拟地址转换到物理地址,并进行实际上的访问。而对于操作系统来说,管理虚拟地址和物理地址的内存池,则是其主要职责之一。
我们首先分析一下如何规划物理内存池。对于物理内存,可行的方案就是将物理内存划分为两部分,一部分只用来运行内核,另一部分只用来运行用户进程。每次从内存池申请内存的话,按照内存的单位大小——页,即4KB的倍数进行进行分配和回收。为了方便起见,我们就直接将这两部分对半分,也就是一半物理内存用于内核内存池,一半物理内存用于用户内存池,如下图所示
下面我们讨论另一个,即虚拟内存的地址池。对于我们采取的分页机制来说,一方面,在我们的32位环境下,虚拟地址空间是4GB;零一方面,每个任务都有自己的4GB虚拟地址空间,这样子避免了程序间内存地址的冲突。当然,程序(进程、内核线程)等在运行的过程中,也会有申请内存的需求,这种动态申请内存一般是在堆中申请内存,当操作系统接受申请后,为进程或者内核,在堆中选择一个空闲的虚拟地址,然后再找个空闲的物理地址进行映射即可,之后把虚拟地址返回给程序即可。
对于内核的内存申请,内核也通过内核管理系统申请内存,然后内核从自己的虚拟地址池中分配虚拟地址,然后再从内核专用的物理内存池分配物理内存,最后内核自己的页表将这两个地址建立映射关系,即可完成内核的内存申请。
对于用户进程来说,它同样向内核管理系统,即操作系统申请内存。然后操作系统首先从用户进程自己的虚拟地址池中分配空闲虚拟地址,然后再从用户物理内存池(所有用户进程共享)中分配空闲的物理内存,最后在该用户进程自己的页表中,将这两个地址建立好映射关系即可。
当然,为了方便管理,虚拟地址池中的地址单位同样是4KB,从而可以方便的进行映射。实际上计算机中的虚拟地址池与物理地址池的示意图如下所示
物理内存池构建
下面分析了一下虚拟地址和物理地址的内存池的整体规划,下面我们介绍一下物理内存的内存池。
根据前面的示意图,我们容易看出来——实际上物理内存是全局的,也就是整个计算机系统中只保留一份物理内存池的管理结构,这也是很自然的(不类似于每一个进程一个虚拟地址空间,整个操作系统、程序等公用一个物理内存空间,也就是真实的物理内存,自然管理的数据结构只可能会有一部分,并且所有进程共享)。
当然,前面也分析过了——整个物理内存池是需要被均分分为两部分,内核内存池,用来内核申请物理内存;用户内存池,用户进程申请物理内存的。这里还是说一下我一开始的疑问——前面的博客一直再讲,内核占用高1GB的内存空间,用户占用剩下的3GB内存空间,到这里怎么又被平分了?这实际上是由于搞混了概念。首先对于内存池的管理,我们是分成了虚拟内存池的管理和物理内存池的管理的。对于虚拟内存池的管理,是每一个进程自己管理自己的虚拟内存池,其大小就是虚拟内存空间的大小,这里就是4GB,其中布局往往是高1GB的内存空间属于内核空间,剩下3GB的空间是真正的用户进程使用的,因此这仅仅是虚拟内存。而对于物理内存池的管理,其管理的是实际的物理内存,在我们的虚拟机中是被设置为了32MB的,这里的布局往往是一半是内核内存池,另一半是用户进程内存池。因此,一定需要分清虚拟内存池中的用户空间、内核空间,和物理内存池中的内核内存池、用户内存池这些概念。
下面我们接着将物理内存池均分为内核内存池和用户内存池部分讲解。首先,这里的物理内存池是用来管理初始化后空闲的内存的,即初始化后已经被使用的,是已经被赋予了重要任务了,所以基本不可能在空闲了,所以不需要管理。这里“初始化后已经被使用的"内存指的是操作系统相关的数据结构,在这里就是低端1MB数据(包含内核、内核数据、GDT、IDT等)和页目录表、页表等,值得庆幸的是,我们前面实现这些结构的时候,将页目录表、页表紧邻且紧接着低端1MB数据。因此,实际上我们需要管理的物理内存池的起始地址就是低端1MB数据 + 页目录表 + 页表大小,也就是0x100000 + (1 + 1 + 254) * 4K这个地址,而其管理的物理内存池的大小就是总的物理内存大小(这里是32MB)-使用的物理内存大小,也就是前面的值。
最后则是用户内存的地址。首先,我们是通过操作系统对物理内存进行管理,也就是这些管理物理内存的内核内存池和用户内存池结构——位图,都存储在内核空间中。而另一方面,实际上在我们对内核内存池和用户内存池的初始化时,我们已经指定好了已使用的内存,也就是我们这些用于管理物理内存的这些数据结构只能放置在前面已经指明的已使用内存中。而页目录表和页表都有其他用处,因此我们只能将其放置在低端1MB的内存空间中。而我们的虚拟机物理内存仅仅32MB,因此我们仅需要32MB / (4K * 8) = 1KB大小的位图,但是考虑到栈空间,我们将物理内存池相关的位图起始地址放置在原始栈顶下5个页,也就是位图的起始地址为0xc009f000 - 4K * 5= 0xc009a000即可。
这样,我们就基本描述完了物理内存池的数据,然后将其在等分为内核内存池和用户内存池,即可基本完成物理内存池的构建,其相关代码如下所示
#include "memory.h" #include "stdint.h" #include "print.h" #include "debug.h" #define PG_SIZE (4096) //即每一个页的大小为4KB,即4096字节 /* 实际上前面已经分析过了,内核是从虚拟地址3G开始,即0xc0000000 但是实际上最开始的分页机制中,我们将低端1MB内存映射给了内核空间了,也就是0xc0000000 - 0xc00fffff已经映射到了物理地址0 - 0xfffff中 因此内核申请的堆的起始地址我们就设置为0xc0100000即可 */ #define K_HEAP_START (0xc0100000) Virtual_Pool kernel_vir_pool; //内核的虚拟内存池 Physical_Pool kernel_phy_pool, user_phy_pool; //物理内存池中的内核内存池和用户内存池 /* 初始化物理内存池,也就是初始化内核内存池和用户内存池 输入参数: all_mem,此参数表示整个物理内存容量 */ static void mem_pool_init(uint32_t all_mem) { put_str("[*] mem_pool_init start\n"); uint32_t page_table_size = PG_SIZE * 256; /* page_table_size用来记录页目录表和页表占用的总大小 也就是1页目录表(页目录表) + 第0个页目录项/第768页目录项(指向第1个页表,页表) + 第769 ~ 1022个页目录项(指向第2个页表- 第254页表,共254个页表) 即256个页 */ uint32_t used_mem = page_table_size + 0x100000; //即已经被使用的物理内存大小,这里值得说明的是,这些被使用的物理内存是紧邻的 uint32_t free_mem = all_mem - used_mem; //剩余需要通过物理内存池进行管理的内存大小 uint16_t all_free_pages = free_mem / PG_SIZE; //最终物理内存池通过bitmap,即位图进行管理,获取代管理的物理内存的位图位数 /* 物理内存池会被均分为内核内存池和用户内存池 */ uint16_t kernel_free_pages = all_free_pages / 2, user_free_pages = all_free_pages - kernel_free_pages; // 这里为了管理的方便,余数不做处理,因此实际上位图表示的可用内存可能会少于实际的可用内存 uint32_t kbm_length = kernel_free_pages / 8, ubm_length = user_free_pages / 8; /* 前面已经分析过了,已使用的物理内存都是从0开始的,紧邻的。 因此待管理的物理内存池的起始地址数值上就等于已使用的物理内存的大小 这里低端内存分配各内核内存池,剩下的待管理的物理内存就是用户内存池 */ uint32_t kp_start = used_mem, up_start = kp_start + kernel_free_pages * PG_SIZE; /* 下面初始化物理内存中的用来管理内核内存池和用户内存池的数据结构即可 */ /****************************************************************************************************************************************************/ kernel_phy_pool.phy_addr_start = kp_start; kernel_phy_pool.pool_size = kernel_free_pages * PG_SIZE; kernel_phy_pool.phy_bitmap.bitmap_bytes_len = kbm_length; user_phy_pool.phy_addr_start = up_start; user_phy_pool.pool_size = user_free_pages * PG_SIZE; user_phy_pool.phy_bitmap.bitmap_bytes_len = ubm_length; /* 物理内存的内核内存池和用户内存池的管理数据结构的存放位置 毕竟物理内存的管理数据结构仍然是需要存放到物理内存中的。实际上,一定位于低端的1MB物理空间——因为我们前面已经分析完了当前的可用物理内存, 也就是我们这些物理内存的管理数据能存放的位置只能是前面讨论过的已使用的物理内存部分。而页目录表和页表自然不能使用,则只能放置在低端的1MB物理空间中 在虚拟地址中,也就是0xc0000000 - 0xc00fffff 在虚拟地址中,内核的栈被设置为0xc009f000,我们就将管理物理内存的位图放置在这个地址附近 而我们的虚拟机物理内存仅仅32MB,因此我们仅需要32MB / (4K * 8) = 1KB大小的位图,因此考虑到栈空间,我们将物理内存池相关的位图起始地址放置在原始栈顶下5个页 也就是 0xc009f000 - 4K * 5 = 0xc009a000 这个大小已经足够放置32MB物理内存的位图 实际上后面的虚拟内存的位图同样会放置在这里 */ #define BITMAP_BASE (0xc009a000) kernel_phy_pool.phy_bitmap.bits = (uint8_t*)BITMAP_BASE; user_phy_pool.phy_bitmap.bits = (uint8_t*)(BITMAP_BASE + kbm_length); bitmap_init(&kernel_phy_pool.phy_bitmap); bitmap_init(&user_phy_pool.phy_bitmap); /* 这里顺便设置一下内核进程的虚拟内存池 */ kernel_vir_pool.vir_addr_start = K_HEAP_START; kernel_vir_pool.vir_bitmap.bitmap_bytes_len = kbm_length; kernel_vir_pool.vir_bitmap.bits = (uint8_t*)(BITMAP_BASE + kbm_length + ubm_length); bitmap_init(&kernel_vir_pool.vir_bitmap); /* 输出物理内存池的相关信息 */ put_str("[*] kernel_phy_pool_bitmap_start: 0x"); put_uHex(kernel_phy_pool.phy_bitmap.bits); put_str(";kernel_phy_pool_phy_addr_start: 0x"); put_uHex(kernel_phy_pool.phy_addr_start); put_char('\n'); put_str("[*] user_phy_pool_bitmap_start: 0x"); put_uHex(user_phy_pool.phy_bitmap.bits); put_str(";user_phy_pool_phy_addr_start: 0x"); put_uHex(user_phy_pool.phy_addr_start); put_char('\n'); /* 输出内核虚拟内存池的相关信息 */ put_str("[*] kernel_vir_pool_bitmap_start: 0x"); put_uHex(kernel_vir_pool.vir_bitmap.bits); put_char('\n'); put_str("[*] mem_pool_init done\n"); } /* 内存管理的初始化入口 */ void mem_init() { put_str("[*] mem_init start\n"); // 在loader程序中,我们在GDT后面保存了物理内存总容量,其地址即为loader加载地址 + 偏移(指令对齐) // 即0x700 + (60 + 4) * 8 + 4 = 0x900,则对应的虚拟地址为0xc0000900 uint32_t mem_bytes_total = *(uint32_t*)0xc0000904; mem_pool_init(mem_bytes_total); put_str("[*] mem_init done\n"); }
注释实际上已经非常详细了,这里我最后说一点——一定分清楚物理内存和虚拟内存,这里我们管理的是物理内存池,也就是实际的虚拟机的32MB的内存,这个是全局唯一的。这里说一下,实际上这个0xc0000904地址是在loader中定义的数据,其物理地址是0x904,其通过BIOS提供的中断功能获取当前的物理内存容量。
虚拟内存池构建
实际上前面也已经分析过了,虚拟内存是用来管理每个进程的内存空间的,也就是每一个进程都会有自己的虚拟内存池,其中其高1GB是分配给内核空间的,低3GB才是用户进程自己申请的。自然的,由于每个进程都有自己的虚拟内存池,也就是内核进程也会有自己的虚拟内存池。由于这里我们操作系统中仅仅包含内核进程一个,而不包含其他的进程,因此这里我们只需要实现内核进程的虚拟内存池即可。
实际上虽然虚拟地址空间确保了不同进程间相同地址不会冲突,但是无法保证相同程序的相同地址不冲突,因此我们自然需要使用数据结构——这里就是虚拟内存池,对虚拟地址空间进行管理,从而保证分配到的虚拟内存是唯一的。而由于内核也是程序,自然其在运行的过程中也可能需要申请额外的内存,所以我们同样通过虚拟内存池来管理内核虚拟地址空间中的内存分配情况。
对于内核来说,实际上其申请的内存空间就是堆空间。这里实际上我们需要管理的就是这个堆空间,首先是其起始虚拟地址。我们知道,在前面开启分页机制的时候,我们将0xc0000000~0xc00fffff已经映射到物理地址的低端1MB内存中,也就是实际上虚拟地址的0xc0000000~0xc00fffff已经被使用了。那么为了让虚拟地址可以连续使用,我们就不妨设置堆的起始地址就为0xc0010000,之后申请的虚拟地址空间都从这里开始申请,即可相对的保证连续地址的连续。
而我们考虑到,前面物理内存池的数据管理结构中,我们制定了内存池的大小。按理说,对于虚拟内存池的数据管理结构,应该和物理内存池的是类似的,但实际上虚拟内存池并没有指定内存池的大小——实际上之所以指定物理内存池的大小,是因为其大小是十分有限的,在我们的虚拟机中,其为32MB,因此需要小心,避免申请超过总大小。而对于虚拟内存池来说,其和地址线宽度是一样的,因此不需要再指定内存池的大小了,其基本上可以看作是无限的。虽然说是无限的,但实际上为了方便,我们将内核的虚拟内存池大小设置为和物理内存池的内核内存池一样的大小,这样极其方便管理——一一对应即可,毕竟只有一个内核进程。
由于我们这里仅仅只需要实现内核的虚拟内存池,其代码也十分简单,如下所示
/* 这里顺便设置一下内核进程的虚拟内存池 */ kernel_vir_pool.vir_addr_start = K_HEAP_START; kernel_vir_pool.vir_bitmap.bitmap_bytes_len = kbm_length; kernel_vir_pool.vir_bitmap.bits = (uint8_t*)(BITMAP_BASE + kbm_length + ubm_length);
可以看到,确实很简单。
内核进程分配内存页
既然我们已经有了物理内存池和内核的虚拟内存池,自然的,我们就可以给内核进行内存的分配了。这里我们要实现的,是一个基础的“整页分配”,也就是我们支持内核申请一次分配n个页的内存,即申请n * 4096字节。
下面我们简单的介绍一下申请内存,也就是分配内存页的大体思路,方便我们有一个更清晰的了解,从而更方便的实现。实际上申请内存页,就是要将虚拟内存池中的空闲内存和物理内存池对应的空闲内存建立映射关系,因此实际上我们需要做三件事
1. 在虚拟内存池中申请足够大小的虚拟内存
2. 在物理内存池中申请足够大小的物理页
3. 将上面两步得到的虚拟地址和物理地址在页表中完成映射。
这样,实际上就相当于我们完成了分配内存页的过程,下面我们给出对应的源代码
/* 在flag表示的虚拟内存池中申请pg_cnt个虚拟页 成功则返回虚拟页的起始地址,失败则返回NULL */ static void* vir_addr_get(enum pool_flags flag, uint32_t pg_cnt) { // 根据传入的flag获取内核内存池或者用户内存池 Virtual_Pool pool = (flag == PF_KERNEL) ? kernel_vir_pool : kernel_vir_pool; //这里还没有实现用户池,同样设置为内核内存池 int idx = bitmap_scan(&pool.vir_bitmap, pg_cnt); // 如果返回小于0,则表明此时对应的内存池中的空闲内存不足够,则返回NULL if(idx < 0) {return NULL;} // 将对应的位图的位进行标记 for(int i = 0; i < pg_cnt; ++i) {bitmap_set(&pool.vir_bitmap, i + idx, 1);} return (void*)(pool.vir_addr_start + idx * PG_SIZE); } /* 获取虚拟地址对应的页表项的指针,也就是对应的页表项的虚拟地址 */ uint32_t* pte_ptr(uint32_t vir_addr) { /* 首先高10位应该是1023,从而访问页目录表的最后一项,仍然是页目录表(当做页表) 其次是中间10位即为虚拟地址的页目录项索引,从而访问上述页表(仍然是页目录表)的索引项,即访问到虚拟地址对应的页表 最后12位则是对应的内存的偏移,由于内存指向的是页表,因此偏移即为虚拟地址在页表中的索引项 * 4,这从而访问的是虚拟地址对应的页表项 */ return (uint32_t*)(0xffc00000 + ((vir_addr & 0xffc00000) >> 10) + ((vir_addr & 0x003ff000) >> 10)); } /* 获取虚拟地址对应的页目录项的指针,也就是对应的页目录项的虚拟地址 */ uint32_t* pde_ptr(uint32_t vir_addr) { /* 首先高10位应该是1023,从而访问页目录表的最后一项,仍然是页目录表(当做页表) 其次是中间10位也应该是1023,从而访问上述页表(仍然是页目录表)的最后一项,仍然是页目录表 最后12位则是对应的内存的偏移,由于内存指向的是页目录表,因此偏移即为虚拟地址在页目录表的索引项 * 4,这从而访问的是虚拟地址对应的页目录项 */ return (uint32_t*)(0xfffff000 + ((vir_addr & 0xffc00000) >> 20)); } /* 在pool指向的物理内存池中分配1个物理页 如果成功,则返回物理页的物理地址 如果失败,则返回NULL即可 */ static void* palloc(Physical_Pool *pool) { int idx = bitmap_scan(&pool->phy_bitmap, 1); // 如果返回索引小于0,则表明内存池不足,分配失败 if(idx < 0) {return NULL;} // 将对应的位图的位进行标记 bitmap_set(&pool->phy_bitmap, idx, 1); return (void*)(pool->phy_addr_start + PG_SIZE * idx); } /* 下面在对应的页表中完成虚拟地址_vir_addr和物理地址_phy_addr的映射关系 */ static void page_table_add(void* _vir_addr, void* _phy_addr) { uint32_t *pte = pte_ptr((uint32_t)_vir_addr), *pde = pde_ptr((uint32_t)_vir_addr); uint32_t phy_addr = _phy_addr; /****************************************************************** 因为pde对应的页目录项可能为空,从而导致访问*pte会引发page_fault。 所以确保pde创建完后,在执行相关的*pte操作 ********************************************************************/ // 首先判断pde是否已经被创建 if(!(*pde & PG_P_1)) {//页目录项不存在,首先初始化页目录项,然后在初始化对应的页表项 uint32_t pte_phy_addr = (uint32_t)palloc(&kernel_phy_pool); *pde = (pte_phy_addr | (PG_US_U << 2) | (PG_RW_W << 1) | PG_P_1); /* 下面我们将分配的pde_phy_addr进行清0,避免里面的原始数据当做页表项 从而避免造成不必要的麻烦 这里我们使用memset进行清空,那么我们需要一个通过两次页表转换的内存指向pde_phy_addr即可 实际上我们考虑一下pte变量,其指向当前虚拟地址的页表项。如果我们将其低12位置为0,则指向当前页表的起始项 从而我们可以通过遍历低12位完成虚拟地址对应的页表的清空 */ memset((void*)((uint32_t)pte & 0xfffff000), 0, PG_SIZE); } // 写入虚拟地址对应的pte的映射的物理地址 if(*pde & PG_P_1) { //如果pde已经被创建,则可以直接访问*pte,由于_vir_addr没有被分配,因此再确认一下 ASSERT(!(*pte && PG_P_1)); // 如果确实不存在,向页表中写入相关信息即可 if(!(*pte && PG_P_1)) { // 当前分配的页的属性为存在与内存、可读写、用户级别 *pte = (phy_addr | (PG_US_U << 2) | (PG_RW_W << 1) | PG_P_1); }else { //基本不会执行到这里,因为前面已经有ASSERT判断了 PANIC("pte repeat"); // 覆盖该页表项 *pte = (phy_addr | (PG_US_U << 2) | (PG_RW_W << 1) | PG_P_1); } } } /* 从flag对应的虚拟地址池和flag对应的物理内存池中分配pg_cnt个页空间,并完成虚拟内存池、物理内存池以及页表的映射 */ void* malloc_page(enum pool_flags flag, uint32_t pg_cnt) { // 确保申请的虚拟内存页个数是有效的,因为实际上我们一次性最多可以申请的内存页也就是物理内存的容量 // 即15MB / (4K) = 3840 ASSERT(pg_cnt > 0 && pg_cnt < 3840); void* vir_addr_start = vir_addr_get(flag, pg_cnt), *phy_addr; // 如果此时返回的虚拟地址的起始地址为空,则表明虚拟内存池不足以分配这些内存页 if(!vir_addr_start) {return NULL;} uint32_t vir_addr = (uint32_t)vir_addr_start; Physical_Pool pool = (flag == PF_KERNEL) ? kernel_phy_pool : user_phy_pool; // 由于物理内存池往往很小,无法分配到连续的页,因此需要一页一页进行分配 while(pg_cnt-- > 0) { phy_addr = palloc(&pool); if(!phy_addr) { //说明即使一页一页分配,物理页仍然不足够进行分配,则需要将占用的虚拟内存池和物理内存池中的内存全部释放掉 // 这里等实现内存回收后在实现 return NULL; } page_table_add((void*)vir_addr, phy_addr); // 映射下一个虚拟页和其对应的物理页 vir_addr += PG_SIZE; } return vir_addr_start; } /* 从内核空间申请pg_cnt个页空间 成功返回对应的起始虚拟地址,失败则返回NULL即可 */ void *malloc_kernel_page(uint32_t pg_cnt) { void *vir_addr = malloc_page(PF_KERNEL, pg_cnt); // 如果成功分配到了pg_cnt个页空间,则将该空间进行清0,避免重要的旧数据泄露 if(!vir_addr) { memset(vir_addr, 0, PG_SIZE * pg_cnt); } return vir_addr; }
注释已经非常详尽了。这里在具体说明几点
1. 对于进程(内核进程/用户进程),其申请内存的实质,是在该进程的虚拟内存池中申请对应的资源(位图上进行查找和标注),然后再全局的物理内存池中申请物理页(根据进程类别在内核内存池/用户内存池中进行申请),最后在将该进程中申请的虚拟地址对应的页表填充上申请的物理页即可。目前由于仅仅实现了内核进程,因此这里的页表就是内核进程的页表,也就是我们前面实现的全局页表,则我们申请物理页,自然在内核内存池中进行申请即可。
2. 由于虚拟内存池是足够大的,每一个进程的虚拟内存池理论上都是4GB,因此我们可以一次申请大量的连续虚拟页,基本不会出现问题。物理内存池一般没有那么大,至少在我们实验的虚拟机中,仅仅设置了32MB,因此我们不一定可以申请到大量连续的物理页,因此我们一般一次申请一个单位,也就是一个物理页,然后多次申请。因此我们一般申请虚拟内存和物理内存的方式是不同的,向虚拟内存池中申请,我们直接一次性申请连续的虚拟内存页即可;而对于物理内存页,我们多次申请不一定连续的物理页,每一次仅仅申请一个物理页。当然,通过页表映射,我们可以通过连续的虚拟地址访问不连续的物理页,从而使不连续的物理页逻辑上连续起来。
3. 注意到,虽然我们说虚拟内存池是足够大的。但是这里我们实现的内存管理比较简单,因此对于内核进程来说,其虚拟内存池大小就和其物理的内核内存池大小相同即可。这里特别说明一下。
4. 最后一点,就是在虚拟地址上进行直接读写页表、页目录表。实际上前面分页机制部分已经讲过了,这里只说明以下原因——实际上可以简单理解为开启分页机制后,CPU的所有内存访问,其地址都会通过页部件,而页部件会进行两次页目录表、页表映射,页部件中所有的地址访问都是真实的。那么问题就在于两次映射,即使我们拥有真实的页表物理地址,但是只要是从CPU中传递的,统统认为是虚拟地址(即使其值为真实的物理地址),则也要经过两次映射,则最后访问的大概率不是前面传送的物理地址。这里具体的细节不再赘述,直接给出更为使用的结论。
如果我们访问内存地址为虚拟地址(0xfffff000 + offset),则我们实际上读写的是页目录表的offset偏移的数据,也就是通过*(0xfffff000 + offset)修改的是页目录表的offset偏移处的数据
如果我们访问内存地址为虚拟地址(0xffc00000 + (idx1 << 12) + offset),则我们实际上读写的是页目录表的第idx1页目录项指向的页表的offset偏移的数据。也就是通过*(0xffc00000 + (idx1 << 12) + offset),我们可以修改页目录表的第idx1页表的offset偏移的数据。按照这个结论,则我们完全可以将内核进程的虚拟地址空间的指定虚拟地址的页表项、页目录表项的值进行修改,从而指定其项的真实物理地址,完成物理地址和虚拟地址的映射。
这里给出仓库链接。下面给出我们的测试代码,如下所示
#include "print.h" #include "init.h" #include "memory.h" int main(void) { /* 初始化所有的模块 */ init_all(); // 尝试申请页 void *vir_addr = malloc_kernel_page(2); put_str("[*] malloc_kernel_page's vir_addr_start1: 0x"); put_uHex(vir_addr); put_char('\n'); vir_addr = malloc_kernel_page(3); put_str("[*] malloc_kernel_page's vir_addr_start2: 0x"); put_uHex(vir_addr); while(1); return 0; }
则我们通过make命令进行编译,最后在虚拟机上进行运行,结果如图所
确实从内核虚拟地址空间的虚拟内存池的起始地址处分类了两个虚拟页,我们首先观察一下此时的内核空间的页表和页目录表,如下所示
可以看到,和刚刚开启分页机制相比,多了虚拟地址0xc0100000 - 0xc0104fff映射到0x200000 - 0x204fff的物理地址的映射,也就是内核的虚拟内存池的5个虚拟页映射到了内核内存池的5个物理页,和预期的是一样的。下面则是再查看一下内核的虚拟地址空间的位图情况,其地址位于0xc009a3c0(前面的输出),如下所示
这表明确实bitmap中连续的5个位被置为1,下面则是内核内存池,其地址位于0xc009a000(前面的输出)
其位图也被成功置位。表明最后成功了。