第三章:内存管理

内存管理的概念

内存管理的基本概念

内存的基础知识

先回顾一下一个程序的编写流程,首先我们要用 IDE 写出源代码,接着用编译程序将源代码编译为目标文件,接着还要进行链接,链接后的文件就是一个 exe 可执行文件,再双击就可以进行运行。
这里有几个连接点,首先是源代码编译为目标文件,这个过程其实就是编译器将我们写的高级语言代码翻译为机器语言的过程,本课程并不涉及。接着是链接,由于我们编写的程序一般不止一个文件,比如我们在编写 C 语言的时候可能要用到 stdio. H 这个文件,我们的程序基本都是多文件配合的,而最后的可执行文件只有一个文件,链接的过程就是将编译后的多个目标文件变成一个可执行文件。而执行一个可执行文件之前,要将 exe 文件装入内存,也就是放到内存中去,我们这里补充一点链接和装入相关的知识。
首先我们要知道,在高级语言中我们编写程序的时候是不需要管内存的,例如我写了一个 int x,这是在内存中分配了一个整型变量,我根本不需要管这个整型变量的内存地址是多少,在我看来我只需要记注 x 这个符号即可,也就是用 x 这个符号代替了内存地址。这是站在我们高级语言程序员的视角上来看的,但实际上读者应该知道,计算机对内存的任何操作本质上都要转换为对内存地址的操作,也就是说虽然我们程序员不用关心变量的内存地址,但是可执行文件里肯定都是一些内存地址,而编译器要将高级语言翻译为机器语言,也是需要关心真实内存地址的。
为了简化,我们这里讨论连续的内存空间,假设每一个进程所占用的内存空间都是连续的一整片。
在计算机的硬件层面,物理地址那就是真实的物理地址,但在我们编写程序的时候,用到的那都是逻辑地址,每一个程序员的逻辑地址都是从 0 开始的,为什么不能在编写程序的时候就给出真实地址呢?因为程序在装入内存的时候内存是动态分配的,我并不知道其真实的内存地址是什么,在这个电脑上给我的地址可能从 100 开始,但是如果拷贝到另一个电脑上的话那地址可能就是从 200 开始,如果我直接写死,那我们编译出来的可执行文件显然是毫无可移植性可言,甚至在同一个电脑上都不能运行两次。所以虽然我们的程序里面都是逻辑地址,但最后肯定都是要转变为物理地址的,逻辑地址和物理地址的转换,就是我们这里要讨论的内容。
首先我们每一个程序的逻辑地址都是从 0 开始的,也就是说任何一个程序都是默认地址是从 0 开始为他分配的,最后都会将它转换为真实的物理地址,我们有三种策略进行转换

  • 绝对装入
  • 静态重定位(可重定位装入)
  • 动态重定位(动态运行时装入)

下面一个一个介绍,绝对装入其实我们前面已经介绍过了,比如我这个程序需要 200 个内存,我的逻辑地址就是 0 到 200,在编译的时候,如果编译器可以知道我这个程序装入内存的时候一定是从 100 的地址开始装入的,那我直接在编译链接的时候将所有地址加 100 就可以了,这样得到的可执行文件(装入模块)里面存储的就是完全真实的物理地址,并且也可以运行。这种方式的劣势非常明显,首先编译器在编译之前就必须知道我这个程序装入内存的地址,然后我这个程序是没有可移植性可言的,因为我只知道在我这台电脑上,我这个程序是从这个地址开始装入的,但如果换一台电脑就不一定了,所以就只有重新去编译,所以这种方式早已被淘汰,只适用于早期单道程序的环境。
如果说绝对装入是在编译链接的时候进行的地址转换,那么静态重定位就是在将可执行文件装入内存的时候进行的地址转换,在可执行文件的内部其实还是用的逻辑地址,在程序装入内存之前,操作系统必须已经分配好了空间,所以在可执行文件装入内存之前,一定是可以知道这个可执行文件装入内存的起始位置的,例如起始位置为 100,那我在装入内存的过程中就可以把所有的内存地址都加 100 即可。这种方式相比于绝对装入来说最大的优势就是可以跨平台,我这个程序是在装入内存的时候动态修改的地址,我在我这个电脑上是从 100 开始装入那我就加 100,如果在另一台电脑上是从 300 开始装入我就可以加 300,就实现了可移植的操作。当然这种方式也不是最好的办法,因为这种静态重定位在内存里使用的依旧是真实的物理地址,这也会造成一些问题,比如这个程序在运行过程中完全不可移动,这就是一个很致命的问题,我们前面在进程管理的时候提到的挂起,就是将内存中不常用的内存换到外存,但如果采用这种装入模式,那我要再把外存的程序换回来就只有原封不动地换到原来的位置。同时其在运行过程中还不能动态申请新的内存,这显然是不好的。
接下来就是动态重定位,这种方式是现代操作系统普遍采用的方式,这种方式是在寻址操作的时候进行的地址转换,也就是说,我这种方式在可执行文件里用的是虚拟地址,我装入之后用的还是虚拟地址,我只需要在 CPU 中设立一个重定位寄存器就可以完成虚拟地址和真实物理地址的转化,比如我这个进程分配的起始地址为 100,那我就可以在这个寄存器中存上 100,每当我要进行寻址操作,也就是要对内存进行访问,我就会用我的虚拟地址去加上重定位寄存器中的 100,形成最终的物理地址。这种方式就可以让程序可以在运行中移动,比如我现在程序的起始地址为 100,我要移动到起始地址为 200 的地方去,我移动后只需要修改寄存器的值为 200 即可,这样在寻址操作的时候就会加 200,这样就完全不需要担心地址混乱的问题。

以上介绍完了装入操作,也就是在可执行文件装入内存的时候,应该怎么把可执行文件中的相对地址转换为绝对地址。接下来介绍一下链接操作链接操作在前面介绍过基本概念,链接操作其实就是针对多个目标文件,让这些目标文件同时合在一起配合工作,但每个目标程序的地址其实都是从 0 开始的,所以为了让他们同时工作,就不能简单的拼接,应该要对内存地址进行一些操作,链接的方式也主要有三种

  • 静态链接
  • 装入时动态链接
  • 运行时动态链接

首先是静态链接,这种方式就是我们比较常见的,链接之后生成一个可执行文件,本质上就是对逻辑地址的拼接,比如我有两个模块需要使用静态链接进行链接,如果我第一个模块的地址范围是 0 到 99,那我第二个模块的地址就必须从 100 开始,也就是需要将第二个模块的地址都加上 100 再进行拼接,最后形成一个可执行文件。
接下来是动态链接,不管是运行时动态链接还是装入时动态链接,其可执行文件里都不会包含模块里的代码,动态链接允许程序在运行时将所需的库(可能包含多个模块)动态地加载到内存中。也就是说,比如我编写了 5 个模块,他有可能生成 5 个动态链接库,这五个动态链接库的逻辑地址都是从 0 开始,都是独立的,这个时候如果我程序里用到了这五个库,那库里的代码都不会编译进 exe 文件,这个克制行为文件此时叫做动态可执行文件,这个文件里不包含库文件,也就是说只包含程序自己的代码,如果我用到了 stdio. H 这个文件,我不需要像静态链接一样还要把 stdio 这个文件直接拼接过来,而是根据需要进行调入。
如果是装入时的动态链接,那我这个程序在装入时就会将所有用到的动态链接库都装入到内存中,在装入的过程中完成模块之间逻辑地址的链接。这种方式会将所有模块都装入到内存中,但实际上并不是所有模块都需要使用,比如某些模块的函数我可能要根据条件来确定要不要使用,这个时候就可以使用运行时动态链接,这种方式可以在运行的时候动态载入,如果我这个程序在运行的过程中需要用到某个模块,我就将这个模块装入,并且完成链接,如果有一个模块从头到尾都没有被使用到,那么就不需要对其进行装入,这种方式明显更加灵活。

链接本质上是确定一个完整的逻辑地址,并不涉及到真实的物理地址,真实地址和逻辑地址的转换操作是在装入阶段需要考虑的问题

内存管理的概念

本节主要介绍内存管理的基本概念以及本章大体的逻辑框架,也就是要知道我们本章要学什么。操作系统对内存的管理主要可以分为四个方面

  • 内存空间的分配与回收
  • 逻辑上的内存扩充
  • 逻辑地址和物理地址的转换
  • 内存保护

