目录

引言

4.1 存储器系统

4.1.1 寄存器

4.1.2 高速缓存

4.1.3 缓存系统

4.1.4 主存

4.2 内存管理 

4.2.1 无存储器抽象

4.2.1.1 无存储器抽象条件下运行多个程序

4.3 一种存储器抽象:地址空间

4.3.1 地址空间的概念

4.3.1.1 基址寄存器与界限寄存器

4.3.2 交换技术

4.3.3 空闲内存管理

4.3.3.1 使用位图的存储管理

4.3.3.2 使用链表的存储管理


引言

在任何一种计算机中,CPU都是它的大脑,它的角色不可或缺,除此之外,第二种主要部件就是存储器。理想情况下,存储器应该极为迅速的执行指令(这样CPU不会受到存储器的限制),充分大,并且非常便宜。但是目前的技术无法同时满足这三个目标,于是出现了不同的处理方式。

4.1 存储器系统

存储器系统采用一种分层次的结构,如下图所示:

启动 bios会存到内存_启动 bios会存到内存

4.1.1 寄存器

存储器系统的顶层是CPU中的寄存器。它们用与CPU相同的材料制成,所以和CPU一样快。显然,访问它们是没有时延的。其典型的存储容量是,在32位CPU中为

启动 bios会存到内存_使用位图的存储管理_02

位,而在64位CPU中为

启动 bios会存到内存_使用位图的存储管理_03

位。在这两种情形下,其存储容量都小于1 KB。

4.1.2 高速缓存

下一层是高速缓存,它多数由硬件控制。主存被分割成高速缓存行(cache line),其典型大小为64字节,即地址0至63对应高速缓存行0,地址64至127对应高速缓存行1,以此类推。

最常用的高速缓存行放置在CPU内部或者非常接近CPU的高速缓存中。当某个程序需要读一个存储字时,高速缓存硬件检查所需要的高速缓存行是否在高速缓存中。如果是,称为高速缓存命中,缓存满足了请求,就不需要通过总线把访问请求送往主存。

高速缓存命中通常需要两个时钟周期。高速缓存未命中就必须访问内存,这要付出大量的时间代价。由于高速缓存的价格昂贵,所以其大小有限。有些机器具有两级甚至三级高速缓存,每一级高速缓存比前一级慢且容量更大。

4.1.3 缓存系统

缓存在计算机科学的许多领域中起着重要的作用。只要存在大量的资源可以划分为小的部分,那么,这些资源中的某些部分就会比其他部分更频繁地得到使用,通常缓存的使用会带来性能上的改善。在任何缓存系统中,都有若干需要尽快考虑的问题,包括:

  1. 何时把一个新的内容放入缓存。
  2. 把新内容放在缓存的哪一行上。
  3. 在需要时,应该把哪个内容从缓存中移走。
  4. 应该把新移走的内容放在某个较大存储器的何处。

对于CPU缓存中的主存缓存行,每当有缓存未命中时,就会调入新的内容。通常通过所引用内存地址的高位计算应该使用的缓存行。举个例子,对于64字节的4096个缓存行以及32位地址,其中6~17位用来定位缓存行,而0 ~ 5位则用来确定缓存行中的字节。这个位置有两个作用:

  • 在有的操作系统中,被移走内容的位置就是新数据要进入的位置。
  • 当将一个缓存行的内容重写进主存时(该内容被缓存后,可能会被修改),通过该地址来唯一确定需重写的主存位置。

缓存是一种好方法,所以现代CPU中设计了两个缓存。

启动 bios会存到内存_存储器系统_04

缓存和 

启动 bios会存到内存_存储器系统_05

缓存: 

启动 bios会存到内存_存储器系统_04

缓存总是在CPU中,通常用来将已解码的指令调入CPU的执行引擎。对于那些频繁使用的数据字,多数芯片安排有第二个

启动 bios会存到内存_存储器系统_04

缓存。典型的

启动 bios会存到内存_存储器系统_04

缓存大小为16KB。

