个人主页:Lei宝啊
愿所有美好如期而遇
我们先看一张图
你可能只是见过这个图,或者听过,但是验证过吗?或者说真正了解过这个图吗?我们通过代码来验证一下。(代码贴在后面)
#include <stdlib.h> 3 4 int g_val; 5 int g_lav = 1; 6 7 int main(int argc,char* argv[],char* env[]) 8 { 9 10 printf("main: %p\n",main); 11 printf("g_val:%p\n",&g_val); 12 printf("g_lav:%p\n",&g_lav); 13 printf("--------------------\n"); 14 int *heap1 = (int*)malloc(4); 15 int *heap2 = (int*)malloc(4); 16 int *heap3 = (int*)malloc(4); 17 int *heap4 = (int*)malloc(4); 18 printf("heap1: %p\n",heap1); 19 printf("heap2: %p\n",heap2); 20 printf("heap3: %p\n",heap3); 21 printf("heap4: %p\n",heap4); 22 printf("--------------------\n"); 23 printf("stack1: %p\n",&heap1); 24 printf("stack2: %p\n",&heap2); 25 printf("stack3: %p\n",&heap3); 26 printf("stack4: %p\n",&heap4); 27 printf("--------------------\n"); 28 printf("&argv[0]:%p\n",argv); 29 printf("&argv[1]:%p\n",argv+1); 30 printf("&env[0]:%p\n",env); 31 printf("&env[1]:%p\n",env+1); 32 printf("------\n"); 33 printf("argv[0]:%p\n",argv[0]); 34 printf("argv[1]:%p\n",argv[1]); 35 printf("env[0]:%p\n",env[0]); 36 printf("env[1]:%p\n",env[1]); 37 38 39 return 0; 40 }
通过我们的验证,我们发现确实如同我们的图上一般。
接下来我们再来看一段代码。
关于下图我们觉得很正常,但是我们再将代码稍作修改
我们让子进程对全局变量g_val进行修改,子进程先跑完,所以子进程先修改,之后父进程再读取。
我们发现父子进程中输出的全局变量地址相同,但是值不一样,我们可以得出以下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
- 地址值相同,但是,我们可以肯定的是,该地址绝对不是物理地址,因为他们的值不一样。
- 在Linux下,这种地址叫做虚拟地址或者说是线性地址。
- 我们用C/C++写的代码所看到的地址,都是虚拟地址,物理地址,用户统一看不到,由操作系统统一管理。
也就是说操作系统必须将虚拟地址转换为物理地址。
我们说这么多东西想说明什么呢?就是想说明其实我们一开始看到的那张图就是进程地址空间,我们先看图说结论,再分开详细讲解:
我们的进程地址空间也是task_struct里的一个结构体,初始化数据会在进程地址空间申请空间,并返回虚拟地址,当我们向这个变量做写入时,如果他的虚拟地址及操作合法,那么操作系统就会先停止写入,在物理地址空间申请内存,然后在页表中构建虚拟地址到物理地址的映射,之后再做写入。
那这和我们的问题又有什么关联?我们接着看图和解释:
子进程会继承拷贝父进程task_struct里的大部分内容,其中就包括父进程的进程地址空间,同时子进程也会有一个页表,同样会将父进程的页表内容拷贝一份,所以我们才说他们的数据是共享的,但是我们说进程是相互独立的,如果父子进程要对变量内容做修改,一定不能影响到其他进程,所以就会有写时拷贝,这里当子进程做写入,操作系统会在物理地址空间再申请一块空间,将原来空间的数据做拷贝,然后在页表修改映射关系,物理地址就和父进程不一样了,但是虚拟地址不做修改。
所以我们也就能解释为什么fork返回两个值,却使用一个变量接收,因为变量名在编译后其实就变成了地址,而我们能够看到的地址全部都是虚拟地址,所以其实fork返回的两个值他们最终的物理地址是不同的。
那么问题来了,什么是进程地址空间?
我们说每一个进程都有自己的进程地址空间,在32位机器下,进程地址空间可以被操作系统分配到的空间大小范围是[0 , 4G],我们之前也提到过,他其实是task_struct里的一个数据结构,我们看图:
也就是说,进程地址空间划分了区域的范围,并没有保存数据的能力,申请的空间如果在合法的区域范围内,操作系统就会将进程地址空间上的虚拟地址转化到物理内存中,于是我们也就有了页表。
第二个问题,为什么要有进程地址空间+页表?
根据上图,我们其实可以知道,虚拟地址可以映射到物理内存上,那么数据和代码从磁盘上加载进内存时虚拟地址是可以映射他们的物理地址的,也就是说,申请的物理地址其实不需要连续,我们的虚拟地址是可以保证连续的,所以,也就将物理地址从无序变有序,让进程以统一的视角去看待内存。
再一个,进程管理和内存管理的关系其实就不是很紧密了,当进程需要虚拟地址映射到物理地址时,操作系统去内存中申请就好,进程也不会管他是否连续,进程只需要知道他申请好内存了,然后在页表中映射,接着访问内存,进程管理只需要将进程管理好,内存管理只需要去申请内存,实现了两者间很好的解耦合。
还有一个原因就是内存的安全问题,当进程的操作不合法,例如非法访问或者申请的虚拟地址不合法,那么在页表那里操作系统就会拦截,严重点的进程直接就被操作系统kill掉,而我们的内存不受任何影响,只有合法的操作才会影响到内存,这就保证了内存的安全问题,所以进程地址空间加上页表是保护内存安全的重要手段。
最后,我们解释几个问题
前面我们有提过缺页中断,我们再来理一遍思路,当我们的进程有一个新的变量,那么就会去进程虚拟空间上申请内存,但是不是说你申请,操作系统立刻就去内存上申请,操作系统要保证效率和资源使用率,他并不清楚用户定义的变量是否要使用,或者什么时候使用,如果你定义我就开空间,那么效率显然不高,所以当我们真正要给这个变量写入数据时,而且操作合法,由于没有申请内存,那么操作系统此时会中断写入操作,先去内存中申请物理空间,然后在页表上构建映射关系,然后恢复写入操作,我们将上述流程称为缺页中断。
所以我们的new和malloc也是同理,不是说你申请空间,操作系统立刻就会去内存上申请,而是在使用时才去申请,这样就保证了内存的使用率,不会让内存申请了而不去使用。
我们最后要提及的就是写时拷贝,页表其实有很多选项,只不过我们只列出来两个而已,这里我们再说一个,就是权限,只读,只写,还是读写,或者可执行,父进程在创建子进程后,会将数据段的数据权限全部改为只读,而代码段我们本身就是只读,不解释。
那么为什么要改为只读呢?这样在写入数据时,因为没有写权限,是不是会报错?其实这是故意设计成这样的,就是因为通过这样的冲突让操作系统知道出现这个问题时就需要进行写时拷贝了,然后操作系统进行解决。那么为什么申请了空间还要拷贝?我既然要有一块新的空间,旧的数据我为什么还要?如果说我要对旧的数据进行自增或者说位运算呢?没有旧的数据恐怕不行,所以要在开辟新空间后还要拷贝旧的数据。
至此,我们对进程地址空间的讲解就结束了。