你好,我是 LMOS。
今天,我们继续研究操作系统如何实现虚拟内存。在上节课,我们已经建立了虚拟内存的初始流程,这节课我们来实现虚拟内存的核心功能:写出分配、释放虚拟地址空间的代码,最后实现虚拟地址空间到物理地址空间的映射。
虚拟地址的空间的分配与释放
通过上节课的学习,我们知道整个虚拟地址空间就是由一个个虚拟地址区间组成的。那么不难猜到,分配一个虚拟地址空间就是在整个虚拟地址空间分割出一个区域,而释放一块虚拟地址空间,就是把这个区域合并到整个虚拟地址空间中去。
虚拟地址空间分配接口
我们先来研究地址的分配,依然从虚拟地址空间的分配接口开始实现,一步步带着你完成虚拟 空间的分配。
在我们的想像中,分配虚拟地址空间应该有大小、有类型、有相关标志,还有从哪里开始分配等信息。根据这些信息,我们在 krlvadrsmem.c 文件中设计好分配虚拟地址空间的接口,如下所示。
上述代码中依然是接口函数进行参数检查,然后调用核心函数完成实际的工作。在核心函数中,会调用 vma_find_kmvarsdsc 函数去查找 virmemadrs_t 结构中的所有 kmvarsdsc_t 结构,找出合适的虚拟地址区间。
需要注意的是,我们允许应用程序指定分配虚拟地址空间的开始地址,也可以由系统决定,但是应用程序指定的话,分配更容易失败,因为很可能指定的开始地址已经被占用了。
接口的实现并不是很难,接下来我们继续完成核心实现。
分配时查找虚拟地址区间
在前面的核心函数中我写上了 vma_find_kmvarsdsc 函数,但是我们并没有实现它,现在我们就来完成这项工作,主要是根据分配的开始地址和大小,在 virmemadrs_t 结构中查找相应的 kmvarsdsc_t 结构。
它是如何查找的呢?举个例子吧,比如 virmemadrs_t 结构中有两个 kmvarsdsc_t 结构,A_kmvarsdsc_t 结构表示 0x1000~0x4000 的虚拟地址空间,B_kmvarsdsc_t 结构表示 0x7000~0x9000 的虚拟地址空间。
这时,我们分配 2KB 的虚拟地址空间,vma_find_kmvarsdsc 函数查找发现 A_kmvarsdsc_t 结构和 B_kmvarsdsc_t 结构之间正好有 0x4000~0x7000 的空间,刚好放得下 0x2000 大小的空间,于是这个函数就会返回 A_kmvarsdsc_t 结构,否则就会继续向后查找。
明白了原理,我们就来写代码。
结合前面的描述和代码注释,我们发现 vma_find_kmvarsdsc 函数才是这个分配虚拟地址空间算法的核心实现,真的这么简单?是的,对分配虚拟地址空间,真的结束了。
不过,这个分配的虚拟地址空间可以使用吗?这个问题,等我们解决了虚拟地址空间的释放,再来处理。
虚拟地址空间释放接口
有分配就要有释放,否则再大的虚拟地址空间也会用完,下面我们就来研究如何释放一个虚拟地址空间。我们依然从设计接口开始,这次我们只需要释放的虚拟空间的开始地址和大小就行了。我们来写代码实现吧,如下所示。
结合上面的代码和注释,我相信你能够看懂。需要注意的是,处理释放虚拟地址空间的四种情况。
因为分配虚拟地址空间时,我们为了节约 kmvarsdsc_t 结构占用的内存空间,规定只要分配的虚拟地址空间上一个虚拟地址空间是连续且类型相同的,我们就借用上一个 kmvarsdsc_t 结构,而不是重新分配一个 kmvarsdsc_t 结构表示新分配的虚拟地址空间。
你可以想像一下,一个应用每次分配一个页面的虚拟地址空间,不停地分配,而每个新分配的虚拟地址空间都有一个 kmvarsdsc_t 结构对应,这样物理内存将很快被耗尽。
释放时查找虚拟地址区间
上面释放虚拟地址空间的核心处理函数 vma_del_vadrs_core 函数中,调用了 vma_del_find_kmvarsdsc 函数,用于查找要释放虚拟地址空间的 kmvarsdsc_t 结构,可是为什么不用分配虚拟地址空间时那个查找函数(vma_find_kmvarsdsc)呢?
这是因为释放时查找的要求不一样。释放时仅仅需要保证,释放的虚拟地址空间的开始地址和结束地址,他们落在某一个 kmvarsdsc_t 结构表示的虚拟地址区间就行,所以我们还是另写一个函数,代码如下。
释放时,查找虚拟地址区间的函数非常简单,仅仅是检查释放的虚拟地址空间是否落在查找 kmvarsdsc_t 结构表示的虚拟地址区间中,而可能的四种变换形式,交给核心释放函数处理。到这里,我们释放虚拟地址空间的功能就实现了。
测试环节:虚拟空间能正常访问么?
我们已经实现了虚拟地址空间的分配和释放,但是我们从未访问过分配的虚拟地址空间,也不知道能不能访问,会有什么我们没有预想到的结果。保险起见,我们这就进入测试环节,试一试访问一下分配的虚拟地址空间。
准备工作
想要访问一个虚拟地址空间,当然需要先分配一个虚拟地址空间,所以我们要做点准备工作,写点测试代码,分配一个虚拟地址空间并访问它,代码如下。
你大概已经猜到,这个在 init_kvirmemadrs 函数的最后调用的 test_vadr 函数,一旦执行,一定会发生异常。为了显示这个异常,我们要在异常分发器函数中写点代码。代码如下所示。
上述代码非常简单,下面我们来测试一下,看看最终结果。
异常情况与原因分析
所有的代码已经准备好了,我们进入 Cosmos 目录下执行 make vboxtest 指令,等 Cosmos 跑起来的时候,你会看到如下所示的情况。

