内存管理
前言
计算机系统的主要用途是执行程序。在执行是,这些程序及其所访问的数据必须在内存里。为改善CPU的使用率和对用户的相应速度,计算机必须在内存里保留多个进程。内存管理方案有很多,以适应各种不同的需求,每个算法的有效性与特定情况有关。对系统内存管理方案的选择取决于很多因素,特别是系统的硬件设计 。每个算法都需要有自己的硬件支持。
因为到了新的部分——内存。新的专有名词会很密集的出现,但也正是这些专有名词才是学习的基础。前面的进度可能会慢一些。感觉书上的每一句话都有用。
一、背景
内存是现代计算机运行的中心。
内存管理的应用场景:
- CPU根据程序计数器的值从内存中提取指令,这些指令可能会引起进一步特定内存地址的读取和写入。
- 一个典型指令执行周期,首先从内存中读取指令。接着该指令被解码,且可能需要从内存中读取操作数。在指令对操作数执行后,其结果可能被存回到内存中。内存单元只能看到地址流,而并不知道这些地址是如何产生的。
1. 基本硬件
背景: CPU所能直接访问的存储器只有内存和处理器内的寄存器。机器指令可以用内存地址作为参数,而不能使用磁盘地址作为参数。 因此,执行指令以及指令使用的数据必须在这些直接可访问的存储设备上。如果数据不存
在内存中,那么在CPU使用前先把数据移动到内存中。
中心问题: 如何确保CPU可以正确、高效地从内存中提取指令.
- CPU内置寄存器通常可以在一个CPU时钟周期内完成访问,而完成内存访问可能需要多个CPU时钟周期。由于没有数据以便完成正在执行的指令,CPU通常需要暂停(stall)。由于内存访问频繁,经常的暂停难以忍受。解决办法是在CPU与内存之间,增加高速缓存(cache)。
- 除了保证访问物理内存的相对速度之外,还要确保操作系统不被用户进程所访问。这个问题我们会在后面详细讨论,这里只提及一种可能的方案:确保每个进程有独立的内存空间和内存空间的保护。
确保每个进程有独立的内存空间:
- 我们需要确定进程可访问的合法地址的范围,并确保进程只访问其合法地址。
- 我们可以通过基地址寄存器(base register) 和 界限地址寄存器(limit register) 实现。
- 基地址寄存器含有最小的合法物理内存地址,界限地址寄存器决定了范围的大小。
内存空间的保护:
- 通过CPU硬件对用户模式所产生的每一个地址与寄存器的地址进行比较来完成的。
- 比如,用户模式下执行的程序试图访问操作系统内存或其他用户内存,则会陷入操作系统,并作为致命错误处理。
- 只有操作系统可以通过特殊的特权指令来加载和修改基地址寄存器和界限地址寄存器。
2. 地址绑定
背景: 通常,程序以二进制可执行文件的形式存储在磁盘上。为了执行,程序被调入内存并放在进程空间内。根据所使用的内存管理方案,进程在执行时可以在磁盘和内存之间移动。在磁盘上等待调入内存以便执行的进程形成输入队列。
进程执行过程: 通常的步骤是从输入队列选取一个进程并装入内存。进程在执行时,会访问内存中的指令和数据。最后,进程终止,其地址空间将被释放。
进程装入内存时,放在什么位置?
- 如果可以放在物理内存的任意位置,那么会影响用户程序可以使用的地址空间。
通常,将指令与数据绑定到内存地址有以下几种情况:
- 编译时(compile time)绑定: 如果在编译时就知道进程将在内存中驻留的地址,那么可以生成绝对代码。例如进程驻留在内存地址R处,则生成的编译代码就从R处开始向后扩展,若开始地址发生改变,需要重新编译。缺点: 不利于程序的浮动,不支持虚拟内存机制。
- 加载时(load time)绑定: 绑定在加载时完成。若开始地址改变,只需要重新加载用户代码来引入改变值。缺点: 不利于程序的浮动,不支持虚拟内存机制。
- 执行时(execution time)绑定: 进程在执行时可以从一个内存段移动到另一个内存段,则绑定必须延迟到执行时完成。需要有硬件支持,支持虚拟内存机制。
3. 逻辑地址空间与物理地址空间
背景:
- CPU所生成的地址通常称为逻辑地址或虚拟地址。而内存单元所看到的地址加载到内存地址寄存器,通常称为物理地址。
- 编译和加载时的地址绑定方法生成相同的逻辑地址和物理地址。执行时的地址绑定方案导致不同的逻辑地址和物理地址。
- 由程序所生成的所有逻辑地址的集合称为逻辑地址空间,与这些逻辑地址相对应的所有物理地址的集合称为物理地址空间。
- 运行时从虚拟地址到物理地址的映射是由被称为内存管理单元(memory-management unit , MMU)的硬件来完成的。
- 在映射视角下,基地址寄存器称为重定位寄存器。
- 用户程序决不会看到真正的物理地址。用户程序一直使用的是逻辑地址,只有当它作为内存地址时(在间接加载和存储时),它才进行相对于基地址寄存器的重定位。
- 用户程序处理逻辑地址,内存映射硬件将逻辑地址转变为物理地址。
- 总结: 现在有两种不同的地址,逻辑地址(范围从0到max)和物理地址(范围为R+0到R+max,其中R为基地址)。用户只生成逻辑地址,且认为进程的地址空间为0到max。用户提供逻辑地址,这些地址在使用前必须映射到物理地址。
- 逻辑地址空间绑定到单独的一套物理地址空间这一概念对内存的管理至关重要。
以及:
4. 动态加载
- 我们以上的讨论都是基于进程的整个程序和数据都必须处于物理内存中。
- 为了获得更好的内存使用率,采用动态加载时,一个子程序只有在调用时才被加载。所有子程序都以可重定位的形式保存在磁盘上。
- 优点:提高了内存的利用率。
- 缺点:管理复杂,执行速度慢。
5. 动态链接
- 动态链接的概念与动态加载相似。只是这里把链接延迟到运行时。
- 为了使用动态链接,内存中的二进制镜像对每个库的引用都有一个存根(stub)。存根是一小段代码,用来指出如何定位适当的内存驻留库程序,或如果该程序不在内存时如何装入库。
- 当执行存根时,它首先检查所需子程序是否已在内存中。如果不在,就将子程序装入内存。
- 动态链接可用于库更新,且不需要重新加载和链接。
- 使用动态链接一般需要操作系统协助。
二、交换
交换: 进程需要在内存中以便执行。不过,进程可以暂时从内存中交换到备份存储中上,当需要再次执行时再调回到内存中,称为交换。
在理想情况下,内存管理器可以以足够快的速度交换进程,以便当CPU调度器需要调度CPU时,总有进程在内存中可以执行。此外,时间片必须足够大,至少要比上下文交换时间大,以保证交换之间可以进行一定量的计算。
三、连续内存分配
从连续内存分配开始,到分段,都是将多个进程同时放在内存中时,分配内存空间的策略。
1. 内存映射与保护
利用重定位寄存器和界限地址寄存器实现(上面已经提到过)。
2. 内存分配
内存连续分配即调入内存的每个进程都会被分配一段连续的物理内存区域。
固定分区: 是最简单的内存分配方法,将内存分为多个大小固定的分区。并且每个分区只能容纳一个进程。
可变分区: 在可变分区方案里,操作系统有一个表,用于记录哪些内存可用和哪些内存已经被占用。一开始,所用内存都可用于进程,因此可以作为一大块可用内存,称为孔(hole)。
分配过程: 当有新进程需要内存时,为该进程查找足够大的孔并分配。剩余为分配的内存空间会形成新的孔或者外部碎片。
常用的分配算法:
- 首次适应: 分配一个足够大的孔。需要遍历整个列表,找到第一个足够大的孔。
- 最佳适应: 分配最小的足够大的孔。也需要遍历整个列表。
- 最差适应: 分配最大的孔。也需要遍历整个列表。
3. 碎片
首次适应方法和最佳适应方法都有外部碎片问题(external fragmentation)。
外部碎片: 随着进程装入和移出内存,空闲内存空间被分为小片段。当所有总的可用内存之和可以满足请求,但并不连续时,这就出现了外部碎片问题。
50%规则: 假定有N个可分配块,那么可能有0.5N个块为外部碎片。即1/3的内存可能不能使用。
解决外部碎片的方法:
- 紧缩(compaction): 解决外部碎片的方法之一,移动内存内容,以便所有空闲空间合并成一整块。紧缩仅在重定位是动态并且运行时才可以采用。
- 允许物理地址空间非连续。下面的分页和分段技术,就是讲的这个。
内部碎片: 进程被分配的内存可能比所要的大。比如进程需要1022B内存,分配了1024B,那么就有2B内部碎片。
四、分页
这里的内存分配方案是非连续的。各种形式的分页由于其优越性,因此通常被绝大多数操作系统所采用。
1. 基本方法
首先明确三个大小相等的概念,帧(frame)、页(page)、备份存储中的块。
- 帧: 将物理内存分为固定大小的块,称为帧。
- 页: 逻辑内存也分为同样大小的块。当需要执行进程时,其页从备份存储中调入到可用的内存帧中。
- 备份存储中的块: 备份存储中你的固定大小的块。
上图为逻辑地址的组成,其中包括页号和页偏移。
- 页号: 页号作为页表的索引。可以通过页表找到物理地址的基地址。
- 页偏移: 上一步找到的基地址加上页偏移就是真正的物理地址。
直接上图:
- 逻辑地址----页表----物理地址
- 分页具体过程
- 具体的例子:分页过程
- 空闲帧:分配前与分配后
2. 硬件支持
不同的操作系统有自己的方式来保存页表。页表的指针与其他寄存器的值一起存入进程控制块中。
页表的硬件实现有多种方法:
- 用一组专用寄存器来实现:寄存器的访问速度快,所以地址变换的过程也快。但是只适用于页表比较小的时候。如果页表较大,则开销太大。
- 将页表放入内存,使用页表基寄存器(page-table base register PTBR)指向页表,页表基寄存器长度记录页表长度。采用这种方法的问题是访问用户内存为止需要额外时间。访问一个字节需要两次内存访问(一次用于页表条目,一次用于字节)。这样,内存访问速度就减半。在大多数情况下,这种延迟还不如采用交换机制。
- 使用转换表缓冲区(translation look-aside buffers,TLB): 类似于高速缓存,是一种快速内存。可以理解为寻找地址时的cache。
有了TLB,进了有个一个新的概念:有效内存访问时间
这个时间和TLB命中的概率直接相关。假设单独访问内存需要100ns,查找TLB需要20ns。如果TLB命中,内存映射只需要120ns。如果TLB没有命中,就需要利用页表基寄存器在内存中寻找页表已得到帧号,额外需要100ns,共220ns。
假设命中率为0.98,那么有效访问时间 t = 0.98 ∗ 120 + 0.02 ∗ 220 = 122 ( n s ) t = 0.98*120+0.02*220 = 122(ns) t=0.98∗120+0.02∗220=122(ns)。
在这种命中率下,内存访问速度只慢了22%。
3. 保护
复习一下哈,内存保护是指每个进程都有独立的内存空间以及内存空间保护(防止其它用户程序修改操作系统或其它用户程序)。所以在分页的情况下,如何进行保护呢
- 可以通过与每个帧相关联的保护位来实现。设置有效-无效位。当该位位有效时,表示相关的页在进程的逻辑地址空间内,因此是合法的页;当该位为无效时,表示相关的页不在进程的逻辑地址空间内。因此通过有效-无效位可以捕捉到非法地址。操作系统通过对该位的设置可以允许或不允许对某位的访问。
- 还可以提供硬件实现页表长度寄存器来表示页表的大小,该寄存器的值可用于检查每个逻辑地址已验证其是否位于进程的有效范围内。
4. 共享页
以分时环境为例,如果5个用户都打开了浏览器,那么不一定需要在内存中执行五个完整的浏览器进程代码,因为代码会有一大部分是相同的。这5个用户可以共享那些相同的代码,前提是代码是可重入代码。
可重入代码: 多次运行,内容不变。
五、页表结构
我们刚才提到有各种各样的页表结构,这里我们了解三种。
1. 层次页表
因为页表可能非常大,内存找不到如此大的连续空间,所以将页表划分成更小的部分进行存放,即将页表再分页。
以二级分页算法为例:
如果页大小为4KB,即 2 12 B 2^{12}B 212B的32位系统为例。如果不采用层次页表,那么页号部分占20位,即有 2 20 2^{20} 220个4KB大小的页。假设页表的每个条目占4B,那么每个进程需要4MB物理地址来存储页表本身。这样消耗过大。
于是我们使用二级存储,分内页和外页。
逻辑地址内容如下:
页表的大小直接由 2 20 B 2^{20}B 220B缩小到了 2 10 B 2^{10}B 210B。
2. 哈希页表
哈希页表的原理哈希,是数据结构中的部分。处理超过32位地址空间的常用方法是使用哈希页表(hashed page table),并以虚拟页码作为哈希值。
哈希页表的组成: 哈希页表的每个条目都包括一个链表的元素,这些元素哈希成同一位置(要处理碰撞)。每个元素有三个域:
- 虚拟页码
- 所映射的帧号
- 指向链表中下一个元素的指针。
哈希页表工作方式: 虚拟地址中的虚拟页号转换到哈希表中,用虚拟页号与链表中的每一个元素的第一个域相比较。如果匹配,那么相应的帧号就用来形成物理地址。如果不匹配,就利用线性开址处理碰撞,与链表中的下一个节点进行比较,以寻找一个匹配的页号。
3. 反向页表
以上的讨论,都是基于每一个进程拥有一个页表。该进程所使用的每一个页,在页表中都有一项。而反向页表则不同,反向页表需要我们反向的视角来理解。
反向页表: ** 反向页表对于每个真正的内存页或帧才有一个条目。每个条目包含保存在真正内存位置的页的虚拟地址以及拥有该页的进程信息**。整个系统只有一个页表。
六、分段
采用分页内存管理有一个不可避免的问题,用户视角的内存和实际物理内存的分离。用户视角的内存与实际物理内存不一样。用户视角的内存需要映射到实际物理内存,该映射允许区分逻辑内存和物理内存。
1. 基本方法
用户眼中的程序:
分段: 分段就是支持这种用户视角的内存管理方案。逻辑地址空间是由一组段组成的。 每个段都有名称和长度。地址指定了段名称和段内偏移(与分页方案中的逻辑地址非常相似)。
一个C编译器可能会创建如下段:
- 代码
- 全局变量
- 堆
- 每个线程采用的栈
- 标准的C库函数
在编译时链接的库可能被分配不同的段。加载程序会装入所有这些段,并为它们分配短号。
2. 硬件
段表: 虽然用户现在能够通过二维地址来引用程序中的对象,但是实际物理内存仍然是一维序列的字节。因此,必须定义一个实现方式,以便将二维的用户定义地址映射为一维物理地址。这种映射是通过段表(segment table)来实现的。