20135220谈愈敏-信息安全系统设计基础第十二周学习总结
第九章 虚拟存储器
系统中的进程是共享CPU和主存资源的,所形成的挑战:
进程以平滑方式慢了下来
某些进程无法运行
存储器容易被破坏
进程写了其他进程的存储器
为了有效的管理存储器,提供了对主存的抽象概念:虚拟存储器
虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,为每个进程提供了一个大的、一致的和私有的空间。三个重要能力:
(1)它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,
并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。
(2)它为每个进程提供了一致的地址空间,从而简化了存储器管理。
(3)它保护了每个进程的地址空间不被其他进程破坏。
理解虚拟存储器:
虚拟存储器是中心的
虚拟存储器是强大的
虚拟存储器是危险的
本章总括:
前一部分描述虚拟存储器是如何工作的
后一部分描述应用程序如何使用和管理虚拟存储器。
9.1 物理和虚拟地址
主存被组织成一个由M个连续的字节大小的单元组成的数组。
每字节都有一个唯一的物理地址(Physical Address,PA)。第一个字节的地址为0,累加。
- 物理寻址(virtual addressing):CPU访问存储器的最自然的方式就是使用物理地址。
早期的PC、数字信号处理器、嵌入式微控制器、Cray超级计算机这样的系统仍然使用物理寻址。
-
虚拟寻址(virtual addressing)CPU通过生成一个虚拟地址来访问主存。
现在处理器使用虚拟寻址。 -
地址翻译(address translation):将一个虚拟地址转换为物理地址的任务。
地址翻译需要CPU和操作系统之间的紧密合作。 -
存储器管理单元(Memory Management Unit,MMU):CPU芯片上的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容是由操作系统管理的。
9.2 地址空间
-
地址空间(adress space)是一个非整数地址的有序集合:
-
如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space)。
-
在一个带虚拟存储器的系统中,CPU从一个有N = 2 ^ n个地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space):
-
一个地址空间的大小是由表示最大地址所需要的位数来描述的。例如,一个包含N=2^n个地址的虚拟地址空间叫做一个n位地址空间。现在系统典型地支持32位或者64位虚拟地址空间是。
-
一个系统还有一个物理地址空间(physical addresss space),它与系统中物理存储器的M字节相对应:
M不要求是2的幂,但是为了简化讨论,我们假设M = 2 ^ m。
地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地址)。一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟存储器的基本思想。主存中每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。
9.3 虚拟存储器作为缓存的工具
概念上而言,虚拟存储器(VM)被组织为一个由存放在磁盘上N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,这个唯一的虚拟地址是作为到数组的索引的。磁盘上的数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟存储器分割称为虚拟页(Vitual Page,VP)的大小固定的块来处理这个问题。每个虚拟页的大小为P = 2 ^ n字节。类似地,物理存储器被分割为物理页 (Physical Page,PP),大小也为P字节(物理页也称为页帧(page frame))。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
-
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。(没有调用malloc或者mmap的)
-
缓存的:当前缓存在物理存储中的已分配页。(已经调用malloc和mmap的,在程序中正在引用的)
-
未缓存的:没有缓存在物理存储器中的已分配页。(已经调用malloc和mmap的,在程序中还没有被引用的)
DRAM缓存的组织结构
DRAM缓存:表示虚拟存储器系统的缓存,它在主存中缓存虚拟页。
DRAM缓存总是使用写回,而不是直写。
页表
同任何缓存一样,虚拟存储器系统必须有某种方法来判定一个虚拟页是否存放在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理存储器中选择一个牺牲页,并将虚拟页从磁盘拷贝到DRAM中,替换这个牺牲页。
这些功能是由许多软硬件联合提供的,包括操作系统软件,MMU(存储器管理单元)中地址翻译硬件和一个存放在物理存储器中叫做页表(page table)的数据结构。
-
页表将虚拟页映射到物理页。
-
页表就是一个页表条目(Page Table Entry,PTE)的数组。
-
每个PTE由一个有效位和一个n位地址字段组成
页命中
地址翻译硬件将虚拟地址作为一个索引来定位PTE2,并从存储器中读取他。因为设置了有效位,VP2是缓存在存储器中,使用PTE中的物理存储器地址构造出这个字的物理地址。
缺页
-
缺页(page fault):DRAM缓存不命中。
-
在磁盘和存储器之间传送页的活动叫做交换或者页面调度
-
按需页面调度:当有不命中发生时,才换入页面的策略。
分配页面
操作系统分配一个新的虚拟存储器页。
局部性
-
程序往往在一个较小的活动页面集合上工作,这个集合叫做工作集或者常驻集。
-
颠簸:页面不断换进换出。
9.4 虚拟存储器作为存储器管理的工具
操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。
多个虚拟页面可以映射到同一个共享物理页面上。
按需页面调度和独立的虚拟地址空间的结合,对存储器的使用和管理造成了深远的影响:
简化链接
简化加载
简化共享
简化存储器分配
9.5 虚拟存储器作为存储器保护的工具
计算机系统必须为操作系统提供手段来控制对存储器系统的访问。
地址翻译机制可以以一种自然的方式扩展到提供更好的访问控制:
9.6、地址翻译
地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素之间的映射。
MMU利用页表实现映射。CPU中的一个控制寄存器---页表基址寄存器(Page Table Base Register,PTBR):指向当前页表。
页面命中:
缺页:
- 页面命中完全是由硬件来处理的,处理缺页要求硬件和操作系统内核协作完成。
9.7、案例研究:Intel Core i7/Linux 存储器系统
Linux虚拟存储器系统
Linux为每个进程维持了一个单独的虚拟地址空间。
内核虚拟存储器包含内核中的代码和数据结构。内核虚拟存储器的某些区域被映射到所有进程共享的物理页面。例如,每个进程共享内核的代码和全局数据结构。
Linux虚拟存储器区域
Linux将虚拟存储器组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟存储器的连续片(chunk),这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都不同的区域。每个存在的虚拟页面保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。区域的概念很重要,因为它允许虚拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用存储器。磁盘或者内核本身的任何额外资源。
内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID,指向用户栈的指针、可执行的目标文件的名字以及程序计数器)。
task_struct中的一个条目指向mm_struct,它描述了虚拟存储器中的当前状态。
其中pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm_area_struct(区域结构)的链表。
其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域(area)。
当内核运行这个进程时,它就将pgd存放在CR3控制寄存器中。
一个具体区域结构包含下面的字段:
vm_start:指向这个区域的起始处。
vm_end:指向这个区域的结束处。
vm_prot:描述这个区域的内包含的所有页的读写许可权限。
vm_flags:描述这个区域内页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。
vm_next:指向链表中下一个区域结构。
Linux缺页异常处理
9.8存储器映射
Linux(以及其他一些形式的Unix)通过将一个虚拟存储器区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射(memory mapping)。虚拟存储器区域可以映射到两种类型的对象的一种:
-
(1)Unix文件上的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始化内容。因为按需进行页面高度,所以这些虚拟页面没有实际进行物理存储器,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域文件区要大,那么就用零来填充这个区域的余下部分。
-
(2)匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理存储器中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在存储器中的。注意在磁盘和存储器之间没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-zero page)。
无论在哪种情况下,一旦一个虚拟页面被初始化了, 它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)。需要意识到的很重要的一点,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
再看共享对象
一个对象可以被映射到虚拟存储的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟存储器的其他进程而言也是可见的。而且,这此变化也会反映在磁盘上的原始对象中。(IPC的一种方式)
另一方面,对一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟存储器区域叫做共享区域。类似地,也有私有区域。
共享对象的关键点在于即使对象被映射到了多个共享区域,物理存储器也只需要存放共享对象的一个拷贝。
一个共享对象(注意,物理页面不一定是连续的。)
私有对象是使用一种叫做写时拷贝(copy-on-write)的巧妙技术被映射到虚拟存储器中的。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝。
再看fork函数
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟存储器,它创建了当前进程的mm_struct、区域结构和页表的原样拷贝。它将两个进程中的每个页面都为标记只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝。
当fork在新进程中返回时,新进程现在的虚拟存储器刚好和调用fork时存在的虚拟存储器相同。当这两个进程中的任一个后来进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
再看execve函数
假设运行在当前进程中的程序执行了如下的调用:
execve("a.out",NULL,NULL) ;
execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
-
删除已存在的用户区域。删除当前进程虚拟地址用户部分中的已存在的区域结构。
-
映射私有区域。为新程序的文本、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时拷贝的。文本和数据区域被映射为a.out文件中的文本和数据区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的。
-
映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
-
设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
使用mmap函数的用户级存储器映射
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指定的对象的一个连续的片(chunk)映射到这个新区域。连续的对象片大小为length字节,从距文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL。
munmap函数删除虚拟存储器的区域:
#include<unistd.h>
#include<sys/mman.h>
int munmap(void *start,size_t length);
返回:若成功则为0,若出错则为-1
9.9 动态存储器分配
虽然可以使用低级的mmap和munmap 函数来创建和删除虚拟存储器的区域,但是C程序员:当运行时需要额外虚拟存储器时,用动态存储器分配器(dynamicmemory allocator)更方便,也有更好的可移植性。
-
堆(heap):动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆。
-
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的存储器片(chunk)。
分配器有两种基本风格:
显式分配器(explicit allocator):要求应用显示地释放任何已分配的块。
C程序:调用malloc函数来分配一个块,调用free函数来释放一个块。
C++程序:new 和 delete 操作符分配和释放块。
隐式分配器(implicit allocator):要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
隐式分配器也叫垃圾收集器(garbagecollector)。
垃圾收集(garbage collection):自动释放未使用的已分配的块的过程。
malloc和free函数
C标准库提供了一个mallo程序包的显示分配器。程序通过调用malloc函数来从堆中分配块。
#include <stdlib.h>
void *malloc(size_t size);
malloc函数返回一个指针,指向大小为至少size字节的存储器块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。
malloc函数不初始化它返回的存储器。
calloc函数是一个基于malloc的瘦包装函数,它将分配的存储器初始化为零。
realloc函数改变一个以前分配块的大小。
动态存储器分配器,例如malloc函数,可以通过使用mmap、munmap和sbrk函数,显示的分配和释放堆存储器。
程序通过调用free函数来释放已分配的堆块。
#include <stdlib.h>
void free(void *ptr);
ptr参数必须指向一个从malloc calloc realloc函数获得的已分配块的起始位置。如果不是,那么free的行为就是未定义的。
为什么要使用动态存储器分配
程序使用动态存储器分配器的最重要的原因是:经常到程序实际运行时,才知道某些数据结构的大小。
(1)硬编码数组界限:
#define MAXN 15213
int array[NAXN];
对于拥有百万行代码和大量使用者的大型软件产品而言,会变成一场维护的噩梦。
(2)在运行时,在已知了N值之后,动态地分配这个数组:
使用这种方法,数组大小的最大值 就只由可用的虚拟存储器数量来限制了。
int *array, i, n;
scanf(“%d”,&n);
array=(int*)malloc(n*sizeof(int));
for(i=o;i!=n;i++)
scanf(“%d”,&array[i]);
exit(0);
分配器的要求和目标
要求:
处理任意请求序列
立即响应请求
只使用堆
对齐块
不修改已分配的块
目标:
最大化吞吐率
吞吐率:每个单位时间里完成的请求数。
最大化存储器利用率
天真的程序员经常不正确的假设虚拟存储器是一个无限的资源。
好的程序员知道虚拟存储器是一个有限的空间,必须高效地利用。
实际上,一个系统中被所有进程分配的虚拟存储器的全部数量是受磁盘上交换空间的数量限制的。
碎片
碎片(fragmentation):造成堆利用率低的主要原因。
内部碎片:在一个已分配块比有效载荷大时发生的。
外部碎片:当空闲存储器合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以处理这个请求时发生的。
实现问题
空闲块组织
放置
分割
合并
隐式空闲链表
称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含的连接着的
分配器可以通过遍历堆中所有的块,从而间接的遍历整个空闲块的集合。
放置已分配的块
请求块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块,搜索方式是由放置策略确定的。
首次适配
下一次适配
最佳适配
分割空闲快
将空闲块分割成两部分:
第一部分变成分配块
剩下的变成一个新的空闲块
获取额外的堆存储器
不能为请求块找到合适的空闲块时:
1、合并空闲块。
2、通过调用sbrk函数,向内核请求额外的堆存储器。
分配器将额外的存储器转化成一个大的空闲块,并插入空闲链表中。
合并空闲块
邻接的空闲块可能引起一种现象:假碎片
就是有许多可用的空闲块被切割成小的、无法使用的空闲块。
合并:分配器合并相邻的空闲块
何时合并:策略决定:
立即合并
推迟合并
带边界标记的合并
当前块:想要释放的块
边界标记:在每个块的结尾处添加一个脚部。
四种情况下进行的合并:
综合:实现一个简单的分配器
-
一般分配器设计
-
操作空闲链表的基本常数和宏
-
创建初始空闲链表
-
释放和合并块
-
分配块
显示空闲链表
将空闲块组织为某种形式的显示数据结构。
在每个空闲块中,都包含一个pred前驱和succ后继指针。
空闲链表中块的排序策略:
后进先出
按照地址顺序
分离的空闲链表
分离存储(segregated storage):维护多个空闲链表,其中每个链表中的块有大致相等的大小。---一种流行的减少分配时间的方法。
简单分离存储
分离适配
伙伴系统
9.10、垃圾收集
垃圾收集器(garbage collector):一种动态存储分配器,它自动释放程序不再需要的已分配块。
垃圾(garbage):程序不再需要的已分配块。
垃圾收集(garbage collection):自动回收堆存储的过程。
在一个支持垃圾收集的系统中,应用显式分配堆块,但是从不显式地释放。
(1)垃圾收集器的基本知识
垃圾收集器将存储器视为一张有向可达图(reachabilitygraph)。
节点可达的:存在一条从任意根节点出发并到达p的有向路径。
不可达节点对应于垃圾。
-
ML和JAVA的垃圾收集器,对如何创建和使用指针有严格的控制,能够维护可达图的一种精确表示,因此能够回收所有垃圾。
-
C和C++的垃圾收集器是保守的垃圾收集器,不能维护可达图的精确表示。每个可达图块都被正确的标记为可达,而一些不可达节点却可能被错误的标记为可达。
(2)Mark&Sweep垃圾收集器
Mark&Sweep垃圾收集器:
标记阶段:
清除阶段:
(3)C程序的保守Mark&Sweep
C程序的Mark&Sweep收集器必须是保守的,根本原因是C语言不会用类型信息来标记存储器位置。
9.11 C程序中常见的与存储器有关的错误
(1)间接引用坏指针
在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。
坏指针:
读:段异常
写:保护异常---覆盖存储器
(2)读未初始化的存储器
一个常见的错误就是假设堆存储器 被初始化为零。
正确的做法是显示地初始化。
(3)允许栈缓冲区溢出
缓冲区溢出错误(buffer overflow bug):如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误。
例如:gets()函数拷贝一个任意长度的串到缓冲区,就有缓冲区溢出错误。用fgets()函数可以纠正这个错误,它限制了输入串的大小。
(4)假设指针和它们指向的对象是相同大小的
int int*
(5)造成错位错误
错位错误(off-by-one):另一种覆盖错误。
例如:试图初始化具有n个元素的第(n+1)个元素,会覆盖数组后面的某个存储器。
(6)引用指针,而不是它所指向的对象
注意C操作符的优先级和结合性。区别 :*size *size+1 *(size+1)
(7)误解指针运算
指针的算术操作:p++
(8)引用不存在的变量
函数不能返回局部变量的指针或引用。
(9)引用空闲堆块中的数据
int*x=new int (5); delete x; 后再次引用 int *y=x; ---错误。
(10)引起存储器泄露
忘记了释放已分配的块。 new 之后忘记调用 delete;