【一个操作系统的设计与实现】第8章 内存管理系统

设计君
• 阅读 122

计算机上的任何程序,包括操作系统自己,都需要使用内存。因此,操作系统需要实现内存管理系统,以进行内存的分配和回收。

在我们的操作系统中,内存管理系统由两部分组成:页分配器与页回收器。本章将实现这两个部分。

8.1 从虚拟地址到物理地址

回顾CPU对内存地址的转换过程:

  1. 使用段寄存器中的段选择子,在GDT中找到一个段描述符,从中取得段基址
  2. 将段基址与偏移地址相加,得到虚拟地址
  3. 取CR3中的页目录表地址,并取虚拟地址的最高10位作为页目录表索引值,从页目录表中取得页表地址
  4. 取虚拟地址的中间10位作为页表索引值,从页表中取得页地址
  5. 取虚拟地址的最低12位,将其与页地址相加,得到物理地址

我们的操作系统使用的是平坦模型,所有的段基址都是0,所以上述第1、2步对地址没有任何影响。

当分配新的虚拟页地址时,上述第3、4步都有可能找不到页表(PDE的P位为0)或页(PTE的P位为0)。当PDE的P位为0时,需要分配一页作为页表,并将其物理地址和属性填入PDE;当PTE的P位为0时(对于新分配的虚拟页地址,这个条件一定成立),需要分配一页,并将其物理地址和属性填入PTE。这样一来,从虚拟地址到物理地址的转换就畅通了。

另一方面,无论是虚拟页地址还是物理页地址,怎么知道哪些地址是可用的呢?这就需要构造两个布尔数组,分别用于记录虚拟页地址与物理页地址的使用情况。布尔数组在实现上可以使用位图优化。

分页模式的特点是:虚拟地址连续而物理地址可以不连续。于是,在分配虚拟地址时,需要找N页连续可用的地址;在分配物理地址时,可以分N次进行,每次找一页可用的地址,然后将虚拟地址与物理地址建立联系。

综上,想要分配N页内存,需要依次进行以下步骤:

  1. 在虚拟地址位图中找到N个连续可用位,将其设定为已使用。将找到的位图索引转换为虚拟页地址
  2. 循环N次,每次在物理地址位图中找到1个可用位,将其设定为已使用。将找到的位图索引转换为物理页地址,然后,将当前的虚拟页地址与物理页地址建立联系

同理,想要回收N页虚拟内存,需要依次进行以下步骤:

  1. 将虚拟页地址转换为位图索引,将虚拟地址位图中的对应位设定为可使用
  2. 循环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指向页目录表自己的物理地址。

这样做有什么用呢?再次回顾从虚拟地址到物理地址的转换过程,其需要且必须经历三个步骤:

  1. 找到一个PDE,取得其中的页表地址
  2. 找到一个PTE,取得其中的页地址
  3. 将页地址与偏移地址相加,得到物理地址

现在需要的是PDE和PTE的地址,即第1步和第2步的结果。但上述三个步骤是不能暂停,也不能去除的。不过,由于最后一个PDE指向的是页目录表自身,所以,如果使用这个PDE,就能在页目录表中空兜一次,从而消耗掉一个步骤。

具体来说,如果虚拟地址的最高10位全为1,那么上述过程就会变成这样:

  1. 空兜
  2. 找到一个PDE,取得其中的页表地址
  3. 将页表地址与偏移地址相加,得到PTE地址

进一步的,如果虚拟地址的最高20位全为1,那么上述过程就会变成这样:

  1. 空兜
  2. 空兜
  3. 将页目录表地址与偏移地址相加,得到PDE地址

综上,从虚拟地址获取其对应的PDE和PTE的方法如下:

  1. 构造这样的地址:0xfffff000 | (虚拟地址 >> 22 << 2),就能访问或修改PDE
  2. 构造这样的地址: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的方法有两种:

  1. 加载CR3。这样做将直接清空整个TLB
  2. 使用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函数,请读者自行分析输出结果。

