虚拟存储器是现代操作系统提供的一种对主存的抽象概念。
将虚拟地址转换成物理地址叫地址翻译。
虚拟存储(VM): 被组织成存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有唯一的虚拟地址。磁盘上数组的内容被缓存在主存中。
虚拟页与物理页: VM系统将虚拟存储器分割成大小固定的块,称为虚拟页。类似地,物理存储器也被分割成物理页。
虚拟页面的集合可分为:
两个术语:
DRAM缓存不命中比SRAM缓存不命中代价要大得多,因为DRAM缓存不命中要由磁盘来服务,而SRAM缓存不命中通常是由基于DRAM的主存来服务的。
由于大的不命中处罚,虚拟页往往很大,典型的是2KB~2MB。
虚拟存储器系统必须有某种方法来判断一个虚拟页是否存放在DRAM中。如果是,还得确定这个虚拟页存放在哪个物理页中。若不命中,系统需判断这个虚拟页在磁盘的哪个位置,在物理存储器中选择一个牺牲页,并将虚拟页从磁盘拷贝到DRAM中,替换这个牺牲页。
页表: 存放在物理存储器中,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时都会读取页表.
图9-4展示了一个页表的基本组织结构。页表就是一个页表条目(Page Table Entry, PTE)的数组。假设每个PTE是由一个有效位和一个n位地址字段组成。有效位表明该虚拟页当前是否被缓存在DRAM中。
如图9-6中VP2,已经缓存在存储器中。
在虚拟存储器中,DRAM缓存不命中称为缺页。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从存储器中读取PTE3,从有效位推断VP3并未缓存,并且触发一个缺页异常。
缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如VP4。若VP4已被修改,那么内核就会将它拷贝回磁盘。
接着,内核从磁盘拷贝VP3到存储器中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但现在VP3已经缓存在主存中,那么页命中也能由地址翻译硬件正常处理了。
在虚拟存储器的说法中,块被称为页。在磁盘和存储器之间传送页的活动叫做交换或者页面调度。页从磁盘换入(或者页面调入)DRAM和从DRAM换出(或者页面调出)磁盘。
局部性保证了在任意时刻,程序往往在一个较小的活动页面集合上工作,这个集合叫做工作集。
如果工作集的大小超出了物理存储器的大小,那么程序将会产生一种不幸的状态,叫做颠簸,这时页面将不断换进换出。
虚拟存储器工作机制: 利用DRAM缓存来自通常更大的虚拟地址空间的页面。
目前,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。实际上,操作系统为每个进程都提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。图9-9展示了基本思想。进程i的页表将VP1映射到VP2,VP2映射到PP7。进程j的页表将VP1映射到PP7,VP2映射到PP10。注意,多个虚拟页面可以映射到同一个共享物理页面上.
按需页面调度和独立的虚拟地址空间的结合,对系统中存储器的使用和管理造成了深远的影响。特别的,VM简化了链接和加载、代码和数据共享,以及应用程序的存储器分配。
每次CPU生成一个地址时,地址翻译硬件都会先读一个PTE(即页表条目),可以在PTE上添加一些许可位来控制对一个虚拟页面内容的访问。
地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。MMU利用页表来实现这种映射,如下图.
假设系统只用一个单独的页表来进行地址翻译(实际是操作系统为每个进程都提供了一个独立的页表),并假设有一个32位的地址空间、4KB的页面和一个4字节的PTE,那么也需要一个4*(232/4*210)=4MB的页表驻留在存储器中。对于地址空间为64位的系统,问题会更复杂。如何解决?
常使用层次结构的页表来压缩页表。假设32位虚拟地址空间被分为4KB的页,而每个PTE(即页表条目)都是4字节。还假设这一时刻,虚拟地址空间形式如下: 存储器的前2K个页面分配给了代码和数据,接下来6K个页面还未分配,再接下来1023个页面也没分配,接下来的一个页面分配给了用户栈。图9-17展示了如何为这个虚拟地址空间构造一个两级的页表层次结构。
一级页表每个PTE负责映射虚拟地址空间的一个4MB的片(chunk),这里每一片都是由1024个连续的页面组成。假设地址空间是4GB,1024个PTE已经足够覆盖整个空间.
二级页表中每个PTE负责映射一个4KB的虚拟存储器页面。
这种方法从两个方面减少了存储器要求:
Linux为每个进程维护了一个单独的虚拟地址空间,如图9-26。内核虚拟存储器位于用户栈之上。
内核虚拟存储器包含内核中的代码和数据结构。
Linux将虚拟存储器组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟存储器的连续片(chunk)。如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。区域允许虚拟地址空间有间隙。内核不用记录那些不存在的虚拟页,这样的页也不占用存储器、磁盘或者内核本身的任何额外资源。
图9-27展示了一个进程中虚拟存储器的内核数据结构。内核为系统中每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用户栈的指针、可执行目标文件的名字以及程序计数器)
task_struct中一个条目指向mm_struct,它描述了虚拟存储器的当前状态,其中pgd字段指向第一级页表的基址,而mmap指向一个vm_area_structs的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域(area)。
MMU试图翻译虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序执行以下步骤:
Linux通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射。
一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。交换文件也叫作交换空间或者交换区域(swap area)。在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。回忆,在安装ubuntu系统时,我们会分配一个swap区域,想必是这个目的吧。
进程这一抽象能够为每个进程提供自己私有的虚拟地址空间,可以免受其他进程的错误读写。不过,许多进程有同样的只读文本区域。例如,每个运行Unix外壳程序tcsh的进程都有相同的文本区域。而且,许多程序需要访问只读运行时库代码的相同拷贝。如每个C程序都需要来自标准C库的诸如printf这样的函数。若每个进程都在物理存储器中保持这些常用代码的复制拷贝,那就极度浪费空间。幸好存储器映射提供了多个进程共享对象的机制。
一个对象可被映射到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象。
即使对象被映射到了多个共享区域,物理存储器中也只需要存放共享对象的一个拷贝。
写时拷贝: 私有对象是使用写时拷贝的技术被映射到虚拟存储器中的。一个私有对象开始时和共享对象一样,在物理存储器中只保存私有对象的一份拷贝。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理存储器中对象的一个单独拷贝。直到有一个进程试图写私有区域内的某个页面,这个写操作会触发一个保护故障。故障处理程序会在物理存储器中创建这个页面的一个新拷贝,更新页表条目指向这个新的拷贝,然后恢复这个页面的可写权限。当故障处理程序返回时,CPU重新执行这个写操作,现在在新建的页面上就可以执行写操作了。写时拷贝充分使用了稀有的物理存储器。
如图9-30,a)中两个进程将一个私有对象映射到它们的虚拟存储器的不同区域,但共享这个对象同一个物理拷贝. b)为进程2写私有区域的一个页。
fork被当前进程调用时,它创建了当前进程的mm_struct、区域结构和页表的原样拷贝。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝。子进程与父进程的虚拟存储器相同。当这两个进程中有一个进行写操作,写时拷贝机制会创建新页面。也就为每个进程保持了私有地址空间。
假设进程执行execve(“a.out”, NULL, NULL)时。加载并运行a.out需要以下几步:
Unix进程可以使用mmap函数来创建新的虚拟存储器区域,并将对象映射到这个区域。
mmap函数要求内核创建一个新的虚拟存储器区域,并将文件描述符fd指定的对象的一个连续的片(chunk)映射到这个新的区域。连续的对象片大小为length字节,从文件开始偏移offset字节的地方开始。
munmap函数删除虚拟存储器的区域: munmap函数删除从虚拟地址start开始的length字节组成的区域。
#include
#include
void *mmap(void *start, size_t length, int port, int flags, int fd, off_t offset);
返回: 若成功则为指向映射区域的指针,若出错则为MAP_FAILED(-1)
int munmap(void *start, size_t length); 返回: 若成功返回0,出错返回-1
动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆(heap)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器两种风格:
#include
void *malloc(size_t size);
void free(void *ptr);
malloc: 分配至少size字节的存储器块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。在Unix系统上,malloc返回一个8字节(双字)边界对齐的块。
动态存储器分配器,例如malloc,可以使用mmap和munmap函数,显式地分配和释放堆存储器,或者使用sbrk函数:
#include
void *sbrk(intptr_t incr);
sbrk函数通过将内核的brk指针增加incr来扩展和收缩堆。
碎片:
分配器需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。
1). 隐式空闲链表
一个简单的堆块的格式如图9-35,此时,块由一个字的头部、有效载荷,以及可能的额外填充组成。头部编码了这个块的带下,以及这个块是已分配的还是空闲的。
假设块格式如图9-35所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如图9-36所示.这种结构称为隐式空闲链表。分配器可以通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
隐式空闲链表的优点是简单。显著缺点是任何操作的开销,例如放置分配的块,要求空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。
放置已分配的块:放置策略:
2). 显式空闲链表
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
垃圾收集器定期识别垃圾块(即程序不再需要的已分配块),并相应的调用free,将这些块放回到空闲链表中.
一种垃圾收集算法: Mark&Sweep(标记&清除)算法.
垃圾收集器将存储器视为一张有向可达图,如图9-49。该图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中一个已分配的块。根节点对应于一种不在堆中的位置,它们中包含指向堆中的指针。
当存在一条从任意根节点出发并到达p的有向路径时,我们说节点p是可达的。在任何时刻,不可达节点对应于垃圾。垃圾搜集器是维护可达图的某种表示,并通过释放不可达节点并将它们返回给空闲链表,来定期回收它们。
由标记(mark)阶段和清除(sweep)阶段组成,标记阶段标记出根节点的所有可达的已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。在标记阶段的末尾,任何未标记的已分配块都被认定是不可达的,是垃圾,可以在清除阶段回收。
图形化解释:
如图9-52,堆由6个已分配块组成,其中每个块都是未标记的。第3块包含一个指向第1块的指针。第4块包含指向第3块和第6块的指针。根指向第4块。在标记后,第1块、第3块、第4块和第6块被做了标记,因为它们都是根节点可达的。第2块和第5块是未被标记的,因为它们是不可达的。清除阶段之后,这两个不可达块被回收到空闲链表。