计算机基本功:虚拟内存与物理内存
计算机基本功:虚拟内存与物理内存
虚拟内存和物理内存是计算机系统中两个重要的概念,它们之间的转换机制是计算机科学中的核心原理之一。本文将通过代码示例、图解和详细的文字说明,深入浅出地解释虚拟内存的工作原理,包括地址转换、内存保护机制等核心概念。
虚拟内存
程序中的地址能否索引到具体储存单元呢?具体的储存单元,又是如何分配的呢?下面我们用两个问题来说明其中的原理。
第一个问题
应用程序中使用的地址是什么内存地址?是不是感觉情况有很多种,一时很难回答清楚?遇到这种状况不要慌,我们只要动手写一个简单的程序就可以验证。
#include "stdio.h"
#include "stdlib.h"
void func_a()
{
//定义地址:0x40000000
int* p = (int*)0x40000000;
printf("内存地址:%p\n", p);
//向该地址写入数据
*p = 0xABABBABA;
printf("内存地址:%p处的值:%x\n", p, *p);
return;
}
int main()
{
func_a();
return 0;
}
上述应用程序非常简单,我们在 main 函数中调用函数 func_a,而在函数 func_a 中,我们定义一个整型指针,C 语言中指针就是内存地址,其地址值为 0x40000000。 代码我给你存到了课程相关的工程目录中,你可以打开工程目录 make 一下,就会自动编译好。然后,你需要在终端下运行这个 main.elf 程序,首先会出现“内存地址:0x40000000”,接着会出现“段错误,程序异常退出”的提示。
出现了段错误提示,在你的预料之中么?我来解释一下,为什么会出现这种情况,这是因为我们使用了一个没有分配的地址。很显然,如果一个地址真的能索引到内存,该地址就能访问内存,除非这地址是个假地址,在内部需要某种机制进行转换才能访问内存。这个转换机制可能需要一些表或者数据结构进行控制,并且这个控制权掌握在操作系统的手里。
由于操作系统管理内存的规则,是先分配后使用,所以,我们就猜想操作系统分配内存的时候,就会处理控制地址转换的相关表和数据结构。接下来我们写段代码,来验证一下猜想,如下所示:
#include "stdio.h"
#include "stdlib.h"
void func_b()
{
//分配内存,返回其地址
int* p = (int*)malloc(sizeof(int));
if(p){
printf("内存地址:%p\n", p);
//向该地址写入数据
*p = 0xABABBABA;
printf("内存地址:%p处的值:%x\n", p, *p);
}
return;
}
int main()
{
func_b();
return 0;
}
这次我们编译运行,就会正确地输出结果了。 其实 malloc 函数在内部最终会调用 Linux 内核的 API 函数,在该进程的虚拟地址空间中分配一小块虚拟内存,返回其首地址。这个过程我用一幅图来为你展示,如下所示:
由于代码优化的原因,malloc 函数并不是每次调用,都会导致 Linux 内核建立一个vm_area_struct 数据结构。我们假定 malloc 函数导致 Linux 内核建立了一个 vm_area_struct数据结构,该结构中有描述虚拟内存的开始地址、大小、属性等相关字段,表示已经分配的虚拟内存空间。
许多个这样的结构可以一起表示进程的虚拟地址空间分配情况。但是,这个从 vm_area_struct数据结构中返回的地址,仍然是虚拟的、是假的,是不能索引到内存单元的,直到访问该地址时,会发生另一个故事,如下图所示:
上图中 CPU 拿着一个虚拟地址访问内存,首先会经过 MMU,对于调用 malloc 函数的情况是该虚拟地址没有映射到物理内存,所以会通知 CPU 该地址禁止访问。
上图中 1 到 4 个步骤为硬件自动完成的,然后 CPU 中断到 Linux 内核地址错误处理程序,软件开始工作,也就是说 Linux 内核会对照着当前进程的虚拟地址空间,去查找对应的vm_area_struct 数据结构,找不到就证明虚拟地址未分配,直接结束,进程会发出段错误;若是找到了,则证明虚拟地址已经分配,接着会分配物理内存,建立虚拟地址到物理地址的映射 关系,接着程序就可以继续运行了。
当然了,实际情况比图中的复杂,这里我们只是要理清楚 malloc 函数的逻辑,并且明确malloc 是返回的虚拟内存地址就可以了。
第二个问题
我们要想清楚的第二个问题就是,直接使用物理内存地址,会出现什么后果?我们来看一个程序,下面这段代码是一个简单版的 memset 函数。
void mymemset(void* start, char val, int size)
{
char* buf = (char*)start;
for(int i = 0; i < size; i++)
{
buf[i] = val;
}
return;
}
我们提出一个假设:这个函数被不同的应用程序调用,且使用的地址就是物理地址,能直接访问物理内存单元。
你可以想一想,如果假设成立,恶果就是一个程序可以改变另一个程序的内存,甚至是全部的内存。想想吧!这是何等可怕。通过这个例子,我们发现物理地址不能有效地隔离内存,达到保护内存的结果。
想要隔离内存,就需要依赖虚拟内存这个东西。我画了一幅图,带你总结一下虚拟内存的本质,如下所示:
由上图可知,我们各种应用都可以拥有从 0 到最大虚拟地址的完整的虚拟内存空间,并且可以
任意使用这个虚拟内存空间。每个应用,都认为自己拥有整个内存,这一点可以从所有的应用程序使用相同的链接脚本进行链接得到佐证。各个应用程序调用 malloc 函数,可能得到相同地址,是另一个佐证。
我们现在终于知道了,虚拟地址真的只是一个整数,一系列的这种整数集合,就构成了虚拟内存空间。这个整数能索引一个字节的虚拟内存单元,但这个虚拟内存单元不会对应到真正的物理设备,因此它虽然可以独立存在,但却需要下层的物理内存作为支撑,才能实现访问和储存数据。
物理内存
物理地址空间是 CPU 地址线位宽所能表示最大整数集合,只是一个地址,它能索引物理设备,或者什么都不索引,这里的物理设备中就包括了物理内存。下面我们来看看真实的内存长什么样,如下所示:
从上图可以看到,在 PCB 板上有内存颗粒芯片,主要是用来存放数据的。SPD 芯片用于存放内存自身的容量、频率、厂商等信息。还有最显眼的金手指,用于连接数据总线和地址总线、电源等。
其实内存应该叫 DRAM,即动态随机存储器。内存储存颗粒芯片中的存储单元是由电容和相关元件做成的,电容存储电荷的多、少代表数字信号 0 和 1。而随着时间的流逝,电容存在漏电现象,就会引起电荷不足的情况,导致存储单元的数据出错。所以,DRAM 需要周期性刷新,以保持电荷状态。
DRAM
结构比较简单且集成度很高,通常用于制造内存条中的储存颗粒芯片。我们无需过多关注内存硬件层面的技术规格标准,这里重点需要关注的是,
逻辑上内存和硬件系统的连接方式和结构
。
还是画幅图来说明吧,这样方便你建立直观印象,如下图所示:
我们假定从物理地址 0 开始,索引的是物理内存,CPU 发出的地址是虚拟地址,经由 MMU转换变成物理地址,物理地址经由地址译码单元就会对应到具体的内存字节储存单元。一个字节单元能储存 8 个二进制位,即一个地址能对应到 8 个二进制位。
你可以通过 dmsg 命令,查看你物理机上的情况。在我的 x86 机器里,情况如下图所示:
从图里我们可以看到,usable 类型的物理地址区间,对应的是 DRAM,即内存。其它的则是保留的或者硬件设备的地址空间,这些空间程序是不能当作内存来使用的。
逻辑上物理内存相当于几个地址上不连续的字节数组,始终有一个物理地址能索引到其中一个字节。
虚实结合
提出虚拟内存这个概念,一是为了让应用认为自己享有完整的地址空间,拥有整个内存的使用权。二是要对物理内存进行保护,即使各个应用程序都存放在物理内存之中,也不能随意访问自己的物理内存,更不能侵犯别的应用程序所占用的物理内存,不然就会出现互相改写对方内存的情况,一旦出现这样的情况后果就严重了,任何应用程序都不能正常运行了。
那接下来要考虑的问题就是,虚拟内存跟物理内存要如何对应起来?
虚拟内存必须要落实到物理内存才能真正完成工作,最简单的方案是让虚拟地址能够索引到物理内存单元,但虚拟地址和物理地址显然不能一一对应,如果那样的话,虚拟地址等于物理地址且不受控制,这样虚拟地址就没有任何意义了。
因此,我们需要在虚拟地址空间与物理地址空间之间加一个机构,这个机构相当于一个函数:p=f(v) 。对这函数传入一个虚拟地址,它就能返回一个物理地址。该函数有自己的计算方法,对于没法计算的地址或者没有权限的地址,还能返回一个禁止访问。
这个函数用硬件实现出来,就是 CPU 中的 MMU,即内存管理单元。CPU 发出的虚拟地址首先经过 MMU,MMU 内部计算得出物理地址,最后用物理地址去访问内存。MMU 的结构如下:
上图中,展示了 CPU 发出的虚拟地址经过 MMU 转换出物理地址,进而访问内存的过程,但我们并没有弄清楚 MMU 是使用什么方法进行转换的,所以下面我们继续探讨 MMU 的地址转换过程。
你不妨想一想,把一个数据转换成另一个数据,最简单的方案是什么?当然是建立一个对应表格,对照表格进行查询就行了。MMU 也是使用一个地址转换表,但是它做很多优化和折中处理。不做任何折中处理的话,这种方案是无法实施的。
你可以想象一下 32 位的地址空间,有 4G 个虚拟地址和 4G 个物理地址。在这种情况下,每8 个字节存放两个地址数据,想要装下所有的地址,这个表有多大?应该放在哪里?查询代价有多大?所以这个方案直接 pass 掉。
我们现在来看看,通常情况下 MMU 是如何解决这个问题的,一共有三个关键环节。
首先,MMU 对虚拟地址空间和物理地址空间进行
分页处理
,一个页大小可以是 4KB、16KB、2MB、4MB、1GB 不等。这是为了增加地址的粒度,避免采用每个字节一个地址,现在一页一个地址,地址数量就会大大减少,从而减少转换表的大小。
其次,MMU 采用的转换表也称为页表,其中只会对应物理页地址,不会储存虚拟地址,而是
将虚拟地址作为页表索引
,这进一步缩小了页表的大小。
最后
MMU对页表本身进行了拆分,变成了多级页表
。假如不分级,4GB 内存空间 ,按照4KB 大小分页,有 1M 个页表项,每个页表项只占用 4 个字节,也需要 4MB 空间。如果页表分级,在建立页表时就可以按需建立页表,而不是一次建立 4MB 大小的页表。
我们一起来画一幅图来描述一下这个过程,如下所示:
对照图片我们可以看到,虚拟内存页和物理内存页是同等大小的,都为 4KB,各级页表占用的空间也是一个页,即为 4KB。MMU 把虚拟地址分为 5 个位段,各位段的位数根据实际情况有所不同,按照这些位段的数据来索引各级页表中的项,一级一级往下查找,直到页表项,最后用页表项中的地址加页内偏移,就得到了物理地址。
我再画一幅图,为你描述这一过程。
看到这幅图,我们就清楚了 MMU 用虚拟地址转换物理地址的过程。如果转换成功就可以直接访问内存了;但如果转换失败,MMU 就会通知 CPU,地址转换失败,让 CPU 产生一个异常中断,进而通知操作系统内核,让操作系统内核来处理这个异常,就像 malloc 分配内存的过程那样。
我们已经知道了虚拟地址如何转换成物理地址,但是如果只是按部就班地转换可不行,别忘了,还需要对物理内存进行保护。这个保护物理内存的问题的关键就是,想清楚一个虚拟地址在什么情况下能被转换成物理地址。
这就要说到 MMU 是如何控制转换动作的。要进行控制就需要相关的控制信息,聪明如你,大概已经猜到了,控制信息就放在页表项中,MMU 在转换过程中首先就会查看那些信息,以此作出判断。
下面我们看一下控制信息的格式,如下所示:
从上图中可以看到,页表项中的低 12 位为属性位段,这里保存一个物理内存页面的读写、执行、存在的相关权限,还有页面是否存在、可不可以缓存,是否已经访问或者写入,大小等信息。这些信息统统编码在 12 个二进制位中。
为什么表示各种页面地址的页表项,能让出 12 位用于编码这些信息呢?这是因为一个页面最小也是 4KB 且与 4KB 对齐,那么页面开始地址的低 12 位永远为 0,所以可以挪为它用。
到这里,我们就已经搞清楚虚拟地址如何转换成物理地址,并且知道了 MMU 如何控制转换过程,恭喜你解锁了虚实结合的思路和过程。
现在你可能隐约感觉到,只要操作系统牢牢控制页表数据,就能实现对内存的完全控制和保护,使得各个应用程序在自己的虚拟地址空间中安全地运行,不被打扰,也不能打扰别人。每个应用程序都有相同的虚拟内存,但却占用着不同的物理内存。
回顾梳理
首先我们从两个实际问题出发,研究了虚拟内存的本质。虚拟内存的应用,一是为了保护内存,二是为了限制访问内存。让应用程序拥有独立的地址空间,误以为自己能享用全部的内存。
接着我们分析了物理内存,了解了 DRAM 的特性和结构,因为 DRAM 就是我们常说的内存设备。这里你重点要关注的是内存的逻辑结构和系统连接方式。
最后我们讨论了虚实结合究竟是怎么实现的。硬件工程设计了 MMU,让它把虚拟内存地址通过页表中的信息转换成物理地址,并控制转换过程。如果转换失败就会通知 CPU,然后 CPU产生地址异常中断,最后由操作系统处理这个异常。操作系统将会通过修改页表的数据来修复这个问题,进而完全控制内存的访问。