开启分页-代码详解
阅读开启分页机制是本节的前置要求。
本节代码对应分支open-page
。
boot.inc
在进入保护模式的基础上,boot.inc
增添了如下内容:
1 | ;========页目录地址和页表起始地址=========== |
- 和 GDT 相同,页目录也可以放置在内存中的任何地方 ,这里我们直接将其放在
0x100000
处。 - 为了使内存紧凑,这里让页表紧挨着页目录。注意,这不是必须的!页目录表大小为 4KB,所以页表地址在页目录地址的基础上加 4096 (0x1000)。
以下为内存映像图:
loader.s
1 | ;文件说明:loader.s |
首先,务必先理清楚页目录表、页目录项、页表、页表项的关系,否则上面的代码将会看得你一头雾水!另外再次强调,页目录项和页表项中装载的是物理地址,这点很重要。为方便对照,将开启分页机制中的页目录项/页表项的结构图搬过来:
让我们先聚焦 setup_page
,从第 123 行代码开始。
-
第 137 行,
PG_US_U | PG_RW_W | PG_P
,这三位为 1,其他位都为 0 。 -
第 140 行,为什么要将第 0 号页表的地址装载到第 0 号目录项中?原因是:分页机制是在 loader 中开启的,而 loader 本身已经位于 1MB 物理内存中,所以我们必须保证开启分页前后
CS : EIP
都正确指向 1MB 内的相关 loader 代码,即必须保证之前段机制下的线性地址和分页后的虚拟地址所对应的物理地址一致 。也就是说虚拟地址下的 1MB 内存与真实物理地址下的 1MB 内存是完全一一对应的 。还是举个例子:开启分页前一瞬间CS:IP=0x0000:1002
即0x00001002
,这是真实的物理地址;开启分页后执行的一条指令的地址为CS:IP=0x0000:1004
,由于已经开启分页,这就成了虚拟地址,即0x00001004
。按照分页机制中的计算方法,这个虚拟地址将映射到第 0 号页目录,因为其中装载的是第 0 号页表的地址,进而到第 0 号页表,虚拟地址中的1
则将其映射到第 1 号页表项,对应的物理页框为0x1000
(后续147~155行代码会将物理页框写入页表项),最后加上偏移地址0x04
,得到物理地址0x00001004
。可见,开启分页前后物理地址和虚拟地址是相同的。 -
第 141 行,为什么将第 0 号页表的地址装载到第 768 号目录项中?原因是:第 768 号目录项对应的虚拟地址是 3GB~3GB+4MB,我们的内核镜像就在此处。实际内核位于低 1MB 的内存中,现在将其映射到内存 3GB 处,所以必须把第 0 号页表的地址装载到第 768 号目录项中 。
必须说明的是,第 2, 3点之所以能够顺利将虚拟地址下的 1MB 内存与真实物理地址下的 1MB 内存一一映射,其基础是 147~155 代码,这段代码将虚拟 1MB 与物理 1MB 地址空间一一对应。
-
第 145 行,为什么往最后一个(1023)目录项中安装目录表自身的地址?这是为了在开启分页后,通过虚拟地址找到页表,这样才能动态操作页表 。二级页表是一种动态的数据结构,要申请一大块内存时可能会添加页表项,释放一块内存时可能会删减页表项。而页表和页目录都是存在于内存中的,要对其进行删减就必须知道它的地址,问题是现在已经进入了虚拟地址空间,我们该如何访问它呢?通过往最后一个目录项中安装目录表自身的地址可以迂回实现。当虚拟地址为
0xfffff000
,即高 10 位和中间 10 位都为0x3ff(1023)
时,通过高 10 位访问到第 1023 目录项,取得其中的地址;因为该地址为目录表自身地址,所以通过中间 10 位进行索引时会以该地址为基准, 也就是说,第一次索引和第二次索引都是在目录表中进行的(原本第一次索引是在目录表中,第二次索引在页表中);第二次索引后,取得的地址仍为目录表自身起始地址,而 CPU 会将其当作物理页框地址来使用;最后 12 位页内偏移地址置为 0,则最终虚拟地址就被映射成了目录表起始地址 。如果想访问目录表项,将0xfffff000
改为0xfffffxxx
即可,其中xxx
为索引值*4
,原因不再赘述。如果想访问页表项,则高 10 位为0x3ff
,中间 10 位为索引值,此时得到相应页表的起始地址,CPU 将其作为物理页框地址,加上最后 12 位,为索引值*4
,最终映射成某页表项的地址 。此方式的核心在于:CPU 很笨,通过目录项原本应该取得页表的地址,然后访问该页表;然而此方式通过目录项取得的却仍是目录表的起始地址,但 CPU 可不知道这个是目录表的地址,它仍将其看作页表地址,并用中间 10 位继续索引。用代码描述可能更清晰:1
2
3
4
5
6
uint32_t* pte = (uint32_t*)(0xffc00000 + ((vaddr & 0xffc00000) >> 10) + PTE_IDX(vaddr) * 4);
//此时pte即为虚拟地址vaddr对应的PTE的地址
uint32_t* pde = (uint32_t*)((0xfffff000) + PDE_IDX(vaddr) * 4);
//此时pde即为虚拟地址vaddr对应的PDE的地址 -
第 148 行,注意这里只填充了一张页表的四分之一,一张页表可映射 4MB 内存,但我们的内核当前只有不到 1MB,所以只映射了 1MB 的空间。
-
第 157~168 行,物理内核不是只映射在高 1GB 虚拟空间的最低 1MB 处吗?为什么还要安装高 1GB 虚拟地址对应的其他目录项?这是为了实现内核完全共享 。所有用户进程的高 1GB 虚拟空间都会被映射到物理内核处,所以在为用户进程创建页表时,我们必须把内核页目录中第 768~1022 目录项复制到用户进程页目录的相同位置处(第 1023 目录项指向用户目录表的起始位置) 。如果不这样的话,进程陷入内核时,内核可能因为申请大量内存而新增页表,此时就必须手动将新增的内核页表同步到其他进程的页目录中,否则就只能部分共享。手动同步是很麻烦的,最简单的方式就是提前把高 1GB 的目录项定下来,未来创建用户进程页目录时直接复制过去。这是实现内核共享的关键!
读者可能还是对这点存有疑惑,不要慌,学到内存管理基础篇后,你将恍然大悟。
以上是对 setup_page
代码的解析,下面我们聚焦 91~199 行代码。
- 第 91 行,
sgdt
即store gdt
,作用是将 GDTR 中的基地址和边界重新倒(dump)在指定地址处。因为无法直接在 GDTR 中修改,所以要先倒出来,在内存中修改,然后再使用lgdt
重新加载进去。此处sgdt
似乎有点鸡肋,因为gdt_ptr
还在内核中,没有被覆盖。 - 第 95 行,将显存段的基地址放在了 3GB 处。打印功能涉及硬件(显存),所以是在内核中实现的,用户要打印须陷入内核,然后再调用打印功能,肯定不能让用户直接控制显存 。因此显存段的段基址要改为 3GB 以上。
- 第 98 行,将 GDT 也移入内核空间,将其基地址加上 3GB 。这不是必须的,如果分页后不重复加载 GDT,也可以不修改 GDT 的基址 。
- 第 100 行,将栈指针也指向内核空间,这点原因暂不清楚,后续补充。
最终效果如下:
另外,也可以通过 C 语言来设置页表,读者可自行尝试。