在讲解之前,我们先看一张空间分布图:
首先,我们回顾一下C语言中应该弄懂的问题,我们来验证一下这张图中代码区,字符常量区,已初始化全局变量,未初始化全局变量,堆区以及栈区的地址空间排列顺序,我们依次打印这些地址:
#include <stdio.h> #include <stdlib.h>
int g_val_1;//未初始化全局变量
int g_val_2 = 100;//已初始化全局变量
int main()
{
printf("code addr: %p\n",main);
const char *str = "hello bit";
printf("read only string addr: %p\n",str);
printf("init global value addr: %p\n",&g_val_2);
printf("uninit global value addr: %p\n",&g_val_1);
char *mem = (char*)malloc(100);
printf("heap addr: %p\n", mem);
printf("stack addr: %p\n",&str);
free(mem);
return 0;
}
该代码运行结果为:
由此可见字符常量区,已初始化全局变量,未初始化全局变量,堆区以及栈区的地址空间排列顺序依次增长,接下来我们再证明栈区空间向下增长,堆区空间向上增长:
#include <stdio.h>
#include <stdlib.h>
int g_val_1;//未初始化全局变量
int g_val_2 = 100;//已初始化全局变量
int main()
{
printf("code addr: %p\n",main);
const char *str = "hello bit";
printf("read only string addr: %p\n",str);
printf("init global value addr: %p\n",&g_val_2);
printf("uninit global value addr: %p\n",&g_val_1);
//堆区开辟空间
char *mem = (char*)malloc(100);
char *mem1 = (char*)malloc(100);
char *mem2 = (char*)malloc(100);
printf("heap addr: %p\n", mem);
printf("heap addr: %p\n", mem1);
printf("heap addr: %p\n", mem2);
//栈区开辟空间
int a;
int b;
int c;
int d;
int f;
printf("stack addr: %p\n",&a);
printf("stack addr: %p\n",&b);
printf("stack addr: %p\n",&c);
printf("stack addr: %p\n",&d);
printf("stack addr: %p\n",&f);
//end
free(mem);
free(mem1);
free(mem2);
return 0;
}
代码结果:
由此可见,堆区地址逐渐增加,而栈区地址逐渐减少。
接下来我们将变量a改为静态变量,然后我们会发现代码结果如下(int a 改为 static int a = 1):
a的地址变得很小,可以看到其地址在全局变量地址的范围内,正是因此,静态变量会随着函数调用一直存在的原因。
接下来,我们将真正开始了解和学习进程地址空间。
我们知道,通过fork()函数可以创建子进程,子进程和父进程会共同享用这一份代码方案,但数据段是不共享的,接下来我将用一个函数创建子进程,但子进程会更改全局变量的值,看看修改前后父子进程对这个全局变量的读取有何变化。
#include <stdio.h>
#include <unistd.h>
int g_val = 100;
int main()
{
pid_t id=fork();
if(id == 0)
{
int cnt = 2;
//子进程
while(1)
{
printf("I am child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n",getpid(), getppid(), g_val, &g_val);
sleep(1);
//两秒后修改cnt的值
if(cnt) --cnt;
else{
g_val=200;
printf("g_val changed\n");
cnt--;
}
}
}
else{
//父进程
while(1)
{
printf("I am parent, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n",getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
结果如下:
这里我们会发现,子进程和父进程所指向的地址是“相同”的,但是子进程把全局变量修改后,父进程的值不变,这是为什么呢?
首先根据这种现象我们可以推出,这个地址一定不是物理地址,同一个物理地址是不可能有两个不同的值的,这个地址是线性地址or虚拟地址,而我们平时在C/C++等语言里面的地址也不是物理地址。
在进程中,每一个进程都有一份页表,页表是一种key-value模型,页表中key存储的是虚拟地址,而value则是物理地址,也就是说进程读取数据是通过虚拟地址在页表中查找映射的物理地址,而在父进程中创建子进程的时候,最初子进程和父进程会享用同一份内存数据结构以及方案,它们共享的函数方法是不会改变的,因为函数是无法改变的,只有数据是可以改变的,但这并不意味着父子进程需要在子进程创建的时候就单独为子进程再深拷贝一份数据,而是采用写时拷贝的方法,也就是说刚开始父子进程的页表是一样的,但在子进程需要改变g_val时,就需要单独为子进程拷贝一份g_val,也就是单独为子进程开辟一份物理空间,我们只需要在页表中将虚拟地址所映射的物理地址修改成写时拷贝的物理空间的地址就可以了,正是因此,子进程在修改数据后父子进程地址一样数据却不一样,因为它们的虚拟地址是一样的,修改的是虚拟地址所映射的物理地址。
那么,什么是进程地址空间呢?所谓的进程地址空间,实际上就是一个描述进程可视范围的大小,地址空间内存在各种区域划分,一部分属于代码区,一部分属于只读数据区等等,这些数据的空间划分通过规定它们地址的start和end就可以了,这样做有三个好处:
1.让进程以统一的视角看待内存。
2.增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转化的过程,在这个转化的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。
3.因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
针对第三点,因为一个页表存储的信息有虚拟地址,所映射的物理地址,访问权限,以及是否在内存中存储,这样的话我们就可以通过判断进程是否在内存中从而对是否需要运行该进程和如何运行该进程做出判断,因此进程管理模块和内存管理模块就可以分开进行管理,不会因为内存管理出现问题而导致进程管理受到限制,这种现象就叫做解耦合。