一、进程地址空间分布
上图是C/C++常见的内存分布图,但这个其实并不代表真实的物理内存,而是虚拟内存。我们可以通过下面这个代码来进行验证。
二、虚拟进程地址
int g_val = 100;
void Test1(){
pid_t id = fork();
if(id == 0)
{
//child:
int cnt = 0;
while(1)
{
cout << "I'm child process "<< "pid:" << getpid() << " ppid:" << getppid();
cout << "g_val:" << g_val << " &g_val:" << &g_val << endl;
sleep(1);
++cnt;
if(cnt == 3) {
g_val = 200;
cout << "child change g_val 100->200 success!" << endl;
}
}
}
else{
//father:
while(1)
{
cout << "I'm father process "<< "pid:" << getpid() << " ppid:" << getppid();
cout << "g_val:" << g_val << " &g_val:" << &g_val << endl;
sleep(1);
}
}
}
运行结果如下:
结果我们发现,为什么父子进程g_val的地址相同但值不同?
结论:
- 这个地址不是物理内存地址,而是虚拟内存地址。
- 几乎所有的语言,如果他有“地址”的概念,就一定指的是虚拟地址(线性地址)!
- 物理地址,用户一概看不到,由OS统一管理。同时OS必须负责将虚拟地址转化成物理地址。
其实,操作系统为每个进程都分配了一个地址空间和对应的映射页表,而每个进程都认为自己拥有整个系统的内存,即虚拟地址范围从0x00000000到0xFFFFFFFF。而Linux内核中的地址空间本质上是一个名为mm_struct的结构,其中就包括了地址空间的区域划分及其他属性。
mm_struct源码:
进程的task_struct中记录了指向mm_struct结构体的指针,也就是说可以通过PCB找到进程地址空间,再通过页表将虚拟地址和真实物理地址进行映射,从而实现了进程对内存的操作。
为什么地址相同而值不同?
- 因为子进程是以父进程为模版创建的,所以父子进程的地址空间和页表映射关系也相同。因此父子进程g_val的虚拟地址相同,甚至在子进程进行写入操作之前,页表会将其虚拟内存映射到与父进程相同的物理内存。
- 但在子进程试图进行写入操作时会在物理内存中拷贝形成一块子进程所独有的新的内存,这样的拷贝称为写时拷贝。同时映射关系也会发生改变:子进程的虚拟内存将会映射到新的物理内存中。因此子进程中的g_val发生了改变但不影响父进程中的值。
- 父子进程之间的独立性由此而来。
二、为什么要有进程地址空间?
想要访问物理内存必须通过地址空间和映射页表,而他们又是操作系统创建并维护的。也就是说必须要在操作系统的监管之下访问物理内存。
1.通过虚拟地址和页表映射为真正的物理内存,防止了地址随意访问,保护物理内存与其他进程。
2.将进程管理和内存管理进行解耦合,对物理内存的分配和管理,完全是独立于进程之外的另一个模块。用户包括进程对内存管理完全0感知。
3.可以让进程以统一的视角看待自己的代码和数据。程序在没有加载到内存中的时候,就已经按照虚拟地址空间的方式创建了自己的虚拟地址空间,即已经存在了虚拟地址。
当程序加载到内存中时,就会建立好虚拟地址和物理内存的映射关系,通过进程自己的虚拟地址空间找到正文代码的地址,将该地址映射到物理内存,找到物理内存中的代码和数据,cpu来执行代码,而cpu执行的代码都是虚拟地址,如cpu要执行一个函数(call 0XAABBCC) 这个地址就是最开始程序还在磁盘中就形成的虚拟地址,又通过页表将该虚拟地址转化为物理内存,从而找到代码数据执行操作。
注意:
- OS一般不允许任何浪费或者不高效的行为
- 申请内存不等于立马使用(不一定),即使后面的代码立马使用内存,也有可能因为切换进程,而只申请内存来不及使用
- 在申请成功和使用的之前这段小小的时间窗口,如果存在多个进程这样做,就会导致大量内存处于显示状态,没有被正常使用,也不能被别人使用
- 进程的代码和数据不一定一直在内存当中:因为有了虚拟地址空间的存在,使进程可以边加载边执行。