启动 bios会存到内存_存储器系统_05

缓存,用来存放近来使用过的若干兆字节的内存字。

启动 bios会存到内存_存储器系统_04

启动 bios会存到内存_存储器系统_05

缓存之间的差别在于时序。对

启动 bios会存到内存_存储器系统_04

缓存的访问,不存在任何延时:而对

启动 bios会存到内存_存储器系统_05

缓存的访问,则会延时1或2个时钟周期。

启动 bios会存到内存_虚拟内存_14

在左侧的图中,一个

启动 bios会存到内存_存储器系统_05

缓存被所有的核共享。相反,在右边图中,每个核有自己的

启动 bios会存到内存_存储器系统_05

缓存。不过每种策略都有自己的优缺点。例如,共享

启动 bios会存到内存_存储器系统_05

缓存需要有一种更复杂的缓存控制器,而右边的方式在设法保持

启动 bios会存到内存_存储器系统_05

缓存一致性上存在困难。

4.1.4 主存

缓存的下一层是主存。这是存储器系统的主力。主存通常称为随机访问存储器(Random Access Memory,RAM)。所有不能在高速缓存中得到满足的访问请求都会转往主存。除了主存之外,许多计算机已经在使用少量的非易失性随机访问存储器。它们与RAM不同,在电源切断之后,非易失性随机访问存储器并不丢失其内容。只读存储器(Read Only Memory,ROM)在工厂中就被编程完毕,然后再也不能被修改。ROM速度快且便宜。在有些计算机中,用于启动计算机的引导加载模块就存放在ROM中。另外,一些I/O卡也采用ROM处理底层设备控制。

4.2 内存管理 

正如我们上面所学习的,在硬件层面分层存储器体系(memory hierarchy)是指计算机有若干兆(MB)快速、昂贵且易失性的高速缓存(cache),数千兆(GB)速度与价格适中且同样易失性的内存,以及几兆兆(TB)低速、廉价、非易失性的磁盘存储,甚至还有诸如DVD和USB等可移动存储装置。

但是在操作系统层面,操作系统的工作是将这个存储体系抽象为一个有用的模型并管理这个抽象模型。操作系统中管理分层存储器体系的部分称为存储管理器(memory manager)

存储管理器的任务是有效地管理内存,即记录哪些内存是正在使用的,哪些内存是空闲的:在进程需要时为其分配内存,在进程使用完后释放内存。

下面我们会研究几个不同的存储管理方案,重点在于针对编程人员的内存模型,以及怎样优化管理内存。
 

4.2.1 无存储器抽象

我们要想知道为什么需要存储器抽象,就要先学习无存储器抽象带来的问题。早期大型、小型和个人计算机都没有存储器抽象。这就意味着每一个程序直接访问物理内存。

当一个程序执行如下指令:

MOV REGISTER1, 1000

计算机会将位置为1000的物理内存中的内容移到REGISTER1中。在这种情况下,要想在内存中同时运行两个程序是不可能的。因为新程序写入的值,将会擦掉之前程序存放在相同位置上的所有内容,这两个程序会立刻崩溃。但是我们有一些解决办法,如下图所示:

启动 bios会存到内存_虚拟内存_19

在最左边的变体中,操作系统位于RAM的底部,这种方案以前被用在大型机和小型计算机上,现在很少使用了。中间的变体,操作系统位于内存顶端的ROM(只读存储器)中,这方案被用在一些掌上电脑和嵌入式系统中。而最右边的变体,设备驱动程序位于内存顶端的ROM中,而操作系统的其他部分则位于下面的RAM的底部,这种方案用于早期的个人计算机中,在ROM中的系统部分称为BIOS(Basic Input Output System,基本输入输出系统)。第一种方案和第三种方案的缺点是用户程序出现的错误可能摧毁操作系统,引发灾难性后果。当按这种方式组织系统时,通常同一个时刻只能有一个进程在运行。一旦用户键入了一个命令,操作系统就把需要的程序从磁盘复制到内存中并执行:当进程运行结束后,操作系统在用户终端显示提示符并等待新的命令。收到新的命令后,它把新的程序装入内存,覆盖前一个程序。

