(一)虚拟存储器简介

虚拟存储器提供三个功能:

1. 将主存看成是一个存储在磁盘上地址空间的高速缓存,在主存中只保存活动区域;并根据需要在磁盘和主存之间来回传递数据。

2. 为每个进程提供一致的地址空间,从而简化存储器管理;

3. 保护了每个进程的地址空间不被其他进程破坏。

 

CPU通过生成一个虚拟地址(Virtual Adress,VA)来访问主存,这个虚拟地址在被送到存储器之前先转换成适当的物理地址。这一过程也叫做地址翻译。

地址翻译需要CPU和操作系统之间紧密合作:1)CPU芯片上叫做存储器管理单元(MMU,memory management unit)的专用硬件;2)操作系统管理在主存上的查询表。MMU利用查询表来动态翻译虚拟地址。

主存中的每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。

 

(二·一)VM功能1:作为缓存的工具(虚拟地址空间为N,物理地址空间为M)

概念上,VM被组织为一个由存放在磁盘上的N个连续字节大小的单元组成的数组。每个字节都有唯一的虚拟地址。磁盘上数组的内容被缓存在主存中。磁盘上的数组被分割成块,这些块作为磁盘和主存之间的传输单元。其中VM把虚拟存储器分割为虚拟页(VP)大小的固定的块,物理存储器也被分割成物理页(PP),大小都为P字节,物理页也叫页帧。

基于这些基础知识,我们就可以说明VM的功能了:

首先CPU产生一个VA,我们要检查VA对应的字节所在的页是否已经缓存在DRAM主存中。如果已经缓存,那么从主存中读取;如果没有缓存,那么从磁盘中读取到缓存,再从缓存读取。这里需要解决的是:如何通过一个VA,知道其在主存中的位置?亦或是在磁盘中的位置?

首先要搞明白:虚拟地址空间、磁盘和主存三者的关系

首先考虑虚拟地址空间与磁盘的关系。根据“VM被组织为存放在磁盘上N个连续字节大小的数组”我们可以得出:VM就是磁盘上的一个N字节数组(磁盘空间远大于N);对于VM来说,我们可以将N字节数组分成好多页,不必按顺序使用。

第二,主存只是上述关系中磁盘上N字节数组的一个缓存而已。因为我们最终是通过主存来读取数据,所以主存又叫物理存储器,其物理地址空间为M。

 

如此一来,VM作为缓存的工具就容易理解了:

yarn配置虚拟内存 csapp 虚拟内存_yarn配置虚拟内存

 

还有几个小问题:

  • DRAM比磁盘快10万倍,那么DRAM缓存不命中的开销就比较大——这就导致了DRAM缓存是全相联的(尽可能缓存数据);第二由于从磁盘读取第一个字节比读其他字节慢10倍,所以——虚拟页往往很大,典型地是4KB-2MB。
  • 由于局部性,程序往往会在一个较小的活动页面(active page)集合上工作,这个工作集合叫做工作集(working set)。如果工作集大小超出了物理存储器的大小,那么程序将产生一种不幸的状态——颠簸(thrashing),这时页面将不断地换进换出。虽然虚拟存储器通常是有效的,但是如果一个程序性能非常慢,那么程序员会考虑是不是发生了颠簸。

 

(二·二)VM功能2:作为存储器管理的工具

yarn配置虚拟内存 csapp 虚拟内存_虚拟存储器_02

根据之前我们关于VM、磁盘和主存三者关系的猜想,那么如何实现共享?如何实现存储器分配?

假设VM被组织为磁盘上连续的N字节数组,如何实现共享?当存储器分配时,如何分配页面?分配到什么地址上?之前说的“未分配的虚拟页”,可是如果以N连续字节模型的话,必然不存在‘未分配虚拟页“,因为必然占据N个字节。所以连续N字节模型是错误的。

VM仅仅是一串地址而已,其中的某些地址可以映射到磁盘的不同区域。而主存依然作为缓存。这一模型比较合理

 

(二·三)VM功能3:作为存储器保护的工具。

yarn配置虚拟内存 csapp 虚拟内存_虚拟存储器_03

 

(三·一)地址翻译过程

yarn配置虚拟内存 csapp 虚拟内存_虚拟存储器_04

yarn配置虚拟内存 csapp 虚拟内存_缓存_05

 

(三·二)组合高速缓存和虚拟存储器

根据之前的描述,高速缓存/主存,我们现在来细细讲一些这个词是什么意思。SRAM作为DRAM与CPU之间的缓存时,DRAM保存的物理页中的数据和PT中的数据都会被缓存到SRAM中。

