💘作者:泠沫
💘博客主页:泠沫的博客
💘专栏:Linux系统编程,文件认识与理解,Linux进程学习…
💘觉得博主写的不错的话,希望大家三连(✌关注,✌点赞,✌评论),多多支持一下!!
🏠 由进程地址空间引发的怪异现象
~ 废话不多说,直接上代码:
#include<iostream>
#include<cstdio>
#include<unistd.h>
using namespace std;
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 0;
while(1)
{
printf("我是子进程,pid:%d, ppid:%d, g_val:%d, &g_val:%p\n",getpid(), getppid(), g_val, &g_val);
sleep(1);
cnt++;
if(cnt == 10)
{
g_val = 200;
}
}
}
while(1)
{
printf("我是父进程,pid:%d, ppid:%d, g_val:%d, &g_val:%p\n",getpid(), getppid(), g_val, &g_val);
sleep(2);
}
return 0;
}
运行结果:
我们惊奇的发现,我们定义的全局变量在父子进程中的值一开始是一样的,但是经过一段时间后,子进程对该变量进行了修改,所以该后续子进程打印的g_val是200。到了这里,想必大家内心都会疑惑。为什么一个变量可以有两个不同的值?甚至为什么这两个值的地址还是一样的?
上述问题在文章末尾会给各位读者进行解答。
🏠 进程地址空间介绍
其实,早在我们学习语言的时候,我们就已经听说过了一些名词,例如:常量区,堆区,栈区…,有的读者可能也看过下面这种空间布局图:
可是,我们对此并不真正理解。接下来,我讲给大家从操作系统底层来给大家讲解上面的空间布局图。
首先,我把这个空间布局图称之为进程地址空间,也叫虚拟地址空间。
还记得之前我们提到过,一个程序被加载到内存中变成一个进程的时候,操作系统需要给每一个进程创建一个内核数据结构,也就是struct task_struct{}。在这个进程控制块当中,有一个结构体指针struct mm_struct *mm,该指针指向的就是进程地址空间的起始地址。而上面的进程地址空间,在操作系统中本质上就是一个结构体 struct mm_struct{}。而所谓的地址其实本质上就是一个一个数字,各种区域划分其实就是一个又一个的数字区间。通过这种方式,操作系统就虚拟的构建出了一个进程地址空间,给变量分配地址。
而每一个进程的代码的地址是这个虚拟地址空间来进行编码的,cpu读取到的也是虚拟地址,然后通过页表进行虚拟地址和内存物理地址的映射找到内存中的代码,然后执行程序。所以,我们平时写代码,打印出来的地址都是虚拟地址,而不是物理地址!页表就是专门保存虚拟地址和物理地址的映射关系。
🏠 进程地址空间的好处
-
提供了一种隔离不同进程之间内存使用的方式,确保每个进程都拥有独立的虚拟内存空间,从而避免了它们之间相互干扰和相互影响。
-
使得操作系统能够更加有效地管理系统资源,同时也增强了系统的安全性,防止恶意程序或错误的进程损坏其他进程的内存数据。
-
为操作系统提供了一种管理虚拟内存的方式,在系统出现内存不足的情况下,可以通过对进程的地址空间进行调整来优化内存的使用。
🏠 进程的写时拷贝
每一个进程都有自己进程控制块task_struct,里面保存了一个指向虚拟地址空间表的指针,以便于CPU能读取到指令的虚拟地址,然后通过页表进行映射到内存,获取到指令的物理地址,从而正确执行指令。
子进程被创建出来,几乎是以父进程为模板进行创建的。所以,子进程的进程控制块,地址空间和父进程基本一致。所有的变量都是同一个虚拟地址。接下来解释一下文章开头我们遇到的疑惑现象。
首先,由于子进程几乎是拷贝父进程的进程控制块,所以在父子进程中看到的g_val的变量名和地址都是一样的(虚拟地址),但是由于操作系统为了保证进程的独立性,一旦父进程或者子进程想要对数据进行修改的时候,操作系统会在内存中重新开辟一块空间,用来存放子进程要修改的变量的值,注意这里是在物理内存中开辟。所以,最后就会出现,不论是父进程还是子进程的虚拟地址空间中的g_val变量名和地址都一样,但是子进程中g_val在页表中的映射关系发生了该变,所以访问到物理内存中的数据就不一样。