首先,操作系统要对内存资源进行管理,最容易想到的就是内存的分配和回收,因为操作系统要给每一个进程分配空间,操作系统想要为进行分配空间的前提就是得知道现在系统中哪些内存空间是空闲的,哪些是已经分配出去的。同时,当一个进程结束的时候,操作系统也需要将其内存空间回收,这就是操作系统对内存空间的分配和回收。
上一节中,我们介绍过逻辑地址和真实地址的概念,介绍了三种装入方式,绝对装入,可重定位装入和动态运行时装入,其中绝对装入用于单道批处理系统,这个时候还没有操作系统的概念,这种装入方式本质上是在编译过程中进行的,所以和操作系统完全没有关系。而可重定位装入则是在程序装入内存的过程中将程序的逻辑地址转变为物理地址,这个过程其实是由一个叫装入程序的程序负责的,而装入程序本身是操作系统的一部分,所以这种方式是需要操作系统支持的,这种方式多用于早期多道批处理系统。而现代操作系统所使用的都是运行时装入,它不仅需要操作系统支持,还需要在 CPU 内部设立一个重定位寄存器,所以也需要硬件层面的支持。可见,只要是重定位的方式,不管是静态重定位还是动态重定位,将逻辑地址转换为物理地址都需要操作系统层面的支持,所以操作系统需要有这种机制来进行逻辑地址和真实地址转换。
内存保护则是一种处于安全的考虑,前面我们提到过进程通信,我们介绍进程通信概念的时候应该提到过,进程之间的内存空间是独立的,我的进程只能访问我自己的内存空间,不能去访问你的内存空间,我们两个进程想要进行通信只有通过进程通信的方式,比如共享存储,消息传递,管道等方式。当然,也很好理解,如果我这个进程能操作其他进程的内存空间这显然是不安全的,所以操作系统就要实现内存保护这样一个功能,让进程只能访问自己的内存空间,内存保护主要有两种实现方式,第一种方式是在 CPU 里设立一对上限和下限寄存器,用于保存当前进程的内存空间,当然这个数据平时是存储在 PCB 上的,只有在 CPU 上运行的时候才会放到这一对寄存器上,每当进程需要访问物理地址的时候,都会检测当前是否越界访问,从而就可以判断该进程的内存操作是否合法。还有一种实现方式是设立重定位寄存器和界地址寄存器,重定位寄存器就是当前进程内存的起始位置,而界地址寄存器则是当前进程最大的逻辑地址,例如重定位寄存器为 100,界地址寄存器为 20,那么这个进程可以访问的地址空间就是 100 到 120,当进程要访问内存的时候,操作系统就可以根据这样,两个寄存器判断其是否越界。
内存管理还需要实现逻辑上的内存扩充,这和我们前面提到的操作系统虚拟性有一点关系,这样一功能将通过覆盖交换技术和虚拟内存详细介绍,此处不多做概述。

进程的内存映像

image.png
在操作系统中,内存映像就是一个进程在内存中的布局,主要考察点就是某个数据在内存中的哪些位置,例如局部变量就是存放在栈内存的,new 关键字分配的内存就是存放在堆内存的,而程序代码本身作为只读的,就存放在只读代码/数据区,属于常识性内容,难度并不大。

覆盖和交换

覆盖交换技术是用来实现内存扩充的,当然这是逻辑上的扩充,并不是真的给他扩充内存。

覆盖技术

在早期,内存资源是非常宝贵的,一个计算机可能就只有几 M 的内存,所以经常出现内存不够的情况,这种情况下就引入了覆盖技术来解决这个问题。
覆盖技术是将程序分为多个段,也就是多个模块,常用的段常驻内存,不常用的段只在需要的时候调入内存。
同时还需要将内存分为一个固定区和若干个覆盖区,要常驻内存的段就放入固定区,调入固定区的数据不再调出,除非运行结束。覆盖区则是存放不常用的段,需要用到的时候调入,不需要用到的时候调出。
image.png
如果我们的程序有以上调用结构,A 可以调用 B 和 C 模块,B 模块调用 D 模块,C 模块调用 E 和 F 模块,同时 B 和 C 之间是依次调用的,也就是 B 和 C 模块不可能同时存在于内存中。此时,由于 A 模块作为主模块,肯定是要常驻内存的,所以 A 模块放入固定区,而 B 模块和 C 模块本身由于不可能同时被调用,所以可以让他们两个共享一个 10 K 的内存空间,同理,D, E, F 也肯定是不会同时在内存中的,所以可以让他们共享一个 12 K 的内存空间。
这就是内存覆盖技术,本质上就是把那些不可能被同时访问的程序段共享一个覆盖区。

注意,这里说的都是程序段,也就是程序代码,不是数据。

这种技术存在明显缺陷,因为计算机并不知道我们这个程序员的逻辑结构是什么样的,所以这个覆盖结构就需要程序员来声明,并且这个覆盖是由操作系统根据用户申明自动完成的,所以对用户是不透明的,也就无法调试,也就增加了程序员编程负担。

交换技术

交换技术就是我们前面提到过的中级调度,或者说内存调度。将内存中某些无法运行的进程换出外存,同时把外存中可以运行的进程再换入进程,这就是交换技术。

被换出外存的进程处于挂起态,这是前面学过的知识点。

由于交换技术本身就是中级调度,这个知识点已经介绍过,这里主要考虑三个问题

  • 当内存数据换出外存的时候,应该存储到外存的什么地方
  • 什么时候应该进行交换
  • 应该选择哪些进程换出

首先回答第一个问题,也就是当内存数据换出外存的时候,应该存储到外存的什么地方,如果一个系统支持交换技术,那么这个系统就会把外存分为两个部分,分别为文件区和对换区,文件区就用于存储我们的文件,对换区则是为交换技术准备的,被换出的进程就会放在对换区。文件区和对换区本身虽然都是外存的一部分,但是其分配方式存在明显区别,文件区主要是为了存储更多的文件,所以使用的是离散分配方式,而对换区主要追求对换速度,所以采用连续的分配方式。总之,对换区的 IO 速度高于文件区。
接下来第二个问题,什么时候应该进行交换,很简单,如果内存资源缺乏就可以进行交换,而内存资源比较充足的时候就暂停交换。至于如何衡量内存资源是否充足,可以用后面会介绍到的缺页率。
接下来第三个问题,应该选择什么进程换出,首先,对于阻塞的进程,它是肯定不会被运行的,所以一定是优先换出的,其次,对于一些优先级低的进程也可以考虑换出,但这并不是绝对的,比如一个优先级低的进程刚被调入内存马上就被调出,这显然不合理,因为这样频繁换入换出也需要成本,所以我们可以考虑一个进程在内存中的驻留时间,只有驻留时间比较长并且优先级比较低的进程才考虑换出。 ^49649e

PCB 必须常驻内存

有的读者可能会把覆盖和交换技术弄混,可能觉得这两者都是换入换出,但本质上是不一样的,覆盖技术的在一个进程内部的,是一个进程中某些模块的换入换出,而交换技术的换入换出则是针对整个进程的换入换出。

连续分配管理方式

概念

连续分配管理方式首先他是一个分配管理方式,也就是用来实现内存的分配和管理的,这里的连续分配管理,说大白话就是分配的内存空间都是连续的,或者说,对于每一个进程,分配的内存空间都是连续的,这就是连续分配管理方式。
而连续分配管理方式一共有三种,分别是单一连续分配,固定分区分配,动态分区分配,下详细叙述。

单一连续分配

单一连续分配将内存分为了两部分,如下图所示
image.png
这种分配方式要求内存中只能有一个用户进程,所以当有用户进程的时候,他会把一整个用户区的空间全部分配给这个用户进程
image.png
也就像这样,此时用户区已经有了用户进程,那这个用户进程就拥有除了系统区以外所有区域内存的使用权,也就是用户进程独占整个用户区空间。
这种方式的优点就是实现简单,并且没有外部碎片,同时,由于用户进程是独占整个用户区的,所以根本不需要使用内存保护,整个用户区全都是自己的,根本不会越界操作,哪怕把系统区的数据改了,其实也可以通过重启来还原。
这种做法的缺点也很明显,他把一整个用户区都分配给用户进程了,而用户进程其实也可以通过覆盖技术来从逻辑上扩充内存,所以很难用完整个用户区,这导致了内存利用率非常低下,并且这种方式要求单个用户进程独享整个用户区,这也就决定了这种方式只能用于单用户单任务的操作系统中,同时这种方法还有内部碎片。
这里介绍一下外部碎片和内部碎片的概念
内部碎片:分配给用户进程,但用户进程没有使用的内存空间。
外部碎片:没有分配给用户进程,但由于空间太小,又难以分配给用户进程的内存空间。
这两个概念在后面的动态分区分配那里读者应该会有更深入的理解,读者这里只需要了解什么是内部碎片即可,比如我给一个进程分配了 100 M 的空间,但这个进程只使用了 60 M,那么剩下的这 40 M 没有使用的空间就属于内部碎片,内部碎片是分配给进程但是没有使用了,但外部碎片本身就是没有被分配的空闲区间。

固定分区分配

为了支持多道程序并发运行,或者说为了让内存中能够同时装下多个程序,我们就研究出了固定分区分配方式。
固定分区分配方式,就是将整个用户区划分为多个固定大小的区域。
固定分区分配方式又可以分为两个类型,一个是大小相等,一个是大小不等,如下图所示
image.png
分区大小相等则会将用户区分割为大小相等的多个分区,而分区大小不等则会分割为大小不等的分区,至于分区的具体容量需要根据需求来设计。
很明显,分区大小不等的方式会比分区大小相等的方式更加灵活,但如果要同时控制多个相同对象的进程,则完全可以考虑采取分区大小相等的固定分区分配方式。
这种分配方式中,由于分区大小是固定的,我可以很方便地用一个线性表来把这些分区管理起来,如下图所示
image.png
当系统要给一个进程分配空间的时候,就需要在分区表中找到一个满足大小且没有被分配的空间,将给空间分配给他,同时修改分区表中该分区块的状态。
这种列固定分区分配方式实现起来依然非常简单,同时也不存在外部碎片,但是当应用程序太大的时候,可能所有分区都无法满足该进程的需求,所以不得不采用覆盖技术来解决,而覆盖技术的换入换出则会导致性能的降低,同时,这种方式也会产生很多内部碎片,内存利用率也很低。

