本文已参与「新人创作礼」活动,一起开启掘金创作之路。
小伙伴们,大家好,我是追風者。今天来聊一下操作系统的内存管理(下面基于32位系统进行阐述)。
我们在进行开发时一定和内存分不开,无论是程序在运行前要占用内存,还是在运行时获取内存,都需要对内存进行获取或释放操作。我们知道,运行程序时,都是操作系统自动为程序来分配内存,那么操作系统时如何操作的呢?下面来探讨一下。
物理地址
首先,物理地址就是我们所说的内存,程序代码需要加载到此处,CPU才能够直接执行,下面都写为内存单元(此处声明物理地址是为了与虚拟地址做对比)。
我们知道,没有操作系统,计算机也是可以工作的,但是操作十分繁杂。有了操作系统之后,我们操作计算机就十分的简便了。此时需要在头脑中有一个概念,那就是操作系统是对计算机进行高效管理的,只是搭建个环境而已,并没有多么的高大上(内核层面,而非其他操作)。
在没有操作系统时,程序可以直接操作内存单元(一般如C的程序编译链接为可执行文件后,无需操作系统的支持也是可以在裸机上运行,已经是二进制代码表示了)。
如果没有管理规范,就会有两个缺点:
-
此时只是单个程序运行,如果没有任何限制约束,那么在多个程序并发运行时,可能会有不同的程序占用同一个内存单元,导致出现数据错误。
-
当一个程序占用内存过大时,每次切换都要内存的换入换出,并且是需要整个程序进行换入换出,上下文切换十分浪费时间。
以上,可以看出直接操作物理内存单元其实麻烦又危险,在现在的高并发场景下,两个缺点都是致命问题。
段页式内存管理概述
段页式内存管理,顾名思义就是程序分段,而后分页进行管理(页为二级页表,一级为页目录PDE,二级为页表PTE)。程序在编译时会将不同的段整合在一起(暂对编译原理不太了解,有时间再深入,就不细说太多了)。
GDT表项中存储的就是虚拟地址,根据该虚拟地址可以找到页目录项、页表项和物理地址,并且是一级一级的查找,具体规则如下。
以下为段页式虚实地址转换规则,如果未理解,可以粗略看,后面有例子解释。
虚拟地址:直接根据虚拟地址是无法定位物理地址的,因为其需要一级一级转化查找才会找到对应的物理地址。
物理页框:在段页式内存管理中,内存也被逻辑上分为了页,虚实对应,一页为4K,表示的是一个范围内的物理地址,所以称为页框。
GDT表中存储的就是虚拟地址,GDTR寄存器中存储GDT表的起始物理地址,通过段描述符可以找到具体的段表项。GDT表项中每一个表项的就是段表项,其中存储的就是虚拟地址。
我们可以看出,虚拟地址是8位16进制数字,表示32位二进制。在32位的二进制中,高10位存储的是页目录表的偏移量,中间10位存储的是页表的偏移量,最后12位置存储的就是物理地址的偏移量。
页目录表的地址存储在CR3寄存器中,将上面得到的页目录表偏移量乘4,获取到的就是该虚拟地址对应的页目录项的起始地址(每个页目录项都占4字节)。页目录项中存储的的是页表的起始地址,将虚拟地址的中10位乘4作为偏移量与起始地址相加,获取到页表项(每个页表项也占4字节)。页表项中存储的是物理页框的起始地址,将虚拟地址的低12位与物理页框相加后获取到真实物理地址,至此转换结束。
通过上面的转换就完成了虚拟地址到物理地址的转换,使用段页式存储有几个优点:
-
内存碎片变少了,浪费的内存变少了。使用分段+分页的管理后,每个物理地址都按页管理,每次分配内存也是按页分配,不会出现有大量空间剩余而没有连续空间加载程序的问题。
-
每次换入换出都会按页进行,每次操作的内存变小了。
-
虚拟空间变大。每个进程都会有一个页目录表,一个页目录表占一页即4K内存,包含1024个页目录项;一个页表占4K内存,包含1024个页表项。1024 * 1024=1M,每个页框占4K,1M * 4K = 4G,即每个进程逻辑上都有4G内存。
理解段页式
段页式管理可以抽象为班级学生的座位,段表项中存储的虚拟地址就是学号,根据该学号可以找到学生的年级和班号,并且该学号有些特殊,最后几位记录你在班级的哪个座位(行列矩阵)。
当查找一个学生时,先去教务处找学号名单。根据学号高10位置来确定在哪个年级(此时的起始班级号可有可无,默认为0,可以存储到一个地方)。找到年级楼层后,根据中10位偏移量,找到具体班级。根据低12位找到他的具体座位即可。
当然了,这样一级一级查也比较麻烦,我们可以广播一下学号,该学生喊到即可,此为快表,一次查询。
内存池申请内存
申请内存的主要思想:由内存池来分配内存,由位图抽象记录内存页的分配情况,将虚拟页和物理页都申请下来后,进行虚实地址映射,内存就申请好了。
在申请内存时,可以进一步抽象出物理内存池和虚拟内存池来进行分配内存,当内存池中没有位置时,则无法分配内存。
内存池分为物理内存池和虚拟内存池,物理内存池中又分为用户物理内存池和内核物理内存池。
在此需要提及一个结构:位图。位图在此处抽象表示为一页内存是否被分配了。
位图结构包含位图长度以及位图数组的指针。它就是一个固定长度的整数型数组结构,一个int占8位,根据位运算来判断此位为1或0。
虚拟内存池中包含一个位图结构和虚拟内存池虚拟地址的起始地址,根据起始地址和位图就能够定位到具体页表。位图中的每一位代表一个虚拟页。
物理内存池中也包含一个内存池的位图结构和物理内存池的物理起始地址,它还包含物理内存池的容量,根据这些属性可以定位到具体的物理页框。位图中的每一位代表一个物理页。
根据前文的抽象,此处的虚拟内存池就是一个空白表单,等待填写数据。而物理内存池呢,就是一套套的桌椅,使学生分配座位后有位置坐。
申请内存(非堆)
分配内存的大致逻辑顺序:申请多个虚拟页,而后逐一申请物理页并将虚实地址进行映射。
申请虚拟页步骤
-
判断在哪个虚拟内存池中申请虚拟内存页。
-
如果为内核虚拟内存池。
-
在虚拟内存池的位图中,寻找连续的可分配的虚拟页面,即 bitmap 中连续为0的位。返回bitmap中连续为0的起始位,即可分配目标页数的位图的第一个0位索引,下面统称为位图的起始索引。
-
从起始位开始,申请多少页面就将连续的多少位置1。
-
返回虚拟页面的起始地址。将内核虚拟内存池中的起始虚拟地址 + 位图的起始索引 * 页大小。得到的结果为分配的虚拟页面的起始地址。位图的起始索引 * 页大小 表示有多少虚拟内存已经被分配。
-
如果是用户虚拟内存池。
-
获取到运行线程的PCB。
-
从PCB中的虚拟内存池的bitmap中获取连续可分配目标内存页数量的 位图起始索引。
-
如果位图起始索引有效则循环从起始位开始,申请多少页就将多少位置1.
-
返回虚拟页面的起始地址。将用户虚拟内存池中的起始虚拟地址 + 位图的起始索引 * 页大小。
申请物理页
-
在物理内存池的位图中,寻找一个可用页面(即位图中未使用的位),并将其位图索引返回。
-
将物理内存池中的位图的位图索引置1,表示该页面被使用。
-
返回物理页面的起始地址。将物理内存池中的起始物理地址 + 位图的起始索引 * 页大小。
根据虚拟地址,得到页表项的虚拟地址
-
高10位全部位1,为 0xffc00000,这就选择到了最后一个页目录中的最后一个页目录项,即页目录表本身(此为创建页目录时,将最后一个页目录项存储的地址时页目录表的地址)。
-
通过虚拟地址的高10位转化为中10位,能够获取到保存 目标页表物理地址的 页目录项。
-
通过虚拟地址的中10位座位偏移量(转为12位置),能够定位到目标页表项的起始地址。
将上面的32位地址整合后,就是指向 虚拟地址 的 页表项 的 虚拟地址。即 得出的虚拟地址是定位原虚拟地址的页表项的。
根据虚拟地址,得到页目录项的虚拟地址:
-
前20位全部为1,即0xfffff000,即将页目录表看待为页表处理。高10位定位页目录项,指向最后一项,即指向页目录本身。中10位置定位页表项,指向最后一项,仍然指向页目录表本身。
-
将虚拟地址的高10位,作为偏移量(转为12位),能够定位到目标页目录项的起始地址。
将上面的32位地址整合后,就是指向虚拟地址的 页目录项 的 虚拟地址。即 得出的虚拟地址是定位 原虚拟地址的页目录项的。
虚实地址映射
-
获取虚拟地址的pde指针和pte指针。
-
对pde的P位是否为1进行判断(P为存在位)。若pde存在则继续判断pte的P位。此时如果pte存在则见鬼!异常打印即可。
-
若pte不存在,则将pte指向物理页面的地址(即物理页框),并为页表项的各个属性赋值。
-
如果连pde都不存在,则先创建pde,为pde创建新的页表,即为pde申请一个物理页表,返回值是虚拟地址,刚刚好为pde赋值。将pde中的数据全部清空(内存全部置0)。而后将参数中的物理页框存储到pte中。
分配内存(非堆)小结
申请内存的顺序如下:
-
申请一个或多个虚拟页面。
-
判断是从用户/内核内存池中申请内存。
-
根据申请页面的数量进行循环操作,每次都申请一个物理页面,并与虚拟地址进行映射(每次只申请一个物理页面是因为物理页面无需保证连续)。
-
每次循环时,起始虚拟地址都增加一个页面大小即可(此处的起始虚拟地址是备份后的)。
-
最后返回最初的起始虚拟地址。
堆内存申请
我们知道,程序可以在运行期间进行内存的申请,如 C 的 malloc 函数,Java new 一个新的对象等等。程序编译后,会有一个固定的内存大小,而在其中需要预留出堆的空间,给程序分配内存时使用。
堆内存申请的思路:比对程序要申请的内存大小,超过 1024 字节就按页分配,不超过就 1024 字节就按照内存块描述符的大小选定需要的内存块,分配 arena 并将其按内存块分解放入空闲队列,最后将分配的内存块起始地址返回即可。
在申请堆内存时,由于我们申请的内存大小不定,所以不能采用申请一页大小的规则来进行分配内存了,这就引出了 arena 的结构概念。
arena 结构中包含 mem_block_desc 内存块描述符,标志位 larget和数量 cut(当 larget 为 true 时,cut 表示的是页框数,否则表示空闲的内存块数量)。
arena 中包含元数据和多个内存块。
arena 一般分为16字节、32字节、64字节、128字节、256字节、512字节和1024字节七种。
通过 free_list 链表来保存未使用的 arena 内存块。
mem_block 是内存块结构,其中保存的就是可用的内存块,是可用内存块链表中的元素 free_elem。
mem_block_desc 是内存块描述符,用来追踪 arena 块。该结构中有内存块大小属性 block_size、arene 中可容纳内存块的数量和一个可用的内存块链表即 free_list。
堆中申请内存
每个线程的 PCB 中都含有一个内存块描述符数组,用于后续的堆内存分配。
-
获取到当前运行进程的PCB(具体会在进程/线程管理中进行详细叙述),即获取到了当前运行的线程的数据信息。
-
判断线程为内核线程,如果是内核线程,则使用内核内存池。否则使用用户内存池,并对内存池加锁(锁部分也在线程管理时详细叙述)。
-
如果申请的内存大小超出返回则返回空。
-
如果要分配的内存块超过 1024 字节就分配页框。
-
4.1 首先对(申请的内存大小 + arena 内存大小)/ 页大小 并向上取整,获取到需要申请几页。
-
4.2 为 arena 申请目标数量页,并记录起始虚拟地址。
-
4.3 若起始虚拟地址为空,则表示无内存可分配,释放内存池锁。
-
4.4 若起始虚拟地址不为空,将分配的内存全部清0,并为 arena 属性赋值,cut保存页数量,描述符为空,并将 large 设置为 true。
-
4.5 释放内存池的锁。
-
4.6 返回跨过 arena 元数据的内存起始地址。
-
-
如果分配的内存块不超过 1024 字节就分配小内存块。
-
5.1 根据内存块描述符中的内存块大小进行比对,由于PCB中的内存块描述符表下标越小,内存块越小,那么一定会找到最适合的内存块。
-
5.2 如果 mem_block_desc 中的空闲内存块链表 free_list 为空,则表示没有可用的内存块,创建新的。为 arena 分配一页页框,如果 arena 仍然为空,则释放内存池锁并返回空。
-
5.3 将分配的内存页全部清零。使用描述符数组(PCB 中的描述符数组,索引是前面进行内存大小比对是获取到的)中的值赋值给 arena 中的描述符属性。arena 的 large 属性设置为 false。将cut属性设置为内存块描述符中的 内存块数量属性值。
-
5.4 关闭中断响应。将 arena 拆分为内存块,并添加到内存块描述符的空闲内存块链表中(将 arena 的起始地址 + arena 所占内存 + 内存块索引 * 内存块描述符的内存块大小属性 此时获取到的就是 arena 中索引内存块的地址了)。
-
5.5 开始分配内存块。从对应的内存块描述符中的空闲队列出队一个内存块,并获取到内存块的起始地址。
-
5.6 将内存块内存全部置0。获取到该内存块的 arena 结构体。将该 arena 结构体的cut数量减一,释放内存池锁,并返回内存块起始地址。
-
释放页框级内存
思路:
-
在物理地址池中释放物理页地址
-
在页表中去掉虚拟地址的映射,即将虚拟地址对应的 pte P位置0,即不在内存中。
-
在虚拟地址池中释放虚拟地址。
物理地址回收到物理内存池
判断使用的是用户物理内存池还是内核物理内存池,获取到该物理地址对应位图的哪个索引,将该索引的位图置0即可。
去掉页表中的虚拟地址的映射
获取到虚拟地址的页表项虚拟地址,将页表项pte的P位置0,更新 tlb 即快表。
虚拟地址池中释放以 _vaddr 起始的连续cut个虚拟页地址
判断是内核还是用户的虚拟地址池,根据 _vaddr 起始地址减去虚拟地址池开始地址并与页表大小做向上取整除法,得到了连续虚拟地址的位图起始索引。将位图从上面获得的位图起始索引开始的 cut 位置0。
释放虚拟地址 vaddr 为起始的 cut 个物理页框
首先判断该虚拟地址在内核还是用户物理内存池中。然后将虚拟地址减去一页大小,为了后面循环做准备。循环操作,将虚拟地址加一页大小后,转换为物理地址,然后将物理地址回收到物理内存池,再去掉页表中虚拟地址的映射,最后在虚拟地址池中释放 该虚拟地址起始的 cut 个页即可。
释放内存块内存
根据给出的内存地址,来回收内存。
-
判断是线程还是进程。
-
如果是线程,使用内核内存池,如果是进程,使用用户内存池。
-
对内存池上锁,并获取到内存块的 arena (将内存块的高 20 位保留即可,第8位是内存块的偏移量,只要是通过局部属性获取到整个结构体的起始地址,都可以通过将偏移量进行去除即可)。
-
判断该 arena 是否为管理大于 1024 内存的块,如果是,则释放一页的内存。
-
如果不是则先将内存块回收到内存块描述符的空闲内存块链表中。
-
判断该 arena 中的内存块是否全部是空闲的。
-
如果是全部是空闲的,将 arena 中的所有内存块 一个一个的从空闲内存块队列移除,最后将 arena 也释放掉(也就是释放一页内存,因为创建时就是开辟一页内存创建的)。最后释放内存池锁。
注
以上是对《操作系统真相还原》这本书中内存管理系统的一个概括总结,大体上内存的申请和释放都比较清晰了。
如果文章有任何错误欢迎各位斧正,编程心得就是需要不断的交流才会拓宽视野,感谢各位。