【Linux】进程概念(六):进程地址空间深度解析:虚拟地址与内存管理的奥秘
文章目录
- 一、C++内存空间验证
- 二、一个实验引入虚拟地址
- 三、进程地址空间概念
- 四、用地址空间概念解决历史问题(写时拷贝)
- 五、感性理解虚拟地址空间
- 六、mm_struct(内存描述符)
- vm_area_struct
- 七、地址空间与以前知识的勾连
- 八、为什么存在虚拟地址空间
在开始本节之前,小编先声明一点,以我们目前的知识储备还无法掌握地址空间的所有详细细节,只能对整体有一个宏观认识,所以本节难免会有一些感觉很生硬的内容,但是小编尽量做到介绍时逻辑自洽,等后续我们一起把系统相关知识补齐后再对地址空间做深度了解。
一、C++内存空间验证
我们在之前学语言的时候已经了解过了C++内存空间(程序地址空间),这和我们接下来要讲的进程地址空间相关,我们先用一段代码来验证一下C++内存空间:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_unval;
int g_val = 100;
int main(int argc, char* argv[], char* env[])
{const char* str = "helloworld";printf("code addr: %p\n", main); //正文代码区printf("init global addr: %p\n", &g_val); //未初始化数据区printf("uninit global addr: %p\n", &g_unval); //初始化数据区int test = 10;char* heap_mem = (char*)malloc(10);char* heap_mem1 = (char*)malloc(10);char* heap_mem2 = (char*)malloc(10);char* heap_mem3 = (char*)malloc(10);// 堆区printf("heap addr: %p\n", heap_mem); printf("heap addr: %p\n", heap_mem1); printf("heap addr: %p\n", heap_mem2); printf("heap addr: %p\n", heap_mem3); //栈区printf("test addr: %p\n", &test); printf("stack addr: %p\n", &heap_mem); printf("stack addr: %p\n", &heap_mem1); printf("stack addr: %p\n", &heap_mem2); printf("stack addr: %p\n", &heap_mem3); return 0;
}
堆栈相对而生:
观察上面代码的运行结果,当我们连续申请多个堆空间或多个栈空间时,堆空间是向上生长的,栈空间是向下生长的。
(补充:堆是堆,栈是栈,堆栈特指栈)
当我们把变量test加上static修饰后,它就成为全局变量了,它的作用域不变,但是生命周期为全局:
这里小编要说明一下,上面的内存空间布局仅针对linux系统,在windows下可能会有不同的结果,这是正常的,因为不同的平台处理地址时的解决方案是不同的。
这里小编先输出一个结论,上面介绍的内存空间并不是物理内存,而是虚拟地址空间(进程地址空间),下文会详细解释。
二、一个实验引入虚拟地址
我们直接看代码:
#include <stdio.h>
#include <unistd.h>int gval = 100; //全局变量int main()
{pid_t id = fork();if(id == 0){//子进程while(1){printf("我是子进程,pid: %d, ppid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);sleep(1);}}else{//父进程while(1){printf("我是父进程,pid: %d, ppid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);sleep(1);}}return 0;
}
运行结果:
我们看到父子进程中全局变量gval的内容和地址一模一样,这可以证明子进程确实继承了父进程的代码和数据。
下面我们对代码稍加改动,让子进程修改全局变量gval:
#include <stdio.h>
#include <unistd.h>int gval = 100; //全局变量int main()
{pid_t id = fork();if(id == 0){//子进程while(1){printf("我是子进程,pid: %d, ppid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);++gval;sleep(1);}}else{//父进程while(1){printf("我是父进程,pid: %d, ppid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);sleep(1);}}return 0;
}
运行结果:
我们看到子进程一直在修改gval值,而父进程的却没变,单看这一个现象我们好理解,之前讲过进程之间是具有独立性的,即便是父子进程,代码是只能读的,这里的独立性主要体现在数据上,所以子进程修改gval不影响父进程,反之亦然。但是我们再看父子进程gval的地址,它们竟然俩的地址竟然是一样的,也就是说同一个地址父子进程读取它时读到的值是不一样的,类似于之前讲的fork返回值,同一个返回值变量返回给父子进程的值是不一样的。虽然我们目前不知道具体原理是什么,但这时候我们可以肯定的是,这个地址一定不是物理内存的地址。实践上它们是虚拟地址,这里小编再告诉大家一点,我们历史所学所见到的地址都是虚拟地址,我们用户是看不到物理地址的,物理地址由操作系统统⼀管理。这个知识和我们以前学的知识并没有直接关联,所以我们以前不知道这个知识点也能正常编程。
补充:程序的代码和数据必须存储到物理内存中,物理内存中的代码和数据是从硬盘加载来的。
三、进程地址空间概念
首先明确一点,所以之前说的程序地址空间是不准确的,准确的应该说成进程地址空间。虚拟地址空间是操作系统为所有进程提供的 “地址映射规则体系”,进程地址空间是单个进程在这套规则下拥有的 “专属虚拟地址范围” —— 前者是 “全局机制”,后者是“局部实例”,不能等同,但紧密依赖。
我们运行一个程序时系统会启动一个对应的进程,并把进程所对应的代码和数据加载到物理内存中,然后在物理内存中为进程创建一个与之关联的task_struct,这是我们之前学习的知识,但是今天小编要补充一点,除了加载代码和数据和创建PCB,系统还会每个进程创建一个独立的虚拟地址空间。 这个虚拟地址空间的地址范围是从全0到全F,虚拟地址空间中的每个地址都叫做虚拟地址,如果系统是32位,虚拟地址空间就是2^32次方,如果系统是64位,虚拟地址空间就是2^64次方,但是大部分教材和书籍都是以32为例讲解这一部分知识的,这里我们也以32位为例讲解。
我们知道物理内存也有它对应的物理地址,虚拟地址需要和物理地址进行映射(映射关系是动态的、按需建立的,并非一一映射),因为我们用户层创建的进程只能拿到虚拟地址,所以进程在访问内存数据的时候,要先进行虚拟地址到物理地址的映射,找到数据对应的物理内存,然后才可以访问对应的内存数据,所以除了虚拟地址空间外系统还会为进程在内存中建立一种数据结构——页表,每个进程有各自独立的页表,它可以用来让虚拟地址和物理地址之间进行映射。
四、用地址空间概念解决历史问题(写时拷贝)
我们先单看上图的左边部分,父进程有gval的虚拟地址0x601054,它可以拿着这个虚拟地址经过页表的映射访问到物理地址中的父进程gval的数据。当父进程fork一个子进程后,子进程会继承父进程的task_struct、进程地址空间和页表(这里的继承指的是拷贝,父进程会把task_struct、进程地址空间和页表都拷贝一份给子进程),所以子进程的进程地址空间中也有一个gval的虚拟地址,并且值和父进程的一样是0x601054,所以这是子进程的gval虚拟地址也能经过相同的页表映射逻辑映射并访问到父进程的gval的数据,这也就是为什么第一次实验子进程没修改gval值时父子进程打印gval的地址和内容都一样。但是当子进程尝试对父子共享数据gval做修改时,操作系统不会让子进程修改物理内存中原gval位置的数据,因为进程之间要遵循独立性,就算亲如父子。所以这时操作系统会自动在物理内存中重新为我们做下面三个操作:1、开辟一块和gval一摸一样大小的空间。2、然后将原空间的数据拷贝一份到新空间。3、修改虚拟地址到物理地址之间的映射关系,让虚拟地址0x601054不再映射到原空间,而是映射到刚开辟的新空间。
当父子进程之间其中一方要修改共享变量时(写操作),操作系统会自动在物理内存中开辟一块新空间并把被修改数据重新拷贝一份到新空间,让修改方修改新空间的数据,我们把这种技术叫做写时拷贝。该工作全部由操作系统自动完成,用户不知道。但是gval的虚拟地址都一样,因为不需要,但是目前还不能详细解释原因,关注小编后面的文章吧。
解释fork返回值:
之前遗留的有关fork返回值的问题也可以解释了,为什么fork函数有两个返回值之前已经解释过了,要站到fork函数内部来看。变量id接受fork返回值时本质就是要对变量id做写入操作,变量id同时接受两个返回值时底层就会发生写时拷贝,父子进程虽然拿到变量id的虚拟地址是一样的,但是实际已经映射到物理内存的不同地址空间了,所以父子进程可以分别修改变量id的数据,互不影响。
五、感性理解虚拟地址空间
我们目前只能对虚拟地址空间有一个感性的认识。小编来举个例子,我们把操作系统看作一个大富翁,它有10个亿资产,这10个亿资产就是4个GB的物理内存。他有很多个私生子,每个私生子互相并不知道彼此的存在,我们把私生子看作一个进程,操作系统给每个私生子说10个亿资产都是你的,我们知道这不现实,所以这就是大富翁给每个私生子画的饼,这些饼就是虚拟地址空间,大富翁给每个私生子画的饼就叫做进程地址空间。但私生子让大富翁把10个亿给他时,大富翁是不允许的,但是要个一两千块大富翁还是可以给的。所以进程不能一次性把4个GB物理内存全部占用了,但是可以申请占用几个kb,几个mb。
六、mm_struct(内存描述符)
- 当私生子很多时相对应的饼也很多,所以这些饼也就是进程地址空间也需要被管理起来。管理进程地址空间也要遵循先描述再组织,操作系统管理所有的进程地址空间也会为它们维护一份内核数据结构,在linux中这个内核数据结构名为mm_struct,它也是结构体,在进程的task_struct中会有一个指向mm_struct的指针。
- 这样我们把task_struct维护成链表,也就相当于变相把mm_struct维护成了链表,所以不用mm_struct单独维护一个数据结构。(但是实际上linux内核仍为mm_struct维护了独立的数据结构,也就是说mm_struct实际内嵌了list_head,以满足共享场景、管理效率和生命周期控制的需求。)
为小编截取了mm_struct的一部分内容:
我们可以看到其中有很多带有start、end的变量,它们就是用来对地址空间进行划分的。
虚拟地址空间中又会划分为两个区,以32位为例,物理内存总共4GB,虚拟继承空间也会映射4GB内存,0-3GB是用户空间,3-4GB是内核空间,程序员可以用地址直接访问用户空间,但要访问内核空间必须通过系统调用,例如访问内核数据结构task_struct中的pid,必须通过系统调用getpid获取。
利用虚拟地址空间解释为什么父子进程共享代码和数据:
我们前面介绍过当父进程创建子进程时操作系统会为子进程创建task_struct,子进程的task_struct是以父进程的task_struct为模板初始化的,然后修正个别子进程特有的属性,如pid,ppid等。所以父进程自然就会将task_struct、mm_struct、页表(内部是键值对指针,分别指向虚拟地址和物理地址)都拷贝一份给子进程,所以父子进程的进程地址空间一样,页表一样,自然映射关系也一样,所以子进程也指向父进程物理内存中的代码和数据,自然父子进程共享代码和数据。
(代码一般不可写,共享可保证,数据共享的前提的父子进程都没有发生写入操作,否则会发生写时拷贝,数据就不是完全共享)
理解进程的独立性: 我们之前的说法是进程 == 内核数据结构+进程的代码和数据,学了本节后我们修正概念:进程 ==(tast_struct+mm_struct+页表) +
进程的代码和数据。就算是父子进程,子进程的tast_struct、mm_struct、页表都是拷贝自父进程的,所以这一部分自然独立,进程的代码不可写和进程的独立性不发生关系,进程修改进程数据时会发生写时拷贝,写时拷贝的本质就是在做数据层面的分离。
页表与只读区与可读可写区:
小编先说明一点,进程地址空间中代码区是只读的,已初始化数据区和未初始化数据区是可读可写的,原因就是页表中有一列信息,它代表虚拟内存映射到物理内存的权限信息,已初始化数据区和未初始化数据区映射时这列信息对应位置会被写成rw,而代码区映射时对应位置会被写成r。
vm_area_struct
进程地址空间中的栈区、全局数据区、代码区的地址空间都是连续的,通常都是作为一个整体使用的,那么它们在一个进程中都只有一个空间起始地址,那么用mm_struct管理就完全足够了,可是堆空间不是这样的,堆区需动态响应多请求,必须拆分小块,无法整体使用,而每一个独立小块空间都有各自独立的起始地址和大小,所以要理解真正的进程地址空间,光一个mm_struct是不够的,因为它只能建立宏观概念,无法映射阐述像堆空间一样的小块内存。
所以linux内核会使⽤ vm_area_struct结构体来表⽰⼀个独⽴的虚拟内存区域(VMA),所有vm_area_struct结构体会由链表或红黑树组织起来,mm_struct里有一个指向进程中所有vm_area_struct结构体链表头的指针,每个vm_area_struct内部也有一个回指向它属于哪个mm_struct的指针。
七、地址空间与以前知识的勾连
我们先把前面的示例代码添加一些内容:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_unval;
int g_val = 100;
int main(int argc, char* argv[], char* env[])
{const char* str = "helloworld";printf("code addr: %p\n", main); // 正文代码区printf("init global addr: %p\n", &g_val); // 初始化数据区printf("uninit global addr: %p\n", &g_unval); // 未初始化数据区(BSS段)static int test = 10;char* heap_mem = (char*)malloc(10);char* heap_mem1 = (char*)malloc(10);char* heap_mem2 = (char*)malloc(10);char* heap_mem3 = (char*)malloc(10);// 堆区printf("heap addr: %p\n", heap_mem);printf("heap addr: %p\n", heap_mem1);printf("heap addr: %p\n", heap_mem2);printf("heap addr: %p\n", heap_mem3);// 栈区printf("test addr: %p\n", &test);printf("stack addr: %p\n", &heap_mem);printf("stack addr: %p\n", &heap_mem1);printf("stack addr: %p\n", &heap_mem2);printf("stack addr: %p\n", &heap_mem3);// 字符串常量printf("read only string addr: %p\n", str);// 命令行参数for (int i = 0; i < argc; i++){printf("argv[%d]: %p\n", i, argv[i]);}// 环境变量for (int i = 0; env[i]; i++){printf("env[%d]: %p\n", i, env[i]);}return 0;
}
运行结果:
从上面的运行结果中我们可以提取出两个重要结论:
1、字符串常量其实是和代码编译在一起的,因为代码是只读的,字符串常量自然也是只读属性。所以像下面一样修改字符串常量的第一个字符就会报错:
char* str = "hello world"; //str指向一个字符串常量
*str = 'C';
今天我们来深究一下其中的原因,当进程拿着字符串常量所在代码段的虚拟地址尝试修改字符串常量时,需要先经过页表将虚拟地址转为物理地址,转的过程中页表会对进程拿的地址进行权限检查,检查到该地址只有读操作,可我们用等号赋值相当于对地址内容进行写操作,权限不匹配,会导致从虚拟地址转为物理地址失败,然后操作系统就会把这个进程杀掉。总的来说就是因为字符常量区被页表映射的时候,有权限约束,不让写入操作进行转换。
我们再看一个示例:
//str是局部变量
const char* str = "hello world";
*str = 'C';
当给局部变量str加const修饰后,str存储在栈区默认是可写的,可以通过指针 “绕过编译检查” 间接修改其值。const的行为是约束编译器,让编译器编译代码时进行检查是否对变量进行了写操作,如果有,则会在编译时报错。而上面没加const进行写操作时是运行时报错,在运行时会查页表,页表发现后映射不过去。
补充:运行时报错全都是进程层面出问题。
2、我们可以看到命令行参数和环境变量的地址比栈区大,所以它们在栈区上面。所以我们可以知道命令行参数和环境变量是属于父进程地址空间的数据资源,和代码区、数据区一样,子进程会继承父进程的地址空间,所以这解释了为什么子进程也能看到命令行参数和环境变量。
八、为什么存在虚拟地址空间
这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?
- 安全问题和进程独立性:每个进程都可以访问任意的物理内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。如果一个进程可以随意访问物理内存,那它也可以访问其他进程的代码和数据,这样一个进程的操作会影响另一个进程,进程独立性就形同虚设了。
- 物理内存的无序性:我们的可执行程序中的代码和数据加载到物理内存时理论上可以加载到任意位置,所以如果没有虚拟内存的话,进程就需要把代码和数据分别加载再物理内存的什么位置记录起来。
- 如果直接使⽤物理内存,需要把进程看作整体在内存和磁盘之间转移,效率低下:当需要把进程的代码和数据加载进内存时,通常需要把进程整体全部加载进内存,如果出现物理内存不够⽤的时候,我们⼀般的办法是将不常⽤的进程拷⻉到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程⼀起拷⾛,这样,在内存和磁盘之间拷⻉时间太⻓,效率较低。
- 直接操作物理内存导致进程管理和内存管理强耦合。
那有了虚拟地址空间就可以解决上面的所有问题了吗?当然!这里涉及了一个计算机学科里的一个哲学思想:计算机中任何问题,都可以通过新增一层软件层来解决。
1、虚拟内存的存在变相保证物理内存的安全,维护进程独立性特性
因为有了虚拟内存,我们用户只能拿到虚拟地址,要访问物理地址必须将虚拟地址转换为物理地址,在转换时需要借助操作系统,这时操作系统就可以在这期间做安全审核,从而变相保证物理内存的安全。
2、虚拟内存的存在可以使进程看待内存由无序看成有序
如果有虚拟空间的话,所有代码和数据包括命令行参数、环境变量、堆区、栈区等等都是以虚拟内存空间的形式有序的呈现给进程的。
3、虚拟内存的存在可以实现进程内存的按需加载和局部交换 (前言补充:新建一个内存会先创建进程task_struct再加载代码和数据)
我们知道当进程新建出来时并不会被立即调度,而是待在过期队列里(这时task_struct已创建),所以在进程新建出来后并不会立即将代码和数据加载进内存,而是当用到对应代码和数据时才将对应的内容加载进内存,这就是所谓的惰性加载(惰性申请)。进程创建到调度之间的时间就可以将本该在创建时加载进内存的数据占据的空间给其他进程使用,提高了内存的利用率。写时拷贝本质也是一种惰性申请,因为创建子进程后为了满足进程独立性本应立即申请和父进程同样的空间存储子进程的数据,而写实拷贝技术可以让你用到对应数据也就是进行写操作时才开辟空间,也是用多少拿多少,可以最大化提升内存的利用率。
4、虚拟内存的存在可以使进程管理和内存管理解耦合
因为有地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配 和
进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。