虽然本篇文章对操作系统的理解不怎么深入,或者说仅仅是一些皮毛知识(也可能皮毛也算不上),但还是需要读者有一些Linux的基础理解,如何确定是否有这些基础呢?可以参考我的这一篇博客:Linux —— 进程概念超详解!
1.“奇怪”的事
上面给的博客链接提到了环境变量这个概念,环境变量是具有全局属性的一个变量,我们似乎可以推导出子进程可以继承父进程的全局变量(不管这个结论是否正确,我们先暂时这么认为)。那么继承又是通过传参而得来的,传参的方式是传值,意味着子进程拿到的数据是父进程的一份拷贝。在我们C/C++编程中,当两个指针指向同一块空间时,通过任何一个指针对空间内容数据进行了修改,另一个指针看到的空间内容数据也是修改之后的数据。
那么在父子进程上便会发生一件“奇怪”的事,我们可以使用下面的代码来看一看这件事:
1 #include <stdio.h>
2 #include <sys/types.h>
3 #include <unistd.h>
4
5 int num = 100;
6 int main()
7 {
8 pid_t id = fork();
9 if(id < 0)
10 {
11 printf("创建进程失败!\n");
12 return 1;
13 }
14 else if(id == 0)
15 {
16 int cnt=0;
17 while(1)
18 {
19 printf("我是子进程,num=%d,&num=%p\n",num,&num);
20 sleep(1);
21 cnt++;
22 if(cnt == 5)
23 {
24 num = 500;
25 printf("子进程把num修改为%d啦!\n",num);
26 sleep(1);
27 }
28 }
29 }
30 else
31 {
32 while(1)
33 {
34 printf("我是父进程,num=%d,&num=%p\n",num,&num);
35 sleep(2);
36 }
37 }
38 return 0;
39 }
编译运行便会看到这样的结果:
在C/C++编程中,我们认为地址是内存单元的唯一标识符, 而观察上图,父子进程的num变量都在同一块空间(因为使用了同一个地址),也就意味着子进程对这个变量值进行了修改,理论上来说父进程的num值也应该随之发生变化,但事实上并非如此。这是什么原因?
事实上对于每一个进程来说,它们所使用的地址都是虚拟地址,这是Linux内核允许使用这种地址的。接下来就该介绍进程地址空间了。
2.进程地址空间
进程地址空间由进程可寻址的虚拟内存组成,每个进程都有一个32位或64位的连续地址空间(空间大小取决于体系结构),例如32位地址空间的地址就可以从0扩展到4294967295。如果这个区间内的每个地址都能表示内存当中一个字节,那么32位的地址空间就有4GB。但是想想不对啊!一个进程的地址空间是4GB,100个进程就是400GB,这怎么可能嘛!确实,这是不可能事,所以进程看到的所谓4GB的内存空间仅仅是操作系统给进程画的饼。那么这些数字在进程的角度来说确实是一个个地址,但是在用户的角度来说,这些数字就是数字,它并没有实际意义(个人拙见,希望大佬能在评论区指正)。这些数字,操作系统把它称为虚拟地址,而这些虚拟地址的集合,又是组成虚拟内存的一部分。
如果操作系统把这些数字(整型)一个个存下来的话,也是不可能的事。所以操作系统有了地址区间这一说法,例如操作系统把地址为2000000~5000000的这个地址区间划分为一个内存区域,而这个内存区域对应一个栈或者是堆或者是静态区,那么操作系统就很好管理它了,操作系统只需要每个内存区域的起始和结束位置,就能找到这块区域的所有地址。
有的读者读到这里不免会产生疑惑,单纯的一个数字区间就能代表一个栈、一个堆了?确实是无法代表,但是操作系统聪明啊!操作系统可以拿这些数字,通过一种特殊的手段,映射到物理内存呀!在这里似乎就能理解了吧?例如我们使用C语言定一个变量:int a = 10;,我们 &a 取到的地址便是一个虚拟地址,当我们想要对这块 int 类型的空间做一些修改的时候,操作系统通过一些手段拿着虚拟地址找到物理地址,有了物理地址不就可以对实实在在的空间进行修改了嘛?这不就顺理成章了嘛?也就是进程使用的地址是操纵系统给画的饼,这些地址在进程看来是指向某一块空间的,但实际上不是,当进程要用这个地址指向的空间时候,操作系统就拿着这个虚拟地址找物理地址,这样就可以骗过进程了。
3.内存描述符结构体
与管理进程如出一辙,管理进程地址空间也需要一个内核结构体,这个结构体称为内存描述符结构体,该结构体包含了和进程地址空间有关的全部信息。内存描述由 mm_struct 结构体表示,根据上面的分析结果,我们看一看内核是如何描述进程地址空间的:
但是呢,mm_struct 结构体只记录了内存区域的首地址、尾地址而已,并没有详细描述这一块内存区域。那么这个工作是交给谁的呢?交给另一个内核结构体,叫做 vm_area_struct 结构体。
4.内存描述符放在哪
我们可以自己推理一下, 进程需要的内存是从进程地址空间来的,进程地址空间是用 mm_struct 结构体表示的,那么进程是不是只需要找到 mm_struct 结构体就能找到想要的虚拟地址了?
所以,在进程的进程描述符(pcb)中了存放了内存描述符(mm_struct)的结构体指针。进程只需要有这个指针就能找到进程地址空间。
5.通过页表查询物理内存
刚才提到了操作系统拿着虚拟地址通过一些特殊手段找到物理地址。这个特殊手段就是页表,但实际上并不全是页表。我们分析一下哈,进程使用的地址是虚拟地址吧?那么cpu能用虚拟地址吗?显然不可以,所以操作系统要想办法通过虚拟地址找到物理地址,这时候MMU(内存单元管理器)就站出来了,它能够完成地址的转换工作,但是就它一个还不太行,它需要一个工具叫做页表,MMU通过页表查询到物理内存,然后MMU就可以完成地址转换的工作了。完成转换之后cpu就拿到物理地址了,就可正常的处理进程的指令。
还记得开篇提到的“奇怪”的事吗?父子进程的拿到的 num 变量的地址都是一样的,那是因为它们拿到的地址都是虚拟地址,子进程修改了值后,父进程没有受到影响,可以说明子进程的页表和父进程的页表映射不一样。 那么为什么子进程的虚拟地址和父进程的虚拟地址一样呢?那是因为操作系统是一个非常懒的家伙!它懒得再为子进程单独创造一个进程地址空间,而是让它使用和父进程地址空间一样的拷贝,甚至页表也懒得修改。那么这里就会有读者觉得不对了,地址空间使用的是相同的就算了,页表还一样,那找到的物理地址不也一样了吗?这就是我们后面要说的写时拷贝技术,不要着急。
6.进程地址空间存在的目的
进程地址空间的存在并不是因为那些大佬想把操作系统搞得复杂,而是因为必须要这么这么设计。如果进程直接访问物理内存,那就会非常的不安全,所以进程在访问内存(虚拟内存)的时候,会通过页表映射物理内存,这个过程页表就会控制进程的行为,把一切不安全的行为在访问物理内存之前杀掉。
这么设计也吻合了当时我们说的进程的独立性,即使父子进程使用的地址空间是一样的,但是只要页表的映射关系不同,它就能映射到不同的物理内存,这样就保证了进程的独立性。
这么设计最大的好处就是统一。让所有的进程通过统一的视角看待内存,这样就方便操作系统的管理了。
总结一下,进程地址空间存在的目的有三个:保证安全、确保独立性、统一视角方便管理。
7.“奇怪”变为“不奇怪”
我们现在就可以解释开篇提到的“奇怪”的事了。父进程fork一个子进程,这个子进程使用的地址空间、页表都是直接拷贝父进程的,所以父子进程 &num 得到的地址都是一样的。当子进程没有对 num 的值修改时,因为操作系统很懒,没有改变子进程的页表映射关系,所以父子进程在共用一块物理内存,所以父子进程看到的 num 值都是一样的。当子进程对 num 的值进行修改时,就发生了写入操作,操作系统一看子进程要修改 num 值了,如果不开辟一个新的、独立的物理内存供子进程使用的话,子进程的修改一定会影响父进程,所以这时候操作系统马上更改子进程页表的映射关系,页表就通过新的映射关系就重新在物理内存上发现了一块空间,然后操作系统把共享的物理空间拷贝一份,让子进程在自己拥有的物理内存上做修改。这就是写时拷贝技术。
也就是说,当父子进程共享一块物理内存时,如果这时候都只执行只读的操作,那么操作系统是不会另外开辟物理内存的。但是当其中某一个要进行写入时,就会通过之前内核源码提到的主引用计数器,再判断页表的映射关系,来确当前进程是否需要执行写时拷贝——如果主引用计数器的值为3,就说明有三个进程正在使用同一块进程地址空间(都是拷贝的),然后其中某一个进程要进行写入操作时,操作系统就会观察这三个进程的页表,如果此进程的页表与其他进程的页表不同,就不会发生写时拷贝,反之则发生写时拷贝(个人臆想,求大佬在评论区骂醒我)。
8.fork出来的子进程PID在哪
在我刚接触这个知识点的时候我也是一脸懵逼。懵逼在哪呢?父进程fork出来一个子进程,此时父子进程共享同一块物理空间,那子进程的pcb放在哪?一开始我天真的以为它俩的pcb都一样。后面三天不吃饭仔细想了想,写时拷贝技术并没有提到进程的pcb,而进程的定义是程序加载到内存中,然后生成一个pcb被操作系统组织起来。我仔细一想,fork出来的子进程没有被加载到内存吗?加载了!它跟父进程在一块呢!因为加载到了内存,所以生成了pcb,然后就被操作系统组织起来了,因为生成了pcb,所以操作系统赋予了子进程一个PID,然后把PID放在了pcb中。
9.结尾
本篇博客的篇幅不大,所以有些定义、描述可能比较敷衍,但是我确实是认认真真的写了,水平有限嘛!所以呢,这篇博客里面的某些定义啊、理解啊、概念啊可能跟标准定义差了十万八千里,甚至牛头不对马嘴。在这里诚恳的向各位大佬学习。当然了如果是跟我一样的初学者看到了这篇文章,对你有帮助的话,就支持支持一下吧。