代码改变世界

白话基础之虚拟存储器

2010-03-06 20:05  横刀天笑  阅读(5514)  评论(13编辑  收藏  举报

有些基础知识太过于枯燥乏味,大部头教材也太多的说教,读着读着就进入了梦乡,我想用口语的方式跟大家像聊天一样的方式,也许更能接受点,虽然这样的语言不够严谨,但是,管他呢,有点印象后自己再找大部头仔细研究吧。所以我就想把一些基础知识用大白话聊聊。这一次我们就白话一下虚拟存储器,先从问题开始。

我们碰到什么问题了

我们机器上那几根条子,书面语叫做“物理存储器”。那虚拟存储器就是模拟出来的个玩意儿,是摸不着的东西。那本来就有个好好的几根条子,我们干吗还要虚拟呢,一根条子不够,我两根,两根不够我四根,反正现在条子白菜价。哦,这个时候有人跳出来说,那啥,条子虽然多,但也不是可以无限的插的,插多了也识别不出来,还有这个怪事儿?插多了还识别不出来了啊。

是的,这是为什么呢?这还要从CPU寻址做事的方法说起,CPU与外面交互有三类总线:地址总线、数据总线、控制总线。

寻址的时候就是靠这地址总线来往外面发送地址,那地址到底是怎样传的呢?总不会是传个数字123456吧,肯定不是,在这里肯定都是电信号了,也就是高低电平,一根导线传一个电信号,就是传一个“比特”,那地址总线有几根导线,就能传多少个比特,假设这个CPU的地址总线是32位的,那能传多少个地址的组合呢?聪明的你肯定马上就会算出来:2的32次方,也就是4GB。这个寻址方式如下图所示:

addressing

为什么要说上面这段貌似跟虚拟存储器无关的话呢?这是为了说明即使条子白菜价,也不是随便插的,插多了也没有用。那就明确了一个问题:寻址空间大小是有限制的。

不过有人可能又想,哪个程序会用到4G的内存啊,这个在DOS时代也许是正确的,因为那个时候是单任务,同一时刻只能运行一个程序,那应该用不到,但是现在,你又要上QQ跟MM聊天、还要上MSN聊点技术话题,耳朵上还戴个耳机,还在打开个浏览器看我这篇水文,还装模作样的打开一个Visual Studio和一个SQL Server查询分析器(没让我说中吧)。这么多程序同时搞起来那就有点问题了,程序多了,对于CPU来说总不是慢点(CPU对于多任务就是切换来切换去),但是对于内存来说那就不行了,装不下这么多,那压根儿就有的程序不能运行(程序要运行就得先弄到内存里,然后CPU才能读取)。这是第一个问题。

还有个问题,你机器上运行有QQ等应用程序外还运行了个操作系统,要是哪个没良心的程序员开发个程序,然后搞一下放操作系统代码的那块内存,那你的机器不就挂掉了。嗯,那得想个办法解决一下这个问题,把有部分内存保护起来,对用户代码不可见。这个问题是安全问题,这也得虚拟存储器来帮忙。

上面这两个问题最终都是虚拟存储器来解决的,实际上虚拟存储器的作用远不止如此,后面再进一步说明。这里只是为了说我们碰到什么问题了,怎么解决这些问题,好让虚拟存储器粉墨登场。

虚拟存储器

对于上面的第一个问题好说,因为在某个时刻并不是所有的内存都要用,那我们把暂时不用的内存放到硬盘里,当我们要找一块内存,最后发现它居然不在内存里,那我们就到硬盘中去找,然后把它装到内存里,暂时把内存中那些不用的块给放到硬盘中,这样就在内存和硬盘之间倒来倒去。

在Windows里,这个从内存倒到硬盘上的数据就保存在一个叫做pagefile.sys的隐藏文件中。而在我的电脑-》属性-》高级-》性能设置里还可以对虚拟内存的大小进行设置。

回到前一节的那张图,是石器时代的事情了,现在CPU不再这样干了,CPU现在也玩虚的,它不发这个物理地址了,发个虚拟地址出来,然后经过一个叫MMU(存储器管理单元)的东西来把这个虚拟地址翻译成物理地址,然后传给内存。不过说是这么简单,但这个过程却是很复杂的,这个需要CPU、MMU、内存、硬盘、操作系统这几个一配合才能搞,不过放心,这个对应用程序开发人员来说是透明的。话说这样的个过程是咋样实现的呢?下面来详细说一下。

MMU是咋样把CPU的虚拟地址转换为物理地址呢?这个就需要有一个映射的东西,在内存里有一个(实际上不止一个,这个后面会说明)表,就是来干这个的,这个表叫做页表。为什么叫页表呢?这个也是有原因的。根据数据统计,磁盘要比内存慢10000多倍,如果像刚才说的,到内存中去找个字节的数据,居然不在内存中,那就要到硬盘中去找,这下可丢的大了,这么慢啊,怎么办?所以就把虚拟存储器划分成一页页的,一页很大,物理存储器也划分成一页页的,都一样大。这样把磁盘里的一页数据装到内存中了,命中的机会就更大了(这个是基于一个叫做局部性的理论支持的,这个东西后面的文章会聊聊)。

这里大概就是这样一个情况了:

pagetable

这个页表有很多条目,每个条目两个字段,第一个字段是个标志位,标志这个条目是否有效,0表示无效,无效意味着要么这个条目为空,也就是对应的虚拟内存还没有分配,要么这个条目对应的内容在硬盘上,也就是没有缓存到内存,如果是1就表示,这个条目对应的内容在内存中。第二个字段叫物理页号,这个在后面会说到。

MMU就是依靠这个页表来进行地址翻译的,当CPU发送一个虚拟地址的时候,虚拟地址分为两部分,一部分叫做虚拟页面偏移(刚才不是说了,一个页面有点大,比如4K,如果寻址某个字节,那这个偏移就表示这个字节距离这个页面头部的距离),还有一部分叫做虚拟页号,也就是上面那个页表的编号,比如现在传个虚拟地址过来,虚拟页号是3,偏移是100,那选择页表的第三个条目(假设从0开始),上面说了,这个页表里的条目的第二个字段记录的是物理页号(在这里是2),又因为物理页和虚拟页的大小是一样的,所以虚拟页面偏移和物理页面偏移也是一样的,所以那个物理页号和偏移一结合啊,就会得到物理地址了:用物理页号先找到哪一页,然后用偏移找到具体的字节。那这里就会选择内存中的“块4”,偏移为100的地方的字节。

上面说的是正好这一块儿在内存中的情况,这个叫做页命中,那如果有一块儿还在硬盘中怎么办,比如这里的“块1”,那这个就叫做缺页(page fault)。这时MMU就会触发一个缺页异常,缺页异常会调用操作系统内核中缺页异常的处理程序(操作系统也参与进来了),这个处理程序会在内存中选择一页干掉,比如选择了“块5”,如果这个“块5”发生了修改(也就是从硬盘读到内存后修改了),处理程序就会把“块5”拷贝回硬盘。干掉“块5”后就为“块2”腾出地来了,然后就把块2给装到内存中,然后刚才是哪一条命令触发了这个异常,那个命令就会重复执行一次,不过这一次情况不一样了,那块内存已经在内存中了。

其实这个过程我们可以用代码来形象的描述一下,页表的条目我们就将其定义为一个名为PageTableEntry的结构。页表就用一个List<PageTableEntry> pageTable表示,而每个页啊,都用一个int[100]来表示,那物理内存就是List<int[100]> pm,虚拟内存也是List<int[100]> vm。

假设pageTable最大有100个条目,CPU传来的虚拟地址为050060,前三位表示pageTable的索引(虚拟页号),后三位表示偏移,然后:

   1: //用来表示地址
   2: struct Address
   3: {
   4:     //编号
   5:     int no;
   6:     //偏移
   7:     int offset;
   8: }
   9: //页表条目
  10: struct PageTableEntry
  11: {
  12:     //标志位
  13:     int flag;
  14:     //编号
  15:     int PPN;
  16: }
  17:  
  18: //传入虚拟地址,返回物理地址
  19: Address Translate(Address vAddr)
  20: {
  21:     //虚拟页号
  22:     int vpn = vAddr.no;
  23:     //虚拟页偏移,等于物理页偏移
  24:     int vpo = vAddr.offset;
  25:     
  26:     从页表中取出标志位
  27:     int flag = pageTable[vpn].flag;
  28:     //从页表条目中取出物理页号
  29:     int ppn = pageTable[vpn].PPN;
  30:     
  31:     //判断标志位是否有效,有效从内存中取
  32:     if(flag == 1)
  33:     {
  34:        //物理地址
  35:         Address pAddr;
  36:        //编号就是刚才从条目里取出的页号
  37:        pAddr.no = ppn;
  38:         //由于物理页和虚拟页的大小是一样的,偏移也是一致的
  39:        pAddr.offset = vpo;
  40:         //返回物理地址
  41:        return pAddr;
  42:     }
  43:     //标志位无效,要抛出异常,异常参数中包括页号和偏移
  44:     else
  45:     {
  46:         throw new NotFoundInMemoryException(ppn,vpo,"未命中");
  47:     }
  48: }
  49: //操作系统内核中处理缺页异常程序
  50: void HandleNotFoundInMemoryException(int ppn,int vpo)
  51: {
  52:     Random r = new Random();
  53:     //随机生成一个页码,将该页干掉(注意,如果该页曾经修改过,就要拷贝回硬盘,这里不做叙述)
  54:     //当然具体使用的策略不一定是随机生成的
  55:     int killPageNo = r.Next(100);
  56:     
  57:     //从硬盘取出目标页
  58:     int[] page = vm[ppn];
  59:     pm[killPageNo] = page;
  60: }
  61: static void Main()
  62: {
  63:     Address vAddr;
  64:     vAddr.no = 50;
  65:     vAddr.offset = 60;
  66:     try
  67:     {
  68:         Address pAddr = Translate(vAddr);
  69:     }
  70:     catch(NotFoundInMemoryException ex)
  71:     {
  72:         HandleNotFoundInMemoryException(ex.ppn,ex.vpo);
  73:     }
  74: }
  75:  

这一段基本上阐释了一下虚拟存储器的大致构造,上面的代码也大致说明了寻址的过程。仔细阅读注释应该不难以理解。但是虚拟存储器的作用却有很多很多,不仅仅是扩展。

虚拟存储器的作用

1、保护作用 现在回到第一节的第二个问题,如果我们在页表条目中,也就是上面的PageTableEntry中添加几个用于权限控制的字段:

   1: struct PageTableEntry
   2: {
   3:     //是否为内核代码,如果是,则只有内核模式才能访问该块内存,用户代码是不能访问的
   4:     bool supper;
   5:     //可读么?
   6:     bool read;
   7:     //可写么?
   8:     bool write;
   9:     
  10:     int flag;
  11:     int PPN;
  12: }

 

这样就解决了保护内核代码不让恶意代码修改的问题。

2、共享代码

机器中同时运行着许多进程,这些进程之间是隔离的(为了安全,一个进程崩溃不会影响其他进程,一个进程也不要去读取其他进程的内存,这可以通过虚拟存储器保护的特性来实现),但是有些代码,比如调用操作系统的API,这些API可能许多进程都要使用,这就要共享一部分内存,我们不需要将这部分内存在每个进程空间都拷贝一份,实际上每个进程都有一个页表,而不是全局只有一个,页表把共享内存映射到同一个地方:

share

这样进程1和进程2都共享这块内存,不仅节约了内存还提高了性能。

3、加载

在硬盘中双击一个图标,启动一个应用程序时,实际上你都不需要将这个程序从硬盘给加载到内存,只需要建个页表,然后页表里的编号指向的是硬盘,然后CPU访问到具体代码的时候,再按照上一节的寻址的方式,按需的将硬盘上的东东加载到内存。加载过程及其简单了。

4、链接

每个进程一个页表后,这个进程就会觉得全世界都是它的(页表模拟出一个虚拟存储器),那什么符号链接的时候(也就是符号映射到地址的时候),不再会受到内存中还有其他应用程序的干扰,因为我们面向的是虚拟存储器,我们的进程的地址空间是独立的,我这个符号放到离0偏移100的地方,那个放到离0偏移200的地方很容易就搞定了。

后记

写完一看,没有啥条理性,希望不要让不明白的人更糊涂就好。虚拟存储器这块内容相当的多,这只是一个起点,终点还需要努力~~