csapp第九章 虚拟存储器

现代系统提供了一种对主存的抽象概念——虚拟存储器。为了更加有效的管理存储器并且少出错。

虚拟存储器是硬件异常、硬件地址翻译、主持、磁盘文件、内核软件的完美交互。

它为每个进程提供了一个大的,一致的,私有的地址空间。

虚拟存储器提供了三个重要的能力:

  • 将主存看成是一个存储在磁盘上的地址空间的高速缓存。
  • 为每个进程提供一致的地址空间。
  • 保护了每个进程的地址空间不被其他进程破坏。

理解虚拟存储器的理由:其是中心的、强大的、危险的。

9.1 物理和虚拟寻址

计算机系统的主存被组织为一个M个连续字节大小的单元组成的数组,每个字节都有唯一的物理地址。——物理地址是对主存的。

cpu使用物理地址访问存储器——物理寻址。

cpu通过生成一个虚拟地址来访问主存——虚拟寻址。——虚拟地址在被送到存储器之前先转换成适当的物理地址。(地址翻译)。

cpu芯片上的MMU利用存放在主存中的查询表来动态翻译虚拟地址。查询表由操作系统管理。

9.2 地址空间

地址空间区分了字节和地址。允许一个字节有多个地址,多个地址中的每一个都来自一个地址空间。

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

9.3 虚拟存储器作为缓存的工具

 物理存储器被分割成物理页,大小P字节。

虚拟存储器分割成虚拟页,大小P字节。

虚拟页包含三部分:

  • 未分配——就是没有和主存中的任何物理页对应,同时,也没有和磁盘上的任何空间对应。
  • 缓存的——和主存的某一物理页对应了。
  • 未缓存的——和磁盘上的某一空间对应了。

系统需要判断几件事:

  • 一个虚拟页是否放在主存中?
  • 是,那么这个虚拟页对应的是那个物理页?
  • 不是,那么这个虚拟页存放在磁盘的那个位置?在物理存储器中选择一个牺牲页,替换之。

页表将虚拟页映射到物理页。每次MMU翻译虚拟地址到物理地址的时候都会读取页表。

操作系统负责维护页表的内容,以及在磁盘和DRAM之间来回传送页。

页表中是一批页表条目PTE,顺序放置,0,1,2····,虚拟页有编号,0,1,2·····,这两个编号是对应的,vp1对应的就是pte1。vp5对应的就是pte5。这两者是操作系统可以安排的,所以可以有这种对应关系。

一个pte,两个部分,一个是一个标志位,标明是否缓存了。是缓存了,那么第二部分就是主存中的物理页的地址。

如果没有缓存,那么,第二部分要么是null(没有分配),要么是磁盘上对应空间的起始地址。

因为DRAM缓存是全相连的,任意物理页都可以包含任意虚拟页。这是说,pte中的物理页可以任意的,两个pte的物理页可以是相同的?

虚拟地址是指向pte的。

如果cpu使用了一个虚拟地址,也就是pte,如果这个pte是未缓存的,那么,就触发一个缺页异常。

缺页异常调用内核中的缺页异常处理程序。这个程序会选择一个牺牲页,也就是对应另一个pte,将这个牺牲页拷贝回磁盘,然后修改对应的pte为未缓存的。

缺页异常处理程序是故障,返回到引起这一故障的指令继续执行。

磁盘和主存之间的传送页的活动叫做交换,或者说页面调度。

局部性原则保证了在任意时刻,程序将往往在一个较小的活动页面集合上工作,这个集合叫做工作集,或者,常驻集。

如果工作集的大小超过了物理存储器(主存)的大小,程序就产生一种不幸的状态——颠簸。

9.4 虚拟存储器作为存储器管理的工具

到目前为止,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。

实际上,操作系统为每个进程提供了一个独立的页表,也就是一个独立的虚拟地址空间。

简化链接——独立的地址空间允许每个进程的存储器映像使用相同的基本格式,而不管代码和数据实际存放在物理存储器的何处。

简化加载——虚拟存储器还使得容易向存储器中加载可执行文件和共享对象文件。要把一个可执行文件加载到一个进程中,只要设置这个进程的虚拟地址空间的一个虚拟页,将这个虚拟页标志为未缓存的,然后指向磁盘上的可执行文件即可。真实使用的时候,cpu会负责让虚拟存储器将实际文件从磁盘换入到主存。

