从根儿上理解虚拟内存

码农有道公众号

共 8570字,需浏览 18分钟

 · 2022-06-21

铺垫

对于程序员来说,内存就相当于若干个存储数据的小格子,这些小格子被从0开始编号,效果如下图所示:

每个小格子中可以存放一些二进制数据,一个格子也被称作一个存储单元,上图展示了一个拥有16个存储单元的内存示意图。

格子的大小可以调整,不过现在人们基本上都把格子做成可以存储8个二进制位的大小,也就是一个格子可以存储一个字节的数据。当然,一个格子可以存储的数字范围比较小(毕竟只有8个二进制位),如果有存储比较大数字的需求,可以占用多个连续的格子进行存储。

专业起见,我们会把格子的编号称作内存地址

起因

狗哥是一个程序员,他写的程序中包含了一行代码:

inc byte [1]    ;狗哥的程序,把内存地址1的存储单元自增1

狗哥的代码的意思很简单,就是把内存地址为1的存储单元的值自增1。

猫爷也是一个程序员,他写的程序中也包含了一行代码:

dec byte [1]    ;猫爷的程序,把内存地址1的存储单元自减1

猫爷的代码意思也很简单,就是把内存地址为1的存储单元的值自减1。

如果狗哥的程序运行完,再运行猫爷的程序,那大家井水不犯河水,都运行的挺开心的,这样的情况持续了很久...

直到有一天有人提出了2个问题:

问题1:有的程序在运行时要等待外部I/O设备的响应,在等待期间CPU啥都不能干,占着茅坑不拉屎不好吧?问题2:为啥猫爷一定要等狗哥的程序运行完,才能运行自己的程序呢,这不公平!

问题1背后代表的是效率问题,问题2背后代表的是公平问题。为了解决这两个问题,我们可以规定:

当一个程序需要等待外部I/O设备响应时,就先让CPU去执行别的程序,等外部I/O设备响应时再回过头来去处理原先的程序,这样就解决了问题1

不必再等待一个程序执行完之后,才去执行另一个程序,而是让CPU执行一段某个程序后,就切换到另一个程序执行,这样看起来就是各个程序交替执行,这样就解决了问题2

事情好像被完美解决了,但是这就带来了新的问题。

新问题

视角回到狗哥和猫爷的程序,狗哥的程序想把内存地址为1的存储单元自增1,而猫爷的程序想把内存地址为1的存储单元自减1,如果他们俩的程序交替执行,那就可能发生狗哥刚给内存地址为1的存储单元自增了1,然后猫爷就把该存储单元的值自减了1(相当于又给改回去了),之后狗哥如果再使用内存地址为1的存储单元时,使用的就不是自增后的值,这个情况让狗哥非常生气!严重程度不亚于我把一个小目标存到了银行,下次来银行的时候竟然发现账上成了1块钱,未经本人同意,擅自就把属于我的钱给取走了,还有王法吗?还有法律吗?

上边的需求总结成一句话就是:狗哥程序所访问的内存不应让猫爷访问

那这就得让狗哥和猫爷在写程序前商量好,哪几个内存地址是狗哥用,哪几个是猫爷用。这听起来就有点儿烦,那如果张三、李四、王五他们写的程序也想在同一台计算机运行的话,那就得五个人商量。重点谁都可以写程序,世界上写程序的人千千万万,难不成写程序之前都得跟你商量商量?

需要新的解决方案...

虚拟内存

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

——某个挺有名的人说的,不过我不记得名儿了

原先CPU在执行指令时,将指令用到的内存地址会直接发送给内存,如下图所示:

这时程序中用到的地址就是实际发送给内存的地址,我们把内存实际接收到的地址称作物理地址(Physical Address)。

