系列目录
- 序篇
- 准备工作
- BIOS 启动到实模式
- GDT 与保护模式
- 虚拟内存初探
- 加载并进入 kernel
- 显示与打印
- 全局描述符表 GDT
- 中断处理
- 虚拟内存完善
- 实现堆和 malloc
- 第一个 kernel 线程
- 多线程切换
- 锁与多线程同步
- 进入用户态
- 进程的实现
- 系统调用
- 简单的文件系统
- 加载可执行程序
- 键盘驱动
- 运行 shell
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 directory
和 page 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 directory
和 page tables
所指向的都是 physical 页,而一旦打开了 paging 模式,我们以后所有对内存的访问将全部通过 virtual 地址,无法再直接操作 physical 地址。那么问题来了,我们如何访问并修改 page directory
和 page tables
本身呢?
一种方法当然是在需要时关闭 paging,直接访问 physical 地址,之前推荐的教程 JamesM's kernel development tutorials 在很多地方都是这么做的,不过这并不是一种好的做法,原因有以下几点:
- 进入复杂的 kernel 以后,代码的执行会大量涉及到 stack 和 heap,以及其他全局变量等内存访问,这些全部都是 kernel 空间的 virtual 地址,如果此时突然关闭 paging,对它们的访问将无法进行。你必须非常小心地安排你的代码对内存的访问,否则将会出现不可预知的后果,但是这其实非常难做到;
- 一旦开启多线程,如果在关闭 paging 的情况下发生了中断,CPU 将进行一些自动的 stack 操作以及中断处理,全部都是对 virtual 地址的操作,显然其结果也是灾难性的;
一个更合理的做法是,我们将 page directory
和 page tables
本身也映射到 virtual 空间,这样就可以像访问其他正常内存一样访问它们。从本质上说 page directory
和 page 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 ~ 0xC0400000
和 0xC0400000 ~ 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 前的最后一道关卡。