本节前置内容:内存管理-基础-初始化内存池
本节对应分支:memory-alloc

概述

对笔者而言,内存分配一直是操作系统最神秘的部分之一,从学习编程开始,就一直能在耳边听到这个词,所以这也是本人最期待的部分,不知读者是否也是如此呢?本节我们实现的内存分配是“整页分配”,这与 malloc 函数不同,后者能申请任意大小的内容,而前者的申请单位则是以页为计。不过,malloc 也是基于“整页分配”进行的,所以未来我们也会借助本节内容来实现 malloc 函数。

本节的函数逻辑也都很简单,只是它们的数量较多,关系稍显复杂,所以贴心的笔者(手动狗头^_^)献上一幅函数关系图以供大家参考:

上图就是内存申请的全过程,大括号中包含的函数即为括号所指函数中调用的函数,且从上到下依次调用。上图只是为了让大家稍微熟悉页分配的过程,具体过程咋们还是来看代码吧。

代码解析

//memory.h
enum pool_flags {
   PF_KERNEL = 1,    // 内核内存池
   PF_USER = 2	     // 用户内存池
};

struct virtual_addr {          
    struct bitmap vaddr_bitmap;  // 内核虚拟内存池用到的位图结构
    uint32_t vaddr_start;        // 内核虚拟起始地址
};

struct pool {
    struct bitmap pool_bitmap;	 // 内核/用户物理内存池用到的位图结构
    uint32_t phy_addr_start;	 // 内存池所管理物理内存的起始地址
    uint32_t pool_size;		     // 内存池字节容量
};

#define	 PG_P_1	  1	// 页表项或页目录项存在属性位
#define	 PG_P_0	  0	// 页表项或页目录项存在属性位
#define	 PG_RW_R  0	// R/W 属性位值, 读/执行
#define	 PG_RW_W  2	// R/W 属性位值, 读/写/执行
#define	 PG_US_S  0	// U/S 属性位值, 系统级
#define	 PG_US_U  4	// U/S 属性位值, 用户级

void  mem_init();
void* get_kernel_pages(uint32_t pg_cnt);
void* malloc_page(enum pool_flags pf, uint32_t pg_cnt);
void  malloc_init();
uint32_t* pte_ptr(uint32_t vaddr);
uint32_t* pde_ptr(uint32_t vaddr);
  • pool_flags 为枚举,用来指明当前的操作对象是内核内存池还是用户内存池。
  • 第 18~23 行为页表项/目录项的属性,这将在我们创建页表项和页目录项时用到。读者可能已经忘了页表项/页目录项的格式:

    关于这些属性的详细介绍,请回顾开启分页
//memory.c
#define PG_SIZE 4096
/***************  位图地址 ********************
* 因为0xc009f000是内核主线程栈顶,0xc009e000是内核主线程的pcb.
* 一个页框大小的位图可表示128M内存, 位图位置安排在地址0xc009a000,
* 这样本系统最大支持4个页框的位图,即512M */
#define MEM_BITMAP_BASE 0xc009a000
#define PDE_IDX(addr) ((addr & 0xffc00000) >> 22) //取得addr对应的页目录表索引,其实直接addr>>22也是可以的
#define PTE_IDX(addr) ((addr & 0x003ff000) >> 12) //取得addr对应的页表索引
/* 0xc0000000是内核从虚拟地址3G起. 0x100000意指跨过低端1M内存,使虚拟地址在逻辑上连续 */
#define K_HEAP_START   0xc0100000
#define MEM_SIZE_ADDR  0x90c
struct pool kernel_pool, user_pool;      // 生成内核内存池和用户内存池
struct virtual_addr kernel_vaddr;        // 此结构是用来给内核分配虚拟地址

/* 在pf表示的虚拟内存池中申请pg_cnt个虚拟页,
 * 成功则返回虚拟页的起始地址, 失败则返回NULL */
static void* vaddr_get(enum pool_flags pf, uint32_t pg_cnt) 
{
   int vaddr_start = 0, bit_idx_start = -1;
   uint32_t cnt = 0;
   if (pf == PF_KERNEL) 
   {
      bit_idx_start  = bitmap_scan(&kernel_vaddr.vaddr_bitmap, pg_cnt);
      if (bit_idx_start == -1) 
        return NULL;
      while(cnt < pg_cnt)       //将申请到的位置1,表示已使用
        bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 1);
      vaddr_start = kernel_vaddr.vaddr_start + bit_idx_start * PG_SIZE;
   }
   else
   {	
      // 用户内存池,将来实现用户进程再补充
   }
   return (void*)vaddr_start;
}