动态分区分配

动态分区分配又称为可变分区分配,这种分配方式不会像固定分区分配那样事先划分好多个分区,而是在进程装入的时候根据进程大小动态建立分区,保证给他的分区刚好可以满足他的需要,所以这种分区方式分区的大小和数量都是不固定的。
使用这种分配算法第一个要考虑的问题就是如何管理这些分区,我们这样考虑,每当我要给一个进程分配内存空间的时候,我都需要从空闲的部分中找到一个满足大小的空闲区间,分配给该进程,所以其实我们只需要把空闲分区管理好即可,对于空闲分区的管理,主要有空闲分区表和空闲分区链两种,先介绍空闲分区表。
image.png
这就是空闲分区表,分别要记录分区号,分区大小,分区的起始地址,这样就可以实现对空闲分区的管理,这个空闲分区表不一定要按照分区的起始地址来排序,它的顺序应该是根据我们的需求来的。
接下来是空闲分区链,空闲分区链本质上是将所有的空闲分区按照链式方式连接起来
image.png
也就是像这样,用语言来描述就是,在每一个空闲分区的开头,都设立一个指针指向上一个空闲分区;在每一个空闲分区的末尾,设立一个指针指向下一个空闲分区,这里的上一个和下一个也不是非得是地址的上一个和下一个,其顺序也是按照需求来。
同时,还需要所在空闲分区的起始位置保存分区大小等信息。
这样,当我们拿到第一个空闲分区的指针,我们就可以对整个空闲分区进行管理,我们可以从空闲分区的起始部分读出空闲分区的大小,用空闲的起始位置加上空闲分区的大小就可以找到空闲分区的末尾,而末尾又可以找到指向下一个空闲分区的指针,这样就可以找到下一个空闲分区,而要找到上一个空闲分区,则直接从分区起始位置找到上一个分区的地址即可。
这种方式将分区从逻辑上按照双向链表的方式存储起来,但请读者注意,这种方式并不等同于数据结构中的双向链表,他并不是真的建立了一个双向链表来存储,而是在真实分区的开头和末尾设立了指针,从逻辑上将这些真实的空闲区用指针连接起来了。
以上我们介绍了如何管理这些空闲分区,接下来就是分配和回收相关的问题了,首先介绍分配
当我们要给一个进程分配内存空间的时候,我们应该如何分配,我们假设空闲区见的数据结构如下
image.png
我们假设一个进程需要 4 M 的内存空间,由于分区 1 的大小可以满足需求,那我们可以将分区 1 的内存分 4 M 给他,此时只需要让分区 1 的大小减 4 M,同时让分区 1 的起始地址加上 4 M 即可,这样就可以实现对分区大小的分配。
以上这种情况,是空闲分区大小大于需要的内存空间的情况,我们可以观察到,由于 3 号分区也可以满足 4 M 的需求,所以我们完全可以把三号分区分配给这个进程,此时 3 号分区的所有空闲区域都分配完了,这个时候只需要把三号分区删除即可。

空闲分区链类似,无非就是在逻辑上对链表进行操作

综上,我们进行归纳,如果我要将空闲区域 A 分配给进程 P,那么首先要保证空闲区域 A 的大小要大于进程 P 所需要的内存空间的大小,如果空闲区域 A 的大小大于进程 P 所需要的大小,那么我们只需要修改空闲区域 A 的分区大小,起始地址等信息即可,但如果空闲区域 A 的大小正好等于进程 P 所需要的大小,那我直接在空闲分区表中将空闲区域 A 删除即可。
讨论完分配,下一个问题就是回收,也就是当一个进程运行结束的时候,它所占用的内存空间就会空闲,我们肯定要把这个内存空间加入到我们的空闲分区表里,但是不能直接给空闲分区表里加一项,这里的加入其实是需要分四种情况的,比较复杂。
第一种情况,就是该进程内存区域前面就是空闲分区,但后面不是空闲分区,这个时候我只需要将前面那个空闲分区进行扩充即可,不需要单独在空闲分区表中增加一项。
第二种情况是该进程前面的区域不是空闲分区,但后面的区域是空闲分区,这个时候只需要将后面的分区向前进行扩充即可,也不需要在分区表中新增一项。
第三种情况就是该进程前后都是空闲分区,这个时候当该进程内存区被释放的时候,它前面,后面,以及自己,这三个空闲的内存区域就可以合并成一个内存区域,所以只需要从前面或者后面任选一个进行扩充即可,例如选择前面的空闲区间进行扩充,那么就可以把自己以及自己后面的空闲区见都包含进前面的内存空间去,之后再把后面的空闲区间从空闲分区表删掉即可
第四种情况就是前后都不是空闲区域,这个时候只有在空闲分区表中新增一项。
综上,我们其实可以很简单地归纳一下,如果该进程释放后,会存在相邻的空闲分区,那就要将他们合并,抓住这个本质即可。
这种分配方式由于是根据进程的需要来分配的,也就是说进程需要 10 M,那我就分配 10 M,换句话说,给进程分配的空间都是进程能用到的,所以这种方式肯定是不会产生内部碎片的,但会产生外部碎片,这里就可以详细介绍一下外部碎片了。
image.png
在这种情况下,即便内存中还有 20 M 空间,也无法满足进程 1 的需求,因为这 20 M 的空间分得太散了,这些小空间虽然都是空闲的,但很难被利用起来,这就是外部碎片。
我们为了解决这个问题,很自然的想到一个办法,就是将内存中的进程挪位置,腾出 20 M 完整的内存空间,从而满足进程 1 的需求,这种方式就是紧凑(拼凑)技术。
image.png
我们这样挪动进程的位置,腾出 20 M,就可以满足进程 1 的需求了,这种紧凑技术很显然是要挪动进程位置的,根据我们前面的学到的知识,在装入方式那里,我们学过绝对装入,静态重定位和动态重定位,很显然的是,动态重定位是最方便实现进程这样挪动位置的,只需要改变进程 PCB 中的起始地址即可,或者说重定位寄存器。

注:重定位寄存器存放的就是进程的起始地址,进程的起始地址就存放在 PCB 中,进程上处理机的时候就会把起始地址放到重定位寄存器中
读者需要理解这三种分配模式,在理解的基础上关注一下外部碎片和内部碎片

动态分区分配算法

在上一节中,有的读者可能会有这样一个疑问,如果我有一个进程需要分配,从理论上来说,只要空闲块的空间大于我进程的需求,那我就可以把这块空闲块分配给他,在实际系统中,一般都不止一个空闲块可以满足这个需求,所以当多个空闲块都可以分配给这个进程的时候,我应该按照什么样的策略来选择空闲块,这就是动态分区分配算法要考虑的事情。

首次适应算法(First fit)

首次适应算法要求我们把空闲分区表或者空闲分区链按照地址递增的次序排列,每次要分配的时候都顺序查找空闲分区表或者空闲分区链,找到第一个能满足大小的空闲分区分配给他。

最佳适应算法(Best Fit)

最佳适应算法要求把空闲分区表(链)按照容量递增的次序排序,每次分配的时候按照顺序查找空闲分区表(链),找到第一个满足大小的空闲分区。
这种分配方式相当于从所有空闲块中找到能满足大小需求的最小的那个块分配给他。

最坏适应算法(Worst Fit)

最佳适应算法要求把空闲分区表(链)按照容量递减的次序排序,每次分配的时候按照顺序查找空闲分区表(链),找到第一个满足大小的空闲分区。
这种分配方式和最佳适应算法相反,最佳适应算法是找最小,最坏适应算法是找最大。

邻近适应算法(Next Fit)

该算法是对首次适应算法的改版,该算法要求将空闲分区表(链)按照地址递增的次序排序,当需要分配的时候,往后依次寻找,当找到满足条件的空闲块分配成功之后,指针不回溯,等下一次需要分配的时候就从上一次分配结束的位置往后找。

几种算法的评价和对比