将高速缓存和虚拟存储器结合起来的主要思路是:地址翻译发生在高速缓存查找之前

MMU中包括一个关于PTE的小的缓存,称为翻译后备缓冲器(Translation Lookaside Buffer,TLB)。TLB是一个小的、虚拟寻址的缓存,每一行都保存着一个又单个PTE组成的块,TLB具有高速的相联性。同时物理页中要查找的内容也要缓存在SRAM中。回想一下,之前的缓存结构,组索引+行标记+块偏移的组织结构:

  • 对于TLB来说,访问地址是VPN,缓存的是PTE,且每个块只有单个PTE,那么就不需要块偏移了——VPN被组织成组索引+行标记。若TLB有T=2t个组,则VPN的低t位为TLB索引(TLBI),剩余的为TLB标记(TLBT)。

    yarn配置虚拟内存 csapp 虚拟内存_yarn配置虚拟内存_06

  • 对于物理地址中字段的缓存来说,访问地址是(PPN+PPO),缓存的是块,那么(PPN+PPO)被组织成组索引+行标记+块偏移;按照之前所说的规则来组织即可。

 

第二个问题是页表太大了,对于32位地址空间,4KB页面,四字节的PTE来说,页表为4MB;所以我们需要压缩页表,最常用的方法是使用层次结构的页表。

yarn配置虚拟内存 csapp 虚拟内存_虚拟存储器_07

 

下面用一个实例来好好说明这一情况:(参见P547,端对端的地址翻译)

 

(四)Intel Core i7/Linux 存储器系统

yarn配置虚拟内存 csapp 虚拟内存_缓存_08

主要还是说一下这个四级页表的PTE格式。当P=0时,存储磁盘上的页表位置。

PTE有3个权限位:R/W确定页内容读写还是只读;U/S确定能否在用户模式下访问;XD(禁止执行)是64位系统引进的,禁止从某些页取指令。还有一些其他位,例如每次访问一个页时,会设置A(引用位),内核可以用它来实现页替换算法;每次对页写了之后,设置D(脏位),确定是否写回牺牲页。

当P=1时,对于第1,2,3级来说,地址字段包含40位,指向适当页表的开始处——要求物理页表4KB对齐;对于第4级页表,地址字段包含40位,指向物理存储器某一页的基地址——要求物理页4KB。

将虚拟地址翻译成物理地址:36位VPN划分成四个9位片,每个片用作一个也表的偏移量。CR3寄存器包含了L1页表的物理地址。VPN1提供了一个L1 PTE的偏移;这个PTE包含L2页表的基地址。VPN2提供L2 PTE偏移。。。

yarn配置虚拟内存 csapp 虚拟内存_缓存_09

Linux为每个进程维持一个单独的虚拟地址空间,分为内核虚拟存储器和进程虚拟存储器。

内核虚拟存储器:

  • 其中内核虚拟存储器的某些区域被映射到所有进程共享的物理页面,例如内核代码和数据;
  • 同时Linux将一组连续的虚拟页面(大小等于DRAM总量)映射到相应的一组连续的物理页面,这就为内核提供了一种便利的方法来访问物理存储器中任何特定的位置。
  • 其他区域包含每个进程的不相同的数据,例如,页表、内核在进程的上下文中执行代码使用的栈、记录虚拟地址空间当前组织的各种数据结构。

进程虚拟存储器不再赘述。

第二个问题:Linux是如何组织虚拟存储器的。

Linux将虚拟存储器组织成一些区域(也叫段)的集合。一个区域就是已经存在着的(已分配的)虚拟存储器的连续片(chunk),这些页以某种方式相关联。区域的概念很重要,因为它允许虚拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这些页也不占用存储器、磁盘和内核本身的任何额外资源。

内核为每个进程维护一个单独的任务结构task_struct。任务结构中的元素包含或者指向内核运行该进程所需要的信息(PID、PC、指向用户栈的指针、可执行目标文件的名字等);

其中一个条目指向mm_struct,它描述了虚拟存储器的当前状态。其中pdg指向第一级页表的基址,而mmap指向一个vm_area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域(area)。当内核运行时,将pdg存放在CR3中。

一个具体的区域包含如下结构:

wm_start/vm_end

vm_prot:描述这个区域包含的所有页的读写许可权限

wm_flags:描述是与其他进程共享还是这个进程私有的。

 

(五)存储器映射