在没有存储器抽象的系统中实现并行的一种方法是使用多线程来编程。由于在引入线程时就假设一一个进程中的所有线程对同一内存映像都可见,那么实现并行也就不是问题了。但是很多时候我们希望能够在同一时间运行没有关联的程序,而这正是线程抽象所不能提供的。更进一步地,一个没有存储器抽象的系统也不大可能具有线程抽象的功能。

4.2.1.1 无存储器抽象条件下运行多个程序

即使没有存储器抽象,同时运行多个程序也是可能的。因为我们只要保证,在某一个时间内存中只有一个程序,那么就不会发生冲突。所以操作系统只需要把当前内存中所有内容保存到磁盘文件中,然后把下一个程序读入到内存中再运行即可。

实现并发运行程序,除了上面这种交换概念。还可以使用特殊硬件,具体做法是,(IBM 360)将内存被划分为2KB的块,每个块被分配一个4位的保护键,保护键存储在CPU的特殊寄存器中。一个内存为1MB的机器只需要512个这样的4位寄存器,容量总共为256字节。PSW(Program Status Word,程序状态字)中存有一个4位码。一个运行中的进程如果访问保护键与其PSW码不同的内存,硬件会捕获到这一事件。因为只有操作系统可以修改保护键,这样就可以防止用户进程之间、用户进程和操作系统之间的互相干扰。

然而,这种解决方法有一个重要的缺陷。如下图所示:

启动 bios会存到内存_无抽象内存_20

当两个程序被连续地装载到内存中从0开始的地址时,内存状态如c) 所示。它们本身没有破坏对方的内存,因为是岔开存储的。但是在运行时,当第一个程序已经运行了一段时间后,操作系统可能会开始运行第二个程序。第二个程序的第一条指令是JMP 28,这条指令会使程序跳转到第一个程序的ADD指令,而不是事先设定的跳转到CMP指令。由于对内存地址的不正确访问,这个程序很可能在1秒之内就崩溃了。

所以核心的问题不是进程的存储,而是进程引用了绝对物理地址,这才是最需要避免的。我们希望每个程序都使用一套私有的本地地址来进行内存寻址。IBM 360对上述问题的补救就是,在第二个程序装载到内存的时候,使用静态重定位的技术修改它。具体工作方式如下:

当一个程序被装载到地址16,384时,常数16,384被加到每一个程序地址上。虽然这个机制在不出错误的情况下是可行的,但这不是一种通用的解决办法,同时会减慢装载速度。而且,它要求给所有的可执行程序提供额外的信息来区分哪些内存字中存有(可重定位的)地址,哪些没有。毕竟“28”可以指代地址,也可以仅仅是数字,例如下面这个指令:

MOV REGISTER1, 28

如果是数字28送到 REGISTER1 的指令不可以被重定位。装载器需要一定的方法来辨别地址和常数。

最后,虽然无存储器抽象在计算机领域已经成为历史,但缺少存储器抽象的情况在嵌入式系统和智能卡系统中还是很常见的。像收音机、洗衣机和微波炉这样的设备都已经完全被(ROM形式的)软件控制,在这些情况下,软件都采用访问绝对内存地址的寻址方式。在这些设备中这样能够正常工作是因为,所有运行的程序都是可以事先确定的。虽然高端的嵌入式系统(比如智能手机)有复杂的操作系统,但是一般的简单嵌入式系统并非如此。

4.3 一种存储器抽象:地址空间

总结一下,把物理地址暴露给进程会带来下面两个严重问题。

第一,如果用户程序可以寻址内存的每个字节,它们就可以很容易地破坏操作系统,从而使系统慢慢地停止运行。即使在只有一个用户进程运行的情况下,这个问题也是存在的。

第二,使用这种模型,想要同时运行(只有一个CPU)多个程序是很困难的

所以我们要想出新的解决方案。

4.3.1 地址空间的概念