最好适应算法:该算法每次都找到一个满足条件的最小的空闲块,看起来很合理,并且似乎空间利用率很高,但实际上并不是这样,这种算法会产生大量的外部碎片,例如我要给一个 24 M 的进程分配空间,如果我使用这个算法,很有可能找到的是 25,26 这种比 24 大不了多少的内存块,如果我找到了一个 25 M 的空闲块分配给他,那剩下的那 1 M 内存空间就非常难以再被利用,哪怕现在有一个 512 K 的进程用到了,那剩下的 512 K 也再难以被利用,所以这就导致了大量细小的外部碎片,并不是什么好办法。同时,由于我们要求空闲分区表/链需要按照大小顺序排序,空闲块空间大小在分配后会发生变化,这使得我们必须每次分配完之后都要更新空闲分区表/链的顺序,如果读者有一定的数据结构基础,应该直到,比起修改数据,排序的代价显然是要大很多的,所以这种算法效率也不高。
最坏适应算法:为了避免最好适应算法产生外部碎片的问题,就有了最坏适应算法,最坏适应算法的逻辑是每次都找到最大的那一个分配,这种算法不会产生很多的细小外部碎片,但还是存在问题,这种算法每次都要把大空闲块进行分配,导致大片连续的空闲块会被很快消耗掉,如果这个时候有一个大进程,就很有可能没有内存分区可用了,这就是最坏适应算法相较于最好适应算法的劣势。同时,这种算法要求将空闲分区表/链按照大小排序,所以每次分配都需要重新排序,系统开销也很大。
首次适应算法:这种算法逻辑是最简单的,就是按照地址顺序从头到尾来找,该算法每次都会从头开始找,也就是每次都要从低地址开始往后找,这种算法逻辑相当简单,从效率上来看,由于是按照地址的顺序来排列的,所以不需要每次都重新排序,效率会高于最好适应算法和最坏适应算法。
临近适应算法:这种算法是首次适应算法的改版,相当于是将原来的空闲分区表/链首尾相连,每次分配都从上次分配结束的位置往后寻找,这是因为首次适应算法每次都要从头开始找,每次都要去找低地址的小分区,所以低地址可能会产生很多细小碎片,每次分配的时候都要经过这些碎片,这可能导致系统开销的增加。但是这种临近适应算法也存在一些问题,他也存在最坏适应算法那样的缺点,也就是很难保存出大块的分区。同时,这种方式效率也是高于最佳适应算法和最坏适应算法的。
接下来做一个整体分析,最好适应算法的问题是会导致大量的细小外部碎片,而最坏适应算法的问题是保留不下来大块的空间,邻近适应算法的问题也是保存不了大量的外部空间,而首次适应算法虽然每次都要从头往后找,相比于最佳适应算法而言,更不容易产生细小碎片,相比于最坏适应算法和邻近适应算法而言,更容易将高地址上的大块空间保留下来,而首次适应算法和临近适应算法本身又要比最好适应算法和最坏适应算法的效率更高,所以综合来看,首次适应算法这种朴实无华的方式反而效果更好。

算法算法思想分区排列顺序优点缺点
首次适应从头到尾找适合的分区空闲分区以地址递增次序排列综合看性能最好。算法开销小, 回收分区后一般不需要对空闲分区队列重新排序
最佳适应优先使用更小的分区, 以保留更多大分区空闲分区以容量递增次序排列会有更多的大分区被保留下来, 更能满足大进程需求会产生很多太小的、难以利用的碎片; 算法开销大回收分区后可能需要对空闲分区队列重新排序
最坏适应优先使用更大的分区, 以防止产生太小的不可用的碎片空闲分区以容量递减次序排列可以减少难以利用的小碎片大分区容易被用完, 不利于大进程; 算法开销大 (原因同上)
邻近适应由首次适应演变而来, 每次从上次查找结束位置开始查找空闲分区以地址递增次序排列 (可排列成循环链表)不用每次都从低地址的小分区开始检索。算法开销小 (原因同首次适应算法)会使高地址的大分区也被用完

非连续分配管理方式

基本分页存储管理

前面讨论的均为连续分配,也就是给一个进程分配一大块连续的空间,我们也可以用非连续分配方式,将进程离散存储在内存中。

首先介绍分页存储,分页存储会将内存划分为一个一个大小相等的页框(页帧/内存块/物理块/物理页面),每个页框有一个编号,称为页框号(页帧号/内存块号/物理块号/物理页面号),页框从 0 开始

我们也要将进程的逻辑地址也要划分为和页框大小相等的页面,也称页,每个页也有一个编号,称为页号,页号也从 0 开始。

注意区分页框和页面的区别,页框是将内存划分为大小相等的一个一个块,而页面是将某一个进程的逻辑地址划分为大小相等的块。

此时页框大小和页面大小相等,我们就可以把进程中的一个页面放到内存中的一个页框中,这样就可以实现一个一一对应的关系,例如我可以将进程的 2 号页面放到内存中 8 号页框中。可见,对于页面之间我们是离散存储的,但是每一个页面内部也是连续存储的。

页框的设计不宜过大,否则可能产生很多内部碎片

我们现在知道了如何将进程离散存储在内存中,也就是将页面存入页框中,一个页框能存储一个页面,实现这样一个对应关系,为了让我们能找到每个页面存储在哪一个页框中了,我们自然想到要建立一个页表来存储这种一一对应的关系。

页号可以看做页表下标,所以不用存储,页表的表长就是页面的个数,页号只需要存储对应的页面存储到哪个页框中了,所以页表存储的其实就是页框号,当我们想要找到某个页面存储在哪一个页框的时候,我们就可以根据页面号在页表中找,找打对应的页框号即可。

页框号的范围也要会求,例如一个 4 GB 的内存,页面大小为 4 KB,那么页框个数就是 2 32 / 2 12 = 20 2^{32}/2^{12}=20 232/212=20 个内存块,所以就需要用 20 位的空间来存储,由于内存是按字节组织的,1 B=8 位,所以至少需要 3 B 的空间才可以存的下,所以在页表中每一个页表项就占 3 B

1 G B = 2 30 B     1 M B = 2 20 B    1 K B = 2 10 B 1GB = 2^{30}B\ \ \ 1MB=2^{20}B\ \ 1KB=2^{10}B 1GB=230B   1MB=220B  1KB=210B

页表存储了页面号和页框号的映射关系之后,我就可以通过页框号乘以页面的起始地址得到页框的物理地址,这里务必注意,页表存储的是页框号,而不是页框的起始地址。

接下来最后一个问题就是如何实现逻辑地址和物理地址转换,我们可以归纳为以下几步

  1. 得到逻辑地址
  2. 根据逻辑地址得到其所在页号和页内偏移量
  3. 根据页表查询到该页号所在的页框号
  4. 根据页框号找到该页框的起始地址
  5. 根据页内偏移量加上页框的起始地址得到物理地址

在这五个步骤中,第一步,第三步,第四步,第五步我相信都不用解释或者前面解释过,这里只解释一下第二步。

首先我们如何根据逻辑地址找到其所在页和页内偏移量,这个其实非常简单,我们知道逻辑地址都是从 0 开始存储的,所以我们只需要将逻辑地址除以页面大小(向下取整),就可以得到页号,只要将逻辑地址对页面大小取模就可以得到其页内偏移量,这一点应该很好理解。

接下来考虑一种特殊情况,就是页面大小为 2 的整数次幂这种情况,这种情况下二进制就有一个得天独厚的优势,例如页面大小为 2 的 12 次幂,那么在地址中,0 到 11 位,也就是低 12 位表示的就是这个字节的页内偏移地址,而剩下的高位表示的就是页号,所以在这种情况下内存地址就被天然拆分成了两部分,如果页面大小为 2 的 n 次幂,那么低 n 位为页内偏移地址,其他高位表示页框号,我们只需要将一个页面的页框号和页内偏移地址直接拼接起来就可以得到真实的物理地址。

解释一下为什么有这样的性质,在二进制中,除以 2 相当于右移 1 位,乘 2 相当于左移一位,我们要根据页框号算出页框的真实地址,其实就是页框号乘以页面大小,假设页面大小为 2 的 n 次方,相当于就是左移的 n 位,所以除了低 n 位以外,高位表示的就是页框号,而页内偏移地址是通过逻辑地址对页面大小,也就是 2 的 n 次方取模算出来的,也不会超过 2 的 n 次方,所以他们加起来表示的就是真实物理地址

除此之外,将页面设置为 2 的整数次幂还可以提高运行速度
image.png

基本地址变换机构

本节主要介绍基本分页存储管理中将逻辑地址转换为物理地址的详细过程

首先,上一节中我们提到,我们为了实现逻辑地址到物理地址的转换,需要设计一个页表,用来保存逻辑地址和物理地址的映射关系,这个页表本身是存储在内存中的,并且是每个进程都有一个,所以在每个进程 PCB 中,都应该存放两个信息,分别是页表起始地址 F 和页表长度 M。当进程被调度的时候,系统会将 PCB 中的页表起始地址和页表长度放到系统中的页表寄存器(PTR)中。

这里的页表长度 M 表示的是该进程页面的个数

有了页面和页表长度,我们下面详细说一下逻辑地址和物理地址的转换流程

  1. 得到逻辑地址
  2. 根据逻辑地址计算页号 P 和页内偏移量 W
    1. 用逻辑地址除以页面大小得到 P,用逻辑地址对页面大小取模得到 W
    2. 如果页面大小刚好是 2 的 n 次方 B,那么逻辑地址二进制中的低 n 位为页内偏移量 W,其余高位为页号 P
  3. 将页号 P 和页表寄存器中的页表长度 M 作比较,如果 P 大于等于 M,表示溢出,会产生越界中断。(页表长度表示的是该进程页面的总个数,页号下标从 0 开始,所以 M 个页的合法下标范围为 0 到 M-1)
  4. 根据页号 P 找到其对应的页表项地址,页表项是一个顺序表,其地址为页表基地址 F+页号 P*页表项的大小(页表项的大小根据页框数量来确定,页表项表示的是页框号,所以必须保证能表示所有页框)
  5. 取出对应页表项的值,就是页框号,将页框号乘以页面大小,得到页框的基地址
  6. 将页框基地址加上前面得到的页内偏移地址 W,得到真实物理地址

以下给出图示
image.png

页大小可以决定页内偏移量的大小,所以如果给了一个题目说页内偏移量有 10 位,要知道页面大小为 2 的 10 次方

