第9章--虚拟内存

为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,他为每个进程提供了一个大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力:1)他将内存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效的使用了主存。2)他为每个进程提供了一致的地址空间,从而简化了内存管理。3)它保护了每个进程的地址空间不被其他进程破坏。

一、物理和虚拟寻址

计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(PA)。第一个字节的地址为0,接下来的字节地址为1,再下一个为2,以此类推。给定这种简单的结构,CPU访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址。

使用虚拟寻址,CPU通过生成一个虚拟地址(VA)来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。将一个虚拟地址转换成物理地址的任务叫做地址翻译。就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。

二、虚拟内存作为缓存的工具

和存储器层次结构中其他缓存一样,VM系统将虚拟内存分割为大小固定的(P字节)虚拟页,类似的,物理内存也被分割为大小相同的(P字节)物理页。

在任意时刻,虚拟页面的集合都分为三个不相交的子集:

  • 未分配的:VM系统还未分配(或者创建)的页。未分配的页没有任何数据和他们相关联,因此也就不占用任何磁盘空间;
  • 缓存的:当前已缓存在物理内存中的已分配页;
  • 未缓存的:未缓存在物理内存中的已分配页;

1. 页表

同任何缓存一样,虚拟内存系统必须有某种方法判定一个虚拟页是否缓存在主存的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到主存中,替换这个牺牲页。

这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与主存之间来回传送页。

2. 缺页

在虚拟内存的习惯说法中,缓存不命中称为缺页。图9-6展示了在缺页之前页表的状态。CPU引用了VP3中的一个字。VP3并未缓存在主存中。地址翻译硬件从内存中读取PTE3,从有效位判断出VP3未被缓存,并且触发一个缺页异常。缺页异常调度内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中。

接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,他会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。

3. 又是局部性救了我们

当我们中的许多人都了解了虚拟内存的概念之后,我们的第一印象通常是它的效率应该是非常低。因为不命中惩罚很大,我们担心页面调度会破坏程序性能。实际上,虚拟内存工作的相当好,这主要归功于我们的老朋友局部性。

尽管在整个运行过程中程序引用的不同页面的总数可能超出物理内存总的大小,但是局部性原则保证了在任意时刻,程序将趋向于在一个较小的活动页面集合上工作,这个集合叫做工作集或者常驻集合。在初始开销,也就是将工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。

只要我们的程序有好的时间局部性,虚拟内存系统就能工作的相当好。但是,当然不是所有的程序都能展现良好的时间局部性。如果工作集的大小超出了物理内存的大小,那么程序将产生一种不幸的状态,叫作抖动,这是页面将不断的换进换出。虽然虚拟内存通常是有效的,但是如果一个程序性能慢得像爬一样,那么聪明的程序员会考虑是不是发生了抖动。

三、虚拟内存作为内存管理的工具

按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深远的影响。特别地,VM简化了链接和加载、代码和数据共享,以及应用程序的内存分配。

  • 简化链接。独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。对于64位地址空间,代码段总是从虚拟地址0x400000开始。数据段跟在代码段之后,中间有一段符合要求的对齐空白。栈占据用户进程地址空间最高的部分,并向下生长。这样的一致性极大地简化了链接器的设计和实现,允许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内存中代码和数据的最终位置的。
  • 简化加载。虚拟内存还使得容易向内存中加载可执行文件和共享对象文件。要把目标文件中.text和.data节加载到一个新创建的进程中,Linux加载器为代码和数据段分配虚拟页,把他们标记为无效的(即未被缓存的),将页表条目指向目标文件中适当的位置。有趣的是,加载器从不从磁盘到内存实际复制任何数据。在每个页初次被引用时,要么是CPU取指令时引用的,要么是一条正在执行的指令引用一个内存位置时引用的,虚拟内存系统会按照需要自动的调入数据页。
  • 简化共享。独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。一般而言,每个进程都有自己是私有的代码、数据、堆以及栈区域,是不和其他进程共享的。在这种情况中,操作系统创建页表,将相应地虚拟页映射到不连续的物理页面。然而,在一些情况中,还是需要进程来共享代码和数据。例如,每个进程必须调用相同的操作系统内核代码。操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个副本,而不是在每个进程中都包括单独的内核副本。
  • 简化内存分配。虚拟内存为向用户进程提供一个简单的分配额外内存的机制。当一个运行在用户进程中的程序要求额外的堆空间时(如调用malloc的结果),操作系统分配一个适当数字(例如k)个连续的虚拟内存页面,并且将它们映射到物理内存中任意位置的k个任意的物理页面。由于页表工作的方式,操作系统没有必要分配k个连续的物理内存页面。页面可以随机的分散在物理内存中。