现在狗哥和猫爷的程序中包含访问相同内存地址的指令,而他们又不想让别的程序修改自己程序用到的数据。这时候就不能简单的让CPU将指令中用到的地址发送给内存了,而是需要引入一个中间层,用来先将CPU发送出来的地址给翻译翻译,翻译完了再送往内存。我们把引入的这个用于翻译地址的设备称作内存管理单元(Memory Management Unit,简称MMU)。那么现在CPU和内存的通信过程就如下图所示了:

不过人们通常把MMU这个部件也集成在CPU芯片内部,所以整体结构如下图所示:

引入了MMU之后,程序中用到的地址和实际发往内存的物理地址就有了区别,我们把程序中实际用到的地址也称作虚拟地址(Virtual Address)。

这样的话,程序员在编程时所使用的地址都是虚拟地址,他们眼中的内存就是一个虚拟内存(Virtual Memory),为做区分,我们将通过物理地址访问的内存叫作物理内存(Physical Memory)。

对于狗哥程序中使用到的虚拟地址1,MMU可以把它翻译成一个物理地址,比方说4,而对于猫爷程序中用到的虚拟地址1,MMU可以把它翻译成另一个物理地址,比方说8。这样狗哥在访问虚拟地址1时,实际上访问的是物理地址4,猫爷在访问虚拟地址1时,实际上访问的是物理地址8,这样他们程序中的数据就可以不被别人访问了。

话说狗哥、猫爷他们写的程序被称作用户程序,这些程序对应着硬盘上的一个可执行文件。这些可执行文件需要被操作系统加载到内存成为一个运行着的程序,操作系统把每个运行着的程序称作一个进程(Process)。为了更好的管理这些进程,操作系统会为每个进程分配一个进程控制块(Process Control Block,简称PCB),在进程控制块中放置了与这个进程有关的诸多属性,诸如本进程执行时CPU中若干寄存器的值是什么(也就是通常所说的上下文),当前进程的编号、当前进程的状态(是运行、就绪、还是挂起),还有很多别的信息,这些信息中就包括如何翻译当前进程用到的虚拟地址的信息。

这样就把程序员和物理内存之间的硬耦合给解开了,程序员编程时所使用的都是虚拟地址,至于这个虚拟地址实际对应哪个物理地址他们并不关心,这是操作系统负责的。这样程序员眼里的内存就是一个虚拟的内存,他们在虚拟内存上面谋篇布局,完全不用关心物理内存是如何使用的。

这样狗哥高兴了,猫爷高兴了,张三李四王五高兴了,世界上千千万万的应用程序员都高兴了。唯一不高兴的是操作系统的设计人员,他们得维护各个程序的虚拟地址到物理地址的映射表,还需要知道哪个物理地址空闲,哪个已经被占用了,想想都烦... 烦也得做,继续往下看吧

接下来的问题就是操作系统如何维护进程的虚拟地址与物理地址映射的信息,以及MMU如何根据此信息来翻译地址了。

我们下边看几种翻译方案。

方案一:给进程用到的虚拟地址建立一个映射表项

操作系统可以专门从物理内存中拿出一块区域作为地址映射表,映射表的一个表项用于说明一个虚拟地址和一个物理地址之间的映射关系,比方说下边这个地址映射表:

这种为进程中每个用到的虚拟地址建立一个表项的方式存在两个问题:

MMU从地址映射表中查找某一个虚拟地址对应的物理地址比较耗时。进程在运行过程中可能需要动态申请内存(诸如调用malloc函数),此时操作系统就需要为该进程新申请的虚拟内存建立映射表项,这需要修改地址映射表的结构,十分不便。

结论就是这种映射方案非常不好,再找其他方案吧。

方案二:为所有虚拟地址建立一个映射表

CPU支持的虚拟地址位数是有限的,比方说某个CPU支持16位虚拟地址,那能表示的虚拟地址范围就是:

0000000000000000₂ ~ 1111111111111111₂