接下来对页表项的物理存储做一个探讨,我们将内存分为大小相等的页框之后,页表项本身也必须存放在页框中,我们假设有这样一个例子,内存大小为 4 GB,页面大小为 4 KB,一共有 2 的 20 次方个页框,也就需要 20 位来表示,所以页表项至少要 3 个字节,我们假设每个页表项占 3 个字节,一个页框大小为 4 KB,若我们存放了 1365 个页表项后,会剩下 1 B ,由于 1 B 无法存储下一个页表项,所以下一个页表项会跳过这 1 B 存储到下一个页框中,这种情况对于我们地址变换是不利的,我们希望其是连续存储的一个数组,这样才可以方便地实现随机存取,所以我们可以进行拓展,将一个页表项拓展为 4 B,通过拓展页表项让每一个页面恰好可以存放下整数个页表项,虽然看起来每个页表项都浪费了最高位的 1 B 空间,但实际上这方便了我们进行寻址操作,利大于弊。

image.png

在页式管理中,由于页内偏移量和页号都是可以直接通过逻辑地址算出来的,我们只通过一个逻辑地址就可以自动算出其物理地址,我们称页式管理中地址是一维的。

带有快表的地址变换机构

根据前面所学,我们每一次进行逻辑地址和物理地址的转换都需要去内存访问页表,而逻辑地址和物理地址的转换非常频繁,我们自然会想办法优化。

优化思路就是弄一个快表,这个快表是访问速度比内存快很多,属于高速缓存,块表中存放的是页表项的副本,我们可以将最近访问到的页表项放到块表里,下一次被访问到的时候就可以直接从块表查,由于块表的速度比内存快很多,所以效率也会提高很多。引入快表后,原来内存中的页表就称为慢表。

由于高速缓存价格昂贵,所以块表容量远小于页表,只能存下部分页表项

引入块表之后,逻辑地址转物理地址的变换机构就可以如下描述

  1. 给出逻辑地址
  2. 通过逻辑地址算出页号和页内偏移地址
  3. 判断页号和页表寄存器中的页表长度判断是否越界
  4. 去块表中查询页号是否存在,如果存在,称为命中,则直接可以得到页框号,再根据页框号算出页框基地址,从而算出真实物理地址
  5. 如果块表中查询不到改页号,我们称为未命中,此时就需要根据页表寄存器中的页表基地址,再通过页表基地址,页号,页表项长度算出该页号所对应的页表项地址,得到页框号,再通过页框号和页框大小算出页框基地址,再加上偏移地址,得到真实地址。着重注意:由于系统中有块表机制,所以我们还需要将该页的页表项加入到块表中,如果块表已满,则需要通过一些页面置换算法进行替换)

虽然快表很小,但是其命中率其实是很高的,根据局部性原理,快表的命中率通常在 90%以上。

我们假设一个系统中,访问内存需要 100 us,访问块表需要 1 us,假设命中率为 90%,我们计算一下访问逻辑地址的平均耗时。
有快表:(1+100)*0.9+(1+100+100)*0.1=111 us
没有快表:每次访问都需要访问一次页表 (100),再访问一次物理内存 (100),平均耗时为 200 us

有的系统可能支持块表和页表同时查找,这种情况下平均耗时为 (1+100)*0.9+(100+100)*0.1=110.9
我们前面给出的例子是先查找块表,块表查不到的情况下再查找内存中的页表,同时查找顾名思义就是同时查找块表和慢表。

最后补充一点,就是为什么块表的命中率会这么高,这里涉及到局部性原理
时间局部性:如果执行了程序中的某条指令,那么不久后这条指令很
有可能再次执行;如果某个数据被访问过,不久之后该数据很可能再
次被访问。(因为程序中存在大量的循环)
空间局部性:一旦程序访问了某个存储单元,在不久之后,其附近的
存储单元也很有可能被访问。(因为很多数据在内存中都是连续存放
的)

有关访存次数,没有快表的情况下,查询页表需要访问一次内存,查询到后访问真实的物理地址又会产生一次访存,所以是两次访存,如果有快表,若在快表中查到了那就可以直接得到物理地址,只需要一次访存,否则还是得去查慢表,还是要两次
image.png

多级页表

单级页表主要存在两个问题,我们假设页面大小为 4 KB,页表项长度为 4 B,逻辑地址为 32 位。对于这个 32 位的逻辑地址,由于页面大小为 4 KB,所以页内偏移量最多就是 4 KB,也就是 2 的 12 次方 B,所以这 32 位中,低 12 位表示的就是页内偏移量,高 20 位用来表示页号,所以一个进程最多可能有 2 的 20 次方个页号,也就有 2 的 20 次方个页。假设一个进程真的就拉满了,有 2 的 20 次方个页面,那其页表项就也有 2 的 20 次方个,每个页表项占用 4 B,2 的 20 次方个页表项一共会占用 2 的 22 次方 B,映射到内存中就是 2 的 10 次方个页框才能存的下,而为了实现随机访问,我们都是将页表分配在连续的内存空间中的,这意味着我要分配 2 的 10 次方个连续的页框才可以存的下这一个进程的页表,这显然不合理。这就是单级页表的第一个问题,也就是页表本身需要过大的连续空间。

第二个问题是,根据局部性原理,其实很多时候我们只需要访问部分页面就可以正常运行了,内存资源相对也比较宝贵,我们其实也没有必要将页表常驻内存,我们只需要将部分页表项常驻内存即可。

有了这两个问题,我们就对单级页表进行优化,很容易想到的思路就是,因为单级页表本身就需要占用很多连续的内存,所以单级页表本身也可以再进行二次分页,来将单级页表离散存储在内存中,这就是二级页表。

先回顾单级页表的概念,单级页表将逻辑地址分成两部分,页号和页内偏移量,通过页号可以在页表中找到页框号,从而算出物理地址,物理地址拼接上偏移地址即可得出最终物理地址。

二级页表对页表本身进行了分页,举个例子,假设逻辑地址为 32 位,页表项大小为 4 B,页面大小为 4 KB。由于页面大小为 4 KB,所以页内地址占了 12 位,即低 12 位为页内地址,高 20 位为页号,我们假设这个进程页表长度拉满了,也就是有 2 的 20 次方个页面,页表项中也就对应有 2 的 20 次方个页表项,而每一个页面都可以存放下 4 K/4=1 K 个页表项,所以我们可以将这 2 的 20 次方个页表项分组,每组有 2 的 10 次方个页表项,一共有 2 的 10 次方组,将这 2 的 10 次方组离散存储在内存中,再通过一个页目录表进行管理即可,如下图

image.png

image.png

如此,由于一共有 2 的 10 次方组,每组包括 2 的 10 次方个页表项,所以高位的页号也被分为了两部分,分别是高 10 位的一级页号和后 10 位的二级页号。

分组后每一个页表的逻辑地址都从 0 开始

关于逻辑地址位数的划分,若页框大小为 2 的 n 次方,那么低 n 位都是页内偏移量,对高位的划分都是对页表进行的划分,划分的依据都看一个页框能存下页表的个数,若一个页框能存下 2 的 10 次方个页表项,那么就应该从后往前以 10 位进行划分

值得注意的是,当采用了多级页表之后,页表寄存器内的页表信息保存的就是一级页表的信息,并且多级页表存储的也是页框号,切勿理解为页号。

接下来考虑如何通过多级页表进行地址转换,首先我们拿到一个逻辑地址之后需要进行划分,低位表示页内偏移地址,高位对页进行划分,划分依据前面已经提到过,我们以二级页表为例,划分为一级页表号,二级页表号,页内偏移量三部分。

image.png

首先找到一级页号,根据一级页号和页表寄存器的基地址找到二级页表所在的页框号,根据页框号和页框大小算出真实物理地址,找到对应的二级页表后,根据二级页表号在二级页表中找到其对应的页框号,再根据页框号和页框大小算出最终的物理地址,加上页内偏移地址得到最终地址。以上就实现了页表本身的离散存储。

前面提到过,单级页表还存在一个问题,当我们对二级页表进行分组后,我们可以将一些不常用的页表组放到外存去,当我们需要访问的时候再将其调入内存,这就涉及到后面的虚拟存储只是,这里简要介绍,我们可以设置一个标志位来解决。

image.png

关于访存次数,在没有块表的情况下,二级页表会进行三次内存访问,第一次访问一级页表,第二次访问二级页表,根据二级页表才能得出最终的物理地址,所以一共是 3 次访存,比一级页表多一次。

基本分段存储管理方式

分段和分页比较类似,可以对比学习,在分页中,我们将进程分成了很多固定大小的页面,但在分段管理中,我们是根据逻辑将一个进程分成了很多段,比如可以将 main 函数分成一个段,可以将全局变量分到一个段,将某个子函数分到一个段,很显然,分段管理中段大小并不相同。

分段后,每一段的起始逻辑地址都是 0
image.png
在分段管理中,分段这个操作其实是用户来做的,也就是程序员,程序员编程的时候就通过指令告诉了操作系统应该怎么分段。

分段后,逻辑地址就被分为了段号和段内地址。
image.png

其中段内地址就是段内部的偏移地址,它决定了一个段最长有多长。段号则是对段进行编号,它决定了一个进程最多有多少个段。前面提到的每一段的起始地址都是从 0 开始,说的就是段内地址是从 0 开始,每一段都有一个新的段号。

