下图是计算机系统中存储层次结构:
本文主要讨论的是其中的 主存 (primary storage) 部分。计算机程序在执行过程中,所有程序必需放入内存并放入一个进程才能被执行,程序是磁盘中的一个静态的实体,通常对应着一个文件,所以的程序都是在输入队列中等待的,所谓输入队列就是磁盘上等待进入内存并执行的进程的集合。用户程序在执行之前必需经历很多步骤,这里可以以C语言为例参考我的这篇文章 C语言-用gcc指令体验C语言编译过程,如下图所示:
在程序装入内存中,存在着逻辑地址到物理地址的映射问题,即在逻辑地址中x
的地址是200
,但在物理地址中x
的地址是1200
,因为实际内存中不可能都是从地址0
开始的,如下图:
因此需要地址之间的绑定以便于指令能够找到实际的物理地址,指令和数据绑定到内存地址可以在三个不同的阶段发生:
下图是三种绑定阶段的示例图。在编译时绑定,则在编译后就明确指出操作的物理地址,即move 1156 3
表示把count
的值放到地址为1156
的位置;在加载时绑定,则在编译后就明确指出操作的逻辑地址,即move 156 3
,在加载到内存后进行变换,变换到1156
的位置;在运行时绑定,则在编译后也是只能给出逻辑地址,在加载到内存时,依然保存的是逻辑地址,但是当执行到这条语句时,再执行该指令的地址变换1000+156=1156
。
上文所提及的逻辑地址和物理地址两个概念会贯穿于整个内存管理内容中,他们的定义如下:
在上面的图中,指令move 1156 3
中的1156
就是逻辑地址,内存中的1156
地址块就是物理地址,可以发现在编译时绑定和加载时绑定中,逻辑地址和物理地址是相同的,但在运行时绑定中,逻辑地址和物理地址是不同的。
我们把将程序装入到与其地址空间不一致的物理空间,所引起的一系列地址变换过程叫做地址重定位,地址重定位分为如下两种:
上一节中讲的主要内容是如何把程序放入内存,即要实现逻辑地址到物理地址的映射,使之能够正确的运行,这一节所讲的主要内容是研究放入内存后如何给它分配内存空间,通常来说会为一个程序分配一段连续的内存空间,主要有:
对于固定分区分配方式,固定式分区是在作业装入之前,内存就被划分成若干个固定大小的连续分区,划分工作可以由系统管理员完成,也可以由操作系统实现,一旦划分完成,在系统运行期间不再重新划分,即分区的个数不可变,分区的大小不可变,所以,固定式分区又称为静态分区划分分区的方法如下:
100MB
内存,分五个分区,每个分区20MB
,只适用于多个相同程序的并发执行(处理多个类型相同的对象),缺乏灵活性,会造成内碎片问题;内碎片:比如一个分区大小是
20MB
,但是进程只需要16MB
,多出的4MB
用不上,而别的进程也无法使用,这4MB
大小的空间就叫做内碎片。
一般将内存的用户区域划分成大小不等的分区,可适应不同大小的作业的需要。当作业到来时,系统有一张分区说明表,每个表目说明一个分区的大小、起始地址和是否已分配的使用标志,分区说明表和内存分配图如下图所示:
总结来说固定分区分配的优点是易于实现,开销小;缺点是分区大小固定,会造成内碎片问题,同时由于分区总数固定,会限制并发执行的进程数目。
在动态分区分配中,分区的划分是动态的,不是预先确定的,当某个进程到来,它需要多少内存就给它分配多少内存,所以造成不同大小的分区分布在整个内存中,分配过程如下图:
在这种方式下,操作系统也是需要知道内存的状态的,可以采用空闲分区表和空闲分区链两种方式,如下图:
在可变分区分配时要设计分区分配算法来寻找某个空闲分区,其大小需大于或等于程序的要求。若是大于要求,则将该分区分割成两个分区,其中一个分区为要求的大小并标记为“占用”,而另一个分区为余下部分并标记为“空闲”。分区的先后次序通常是从内存低端到高端。同时也要设计分区释放算法把相邻的空闲分区合并成一个空闲分区。(这时要解决的问题是:合并条件的判断)。
那么怎样从一个空的分区序列中满足一个申请需要?有如下三种方式:
很显然,在速度和存储的利用上,首先适应和最佳适应要比最差适应好。
首先适应的思想是从空闲分区表的第一个表目开始查找,把找到的第一个满足要求的空闲区分配给作业,目的在于减少查找时间。通常将空闲分区表(空闲区链)中的空闲分区要按地址由低到高进行排序,它有如下特点:
外碎片:比如一块内存中依次分配了三个进程 P 1 P_1 P1, P 2 P_2 P2, P 3 P_3 P3,其中 P 2 P_2 P2 占
20MB
,此时 P 2 P_2 P2 运行结束了,释放掉了自己的内存,然后来了一个新进程 P 4 P_4 P4, P 4 P_4 P4 需要18MB
的内存,恰好刚刚释放掉了 P 2 P_2 P2 的20MB
内存可以存放,但是会有 P 4 P_4 P4 和 P 3 P_3 P3 之间会有一个2MB
的内存,由于它太小了,很难分配到它,所以这2MB
内存就叫做外碎片。
最佳适应的思想是从全部空闲区中找出能满足作业要求的、且最小的空闲分区,能使碎片尽量小。为了提高查找效率,空闲分区表(空闲区链)中的空闲分区要按从小到大进行排序, 自表头开始查找到第一个满足要求的自由分区分配,它有如下特点:
下面是一个具体的例子,加入要分配一个16KB
分区:
在可变分区系统不断地分配和回收中,必定会出现一些不连续的小的空闲区,称为外碎片。虽然可能所有碎片的总和超过某一个作业的要求,但是由于不连续而无法分配。解决碎片的方法是拼接(或称紧凑),即向一个方向(例如向低地址端)移动已分配的作业,使那些零散的小空闲区在另一方向连成一片,但分区的拼接技术,一方面要求能够对作业进行动态重定位,另一方面系统在拼接时要耗费较多的时间,下图是一个拼接的例子,存在着400K
,300K
,200K
三个外碎片,可以将其朝高地址拼接,也可以移动某个进程,也可以在中间拼接:
上面所提到的用拼接解决外碎片问题在实现的时候还是有很多障碍的,我们需要思考还有没有别的方法来解决外碎片问题,我们首先来看动态分区产生外碎片的原因是什么?这是因为这种分配要求把作业必须安置在一连续存储区内的缘故;那么如果允许物理地址空间非连续,是否可以解决?分页存储管理是解决存储碎片的一种方法,要避开连续性要求,允许进程的物理地址空间不连续。
分页的基本思想是进程的物理地址空间可以是不连续的,如果有可用的物理内存,它将分给进程。我们把物理内存分成大小固定的块。把逻辑内存也分为固定大小的块,叫做页,要求页的大小和块的大小是一样的,如下图所示:
根据上图可以看出,把逻辑内存划分为块之后,可以离散的分布在物理内存中。当然在这种情况下,操作系统需要知道哪些页是空闲的,运行一个有N
页大小的程序,需要找到N
个空的页框读入程序,还要解决的问题就是逻辑地址到物理地址的映射,我们是通过建立一个页表,把逻辑地址转换为物理地址。此外,由于内存块的划分是采用固定大小分配的,所以不可避免的会在最后一个页中产生内碎片,地址映射如下图所示:
我们知道由CPU产生的地址是逻辑地址,CPU 产生的地址被分为:
p
(Page number):它包含每个页在物理内存中的基址,用来作为页表的索引,也就是一个程序会被划分为多个块,对应在物理地址中是多个页,页号指明了具体是哪个页;d
(Page offset):同基址相结合,用来确定送入内存设备的物理内存地址,也就是一个页内有很多地址,偏移是确定具体是哪个地址。通过页号和偏移确定物理地址的过程如下图,通过页号 p
去查找页表 page table
,找到在页表中的哪个页 f
,然后把 f
取出来再加上偏移 d
,就可以映射到所在的物理地址:
总结来说分页解决了外碎片问题,但是会有内碎片,不过每个内碎片不会超过页的大小,这个开销相比之前的方法来说是可以接受的。一个程序不必连续存放,但也要求程序全部装入内存才能执行。
回顾分页的过程,如下图,在页数比较小的时候可以直接把页表放入寄存器,但当页数很多的时候,显然是要将页表放入内存中:
将页表放入内存后,要知道放在了内存的哪个地方,这里引入了两个寄存器来保存页表的位置:
在这个机制中,每一次的数据/指令存取需要两次内存存取,一次是存取页表,一次是存取数据,两次的存取显然性能不高,解决办法是通过一个联想寄存器 translation look-aside buffers (TLBs),可以解决两次存取的问题。
联想寄存器类似于一个快速缓存,每次查找一个页的时候,都记录下页和页的起始地址,当下次查询的时候可以直接在联想寄存器中寻找,找不到的时候再去查找页表,此时地址映射的过程如下,相比在第四节中最后的那张图多了一个联想寄存器的查找步骤:
我们知道寄存器的存取速度是比内存快的,因此用这种方法能大大提高查找效率,举个例子,我们假设联想寄存器的查找需要时间 ε ε ε,内存一次存取要 1 1 1微秒,我们称如果在联想寄存器中找到了对应的页地址的话,叫做“命中”,那么命中率 α α α 就为在联想寄存器中找到页号的比率,比率与联想寄存器的大小有关,此时有效的存取时间 T = ( 1 + ε ) α + ( 2 + ε ) ( 1 – α ) = 2 + ε – α T=(1 + ε) α + (2 + ε)(1 – α)=2 + ε – α T=(1+ε)α+(2+ε)(1–α)=2+ε–α。
可以带入具体数值来看一看,例如,假设检索联想存储器的时间为 20 n s 20ns 20ns ,访问内存的时间为 100 n s 100ns 100ns ,访问联想存储器的命中率为 85% ,则 CPU 存取一个数据的平均时间为 T = 0.85 ∗ 120 + 0.15 ∗ 220 = 135 n s T=0.85*120+0.15*220=135ns T=0.85∗120+0.15∗220=135ns,访问时间只增加 35%。如果不引入联想存储器,其访问将延长一倍(达 200 n s 200ns 200ns )
下图是分页地址变换机构工作原理图,首先按页的大小分离出页号和位移量,放入有效地址寄存器中,再将页号与页表长度进行比较,如果页号大于页表长度,越界中断;再以页号为索引查找页表:将页表始址与页号和页表项长度的乘积相加,便得到该表项在页表中的位置,于是可从中得到该页的物理块号;然后将该物理块号装入物理地址寄存器的高址部分;最后将有效地址寄存器中的位移量直接复制到物理地址寄存器的低位部分,从而形成内存地址:
下面是一个具体的例子,图中省略了越界判断,先分离出了页号和偏移,分别为 2
和 1C4
,然后查找页表,得到块号地址为 8
,然后将 8
放在物理地址寄存器的高位,把偏移 1C4
放在物理地址寄存器的低位,这个地址就是物理地址:
上面所讨论的分页方式有效的解决了外碎片问题,但是实际上并没有考虑用户的观点,也就是它在分页的时候都是硬性的按照等大小来划分,并不关心页中存放的是程序还是数据。本节中引入的分段方式就是一种支持用户观点的内存管理机制。
一个程序是一些段的集合,一个段是一个逻辑单位,如主程序、子过程、函数、局部变量、全局变量等等内容,在用户的眼里是把程序看作各个有机的部分,如下图:
把程序的各个部分放入内存实际上也就是把这每个部分看成各个段,然后放入内存,如下图:
在分段管理方式下要解决的问题依旧是逻辑地址到物理地址的映射问题,与分页类似,分段管理方式下也有段表,和段偏移。由于分页的每一页都是固定大小的,所以只需要起始地址,但是分段的每一段大小是不等长的,所以这里定义了两个变量:
从逻辑地址到物理地址的映射过程如下图所示,段表中保存着每一段的起始地址和线长地址,这样就在内存中唯一确定了段的地址范围:
下图是实现地址映射的物理结构流程图,其过程和分页的过程十分类似:
在分页方式中,页表是要保存在内存中的,所以当时定义了页表基址寄存器(PTBR)和页表限长寄存器 (PRLR)来指明页表的位置,同样在分段方式中,也定义了类似的两个寄存器:
此时地址变化过程如下图,首先系统将逻辑地址中的段号 S
与段表长度 TL
进行比较。若 S≥TL
,访问越界,若未越界,则根据段表的始址和该段的段号,计算出该段对应段表项的位置,从中读出该段在内存中的起始地址;然后再检查段内地址 d
是否超过该段的段长 SL
。若超过,即 d≥SL
,同样发出越界中断信号;若未越界,则将该段的基址与段内地址 d
相加,得到要访问的内存物理地址。