16位二进制数总共可以表示2^16=65536个虚拟地址,操作系统可以在物理内存中创建一个数组,该数组中包含65536个元素,让虚拟地址的值和数组下标一一对应,这个数组元素的值代表该虚拟地址映射到的物理地址。那么如果CPU支持n位虚拟地址,那么操作系统就得为每个进程维护一个包含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的物理内存来存放这些数组。实在是太浪费存储空间啦!

继续改进!

方案三:对内存进行分页后进行映射

一个字节一个字节的映射效率太低,一坨字节一坨字节映射就可以显著的提升效率。

这里说的一坨字节指的是一串地址连续的存储空间,为文明表述,我们可以把一串地址连续的存储空间称作一个(Page)。把这个页开始的地址称作页的基地址,把页中存储单元相对于页的基地址的距离称作该存储单元的偏移地址

这样我们可以把虚拟内存划分成若干个页,把物理内存也划分成若干个与虚拟内存页大小相同的页,操作系统只需要把虚拟内存页和物理内存页之间的映射关系记录下来就好,而虚拟内存页中的存储单元和物理内存页中的存储单元按照它们在页内的偏移地址自动映射就好了(无需操作系统记录)。

比方说虚拟内存可以划分成9个页,物理内存划分成7个页,目前虚拟内存用了第0、3、6这三个页:

操作系统的任务就是将这些虚拟页映射到物理页,并且把映射关系记录下来。

因为现在有9个虚拟页,所以操作系统只需要在物理页中分配一个包含9个元素的数组,虚拟页的页号和数组下标一一对应,数组元素的值表示将元素下标对应的虚拟页映射到哪个物理页的页号。比方说操作系统选择把虚拟页0映射到物理页2、把虚拟页3映射到虚拟页0、把虚拟页6映射到物理页4,并且从物理页6中分配一部分空间用作存储映射数组,如下图所示:

用于映射虚拟页和物理页的数组也被称作页表(Page Table),页表中的一个元素被称作一个页表项(Page Table Entry,简称PTE)。页表项的下标是虚拟页的页号,页表项的值包含物理页的页号。

现代计算机基本上都通过设计页表来完成虚拟地址到物理地址的映射。

实例

将一个虚拟地址翻译成物理地址是需要MMU(这是一个硬件设备)和操作系统协作完成的。

操作系统负责为进程的虚拟页分配相应的物理页,并且把映射关系填充到页表中,当然还需要把当前进程所使用的页表的物理地址告诉MMU。

小贴士:

MMU会从CPU中一个存储页表物理地址的寄存器中获取页表的物理地址。每个进程都有一个进程控制块,进程控制块中会保存当前进程所使用页表的地址,如果发生进程切换,操作系统会把新运行的进程的页表地址放到CPU存储页表物理地址的寄存器中,这样MMU读取的页表就是新进程的页表了。

MMU收到CPU给自己的虚拟地址后,会从虚拟地址中提取出页号,然后以页号为下标,到页表中找到相应的页表项,从页表项中找到物理页的页号,然后将物理页的页号和从虚拟地址中获取的偏移地址组合成完整的物理地址,然后发送给物理内存。

那页的大小是操作系统自己规定的吗?这个还真不是,页的大小是CPU自己规定的。比方说Intel的CPU支持4KB、2MB、4MB、1GB等页面大小。我们以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,即该虚拟页其实被映射到了磁盘页,此时可以从页表项中获取到磁盘页的位置,然后将相应的磁盘页加载到物理内存,并修改页表。之后再重新执行需要访问该虚拟页的指令。

引入了虚拟页和磁盘页的映射之后,编写用户程序的程序员真的就开心到飞起了,他们在编程时可以毫无估计的使用虚拟内存,完全不用考虑物理内存有多大。只是可怜了设计操作系统的同学,他们默默的承受着一切...

推荐阅读:

完全整理 | 365篇高质技术文章目录整理

算法之美 : 栈和队列

主宰这个世界的10大算法

彻底理解cookie、session、token

浅谈什么是递归算法

专注服务器后台技术栈知识总结分享

欢迎关注交流共同进步

浏览 20
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报