Linux 虚拟内存管理引导篇
当程序运行起来之后就变成了进程,进程无论是在内核态还是用户态看到的都是虚拟内存空间,访问具体数据的时候,虚拟内存地址会先被转换为物理内存地址,通过物理内存地址就可以访问到真正存储这些数据的物理内存了,从而得到数据。
那到底什么是虚拟内存地址?为什么不直接使用物理内存地址而要使用虚拟内存地址?
接下来我们来详细解释下:
1. 什么是虚拟内存地址
首先人们提出地址这个概念的目的就是用来方便定位现实世界中某一个具体事物的真实地理位置,它是一种用于定位的概念模型。
举一个生活中的例子,比如大家在日常生活中给亲朋好友邮寄一些本地特产时,都会填写收件人地址以及寄件人地址。
以及在日常网上购物时,都会在相应电商 APP 中填写自己的收货地址。
随后快递小哥就会根据我们填写的收货地址找到我们的真实住所,将我们网购的商品送达到我们的手里。
收货地址是用来定位我们在现实世界中真实住所地理位置的,而现实世界中我们所在的城市一草一木都是真实存在的。
但收货地址这个概念模型在现实世界中并不真实存在,它只是人们提出的一个虚拟概念,通过收货地址这个虚拟概念将它和现实世界真实存在的城市、小区、街道等地理位置一一映射起来,这样我们就可以通过这个虚拟概念找到现实世界中的具体位置。
总结来看:收货地址是一个虚拟地址,它是人为定义的,而我们的城市、小区、街道是真实存在的,它们的地理位置是物理地址。
现在我们把时间切换到计算机的世界,在计算机的世界里,内存地址是用来定义数据在内存中的存储位置的,而内存地址分为虚拟内存地址和物理内存地址。
其中虚拟内存地址也是人为设计的一个概念,类比我们现实世界中的收货地址,而物理地址则是数据在物理内存中的真实存储位置,类比现实世界中的城市、街道、小区的真实地理位置。
说了那么多,虚拟内存地址到底长什么样子呢?
我们还是以日常生活中的收货地址为例做出类比,我们都很熟悉收货地址的格式:xx 省 xx 市 xx 区 xx 街道 xx 小区 xx 室,它是按照地区层次递进的。
同样,在计算机世界中的虚拟内存地址也有这样的递进关系。
这里我们以最通用的 4 级页表为例,64 位虚拟地址的格式为:全局页目录项(9 位)+ 上层页目录项(9 位)+ 中间页目录项(9 位)+ 页表项(9 位)+ 页内偏移(12 位)。共 48 位组成的虚拟内存地址。
虚拟内存地址中的全局页目录项就类比我们日常生活中收货地址里的省,上层页目录项就类比市,中间层页目录项类比区县,页表项类比街道小区,页内偏移类比我们所在的楼栋和几层几号。
2. 为什么要使用虚拟地址
在回答大家的这个疑问之前,让我们先来看下,如果在程序中直接使用物理内存地址会发生什么情况?
假设现在没有虚拟内存地址,我们在程序中对内存的操作全都是使用物理内存地址,在这种情况下,程序员就需要精确地知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节。
如果我们在单进程系统中比如嵌入式设备上开发应用程序,系统中只有一个进程,这单个进程独享所有的物理资源,包括内存资源。
在这种情况下,上述提到的这些直接使用物理内存的问题可能还好处理一些,但是仍然具有很高的开发门槛。
然而在现代操作系统中往往支持多个进程,需要处理多进程之间的协同问题,在多进程系统中直接使用物理内存地址操作内存所带来的上述问题就变得非常复杂了。
这里笔者为大家举一个简单的例子来说明在多进程系统中直接使用物理内存地址的复杂性。
int main(int argc, char *argv[]){
int i = 0;
...
printf("value :%d", i);
}
在程序代码相同的情况下,我们用这份代码同时启动三个进程,我们暂时将进程依次命名为 a、b、c。
这三个进程用到的代码是一样的,都是我们提前写好的,可以被多次运行。
由于我们是直接操作物理内存地址,假设变量 i 保存在 0x396 这个物理地址上。
这三个进程运行起来之后,同时操作这个 0x396 物理地址,这样这个变量 i 的值不就混乱了吗?三个进程就会出现变量的地址冲突。
所以在直接操作物理内存的情况下,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。
现实中一个程序会有很多的变量和函数,这样一来我们都需要给它们计算一个合理的位置,还不能与其他进程冲突,这就很复杂了。
虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离、互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。
系统上还运行了哪些进程和我没有任何关系。
这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。
这一切都是因为虚拟内存能够提供内存地址空间的隔离,极大地扩展了可用空间。
这样进程就以为自己独占了整个内存空间资源,给进程产生了所有内存资源都属于它自己的幻觉,这其实是 CPU 和操作系统使用的一个障眼法罢了。
任何一个虚拟内存里所存储的数据,本质上还是保存在真实的物理内存里的。
只不过内核帮我们做了虚拟内存到物理内存的这一层映射,将不同进程的虚拟地址和不同内存的物理地址映射起来。
当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。
3. 虚拟地址空间整体布局
前面的两小节已经对虚拟地址有了一定的认识,这一节一起来看下,在 64 位机器上,虚拟地址空间到底是如何布局的。
我们知道,在 64 位机器上,指针的寻址范围为 2^64,所能表达的虚拟内存空间为 16 EB。
虚拟内存地址范围为:0x0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF。
事实上在目前的 64 位系统下只使用了 48 位来描述虚拟内存空间,寻址范围为 2^48,所能表达的虚拟内存空间为 256TB。
其中低 128 T表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF。
高 128 T表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF。
这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段0x0000 8000 0000 0000 - 0xFFFF 7FFF FFFF FFFF的地址空洞,我们把这个空洞叫做canonical address 空洞。
大家注意到在低 128 T 的用户态地址空间:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF 范围中,虚拟内存地址的高 16 位全部为 0。
如果一个虚拟内存地址的高 16 位全部为 0,那么我们就可以直接判断出这是一个用户空间的虚拟内存地址。
同样的道理,在高 128 T 的内核态虚拟内存空间:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 范围中的所有虚拟内存地址的高 16 位全部为 1。
也就是说内核态的虚拟内存地址的高 16 位全部为 1,如果一个试图访问内核的虚拟地址的高 16 位不全为 1,则可以快速判断这个访问是非法的。
那么处于canonical address 空洞:0x0000 8000 0000 0000 - 0xFFFF 7FFF FFFF FFFF范围内的地址的高 16 位不全为 0 也不全为 1。
如果某个虚拟地址落在这段 canonical address 空洞区域中,那就是既不在用户空间,也不在内核空间,肯定是非法访问了。
未来我们可以利用这块 canonical address 空洞,来扩展虚拟内存地址的范围,比如扩展到 56 位。
4. 总结
本篇文章详细解释了什么是虚拟内存地址以及为什么要引入虚拟内存地址两个核心问题,理解这两个关键问题,一方面可以为后续的学习做好铺垫,另一方面也可以在面试中和面试官畅聊几句,为面试增添些许亮点。
文章最后以 64 位机器为例,详细描述了虚拟内存地址的整体布局,这里大家只要有一个整体印象即可,后续会针对内核虚拟内存空间和用户虚拟地址空间做详细解释,感兴趣的同学可以持续关注。
专注于 Linux 知识分享的技术开发人员,关注我,一起学习编程知识。
文章部分内容引用:一步一图带你深入理解 Linux 虚拟内存管理,作者有一系列的文章,都非常优秀~