/* 得到虚拟地址vaddr对应的pte指针*/
uint32_t* pte_ptr(uint32_t vaddr) 
{
   uint32_t* pte = (uint32_t*)(0xffc00000 + ((vaddr & 0xffc00000) >> 10) + PTE_IDX(vaddr) * 4);
   return pte;
}

/* 得到虚拟地址vaddr对应的pde的指针 */
uint32_t* pde_ptr(uint32_t vaddr) 
{
   /* 0xfffff是用来访问到页目录表本身所在的地址 */
   uint32_t* pde = (uint32_t*)((0xfffff000) + PDE_IDX(vaddr) * 4);
   return pde;
}

/* 在m_pool指向的物理内存池中分配1个物理页,
 * 成功则返回页框的物理地址,失败则返回NULL */
static void* palloc(struct pool* m_pool) 
{
   /* 扫描和设置位图要保证原子操作 */
   int bit_idx = bitmap_scan(&m_pool->pool_bitmap, 1); // 找一个物理页面
   if (bit_idx == -1 ) 
      return NULL;
   bitmap_set(&m_pool->pool_bitmap, bit_idx, 1);       // 将此位bit_idx置1
   uint32_t page_phyaddr = ((bit_idx * PG_SIZE) + m_pool->phy_addr_start);
   return (void*)page_phyaddr;
}

/* 页表中添加虚拟地址_vaddr与物理地址_page_phyaddr的映射 */
static void page_table_add(void* _vaddr, void* _page_phyaddr) 
{
   uint32_t  vaddr = (uint32_t)_vaddr;
   uint32_t  page_phyaddr = (uint32_t)_page_phyaddr;
   uint32_t* pde = pde_ptr(vaddr);
   uint32_t* pte = pte_ptr(vaddr);

/************************   注意   *************************
 * 执行*pte,可能会访问到空的pde。所以确保pde创建完成后才能执行*pte,
 * 否则会引发page_fault。因此在*pde为0时,*pte只能出现在下面else语句块中的*pde后面。
 * *********************************************************/
   /* 先在页目录内判断目录项的P位,若为1,则表示该表已存在 */
   if (*pde & 0x00000001)           //页目录项的第0位为P,此处判断目录项是否存在
   {	                            //如果存在,则添加映射(安装页表项)
      *pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1);  // US=1,RW=1,P=1
   }
   else                             //如果页目录项对应的页表不存在,则先创建页表再创建页表项. 
   {  
      /* 页表所用页框一律从内核空间分配 */
      uint32_t pde_phyaddr = (uint32_t)palloc(&kernel_pool); //申请页表空间
      *pde = (pde_phyaddr | PG_US_U | PG_RW_W | PG_P_1);     //安装页目录项
      /* 以下将分配到的物理页地址pde_phyaddr对应的物理内存清0,
       * 避免里面的陈旧数据变成了页表项,从而让页表混乱.
       * 访问到pde对应的物理地址,用pte取高20位便可.
       * 因为pte是基于该pde对应的物理地址内再寻址,
       * 把低12位置0便是该pde对应的物理页的起始*/
      memset((void*)((int)pte & 0xfffff000), 0, PG_SIZE);    //将申请到的页表清零
      assert(!(*pte & 0x00000001));
      *pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1);    //注册页表项
   }
}

/* 分配pg_cnt个页空间,成功则返回起始虚拟地址,失败时返回NULL */
void* malloc_page(enum pool_flags pf, uint32_t pg_cnt) {
   assert(pg_cnt > 0 && pg_cnt < 3840);       //3840页内存=15MB
/***********   malloc_page的原理是三个动作的合成:   ***********
      1通过vaddr_get在虚拟内存池中申请虚拟地址
      2通过palloc在物理内存池中申请物理页
      3通过page_table_add将以上得到的虚拟地址和物理地址在页表中完成映射
***************************************************************/
   void* vaddr_start = vaddr_get(pf, pg_cnt);
   if (vaddr_start == NULL) 
      return NULL;

   uint32_t vaddr = (uint32_t)vaddr_start, cnt = pg_cnt;
   struct pool* mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool;

   /* 因为虚拟地址是连续的,但物理地址可以是不连续的,所以逐个做映射*/
   while (cnt-- > 0) 
   {
      void* page_phyaddr = palloc(mem_pool);      //palloc每次申请一个物理页
      if (page_phyaddr == NULL)   // 失败时要将曾经已申请的虚拟地址和物理页全部回滚,在将来完成内存回收时再补充
      {  
          //回滚,后续补充
          return NULL;
      }
      page_table_add((void*)vaddr, page_phyaddr); // 在页表中做映射 
      vaddr += PG_SIZE;	                          // 下一个虚拟页
   }
   return vaddr_start;
}

