计算机上的任何程序,包括操作系统自己,都需要使用内存。因此,操作系统需要实现内存管理系统,以进行内存的分配和回收。
在我们的操作系统中,内存管理系统由两部分组成:页分配器与页回收器。本章将实现这两个部分。
8.1 从虚拟地址到物理地址
回顾CPU对内存地址的转换过程:
- 使用段寄存器中的段选择子,在GDT中找到一个段描述符,从中取得段基址
- 将段基址与偏移地址相加,得到虚拟地址
- 取CR3中的页目录表地址,并取虚拟地址的最高10位作为页目录表索引值,从页目录表中取得页表地址
- 取虚拟地址的中间10位作为页表索引值,从页表中取得页地址
- 取虚拟地址的最低12位,将其与页地址相加,得到物理地址
我们的操作系统使用的是平坦模型,所有的段基址都是0,所以上述第1、2步对地址没有任何影响。
当分配新的虚拟页地址时,上述第3、4步都有可能找不到页表(PDE的P位为0)或页(PTE的P位为0)。当PDE的P位为0时,需要分配一页作为页表,并将其物理地址和属性填入PDE;当PTE的P位为0时(对于新分配的虚拟页地址,这个条件一定成立),需要分配一页,并将其物理地址和属性填入PTE。这样一来,从虚拟地址到物理地址的转换就畅通了。
另一方面,无论是虚拟页地址还是物理页地址,怎么知道哪些地址是可用的呢?这就需要构造两个布尔数组,分别用于记录虚拟页地址与物理页地址的使用情况。布尔数组在实现上可以使用位图优化。
分页模式的特点是:虚拟地址连续而物理地址可以不连续。于是,在分配虚拟地址时,需要找N页连续可用的地址;在分配物理地址时,可以分N次进行,每次找一页可用的地址,然后将虚拟地址与物理地址建立联系。
综上,想要分配N页内存,需要依次进行以下步骤:
- 在虚拟地址位图中找到N个连续可用位,将其设定为已使用。将找到的位图索引转换为虚拟页地址
- 循环N次,每次在物理地址位图中找到1个可用位,将其设定为已使用。将找到的位图索引转换为物理页地址,然后,将当前的虚拟页地址与物理页地址建立联系
同理,想要回收N页虚拟内存,需要依次进行以下步骤:
- 将虚拟页地址转换为位图索引,将虚拟地址位图中的对应位设定为可使用
- 循环N次,每次从当前的虚拟页地址得到物理页地址,将其转换为位图索引,将物理地址位图中的对应位设定为可使用。然后,将当前的虚拟页地址对应的PTE清零
8.2 PDE和PTE的虚拟地址
想要在虚拟地址和物理地址之间建立联系,就需要设定虚拟地址对应的PDE和PTE。那么,PDE和PTE的地址是什么呢?
这个问题乍一看很简单:页目录表地址在CR3中,而PDE和PTE的索引值在虚拟地址中,只需要将其取出,手动完成查找PDE和PTE的过程,就可以了。这个方案看似很有道理,但问题是:分页模式下,所有的地址都是虚拟地址,而CR3、PDE、PTE中存放的都是物理地址,就算已知页目录表的地址是0x100000
,页表的地址是0x101000
,也不能使用这两个物理地址,必须使用虚拟地址。那怎么办呢?
事实上,只需一行非常精妙的代码就能解决这个问题,它位于本章代码8/Mbr.s
的第32行:
mov dword [0x100ffc], 0x100003
这行代码看上去很普通,它将页目录表的最后一个PDE指向页目录表自己的物理地址。
这样做有什么用呢?再次回顾从虚拟地址到物理地址的转换过程,其需要且必须经历三个步骤:
- 找到一个PDE,取得其中的页表地址
- 找到一个PTE,取得其中的页地址
- 将页地址与偏移地址相加,得到物理地址
现在需要的是PDE和PTE的地址,即第1步和第2步的结果。但上述三个步骤是不能暂停,也不能去除的。不过,由于最后一个PDE指向的是页目录表自身,所以,如果使用这个PDE,就能在页目录表中空兜一次,从而消耗掉一个步骤。
具体来说,如果虚拟地址的最高10位全为1,那么上述过程就会变成这样:
- 空兜
- 找到一个PDE,取得其中的页表地址
- 将页表地址与偏移地址相加,得到PTE地址
进一步的,如果虚拟地址的最高20位全为1,那么上述过程就会变成这样:
- 空兜
- 空兜
- 将页目录表地址与偏移地址相加,得到PDE地址
综上,从虚拟地址获取其对应的PDE和PTE的方法如下:
- 构造这样的地址:
0xfffff000 | (虚拟地址 >> 22 << 2)
,就能访问或修改PDE - 构造这样的地址:
0xffc00000 | (虚拟地址 >> 12 << 2)
,就能访问或修改PTE
8.3 内存管理系统的实现
8.3.1 位图的实现
想要实现内存管理系统,就需要先实现出位图。
请看本章代码8/Bitmap.h
。
第5\~9行,定义了位图结构体。位图结构体由指向位图的指针和位图的长度构成。
第12\~16行,声明了位图的各种函数。
接下来,请看本章代码8/Bitmap.hpp
。
bitmapInit
函数用于初始化位图。this->__size
的单位是位,所以,初始化位图长度时,如果使用的单位是字节,就需要乘以8。函数中使用的memset
函数实现于本章代码8/Memory.hpp
中。
bitmapGet
函数与bitmapSet
函数分别用于读取位和设置位。
bitmapAllocate
函数用于分配一段连续的位。实现中,0表示可用而1表示不可用。所以,当找到一段连续的0后,应将这些0转变为1。需要注意的是:第40行使用的是无限循环,这是因为我们的操作系统不考虑分配失败,而是假定位图所管理的资源是无限的。
bitmapDeallocate
函数用于回收一段连续的位。
8.3.2 页分配器的实现
请看本章代码8/Memory.h
。
这个头文件中声明了内存管理系统的各种函数。
接下来,请看本章代码8/Memory.hpp
。
第7\~8行,定义了两个位图。__pMemoryBitmap
供物理地址使用,__vMemoryBitmap
供虚拟地址使用。
memoryInit
函数用于初始化这两个位图。在我们的操作系统中,由于不考虑物理内存的实际大小,故物理内存位图固定使用一页;虚拟地址空间的理论大小为4G,但出于简化考虑,实现中也只使用一页位图。一页位图可以管理0x8000
个页,即128M内存。位图的地址从0x7e00~0x9ffff
都可用,可以随便选。
memset
函数与C语言标准库的同名函数等价。
__allocateAddr
函数用于分配地址。其先调用bitmapAllocate
函数以分配位,然后将得到的位图索引转换为地址。
__installPage
函数用于在虚拟地址与物理地址之间建立联系。
第36\~37行,使用上文中的公式取得PDE指针和PTE指针。
第39行,判断PDE的P位,如果为0,说明当前虚拟地址没有对应的页表,此时需要分配一页,并将其物理地址与属性填入PDE。
第41行,分配一页,并将其物理地址与属性填入PDE。这里使用的页属性是0x7
,表示存在、可读写、所有特权级均可访问。
第42行,将新分配的页表清零。页表的虚拟地址可由PTE地址去除偏移量得到。
第45行,将页的物理地址与属性填入PTE。这里使用的页属性也是0x7
。
第47行,执行invlpg (虚拟内存地址)
指令。这条指令有什么用呢?原来,CPU内部为虚拟地址到物理地址的转换提供了缓存,以加速这一过程。这个缓存被称为快表(Translation Lookaside Buffer,TLB)。然而,如果页目录表或页表发生了变化,与之相关的TLB缓存就失效了,但CPU作为缓存的使用者,无法感知到此事。因此,CPU要求操作系统在修改页目录表和页表时主动刷新TLB。
刷新TLB的方法有两种:
- 加载CR3。这样做将直接清空整个TLB
- 使用
invlpg (虚拟内存地址)
指令。这样做将刷新这个虚拟地址的TLB缓存(如果有)
__allocatePage
函数用于分配页,它是__allocateAddr
函数与__installPage
函数的封装。对于虚拟地址,需要调用一次__allocateAddr
函数,得到pageCount
页连续的虚拟地址;对于物理地址,需要调用pageCount
次__allocateAddr
函数和__installPage
函数,每次分配一页物理地址,并将其与虚拟地址建立联系。
allocateKernelPage
函数是__allocatePage
函数的封装。其将虚拟地址的分配起点设为0x66600000
,这是一个随便写的地址,只要大于等于0x100000
即可;物理地址的分配起点设为0x200000
,这个地址需要大于等于0x102000
,因为0x100000
的前两页已经被使用了。
8.3.3 页回收器的实现
请看本章代码8/Memory.hpp
。
__deallocateAddr
函数用于将待回收的页地址转换为位图索引,然后调用bitmapDeallocate
函数,将其从位图中删除。
__uninstallPage
函数比__installPage
函数简单的多,其用于将待回收的虚拟地址所在的PTE清零,并刷新TLB。
__deallocatePage
函数用于回收页。它是__deallocateAddr
函数与__uninstallPage
函数的封装,且操作步骤与__allocatePage
函数完全相反。
第90行,将虚拟页地址从位图中删除。
第92行,循环pageCount
次,每次循环回收一页物理地址,并断开虚拟地址与物理地址之间的联系。
第94行,使用上文中的公式取得PTE指针,然后读取PTE,并去除低12位上的属性,就得到了页的物理地址。
第96行,将物理页地址从位图中删除。
第97行,断开虚拟地址与物理地址之间的联系。
deallocateKernelPage
函数是__deallocatePage
函数的封装,其使用的参数与allocateKernelPage
函数一致。
8.3.4 杂项
请看本章代码8/Mbr.s
。
第32行,将页目录表的最后一个PDE指向页目录表自己的物理地址。这样做的意义已经在上文讨论过。
接下来,请看本章代码8/Int.s
。
在上一章中,intTimer
函数包含了一段打印数字的代码,在本章中已经将其删除;与之配套的extern printInt
声明也已删除。
接下来,请看本章代码8/Kernel.s
。
第10行,调用memoryInit
函数,完成内存管理系统的初始化。
8.4 测试
本章代码8/Kernel.c
测试了allocateKernelPage
函数与deallocateKernelPage
函数,请读者自行分析输出结果。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。