Linux的虚拟存储器被组织为一些区域。那么,这些区域是什么来的??且这些区域是什么??

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

  • Unix文件系统中的普通文件。一个区域可以映射到一个普通磁盘文件的连续部分。文件区(section)被分成页大小的片,每一片包含虚拟页面的初始内容。因为按需页面调度,所以这也虚拟页面没有实际进入物理存储器,直到CPU第一次引用页面。如果区域比文件区要大那么就用零来填充这个区域的剩余部分;
  • 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件由内核创建,包含的全是二进制0.CPU第一次引用这样一个区域的虚拟页面时,内核就在物理存储器中找到一个合适的牺牲页面,用二进制0覆盖牺牲页并更新页表,这个页面是驻留在存储器中的,磁盘与存储器之间并没有实际的数据传送。

无论哪种情况,一旦虚拟页面初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。任何时刻,交换文件(或交换空间、交换区域)都限制着当前运行着的进程能够分配的虚拟页面总数。

 

至此,我们已经完全明白了Linux的虚拟存储器系统,那么再回过头来看:共享对象、fork函数和execve函数:

yarn配置虚拟内存 csapp 虚拟内存_缓存_10

(补充:int munmap(void *start, size_t length) 删除从虚拟地址start开始,接下来length字节组成的区域。)

 

(六)动态存储器分配

虽然使用低级的mmap和munmap可以创建和删除虚拟存储器的区域,但是C程序员会使用动态存储器分配器(dynamic memory allocator)。

[这句话的意思是:动态存储器分配器与mmap/munmap是对等关系]

动态存储器分配去维护一个进程的虚拟存储器区域,称为堆(heap)。假设堆是一个请求的二进制0区域,对于每个进程,内核维护一个变量brk,指向堆的顶部

分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟存储器片(chunk),要么是已分配的,要么是空闲的

分配器有两种基本风格,但都要求显式地分配块。显式分配器:要求应用显式地释放块;隐式分配器:也叫垃圾收集器。

 

我们需要考虑几个问题:1.区域、堆、块之间的关系。2.堆空间不够了怎么办? 3.分配器如何维护块?

yarn配置虚拟内存 csapp 虚拟内存_yarn配置虚拟内存_11

 

如何评估分配器的性能?对于一系列n个分配和释放请求的指令序列:1.吞吐率:每个单位时间里完成的请求数。2.存储器利用率:我们使用峰值利用率来表示。这两个性能之间存在相互牵制。

造成堆利用率低的主要原因是碎片(fragmentation)现象。内部碎片是:一个已分配的块比有效载荷大,例如:对分配块强加最小的size;对齐要求等。外部碎片是:当空闲存储器合计起来足够满足一个分配请求时,没有一个单独的空闲块足够大可以来处理这个请求。

因为外部碎片难以量化且不可预测,所以分配器采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。

 

当一个实际分配器要在吞吐率和利用率之间把握一个平衡时,需要考虑几个问题:

1.空闲块组织:如何组织空闲块?

2.放置:如何选择一个合适的空闲块来放置一个新分配块?

3.分割:如何利用空闲块的剩余部分?

4.合并:如何处理刚刚释放的块?

 

方案一:

yarn配置虚拟内存 csapp 虚拟内存_主存_12

[通过上面的描述,我们可以把堆想象成一个vector,一开始的时候,大小是0;随着要求的增加,以vector的方式进行增长]

我们自己实现一个简单分配器,其挑战在与:1)设计空间很大:有多种块格式、空闲链表格式、以及放置、分割和合并策略可供选择;2)经常被迫在类型系统的安全和熟悉的限定之外编程,依赖于容易出错的指针类型转换和指针运算。

yarn配置虚拟内存 csapp 虚拟内存_虚拟存储器_13

(细节见Page570)

------------------------------------------------------------------------------------------------------------------------------------------------------------------------

yarn配置虚拟内存 csapp 虚拟内存_缓存_14

yarn配置虚拟内存 csapp 虚拟内存_yarn配置虚拟内存_15

 

(七)隐式分配器——垃圾收集

垃圾收集器将存储器视为一张有向可达图;该图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中的一个已分配的块。根节点对应于不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈中的变量、或者寻你存储器的读写数据区域内的全局变量。

垃圾收集器释放不可达节点。

yarn配置虚拟内存 csapp 虚拟内存_虚拟存储器_16

 

(八)C程序中常见的与存储器有关的错误

yarn配置虚拟内存 csapp 虚拟内存_yarn配置虚拟内存_17