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

概述

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

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

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

代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//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 行为页表项/目录项的属性,这将在我们创建页表项和页目录项时用到。读者可能已经忘了页表项/页目录项的格式:

    关于这些属性的详细介绍,请回顾开启分页
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
//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 清零页表:

    1
    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,本节就到这里,内容少但密度大,注意消化。