文章目录
- 一、地址空间了解
- 地址空间图
- 虚拟地址
- 二、进程、地址空间、物理内存之间的关系
- 进程地址空间和物理内存
- 地址空间的本质
- 地址空间的区域划分
- 三、页表
- 四、为什么要有进程地址空间?
一、地址空间了解
地址空间图
很早我们就了解到空间分布的图是这样的:
- 首先我们写段代码来看一下各部分的地址,检验一下上图所显示的地址趋势是否是正确的:
#include<stdio.h>int g_unval ; //未初始化数据
int g_val = 20; //初始化数据
static int sval = 50;int main()
{int a = 0; //栈区数据printf("代码区地址:%p\n",main);printf("初始化数据区地址:%p\n",&g_val);printf("未初始化数据区地址:%p\n",&g_unval);printf("静态数据区地址:%p\n",&sval);char* heap = (char*)malloc(20);printf("堆区地址:,%p\n",heap);printf("栈区地址:,%p\n",&heap);return 0;
}
运行一下,看看结果:
我们可以看到,各部分地址确实是按上图的趋势存在的。
注意: heap
里存的是 malloc
出来的空间的地址(堆),而 heap
本身是在栈中存放着的。
- 再来看一下命令行参数和环境变量:
int main(int argc ,char* argv[], char* env[])
{for(int i =0; i< argc;i++){printf("argv[%d]:%p\n",i,argv + i);//打印命令行各参数地址}printf("\n");for(int i = 0; env[i];i++){printf("env[%d]:%p\n",i,env + i);//打印各环境变量的地址}
结果如下:
环境变量和命令行参数的地址相近,可以猜测大概是位于同一区域的。
虚拟地址
首先我们来看这样一份代码,父子进程共享数据,但子进程要对某个数据进行改动,我们来看一看这个数据的地址会不会变化:
int g_val = 20; int main(int argc ,char* argv[], char* env[])
{pid_t id = fork();if(id == 0) //子进程{int count = 0;while(1){printf("pid of child:%d, g_val:%d,&g_val:%p\n",getpid(),g_val,&g_val);sleep(2);if(count++ == 5){g_val = 100;printf("child change g_val!!!\n");}}}else //父进程{while(1){printf("pid of father:%d, g_val:%d,&g_val:%p\n",getpid(),g_val,&g_val);sleep(2);}}return 0;
}
执行结果:
- 我们可以看到在子进程更改
g_val
变量的值之前,父子进程共享数据,是一个g_val = 20
,地址也是一样的。但当子进程将该变量的值改变之后,可以看到一个g_val
是20
,一个是100
,也是合理的,但是两个的地址竟然也是一样的,同一个地址空间放的数据又是20
,又是100
的,这显然是不可能的。所以,这里打印出来的地址绝对不是物理地址,该地址我们称为虚拟地址/线性地址。 - 因为我们就是使用的
&
正常取的地址,所以表明我们语言用到的地址,全都不是物理地址,而是虚拟地址。
所以下图表示的不是物理内存,而是进程地址空间。
该图属于操作系统,而不属于语言。不管什么语言写的程序,要运行都要变为进程,拥有进程地址空间。
二、进程、地址空间、物理内存之间的关系
每一个进程都有一个自己的进程地址空间,因为进程信息由 PCB
管理,所以进程地址空间的信息也在 PCB
中。
进程地址空间和物理内存
我们可以接着我们上面所看到的不同的两个 g_val
值对应的一个地址,来看看到底内部是怎样的 ?
-
物理内存和虚拟地址之间有映射关系:
首先,
g_val
属于初始化数据,会在地址空间的初始化数据区有地址,这是一个虚拟地址,既然是虚拟的那就不是实际空间,无法存放数据,数据是存放在内存中的(程序被加载到了内存中),既然这样,那虚拟地址和物理内存的地址就存在对应关系(映射),一张表中存放的就是两个地址间的映射关系,这张表叫页表。 -
当创建一个子进程时,子进程继承父进程,两个进程指向同一块内存:
父进程创建子进程,子进程会继承父进程的大部分数据,包括 g_val
的虚拟地址、映射关系,所以呈现出来的就是父子进程的 g_val
的虚拟地址一样(所以打印出来的地址是相同的),同时两个变量也会映射到同一块实际存放 g_val
数据的物理内存空间。
- 子进程修改数据,发生写时拷贝:
当子进程尝试修改变量时,会影响到父进程的数据,而进程之间是相互独立的,不能相互影响。所以,在物理内存上重新开辟一块空间,将父进程的数据拷贝一份下来,让子进程使用新开空间的数据。
这样父子进程是可以保持独立的,内核数据结构是父子都各有一份的,代码是只读的,所以两者共用一份没问题,而数据也是各有一份的,所以可以保证独立性。
总结:
从上面几点,我们可以解释之前提出的问题了:【不同的两个 g_val
值对应的一个地址?】
- 因为打印出来的是虚拟地址,而两个进程是父子进程,子进程继承于父进程,继承了
g_val
变量的虚拟地址,所以打印出来的地址相同。 - 因为两个虚拟地址映射的物理内存不是一块,而是两个不同的物理内存空间,所以存放不同的数据,一个存
20
,一个存100
是完全可以的。
地址空间的本质
在系统中,同时存在很多进程,那就会有很多进程地址空间,对于这些地址空间,操作系统也需要管理,所以会定义一个结构体/类来描述地址空间,这样每个地址空间就可以成为一个对象了,然后再通过某种数据结构连接起这些对象,从而通过增删查改来实现管理(在Linux
系统中,描述地址空间的结构体叫 mm_struct
)。
综上,那么地址空间的本质是什么呢?—— 特定数据结构的对象。
地址空间的区域划分
进程地址空间通过结构体来描述了,那地址空间中的不同区域的划分会通过成员变量来划分。
三、页表
地址空间(结构体对象)不具备保存代码和数据的能力,代码和数据是在物理内存中存放的。所以需要为进程提供一张虚拟地址和物理内存的映射表,该表就是页表。
-
页表也是先描述,再组织进行管理,它本质也是一个结构体对象。
-
进程地址空间(结构体)中有指针指向页表地址。
-
虚拟地址到物理地址的转换工作,由
CPU
完成。
CPU
中有一个硬件,MMU(memory manage unit)
内存管理单元,该硬件集成在CPU
上,整个管理,映射,查找工作都由硬件自动完成。CPU
中存在一个寄存器CR3
,里面存放当前进程页表的起始地址(CR3
中存放的就直接是物理地址,因为如果是虚拟地址,会无法映射找到物理地址)。CPU
通过CR3
找到页表,通过页表查找到某个变量虚拟地址对应的物理地址,然后进行访问,修改。
四、为什么要有进程地址空间?
-
将物理内存从无序变为有序,让进程以统一的视角,看待内存
将不同位置不连续的物理内存可以映射到连续的一块空间。
每个进程看到的都是同一个地址空间,同样的区域划分。 -
内存管理和进程管理分开/解耦
两个方面只需要通过页表映射连接即可,即使内存管理出了问题,某个进程重新选择物理内存空间,也只需要改动页表即可,不会影响到进程管理。 -
地址空间+页表是保护内存安全的重要手段
当进程对内存的访问不合法时,操作系统可以拦截,阻止进程转换虚拟地址,必要时操作系统可能会杀死进程。
-
提高空间资源的利用效率
- 当我们通过
new/molloc
申请内存时,如果一申请就直接给你一段物理内存空间,但你不立即使用,那这段时间这段空间就浪费了(已经给你了,那操作系统也控制不了这块空间了),而操作系统,一定要为效率和资源使用率负责,这种做法显然不合适。 - 那么地址空间的存在就可以解决这个问题:① 先在地址空间申请空间。申请内存时,将地址空间堆上某一区域地址给进程,该地址当前在页表上没有映射内容;② 2. 等用户使用时,再申请物理内存空间。当用户尝试通过虚拟地址进行写入时,操作系统会暂停写入操作(缺页中断),然后在物理内存上开辟空间,并在页表上建立映射关系,再恢复写入操作。
- 关于分成两个阶段后的效率问题: 就算直接给内存,那也需要【申请地址空间】【申请物理内存】两步操作,只不过是一起做的。现在操作系统将两个部分拆开,在总的时间成本上没有任何差别,但拆开后,会使
new/malloc
的效率变高。
本文到这里就结束了,如果对您有帮助,希望得到您的一个赞!🌷
如有错漏,欢迎指正!😄