我们参考页表,同样可以设计出段表,页表其实是建立了一个页号和页框号的映射关系,但是说实话,我们知道页框号其实也就知道了页框的真实地址,所以我们也可以说他是建立了一个页号和页框真实基地址的映射。同样,段表也应该建立一个段号和段的真实基地址的映射关系,我们可以通过段号查询到该段真实的物理基地址,这里还有一个问题,我们前面提到过,分段不同于分页,分页的每一个页面都是一样大的,分段则不一定,所以段表中不仅要存放段的基地址,还应该存放段长信息。
image.png
同样,为了找到段表,我们也应该在 PCB 中记录段表的起始地址和段表长度,同样也可以在硬件层面添加一个段表寄存器,当进程被调度,就将其 PCB 中对应的段表长度和段表基址放到段表寄存器里。

注意,虽然段本身长度不一,但段表的每一项长度一定是一致的,段号这个信息我们可以看成数组下标不需要存储,我们说过,段内地址决定了段有多长,所以段内地址的位数就是段长的位数,基址则表示的真实物理地址,例如段内地址用 16 位表示,操作系统中有 4 GB 空间(物理地址有 32 位),则每一个段表项就是 16+32=48 位

接下来考虑地址变换

  1. 得到逻辑地址,拆分为段号和段内地址
  2. 得到段表寄存器中段表的长度,若段号大于等于段表长度,则发生溢出,产生越界中断。
  3. 根据段表寄存器中段表的首地址,再根据段号和每一个段表的长度,查询段表,得到该段在物理内存中的基地址和段长。
  4. 判断逻辑地址中的段内地址是否大于等于段长,如果大于等于则产生越界,发出内中断。
  5. 根据段的基地址和段内地址,计算得出真实地址。

image.png

分段管理和分页管理地址变换其实相当类似,只需要留意两个地方,一是段表存储的是基地址而页表存储的是页框号,二是上面第四点,也就是要判断段内地址是否溢出。

以下对分段和分页进行对比

页是物理单位,分页是系统为了实现离散分配自己干的事,对用户不可见。
段是逻辑单位,用户可以在编程的时候显示给出段名,对用户是可见的。

页大小固定,有系统决定。段大小不固定,有程序本身决定。

分页管理中,地址空间是一维的,只需要给出一个逻辑地址即可找到真实物理地址。
分段管理中,需要给出段号和段内地址两个信息才可找到真实的物理地址,地址空间是二维的。

分段可以跟容易实现对内存的保护和共享。

对于没有快表的系统而言,分页管理(一级)需要访存两次,分段管理也需要访存两次。

分段管理也可以引入快表机构,可以减少一次访问。

段页式管理方式

不论是单独的分段还是单独的分页,多多少少存在一些缺陷

对于分页来说,其内存利用率高,只会有少量的内部碎片,但是分页不方便按照逻辑模块实现信息的共享和保护。

而对于分段你来说,虽然可以很方便地按照逻辑模块实现共享和保护,但如果段过长,则会需要大量的连续空间,并且段式管理还会产生大量外部碎片,虽然这些外部碎片可以通过紧凑技术解决,但所耗费的时间成本是不小的。

于是我们就将二者结合起来,形成段页式管理方式,段页式管理方式,顾名思义,就是分段后再分页,我们先将进程按照逻辑进行分段,再对每一段都进行分页。同样,由于这里分页了,内存也会被分成等大小的页框,再将这些先分段后分页的一个一个页面存入页框中即可。

image.png

我们这里对每一个段都进行了一个分页,实际上是对段内地址进行的分页,分页后,段内地址可以分成两部分,分别是高位的页号和低位的页内偏移量。

image.png

此时段表中保存的就不应该是基地址和段长了,而是页表的信息,分别是页表的页框号和页表长度。而在该段的也表中,则保存了该页的页框号,我们通过段表找到页表,再通过页表找到页框号,从而找到最终的物理地址。

image.png

段号决定了一个进程最多可以有多少个段。页号决定了一个段最多可以分为多少个页。页内偏移量决定了页面大小或者说页框大小。
对于程序员而言,这种方式和分段管理没有任何区别,因为分页对用户是不可见的,这里的页号+页内偏移地址实际上还是段内地址,把它拆分为页号和页内偏移量只是操作系统干的事,所以本质上段页式管理还是一个二维的地址空间。

下面描述一下逻辑地址转物理地址的流程

  1. 得到逻辑地址,分出段号,页号,页内偏移地址三个部分。
  2. 如果段号大于等于段表寄存器中的段表长度,那么说明越界访问,产生越界中断,否则继续执行。
  3. 根据段表基址,段号,段表项长度查询到对应的段表项,取出其中的页表长度和页表存放的页框号。
  4. 检查逻辑地址中的页号是否大于等于页表长度,如果大于等于页表长度,也说明是越界访问,发生越界中断,否则继续。
  5. 根据页表存放的页框号,再根据页框的大小,算出页表存放的页框基址,再根据页框基址,页表项长度,页号,三者找到对应页表项,找出其中的页框号。
  6. 此时页框号就是真实物理地址的页框号,根据页框大小和页框号算出地址即可。
    image.png
    整个过程进行了三次访存,分别是段表,页表,存储单元的访问。

我们也可以将块表引入段页式管理,把段号和页号同时作为关键字进行查询块表操作,最快只需要一次访存。

虚拟内存管理

基本概念

以上我们介绍的所有内存管理方式,其实都存在一定缺陷,我们在学习它们的时候,都是默认将作业全部装入内存后才能执行,并且装入后就只有等到进程运行结束之后才会清除,这会带来几个问题。

首先,如果我作业很大,不能一次性全部装入内存,这就会直接导致大作业无法运行,哪怕退一步讲,我的作业没有那么大,但是如果作业数量很多,那也会导致内存无法容量大量作业,也会降低并发性。同时对于一些数据进程可能全程只会用到一次,对于这些用完一次就不用的数据,如果你还让他驻留在内存,无疑是在浪费内存资源。

基于以上缺陷,我们引入虚拟存储技术来解决为了介绍虚拟存储结束,补一下前面的坑,详细介绍一下局部性原理。

局部性原理分为空间局部性原理和时间局部性原理,所谓的时间局部性原理,就是指当前访问到的指令(数据)在不久的将来很有可能再次被访问到。而时间局部性原理是指如果程序访问了一个存储单元,那么这个存储单元附近的存储单元在不久的将来也很有可能被访问到。时间局部性原理实际上是基于程序中出现的大量循环语句,而空间局部性原理则是由于指令或者我们常用的数据结构,例如数组这些都是连续存放在内存中的。

基于以上局部性原理,我们可以这样处理,在程序装入的时候,我们让程序启动时很快就会用到的不分装入内存,暂时用不到的部分就留在外存,然后让程序直接开始执行;在程序执行的过程中, 如果要访问到的数据没有在内存,那就需要由操作系统去把这些数据调入内存然后继续执行程序;当内存空间不足的时候,操作系统会将内存中暂时不会用到的数据换出到外存。如果我们使用这种管理方式,那么在用户的视角看来,同样大小的内存就可以并发运行更多的程序,看起来就是内存变得更大了,这就是虚拟内存。

虚拟内存是操作系统虚拟性的体现

接下来介绍虚拟内存关于容量相关的说法,首先是虚拟内存的最大容量,虚拟内存的最大容量是由 CPU 寻址范围直接确定的,或者说是由地址结构直接确定的,地址的位数就决定了虚拟内存的最大容量,例如我这个系统按照 32 位寻址,那我的最大虚拟内存就是 4 GB。而还有个易混淆的概念是虚拟内存的实际容量,虚拟内存的实际容量并不等于虚拟内存的最大容量,虚拟内存本质上是把外存拿来当临时内存了,比如我这个系统有 512 M 内存和 2 GB 外存,那么我的实际容量就应该是 2 GB+512 M 和 4 GB(虚拟内存最大容量) 之间的最小值。

CPU 寻址范围,或者说计算机的地址结构由两方面决定,分别是 CPU 位数以及操作系统的支持

接下来介绍虚拟内存的三个主要特征,这三个主要特征在前面的文字描述里均提及过,这里不再赘述。

  • 多次性:无需在作业运行时一次性全部装入内存,而是允许被分成多次调入内存。
  • 对换性:在作业运行时无需一直常驻内存,而是允许在作业运行过程中,将作业换入、换出。
  • 虚拟性:从逻辑上扩充了内存的容量,使用户看到的内存容量,远大于实际的容量。

接下来考虑如何实现虚拟内存,首先,虚拟内存是需要将作业分批次装入内存的,这很明显得用离散分配的方式。我们前面学过的离散分配方式一共有三个,分别是基本分页,基本分段,基本段页式。

回顾前面学过的连续分配方式,分别是单一连续分配(将内存用户区全部分给一个进程),固定分区分配(为了实现并发性,将内存分为很多块,每次选择一块分配给一个进程),动态分区分配(将空闲区以线性表的结构管理起来,组织形式有空闲分区表和空闲分区链,每次分配根据动态分区分配算法来选择),动态分区分配算法分别有首次适应,最佳适应,最坏适应,邻近适应。

如果我们学过的三种离散分配算法引入虚拟内存之后,其表述发生了变化,如下

  • 基本分页存储管理  =>  请求分页存储管理
  • 基本分段存储管理  =>  请求分段存储管理
  • 基本段页式存储管理  =>  请求段页式存储管理