四、虚拟内存作为内存保护的工具

任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。不应该允许一个用户进程修改它的只读代码段。而且也不应该允许它读或修改任何内核中的代码和数据结构。不应该允许它读或者写其他进程的私有内存,并且不允许他修改任何与其他进程共享的虚拟页面,除非所有的共享者都显式的允许他这么做(通过调用明确的进程间通信系统调用)。

就像我们所看到的,提供独立的地址空间使得区分不同进程的私有内存变得容易。但是,地址翻译机制可以以一种自然的方式扩展到提供更好的访问控制。因为每次CPU生成一个地址时,地址翻译硬件都会读一个PTE,所以通过在PTE上添加一些额外的许可位来控制一个虚拟页面内容的访问十分简单。

如果一条指令违反了许可条件,那么CPU就触发一个一般保护故障,将控制传递给一个内核中的异常处理程序。Linux shell一般将这种异常报告为“段错误”。

五、Linux虚拟内存系统

Linux为每个进程维护了一个单独的虚拟地址空间,形式如图9-26所示。其中进程虚拟内存包括了代码、数据、堆、共享库以及栈段,内核虚拟内存包括了内核中的代码和数据结构,这些区域被映射到所有进程共享的物理页面。有趣的是,Linux也将一组连续的虚拟页面(大小等于系统中主存的总量)映射到相应的一组连续的物理页面。这就为内核提供了一种便利的方法来访问物理内存中任何特定的位置。内核虚拟内存的其他区域还包含了每个进程都不相同的数据。比如说,页表、内核在进程的上下文中执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。
在这里插入图片描述

六、内存映射

Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存区域可以映射到两种类型的对象中的一种:

1)Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。

2)匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。

1. 共享对象

一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到他们虚拟内存的其他进程而言,也是可见的。而且,这些变化也会反映在磁盘上的原始对象中。

另一方面,对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所作的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟内存区域叫做共享区域。类似的,也有私有区域。

私有对象使用一种叫做写时复制的巧妙技术被映射到虚拟内存中。

2. fork函数

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页面的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

3. execve函数

execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效的替代了当前程序。加载并运行a.out需要以下几个步骤:

  • 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域机构。
  • 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss区域是请求二进制零的,初始长度为零。
  • 映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  • 设置程序计数器(PC)。最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

4. 使用mmap函数的用户级内存映射

Linux进程可以使用mmap函数来创建新的虚拟内存区域,并将对象映射到这些区域中。

#include <unistd.h>
#include <sys/mman.h>

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
// 若成功则返回指向映射区域的指针,若失败则为MAP_FAILED(-1)。

mmap函数要求内核创建一个新的虚拟内存区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片映射到这个新的区域。连续的对象片大小为length字节,从距文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL。

参数prot包含描述新映射的虚拟内存区域的访问权限位(即在相应区域结构中的vm_prot位)。

  • PROT_EXEC:这个区域内的页面由可以被CPU执行的指令组成。
  • PROT_READ:这个区域内的页面可读。
  • PROT_WRITE:这个区域内的页面可写。
  • PROT_NONE:这个区域内的页面不能被访问。

参数flags由描述被映射对象类型的位组成。如果设置了MAP_ANON标记位,那么被映射的对象就是一个匿名对象,而相应的虚拟页面是请求二进制零的。MAP_PRIVATE表示被映射的对象是一个私有的、写时复制的对象,而MAP_SHARED表示是一个共享对象。

#include <unistd.h>
#include <sys/mman.h>

int munmap(void *start, size_t length);  // 若成功则返回0,若出错则为-1。

munmap函数删除从虚拟地址start开始的,有接下来length字节组成的区域。接下来对已删除区域的引用会导致段错误。

七、动态内存分配

动态内存分配其维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,他紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示的被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格。两种风格都要求应用显示的分配块。它们的不同之处在于有哪个实体来负责释放已分配的块。

  • 显示分配器:要求应用显式的释放任何已分配的块。
  • 隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器。

1. malloc和free函数

C标准库提供了一个称为malloc程序包的显示分配器。程序通过调用malloc函数来从堆中分配块。

#include <stdlib.h>

void *malloc(size_t size);  // 若成功则返回已分配块的指针,若出错则为NULL。

malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。

如果malloc遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回NULL,并设置errno。malloc不初始化他返回的内存。那些想要已初始化的动态内存的应用程序可以使用calloc,calloc是一个基于malloc的瘦包装函数,他将分配的内存初始化为零。想要改变一个以前已分配块的大小,可以使用realloc函数。

程序是通过调用free函数来释放已分配的堆块。

