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

系列目录

kernel 虚拟内存概览

接上一篇 GDT 与保护模式,这一篇将是 loader 的重点。首先咱们须要创建 kernel 空间的虚拟内存。若是你对虚拟内存的原理还不熟悉,请务必先自学,这里能够提供一个文档供参考。html

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

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

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

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


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

  • 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 则是画笔,在遵循必定规则的前提下,能够作自由发挥。ide

下面咱们首先开始橙色部分,即内核 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 前的最后一道关卡。