引入虚拟内存之后,名字都会变成请求,操作系统也会额外提供两个功能,分别是请求调页(调段)和页面置换(段置换)功能,请求调页其实就是当访问的数据在外存的时候操作系统负责去外存把数据调入内存,页面置换就是当内存空间不足的时候操作系统会将内存中暂时用不到的资源换出外存。我们主要介绍请求分页管理方式。

请求分页管理方式

请求分页管理方式是基本分页管理方式加入虚拟内存技术的版本,操作系统需要提供页面请求功能和页面置换功能,在请求分页管理方式中,不需要把页面全部调入内存,只有当操作系统需要某个页面但这个页面并不在内存的时候,按需将页面从外存换入内存。

首先来考虑请求功能,操作系统需要知道哪些页面在内存中,哪些页面不在内存中,为了让操作系统拿到这些信息,我们需要在页表中给每一个页面新增一个标志位,或者说状态位,用来记录该页面是否在内存中。同时,如果页面不在内存中,操作系统就要去外存将页面调入内存,所以操作系统也应该知道这个页面存储在外存的地址。综上,我们应该在页表中添加状态位和外存地址以实现请求功能。

下面来讨论置换功能,当内存不够的时候,操作系统应该选择一个不常用的页面换出外存,然后用需要换入内存的页面来替换其原来的位置,这里要面对的问题就是,操作系统如何知道哪个页面是不常用的页面,我们可以设置一个访问字段,我们可以用其记录访问的次数,或者上次访问的时间,以供操作系统选择应该将哪个页面换出。除此之外,这里提出一种优化思路,首先我们应该知道,我们将外存中的页面放入内存,这是一个拷贝操作,在外存中这个页面依旧存在,所以如果我将一个页面换入内存后没有修改就换入外存,那这个时候我是不用去重新写外存的数据的,基于这个思想,我们考虑在页表中加入一个字段用来确定这个页面是否被修改过,如果被修改过,那么在换出外存的时候就要对外存的数据进行修改。综上,我们还需要再页表中加入访问字段和修改标志位用于实现置换功能。

综上所述,我们需要在基本分页管理的页表中新增四个页表项,分别是状态位,访问字段,修改位,外存地址。

image.png

以上讨论了请求分页存储管理页表和基本分页管理页表的区别,接下来基于这个页表,我们介绍请求和置换的具体实习细节。

首先是页面请求,页面请求就是在访问某个页面的时候,发现这个页面状态位为 0,也就是表示这个页面没有在内存中,这个时候就会产生一个缺页中断(由于这个中断是由指令导致的,所以属于内部中断)此时操作系统会根据外存地址,从外存中找到这个页面将其换入内存中。

此时会出现两种情况,如果内存中有空闲页框,那么会将这个页框直接分配给这个页,同时将这个页框的页框号赋值到页表中的页框号中,同时将页表中的状态位修改为 1,重置访问字段和修改位(并不修改外存地址)。

但如果此时内存中没有空闲块了,就需要将某个内存块置换出外存,操作系统会根据访问字段,基于一些置换算法来选择将哪些段换出去,选择好之后,需要访问修改位,如果这个页面没有被修改,那么可以跳过换出步骤,直接将状态位置为 0 即可,此时这个页框相当于空出来了,就可以将这个页框分配给待分配的页面。如果此时修改位被修改了,那么我们需要先将这个根据页表中的外存地址,先将这个页面重新写入外存,之后再将状态位置为 0,此时就可以将页面分配给待分配的页面了。

基于以上文字描述,我们画出如下流程图

缺页中断属于内部中断,内中断有三种,分别是陷入,故障,终止,缺页中断属于故障中断
每次对逻辑地址的访问都可能导致缺页中断,一条指令可能会访问两次逻辑地址,所以可能会有两次中断

综上,对比基本分页,请求分页管理方式新增的步骤其实就是请求,分页,以及修改页表新增项
image.png

这张图中引入了块表,首先要明确,快表中存在的页面一定是存在与内存中的,所以访问块表中的页面一定不会产生缺页中断,如果快表中的一个页面被换出外存,则也应该删除块表中的项。

修改字段只有在写指令的时候才会被修改
块表中的字段如果被修改可以不写入到慢表,因为操作系统会优先访问块表,只有块表的项被删除的时候才需要将该项写回慢表

页面置换算法

上一节中说到,如果发生缺页中断,我们需要将外存中的一个页面调入内存,但内存如果满的情况下,我们需要选择一个页面换出外存,本节就来介绍如何选择,我们主要介绍四种算法。

首先说明页面置换算法的设计原则,由于置换操作是要把内存换出外存,这种操作属于 IO 操作,会有较大的开销,所以我们应该追求最少的缺页率。

最佳适应算法(OPT)

最佳置换算法每次发生置换的时候都选择在最长时间内不会被使用到的页面。

image.png

例如以上访问序列,7,0,1 三个页面都会发生缺页,此时这三个页面已经占满了内存,再要访问 2,此时我们按照最佳适应算法,内存中有 7,0,1 三个页面,0 号页面下一次就会被访问,1 号页面在第 10 次会被访问到,而 7 号页面要在 14 次之后才会被访问到,所以我们优先选择 7 号页面置换出去。后续访问 0 不会产生问题,访问 3 再次缺页,此时内存满,此时内存中有 2,0,1 三个页面,0 号页面下一次会被访问,2 号页面在第三次会被访问,而 1 号页面需要在第八次才被访问到,所以优先选择 1 号页面换出去。后续过程以此类推。

这个过程中缺页中断发生了 9 次,但是页面置换发生了 6 次,所以缺页时未必会导致页面置换,但页面置换一定是产生了缺页置换。

我们使用缺页次数除以序列总长度就可以得到缺页率,这个访问序列的缺页率为 0.45

最佳适应算法可以保证最少的缺页率,但很可惜无法实现,因为我们不知道以后会访问哪些页面,,也就不知道哪些页面会被最后访问到,或者说要让这个算法实现的前提是我最开始就知道整个访问序列,很显然计算机是没有这种预知未来的能力的,因此最佳适应算法只停留在理论层面。

先进先出算法(FIFO)

先入先出算法是实现起来最简单的算法,最早进入内存的最先淘汰,可以直接通过一个队列简单实现,这个算法非常简单,所以不做细节分析。

image.png

我们考虑这样一个序列,我们分别为其分配三个内存块和四个内存块,我们发现,当分配三个内存块的时候,缺页次数是 9,分配四个内存块的时候缺页次数为 10,这就是一个很神奇的现象,因为我们朴素的理解下,内存块越多性能应该越好,这里反而更差。

当进程分配物理块越多,缺页次数反而增加的现象,称之为 Belady 异常,只有 FIFO 算法会产生这种问题。

FIFO 算法实现相当简单,但性能很差。

最近最久未使用算法(LRU)

在上一节中,我们提到了,要在页表中添加一个访问字段供置换算法使用,这里终于用上了,最近最久未使用算法的思想是找一个最久没有被访问过的页面给他换出去。

这个算法需要在页表中添加一个访问字段,用来记录这个页面距离上次被访问的时间 t,每次访问一个页面时,将该页面的访问字段清零,同时将其他所有页面的访问字段加一,置换的时候选择访问字段最大的那一个。

image.png

如上访问序列,1,8,1,7,8,2,7,1,8 时都不会产生置换,下一个 3 会产生缺页中断,并且内存满,会产生置换,我们往前找,距离 8 上一次被访问过,1 两次前被访问过,2 三次前被访问过,而 7 则是 4 次前被访问的,所以选择 47将其置换出去。后面的以此类推。

最近最久未使用算法可以获得最接近最佳适应算法的缺页率,但是这个算法需要专门硬件支持,虽然性能好,但是实现难度大,开销也大。

时钟置换算法(CLOCK)

前面三种算法多多少少都存在一些问题,并且问题都比较严重,时钟置换算法就是一个权衡之策。时钟置换算法需要在页表新增一个访问位,用来表示最近是否访问过,1 表示最近访问过,0 表示最近没有访问过,该算法会将所有的内存块组织为一个循环链表,使用一个指向内存块的指针 P 来实现,该算法比较复杂,先给出一个流程图。

简单用语言描述,首先,需要需要将页面维护为一个循环链表,每次从 P 开始扫描,如果 P 所指向的页面最近没有被访问过,那么就让它替换,如果 P 所指向的页面最近被访问过了,则让他的访问标志置为 0,表示他现在没有被访问过了,然后 P 继续往后扫描。

很显然,在最差情况,所有内存块的标志位都为 1,表示都被访问过,那么 P 需要扫描完一整个一轮,发现都没有,会接着扫描第二轮,第二轮必定会有访问位为 0 的页面,所以 CLOCK 算法最多会扫描到第二轮。

下一次置换的 P 指针会从上一次结束的位置继续扫描

改进型的时钟置换算法

改进型时钟置换算法是对时钟置换算法的改进,时钟置换算法只考虑了缺页率,但是我们要追求的不仅仅是缺页率,我们要的是整体效率,而一个页面如果修改过,我们需要将其写入外存,这是需要时间的,一个页面没有修改过就不需要重新写入外存,效率显然更高,所以改进型时钟置换算法就是在这里做的改进,考虑了最近是否被访问和是否被修改两个层面。