#include <stdlib.h>

void free(void *ptr);

ptr参数必须指向一个从malloc、calloc或者realloc获得的已分配块的起始位置。如果不是,那么free的行为就是为定义的。更糟的是,既然他什么都不返回,free就不会告诉应用出现了错误。

2. 分配器的要求和目标

显示分配器必须在一些相当严格的约束条件下工作:

  • 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,分配器不可以假设分配和释放请求的顺序。
  • 立即响应请求。分配器必须立即响应分配请求,不允许分配器为了提高性能重新排列或者缓冲请求。
  • 只使用堆。为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里。
  • 对齐块。分配器必须对齐块,使得它们可以保存任何类型的数据对象。
  • 不修改已分配的块。分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。

在这些限制条件下,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,而这两个性能目标通常是相互冲突的。

3. 碎片

造成堆利用率很低的主要原因是一种称为碎片的现象,当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有以下两种形式的碎片:

  • 内部碎片:是在一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。例如,一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小要比某个请求的有效载荷大。
  • 外部碎片:是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。

八、垃圾收集

垃圾收集器是一种动态内存分配器,它自动释放程序不再需要的已分配块,这些块被成为垃圾。自动回收堆存储的过程叫做垃圾收集。在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显式地释放他们。在C程序的上下文中,应用调用malloc,但是从不调用free。反之,垃圾收集器定期识别垃圾块,并相应地调用free,将这些块放回到空闲链表中。

1. 垃圾收集器的基本知识

垃圾收集器将内存视为一张有向可达图,其形式如图9-49所示。该图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中的一个已分配块。有向边p->q意味着块p中的某个位置指向块q中的某个位置。根节点对应于这样一种不再堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。

当存在一条从任意根节点出发并到达p的有向路径时,我们说节点p是可达的。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期的回收他们。

九、C程序中常见的与内存有关的错误

1. 间接引用坏指针

正如我们在前面说到的,在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据,如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常终止程序。而且,虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常终止这个程序。

2. 读未初始化的内存

虽然bss内存位置(诸如未初始化的全局C变量)总是被加载器初始化为零,但是对于堆内存却并不是这样的。

3. 允许栈缓冲区溢出

如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误。

4. 假设指针和它们指向的对象是相同大小的

一种常见的错误是假设指针和它们指向的对象是相同大小的。

5. 造成错位错误

错位错误是另一种很常见的造成覆盖错误的来源。

6. 引用指针,而不是它所指向的对象

如果不太注意C操作符分优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象。

7. 误解指针运算

另一种常见的错误是忘记了指针的算术操作是以他们指向的对象的大小为单位来进行的,而这种大小单位并不一定是字节。

8. 引用不存在的变量

没有太多经验的C程序员不理解栈的规则,有时会引用不再合法的本地变量。

9. 引用空闲堆块中的数据

一个相似的错误是引用已经被释放了的堆块中的数据。

10. 引起内存泄漏

内存泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放已分配块,而在堆里创建了垃圾时,会发生这种问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
《深入理解计算机系统》是由布莱恩特和欧哈拉合著的一本经典计算机教材。第九“虚拟存储器”介绍了操作系统如何管理计算机的内存和磁盘空间,提高计算机系统的性能和可用性。 在这一中,作者首先介绍了虚拟存储器的概念和设计原理。虚拟存储器是一种计算机存储技术,它允许计算机使用比实际物理内存更大的逻辑内存空间,将物理内存和磁盘空间组合起来形成一个统一的地址空间。这样,即使程序需要的内存超过了实际物理内存的大小,也能够正常执行。 接着,作者介绍了虚拟存储器的实现技术,如页面分配和置换算法。通过将程序的逻辑地址空间分割成固定大小的页面,操作系统可以将页面从磁盘加载到内存中,并根据需要进行页面置换。这些技术能够提高内存利用率和程序的执行性能。 作者还讨论了虚拟存储器的性能优化和问题处理。例如,通过合理设置页面大小、调整页面置换算法和增加内存容量,可以提高虚拟存储器的性能。此外,当程序访问的数据不在内存中时,操作系统需要进行页面调度和磁盘IO操作,对于这些问题,作者也给出了解决方案。 最后,作者介绍了一些现代计算机系统中的虚拟存储器实现技术,如地址转换缓冲区、TLB缓存和多级页表。这些技术进一步提高了虚拟存储器的性能和可靠性。 综上所述,第九“虚拟存储器”介绍了虚拟存储器的概念、设计原理、实现技术和性能优化。通过深入理解这些知识,可以帮助我们更好地理解计算机系统的内存管理和磁盘空间利用,从而提高计算机系统的性能和可用性。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值