要使多个应用程序同时处于内存中并且不互相影响,需要解决两个问题:保护重定位

我们上面举的IBM 360的例子,它给内存块标记上一个保护键,这种方法解决了保护没有解决重定位,虽然重定位可以通过在程序被装载时重定位程序来解决,但上面也说了,这是一个缓慢且复杂的解决方法。

所以我们想到了一个更好的办法,创造一个新的存储器抽象:地址空间。就像进程的概念其实是创造了一类抽象的CPU以运行程序一样,地址空间为程序创造了一种抽象的内存。地址空间是一个进程可用于寻址内存的一套地址集合。每个进程都有一个自己的地址空间,并且这个地址空间独立于其他进程的地址空间(除了在一些特殊情况下进程需要共享它们的地址空间外)。

4.3.1.1 基址寄存器与界限寄存器

一个解决办法是使用动态重定位,简单地把每个进程的地址空间映射到物理内存的不同部分。从CDC 6600(最早的超级计算机)到Intel 8088(原始IBM PC的心脏),所使用的经典办法是给每个CPU配置两个特殊硬件寄存器,通常叫作基址寄存器界限寄存器。当使用基址寄存器和界限寄存器时,程序装载到内存中连续的空闲位置且装载期间无须重定位,如图所示:

启动 bios会存到内存_启动 bios会存到内存_21

当一个进程运行时,程序的起始物理地址装载到基址寄存器中,程序的长度装载到界限寄存器中。当第一个程序运行时,装载到这些硬件寄存器中的基址和界限值分别是0和16,384。 当第二个程序运行时,这些值分别是16,384和32,768。以此类推, 如果第三个16KB程序的基址寄存器和界限寄存器里的值会是32,768和16,384。

每次一个进程访问内存,CPU硬件会在把地址发送到内存总线前,自动把基址值加到进程发出的地址值上。同时,它检查程序提供的地址是否等于或大于界限寄存器里的值。如果访问的地址超过了界限,会产生错误并中止访问。

这样,对上图中第二个程序的第一条指令,程序执行

JMP 28

指令,但是硬件把这条指令解释成

JMP 16412

所以程序正确跳转到了CMP指令。

使用基址寄存器和界限寄存器是给每个进程提供私有地址空间的非常容易的方法,因为每个内存地址在送到内存之前,都会自动先加上基址寄存器的内容。在很多实际系统中,对基址寄存器和界限寄存器会以一定的方式加以保护,使得只有操作系统可以修改它们。

4.3.2 交换技术

现在我们来学习上面说的第二种解决保护和重定位的方法,那就是交换技术。

上面提出的解决方案要求计算机的物理内存足够大,可以保存所有进程,但实际上,所有进程所需的RAM数量总和通常要远远超出存储器能够支持的范围。有两种处理内存超载的通用方法。

最简单的策略是交换(swapping)技术,即把一个进程完整调入内存,使该进程运行一段时间,然后把它存回磁盘。空闲进程主要存储在磁盘上,所以当它们不运行时就不会占用内存(尽管其中的一些进程会周期性地被唤醒以完成相关工作,然后就又进入睡眠状态)。另一种策略是虚拟内存(virtual memory),该策略甚至能使程序在只有一部分被调入内存的情况下运行。我们先讨论交换技术。交换系统的操作如下图所示:

启动 bios会存到内存_虚拟内存_22

图a) 开始时内存中只有进程A,之后创建进程B和C或者从磁盘将它们换入内存。图d) 显示A被交换到磁盘。然后D被调入,B被调出,最后A再次被调入。由于A的位置发生变化,所以在它换入的时候通过软件或者在程序运行期间(多数是这种情况)通过硬件对其地址进行重定位。例如,基址寄存器和界限寄存器就适用于这种情况。

交换在内存中产生了多个空闲区(hole,也称为空洞),通过把所有的进程尽可能向下移动,有可
能将这些小的空闲区合成一大块。该技术称为内存紧缩(memory compaction)。但通常不进行这个操作,因为它要耗费大量的CPU时间。例如,一台有16GB内存的计算机可以每8ns复制8个字节,它紧缩全部内存大约要花费16s。

