一、内存管理的基本原理
我们都知道外存的读写能力很慢,而CPU的运行速度很快,所以为了提高CPU的利用率,我们就需要将进程放入一个更加高速的位置,这个位置就是内存。内存的运行速度比外存快的多,可以大大提升CPU的利用率。而内存管理这一章主要讲的就是用什么办法将一个作业调度到内存中?以及如何改进这些方法?以及如何管理内存中的这些进程或者说是页面。
1.1程序的装入与链接
程序的连接是在将进程调入内存之前发生的,程序是由程序员编写的,而一个程序可能需要多个程序员编写,那么形成程序之前,就会有很多的源代码段,我们需要将这些源代码段翻译成对应的目标模块,这个过程叫做编译,接下来我们需要将编译好的目标模块连接起来形成一个装入模块,此时程序还没有进入内存运行,这就是程序的装入与连接,而程序的连接有三种办法:
1.静态链接:直接将编译好的目标模块拼接成装入模块,不再拆开,然后将整个装入模块一起装入内存,这种装入方式一旦装入便不能修改。
2.装入时动态连接:直接将目标模块装入内存,一边装入内存一边连接目标模块。这种一遍装入一遍连接的方式有利于修改和更新,且便于实现对目标模块的共享(此处的共享基于分段储存的原理实现,会在分段储存那里细说,大概原理就是两个进程都指向同一个目标模块来实现共享)。
3.运行时动态连接:在程序运行的时候,只有需要某个目标模块的时候才会将其装入内存,凡是在执行期间没有用到的模块都不会被调入内存和被连链接装入模块上。其优点是可以加快程序的装入过程,还可以节省大量的内存空间。
这里还有一些问题值得思考:我们在将装入模块装入内存的时候,要将其装在内存的什么位置呢?我们如何找到这些位置?当我们想访问内存中的该程序应该如何判断是否越界呢?
1.2装入模块装入内存
我们在将装入模块装入内存之前,需要先将装入模块中的源代码进行编号,从第一句代码的0号到最后一句代码的编号。而这些编号就称为逻辑地址。但是我们可以将逻辑地址中的0号地址直接放到内存中的0号地址吗?显然是不可能的,因为内存中的低地址存放的都是操作系统的代码。
所以我们就需要将装入模块中的逻辑地址通过某些方法来将其转化为内存中的物理地址(这里我们将用连续分配内存空间举例):
1.绝对装入:假如我们在将装入模块装入内存的时候,发现内存中100号地址后面的内存可以容纳下整个装入模块,那么我们就需要在装入之前将装入模块的逻辑地址的所有地址编号加上100。地址变化完后就是可以与内存的地址一一对应了,而这种地址的变化是不可改变的。这种装入方式就叫做绝对装入,这种笨方法的应用时期操作系统还没有出现。
2.可重定位装入(静态重定位):这种装入方式就是在将装入模块装入内存的时候装入程序将装入模块的逻辑地址按照物理地址进行重定位,就类似于绝对装入中对地址进行加100的操作,只不过这种操作是发生在装入内存的过程中发生的。这种方法的局限性在于程序一旦装入,就不能移动进程的位置,且必须要分配连续的内存空间来存储。
3.动态运行时装入(动态重定位):前面两种方法在将逻辑地址变换为物理地址之后,物理地址就不能再更改了,但是动态运行时装入,我们可以将逻辑地址与物理地址之间转换的的“偏移量”放入一个叫做重定位寄存器的CPU空间中,然后将逻辑地址中的最大值放入一个叫做界地址寄存器的空间中。如果我们想要更换进程在内存中的位置,只需要将重定位寄存器中的偏移量改变一下即可;如果我们想要判断我们访问的位置是否越界,只需要将界地址寄存器中的逻辑地址最大值加上重定位寄存器中的起始地址即可判断该程序的末尾地址。
这种存储方式可以将程序分配到不连续的存储区中(分页存储管理);且可以根据需要来动态申请分配空间;程序运行时,只需要将部分用得到的代码段装入内存即可运行;便于程序段的共享(分段存储)。
二、连续分配管理
2.1单一连续分配
内存中的空间分为系统区和用户区,而这种分配方式是一个程序单独占用一个内存空间。假如内存的大小为100,程序大小为80。那么将程序放入内存之后,就会有20的空间剩余,这剩余的20空间叫做内部碎片,因为整个内存为一个区域,而这个区域中只有一个程序。且由于这种一个城区占用一整个内存的特性,其他程序不可能非法访问该程序,故不需要进行内存保护,不过这种存储方式只适用于单用户、单任务的操作系统。
2.2固定区间分配
我们将内存空间的用户区分为若干个区域,然后将程序存放在这些区域中,而我们对用户区进行分区的时候,又可以使各个分区相等或者按照小、中、大的方式来分区,小的分区在低地址,大的分区在高地址。基于这种分区思想,为了更好的管理这些分区,我们还需要定义一个分区使用表来记录分区的分区号、起始地址、分区大小以及分配状态。如果是按照小、中、大的方式来分区,可以增加程序分配的灵活性,留下更大的内存连续区间供大程序使用。
显然这种分区的方式不可能所有的进程都正好的占满整个分区空间,而这种分区空间又是固定不变的,所以这种在某一段固定区间内产生碎片的情况叫做内部碎片。而由于每个分区空间之间又是紧挨着的,所以这种分配方式没有外部碎片。
2.3动态分区分配
这种分配方式是当某个程序进入内存之后,系统才将该程序占用的内存来划分成该程序所占用的分区。显然这种分配方式可以使每个程序都占满整个分区。但是随着程序越来越多,分区也按照某种算法一直在动态的分配,分配到最后一定会有某部分很小的内存空间无法再让程序来占用。这种分区之外的碎片化的内存空间就叫做外部碎片。
那么为了更好的管理那些可用的内存空间,我们可以引入一个叫做空闲分区表/链的数据结构来记录哪部分内存空间没有分配,并记录他们的编号、大小以及状态,当我们分配或回收某些空间的时候,空闲分区表也要随之变化,可能新增一个表项,也可能删除一个表项、也可能使某个表项的内容改变,需要按照分配方式来思考。
那这种分配方式产生的若干外部碎片,由于碎片化导致程序无法使用,此时我们可以使用紧凑技术来使所有的动态分配分区都紧挨着。这样就可以使外部碎片聚集在一起来形成一个可用的内存空间。这种紧凑技术显然需要使用动态运行时装入的方法,通过改变CPU中的基地址寄存器(重定位寄存器)中的进程起始位置来实现。而其他两种方法显然是不行的。
2.4动态分区分配的算法
1.首次适应算法:空闲分区以地址递增的顺序排序,分配内存时查找空闲分区表,找到第一个满足该程序所需空间大小的空闲内存区间分配。
2.邻近适应算法:邻近适应算法就是基于首次适应算法的基础上,分配内存的时候从上一次分配内存的空闲区间的位置开始分配。
3.最佳适应算法:空闲分区以空间大小递增的顺序排列,分配内存时查找空闲分区表,找到第一个满足该程序所需空间大小的空闲内存区间分配。
4.最坏适应算法:空闲分区以空间大小递减的顺序排列,分配内存时查找空闲分区表,找到第一个满足该程序所需空间大小的空闲内存区间分配。
了解了这四种算法的运行方式后我们发现,最佳适应算法由于每次将空间小的空闲区间分配给进程,这就会导致其会产生更小的外部碎片;最坏适应算法虽然每次将更大的空闲区间分配给程序,不会产生极小的外部碎片,但是由于大的空闲区间被分配,那么内存中就没有大的连续的空闲区间分配给大程序。而且这两种算法由于是按照空间大小来对空闲分区表进行排列的,所以每当我们对空闲区间进行分配时,都需要对空闲分区表的表项顺序进行改变,无疑增加了算法时间开销。邻近适应算法由于平衡了低地址的空闲区间与高地址空闲区间的分配概率,那么内存空间中的各个位置就都有可能被分配进程,那么就会导致高地址可能存在的大的空闲区间被分配,导致内存中不再具有分配给大程序的大的连续空闲区间。这样看来,性能最好的就是首次适应算法,算法开销小,且更有可能会给内存中留下大的连续空闲区间。
三、基本分页存储管理
3.1分页存储管理的实现方式
首先我们将一个程序分成若干个连续的页面,从0开始,每个页面4KB,接下来我们将内存分成若干个连续的页框,从0开始,每个页框4KB,接下来我们将连续的页面存放在不连续的页框当中,即可完成第一步存储。接下来的问题就是,如何找到页面存储在哪个页框,所以我们要引入一个叫做页表的数据结构,页表是由页框号形成的,而每个页框号对应一个页号,而页表的页号是隐含的(这里参考数组下标的对应方式)这样一来就形成了连续的页号(下标)对应着不连续的页框号,这样就可以通过页号来找到页面存放在哪个页框之中。而根据页框的大小,页框大小×页框号=页框起始地址。这样一来我们就得到了该页面在内存中的起始地址。那么如何知道该页面中具体的某一段代码在页面内的存放地址呢?我们还需要引入一个概念叫做分页存储的逻辑地址结构。
3.2分页存储的逻辑地址结构
分页存储的逻辑地址结构是由32个比特位形成的,由于一个页面大小为4KB,需要12位2进制数来表示,所以我们需要用0~11位的二进制数来表示;而12~31位的二进制数用来表示页号,故一个地址结构可以存放2^20-1个页号,而一个页框是4KB,也就是一个逻辑地址结构共能记录2^32大小的内存,也就是可以将4GB的内存中划分的所有页框都记录下来。
接下来我们将页号与页内偏移量结合在一起,再将逻辑地址结构的页号与页表的页号一一对应起来,我们就可以根据逻辑地址结构中的页内偏移量,再加上页表项中的页框号,就可以知道页面中具体的某一段代码在页面内的存放地址。也完成了逻辑地址到物理地址的转换。
此外,一个逻辑地址结构中最多可以记录2^20-1个页号,也就是该逻辑地址结构对应的页表共需要记录2^20-1个页框号,也就是页框号至少需要20个比特位才能表示,也就是至少需要3个字节的大小才能表示。也就是说如果共有n+1个页号,一个页表就需要(n+1)*3B的大小,但是页表如果想存放到页框中的话,可以将页框号适当增大到32个比特位,这样一来一个页框号就是4B而一个页框是4KB,那么一个页框就正好能存放1024个页表项。
需要注意的是要区分好逻辑地址结构与页表项的区别,逻辑地址结构与页表项是两个不同的东西,我们可以通过逻辑地址结构中的页号来找到对应的页表项,逻辑地址结构是由页号与业内偏移量形成,此时的页号不是隐含的,是由12~31共20位2进制数表示的;而页表项是由页号与页框号形成,此时的页号是隐含的,而页框号至少需要20位2进制数来表示,也就是大小至少为3B。
3.3基本地址变换机构
我们知道在2进制的表示下,只需要将页框号与页内偏移量代表的2进制数拼接起来即可那么想要找到某个逻辑地址结构对应的物理地址需要以下5个步骤:
1.找到页表起始地址以及页表长度:在计算机中,页表起始地址与页表长度是被存放在PCB中的,当我们想要使用该页表时,系统会将PCB中记录的页表内容放到页表寄存器中。
2.根据逻辑地址结构对应的页号来与页表长度进行比较,如果超过页表长度,那么就说明该地址非法越界。值得注意的是:页表长度为1时,页表中只有一个页表项,其页号为0。
3.根据页号与页表起始地址来判断该页号对应的页表项所在的地址,因为页表也是存放在页框中的有了页表的起始地址,假设页表项长度为4B,那么页号×页表项长度=该页表项起始地址。
4.找到页表项起始地址后就可以找到该页表项记录的页框号,那么同样的原理,假设页框长度为4KB,那么页框号×页框长度=该页面存放在内存的起始地址。
5.再根据该逻辑地址结构找到页内偏移量,页面起始地址+页内偏移量=物理地址。
这种地址变换的过程中,我们首先对内存中存放页表的位置访问了一次,接着又对存放对应页面的位置访问了一次,一共访问了两次内存,相信了解了基本地址变换机构后,我们就能很清晰的知道,逻辑地址结构、页号、页表、页表项为什么要这么设计了。
3.4具有快表的地址变换机构
这种变换机构引入了一个概念叫做快表,快表是一种在CPU中的寄存器,又称联想寄存器(TLB)因为该寄存器可以记忆最近访问过的页表项页号与页框号,故名由来。
这种地址变换机构与基本地址变换机构类似,只不过在访问内存中的页表之前,需要对快表进行访问,如果我们的逻辑地址结构中的页号可以在快表中找到,那么我们可以直接跳过访问内存中页表的过程,直接根据快表中的页框号来访问内存中的页框号对应的页面即可。
这种地址变换的过程中,如果快表命中,那么我们就可以减少一次对内存的访问。但是当快表中的数据存满的时候,需要根据某种算法对快表中的数据进行更新。由于快表是寄存器,在高速缓存区中,访存速度比内存快得多,且由于局部性原理,快表的命中率可以在90%以上。
局部性原理:时间局部性:如果某个数据被访问过,那么在短时间内,该数据很大概率会被再次访问;空间局部性:如果某个存储单元被访问,那么其邻近的存储单元很大概率会被访问。
值得注意的是,有些处理机可以允许快表和慢表(存放在内存中的页表)同时查找,有些处理机只能使快表和慢表分开查找,这两种查找方式对应着不同的查找时间,因为查询快表的同时,如果阻塞慢表的查询,一旦快表没命中那么慢表查询就会晚一步,需要会算两种方式的查询时间。
3.5两级页表
前面的学习中,我们了解到一个页表可以存放2^20次方个页号对应的页框号,而每个页表项大小至少为3B,这里按照4B计算,那么一个页表需要占用2^20*4B大小的连续内存空间,显然这违背了分页存储管理的不连续存储精神。且我们根据局部性原理可知,页表中的页表项访问概率有高有低,甚至某些页表项根本就不会被访问,所以也没必要将整个页表存入内存中。
基于以上单极页表存在的问题,我们就要引入两级页表的概念来解决这些问题。这里我们按照页表项为4B,页框大小为4KB举例。
我们从逻辑地址结构入手,我们知道单极页表的逻辑地址结构为0~11用来表示页内偏移量,这里由于页框大小为4KB,而32位机器中地址的单位是1B。那么4KB可以存放4*1024个单位地址,也就是2^12个,所以用0~11位来表示页内偏移量。
说完了0~11位的由来,我们再思考一件事,一个4KB的页框,可以存放1K个4B的页表项,如果我们将页表按照1K为单位拆分,将其分配到不连续的页框中,那么我们想要找到页框中的页表项,只需要2^10,也就是10位2进制数即可表示页框中所有的页表项,因为每个页框中存放的页表项的页号都是从0开始,也就是最多有10位2进制数的页号。那么我们是不是就可以使用逻辑地址中的12~21位来表示页框中所有的页表项对应的页号?这就是二级页号。
说完了12~21位,我们再思考一件事,单极页表的页表项共有2^20个,前面说过我们将1K个页表项结合放入页框中,那么2^20个页表项就需要2^10个页框来存储,那么我们是不是需要10个2进制位来表示这1K个页表项存放在哪个页框中?这就是一级页号。
逻辑地址结构我们清楚之后,根据一级页号对应的一级页表来找到这1K个页表项存放在哪个页框中,再根据二级页号就可以知道该页表项在页框中的什么位置,找到该页表项后,就可以根据页表项中的页框号来找到该逻辑地址的起始物理地址,再加上逻辑地址结构中的页内偏移量,即可算出其真正的物理地址。
四、基本分段存储管理
4.1基本分段存储管理
本节内容涉及汇编语言,我没学过,可能讲述会有一些纰漏。
基本分段存储管理机制:程序员在用汇编语言写好一个程序之后,为了更好的实现进程的共享和保护,操作系统会为该程序按照程序员所取的段名以及划分的段长来为每个段分配内存空间,当程序员想要访问内存中的某个段的时候需要使用一种叫做段表的数据结构,段表的段表项记录了每个段的基址以及段长,这里还有段号,但是类似于页表,段表的段号也是隐含的。而如何通过段表来找到对应的段,需要引入一个概念叫做分段逻辑地址结构。
分段逻辑地址结构:该逻辑地址结构是由段号与段长形成的32位2进制数形成,0~15位用来形容段长,16~31位用来形容段号。类比分页逻辑地址结构,故段长表示的段内偏移量最多为64KB,也就是段内地址、段长最多为64KB,而可以记录2^16次方个段号。根据分段逻辑地址结构我们在来看看段表项。
段表项:段表项前面说过是由段的基址以及段长构成,而段长可以用16位2进制数来表示也就是2B,此时假如我们的内存空间大小为4GB,那么4GB是2^31~2^32-1需要用32位2进制数才能全部表示,故我们可以用32位2进制数来表示段的基址,大小为4B,这样一来,一个段表项的大小为6B,而一个段表就可以表示整个4GB的内存空间。
分段地址变换结构:类似于分页地址变换结构,先用PCB中记录的段表起始地址以及段长来判断分段逻辑地址结构是否越界,然后再通过查询段表来找到某个段的基址,在讲此基址加上段内偏移量就可以找到该段的某个单元的位置。
分段存储管理和分页存储管理的比较:
1.分页存储管理的页面大小是固定不变的,且分页对于用户来说是不可见的;分段存储管理的分段是由程序员决定的,分段对于用户来说可见;
2.分页存储管理由于一级页表页面编号是连续的,所以分页存储是一维的,只需要表示页内偏移量就可以表示该单元的具体位置,但是由于段表中各个段的编号是不连续的(每个段都要重新编号),故分段存储是二维的,故这里不仅要给出段内偏移量,还要给出段号,才能找到该单元的具体位置。
3.分段存储管理更有利于代码段的共享,可以共享的代码段叫做可重入代码(纯代码),这种代码是不能被修改的,比如用于进程申请临界资源的代码。这种代码由于分段的特性,每个段占一个内存空间,故只需要令两个进程的段表都指向内存中该代码段即可实现共享。但是分页存储就无法做到,由于页框大小是恒定的,但是代码段大小是不一定的,这就导致,一个可以共享的代码段可能要存放在两个页框之中,那么页表中的页表项就无法指向该代码段所在的页框。
4.2段页式存储管理
段页式存储管理主要就是将每个段分成若干个页面,然后将这若干个页面存放于内存中的不同页框中,这种存储方式我们可以发现,分段摆脱了一个段需要连续内存空间的限制,使分段具有了分页的优点。而我们要想理解段页式存储管理,首先要学的就是其逻辑地址结构。
段页式存储的逻辑地址结构:是由:0~11位2进制数来表示每个段分出的页面的页内偏移量;12~15位用来表示每个段分出页面的页号;16~31位用来表示段号。通过逻辑地址结构我们可以看出,页面大小为4KB,而每个段最多分出16个页面,所以一个段最大为64KB,那么段号共由16位2进制数表示,那么段号×段长=4GB,也就是说该逻辑地址结构可以表示4GB内存空间的所有位置。那么该逻辑地址结构对应的段表也就可以记录4GB内存的全部空间。
段表:该存储方式的段表与基本分段的段表不同,该段表记录了页表长度以及页表存放页框号,但是段号还是隐含着存在的。也就是说,我们可以通过逻辑地址结构的段号来查询段表找到该段号对应的段表项,而段表项可以找到用来记录该段所划分页面的页表。而通过逻辑地址结构中记录的页号,可以找到页表中记录的该页面的存放位置,最后通过页内偏移量来找到具体的物理地址。
我觉得段页式存储管理有点类似于二级页表,段页式存储管理中的分段是面向用户的,而给每个分段进行分页操作对于用户来说是不可见的。首先我们查询段表需要通过段号先访问一次内存中的段表,再通过段表来访问内存中用来记录对应段划分成的页面的页表,最后通过访问内存中的页表访问内存中对应的页面。一共进行了三次访存,但是如果快表命中,就只需要一次访存。