内存管理-进阶-分配页内存
本节前置内容:内存管理-基础-初始化内存池
本节对应分支: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,本节就到这里,内容少但密度大,注意消化。