从零开始写 OS 内核 - 虚拟内存初探

navi
English

系列目录

kernel 虚拟内存概览

接上一篇 GDT 与保护模式,这一篇将是 loader 的重点。首先我们需要建立 kernel 空间的虚拟内存。如果你对虚拟内存的原理还不熟悉,请务必先自学,这里可以提供一个文档供参考。

到目前为止我们始终在物理内存上操作,确切地说是在 1MB 的低地址空间内操作,这一切都很简单直接。但是接下来 loader 即将为加载 kernel 做准备,我们需要在更广阔的 4GB 虚拟内存空间上规划数据和代码。

仿照 Linux 系统,我们将使用 3GB 以上的高地址空间作为内核空间来开展后续所有工作。例如最基本的,目前的物理低地址 1MB 会被映射到 virtual 地址 0 ~ 1MB 以及 3GB 以上空间 0xC0000000 ~ (0xC0000000 + 1MB) 处:

进入 kernel 以后,对低 1MB 空间的访问将会使用 0xC0000000 ~ (0xC0000000 + 1MB) 虚拟地址,这里主要包括当前使用的 stack,以及显示器对应的内存映射:

所以 video 内存基地址将从 virtual 地址 0xC00B8000 开始,不过目前不必深究,后续将会在显示与打印一篇中详解。


除了最基本的低 1MB 内存空间,loader 还需要进一步在 0xC0000000 以上的 virtual 空间中开疆拓土,这主要包括两部分:

  • kernel 所使用的页目录(page directory)和页表(page table);
  • kernel 二进制镜像的读取,以及代码、数据的加载;

下面给出整个 loader 阶段将要搭建的 virtual-to-physical 内存映射关系图:

这张图是本篇最重要的全局图,其中第二行是第一行经过“扭曲”比例的图示,我们将 3GB 以下的用户空间缩小显示,当前重点只关注 3GB 以上的内核空间(粗框部分)。由于是 virtual 地址空间,我们的空间划分可以比较随意和“奢侈”,我们以 4MB 为单位,从 0xC0000000 开始在 virtual 空间切割划分出以下几个区域:

  • 第一个 4MB 保留,其中低 1MB 空间映射到了 physical 地址的低 1MB,这是上面已经解释过的;
  • 第二个 4MB(橙色)用来映射 kernel 的所有 page tables
  • 第三个 4MB(绿色),即从 0xC0800000开始,作为加载、存放 kernel 代码和数据的空间,也就是说 kernel 从该处开始编址;

这里要说一句,实现一个 OS 并没有固定的方式,以上只是我个人的实现方式。实际上对于内存的规划是很灵活的,就像这个项目的名字 scroll 一样,内存就是一幅画卷,CPU 则是画笔,在遵循一定规则的前提下,可以做自由发挥。

下面我们首先开始橙色部分,即内核 page directorypage tables 的建立。

建立 kernel 虚拟内存

在开始这一段之前,我们还是回顾一下页目录(page directory)和页表(page table)的相关原理。

有一些关键数字需要记住:

  • 页(page)的大小为 4096;
  • 页目录项 pde (page directory entry) 和页表项 pte (page table entry),本质上是一样的结构,大小为 4 bytes
  • page direcotry 一共有 1024 项,指向总共 1024 张 page table,一共 4MB
  • 每个 page table 都有 1024 项,指向 1024 张 pages,管理着 1024 * 4KB = 4MB 的 virtual 空间;
  • 所以每个 pde 管理着 4MB 的 virtual 空间;

好了,下面我们开始建立 kernel 空间的页表。按照惯例给出代码链接:这一部分相关的代码从函数 setup_page 开始,供你参考。

从这里开始以下,按照术语惯例,virtual 页我将用 page 表述,而 physical 页将用 frame 来表述。

建立 page directory

首先我们需要拿出一个 frame,用来作为 page directory。回到 physical 内存分布的那张图,目前 1MB 以下的部分已被占用,我们可以使用的部分就从 1MB 即 0x100000 开始。

我选择的是 0x100000 + 4KB,即 0x100000 后的第 2 个 frame 作为 page directory,当然这完全是个人选择;0x100000 后的第 1 个 frame 我选择将它作为第一个 page table

再次强调,这是我的个人选择;frame 的选择是非常自由的,只要是还没被占用的都可以使用,当然了你要记住自己用过了哪些 frames,合理紧凑并且尽量“美观”地规划使用。

映射 1MB 低内存空间