点赞
收藏
评论区
推荐文章
【Java面试题】阿里+头条+腾讯大厂Java笔试真题
垃圾回收算法垃圾回收算法的实现设计到大量的程序细节,并且每一个平台的虚拟机操作内存的方式都有不同,所以不需要去了解算法的具体实现。复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑
【面试必会】最新阿里+头条+腾讯大厂Java笔试真题
一、内存与线程1、内存结构内存是计算机的重要部件之一,它是外存与CPU进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。JVM的内存结构规定Java程序在执行时内存的申请、划分、使用、回收的管理策略,通说来说JVM的内存管理指运行时数据区这一大块的管理。2、线程运行JVM中一个应用是可以有多个线程并行执行,线程
Bill78 Bill78
4年前
python内存管理机制
1\.内存管理架构第0层:是操作系统提供的内存管理接口,比如c运行时提供的malloc和free接口。这一层是由操作系统实现并管理的,python不能干涉这一层的行为。第1层:基于第0层操作系统提供的内存管理接口包装而成,其目的仅仅是为python提供一层统一的rawmemory的管理接口。提供统一的接口是虽然不同的操
九路 九路
4年前
Android 内存管理机制
前言:Android系统是基于Linux内核开发的操作系统,而Linux系统有其独到的内存管理机制,会在进程活动停止后结束该进程。Android在此基础上优化了内存管理,会把进程都保存在内存中,直到系统需要更多内存为止,释放部分进程。这些被保存在内存中的进程,并不会影响系统的运行速度,相反,在重新打开这些进程时,会提升进程启动速度Android内存管
Stella981 Stella981
3年前
Linux页框分配器之内存碎片化整理
页框分配器在慢速分配中包括内存碎片化整理和内存回收,代码如下:static inline struct page __alloc_pages_slowpath(gfp_t gfp_mask, unsigned int order,      struct alloc_context ac){  page  __alloc_
Stella981 Stella981
3年前
Linux 内核 VS 内存碎片 (上)
(外部)内存碎片是一个历史悠久的Linux内核编程问题,随着系统的运行,页面被分配给各种任务,随着时间的推移内存会逐步碎片化,最终正常运行时间较长的繁忙系统可能只有很少的物理页面是连续的。由于Linux内核支持虚拟内存管理,物理内存碎片通常不是问题,因为在页表的帮助下,物理上分散的内存在虚拟地址空间仍然是连续的(除非使用大页),但对于需要从内核线性
Easter79 Easter79
3年前
Tomcat中JVM内存溢出及合理配置
Tomcat本身不能直接在计算机上运行,需要依赖于硬件基础之上的操作系统和一个Java虚拟机。Tomcat的内存溢出本质就是JVM内存溢出,所以在本文开始时,应该先对JavaJVM有关内存方面的知识进行详细介绍。一、JavaJVM内存介绍JVM管理两种类型的内存,堆和非堆。按照官方的说法:“Java虚拟机具有一个堆,堆是运行时数据区域,
Stella981 Stella981
3年前
Redis内存碎片率
一、内存碎片率mem\_fragmentation\_ratioused\_memory\_rss/used\_memoryused\_memory:Redis使用其分配器分配的内存大小used\_memory\_rss:操作系统分配给Redis实例的内存大小,表示该进程所占物理内存的大小两者包括了实际缓存占用的内存和
Wesley13 Wesley13
3年前
Unity优化之
当我们来创建一个对象、字符串或数组时,我们需要从称为堆的中央池中为其分配内存来存储它。当它不再被使用时,我们又需要来释放这块内存便于重复使用。在以前这个过程通常需要我们通过适当的函数调用显式地分配和释放块内存来实现。但现在,运行时系统如Unity的mono引擎将自动地为我们管理内存。自动内存管理比显式分配/释放需要更少的编码工作,大大减少了内存泄漏的可能性(
Stella981 Stella981
3年前
Javascript内存泄露
1.什么是内存泄露?内存泄露是指分配给应用的内存不能被重新分配,即使在内存已经不被使用的时候。正常情况下,垃圾回收器在DOM元素和event处理器不被引用或访问的时候回收它们。但是,IE的早些版本(IE7和之前)中内存泄露是很容易出现的,因为内存管理器不能正确理解Javascript生命周期而且在周期被打破(可以通过赋值为null实现)前不会回收