目录
一.程序地址空间
首先我们先通过一张图回顾一下c/c++中的程序地址空间:
下面简单的介绍一个这几个区域:
1.堆区:
堆数据区即heap区,在C程序中,该区域的分配和回收由
malloc
和free
进行。随着区域分配的进行,区域不断从低地址向高地址方向延伸。2.栈区:
stack区,程序运行时,函数调用产生的堆栈存放在该区域。该区域的开始地址是固定的(紧挨着内核内存区),随着调用函数时堆栈的产生,该区域不断从高地址向低地址方向延伸.
3.代码与数据区:
可执行程序文件的内容加载到该区域,该区域又分成2部分,低地址部分包含程序的代码及只读数据,为只读部分;另一区域存放可执行文件的可读写数据,为可读写区。
4.内核存储区:
内核虚拟内存区,在虚拟地址空间的最高地址处的一块地址空间内。该区域用户进程不能访问。
题目通过一段简单的代表对上图的地址空间分部进行验证:
#include<stdio.h> 2 #include<iostream> 3 using namespace std; 4 int g_unval; 5 int g_val=0; 6 int main() 7 { 8 printf("code addr:%p\n",main);//打印代码区的地址 9 const char*str="ddddddd"; 10 printf("string rdonly addr %p\n",str);//打印字符常量区的地址 11 //打印未初始化全局数据区的地址 12 printf("uninit addr: %p\n",&g_unval); 13 //打印已初始化全局数据区 14 printf("init addr: %p\n",&g_val); 15 //打印堆区的地址 16 int *p1=new int (1); 17 int *p2=new int(2); 18 int *p3=new int(3); 19 printf("heap addr: %p \n",p1); 20 printf("heap addr: %p \n",p2); 21 printf("heap addr: %p \n",p3); 22 23 24 //打印栈区的地址 25 int a1=10; 26 int a2=20; 27 int a3=30; 28 printf("stack addr : %p\n",&a1); 29 printf("stack addr : %p\n",&a2); 30 printf("stack addr : %p\n",&a3); 31 32 return 0; 33 } ~
允许结果:
我么发现分部确实是这样的。
下面我们来做一个实验:
#include<unistd.h> 2 #include<stdio.h> 3 #include<sys/types.h> 4 int g_val=10; 5 int main() 6 { 7 pid_t id=fork(); 8 if(id<0) 9 { 10 printf("fork fail"); 11 } 12 else if(id==0) 13 { 14 while(1) 15 { 16 printf(" I am a child pid:%d g_val:%d &g_val:%p\n",getpid(),g_val,&g_val); 17 sleep(2); 18 } 19 20 } 21 else 22 { 23 while(1) 24 { 25 printf("I am a parent pid:%d g_val :%d &g_val:%p\n",getpid(),g_val,&g_val); 26 sleep(2); 27 } 28 29 } 30 31 return 0; 32 } 33
我们将这个代码跑起来:
子进程和父进程的打印出来的g_val和&g_val是一样的没有问题这和我们之前说的父子进程在代码和数据是共享的(不发生修改的前提下).没有问题,下面我们再来看一段代码:
#include<unistd.h> 2 #include<stdio.h> 3 #include<sys/types.h> 4 int g_val=100; 5 int main() 6 { 7 pid_t id=fork(); 8 if(id<0) 9 { 10 printf("fork fail"); 11 } 12 else if(id==0) 13 { 14 int cnt=0; 15 while(1) 16 { 17 cnt++; 18 if(cnt==3) 19 { 20 g_val=10; 21 printf("子进程修改g_val\n"); 22 } 23 printf(" I am a child pid:%d g_val:%d &g_val:%p\n",getpid(),g_val,&g_val); 24 sleep(2); 25 } 26 27 } 28 else 29 { 30 while(1) 31 { 32 printf("I am a parent pid:%d g_val :%d &g_val:%p\n",getpid(),g_val,&g_val); 33 sleep(2); 34 } 35 36 } 37 38 return 0; 39 } 40
我们将这个程序跑起来:
我们发现子进程把g_val的值修改之后由于进程之间是独立的发生了写时拷贝,子进程和父进程打印出来的值不一样我们可以理解但是我们惊奇的发现他们的地址是一样的。如果这里是物理地址,怎么可能从同一块空间里面取里面的内容还不相同。所以我们可以得出:
这绝对不是物理地址。在Linux地址下,这种地址叫做虚拟地址,我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。
二.进程地址空间
所以我们之前说‘程序的地址空间’是不准确的,准确的应该说成进程地址空间。进程地址空间是OS创建的一个结构题名字叫做mm_struct对虚拟地址空间进行划分其虚拟地址从(0x 00 00 00 00到 oxff ff ff ff)同样的别进程控制块PCB管理起来,每个进程对有自己的进程地址空间,也就是每个进程都认为自己是独占资源认为自己拥有4GB的空间(32位平台下)。所以说进程地址空间其实是一个虚拟地址空间。虚拟地址空间也可以认为是地址空间上进行区域划分时,对应的线性位置虚拟地址!。
虚拟地址空间每个进程都有虚拟地址空间,OS会将虚拟地址通过某种映射关系映射到物理地址上,从而对应到真正的物理地址上。
而这个做转换工作的就是传说之中的页表。下面我们就可以解释上面那种现象了,子进程的创建以父进程为模板,所以父子进程都有虚拟地址空间,且内容基本是一样的(部分数据不一样),且页表的映射关系子进程和父进程是一样的当子进程对数据进行修改时,OS会将子进程中断重新开辟一块空间将数据拷贝过来,然后让子进程修改新开辟的空间。虽然物理地址发生了改变但是虚拟地址没有发生变化,这是改变了子进程中虚拟地址和物理地址的映射关系。所以我们之前看到的地址相同本质时虚拟地址相同。
现在我们也就能够明白父子进程是如何做到独立的了。父子进程代码和数据共享,但是只要有一方尝试对其进行写入,那么也就会方式写时拷贝,修改页表中的映射到物理内存的关系,从而使得父子进程有属于自己的数据,达到独立
为什么要这么设计呢?
理由一:
有了虚拟地址空间,让进程访问物理内存不能直接访问物理内存,添加了一个中间层(页表),更利于管理内存的操作。这样一来每个进程就必须要通过虚拟地址空间和页表来访问对应的物理内存,在将虚拟地址转换为物理地址时操作系统可以进行插手,判断转换之后是不是合法的物理地址,从而保护了物理内存。
理由二:
将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间屏蔽底层申请的一系列操作,从而让OS更好的管理内存和进程。
理由三:
有了虚拟地址空间,每个进程认为自己独占内存资源,以相同的方式看待内存。这样就大大的提高了OS的管理效率
举个例子:CPU在执行代码时,首先要找到程序开始的位置即起始地址,有了虚拟地址空间就只需要查找固定的虚拟地址,不同的进程的进程地址空间有着不同的映射关系,所以这个固定的虚拟地址在不同的进程中会映射到不同的物理地址,查找进程相关的代码和数据,让CPU很快的就能过查找到程序的开始位置。
理由四:
虚拟地址空间可以让地址连续化,降低异常访问及越界的概率。
重新理解进程和进程的创建:
1.什么是进程?进程是被加载到内存中的程序,包括了代码,数据以及OS为之创建的数据结构(PCB(task_struct)+mm_struct(进程地址空间)+页表。而我们通过PCB能够找到对应的mm_struct.