实现: 实现分页内存管理
重新建立段映射
前面已经介绍了如何探测物理内存,接下来 ucore 需要根据物理内存的情况来建立分页管理机制。首先观察一下 tools/kernel.ld 文件在 proj4.1 和 proj5 中的区别,在 proj4.1 中:
ENTRY(kern_init)
SECTIONS {
/* Load the kernel at this address: "." means the current address */
. = 0x100000;
.text : {
*(.text .stub .text.* .gnu.linkonce.t.*)
}
在 porj5 中:
ENTRY(kern_entry)
SECTIONS {
/* Load the kernel at this address: "." means the current address */
. = 0xC0100000;
.text : {
*(.text .stub .text.* .gnu.linkonce.t.*)
}
在意味着 gcc 编译出 ucore 的起始地址从 0xC0100000 开始,入口函数为 kern_entry 函数。这与 proj4.1 有很大差别。这实际上说明 ucore 在建立好页映射关系后,虚拟地址空间和物理地址空间之间存在如下的映射关系:
Virtual Address=LinearAddress=0xC0000000+Physical Address
另外,ucore 的入口地址也改为了 kern_entry 函数,这个函数位于 init/entry.S 中,分析代码可以看出,entry.S 重新建立了段映射关系,从以前的
Virtual Address= Linear Address
改为
Virtual Address=Linear Address-0xC0000000
由于 gcc 编译出的虚拟起始地址从 0xC0100000 开始,ucore 被 bootloader 放置在从物理地址 0x100000 处开始的物理内存中。所以当 kern_entry 函数完成新的段映射关系后,且 ucore 在没有建立好页映射机制前,CPU 按照 ucore 中的虚拟地址执行,能够被分段机制映射到正确的物理地址上,确保 ucore 运行正确。
初始化物理内存页分配管理
为了与以后的分页机制配合,我们首先需要建立对整个计算机的页级物理内存分配管理。这部分代码的实现在 kern/default_pmm.[ch]。首先我们需要用一个数据结构来描述每个物理页(也称页帧),这里用了双向链表结构来表示每个页。链表头用 free_area_t 结构来表示,包含了一个 list_entry 结构的双向链表指针和记录当前空闲页的个数的无符号整型变量 nr_free。
/* free_area_t - maintains a doubly linked list to record free (unused) pages */
typedef struct {
list_entry_t free_list; // the list header
unsigned int nr_free; // # of free pages in this free list
} free_area_t;
每一个物理页的属性用结构 Page 来表示,它包含了映射此物理页的虚拟页个数,描述物理页属性的 flags 和双向链接各个 Page 结构的 page_link 双向链表。
struct Page {
atomic_t ref; // page frame's reference counter
uint32_t flags; // array of flags that describe the status of the page frame
list_entry_t page_link; // free list link
};
有了这两个数据结构,ucore 就可以管理起来整个以页为单位的物理内存空间。接下来需要解决两个问题:
- 管理页级物理内存空间所需的 Page 结构的内存空间从哪里开始,占多大空间?
- 空闲内存空间的起始地址在哪里?
对于这两个问题,我们首先根据 bootloader 给出的内存布局信息找出最大的物理内存地址 maxpa(定义在 page_init 函数中的局部变量),由于 x86 的起始物理内存地址为 0,所以可以得知需要管理的物理页个数为
npage = maxpa / PGSIZE
这样,我们就可以预估出管理页级物理内存空间所需的 Page 结构的内存空间所需的内存大小为:
sizeof(struct Page) * npage)
由于 bootloader 加载 ucore 的结束地址(用全局指针变量 end 记录)以上的空间没有被使用,所以我们可以把 end 按页大小为边界去整后,作为管理页级物理内存空间所需的 Page 结构的内存空间,记为:
pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);
为了简化起见,从地址 0 到地址 pages+ sizeof(struct Page) npage) 结束的物理内存空间设定为已占用物理内存空间(起始 0~640KB 的空间是空闲的),地址 pages+ sizeof(struct Page) npage) 以上的空间为空闲物理内存空间,这时的空闲空间起始地址为
uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);
为此我们需要把这两部分空间给标识出来。对于已占用物理空间,通过如下语句即可实现占用标记:
for (i = 0; i < npage; i ++) {
SetPageReserved(pages + i);
}
对于空闲物理空间,通过如下语句即可实现空闲标记:
//获得空闲空间的起始地址 begin 和结束地址 end
……
init_memmap(pa2page(begin), (end - begin) / PGSIZE);
其实 SetPageReserved 只需把物理地址对应的 Page 结构中的 flags 标志设置为 PG_reserved ,表示这些页已经被使用了。而 init_memmap 函数则是把空闲物理页对应的 Page 结构中的 flags 和引用计数 ref 清零,并加到 free_area.free_list 指向的双向列表中,为将来的空闲页管理做好初始化准备工作。
物理内存页分配与释放
关于内存分配的操作系统原理方面的知识有很多,但在 proj5 中只实现了最简单的内存页分配算法,即每次只分配一页或释放一页的内存页分配算法。相应的实现在 default_pmm.c 中的 default_alloc_pages 函数和 default_free_pages 函数,相关实现很简单,这里就不具体分析了,直接看源码,应该很好理解。
其实 proj5 在内存分配和释放方面最主要的作用是建立了一个物理内存页管理器框架,这实际上是一个函数指针列表,定义如下:
struct pmm_manager {
const char *name; //物理内存页管理器的名字
void (*init)(void); //初始化内存管理器
void (*init_memmap)(struct Page *base, size_t n); //初始化管理空闲内存页的数据结构
struct Page *(*alloc_pages)(size_t n); //分配 n 个物理内存页
void (*free_pages)(struct Page *base, size_t n); //释放 n 个物理内存页
size_t (*nr_free_pages)(void); //返回当前剩余的空闲页数
void (*check)(void); //用于检测分配/释放实现是否正确的辅助函数
};
重点是实现 init_memmap/ alloc_pages/ free_pages 这三个函数。当完成物理内存页管理初始化工作后,计算机系统的内存布局如下图所示:
chapt3-proj5-memory.vsd
读者可进一步通过分析 proj5.1/5.1.1/5.1.2/5.2 中 firstfit_pmm[ch]/bestfit_pmm[ch]/ worstfit_pmm[ch]/ buddy_pmm[ch]文件中对应函数实现来体会原理课中的连续空间内存分配中各种分配算法的设计思路和实现。
建立二级页表
为了实现分页机制,需要建立好虚拟内存和物理内存的页映射关系,即建立二级页表。这需要解决如下问题:
- 对于哪些物理内存空间需要建立页映射关系?
- 具体的页映射关系是什么?
- 页目录表的起始地址设置在哪里?
- 页表的起始地址设置在哪里,需要多大空间?
- 如何设置页目录表项的内容?
- 如何设置页目录表项的内容?
下面我们逐一解决上述问题。由于物理内存页管理器管理了从 0 到实际可用物理内存大小的物理内存空间,所以对于这些物理内存空间都需要建立好页映射关系。由于目前 ucore 只运行在内核空间,所以可以建立一个一一映射关系。假定虚拟内核地址的起始地址为 0xC0000000,这虚拟内存和物理内存的具体页映射关系为:
Virtual Address=Physical Address+0xC0000000
由于我们已经具有了一个物理内存页管理器 default_pmm_manager,我们就可以用它来获得所需的空闲物理页。在二级页表结构中,页目录表占 4KB 空间,ucore 就可通过 default_pmm_manager 的 default_alloc_pages 函数获得一个空闲物理页,这个页的起始物理地址就是页目录表的起始地址。同理,ucore 也通过这种方式获得各个页表所需的空间。页表的空间大小取决与页表要管理的物理页数 n,一个页表项(32 位,即 4 字节)可管理一个物理页,页表需要占 n/256 个物理页空间。这样页目录表和页表所占的总大小为 4096+1024*n 字节。
为把 0~KERNSIZE(明确 ucore 设定实际物理内存不能超过 KERNSIZE 值,即 0x38000000 字节,896MB,3670016 个物理页)的物理地址一一映射到页目录表项和页表项的内容,其大致流程如下:
- 先通过 default_pmm_manager 获得一个空闲物理页,用于页目录表;
- 调用 boot_map_segment 函数建立一一映射关系,具体处理过程以页为单位进行设置,即
Virtual Address=Physical Address+0xC0000000
- 设一个逻辑地址 la(按页对齐,故低 12 位为零)对应的物理地址 pa(按页对齐,故低 12 位为零),如果在页目录表项(la 的高 10 位为索引值)中的存在位(PTE_P)为 0,表示缺少对应的页表空间,则可通过 default_pmm_manager 获得一个空闲物理页给页表,页表起始物理地址是按 4096 字节对齐的,这样填写页目录表项的内容为
页目录表项内容 = 页表起始物理地址| PTE_U | PTE_W | PTE_P
- 进一步对于页表中对应页表项(la 的中 10 位为索引值)的内容为
页表项内容 = pa | PTE_P | PTE_W
其中:
- PTE_U:位 3,表示用户态的软件可以读取对应地址的物理内存页内容
- PTE_W:位 2,表示物理内存页内容可写
- PTE_P:位 1,表示物理内存页存在
- 设一个逻辑地址 la(按页对齐,故低 12 位为零)对应的物理地址 pa(按页对齐,故低 12 位为零),如果在页目录表项(la 的高 10 位为索引值)中的存在位(PTE_P)为 0,表示缺少对应的页表空间,则可通过 default_pmm_manager 获得一个空闲物理页给页表,页表起始物理地址是按 4096 字节对齐的,这样填写页目录表项的内容为
建立好一一映射的二级页表结构后,接下来就要使能分页机制了,这主要是通过 enable_paging 函数实现的,这个函数主要做了两件事:
- 通过 lcr3 指令把页目录表的起始地址存入 CR3 寄存器中;
- 通过 lcr0 指令把 cr0 中的 CR0_PG 标志位设置上。
执行完 enable_paging 函数后,计算机系统进入了分页模式!但到这一步还不够,还记得 ucore 在最开始通过 kern_entry 函数设置了临时的新段映射机制吗?这个临时的新段映射机制不是最简单的对等映射,导致虚拟地址和线性地址不相等。而刚才建立的页映射关系是建立在简单的段对等映射,即虚拟地址=线性地址的假设基础之上的。所以我们需要进一步调整段映射关系,即重新设置新的 GDT,建立对等段映射。
这里需要注意:在进入分页模式到重新设置新 GDT 的过程是一个过渡过程。在这个过渡过程中,已经建立了页表机制,所以通过现在的段机制和页机制实现的地址映射关系为:
Virtual Address=Linear Address + 0xC0000000 = Physical Address +0xC0000000+0xC0000000
在这个特殊的阶段,如果不把段映射关系改为 Virtual Address = Linear Address,则通过段页式两次地址转换后,无法得到正确的物理地址。为此我们需要进一步调用 gdt_init 函数,根据新的 gdt 全局段描述符表内容(gdt 定义位于 pmm.c 中),恢复以前的段映射关系,即使得 Virtual Address = Linear Address。这样在执行完 gdt_init 后,通过的段机制和页机制实现的地址映射关系为:
Virtual Address=Linear Address = Physical Address +0xC0000000
这里存在的一个问题是,在调用 enable_page 函数使能分页机制后到执行完毕 gdt_init 函数重新建立好段页式映射机制的过程中,内核使用的还是旧的段表映射,也就是说,enable paging 之后,内核使用的是页表的低地址 entry。 如何保证此时内核依然能够正常工作呢?其实只需让低地址目录表项的内容等于以 KERNBASE 开始的高地址目录表项的内容即可。目前内核大小不超过 4M (实际上是 3M,因为内核从 0x100000 开始编址),这样就只需要让页表在 0~4MB 的线性地址与 KERNBASE ~ KERNBASE+4MB 的线性地址获得相同的映射即可,都映射到 0~4MB 的物理地址空间,具体实现在 pmm.c 中 pmm_init 函数的语句:
boot_pgdir[0] = boot_pgdir[PDX(KERNBASE)];
实际上这种映射也限制了内核的大小。当内核大小超过预期的 3MB 就可能导致打开分页之后内核 crash,在后面的试验中,也的确出现了这种情况。解决方法同样简单,就是拷贝更多的高地址项到低地址。
当执行完毕 gdt_init 函数后,新的段页式映射已经建立好了,上面的 0~4MB 的线性地址与 0~4MB 的物理地址一一映射关系已经没有用了。所以可以通过如下语句解除这个老的映射关系。
boot_pgdir[0] = 0;
自映射机制
上一小节讲述了通过 boot_map_segment 函数建立了基于一一映射关系的页目录表项和页表项,这里的映射关系为:
Virtual addr (KERNBASE~KERNBASE+KMEMSIZE) = Physical_addr (0~KMEMSIZE)
这样只要给出一个虚地址和一个物理地址,就可以设置相应 PDE 和 PTE,就可完成正确的映射关系。
如果我们这时需要按虚拟地址的地址顺序显示整个页目录表和页表的内容,则要查找页目录表的页目录表项内容,根据页目录表项内容找到页表的物理地址,再转换成对应的虚地址,然后访问页表的虚地址,搜索整个页表的每个页目录项。这样过程比较繁琐。我们有没有一个简洁的方法来实现这个查找呢?ucore 做了一个很巧妙的地址自映射设计,把页目录表和页表放在一个连续的 4MB 虚拟地址空间中,并设置页目录表自身的虚地址< - >物理地址映射关系。这样在已知页目录表起始虚地址的情况下,通过连续扫描这特定的 4MB 虚拟地址空间,就很容易访问每个页目录表项和页表项内容。
具体而言,ucore 是这样设计的,首先设置了一个常量(memlayout.h):
VPT=0xFAC00000,
这个地址的二进制表示为:
1111 1010 1100 0000 0000 0000 0000 0000
高 10 位为 1111 1010 11,即 10 进制的 1003,中间 10 位为 0,低 12 位也为 0。在 pmm.c 中有两个全局初始化变量
pte_t * const vpt = (pte_t *)VPT;
pde_t * const vpd = (pde_t *)PGADDR(PDX(VPT), PDX(VPT), 0);
NaN. 并在 pmm_init 函数执行了如下语句:
boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W;
这些变量和语句有何特殊含义呢?其实 vpd 变量的值就是页目录表的起始虚地址 0xFAFEB000,且它的高 10 位和中 10 位是相等的,都是 10 进制的 1003。当执行了上述语句,就确保了 vpd 变量的值就是页目录表的起始虚地址,且 vpt 是页目录表中第一个目录表项指向的页表的起始虚地址。此时描述内核虚拟空间的页目录表的虚地址为 0xFAFEB000,大小为 4KB。页表的理论连续虚拟地址空间 0xFAC00000~0xFB000000,大小为 4MB。因为这个连续地址空间的大小为 4MB,可有 1M 个 PTE,即可映射 4GB 的地址空间。
但 ucore 实际上不会用完这么多项,在 memlayout.h 中定义了常量
#define KMEMSIZE 0x38000000
表示 ucore 只支持 896MB 的物理内存空间,这个 896MB 只是一个设定,可以根据情况改变。则最大的内核虚地址为常量
\#define KERNTOP (KERNBASE + KMEMSIZE)=0xF8000000
所以最大内核虚地址 KERNTOP 的页目录项虚地址为
vpd+0xF8000000/0x400000=0xFAFEB000+0x3E0=0xFAFEB3E0
最大内核虚地址 KERNTOP 的页表项虚地址为: vpt+0xF8000000/0x1000=0xFAC00000+0xF8000=0xFACF8000
在 pmm.c 中的函数 print_pgdir 就是基于 ucore 的页表自映射方式完成了对整个页目录表和页表的内容扫描和打印。注意,这里不会出现某个页表的虚地址与页目录表虚地址相同的情况。
自映射机制还可方便用户态程序访问页表。因为页表是内核维护的,用户程序很难知道自己页表的映射结构。VPT 实际上在内核地址空间的,我们可以用同样的方式实现一个用户地址空间的映射(比如 pgdir[UVPT] = PADDR(pgdir) | PTE_P | PTE_U,注意,这里不能给写权限,并且 pgdir 是每个进程的 page table,不是 boot_pgdir),这样,用户程序就可以用和内核一样的 print_pgdir 函数遍历自己的页表结构了。
在 page_init 函数建立完实现物理内存一一映射和页目录表自映射的页目录表和页表后,一旦使能分页机制,则 ucore 看到的内核虚拟地址空间如下图所示:
proj5 使能分页机制后的虚拟地址空间图
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论