值得注意的是,第 0 和第 768 个 pde 都指向了同一个 page table,这个 page table 我们将用它映射 0 ~ 1MB 低内存,即我们目前所处的 1MB 内存空间。当然这个 page table 可以管理 4MB 的空间,我们只映射了其中的 1MB,剩余 3MB 的 virtual 空间就闲置了,不过这没有关系,闲置就闲置,反正这是 virtual 空间。

下图展示了低 1MB 内存在页表中被映射的方式:

pde[0] 管理的是 virtual 空间最低的 4MB,其中的起始 1MB,被映射到了 physical 的低 1MB 上,这是一一对应的映射,virtual 地址完全等于 physical 地址,这样在打开 paging 之后,我们对 1MB 低内存的访问变为使用 virtual 地址,和之前的 physical 地址访问一样,不会感知到任何变化。

pde[768] 管理的是 0xC0000000 即 3GB 开始的第一个 4MB 空间,回到本篇开始的第一张图,其起始的 1MB 也被映射到低 1MB 内存上。在打开 paging 并进入 kernel 后,我们将使用 0xC0000000 ~0xC0000000 + 1MB 的空间访问低 1MB 内存:

映射 page directory 以及 page tables 本身

这里是本节的重点和难点。我们知道 page directorypage tables 所指向的都是 physical 页,而一旦打开了 paging 模式,我们以后所有对内存的访问将全部通过 virtual 地址,无法再直接操作 physical 地址。那么问题来了,我们如何访问并修改 page directorypage tables 本身呢?

一种方法当然是在需要时关闭 paging,直接访问 physical 地址,之前推荐的教程 JamesM's kernel development tutorials 在很多地方都是这么做的,不过这并不是一种好的做法,原因有以下几点:

  • 进入复杂的 kernel 以后,代码的执行会大量涉及到 stack 和 heap,以及其他全局变量等内存访问,这些全部都是 kernel 空间的 virtual 地址,如果此时突然关闭 paging,对它们的访问将无法进行。你必须非常小心地安排你的代码对内存的访问,否则将会出现不可预知的后果,但是这其实非常难做到;
  • 一旦开启多线程,如果在关闭 paging 的情况下发生了中断,CPU 将进行一些自动的 stack 操作以及中断处理,全部都是对 virtual 地址的操作,显然其结果也是灾难性的;

一个更合理的做法是,我们将 page directorypage tables 本身也映射到 virtual 空间,这样就可以像访问其他正常内存一样访问它们。从本质上说 page directorypage tables 无非也是一些 page,完全可以和其它内存访问一视同仁。问题就是,应该如何建立这种映射?来看下图:

我们将 pde[769] 指向了 page directory 这个 frame 本身。这样 page direcotry 实际上同时也充当了一个 page table,它所管理的正好是 1024 张 page tables 本身,一共 4MB。这 1024 张 page tables,其中有一张就是 page direcotry 它自己。

是不是有点绕?换言之,由于 pde[769] 指向了 page directory 它自己,因此 0xC0400000 ~ 0xC0800000 这 4MB 的 virtual 空间,现在被映射到了 1024 张 page tables 上,而且更好的是,它们的 virtual 地址是完全连续地,紧密地排布在这 4MB 空间里。

由此,上面的问题已经解决,page tables 对应的 virtual 地址空间为:

0xC0400000 ~ 0xC0800000

这是 4GB 空间中第 769 个 4MB 空间 (总共 1024 个 4MB 空间,组成 4GB)。

并且我们同时还得到了 page directory 它自己的 virtual 地址为:

0xC0701000

0xC0400000 ~ 0xC0800000 这 4MB 空间中的第 769 个 page,是不是很巧妙:)


这里的核心思想是,page directory 其实本质上是一个特殊的 page table,它和其它 page table 一样,都管理着 4MB 的空间。

如果感觉还是有点绕的话,你不妨反过来验证一下,从上面给出的 virtual 地址开始,推导实际指向的 physical 地址是哪里,我想很快就能理清这里面的逻辑。

如果你进一步思考的话,就会发现这并不是唯一的实现方式。你完全可以不选择 pde[769],而使用其它 virtual 空间来映射 page tables,例如用 pde[770] 也可以,这样所有 page tables 对应的 virtual 空间就变成了 0xC0800000 ~ 0xC0C00000。用 pde[769] 只是我个人的选择,因为它是 0xC0000000 后的第二个 4MB 空间,这样的安排,virtual 空间的使用能比较紧凑整齐一点。

映射 kernel 空间的其它区域

