目录
引入
我们知道,内存是用存储数据的地方,就像鞋子放在鞋柜里,衣服放在衣柜里,内存就是放东西的地方。内存的最小存储单元是字节,计算机系统给每个存储单元编号,从32个全0到32个全1。
每个字节可以存放一些二进制数据,我们将字节的编号叫内存地址。
起因
在一家与计算机程序开发的公司中,A程序员写了一行代码
int byte1=100000000;
代码的意思很简单,就是把内存地址为1的内存到4的内存中放入数据100000000;,而B程序员也写了一行代码
int byte1=0;
代码的意思也很简单就是将 0存入以上的内存,如果A 的程序先运行完,再运行B的程序,大家互相不打扰挺好,这样的情况持续了很久........
直到有一天有人提出了两个问题:
问题1:有的程序在运行时等待外部I/O设备的响应,而在等待期间CPU什么都不干,CPU不允许程序在占有CPU时,不作为。也就是占着茅坑不拉屎不好吧?
问题2:凭啥A的程序先跑?B你咋就不敢跑他A干一架?
问题1:代表的是效率问题
问题2:代表的是公平问题
为了解决以上问题我们可以规定:
1.当一程序在等待外部I/O设备的响应,就让CPU先去执行别的程序,等到外部I/O设备的响应时,再回过头来执行原来的程序,这样就解决了问题1。
2、CPU不再需要等待一个程序执行完成,才去执行另一个程序,而是让CPU执行某一程序,在程序需要等待外部/O设备的响应时切换到另一个程序去执行。
这样规定后事情好像被完美解决了。
新问题
再回到A和B的程序代码中,A的程序想向内存地址为1、2、3、4的地址中存入1、2、3、4的地址中存入100000000;,而B的程序是向内存地址为1、2、3、4的地址中存入0,如果A的程序在占有CPU运行时已经向内存中写了数据,在等待外部I/O时,CPU切到B的程序去执行了,向内存地址为为1、2、3、4的地址中存入0,等到CPU在去执行程序A时,再使用这四个内存地址时发现具值不为100000000,这让A很是生气,严重程度不亚于A将100000000美金存入银行,再来取钱时发现卡里一毛钱没有了。这还有法律吗?
上边的需求总结成一句话,就是:A程序和B程序要互相不访问对方使用的内存。
如果这世界上只有他们两个程序员,那他们两直接商量那一块内存是A的,那一块是B的,就好了,可是世上的程序员很多。这一家公司也不会只有两个程序员,如果C、D、E、F也是这家公司的员工,也想在这一台机器上跑程序,在写程序时这不得开一个大会?
虚拟内存
原先CPU在执行指令时,指令用得到的内存地址会直接发送给内存。
这时程序中用到的地址就是实际发送给内存的地址,把内存接收到的地址称为物理地址。
现在A和B的程序中抱含了访问相同地址的指令,要做到程序间的数据相互独立,也就是程序间的地址相互独立。就不能直接让CPU将指令中用得到的地址发送给内存,而是需要引入一个中间层, 这个中间层会先将CPU发出的地的先翻译,再发送到内存 。这个用于翻译地址的设备我们称为内存管理单元MMU。
那现在我们知道CPU和内存的通信模型如下:
这个设备通常集成在CPU芯片内。
有了MMU后,程序中用到的地址和实际发到内存的地址就有了区别,我们在程序中用到的,看到的地址都是虚拟地址。为做区分,将通过物理地址访问的内存叫物理内存。
对于A程序中用到的虚拟地址经MMU翻译后对应的地址可能为1,B程序中用到的虚拟地址1经MMU翻译后会不同于A程序的物理地址,可能会译成10086。MMU的引入把程序员与物理内存之间的硬耦合给解开了,至于开发中用到的虚拟内存经翻译后会对应哪一个物理内存地址程序员不需要关心,这是操作系统负责的,这样程序员就使用虚拟地址开发,不用去关心,物理内存如何使用,能将更多的注意力集中在开发上。这样A、B、C、D、E、F高兴了,世上千万程序高兴了,不高兴的操作系统的设计人员们、他们得维护各个程序的虚拟地址到物理地址的映射表,得知道哪些物理地址是空闲的,哪些物理地址已经被占用占有了。
那操作系统是如何维护进程的虚拟地址与物理地址映射的信息? MMU如何根据信息翻译成相应的物理地址?
我们来看几种翻译方案
案一:给进程用到的虚拟地址建立一个映射表项
操作系统从物理内存中拿出一块区域用于存放地址映射表,映射表的一个表项用于说明一个虚拟以地址和物理地址之间的映射关系如下图:
这种为每个进程中每个用到的虚拟地址建立一个射表的方式有两个问题
1.MMU从地映射表中查找某一个 虚拟地址对应的物理地址比较耗时。
2.进程在运行时可能需要动态申请内在(new/malloc函数)此时操作系统需要为该进程新申请的虚拟内存建立映射表项,要修改地址映射表的结构,十分不方便。
方案二:为所有虚拟地址建立一个映射表
CPU支持的虚拟地址位数是有限的、比方说某个CPU支持16位虚拟地址,那拟地址范围就是
0000000000000000(2)~1111111111111111(2)
16位二进制数总的可以表示2^16=65536个虚拟地址,操作系统可以在物理内存中创建一个数组,该数组中包含65536个元素让虚拟地址的值和数组的下标一一对应,这个数组的值代表虚拟地址映射到的物理地址。如果CPU支持n位虚拟地址,那操作系统就得为每一个进程维护一个包含个元素的数组。
这样就解决了方案一带来的两个问题:
•MMU可以将虚拟地址直接作为数组的下标,就可以获取到该虚拟地址对应的物理地址,加快了搜索速度。
•由于数组中包含了所有的虚拟地址,所以之后进程动态申请内存也不需要向数组中插入新元素,十分便捷。
但是这个方案有一个致命的缺陷:用于映射地址的数组太占地方了!
对于一个支持16位虚拟地址的CPU,操作系统就得为每个进程分别分配一个包含65536个元素的数组,那对于一个支持32位虚拟地址的CPU,操作系统就得为每个进程分别分配一个包含232=4 294 967 296个元素的数组。大家可能对4 294 967 296没什么概念,232 = 22*210*210*210,而210=1024=1K,所以232 =4*1K*1K*1K=4G。即32位二进制数可以表示4G个虚拟地址,如果每个物理地址也用32位(4字节)表示的话,那意味着数组元素大小就是4字节,那数组的总大小就是4G*4B=16GB。
操作系统需要为每个进程都分配这样的一个映射数组,如果10个进程并发执行的话,那就需要16GB*10 = 160GB的物理内存来存放这些数组。实在是太浪费存储空间啦!
方案三:对内存进行分页后 进行映射
一个字节一个字节的映射效率太低,可以将一串连续存储的地址空间进行映射,就可以提高效率,我们可以把一串连续的存储空间称作一个页,把页的起始地址叫基地址,把页中存储单元相对于页的基地址的距离称为该存储单元的偏移地址。
这样我们就可以把虚拟内存划分若干个页,把物理内存也划分若干个与虚拟内存大小相同的页,操作系统只需把虚拟页和物理之间的映射关系记录下来就行,而虚拟内存页中的存储单元和物理内存中的存储单元就按照在他们页内偏移地址自动映射就好了,
例如虚拟内存划分成9个页,物理内存划分成7个页、虚拟内存的第0,3,6被使用了。
操作系统的任务就是将这些虚拟项映射到物理,并记录映射关系。
因为现在有 9个虚拟页,所以操作系统只需要在物理页中分配一个包含9个元素的数组,虚拟页的页号和数组下标一一对应,数组元素的值表示将元素下标对应的虚拟页映射
到哪个物理页的页号。
举个例子,操作系统将虚拟页0映射到物理页2,把虚拟页3映射到物理0,把虚拟页6映射到物理页4,并在物理页6中分配一部分空间用于存储映射数组。用于映射虚拟页和物理页的数组叫做页表,页表中的一个元素被称作一个页表项、页表项的下标是虚拟页的页号,页表中的值包含物理的页号。
现代计算机基本上都通过设计页表来完成虚拟地址到物理地址的映射。
虚拟地址到物理地址
将一个虚拟地址翻译成物理地址需要MMU和操作系统协作完成的。
操作系统负责为进程的虚拟分配相应的物理页,并把映射关系填到页表中,当然还需要把当前进程所使用的页表的物理地址告诉MMU。
补充
MMU会从CPU中一个存储页表物理地址上的寄存器中获取负卷的物理地址。每个进程都有一个进程控制块,进程控制会保存当前进程所使用负卷的地址,如果发生进程切换,操作系统会把新运行的进程的负表地址放到CPU存储页表物理地址的寄存器中,这样MMV读取的页表就是新进程的页表了。
MMU收到CPU发给自己的虚拟地址后,会从虚拟地址中提取出页号,然后以号作为下标,到页表中找到相应的页表项,从页表项中找到物理页的页号,然后将物理页的号和从虚拟地址中获取的偏移地址组合成完整的物理地址,然后发送给物理内存。
那页的大小是操作系统自己规定的吗?
页的大小是CPU规定的。
我们以4MB大小的页、32位虚拟地址为例来分析一下,MMU如何将一个虚拟地址映射到物理地址的过程。
4M = 222次方,也就意味着一个页内的偏移地址由22个二进制位组成,那我们可以将一个32位的虚拟地址分为两个部分:
•高10位表示页号
•低22位表示页面内的偏移地址
既然用10位表示页号,那么相当于总共就有210=1024=1K个虚拟页面,为了映射这些虚拟页面,我们建立的页表就需要包含1K个页表项。
CPU规定页表项大小为4个字节,页表项中除了保存物理页的页号之外,还会记录一些页面的属性,比方说页面是否可读、是否可写、是否可以执行该页面中的指令等等。
那么一个页表项是4B,一共需要1K个页表项,那么页表所需的存储空间大小就是4KB。
下边举一个具体的例子,比方说程序中用到了虚拟地址00000001110000000000000000000101₂(十六进制的:0x01c00005),那么这个虚拟地址可以被分成两个部分:
•高10位表示虚拟页的页号,即0000000111₂(十进制的7)
•低22位表示虚拟页偏移地址,即0000000000000000000101₂。
假设操作系统将这个虚拟页映射到物理页的页号为0000010110₂,那么MMU将虚拟地址映射到物理地址的过程就如下图所示:
也就是MMU先将虚拟页号作为页表的下标去定位页表项,从页表项的物理页号部分拿出物理页号。虚拟地址的虚拟页偏移地址和物理页偏移地址是相同的,那么将物理页号和其偏移地址拼接起来,就组成了最终的物理地址:00000101100000000000000000000101₂(十六进制的0x05800005)。即最终的效果就是我们程序里虽然访问的是虚拟地址0x01c00005,但实际发送给物理内存的地址却是0x05800005。
二级页表的引入
使用4MB大小的页面的话,那就意味着操作系统一次至少要给应用程序分配4MB大小的物理内存,对于某些小的程序来说实在是天大的浪费。如果使用的页面大小为4KB的话,那么对于一个32位大小的虚拟地址来说:
•高20位表示页号
•低12位表示页面内的偏移地址
这就意味着我们设计的页表需要220=1M个页表项,如果一个页表项占4个字节的话,整个页表就需要4MB的大小。也就是说不论多大的程序,操作系统先得给它分配一个4MB大的页表,这对于比较小的程序也是非常大的浪费。
页设计的大了也不好,设计的小了也不好,真烦人。
回想一下我们网购时填地址时的情况,都是先填写省级行政区,然后系统会将省级行政区下的市级行政区列出来供我们选择。如果系统直接将全国所有市级行政区列出来让我们挑选的话,那用户肯定要被气死。
类似的,4KB页面的既然20位的页号太长了,我们也可以把页号拆成两个部分:
•把高10位的页号称作一级页号
•把低10位的页号称作二级页号
然后就可以给一级页号和二级页号分别制作页表。还拿32位虚拟地址00000001110000000000000000000101₂(十六进制的:0x01c00005)为例,如果使用4KB大小的页面的话,那么该地址的:
•虚拟页的偏移地址为低12位,即000000000101₂。
•虚拟页的页号为高20位,即00000001110000000000₂,将这20位可以继续拆成高10位的一级页号0000000111₂和低10位的二级页号0000000000₂。
接下来就可以如下图所示的方式来映射虚拟地址:
先为一级页号建立一个页表,我们称作一级页表。一级页号包含10位,所以一级页表中包含210=1K个页表项,每个页表项占用4B,整个一级页表就占用4KB。一级页表中的每个页表项其实都对应4MB的虚拟内存,比方说:第0个页表项代表虚拟地址前10位为000000000₂的虚拟地址,该页表项对应的虚拟地址范围就是:000000000 0000000000000000000000₂ ~ 000000000 1111111111111111111111₂;第1个页表项代表虚拟地址前10位为000000001₂的虚拟地址,该页表项对应的虚拟地址范围就是:000000001 0000000000000000000000₂ ~ 000000001 1111111111111111111111₂。
本例中虚拟地址的一级页号为0000000111₂(7),所以我们在一级页表中定位到下标为7的页表项,这个页表项用于映射0000000111 0000000000000000000000₂ ~ 0000000111 1111111111111111111111₂这4MB大小的虚拟内存。为了映射这4MB大小的虚拟内存,我们需要再创建一个页表,而一级页表的页表项中包含新创建的这个页表的物理页号。本例中一级页表下标为7的页表项包含的物理页号是0000000000000000110011₂(51),即新创建的页表的基地址为0000000000000000110011000000000000₂。
•再为二级页号建立一个页表,我们称作二级页表。二级页表也包含10位,所以二级页表中包含210=1K个页表项,每个页表项占用4B,一个二级页表就占用4KB。整个二级页表用于映射4MB大小的虚拟内存,所以二级页表中的每个页表项用于映射4KB的虚拟内存。本例中二级页号为000000000₂,所以在二级页表中定位到下标为0的页表项,这个页表项中就包含着最终映射到的物理页页号,本例中最终物理页页号为00000101100000000000₂。
将物理页页号和虚拟地址中的偏移地址组合起来,就得到了最终的物理地址:00000101100000000000000000000101₂。
为了将一级页表和二级页表作区分,我们也把一级页表称作页目录(Page Directory),一级页表里的页表项也被称作页目录项(Page Directory Entry,简称PDE)。二级页表中的称呼保持不变。
引入了两级页表后,操作系统可以以4KB大小的页面作为虚拟内存和物理内存之间映射的单位,而且在建立页表时也不用直接分配4MB大小的页表,而是做到了实现了“什么时候用页表,什么时候再建页表”的功能。初始的时候只需要建一个4KB大小的页目录,之后用到了哪块虚拟内存,就给该块虚拟内存分配二级页表。
当然,如果CPU支持的虚拟地址位数更多,比方说达到64位,那可以继续建立更多层级的页表,现代Intel CPU最多支持4级页表。
虚拟内存和硬盘
从上边的叙述中大家可以看出,操作系统给程序员提供了一个假象:程序员认为自己有一个很大很大且地址连续的内存。其实程序员面向的内存是虚拟的,操作系统和MMU共同负责把程序员使用的虚拟地址转换为真正的物理地址。
这样的话,进程使用的虚拟内存可能会比实际的物理内存更大,多个进程都有自己的虚拟内存,却共享一份物理内存,很容易造成进程使用的虚拟内存大小超过可分配的物理内存大小,这该咋办?
一种办法是操作系统直接向用户进程报告:不好意思,物理内存用完了,不能给你要的虚拟内存映射物理内存了,我先把你挂掉了哈。
这种做法太粗暴,于是有的设计操作系统的大叔就想:物理内存比较小,可我们的硬盘大啊。物理内存里的页面又不是每时每刻都会被用到,对于那些暂时用不到的物理页面,我们先把它们转移到硬盘里,这样这些物理页面就可以分配给现在进程急需分配的虚拟内存了呀。等到啥时候某个进程需要访问这些被转移到硬盘的物理页面,再把它们转移回物理内存,并且重新把虚拟页面和物理页面的映射关系填到页表中不就好了!
这时候页表的页表项就又起作用啦,我们说页表项除了包含物理页的页号之外,还回包含页的一些属性,其中就有一个该页是否在物理内存中的属性,我们把这个属性称作Present属性,简称P属性:
•当P=0时,表示该页不在物理内存中。
•当P=1时,表示该页在物理内存中。
比方说虚拟内存包含9个页,物理内存包含7个页,操作系统按如下图所示的方式填充页表:
本例中操作系统用物理页6来存储页表,进程使用了虚拟页0~虚拟页5共6个虚拟页,操作系统可以:
•让虚拟页0映射到物理页2、虚拟页3映射到物理页0、虚拟页4映射到物理页4
•让虚拟页1映射到磁盘页0、虚拟页2映射到磁盘页1、虚拟页5映射到磁盘页3
当CPU执行某条指令时,该指令需要访问被映射到磁盘页的虚拟页,CPU就会发现该虚拟页相应的页表项的P属性为0,即该虚拟页其实被映射到了磁盘页,此时可以从页表项中获取到磁盘页的位置,然后将相应的磁盘页加载到物理内存,并修改页表。之后再重新执行需要访问该虚拟页的指令。
引入了虚拟页和磁盘页的映射之后,编写用户程序的程序员真的就开心到飞起了,他们在编程时可以毫无估计的使用虚拟内存,完全不用考虑物理内存有多大。只是可怜了设计操作系统的同学,他们默默的承受着一切...