有一个问题值得注意,即当进程被创建或换入时应该为它分配多大的内存?我们多考虑几种情况:

若进程创建时其大小是固定的并且不再改变:

则操作系统准确地按需分配。但是如果进程的数据段可以增长,例如,很多程序设计语言都允许从堆中动态地分配内存,那么当进程空间试图增长时,就会出现问题。

若进程与一个空闲区相邻:

那么可把该空闲区分配给进程供其增大。

若进程相邻的是另一个进程:

那么要么把需要增长的进程移到内存中一个足够大的区域中去,要么把一个或多个进程交换出去,以便生成一个足够大的空闲区。

若一个进程在内存中不能增长,而且磁盘上的交换区也已满了:

那么这个进程只有挂起直到一些空间空闲(或者可以结束该进程)。

若大部分进程在运行时都要增长:

为了减少因内存区域不够而引起的进程交换和移动所产生的开销,一种可用的方法是,当换入或移动进程时为它分配些额外的内存。然而,当进程被换出到磁盘上时,应该只交换进程实际上使用的内存中的内容(不交换额外内存)。

对于最后一种情况,我们来看一张图:

启动 bios会存到内存_无抽象内存_23

从图a) 中可以看到,我们为两个进程预留了增长空间的内存配置。但,如果进程有两个可增长段,例如,供变量动态分配和释放的作为堆使用的一个数据段,以及存放普通局部变量与返回地址的一个堆栈段,则可使用另一种安排,如图b) 所示:

  • 进程的堆栈段在进程所占内存的顶端并向下增长;
  • 紧接在程序段后面的数据段向上增长。

在这两者之间的内存可以供两个段使用。如果用完了,进程或者必须移动到足够大的空闲区中(它可以被交换出内存直到内存中有足够的空间),或者结束该进程。

4.3.3 空闲内存管理

在动态分配内存时,操作系统必须对其进行管理。一般而言,有两种方法跟踪内存使用情况:

  • 位图
  • 空闲区链表

我们首先来学习第一个。

4.3.3.1 使用位图的存储管理

使用位图方法时,内存可能被划分成小到几个字或大到几千字节的分配单元。每个分配单元对应于位图中的一位, 0表示空闲,1表示占用。一块内存区和其对应的位图如下图b) 所示。

启动 bios会存到内存_虚拟内存_24

分配单元的大小是一个重要的设计因素。分配单元越小,位图越大。然而即使只有4个字节大小的分配单元,32位的内存也只需要位图中的1位。32n位的内存需要n位的位图,所以位图只占用了1/32的内存。若选择比较大的分配单元,则位图更小。但若进程的大小不是分配单元的整数倍,那么在最后一个分配单元中就会有一定数量的内存被浪费了。

它的优点是:

因为内存的大小和分配单元的大小决定了位图的大小,所以它提供了一种简单的利用一块固定大小的内存区就能对内存使用情况进行记录的方法。

它的缺点是:
在决定把一个占k个分配单元的进程调入内存时,存储管理器必须搜索位图,在位图中找出有k个连续0的串。查找位图中指定长度的连续0串是耗时的操作(因为在位图中该串可能跨越字的边界)。

4.3.3.2 使用链表的存储管理

另一种记录内存使用情况的方法是,维护一个记录已分配内存段和空闲内存段的链表。其中链表中的一个结点或者包含一个进程,或者是两个进程间的一块空闲区。可用上面图c) 所示的段链表来表示内存布局。链表中的每一个结点都包含以下域:空闲区(H)或进程(P)的指示标志、起始地址、长度和指向下一结点的指针。

启动 bios会存到内存_虚拟内存_25