简化共享——独立空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。

简化存储器分配——malloc这种,操作系统只要分配k个连续的虚拟存储器页面,未缓存的即可。

9.5 虚拟存储器作为存储器保护的工具

任何现代计算机系统必须为操作系统提供手段来控制对存储器系统的访问。

不应该允许用户进程修改它的只读文本段。

不应该允许它读或修改任何内核中的代码和数据结构。

不应该允许它读或者写其他进程的私有存储器。

不应该允许它修改任何与其他进程共享的虚拟页面,(除非通过调用明确的进程间通信系统调用)。

通过在pte上添加一些额外的许可位来控制对一个虚拟页面内容的访问十分简单。

sup——是,则进程在用户模式下,不可访问本pte指向的虚拟页。在内核模式下,总是可以访问任意虚拟页的。

read——是否可读。

write——是否可写。

9.6 地址翻译

VPN——虚拟页的页号。

VPO——VPN这个虚拟页中的偏移地址。

这两个连起来是虚拟地址,cpu给出的虚拟地址,cpu给出虚拟地址,一般是希望获得一个字,或者一个字节什么的。

PPN——物理页的页号。

PPO——PPN这个物理页着那个的偏移地址。PPN指出一个物理页,这是真实存在在主存中的一个页。

PPN和PPO连起来就是物理地址,指向一个字节,如果cpu希望的是字节,就把这个字节给回cpu,如果cpu希望字,那么从这个字节开始的4个字节将返回给cpu。

VPO==PPO,这是始终成立的。

PPN怎么得到呢,VPN总有一个PTE和它对应,这是始终成立的。从这个PTE中,就可以得到PPN。虽然,开始的时候PTE中可能没有PPN,但是MMU会去搞定这件事,然后就有了PPN。也就是说PTE中总是有PPN的。

所以,就得到了物理地址。

以上是理想的那,没有这么直接的。那是什么捣蛋呢?是MMU中的TLB和L1高速缓存它们两个。

TLB中放着一个虚拟地址、这个虚拟地址对应的PTE。也就是说包含了VPN,VPO(也就是PPO),PPN,有效位。

cpu给出一个虚拟地址,也就是VPN+VPO,如果VPN和TLB中的一个VPN对应,那么MMU就可以直接返回PPN+PPO给cpu了。NB的效率。

L1高速缓存就出现了,它有什么呢?他有主存中的数据。

也就是说TLB缓存的是地址。高速缓存缓存的是数据。

当cpu得到PPN+PPO的时候,也就是物理地址的时候,它先看看高速缓存有没有这个物理地址,有,那么直接就将对应的数据返回给cpu。

就是这样。

还有一个多级页表,这样理解,VPN是页号,它是前20bit,后12bit是VPO,也就是一个页面的大小4k。

那么前20bit,都用来表示页的号码,能表示多少呢?1024*1024,1M这么多的页面。也就是1M条PTE。

假如一条PTE4个字节,那么就是4M,也就是说一个页表4M,这太大了。

那怎么办呢?分级阿。

前20bit分成两部分,前10bit和后10bit。

对于前10bit来说,后面由22bit。22bit的页就是4M的页,也就是一个页4M大小,但是,总共有1024个页,也就是1024个PTE,一个PTE4字节,那么4k,页表的大小是4k。

然后在一个4M的页里面,再分下,前10bit和后12bit,又是一个页表,4k。

就是这种那,看起来1k*4K不还是这样么,但关键是只有一级的那个4k页表,是需要放在主存的。中间的1k个4k页表可能只有几十个在主存中。所以,节省了空间。就是这样。

9.7 案例研究:Intel Core i7/Linux 存储器系统

现在的(以及可以预见的未来的)corei7实现支持48位虚拟地址空间和52位物理地址空间,还有一个模式,支持32位虚拟和物理地址空间。

处理器包包括4个核,每个核包含一个层次结构的TLB,一个层次结构的数据指令高速缓存。

TLB是虚拟寻址的。

高速缓存是物理寻址的。