改进型时钟置换算法也需要维护为一个循环链表,也需要一个指针 P 来完成,但算法会比简单的 CLOCK 算法更难,改进的 CLOCK 算法是通过修改标志和访问标志两个标志来确定替换谁的,这两种标志会产生四种状态,分别是:

  1. 没有访问也没有修改
  2. 没有访问但修改过
  3. 没有修改但访问过
  4. 访问过也修改过

这四种状态的优先级就是上面的列表,即如果存在没有访问也没有修改过的页面,就有限替换,如果没有,就照没有访问但修改过的,如果还没有,继续往后找,所以从这里我们也可以看出,最差的情况就是所有页面都被访问也被修改过,前三轮扫描就都找不到,这个时候进行第四次扫描,所以这种算法最多会扫描到第四轮。

下面对改进型的 CLOCK 算法具体实现做描述,由于这里有两个标志位,我们约定如下表示:<访问标志位,修改标志位>来表示访问标志位和修改标志位的状态,例如 <1,0>表示这个页面访问过但没有被修改。同时改进型的 CLOCK 也是将页面维护为一个双向链表,也需要一个指针 P 来实现,下面给出流程图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
上面这个流程图可能比较复杂,这里用文字再描述一下

  1. 第一轮:从 P 的当前位置往后扫,找到第一个没有被访问也没有被修改的页面,标志位为<0,0>,如果扫描完一轮还没有找到,进入第二轮
  2. 第二轮:找到第一个没有被访问但修改过的页面,标志位为<0,1>,本轮扫描将标志位不为<0,1>的页面的访问位全部置 0 ,如果没有找到进入第三轮
  3. 第三轮:找到第一个没有被访问也没有被修改的页面,标志位为<0,0>,如果没有找到进入第四轮
  4. 第四轮:找到第一个没有被访问但被修改的页面,标志位为<0,1>

下面给出四种算法的对比

image.png

页面分配策略

本节介绍页面分配策略,在引入虚拟内存的请求管理方式中,我们不是将一个程序的全部都一次性写入内存,我们是根据需求写入,那一个进程进入之后总得给他分配内存空间吧,或者说给他分配空闲页框吧,在上个小节中我们都是默认分配好了页框,本节就来讨论如何给一个页面分配页框。

首先介绍驻留集的概念,对一个进程而言,驻留集就是请求分页存储管理中给这个进程分配的页框的集合。换句话说,在请求分页存储管理方式中,给这个进程分配的空闲物理块合在一起就是驻留集。

根据驻留集的大小是否可变,分成了固定分配和可变分配,固定分配是指驻留集大小在进程运行时就分配好,后续不再变动,而可变分配是指驻留集大小可能会发生改变,也就是说一个进程运行后,如果他物理内存不够,我可以多给他分配物理内存,如果他太多了,我可能会让他得到的物理内存少一点。

根据置换规则,又分为全局置换和局部置换,全局置换是指一个进程发生缺页置换的时候,是否可以把其他进程或者系统的空闲块分配给他,如果可以,那么就是全局置换,否则就是局部置换,显然,对于固定分配来说,他只能在自己的地盘自己玩,不可能出现全局置换的情况。

image.png

固定分配局部置换:系统为每个进程分配一定数量的物理块,在整个运行期间都不改变。若进程在运行中发生缺页,则只能从该进程在内存中的页面中选出一页换出,然后再调入需要的页面。这种策略的缺点是:很难在刚开始就确定应为每个进程分配多少个物理块才算合理。(采用这种策略的系统可以根据进程大小、优先级、或是根据程序员给出的参数来确定为一个进程分配的内存块数)

可变分配全局置换:刚开始会为每个进程分配一定数量的物理块。操作系统会保持一个空闲物理块队列。当某进程发生缺页时,从空闲物理块中取出一块分配给该进程;若已无空闲物理块,则可选择一个未锁定的页面换出外存,再将该物理块分配给缺页的进程。采用这种策略时,只要某进程发生缺页,都将获得新的物理块,仅当空闲物理块用完时,系统才选择一个未锁定的页面调出。 被选择调出的页可能是系统中任何一个进程中的页,因此这个被选中的进程拥有的物理块会减少,缺页率会增加。

可变分配局部置换:刚开始会为每个进程分配一定数量的物理块。当某进程发生缺页时,只允许从该进程自己的物理块中选出一个进行换出外存。如果进程在运行中频繁地缺页,系统会为该进程多分配几个物理块,直至该进程缺页率趋势适当程度;反之,如果进程在运行中缺页率特别低,则可适当减少分配给该进程的物理块。

可变分配全局置换:只要缺页就给分配新物理块
可变分配局部置换:要根据发生缺页的频率来动态地增加或减少进程的物理块

接下来考虑调入页面的一些规则,首先,对于第一次调入和运行时调入,采用的规则是不一样的,第一次调入采用预调页策略,后续发生缺页后的调用采用请求调页策略,这两种调入策略分别如下:

  • 预调页策略:根据局部性原理,一次调入若干个相邻的页面可能比一次调入一个页面更高效。但如果提前调入的页面中大多数都没被访问过,则又是低效的。因此可以预测不久之后可能访问到的页面,将它们预先调入内存,但目前预测成功率只有 50%左右。故这种策略主要用于进程的首次调入,由程序员指出应该先调入哪些部分。
  • 请求调页策略:进程在运行期间发现缺页时才将所缺页面调入内存。由这种策略调入的页面一定会被访问到,但由于每次只能调入一页,而每次调页都要磁盘 I/O 操作,因此 I/O 开销较大。

接下来就是从何处调入的问题,我们自然知道都是从磁盘调入的,前面我们提到过,磁盘分为了对换区和文件区,参见 [[#^49649e|对换区]],对换区速度比文件区快,但容量比文件区小,调入的时候应该是从哪里调入,调出的时候又调出到哪里,这又是一个问题,主要有以下三种方式。

  1. 系统拥有足够的对换区空间:页面的调入、调出都是在内存与对换区之间进行,这样可以保证页面的调入、调出速度很快。在进程运行前,需将进程相关的数据从文件区复制到对换区。
  2. 系统缺少足够的对换区空间:凡是不会被修改的数据都直接从文件区调入,由于这些页面不会被修改,因此换出时不必写回磁盘,下次需要时再从文件区调入即可。对于可能被修改的部分,换出时需写回磁盘对换区,下次需要时再从对换区调入。
  3. UNIX 方式:运行之前进程有关的数据全部放在文件区,故未使用过的页面,都可从文件区调入。若被使用过的页面需要换出,则写回对换区,下次需要时从对换区调入。

下面介绍抖动,刚刚换出的页面马上又要换入内存,刚刚换入的页面马上又要换出外存,这种频繁的页面调度行为称为抖动,或颠簸。产生抖动的主要原因是进程频繁访问的页面数目高于可用的物理块数(分配给进程的物理块不够)。为进程分配的物理块太少,会使进程发生抖动现象。为进程分配的物理块太多,又会降低系统整体的并发度,降低某些资源的利用率

为了研究为应该为每个进程分配多少个物理块,Denning 提出了进程“工作集”的概念

工作集:指在某段时间间隔里,进程实际访问页面的集合。操作系统会根据“窗口尺寸”来算出工作集。

注意区分驻留集

image.png

工作集大小可能小于窗口尺寸,实际应用中,操作系统可以统计进程的工作集大小,根据工作集大小给进程分配若干内存块。如:窗口尺寸为 5,经过一段时间的监测发现某进程的工作集最大为 3,那么说明该进程有很好的局部性,可以给这个进程分配 3 个以上的内存块即可满足进程的运行需要。一般来说,驻留集大小不能小于工作集大小,否则进程运行过程中将频繁缺页。

基于局部性原理可知,进程在一段时间内访问的页面与不久之后会访问的页面是有相关性的。因此,可以根据进程近期访问的页面集合(工作集)来设计一种页面置换算法——选择一个不在工作集中的页面进行淘汰。

内存映射文件

内存映射文件其实就是操作系统向程序员提供的一种系统调用,它可以方便程序员访问文件数据,也可以方便多个进程共享同一个文件
image.png
先来看传统的文件访问方式,假设我要对硬盘里的文件进行修改,首先使用 open 调用打开文件,然后使用 seek 调用将文件读写指针指向某个位置,然后使用 read 调用从指针所指位置开始读入若干数据,即从磁盘读入内存,在内存中对内容修改后,再使用 write 调用将内存中指定数据写回硬盘(写入位置根据读写指针确定,可能还要使用 seek 命令移动读写指针),这非常麻烦。

内存映射文件就是将文件映射到虚拟地址上,首先使用 open 命令打开文件,在使用 mmap 命令将制定文件映射到虚拟地址上,会给我们一个起始地址

image.png

此时内存里 1,2,3 相当于都处于缺页状态的页框,当我们根据起始地址访问文件中的某个内容的时候,会发生缺页中断,操作系统会自动将文件所对应的页框复制到内存中,不需要我们再手动调用对硬盘文件进行读写,如果修改了数据,关闭文件的时候也是由操作系统自己把文件写入硬盘的,我们根本不需要关心这些。

除了方便访问文件以外,还可以很方便地实现共享文件。

image.png

我们可以让多个进程的页表均指向同一块内存空间,此时多个进程看到的其实就是同一个文件,当一个进程修改了文件之后,另一个进程也可以立刻看到修改后的文件。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值