下面是这篇笔记的思维导图:
在连续分配中,一个进程不可被分割,只能整体放入一块连续的内存空间中;但在基本分页存储管理中,允许把一个进程按照固定大小 X
分割为多个部分,同时把内存也按照固定大小 X
分割为多个部分,并把前者对应地放到后者中(不要求连续存放)。通常来说,一个进程的最后一部分会小于 X
,这部分若放到内存的某个 X
空间中,则仍然会产生碎片(这种碎片称为页内碎片),要让这种碎片尽可能小,X
也必须尽可能小。
X
的部分,这些部分就叫做页框/页帧/物理块/内存块,每个页框会有一个数字编号,第一个页框就从 0 开始X
的部分,这些部分就叫做页面/页,每个页面会有一个数字编号,第一个页面就从 0 开始正如我们在上面讲过的,进程的最后一个页面通常无法利用完整个页框,会不可避免地产生页内碎片,为了让碎片足够小,必须控制好单个页框的大小,不能过大。
系统以页框为单位为各个进程分配内存空间,一个页面就对应一个页框,它具体放到哪个页框,这是随意的,无需顾虑先后顺序,无需顾虑是否连续或者相邻。
这里还需要考虑一个地址转换的问题。假设我们采用的是动态重定位方式进行地址转换,那么在模块装入的时候,显然程序中还是逻辑地址,但在程序执行到需要访问地址的时候,就要进行逻辑地址到物理地址的转换了。
具体怎么转换,这是一个问题。这里先以十进制地址讲解地址转换,体会大概的过程,再用二进制地址讲解。
左边进程按照 50b 的大小分为 4 个页面,右边内存按照 50b 的大小分为若干个页框:
在程序执行到指令 1 的时候,需要访问地址 80,这是一个逻辑地址,需要转换成对应的物理地址。转换步骤如下:
从左图可以看出,逻辑地址 80 在 1号页面内,而 1 号页面对应的是右图中的红色页框,起始地址为 450;逻辑地址 80 在 1号页面内的偏移量为 30;所以物理地址 = 450 + 30 = 480
也可以用计算的方法,在已知逻辑地址的情况下:
当然,地址实际上是用 32 位二进制数表示的。这时候计算页号和页内偏移量实际上更加简单,因为地址本身已经包含了这两者。以页面/页框大小 4kb 为例:
一个页面 4kb 大小,也即 2^12^b = 4096b 大小。那么,0 号页、1 号页、2 号页的表示就是:
这里会发现,地址的前 20 位(红色部分)表示页号:全是 0 表示 0 号页,末尾 1 表示 1 号页,末尾 10 表示 2 号页……以此类推;地址的剩余(黑色)部分表示页内偏移量。所以说地址本身其实已经包含了这两者的信息。
若页面/页框大小位 1kb,也即 2^10^b =1024b 大小,那么同样可以发现,地址的前 22 位表示页号,地址的剩余部分表示页内偏移量:
总结下来,规律就是:
如果页面/页框大小为 2^k^ b,那么前面部分表示页号,末尾 k 位表示页内偏移量。在页面/页框大小为 2 的整数幂的时候,就可以直接从地址看出页号和页内偏移量,因此建议将页面/页框大小设置为为 2 的整数幂。
根据地址,就已经可以知道页号和页内偏移量,剩下还有一个工作就是根据页号找到页号对应页面在内存中的起始地址。
对于每一个进程,可以维护一张页表,用它来记录页面号(页号)与页框号(块号)的映射关系,可以根据页号找到页号指代页面在内存中对应页框的编号:
根据地址知道页号后,从页表中找出页号对应的块号,再用块号 * 页面/页框大小,即可算出块的起始地址,再用起始地址加上偏移量,即可算出物理地址。
上面所说的地址转换是通过基本地址变换机构这个硬件实现的,它借助页表将逻辑地址转换为物理地址。转换过程如下:
在程序未执行的时候,PCB 中存放程序对应页表的初始地址 F 以及页表长度 M(页表项个数)。程序一旦开始执行,F 和 M 会被送到页表寄存器中。在需要访问地址的时候,基本地址变换机构开始运行:
X + size*P
,找到这个地址就意味着找到了页号对应的块号在上面例子中,由于涉及到的都是二进制数,所以要计算物理地址,只需要将块号二进制数与偏移量二进制数拼接即可,这是比较方便的,如果例子给出的是十进制数,那么可以用 块起始地址 + 页内偏移量 进行计算,计算结果可以再转化为二进制数,结果其实也是一样的。比如:
若给定的是十进制:
页面大小 1kb,块号 2,偏移量 1023。
那么块起始地址等于 2*1kb = 2*1024b=2048b
,又偏移量 1023,所以物理地址等于 2048+1023=3071
,转化为 32 位二进制数,就是 0000000000000000000010,1111111111
若给定的是二进制:
页面大小 1kb,块号 2,偏移量 1111111111。
那么块号 2 转化为 22 位二进制数就是 0000000000000000000010
,与偏移量拼接,就得到 0000000000000000000010,1111111111
,可以看出与上面的结果是一样的。
在前面的基本地址变换机构中,存在两个问题:
上面这两个问题可以通过引入快表来解决。
快表又叫做联想寄存器,它是一种访问速度比内存快很多的高速缓冲存储器,用以存放访问过的页表项的副本,从而加快地址转换的过程 —— 也就是说,引入快表后,地址转换可以不需要经历第一次访存,而是直接从快表中拿到需要的页表项。与之对应的,内存中原本的页表,叫做慢表。
此时,地址变换机构的运行过程和之前还是差不多的,只是多了一个快表的处理过程:
在程序未执行的时候,PCB 中存放程序对应页表的初始地址 F 以及页表长度 M(页表项个数)。程序一旦开始执行,F 和 M 会被送到页表寄存器中。在需要访问地址的时候,地址变换机构开始运行:
假设又一次地,我们需要访问某个地址,并且这个地址与前次访问的地址的页号一样:
这里可以注意到,由于第一次查询到页号对应的块号后,我们将页表项拷贝了一份副本放到快表中,所以以后再涉及到相同的页号时,只需要先来到快表查找即可,找到了就直接拼接得到物理地址,无需再到内存中去访问页表,寻找页号对应的块号。
当然,由于成本的关系,快表不会做得很大,但对于中小型作业来说也已经足够,只是对于大型作业来说,不太可能把全部页表项都存放到快表中。
某系统使用基本分页存储管理,并采用了具有快表的地址变换机构。假设访问一次快表耗时 1us,访 问一次内存耗时 100us,快表的命中率为 90%。
100 + 100 = 200us
(1+100) * 0.9 + (1+100+100) * 0.1= 111 us
(1+100) * 0.9 + (100+100) * 0.1 = 110.9us
显然,引入快表后,访问一个逻辑地址的速度快多了。假设某系统物理内存大小为 4GB,页面大小为 4KB,则每个页表项至少应该为多少字节?
4GB=2^32^ b, 4KB=2^12^b,因此 4GB 的内存总共会被分为 2^32^/2^12^ = 2^20^ 个内存块,因此内存块号的范围应该是 0~2^20^-1。因此对于单个页表项,它至少要用一个 20 位二进制数才能表示这样的一个内存块号,而一个字节 8 位,所以至少要三个字节才可以表示这样的一个内存块号。又由于实际不知道哪个页表项存放哪个内存块号,所以所有的页表项统一得用到至少三个字节。
但是一个页表项用三个字节其实会出现一些问题。类似于进程被拆分为多个页面存储在内存中一样,页表也是被拆分为多个页表项存储在内存中的。假设页面/页框大小为 4kb,也即 4096b,由于一个页表项 3b,所以一个页框至多可以放 4096/3=1365 个页表项,并且这个页框剩余 1b 的空间。由于 1b 不足以再存放一个页表项,所以第 1366 个页表项(1365 号页表项)只能放在下一个页框中了。
这就会导致,前面 1365 个页表项的地址依然可以采用 X + 3*P
的方式计算,但是第 1366 个页表项,它的地址却应该是 X + 3*P + 1
,也就是说,我们无法以一个通用的式子去计算页表项的地址。
为此,建议一个页表项大小应为 4b 而不是 3b(选取 2 的整数幂)。因为如果页表项大小为 4b,那么一个页框就刚好可以放 4096/4=1024 个页表项,不会有剩余空间,而余下的页表项也可以依次放在下一个页框中。这样的话,涉及到页表项地址的计算,都可以用通用的式子 X + 4*P
来计算,就无需考虑由于页框无法得到完全利用而带来的查询麻烦的问题了。当然,为了这个式子能够通用,页表通常也应该连续地存放在内存块中,中间不出现断节。
前面使用的单极页表存在两个问题:
① 页表占用过大的、连续的内存空间:
假设页面/页框大小 4kb,页表项大小 4b,可以考虑一下一个页表会占用多大的空间:
一共需要 1024 个页框才能放下整个页表,而且为了以通用的式子计算页表项地址,还要求页表必须是连续存放的,这其实违背了当初进行分页存储的初衷。
② 页表常驻内存
其实在执行某个程序的时候,我们往往只需要访问特定的几个页面,但即便如此,整个页表也还是常驻在内存中的,这其实是没有必要的。我们可以想办法只让当前需要用到的页表项调入内存。
就像之前可以把进程拆分为多个页面一样,这里也可以考虑对页表本身进行拆分:
将长长的页表分为多个子页表,再将每一个子页表离散地存放到各个内存块中。比方说,前面我们说过,一个页框可以放 1024 个页表项,那么就可以每隔 1024 个页表项就拆分出一个子页表出来,由于页表一共有 2^20^ 个页表项,所以一共可以拆分出 1024 个子页表,这些子页表再各自存放到内存块中。
同样的,我们需要一张表用来记录子页表页号和块号之间的映射关系,这个表即页目录表/外层页表/顶层页表。如下图,页目录表此时作为一级页表,原页表拆分后形成的多个子页表作为二级页表:
同时,原有的逻辑地址的含义也发生了改变。在单级页表中,前 20 位表示页号;而在两级页表中,前 10 位表示一级页号,紧跟着的 10 位表示二级页号。这么划分之后,一级页号共有 2^10^=1024 种可能的取值,刚好就与页目录表有 1024 个页表项对应;而二级页号也有 2^10^=1024 种可能的取值,刚好就与子页表有 1024 个页表项对应。
在需要进行地址转换的时候:
上面的过程也可以直接看这幅图理解:
事实上,没有必要在一开始就把所有的页表项都调入内存中,我们可以借助虚拟存储技术,在需要访问页面的时候才把对应的页表项调入内存。具体的知识后面再进行讲解。
某系统按字节编址,采用 40 位逻辑地址,页面大小为 4kb,页表项大小为 4b,假设采用纯页式 存储,则要采用多少级页表?页内偏移量为多少位?
4kb = 4*1024b = 2^12^b,根据之前讲过的规律,页面偏移量应该是 12 位。40 - 12 = 28,所以前面 28 位用来表示页号。
因为采用多级页表后,各级页表的大小不能超过一个页面,而一个页面最多只能放 1024 个页表项,所以应该限制页表最多最多只能包含 1024 个页表项(否则就放不下多余的页表项,导致页表超过一个页面了)。由于页号在逻辑地址中是用二进制数表示的,因此页号最多需要十位二进制数去表示所有的 1024 个页表项(比如第 1023 个页表项的页号就会是 1111111111)。当然,这考虑的都是“最多”的情况,页表实际上不一定都能包含 1024 个页表项,因此页号实际上不一定都需要 10 位二进制数来表示(意思是,若页表项没有这么多,位数就不需要这么多了,可以少于 10 位),但就这道题而言,在逻辑地址的前 28 位中,可以选择 10 位用于表示某一级的页号(这一级的页表假设页表项可能有 1024 个这么多),再用 10 位表示某一级的页号(这一级的页表假设页表项可能有 1024 个这么多),再用剩下的 8 位表示某一级的页号(这一级的页表假设页表项远远少于 1024 个)。
那么,就可以考虑前面 8 位作为一级页号,紧接着的 10 位作为二级页号,再紧接着的 10 位作为三级页号,这样刚好就用完了逻辑地址前 28 位。所以回到问题,这里需要采用三级页表。
我们也可以反过来考虑,若这里不采用三级页表,而强行使用二级页表,那么肯定有某一级的页号位数超过了 10,位数超过 10 说明页表的页表项个数超过了 2^10^=1024 个,很显然就会导致一个页框放不下这一级的页表,需要跨页,这与我们前面的规定 —— 采用多级页表后,各级页表的大小不能超过一个页面,是相悖的。
最后,可以做一些题目来巩固一下。
1.若系统采用两级分页存储方式,物理内存 64mb,页面大小 1kb,页表项大小 2b,则顶级页表有多少个页表项?
这里我们可以参考之前求页表项大小的思路。物理内存 64mb,即 2^26^b,所以逻辑地址一共 26 位。这 26 位中,一部分表示一级页号,一部分表示二级页号,剩下的表示页内偏移量。
因为页面大小 1kb,也即 2^10^b,所以页内偏移量一共需要 10 位来表示。一个页面大小 2^10^b,一个页表项 2b,所以一个页面可以最多可以放 2^9^ 个页表项,又由于各级页表不能超过一个页面,所以各级页表不能超过 2^9^ 个页表项。在逻辑地址余下的 16 位中,可以用其中 9 位去表示二级页表的页号(此时该页表的页表项个数取到了最大值),剩下的 7 位表示另一个 —— 顶级页表的页号。因为顶级页表页号有 7 位,所以顶级页表可以包含 2^7^ 个页表项,即包含 128 个页表项。
2.若系统采用分页存储方式,物理内存 256mb,页面大小 1kb,页表如下: 页号 0,1,2,3,4,5,6,7,8,9,10 分别对应块号 15,16,20,28,29,30,31,32,36,38,39 则逻辑地址 1A68(16进制)对应的物理地址是多少?
为了方便计算,我们先统一用十进制计算,得到十进制的物理地址后再转换为十六进制。
1A68 按权展开转化为对应的十进制数字是 6760,对于逻辑地址 6760,可以计算它的页号和页内偏移量:
根据页号 6 找到块号 31,根据块号 31 计算块初始地址为 31*1024 = 31744,偏移量和初始地址相加得到的物理地址为 31744+616 = 32360。32360 是十进制的物理地址,转化为对应的十六进制物理地址就是 7E68。
在基本分页存储管理中,我们是将程序分为多个大小相等的物理单元(页面);而在基本分段存储管理中,我们倾向于从逻辑功能的角度去考虑,将程序分为多个逻辑功能段,每个段都有自己的段名,并且都是从 0 开始编址的。在分配的时候是以段为单位进行分配的,在内存中,段内所占空间是连续的,但是各个段之间可以不相邻。
如下图,进程 A 按照逻辑功能被划分为三个段,每个段大小不一,最后再被分配到内存中不连续的各个空间中:
由于引入了分段存储管理,所以可以将程序按照逻辑功能模块进行划分,程序员在编写程序的时候也会更加方便,程序的可读性会更高,比如:
LOAD 1,[D]|<A>
STORE 1,[X]|<B>
分别表示:将分段 D 中 A 单元内的值读入寄存器 1,以及将寄存器 1 的值存入分段 X 的 B 单元中。很显然,逻辑上是非常清晰的。这里的分段 D 和 X 都是段名,程序员在编程的时候只需要使用段名,而在编译的时候,段名会转化为对应的短号,同理,A、B 单元表示的其实是地址,编译的时候也会转化为对应的地址。
在引入分段存储管理后,逻辑地址的含义也不同了。假设仍然是用 32 位二进制数表示逻辑地址,此时,地址的前 16 位将表示段号,后 16 位表示段内偏移量:
类似的,我们需要用段表来记录某个编号段与实际物理存放位置之间的映射关系。在分页存储管理中,程序被分为多个大小相等的页面,内存被分为多个大小相等的页框,一个页面对应一个页框,因此只需要用页号和块号这两列即可记录两者之间的映射关系。推导出块的初始地址也是很简单的,只需要用块号乘以块的大小。
但在分段存储管理中,程序被分为大小不等的多个段,我们没办法像之前一样只需要块号即可推导出块的初始地址,为了准确地找出段存放在内存中的位置,我们要将段号、段长、基址 这三者作为段表的三列。这样,根据段号可以在段表中找到对应段在内存中的初始地址(基址),再结合段长,可以知道这个段具体占用了哪里的空间。
如下图所示:
还需要考虑的一个问题是每一个段表项的大小。每个段表项由段号、段长、基址构成,我们可以依次考虑每一列可能占用的空间(假设物理内存 4GB,按字节寻址):
综上,每个段表项占用了 16+32=48 位,由于一个字节 8 位,所以占用了 6 个字节, 即 6b。
转换过程我们可以直接看下图理解:
可以联系之前使用分页存储时的地址转换过程来理解,两者的基本流程其实是差不多的,只需要格外注意个别差异。
在分段存储方式中,更容易实现信息共享和保护:
在分页存储方式中,则很难:
关于访存次数,两者都是一样的:
所以结合二者之长,出现了段页式存储管理方式。
如下图,段页存储管理会首先将进程按照逻辑模块划分为多个段,针对每个段再划分为多个页;同时也把内存划分为多个页框。分配内存的时候,一个页面就对应了一个页框。
在分段存储管理中,给出一个逻辑地址,可以划分为段号和段内地址两个部分;而在段页存储管理中,段内地址还要继续细分成页号和页内偏移量两个部分。所以逻辑地址由段号、页号和页内偏移量三个部分组成。
段号的位数仍然决定了一个进程可以被划分为多少个段,而页号的位数则决定了一个段可以被划分为多少个页面,页内偏移量则决定了一个页面可以有多大,即页面/页框大小。
和分段存储管理一样,段页存储管理的地址结构也是二维的。
段页存储管理中的段表不同于分段存储管理中的段表。由于我们是将程序划分为多个段,相当于划分为多个子程序。对于每一个子程序而言,它会再次被划分为多个页面,因此每一个段(每一个子程序),它都维护着属于自己的一张页表。对于我们的段表来说,它需要记录的就是段号与段号对应段的页表之间的映射关系 —— 具体地说,包括这个页表的长度,以及这个页表所在块的块号(或者页表所在块的起始地址也可以)。
所以,段表应该有三列:段号、页表长度、页表所在块的块号。如下图所示:
关于地址转换,可以直接看下图理解:
这里,地址转换机构的运行其实是结合了分页存储和分段存储的方式。
在不采用快表的情况下,段页式存储管理需要经历三次访存:第一次访存,访问内存中的段表,找到段表中记录的页表信息;第二次访存,访问内存中的页表,找到了目标单元所在的块;第三次访存,访问内存中的目标单元。而如果是采用了快表,那么同样会有一个专门的高速缓冲寄存器保存一份副本,可以利用段号和页号去寄存器中进行检索,若检索到(命中),则无需经历第一次和第二次访存,可直接拿到块号并和偏移量进行拼接,得到物理地址,之后只需要访存一次即可。也就是说,引入快表后,省下了两次访存。