到目前为止,pde 768 和 769 已经被使用,即 0xC0000000 ~ 0xC04000000xC0400000 ~ 0xC0800000 这两块 4MB 空间已被征用。剩下的 pde[770] ~ pde[1023] 对应的 254 个 page tables,我们依次为它们安排上 frames。这样我们最终征用了 256 个 pages & frames,总共 1MB 的内存(virtual & physical),来建立 kernel 空间(3GB ~ 4GB)的 page tables,管理这 1GB 的空间。

我们将本章开始的那个 virtual-to-physical 内存映射关系图中的橙色部分抽出放大,展示 kernel 的 256 张 page tables 的内存分布:

注意到我们只分配了 kernel 空间即 3GB 以上的 page tables,共 256 张,占地 1MB,它们映射的也是 0xC0400000 ~ 0xC0800000 空间的后 1/4 部分即 0xC0700000 ~ 0xC0800000;而 3GB 以下的用户空间此时并没有分配 page tables,因为目前我们并没有使用到。

这 256 张 kernel 页表(其中有一张是 page directory 本身),是我们编写 kernel 期间最核心的 page tables,并且在 page directory 里建立了 pde[768] ~ pde[1023] 这全部的 256 个表项,指向了这些 page tables。

其实除了前两个 page table,后面 254 个目前都是空的,没有被用到,我们只是为它们安排好了 frame 而已。这里用去了足足 1MB 的 physical 内存,这看上有点奢侈了,毕竟这个项目配置里 physical 内存总共只有 32 MB(见 bochsrc.txt,当然现在的计算机内存远不止 32 MB,这已经不是个问题)。这样做有一个非常重要的原因,那就是这 256 张 kernel page tables 后面将被所有的进程(process)共享,也就是说对于用户 process 而言,3GB 以下的空间是隔离的,而 3GB 以上的 kernel 的空间是共享的,这也是理所当然的,否则就有多个 kernel 在内存中独立运行了。

每次 fork 出一个新的 process,它的 page directory 的后 1/4 即 768 ~ 1023 项将会直接复制 kernel 的 page directory 的 768 ~ 1023 项,共同指向这 256 张 kernel page tables。所以我们要求这 256 张 page tables 对应的 frames 从一开始就固定下来,后面也不再变化,这样才能实现所有 process 共享的效果。

打开 paging

page tables 都准备就绪以后,就可以打开 paging 了:

enable_page:
  sgdt [gdt_ptr]

  ; move the video segment to > 0xC0000000
  mov ebx, [gdt_ptr + 2]
  or dword [ebx + 0x18 + 4], 0xC0000000

  ; move gdt to > 0xC0000000
  add dword [gdt_ptr + 2], 0xC0000000

  ; move stack to > 0xC0000000
  mov eax, [esp]
  add esp, 0xc0000000
  mov [esp], eax

  ; set page directory address to cr3 register 
  mov eax, PAGE_DIR_PHYSICAL_ADDR
  mov cr3, eax
  
  ; enable paging on cr0 register
  mov eax, cr0
  or eax, 0x80000000
  mov cr0, eax

这里最重要的就是设置 CR3 寄存器,使之指向 page directory 的 frame (注意是 physical 地址),然后打开 CR0 寄存器上的 paging 比特位开关。

总结

至此,loader 阶段关于 kernel 虚拟内存初始化的部分就结束了。这一段的代码并不长,核心仅仅是 setup_page 这一个函数,但是其背后的原理却是非常深刻复杂。在 loader 阶段初步建立起 virtual memory 的框架,这对后面进入 kernel 之后的内存管理打下了良好的基础。

在当前阶段我们所有的 virtual-to-physical 的内存分配和映射都是提前规划,预先分配再使用的,每一块 physical frame 都是手动安排。这其实并没有完全发挥出 virtual memory 的作用。在后面进入 kernel 之后,我们将进一步完善 virtual memory 相关的工作,这将包括缺页异常 (page fault)的处理,进程 page directory 的复制等。

virtual memory 的处理是贯穿 kernel 实现和运行的底层核心工作,必须保证绝对的正确和稳定。一旦出错,系统会立刻出现各种难以预知的奇怪错误甚至崩溃,并且 debug 非常困难。

下一篇我们将会加载真正的 kernel 到内存并且转到 kernel 开始执行代码,这将是进入 kernel 前的最后一道关卡。

阅读 758

naive programmer

511 声望
90 粉丝
0 条评论
你知道吗?

naive programmer

511 声望
90 粉丝
宣传栏