页大小在启动时被配置为4KB或者4MB。

linux使用的是4KB。

i7是这样的:

虚拟地址48bit,12bit的VPO,36bit的VPN,36/4=9,4级页表,每级的页表有9bit,CR3寄存器记录的是一级页表的起始地址,VPN1是9bit,也就是表示0-511,CR3的值加上VPN1的值,就得到一级页表的一个条目,也就是一个PTE,这个PTE呢,指向一个二级页表的起始地址。

这里有一条要记住,一级页表,只有一个,CR3记录它的起始地址。一级页表,有多少条目,即有多少PTE呢?有512个,也就是VPN1的9bit的表示的值。

二级页表呢,就不止一个了,它有512个,对应于一级页表的条目数。

那一个二级页表有多少了条目呢?VPN2个,VPN2也是9bit,所以也是512个条目。所以所有的二级页表总共有多少个条目呢:512*512个条目。

而这个512*512就是三级页表的个数,而每个三级页表有512个条目,所以,4级页表就有512*512*512个。

那么,4级页表有几个条目呢?512个,对应于VPN4,而四级页表的每个条目,即每个PTE指向的就是虚拟页的其实地址,而不是五级页表的起始地址了,因为没有5级页表了。

也就是说:有512*512*512*512个虚拟页,每个虚拟页4KB。就是2的48次方字节。也就是256TB。

从4级页表的PTE里面可以得到一个物理页的地址,这个物理页是4k的。

实际的4级页表的PTE里面是一个PPN,40位的。注意一下,虚拟地址中的VPN是36位的,也就是说,给出一个36位的虚拟页地址,可以查到40位的物理页地址。

MMU会在TLB中找,根据VPN找,找到了,那么,直接从TLB中返回PPN。

反正MMU计算结束之后,剩下的就是PPN。

然后更具PPN+PPO,从高速缓存中找数据。就是这样。

cpu给MMU一个虚拟地址,MMU得到一个物理地址,物理地址得到一个数据,数据返回cpu。

Linux为每个进程维持了一个单独的虚拟地址空间。这个虚拟地址空间包括两个部分——内核虚拟存储器和进程虚拟存储器。

代码段、数据段、堆、共享库、用户栈都是不同的区域。

在内核虚拟存储器中,每个进程都有一个单独的结构,task_struct。这个结构中,有进程运行所需要的所有信息。包括任意一个进程虚拟存储器的区域划分。

每个进程都有一个一级页表。

一个区域有:区域中的页的读写许可权限、区域中的页是共享的还是私有的。

linux的缺页异常处理:cpu给出一个虚拟地址,从VPN得到PTE,但PTE的有效位为0,所以就触发了一个缺页异常。这里有两种情况,一个是未分配一个是未缓存。异常之后,就到了异常处理程序,异常处理程序从更具VPN查找每个区域,看看VPN是否在某个区域中,如果不再任何区域中,就是段错误。

如果是在某个区域中,那么,如果要写,但这个区域只读,那么就是保护错误。

如果在某个区域,且合法,那么就正常换页。

9.8 存储器映射

linux(以及其他一些形式的linux)通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域的内容——存储器映射。

这里有点:是区域,不是一个页,区域与磁盘上的对象关联起来,磁盘上的对象不是指文件,当然包括文件,但页包括文件的一部分。一个区域典型的应该是和一个磁盘文件的一部分关联起来。

虚拟存储器,到现在,也有点明白了,首先是进程,进程中有虚拟地址空间。虚拟地址空间有两部分,一部分是用户进程空间,一部分是内核空间。在内核空间中有一个页表,不,是一群页表,每个进程都对应了一个页表。这个页表,就是这个进程的虚拟页的页表。

这里有点绕:进程中有虚拟地址空间,虚拟地址空间有页表,页表又表明了虚拟地址空间。

页表是从虚拟地址取得物理地址的手段。

虚拟地址空间,本身是分区域的,一个区域对应几个虚拟页,虚拟页虚拟页,就是虚拟的,虚拟页可能对应着物理页。

一个区域不会比它关联的磁盘上的对象小。