段链表是按照地址排序的,其好处是当进程终止或被换出时链表的更新非常直接。一个要终止的进程一般有两个邻居(除非它是在内存的最底端或最顶端),它们可能是进程也可能是空闲区,这就导致了上图所示的四种组合。在图a) 中更新链表需要把P替换为H;在图b) 和图c) 中两个结点被合并为一个,链表少了一个结点;在图d) 中三个结点被合并为一个,从链表中删除了两个结点。

段链表使用双向链表可能要比的单向链表更方便。这样的结构更易于找到上一个结点,并检查是否可以合并。

当按照地址顺序在链表中存放进程和空闲区时

有几种算法可以用来为创建的进程(或从磁盘换入的已存在的进程)分配内存。假设存储管理器知道要为进程分配多少内存。

首次适配(first fit)算法

最简单的算法是首次适配算法。存储管理器沿着段链表进行搜索,直到找到一个足够大的空闲区,除非空闲区大小和要分配的空间大小正好一样,否则将该空闲区分为两部分,一部分供进程使用,另一部分形成新的空闲区。首次适配算法是一种速度很快的算法,因为它尽可能少地搜索链表结点。

下次适配(next fit)算法

它的工作方式和首次适配算法相同,不同点是每次找到合适的空闲区时都记录当时的位置,以便在下次寻找空闲区时从上次结束的地方开始搜索,而不是像首次适配算法那样每次都从头开始。但是测试发现,下次适配算法的性能略低于首次适配算法。

最佳适配(best fit)算法

最佳适配算法搜索整个链表,找出能够容纳进程的最小的空闲区。最佳适配算法试图找出最接近实际需要的空闲区,以最好地匹配请求和可用空闲区,而不是先拆分一个以后可能会用到的大的空闲区。因为每次调用最佳适配算法时都要搜索整个链表,所以它要比首次适配算法慢。但意外的是,它比首次适配算法或下次适配算法浪费了更多的内存,因为它会产生大量无用的小空闲区。一般情况下,首次适配算法生成的空闲区更大一些。

最差适配(worst fit)算法

为了解决最佳适配分裂出很多非常小的空闲区的问题,最差适配算法,即总是分配最大的可用空闲区,使新的空闲区比较大从而可以继续使用。仿真程序表明最差适配算法也不是一个好主意。

为了改进上面的四个算法,我们思考,是否可以为进程和空闲区维护各自独立的链表。这样就能集中精力只检查空闲区而跳过进程。但代价就是增加复杂度和减慢内存释放速度,因为必须将一个回收的段从进程链表中删除并插入空闲区链表。

我们再思考具体一点,如果空闲区有单独的链表,则可以按照大小对空闲区链表排序,以便提高最佳适配算法的速度,因为找到的第一个合适的区,就是能容纳这个作业的最小的空闲区,因此是最佳适配。首次适配算法就等同于最佳适配算法,而下次适配算法在这里则毫无意义。

在与进程段分离的单独链表中保存空闲区时,可以做一个小小的优化。因为不必用单独的数据结构存放空闲区链表,而可以利用空闲区存储这些信息。每个空闲区的第一个字可以是空闲区大小,第二个字指向下一个空闲区。

快速适配(quick fit)算法

它为那些常用大小的空闲区维护单独的链表。例如,一个n项的表,该表的第一项是指向大小为4KB的空闲区链表表头的指针,第二项是指向8KB,第三项是指向12KB,以此类推。像21KB这样大小特别的空闲区既可以放在20KB的链表中,也可以放在一个专门存放大小特别的空闲区的链表中。快速适配算法寻找一个指定大小的空闲区是十分快速的。

将空闲区按大小排序的方案都有一个共同的缺点,即在一个进程终止或被换出时,寻找它的相邻块并查看是否可以合并的过程是非常费时的。如果不进行合并,内存将会很快分裂出大量的进程无法利用的小空闲区。

以上就是有关存储器系统,存储器抽象,和交换技术的全部内容,但是这些技术都有明显的不足,因为软件大小的增长速度远大于存储器容量的增长速度,在这种情况下,大小不足承载单个个进程的内存又该如何解决问题呢?由此产生的虚拟内存技术,将在内存管理(下篇)中学习。