/* 从内核物理内存池中申请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;
}
//==========以下是mem_pool_init和mem_init函数,上节已做解析,不再展示。=====================

建议看官阅读代码时,按上面给的函数关系图的顺序进行,这样思路会更加清晰。注释很详细,下面只对几个点做强调:

  • 第 41 行,获取虚拟地址对应的 PTE 地址。如何根据给定的虚拟地址定位相应的页目录和页表?这在开启分页-代码详解中提到过,请各位回顾该节,此处不再赘述。

  • 第 57 行,“扫描和设置位图要保证原子操作”,这句话的意思是,扫描和设置位图必须连续,中间不能切换线程 。这里和线程切换有关,简单作下阐述:比如当线程 A 执行完第 58 行,成功找到一个物理页面;紧接着,切换到 B 线程,恰好 B 线程也执行到了 58 行,也成功找到了一个物理页面。由于线程 A 找到后还没来得及将该位置 1 就被换下 CPU,因此 A、B 这两个线程此时申请的是同一个物理页面!这必然会引发问题 。因此扫描和设置位图必须保证原子操作。需要注意的是,此处代码并没有保证原子性,未来我们会用锁来实现 。当然,如果读者实在不放心,可以先在此函数首尾分别关开中断,避免时钟中断引发任务调度。

  • 同样是申请页,为什么 vaddr_get() 有申请页数的参数,而 palloc() 没有呢?这个答案在第 100 行 malloc_page() 函数中。这是因为申请的 虚拟地址必须连续,即必须是一整块虚拟内存;而申请的物理内存则无需连续 (如果要求物理内存连续,则分页机制将彻底变成鸡肋)。所以,申请一大块虚拟内存时,填写你所需的页数参数即可;而申请一大块物理内存时,则需要通过第 115 行的 while() 进行。同时注意,第 58 行的位图扫描,申请个数被指定为 1 。

  • 第 79~96 行是需要重点强调的内容
    (1)第 79 行判断该 vaddr 对应页目录项是否存在,这句话并不精确,应该是:判断该页目录项对应的页表是否存在。原因是,页目录项一定是存在的(因为页目录表是完整的),不管是现在的内核进程或是将来的用户进程,创建进程时我们都为其开辟一张完整的页目录表内存,只是说可能并不会为所有的页目录项填写信息(安装页目录项)。有人会问,既然并非每个页目录项都记录了信息,那怎么还能通过 79 行的 if 语句判断目录项对应的页表是否存在呢?好问题!这就是第 133 行将申请到的页内存全部清零的原因 。将来我们为用户进程开辟页目录表时,会通过 get_kernel_pages() 申请一页内存,并将其作为页目录表。此时页目录表所占字节全为 0(第133行),因此每个页目录项中的 P 位也为 0(表示不对应任何页表),如此一来,就可以通过 P 位来判断该目录项对应的页表是否存在。也就是说,如果不显式安装页目录项,则 P=0,无对应页表。
    (2)第 85 行注释,不论是内核页表还是用户页表,所用页框一律从内核空间分配 。注意,用户进程的页目录表/页表存放在内核空间而非用户空间中,否则恶意用户进程就可以通过某些方式修改内存映射,从而访问内核或其他进程的物理内存。因此,内存管理都由内核负责!
    (3)第 93 行,与前类似,须将申请到的页表内存初始化为 0,这样访问某虚拟地址时,如果对应的页表项不存在,即 P=0,则引发缺页异常。注意,笔者最初很疑惑为什么不直接利用 pde_phyadd 清零页表:

    memset(pde_phyadd, 0, PG_SIZE);
    

    这是因为:由于开启了分页,即使 pde_phyadd 为页表的物理地址,编译器也会将其看作虚拟地址 ,所以此方式清零的内存并非物理地址 pde_phyadd!经过第 87 行安装页目录项后,(void*)((int)pte & 0xfffff000) 对应的物理地址才是 pde_phyadd 。这里很绕,请读者反复理解!

    内核的页目录被创建时也被初始化为 0,参见开启分页-代码详解中 loader.s 的第 122 行代码。

    分页机制中我们说过,页目录表必须完整,通过以上解析,大家理解了其中的原因吗?由于页目录表已经覆盖所有地址,页表才能够按需创建,这相比于一级页表,大大节省了页表所占用的内存。

    实际上,只有在用户进程中才会出现页目录项对应的页表不存在的情况。内核代码只运行在 1MB 内,内核堆的约 1GB 空间也已经提前创建好了页表(第769~1022号页表),所以内核不会出现此情况。

  • vaddr_get()、palloc()、page_table_add() 均被声明为静态函数,这是因为这三个函数仅供 malloc_page() 函数使用,对外部不可见。

OK,本节就到这里,内容少但密度大,注意消化。

文章作者: 极简
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
自制操作系统
喜欢就支持一下吧