问题导入
学过C/C++的人都知道,在程序里面一个地址空间就只能存放一个变量的值,这是正确的。那么我们来看看下面这段代码:
#include<stdio.h>
#include<assert.h>
#include<unistd.h>
int g_value = 0;
int main()
{
pid_t id = fork();
assert(id >= 0);
if(id == 0)
{
// 子进程
while(1)
{
printf("子进程,pid=%d,ppid=%d,g_value=%d,地址为:%p\n",getpid(), getppid(), g_value, &g_value);
++g_value;
sleep(1);
}
}
else
{
// 父进程
while(1)
{
printf("父进程,pid=%d,ppid=%d,g_value=%d,地址为:%p\n",getpid(), getppid(), g_value, &g_value);
sleep(1);
}
}
return 0;
}
由程序得知,g_value
是全局变量,地址都相同,但是为什么他们的值却不一样呢?
带着这个问题,我们来学习一下程序地址空间。
一,虚拟地址
-
我们在各种语言里面使用的指针和取得的地址,其实都是虚拟地址,而真正的地址是物理地址,是通过虚拟地址根据某种关系来找到的,是操作系统来完成的事情,我们用户是不能直接获取的。
-
虚拟地址是连续的
我们通过几张图片来理解:
在32位系统中,地址的大小是0~ 232-1字节;64位则是0~264-1字节。32位系统与64位系统在这里举例是没有区别的,为了方便画图,我们以32位系统为例。
图上就是地址空间的大致分部(详细可以见此),值得注意的是,这个是虚拟地址,地址是连续的,因此操作系统会定义一个数据结构(如下)来管理这些虚拟地址的分部。
struct mm_struct
{
//代码段
long code_start;
long code_end;
//……
// 堆区
long brk_start;
long brk_end;
// 栈区
long stack_start;
long stack_end;
}
二,页表
前面说了,用户是无法直接接触到物理地址的,平时在程序里取到的都是虚拟地址,数据都是存在于物理地址的,那么虚拟地址与物理地址又有什么关系呢?
通过下图,我们来理解:
虚拟地址通过页表和MMU转换成物理地址
这里的转换过程就不展开来讲了,详细过程可以自行百度。
三,解决问题
通过前面引入了这么多概念的我们现在可以对前面的问题进行解答了。
1. 父子进程的关系
fork()函数调用的时候,会生成子进程,每个进程都会有自己的页表,而子进程的绝大部分数据都是复制父进程的,所以会出现它的全局变量g_value的地址与父进程的一样,变量g_value的虚拟地址会在页表中转换成相应的映射关系。
2. 写时拷贝(写时复制)
全局变量g_value在被子进程修改时,会在物理内存申请一块地址来复制全局变量的值,这就是写时拷贝。
这里我们来整理一下,子进程在修改全局变量的值时,系统会在内部再开辟一个物理内存来存储修改后的值,修改完后,子进程页表的映射关系发生改变,但是虚拟地址仍然不变。这样就发生了一个地址存储不同变量的现象,事实上这里的地址不是物理地址,而是虚拟地址。
四,虚拟地址的重要性
-
防止地址随意访问,保护物理内存和其他进程
-
将进程管理和内存管理进行解耦合
malloc的用法原理
向系统申请一块内存,但系统不会马上给你分配内存,他是等到这块内存真正被用到时,才会分配好地址。具体体现在页表的变化上面,malloc申请内存时,页表的一边会给你分配好虚拟地址,另一边先不做变化,等到真正使用申请到的内存时,系统才会完善好页表。