上面用了初始化虚拟存储器区域,这个初始化用的恰当,虚拟页有三种状态,未分配,未缓存,缓存了。最初的时候,必须是未分配的。这时候是存储器映射将未分配变成未缓存,至于未缓存到缓存,是之后的事情。

磁盘上的对象分两类:

  • Unix文件系统中的普通文件——文件,分成了文件区,文件区又分成了片,一片的大小就是一个页的大小。每一片包含了一个虚拟页的初始内容。前面说了,区域是很多个虚拟页,所以区域可以很多个片,所以区域可以是一个文件区,甚至是一个文件。区域比文件区大的时候,填充0到余下的区域部分。
  • 匿名文件——映射到匿名文件的区域中的页面,也叫做请求二进制0的页。

一旦一个页面初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。

需要意识到的很重要的一点:在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。

也就是说,其实初始化的页面,就在交换空间中占了一个页大小的空间。

一个对象可以被映射到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象。——这里是磁盘上的对象。

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

对一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域做的任何写操作都不会反映在磁盘的对象中。

共享区域,私有区域。

对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝。当有一个进程试图写私有区域内的某个页面时,那么写操作就会触发一个保护故障。

故障处理程序会知道故障是由于进程试图写私有区域的一个页面引起的,它会在物理存储器中创建这个页面的一个新拷贝,然后恢复这个页面的可写权限。

fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。两个进程中的每个页面都标记为只读,并将两个进程中的每个局域标记为私有的写时拷贝。

9.9 动态存储器分配

动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆。

在.bss和共享库存储器映射区域之间是一段空间,堆开始于.bss之上,但没有到达共享库存储器区域。

.bss和共享库存储器区域之间没有分配给堆的,是空洞,相应的,堆表示的是那一部分分配的。

堆中,还包含已分配的和空闲的。注意这是堆自己的事情,和空洞没关系了,当堆中没有空闲的,然后还需要空间的时候,堆就从空洞中取出一部分,放在自己身上,也就是堆变大。

存储器分配器有两种:显示分配器,隐式分配器。

显示分配器包括:malloc和free,这两个函数就是两个分配器,只是职能不同。

隐式分配器,也叫垃圾收集器,看来只有垃圾收集的功能。没有相应的分配功能。

malloc返回一个指针,指向大小为至少size字节的存储块。遇到问题的话,就返回NULL。

sbrk扩展和收缩堆。

freee参数必须为malloc,calloc,realloc返回的指针。其没有返回值。

这里的存储器分配器,是对堆内部空间的分配和释放。和堆大小没有关系。

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

  • 处理任意请求序列
  • 立即响应请求
  • 只使用堆
  • 对齐块(对齐要求)——在大多数系统中,这意味着分配器返回的块是8字节边界对齐的。
  • 不修改已分配的块——像压缩已分配块这样的技术是不允许被使用的(这里是指已分配的块,而不是堆,sbrk函数可以扩展和收缩堆)

分配器有两个目标:最大化的吞吐率——其实就是以最快的方式执行诸如malloc和free这样的函数;最大化存储器利用率——通常用峰值利用率来表示存储器利用率,这个值是有效载荷/堆大小。

这两个目标其实是相互抑制的。

堆的碎片:内部碎片和外部碎片。

  • 内部碎片——对齐约束条件和具体实现中有最小分配块的要求。
  • 外部碎片——有很多空间块,但满足不了一个分配请求。

分配器的实现问题,暂时不去确切的记录了。

隐式空闲链表——空闲块通过头部的大小字段隐含地连接在一起。

其他的放置策略、合并策略等,不管了。

如果分配器不能为请求找到合适的空闲块,也就是malloc函数的调用失败了,那么分配器就会通过sbrk函数,向内核请求额外的堆存储器。分配器会将额外的存储器转化为一个大的空闲块。

9.9.12的简单分配器的实现,以及后面的两个,暂时放置了。

9.10 垃圾收集

保守的垃圾收集器——c,c++。

ML和java——精确的垃圾收集器。

9.11 C程序中常见的存储器有关的错误

malloc没有将分配的块初始化。calloc初始化了。

等等。

9.12 小结

(over)

posted @ 2012-05-31 20:12  ray hill  阅读(742)  评论(0编辑  收藏  举报