内存管理-基础-初始化内存池
本节对应分支:
memory
概述
操作系统内存管理可以说是操作系统中最重要的部分 ,它是未来所有工作的基础。内存管理相当复杂,大约有如下内容:
但本节并不会讨论以上全部内容,而是根据我们自制操作系统的需要来进行。我们当前的任务是完成操作系统的内存划分(本节)以及虚拟内存的申请(下节),即虚拟空间到物理内存的映射,其他内容咋们后续按需补充。本节内容如下:
- 通过 BIOS 中断获取内存容量
既然要分配内存,就一定需要知道系统的内存容量有多大,这通过 BIOS 中断来获取。 - 通过位图来管理内存
管理内存时,肯定需要知道哪些内存已经被使用,哪些还没有使用,这些信息通过我们自己维护的位图来获取。 - 规划内存池
管理内存前,当然还需要对内存做出规划,比如,哪些内存给内核使用,哪些内存又给用户使用。 - 向页表填写映射关系
我们早就实现了分页机制,就差向其中填入映射关系啦!笔者期待已久,让我们开始吧。
获取内存容量
获取内存容量的例程已经由操作系统厂商写好并存入了 BIOS 中,因此我们只需要调用 BIOS 中断即可。现在问题是,进入保护模式后,BIOS 中断无法再被调用,这怎么办呢?不得已,我们只能回到 loader.s 中,即进入保护模式之前调用 BIOS 中断 。
为什么进入保护模式后不能再使用 BIOS 中断 ?
- BIOS 中断例程的地址存放在中断向量表(IVT)中,实模式下使用
int
指令调用中断时,会跳转到 IVT 描述符指向的例程地址;而保护模式下使用int
指令调用中断时,则是跳转到 IDT 描述符所指向的例程。因此,IVT 不再有效。- BIOS 中断例程是在实模式,即 16 位模式下运行的代码,这些代码并不能直接运行在 32 位保护模式下。
Linux 采用了三种方式来检测内存容量,如果一种方式失败,就调用下一种,全部失败则挂起。这三种方式都是通过调用 0x15 号 BIOS 中断来进行的,它们的功能号及其特点为:
- EAX = 0xE820 :遍历主机所有内存。
- AX = 0xE801 :最大支持 4GB 。
- AH = 0x88 :最大支持 64 MB 内存。
功能号需要装在 EAX 或 AX 中。
由于咋们的操作系统最大不会超过 100 KB,因此我们只使用第三种方式,即 0x88 功能号。因为该部分只需要调用中断,没有其他需要强调或理解的地方,所以此处笔者就不详细记录这三个功能号的中断参数和用途了,详情还请读者朋友移步《操作系统真相还原》第 177 页。下面只贴出我们要用到的 0x88 功能号:
需要注意两点:
- 0x88 功能号返回的内存不包括低端 1 MB,因此我们算总内存时还需要加上 1MB 。
- 返回后 AX 中的值以 1KB 为单位,所以还需要换算成以 1 字节为单位。
1 | total_mem_bytes dd 0 |
代码逻辑很简单,注释也足够清晰,不再赘述。需要强调的是,标号 total_mem_bytes
用来存放所得结果,此结果待会会在 memory.c 中使用,因此,我们还得手动算出该标号所代表的地址,以方便在 C 文件中通过指针引用该值。有读者可能会疑惑了,为什么还得手动算地址呢?难道不能像我们之前那样,使用 global
关键字导出 total_mem_bytes
,然后在 C 文件中声明 extern total_mem_bytes
来直接引用这个变量吗?是的,不能。原因在于,我们链接时并没有将 loader.o 包含进来,看下面的 makefile 语句:
1 | KERNEL=build/guide.o build/print.o build/main.o build/interrupt.o build/idt.o build/port_io.o \ |
其中并没有包含 loader.s 。我猜到你要说什么了:那就包含 loader.s 呗…昂,kernel.bin 可是咋们的内核呀,现在又将 loader 包含进去,可谓不伦不类啦。所以,我们需要手动算出 total_mem_bytes
的地址值,它的位置如下:
1 | SECTION loader vstart=BASE_ADDR ;定义用户程序头部段 |
1 | ;BASE_ADDR=0X900 |
因此,total_mem_bytes
的地址为 0x90c 。如此,咋们就轻松获取了内存容量的大小,为 32MB,即 0x2000000
位图
位图并不止用于内存管理,它是一种映射,用一个位来表示一个对象的状态 。在之前我们也接触过位图,比如 8259A 中的各个寄存器就是关于 IRQ 的位图。现在,我们要使用位图来管理内存,即用一个位来表示某片内存的状态(是否已经使用)。问题是,一个位应该映射成多大的一片内存呢?通过前面内存分页的学习,我们知道内存是按页来分隔的,一页的大小是 4KB,出于这个原因,我们将一个位映射为 4KB 内存,即一个位管理一页内存。如果某位为 1,则表示对应的页已经被使用;为 0 则表示该页为空闲状态,可以使用 。
位图结构体的声明如下:
1 | struct bitmap { |
btmp_bytes_len
表示位图的长度,包含的总位数为 btmp_bytes_len*32 。该值由位图所管理的内存大小决定。bits
为位图的指针。位图也当然是存放在内存中的,所以我们用bits
指针来记录位图的起始地址 。
需要注意,虽然 bits
是 uint8_t*
型的指针,步长为 1 字节,但实际操作时我们会细化为按位处理,即通过掩码来判断相应位是否为 1 。
位图的操作函数有如下几个:
1 | void bitmap_init(struct bitmap* btmp); |
bitmap_init
:用来初始化位图,根据传入的 btmp 参数来决定将哪片内存视为位图,并将其初始化为 0 。bitmap_scan_test
:用来检测位图中第bit_idx
位是否为 1,该函数只在bitmap_apply
中调用。bitmap_set
:用来将位图中的第bit_idx
位赋值为 0/1 。bitmap_apply
:在位图中申请连续cnt
个位,若成功则返回起始位的索引(下标),失败则返回 -1 。
实现如下:
1 | //bitmap.c |
注释较清晰,下面挑重点解释:
-
第 13 行,重点理解这个按位检测,逻辑也比较简单。需要说明的是,《真相还原》中的原代码是:
1
2
3
4
//.......
return (btmp->bits[byte_idx] & (BITMAP_MASK << bit_odd));
//.......这和我们的代码效果是相同的,笔者之所以作如此改动,是因为改动之后的逻辑更符合我们直觉,即,位是按顺序排列的:
而原代码的逻辑是:
-
第 41 行,为什么要把 cnt==1 的情况单独拿出来呢?因为后面我们申请物理内存时,物理内存池中的页可以不连续,所以传参时 cnt=1;申请虚拟内存时,必须连续,所以 cnt 不必为 1 。由于会大量用到 cnt=1 的情况,所以单独拿出来,避免再做后续处理以提高效率。
没懂上述解释的同学不用慌,下节内存管理进阶我们还会说到这点。
-
第 54 行,每当连续的位被断开时,cnt 就需要清零,因为函数要求的是找出连续的 cnt 个位。
位图的介绍就是这些,下面我们用位图来规划内存。
值得一提的是,位图只是内存管理的一种方法,其他常用的方式还有链表,参见空闲内存管理,位图,空闲链表-CSDN
规划内存池
什么是内存池?
说得高深一点,内存池是内存集合的抽象;说白了,内存池就是上面所说的用来管理内存的位图。所以,内存池的职责就是管理内存,需要内存时,从内存池(位图)中申请;回收内存时,则归还内存池。
可以将位图理解成内存池的物理形式。既然将内存池等同于位图,就说明内存池的存取粒度和位图一样,都是以 4KB 为单位 。
如何规划内存池
规划内存池,分为两个大的方向:
- 物理内存和虚拟内存的规划
- 用户内存和内核内存的规划
这两者必须结合在一起讨论。我们已经知道,虚拟内存这个概念,其本身是针对于进程而言的,每个进程都有 4GB 的虚拟内存,其中一部分虚拟地址会映射到物理内存。那么,我们就不得不考虑如下两点:
-
这么多进程都有各自的虚拟空间,它们都会争用物理内存,所以操作系统必须知道哪些物理内存被用了,哪些还未被使用 ,因此,我们需要建立物理内存池,以管理物理内存的使用情况。
-
虽然每个进程都有自己的虚拟 4GB 空间,但在进程内部,虚拟内存也不能重复使用,即,虚拟地址在进程内是唯一的 。同样,为了管理虚拟地址的使用情况,我们需要建立虚拟内存池。
注意,虚拟内存在进程是唯一的,但多个进程之间,可以使用相同的虚拟地址,各自的虚拟地址对外是不可见的,相互独立的!
除此之外,我们还需要将物理内存分为内核内存和用户内存,这是基于以下两点原因:
- 操作系统会占用较多内存,毕竟它是其他用户进程的载体,不仅要引导用户程序的运行,还要负责任务调度,内存开辟等诸多重要任务。
- 为了内核的正常运行,不能用户申请多少内存就给多少,否则有可能因为物理内存不足而导致内核自己都不能正常运行。
因此,咋们专门分出内核的专属内存,其他物理内存则划给用户。
综上所述,我们最后划出三个内存池:1)内核物理内存池;2)内核虚拟内存池;3)用户物理内存池 。
有了前两者,当内核申请内存时,便会先从内核虚拟地址池中申请发配虚拟地址,接着从内核物理地址池中申请分配物理地址,最后在内核自己的页表中建立虚拟地址到物理地址的映射关系 。这个过程我们很快就会用代码展现出来。接着,你一定会问,为什么只有用户物理内存池,而没有用户虚拟内存池呢?是这样的,用户物理内存池是供所有用户进程使用的,用户共享这一片物理内存池;而 用户虚拟内存池则是每个用户进程私有的,当创建用户进程时,也会在其内部开辟虚拟池给自己使用 。用户虚拟内存池将在我们实现用户进程时介绍。
规划细节
话不多说,先放出本操作系统的具体内存安排:
下面对以上内存规划进行阐述:
-
低端 1 MB 完全给内核使用,内核的代码只运行在这 1MB 内,且低端 1MB 物理内存和虚拟内存是一一映射的。这点在开启分页-代码详解中讲解过。
-
为什么将这三个内存池位图放在低端 1MB 内呢?因为 低端 1MB 是不会放入内存池中的 ,这 1MB 空间相当于是上帝视角,不受内存管理约束,原因很简单——它自己就是管理者。内存池用于管理其他内存,而不用关心自己所在的内存,否则就是自己管理自己啦,这么说来,将自己所在内存的对应位置 1,岂不是相当于自杀了?哈哈哈哈开个玩笑,原因大概就是如此。
-
PCB (Process Control Block, 进程控制块),用来管理进程,每个进程的信息(pid、进程状态等)都保存在各自的 PCB 内。关于 PCB 的详细内容会在后面讲线程时提到,现在读者只需记住两点:
-
PCB 需要用一个自然页存放 ,即 PCB 的起始地址必须为 0xXXXXX000,结尾必须是 0xXXXXXfff 。
这就是为什么 0x9f000~0x9fbff 未使用的原因——为了凑一个自然页。这么说大家可能还不清楚什么叫做未使用,放一张图各位就知道了:
原本 0x7E00~0x9FBFF 都是空闲区域,咋们大可以将 PCB 放在 0x9E000~0x9FBFF 处,但无奈 PCB 只能占用一个自然页,所以 0x9F000~0x9FBFF 只能被废弃。 -
PCB 的最高处 0xXXXXXfff 以下用于进程/线程在 ring0 下使用的栈 。为什么将栈放置于 PCB 内以及为什么只用于 ring0,这会在后面实现线程时详细阐述。现在读者仍只需知晓,我们的内核代码本身就是一个线程(主线程,或者说单线程进程),所以它也有自己的 PCB ,没错,就是上上图的那个 PCB。因此,在进入内核前(guide.s),我们会将 esp 指向 PCB 的顶端(栈底),即
mov esp,0x9f000
。先向读者透露一下,PCB 中的栈与线程切换息息相关,可以说,线程切换就是通过栈操作来进行的。
-
-
注意,在开启分页中,我们将页目录表和页表放在了 1MB 地址之上,刚好占用了 1MB~2MB 地址。这里的目录表和页表是供内核进程使用的,已经被占用,所以这部分内存也不能划入用户/内核物理内存池 。
-
除开低 2MB 的内存外,剩下的 30MB 物理内存平均(各自15MB)分给用户/内核物理内存池 。注意,内核物理内存池位图管理 15MB 物理内存(kernel_pool),所以内核虚拟内存池位图也管理 15MB 虚拟内存;用户物理内存池位图虽然管理 15MB 物理内存(user_pool),用户虚拟内存池位图(位于用户进程中)却管理 4GB 虚拟内存。
-
注意,虽然图示中 0x9a000~0x9e000 用来存放这三个位图,但实际上并没有完全放满。因为我们的操作系统目前就 32MB,压根用不了这么多,这个位图空间我们只用了一小部分(三个位图一共才占 0x5a0 字节),其他剩余空间是预留的,以便于未来扩展此操作系统。
内存规划代码剖析
1 | //memory.h |
virtual_addr
结构体目前仅用于内核虚拟内存池,后期还会用该结构管理用户虚拟内存池。pool
结构体用于内核与用户物理内存池的管理。为什么pool
比virtual_addr
多一个 pool_size 成员呢?这是因为,物理内存是很有限的(本OS为32MB),虽然虚拟地址最大为 4GB,但相对而言却是无限的,因此 virtual_addr 无需记录容量。- 至于为什么还需要指定起始地址,阅读下面的代码后你就会彻底明白。
1 | //memory.c |
上面的代码配合注释以及之前的讲解,是完全能够看懂的,便不再挨个解释了,只对部分内容进行说明:
-
第 12 行,
MEM_SIZE_ADDR
,即 0x90c ,这就是前文我们存放内存容量的地址;第 98 行,通过对该地址解引用,取得内存大小,随后传参给mem_pool_init()
,开始初始化内存池。 -
第 19 行,为什么页目录和页表所占内存大小是
PG_SIZE*256
?(1)页目录表,占 1 页;(2)第0、768号页目录项都指向第 0 号页表,此页表占 1 页;(3)第 769~1022 号页目录项一共指向 254 个页表,占 254 页。因此,所占内存大小为4096*(1+1+254)=PG_SIZE*256
。有人肯定纳闷了,我们之前不是仅为 769~1022 号页目录项安装了页表的地址吗?并没有创建页表页呀?那为什么还要算入这 254 页的内存大小呢?就是因为我们提前指向了这些页表的地址,每个地址相差 4096 字节,所以才必须为这些页表预留空间哒!笔者起初对这点很疑惑,想了好一会才反应过来,不知道读者会不会有这样的问题。另外,我们在开启分页-代码详解中也提到过,提前为 769~1022 号页目录项安装页表的地址是为了实现内核的完全共享,忘记的朋友不妨回头看看。
-
第 35 行,物理内存池所管理的物理内存被规定为从 2MB 开始 ,那么为什么要跳过这 2MB 呢?这在前文已经详细说明,0~1MB 是内核空间,1~2MB 是内核进程的页目录和页表,因此这部分物理内存不能再使用,所以不可划入内存池。
-
第 86 行,为什么将内核虚拟起始地址设为
K_HEAP_START
,即 0xc0100000 ?这是因为在开启分页-代码详解中,我们已经将虚拟空间高 1GB 处的起始 1MB 直接映射到物理内存的低 1MB 了,所以 0x0~0xc0100000 实际上是运行的内核代码(内核镜像)。因此,内核虚拟池中对应的起始虚拟地址必须跳过这 1MB,即从 0xc0100000 开始分配 。另外,从K_HEAP_START
应该也能看出,内核物理池是用来存放内核开辟的堆 ,读者应该对堆很熟悉了吧,这就是程序在运行时动态开辟的内存。内核代码都存放在 0x9a000 内,注意,代码是不会运行在内核物理池中的!
好了,本节内容大致就是如此,各位一定还有未能想明白的问题,可在评论区留言。同时也请别着急,进入下一节内存管理-进阶-分配页内存后,也许你就会恍然大悟。