访问虚拟地址异常截图
上图中,显示我们分配了 0x1000 大小的虚拟地址空间,其虚拟地址是 0x5000,接着对这个地址进行访问,最后产生了缺页异常,缺页的地址正是我们分配的虚拟空间的开始地址。
为什么会发生这个缺页异常呢?因为我们访问了一个虚拟地址,这个虚拟地址由 CPU 发送给 MMU,而 MMU 无法把它转换成对应的物理地址,CPU 的那条访存指令无法执行了,因此就产生一个缺页异常。于是,CPU 跳转到缺页异常处理的入口地址(kernel.asm 文件中的 exc_page_fault 标号处)开始执行代码,处理这个缺页异常。
因为我们仅仅是分配了一个虚拟地址空间,就对它进行访问,所以才会缺页。既然我们并没有为这个虚拟地址空间分配任何物理内存页面,建立对应的 MMU 页表,那我们可不可以分配虚拟地址空间时,就分配物理内存页面并建立好对应的 MMU 页表呢?
这当然可以解决问题,但是现实中往往是等到发生缺页异常了,才分配物理内存页面,建立对应的 MMU 页表。这种延迟内存分配技术在系统工程中非常有用,因为它能最大限度的节约物理内存。分配的虚拟地址空间,只有实际访问到了才分配对应的物理内存页面。
开始处理缺页异常
准确地说,缺页异常是从 kernel.asm 文件中的 exc_page_fault 标号处开始,但它只是保存了 CPU 的上下文,然后调用了内核的通用异常分发器函数,最后由异常分发器函数调用不同的异常处理函数,如果是缺页异常,就要调用缺页异常处理的接口函数。
这个函数之前还没有写呢,下面我们一起来实现它,代码如下所示。
上面的接口函数非常简单,不过我们要在 cosmos/hal/x86/halintupt.c 文件的异常分发器函数中来调用它,代码如下所示。
接口函数和调用流程已经写好了,下面就要真正开始处理缺页了。
处理缺页异常的核心
在前面缺页异常处理接口时,调用了 vma_map_fairvadrs_core 函数,来进行缺页异常的核心处理、那缺页异常处理究竟有哪些操作呢?
这里给你留个悬念,我先来写个函数,你可以结合自己的观察,想想它做了什么,代码如下所示。
通过对上述代码的观察,你就能发现,以上代码中做了三件事。
首先,查找缺页地址对应的 kmvarsdsc_t 结构,没找到说明没有分配该虚拟地址空间,那属于非法访问不予处理;然后,查找 kmvarsdsc_t 结构下面的对应 kvmemcbox_t 结构,它是用来挂载物理内存页面的;最后,分配物理内存页面并建立 MMU 页表映射关系。
下面我们分别来实现这三个步骤。
缺页地址是否合法
要想判断一个缺页地址是否合法,我们就要确定它是不是已经分配的虚拟地址,也就是看这个虚拟地址是不是会落在某个 kmvarsdsc_t 结构表示的虚拟地址区间。
因此,我们要去查找相应的 kmvarsdsc_t 结构,如果没有找到则虚拟地址没有分配,即这个缺页地址不合法。这个查找 kmvarsdsc_t 结构的函数可以这样写。
这个函数非常简单,核心逻辑就是用虚拟地址和 kmvarsdsc_t 结构中的数据做比较,大于等于 kmvarsdsc_t 结构的开始地址并且小于 kmvarsdsc_t 结构的结束地址,就行了。
建立 kvmemcbox_t 结构
kvmemcbox_t 结构可以用来挂载物理内存页面 msadsc_t 结构,而这个 msadsc_t 结构是由虚拟地址区间 kmvarsdsc_t 结构代表的虚拟空间所映射的物理内存页面。一个 kmvarsdsc_t 结构,必须要有一个 kvmemcbox_t 结构,才能分配物理内存。除了这个功能,kvmemcbox_t 结构还可以在内存共享的时候使用。
现在我们一起来写个函数,实现建立 kvmemcbox_t 结构,代码如下所示。
上述代码非常简单,knl_get_kvmemcbox 函数就是调用 kmsob_new 函数分配一个 kvmemcbox_t 结构大小的内存空间对象,然后其中实例化 kvmemcbox_t 结构的变量。
映射物理内存页面
好,现在我们正式给虚拟地址分配对应的物理内存页面,建立对应的 MMU 页表,使虚拟地址到物理地址可以转换成功,数据终于能写入到物理内存之中了。
这个步骤完成,就意味着缺页处理完成了,我们来写代码吧。
上述代码中,调用 vma_map_msa_fault 函数做实际的工作。首先,它会调用 vma_new_usermsa 函数,在 vma_new_usermsa 函数内部调用了我们前面学过的页面内存管理接口,分配一个物理内存页面并把对应的 msadsc_t 结构挂载到 kvmemcbox_t 结构上。
接着获取 msadsc_t 结构对应内存页面的物理地址,最后是调用 hal_mmu_transform 函数完成虚拟地址到物理地址的映射工作,它主要是建立 MMU 页表,在 cosmos/hal/x86/halmmu.c 文件中,我已经帮你写好了代码,我相信你结合前面 MMU 相关的课程,你一定能看懂。
vma_map_phyadrs 函数一旦成功返回,就会随着原有的代码路径层层返回。至此,处理缺页异常就结束了。
重点回顾
今天这节课我们学习了如何实现虚拟内存的分配与释放,现在我把重点为你梳理一下。
首先,我们实现了虚拟地址空间的分配与释放。这是虚拟内存管理的核心功能,通过查找地址区间结构来确定哪些虚拟地址空间已经分配或者空闲。
然后我们解决了缺页异常处理问题。我们分配一段虚拟地址空间,并没有分配对应的物理内存页面,而是等到真正访问虚拟地址空间时,才触发了缺页异常。这时,我们再来处理缺页异常中分配物理内存页面的工作,建立对应的 MMU 页表映射关系。这种延迟分配技术可以有效节约物理内存。
至此,从物理内存页面管理到内存对象管理再到虚拟内存管理,我们一层一层地建好了 Cosmos 的内存管理组件。内存可以说是专栏的重中之重,以后 Cosmos 内核的其它组件,也都要依赖于内存管理组件。
思考题
请问,x86 CPU 的缺页异常,是第几号异常?缺页的地址保存在哪个寄存器中?
欢迎你在留言区跟我交流互动,也感谢你坚持不懈跟我学习,如果你身边有对内存管理感兴趣的朋友,记得把今天这节课分享给他。
好,我是 LMOS,我们下节课见。