目录
0.前言
本文是Linux进程篇的第五篇博客,本文我们着重就讲一件事:进程地址空间!!!
本文所有代码文件都已上传到Gitee,如下是链接,请君自取:
1.初始程序的地址空间划分
1.1程序地址空间图解
我们之前在学习C/C++的时候,一定听说过程序地址空间,大概如图所示:
PS:自下往上,地址从低地址变成高地址。自下往上(地址从低到高)依次是,代码区,字符常量区,未初始化全局数据区,已初始化全局数据区,堆区,栈区。堆区和栈区之间有一段镂空,其中栈区往下增长,堆区往上增长。
1.2程序地址空间区域划分验证
下面我们依次寻找到各个区域的变量的地址,并进行打印对比。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
//程序地址空间从低地址到高地址依次是
//代码区,字符常量区,未初始化全局变量区,已初始化全局变量区,堆区(向上增长),栈区(向下增长)
int g_uninit_val; //未初始化全局变量
int g_init_val = 5; //已初始化全局变量
int main()//main函数地址即代码区的地址
{
const char* s = "hello world\n"; //字符常量
printf("code address: %p\n",main);
printf("string readonly address: %p\n",s);
printf("uninit g_val address: %p\n",&g_uninit_val);
printf("init g_val address: %p\n",&g_init_val);
char* pheap1 = (char*)malloc(10);
char* pheap2 = (char*)malloc(10);
char* pheap3 = (char*)malloc(10);
char* pheap4 = (char*)malloc(10);
printf("heap address: %p\n",pheap1);
printf("heap address: %p\n",pheap2);
printf("heap address: %p\n",pheap3);
printf("heap address: %p\n",pheap4);
printf("stack address: %p\n",&s);
printf("stack address: %p\n",&pheap1);
int a = 0; short b = 1;
printf("stack address: %p\n",&a);
printf("stack address: %p\n",&b);
return 0;
}
我们从中可以看到从代码区->字符常量区->未初始化全局数据区->已初始化全局数据区->堆区->栈区,他们的地址是从低地址到高地址。同时也可以看到堆区是向上增长,栈区是向下增长,堆栈相对而生。
1.3 程序地址空间小补充
程序地址空间是真正的内存吗?
答:程序地址空间根本不是真实的内存!!!
其实这不是单纯用C/C++语言就能解释的,这其实是一个系统级别的问题。当你看完下面的进程地址空间讲解就可以真正理解这个问题。
1.4 引入进程地址空间
在介绍进程地址空间之前,下面我们解析一段代码:
#include<stdio.h>
#include<unistd.h>
//g_val作为全局变量,是父子进程都能看到的变量,
//如果g_val被写入,就会发生写时拷贝,父子进程单享一个数据区。
int g_val = 1;
int main()
{
if(fork()==0)
{
//child
int cnt = 5;
while(cnt)
{
printf("I'm child,times:%d, g_val=%d, &g_val=%p\n",cnt,g_val,&g_val);
--cnt;
sleep(1);
if(cnt==3)
{
printf("############child即将更改数据############\n");
g_val=10;
printf("############child更改数据完成############\n");
}
}
}
else
{
//parent
while(1)
{
printf("I'm father, g_val=%d, &g_val=%p\n",g_val,&g_val);
sleep(1);
}
}
return 0;
}
这段代码中,一开始父进程和子进程是共享同一段数据区的,他们都在打印同一份g_val的值和地址,所以此时在父进程和子进程看来,g_val的值和地址都是相同的。
在子进程修改数据g_val的时候,此时就会发生写时拷贝,父子进程单独使用一块数据区,子进程会对自己数据区的g_val修改为10,从而不影响父进程数据区的g_val,其仍为原值1。
父子进程所打印出来的g_val的值不一样,表明g_val的值确实是两份,父进程和子进程的数据区在物理内存上各有一份。
可是我们却惊奇的发现:这两个g_val所打印出来的地址居然是相同的,这就违反常理了。一个地址上的内存只能存储一个值,为什么能打印出两个值呢?
这只能说明这个打印出来的地址并不是真实的物理地址,因为一个真实的物理地址上的内存单元所存储的值是唯一的。我们只能说明一件事:这个地址是虚拟的地址,不是真实的物理地址。
其实我们打印出来的地址叫做 进程的地址空间,是虚拟地址。与之相对应的是 物理地址,是在真实的物理内存上的地址。在Linux操作系统中地址有两套!!!
这里我们所说的虚拟地址,所说的进程的地址空间,其实就是我们刚才所讲的C/C++程序地址空间上的地址!
为了解决什么是进程的地址空间(虚拟地址)我们首先举两个生动的例子。
*2. 两个生动的例子理解进程地址空间
2.1 画大饼----理解虚拟与真实
2.1.1故事讲解
我们第一个例子叫画大饼,从画大饼这个例子类比,引出虚拟地址空间和真实物理空间之间的关系,以及类比每个进程是如何看待内存的。
有一个大富豪,有100亿美刀 , 存在银行里,有10个私生子,然后每个私生子都互相不认识,每个私生子都自认为他们都有一个爹,且每个私生子自认为他们的爹只有他一个儿子。大富豪对A私生子说:“你好好学习,以后这10个亿还是你的。”对B私生子说:“你好好工作,写好代码,以后这10个亿是你的。”对C私生子,对D私生子,对E私生子,对..…..每个私生子都是这套话术,对每个私生子说完之后,每个私生子都认为自己在独占大富翁的财富,在每一个私生子的脑海中,都画了一张100亿美刀的大饼~
然后每个私生子都可以在自己的脑海里规划这100亿美刀的大饼。都比如私生子A现在对这100亿的大饼进行规划,规划拿10个亿美刀作为买房资金,规划拿10亿美刀作为养老资金,规划拿10亿美刀作为慈善资金,规划拿20亿美刀去做XXXXX。事实上,每个私生子现在都自己的脑海里都有100亿美元的大饼,都在规划这100亿具体怎么划分怎么用。
大富翁为什么要对每一个私生子要花一张大饼呢?
这样有一个好处:简化给私生子分配划分钱的方式。
比如给第一个私生子5亿几万几分几毛,第二个私生子2亿几万几千几百,再给第三个私生子4亿几百几块几毛几分,这样有零有整其实是为难大富豪进行划分了,我大富翁也不好划分。给每一个私生子都画一个100亿的大饼,这样每一个私生子都可以以统一的方式来规划这100个亿的花钱方式。
然后也可以发生如下的场景:
大富翁有100个亿,拿不了这100亿钞票存在银行里
小C说:跟大富翁说,给我1个亿买个表,然后富豪就给你小C了,小C拿去用了。
小B说:跟大富翁说,给我5个亿买个楼,然后富豪就给你小B了,小B拿去用了。
小D说:………………………………………
所以现在每个私生子都跟大富翁伸手要钱做一些事情,大富翁都会尽量满足他们。
大富翁也有拒绝的权利,比如现在私生子F跟大富翁说,给我500亿,给不了当然不给了。
那有没有可能, G H I三个私生子都往大富翁要50个亿 (50*3>100),可能,不过这种情况是很少很少发生的,在Linux操作系统中也很少会发生。
2.1.2 在系统中类比故事各角色
我们再类比一下这个例子中的各个角色:
拥有100个亿的大富豪其实就是OS操作系统,掌管这100个亿。
在银行的100个亿其实就是真实的所有的物理内存。
这10个或者是更多的一个个互不知晓的私生子,其实就是一个个独立的进程。
每个私生子脑海里那100亿美元的大饼,其实就是每个进程的进程地址空间,是虚拟地址。
每个私生子脑海里都画了一张饼,都认为自己有100亿,类比一下,也就是说,每个进程都有一个进程地址空间,都认为自己“拥有”全部的物理内存。
每个私生子在脑海里可以规划100亿,其实就是对进程地址空间的划分与利用。
2.1.3 虚拟地址空间具象化
再补充一点:
系统中有那么多的进程,OS操作系统为了管理这些进程,就按照“先描述,再组织”的原则,描述组织了进程控制块PCB,然后对一个个的结构体PCB组织成数据结构进行管理。
而每个私生子都有一个100亿的大饼,每个进程都有一个进程地址空间,系统中有多少进程,就有多少对应的进程地址空间。OS此时作为系统资源的管理者,肯定也要对那么多进程地址空间进行管理,那OS如何对进程的地址空间们进行管理呢?当然是“先描述,在组织”。
在Linux操作系统中,我们使用struct mm_struct{xxx};这个结构体来描述进程地址空间。
每次创建进程的时候,都会创建一个进程的地址空间,OS则会相应的创建一个struct mm_struct,用来描述该进程的地址空间,以方便OS管理,从mm_struct我们也可以窥探出进程地址空间(虚拟地址)的模样。
就像进程PCB task_Struct可以代表进程实体一样,mm_Struct可以代表进程的虚拟地址空间。
那mm_struct里面究竟是什么样子的呢?进程地址空间是如何被mm_struct描述/代表的呢?在进程地址空间,这个虚拟的地址空间是如何与物理内存进行关联的呢?
从这个例子上来说,也就是在问,这个100亿$的大饼(的划分)具体什么样子的呢?这个大饼是如何被结构体描述的呢?这个脑海中100亿大饼和在银行真实的100亿是如何对应的呢?且看第二个例子。
2.2 同桌三八线
2.2.1 故事讲解
话说在小学的时候,你或同桌,通常都会存在一个人是个人空间感特别强烈的人,极其不希望别人跨到你的位置,产生越界。所以你们都会商量建立一个“三八线”,对一个桌子在长度上进行数值的划分,例如一个桌子长100cm,你们就会按50cm:50cm,或者80cm:20cm等,进行数值区间上的划分。
此时我们可以形象的比喻,在一个实际有形的桌子空间上,有一个虚拟无形的尺子在描述桌子的长度。1m的桌子上,有一个无形的100cm长的尺子,尺子的刻度可以帮助对桌子区域的划分。
所以,我们现在就可以定义桌子(尺子描述划分下)就是:
struct desktop{
int start_girl; //0
int end_girl; //50
int start_boy; //50
int end_boy; //100
};
struct desktop dt = {0,50,50,100};
此时男孩可以定义一个2B铅笔的变量pen,放入到60-61cm的这个区域的两个2cm大小的区域当中。这个60--61其实就是尺子上的刻度,这个60--61其实就是虚拟的地址,即进程地址。以后男孩放桌子上东西,只需要看这把尺子,而不是实际的桌子,就可以了。如果没有这把尺子,小男孩要放2B铅笔,就很难知道要放在桌子上的哪里了,很可能越界被女孩打QwQ。只有有了这个无形的尺子,才能把男孩自己的东西对应的在桌子放置到正确的区域。
2.2.2 类比进程地址空间
上面例子通过无形的尺子,来对桌子进行区域的划分( desktop{int start_girl...int end_boy};),我们可以只根据尺子的刻度,就可以完成对于真实桌子空间的划分描述以及利用。
故事中对象类比:
桌子空间可以类比是真实的物理空间。
无形的尺子刻度空间其实就是进程的虚拟地址空间。
一个尺子刻度对应一块真实桌子的空间,这个尺子刻度数值 映射 到对应的真实桌子空间,这个映射关系类比就是连接物理空间地址和进程的虚拟地址空间的页表。
struct desktop{int start_girl...int end_boy}其实就是描述尺子视角下的桌子区域,本质上其实是对100cm尺子刻度空间的划分,可以类比成struct mm_struct,对进程的虚拟地址空间的划分。
关系理解1:
这里的尺子划分出的桌子desktop其实就是大饼,对desktop的区域划分其实就是对100亿$大饼的划分。
100亿的大饼 == 100cm的desktop == mm_struct进程地址空间。
我们可以对100亿$的大饼进行规划,我们可以对100cm的desktop进行区域划分,我们同时也可以对mm_struct进程地址空间进行区域的划分!
在无形尺子的描述下这个大前提下,桌子desktop是{int start_girl...int end_boy}这样被划分的,然后我们根据这个尺子划分的地址,就可以映射找到对应真实桌子上的空间。
虚拟的进程地址空间内部也是类似的,mm_struct也是以这样的方式描述进程地址空间/的,然后进程地址空间进行相应映射,找到对应真实物理上的空间。
mm_struct就是无形的尺子的刻度划分,也就是尺子划分出的struct desktop。这个struct desktop其实就代表了这100cm无形的尺子。mm_struct也代表了这4GB的进程的虚拟地址空间。
struct mm_struct{
Unsigned int code_start; //0
Unsigned int code_start; //0x00000FFF
Unsigned int uninit_data_start; //0x00001000
Unsigned int uninit_data_end; //0X........
Unsigned int init_data_start;
Unsigned int init_data_end;
Unsigned int init_heap_start;
Unsigned int init_heap_end;
……………
Unsigned int init_stack_start;
Unsigned int init_stack_end;
……………… //0XFFFFFFFF
};
//mm_struct以这样的方式描述进程的地址空间
mm_struct内部就是通过一个个 start和end来进行区域的划分。【类比desktop内部】
虚拟地址空间被mm_struct描述的方法与虚拟地址空间被OS视角下的实质,就是如上的各个区的start end在数值上的划分。【类比男孩区女孩区boy girl end start】
mm_struct的值就是从全0到全F,在OS操作系统这里,不需要类型,只需要知道你要几个字节。【类比从0cm--100cm的划分】
虽然这里只有start和end,但是本质每个进程都可以认为mm_struct代表整个内存,且所有的地址为0x0000000->0xFFFFFFFF。【类比脑海中的100亿与银行里真实的100亿】
一把无形的尺子映射划分了这个桌子,完成了对桌子区域的划分,根据两个start-end,来划分出男孩区和女孩区。比如此时男孩区是0—50,女孩区是50—100,那如果男孩把他的东西放到了女孩区,把自己定义的铅笔变量定义到了50—100这个区域,那女孩就会非常生气,生气的理由也是有依据的,那就是跨越了这个尺子的区域50--100->从而实际上把东西放到女孩的那一块桌子上了。
同样的,code_start填0,code_end填50,那进程就认为这块空间就是code区。所以我们通过start,end数值的划分就可以对进程地址空间进行区域的划分了。
每个桌子都是100cm长。每对同桌都认为有100cm进行划分,每个同桌的区域都是对这100cm进行的划分。
每个进程都认为地址空间的划分是按照4GB空间划分的。
其实这个 0—50,50—100,这些尺子的刻度,mm_Struct中start—end的划分,地址空间上进行的区域划分,其实都是对应的线性位置的虚拟地址。
2.2.3 结论总结
结论总结1:
这里的虚拟地址空间(其实是看描述它的结构体mm_struct),无非就是两个意义,一个是形成区域【每个进程都”认为”自己享有4GB的空间】,二是在各个区域中,抽象成一个地址,这个地址是线性连续的【对4GB的空间进行划分分区】。
每个进程都有自己的task_Struct进程PCB(描述进程),也有自己的mm_struct(描述该进程虚拟地址空间),mm_struct从全0划分到全F,叫做虚拟地址,也叫做线性地址,在Linux当中这两个概念是等价的。
如上是从mm_struct对进程虚拟地址空间的描述,以及进程地址空间的划分的阐释,如下则是对mm_struct 虚拟地址空间和物理空间的映射连接关系的阐释,以引入具体的系统内部结构。
结论总结2:
虚拟地址不是真实的物理地址,我进程自己真正要使用内存,总归还是要根据进程的虚拟地址,回归到真实的物理地址。(尺子帮助我们划分刻度,但是我们不是在尺子刻度上放东西,总归还是要根据尺子的刻度,最终映射放到桌子上。尺子->桌子)。
每个进程都可以根据这个虚拟的地址空间(描述进程地址空间的mm_struct),通过映射,找到真实的物理地址空间。【男孩可以根据这个无形的100cm尺子上的刻度(描述100cm尺子刻度的),通过映射,对应找到真实的桌子上的一块空间】
3. 进程的地址空间(在系统中是什么)
我们刚才说每一个进程都有一个进程地址空间,所以在OS看来,每个进程都是一个task_struct连接指向了一个mm_struct。
而mm_struct描述代表了进程地址空间,进程地址空间就是100cm的尺子刻度,物理内存就是真实的桌子上的空间,尺子上的刻度通过映射关系可以找到对应的真实桌子上的空间。所以在OS看来,其实就是mm_struct通过某种映射,可以找到对应的物理地址空间。这个映射是如何做到的呢?在操作系统中我们使用页表+MMU,架起虚拟地址和物理内存的“桥梁”。
什么是MMU?
MMU其实是一种硬件,功能是查页表,学名叫做内存管理单元(memmory manager unit),但是映射的关键在于页表。
什么是页表?
页表其实就是OS操作系统给每一个进程创建维护的一张表,这张表结构,可以理解成表的左侧是虚拟地址,右侧是对应映射成的物理地址,页表的工作就是 将虚拟地址转化成物理地址,所以页表其实就是一张映射表。
也就是说,进程在进程的虚拟地址空间中划分的区域,如代码区,数据区,堆区,栈区,最终其实都可以通过页表,映射到物理内存中的一个区域中。(尺子对100cm的数值进行划分的start,end区域,都可以通过映射,找到桌子上对应的区域)
为了方便画图我们把mm_struct和进程的地址空间看做一回事,事实上mm_struct就是一种描述+代表进程地址空间的结构体。
也就是说,虚拟地址空间可以映射到物理内存地址。进程使用的虚拟地址,而实际在系统内部存储使用的还是物理地址,存储在物理内存中。
那为什么我们要使用虚拟的这个进程地址呢?为什么不能直接使用真实的物理地址呢?加一个进程的地址空间,这个虚拟地址在中间不是非常复杂吗?吃饱了撑的???直接抛弃进程地址空间+页表+MMU,进程直接访问真实的物理地址空间不香吗?
我想说,存在即合理,存在这个进程的虚拟地址空间实际上有三大理由。
4.进程的地址空间(为什么要有这个虚拟地址)
4.1进程地址空间存在的第一个理由
4.1.1 现实例子1
首先我们讲一个现实中的例子。
过年了,你的二大爷给你发了200块钱的压岁钱,然后刚一到家,你的妈妈就和你说:“孩子,这200块钱数目太大,放在你这里不安全,给妈妈帮你保管吧。”小时候你肯定就把钱给你妈了。后来你跟你妈说:“妈,我想买这个玩具,我想去新华书店买本书。”你妈通常都是拿出这笔钱给你买。可是这不是很奇怪吗,为什么要你的妈妈插在中间呢,直接一开始你就自己保管,自己拿钱去花这200压岁钱不就行了吗,非得经过妈妈之手吗?
原因很显然:妈妈不是不想让你自己拿着,而是害怕你乱花钱。所以就是说你妈帮你代保管。
4.1.2 系统例子1
加了一个中间层是有利于做管理的。做管理并不是不给你这200块钱,而是在监管你做的这件事是否合理。这个中间层其实就是虚拟地址空间+页表,其中间层其实就是操作系统!你的妈妈就是操作系统,妈妈管理的方式就是通过虚拟地址空间+页表,这就是做管理的中间层。
而如果允许你直接访问物理地址空间,实际上是很危险的。这就好比你一个小孩自己拿着这200块钱,你妈不再帮你保管,就什么都不知道。你拿着这200块钱被人骗了,被人抢了,或者你拿这200块钱干什么违法的事情,你妈就什么都不知道。我们举一个内存当中的例子说明。·
如果我们允许一个进程直接访问物理内存中的代码和数据,那这的确会变得很简单,这是一步到位的过程,但是直接使用物理地址,也就没有中间的虚拟地址空间与页表映射,操作系统也就没法在中间进行监管,进程可以直接操纵到真实的物理内存,这是可怕的。
若进程采用直接访问物理内存地址的方式,如果此时某个进程出了BUG,产生越界,就会直接修改旁边别的进程的代码和数据,即此时进程A直接访问到旁边进程B,进程C的代码和数据,此时进程的独立性就不复存在,没有中间过程(虚拟地址->页表->物理地址),操作系统其实也无法监管这个行为。
没有进程的地址空间,进程就可以直接访问到物理内存,没有进程的地址空间和页表,也就是没有中间的OS做管理层,你进程越界访问到别的物理内存位置也没法管。
这样的话也就会存在一些恶意的进程,由于可以直接访问真实的物理地址内存,所以就可以去直接访问篡改别的进程的代码和数据。假设进程A去访问到一个私密性很高的进程C的代码和数据。如果这个进程C涉及个人私密,比如可能会访问读取出一个私人的数据,甚至是银行卡密码等,进行非法的盗取与修改,这样做是十分不好的。
我们能够检测出进程之间的越界访问,非法越界修改,其实靠的就是从虚拟地址,经页表到真实物理地址的映射这个过程中,操作系统可以参与进来进行监管。
进程只能看到虚拟地址,进程访问某个虚拟地址,就要把虚拟地址转到物理地址,是通过页表+MMU转,利用页表来转化是由OS来做的,页表也是由OS操作系统维护的。所以你进程想访问某个(虚拟)地址时,OS会首先在页表进行查询,如果你要访问的虚拟地址可以查找到映射对应的物理地址,是建立映射的,那也就是说这块物理内存是你这个进程的,可以访问,而如果OS没有在页表中查到这个虚拟地址有对应的物理地址,即没有对这块虚拟地址在物理内存中建立映射,这块区域就不是该进程的区域,OS也就不允许进程进行越界访问。这其实就解决了这个问题。
你妈其实就是操作系统,在虚拟地址和物理地址添加的中间层,添加页表其实就是操作系统管理虚拟地址的物理地址的“代言人”。这个中间管理层可以保护物理内存的安全。
4.1.3 系统例子2
再补充一个例子,这一点也可以体现出这个中间层的管理保护作用。
我们都知道在C语言当中,常量字符串是无法被修改的,如下代码所示:
//定义常量字符串
//ONE:如下pstr1和pstr2指向的是同一个常量字符串
const char* pstr1 = "Hello world\n";
const char* pstr2 = "Hello world\n";
//TWO:常量字符串无法修改,执行如下代码进程会崩溃
*pstr1 = 'h';
*pstr2 = 'h';
我们此前只能从语言上理解,由于是const修饰类型的变量是常量,所以是无法进行修改的,如果强行修改程序会崩溃。这样的解释格局太小,放大格局,我们站在系统的角度解释这件事。
修改常量,进程会发生崩溃,我们知道进程崩溃是因为OS操作系统对这个进程发送了信号,所以说进程崩溃是OS操作系统认为该进程的代码做出了不合理的举动,OS作为监管者管理者是如何发现这件事的呢?以及定义const类型的常量在系统层面是如何体现的呢?
事实上,被OS维护的页表上,除了虚拟地址和映射的物理地址,页表还有第三栏,权限栏。每一个字节都有唯一的物理地址,和进程相映射的虚拟地址,页表还记录了这个字节的针对于这个进程的权限。例如物理内存中的一个字节是属于这个进程的,那也就是说,这个字节是被记录在页表中,也就是记录了:进程的一个虚拟地址+该字节的物理地址+该字节对该进程的权限开放。如果此时进程对这个页表上只有r权限的字节执行写入的代码,OS作为页表的维护者就会获悉,就会给该进程发送信号使该进程崩溃。
例如我们可以在我们定义一个常量字符串的逻辑,如const char* pstr1 = "Hello world\n"定义常量字符串,其实是在物理内存中开辟一个常量字符串,这个字符串上的每个字节的物理地址和相应的虚拟地址都会被OS维护的页表记录,同时每一个字节的 权限栏上都会只有r权限而没有w权限。
此时如果进程执行到 *pstr1 = 'H';这样修改常量字符串的代码,从系统层面看,就是首先找到pstr这个虚拟地址(因为进程只能看到虚拟地址),然后在页表查找到这个虚拟地址,然后映射找到相应的物理地址,此时页表上这个字节的权限只有r的话,页表是OS管理内存而维护的工具,此时OS就会对这个进程发送信号,使进程崩溃。OS也是通过这种页表的方式来监管进程和保护物理内存的。
4.1.4 第一条理由结论总结
通过添加一层软件层,完成有效的对进程操作内存进行风险管理(权限管理),本质目的是为了保护物理内存,以及各个进程的数据安全。
4.2进程地址空间存在的第二个理由
4.2.1 现实例子1解释
妈:“孩子,今年有200压岁钱,我帮你保管着哈”,后来你说:“我想要一个挖掘机”,妈:“不行,这个不适合你。”这个叫你妈拒绝了你。
后来你说:“我后天周六+同学要和我去吃KFC,我得需要100块钱。”这时候你妈其实并不会把100块钱给你,而是会说:“今天才周四,那不急,等周六早上我再给你100块钱。”你妈会先许诺会给你100块钱-,而真正给你这件事,会在之后再做,因为在这周四->周六早上这挺长的时间内,你并不会使用这100,如果你妈周四在你要的时候就给你了,那这几天你只能是自己拿着,而不会真正去用去花,而你妈完全可以先 分配利用着这100块钱去做其他的事,你又不急着用嘛,到时候再真正给你也不迟。当然,你妈可能会在给你之前把这100块钱给你爸去打牌,你爸可能会赢回来300块钱,你妈可能拿着这100块钱去救济灾区等等,这些都是有可能的,当然到时候给你的不一定是这张100纸币,可能给你的是别的100元纸币,不过这100块钱在周六早上你要用的时候一定会给你的。
4.2.2 系统例子1解释
场景背景:进程在申请空间的时候,比如进程执行到了如char* phead = (char*)malloc(1000);这样,进程想要求操作系统在物理内存中申请1000个字节。
问题1:我在进程当中申请了1000个字节,那我们现在就会立即使用这1000个字节吗?
答:当然不一定!打个比方,你今天早上买了一桶泡面,你就一定会现在就立即吃掉吗?当然不一定,你或许当然有可能留着当做宵夜今天晚上再吃嘛。所以答案就是不一定,可能会存在暂时不会全部使用甚至是就只是申请不使用的情况!
由问题1,站在OS操作系统的角度,如果一个进程申请内存,若操作系统直接无脑把你想要的物理内存,申请出来给你这个进程,这很可能会造成内存资源的占用浪费以及申请效率的降低。也就是说,站在OS操作系统的角度,如果空间立马给你,也就以为着,这整个系统当中会有一部分空间,本来可以给别人立马用的,现在却被你申请到却闲置着。
同时我们不要局限于一个进程申请出来,却闲置不用的内存。系统当中是有成百上千个进程,如果每一个进程申请一些内存,而闲置不用的话,那其实闲置浪费的空间其实是巨大的。那再有一个新的进程有急用,想申请却申请不到!这种情况非常之糟糕!
问题2:我们如何理解申请的内存是“闲置”的?
申请的“闲置”的空间,就是说,有了空间但是从来没有对之进行读写,这就是对申请空间的“闲置”。
我们是很痛恨申请了,不立即使用,一直闲置这种现象的,这就好比是“占着茅坑不用”嘛!类比如上生活例子,你妈不会在周四就把100块钱真正给你,因为你会一直闲置100块钱而直至周六早上才用,所以此时你妈会先不真正给你,而是先许诺给你,让这100块钱先去做一些有用的事情,然后周六再真正给你。
解决3:针对“闲置”的问题,我们操作系统是如何做的呢?
申请的“闲置”的空间,就是说,有了空间但是从来没有对之进行读写,这就是对申请空间的“闲置”,也就是说这个时候操作系统不会真正把这块你申请的空间给你,因为这块空间OS可以给别的进程用。等你要对之读写的时候,OS才会真正把开辟的空间给你。
你现在要在堆区开辟1000个字节,其实OS也并不会直接把这1000Byte给你,而是会首先“许诺”你,即只在mm_struct中heap_end+=1000,只在虚拟地址中给你开辟,进程只能看见虚拟地址,此时进程会“自认为”自己有1000Byte堆区的空间,但是实际上,此时不会开辟真实的1000Byte物理内存,也不会建立页表映射,只是在虚拟地址空间中把start,end的数据空间给你改一改,只是在进程地址空间上”许诺”给进程。
而当后面这个进程要对这1000Byte进行读取写入时,即不再需要“闲置”时,此时OS才会真正在物理内存中开辟1000字节的空间,此时这真实的1000Byte就会先被在页表上建立与虚拟地址的映射,这块物理1000Byte才会被真正给予该进程。
这就是基于缺页中断进行物理内存的申请。
操作系统OS做的内存申请的动作是“隐身”的!即你进程是不知道这个内存申请的真实全过程的。就是说,你妈在周六早上把100块钱给你之前,你并不知道她拿着这一百块钱在周四周五究竟干了什么,因为这100块钱此时还并不真正被你拿到。
所以我们就可以引入进程地址空间存在的第二个理由。
4.2.3 第二条理由结论总结
2.将内存申请和内存使用的概念在时间上划分清除,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和OS进行内存管理操作,进行软件上面的分离。
4.2.4 现实+系统例子2解释
为了加深对第二条结论,对分离+屏蔽的理解,我们再结合实现,举一个系统中的例子。
这学期快交学费了要6千块钱,你跟你爸说了这件事,然后你爸答应了你。然后你认为你就申请到了这6000块学费,这是应用层认为自己申请成功了。
-p1“在进程地址空间上许诺给你(进程),但并未开辟”
后来到了交学费这一天,你爸就在你的银行卡账户里打入了6000块钱,但是你其实是不知道这6000块钱的来龙去脉的,这6000块钱可能是你爹问你妈要的,可能是你爹从他的私房钱里直接拿的,也可能是你爹从邻居家借的,或者甚至有可能是你爹昨天打牌赢的,这实际上你(应用层)并不知道,因为这是你爹和别人如你妈,邻居等人的事情,你只知道交学费那天账户上有了6000块钱可以用。也就是说,到交学费这天你才真正收到 这期间不知道经历了什么的6000块钱。
-p2”(进程)要使用空间时,OS真正开辟物理空间”
-》而其实你问你爹要6000块钱,你爹答应许诺你之后,其实此时你爹手里可能是没有6000块钱的,但是你是他生的孩子,你爹即使现在没有这么多钱,在交学费那天之前,也一定会想办法给你整到这6000块钱的,不管是从自己的私房钱里拿,还是从邻居亲戚家借。
-》同样的,操作系统给你许诺给你这一块内存空间,即只在进程虚拟地址空间中给你开辟,事实上,此时系统中可能并没有足够的你所需要的这些空间,此时就会执行内存管理算法:
此时OS会看到其他的进程B,看到进程B的这块空间一直没有使用,此时这块进程B的所属空间,就会被OS置换到磁盘上先存着,然后在内存中的这块空间就被OS申请给进程A用。
即使此时物理内存已经被占用100%了,进程也是可以被申请到空间成功的。
其实这个过程进程地址空间起到的作用是很大的,相当于“遮羞布”的作用,有了进程地址空间做掩护,用户/进程就不会知道内存管理算法的操作,也不会知道这申请的真实的物理内存是从哪里来的,不知道这块空间是OS把剩余空间给你的,还是OS已经不够了,从别的进程中置换的,你/进程都不清楚。所以你可以更加理解2个理由:
2.将内存申请和内存使用的概念在时间上划分清除,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和OS进行内存管理操作,进行软件上面的分离。
4.3进程地址空间存在的第三个理由
4.3.1 系统例子方法1
抛出问题:CPU是如何知道这个进程的第一行代码在哪里呢?
进程的pc指针可以指向进程的代码和数据,记录当前进程执行代码到了第几行,当然这是找到第一行代码后,后面执行继续的事情。但是CPU总得先找到第一行代码在哪里吧,起始入口在哪呢,即起始地址在哪里呢。
方法1:如果没有进程地址空间的话,由进程直接访问使用物理内存,此时寻找进程的第一句代码就会相对麻烦,因为每一个进程的代码在物理内存中的起始位置都是不一样的,CPU对每一个进程进行初次执行时,CPU就都需要在物理内存中找到进程的代码起始位置。
首先解决这个问题->每个进程的第一句代码/起始地址是不同的,这怎不能让CPU每次在执行某个进程的时候,都在物理内存中找一番吧,CPU这样找入口地址太过麻烦,CPU此时不能以一种统一的方式快速的对每一个进程找到对应的起始地址。所以,初次运行进程A时,就必须去找进程A的入口起始地址,初次运行进程B,就需要去找B的代码起始地址。。。这样CPU就累死啦!!!累死CPU不偿命呀!!!
4.3.2 系统例子方法2
如何才能让CPU能够直接找到每个进程在内存中首行代码的位置呢?
假设现在有虚拟地址空间(and页表),此时对于任何一个进程,CPU可以通过直接访问一个固定的虚拟地址,例如CPU现在只需要访问0x12FF,就可以找到这任意一个进程的首行代码位置,也就是代码区的起始位置。这样CPU就不用费力去找代码区的首地址了,直接0x12FF就能找到代码区的首地址。
何处此言呢?
首先我们记一件事,所有进程的进程地址空间的划分规划方式都是一样的!都是4GB,每个区的大小啊,起始地址啊,结束地址啊,都是一样的。
时光倒流,一开始一个程序是一个在硬盘中的代码和数据,后续该进程的代码和数据导入到了物理内存中,成为一个进程,在物理内存中有这个进程的代码区和数据区,与此同时,这个进程的PCB task_struct就会被创建,此时这个进程的代码区和数据区的地址就会在页表中建立映射,连接起来进程虚拟地址空间。
所以该进程Main函数的代码的地址,即该进程的起始运行地址,所以事实上,我们每个进程创建的时候,就都可以将自己代码区的真实物理地址,在页表上,与同一个虚拟地址进行映射。例如在物理内存中,进程A的main函数首行代码起始地址是0x4321,进程B的main函数首行代码起始地址是0x5432,进程C的起始入口地址是0x6543,在进程建立的时候,在页表上与同一个虚拟地址0x12FF进行页表的映射。如图所示,写一个页表。
这样CPU就可以只通过0x12FF就能通过OS在页表的映射自动找到对应的该任意进程的首行代码的起始地址了。
既然任意进程的代码区的起始地址都是借助页表确定了,即所有进程的虚拟代码区的进程虚拟地址都是固定值0x12FF(进程地址空间划分方式都是一致的),CPU可以直接访问0x12FF就可以映射找到对应的进程真实代码区的物理地址。
4.3.3 升华总结及结论
通过如上例子,我们不难看出是用虚拟地址空间的统一化划分(每个进程的进程地址空间的代码区起始地址都是0x12FF),使得每一个进程的代码区可以存储在任意物理内存位置(反正在页表上都会和相同的虚拟位置建立映射),以虚拟地址之统一,以页表映射物理地址,则可知存储在任意位置的物理内存,也就是说,用虚拟地址的统一化固定,换取物理内存的自由!!!
在虚拟地址空间中,进程代码区的首地址是统一确定的,所以我们可以通过这同一个地址,通过页表建立的映射,找到任意进程在物理内存中的代码区首地址。虚拟地址是统一的固定的,所以物理地址可以是自由的任意的。
虚拟地址空间中,不止代码区的起始地址是统一的固定值,其他区的起始地址都是统一固定的,所以这也就意味着:
站在CPU的角度,可以让任意一个进程的未初始化数据区,初始化数据区,堆区,栈区等等区域的地址在页表上都各自使用统一的同一个进程虚拟地址。
也就是说,现在假设进程地址空间中,代码区的起始地址是0x12FF,那在页表上所有进程的物理内存上的代码区的起始地址,都和0x12FF建立映射,所以此后CPU对任意一个进程只要访问0x12FF就可以找到该进程代码区的起始处。所以假设进程地址空间中,栈区的起始地址都是0x42EE,那在页表上所有进程的物理内存上的数据区栈区的起始地址,都和0x42EE建立映射,所以此后CPU对任意一个进程只要访问0x42EE就可以找到该进程栈区的起始处。
所有的进程都是以统一的视角来看内存的,各种区域代码区,数据区的虚拟起始划分地址都是一样的,带来的好处就是:
程序的代码和数据可以被加载到物理内存的任意位置!这样可以大大的减少内存管理的负担!
所以我们可以得到进程地址空间的第三个理由:
3.站在CPU和应用层的角度,进程统一可以看做统一使用4GB快进啊,而且每个空间区域的相对位置,是比较确定的!操作系统最终这样设计的目的,可以达成一个目标:每个进程都认为自己是独占系统资源的!
5. 用地址空间解释场景
5.1 父子进程数据区的相同地址
5.1.1回想父子进程写时拷贝场景
我们在1.4 引入进程地址空间当中做了一个实验,在发生数据区的写时拷贝之后,父子进程对于不同的数据变量,所打印出来的地址值居然是相同的,当时我们是无法理解的。如下图所示:
#include<stdio.h>
#include<unistd.h>
//g_val作为全局变量,是父子进程都能看到的变量,
//如果g_val被写入,就会发生写时拷贝,父子进程单享一个数据区。
int g_val = 1;
int main()
{
if(fork()==0)
{
//child
int cnt = 5;
while(cnt)
{
printf("I'm child,times:%d, g_val=%d, &g_val=%p\n",cnt,g_val,&g_val);
--cnt;
sleep(1);
if(cnt==3)
{
printf("############child即将更改数据############\n");
g_val=10;
printf("############child更改数据完成############\n");
}
}
}
else
{
//parent
while(1)
{
printf("I'm father, g_val=%d, &g_val=%p\n",g_val,&g_val);
sleep(1);
}
}
return 0;
}
5.1.2解释预备知识
首先我们需要明白一件事,进程是只能看到进程地址空间的虚拟地址的,进程看不到物理地址,这点也可以在我们使用进程地址空间的三个理由中理解,虚拟地址空间屏蔽了进程和物理内存。所以 &g_val取出的是虚拟地址,也就是存储在物理内存当中的g_val,这个变量对应页表上的虚拟地址。
第二件事,一个进程,或者说我们创建一个进程,实际上要做的事情是很多的,我们用一个公式来进行表达: 进程 == 一个描述该进程的PCB(task_struct)+ 一个该进程的进程地址空间(mm_struct)+ 一个页表 + 加载到内存中的进程的代码和数据
第三件事,子进程的创建是以父进程为模板的,也就是说,子进程的PCB(task_struct),子进程的进程地址空间(mm_struct)和页表等,在被父进程刚刚fork创建出来的时候,其实是非常和父进程的非常相像(甚至可以看做是一模一样)。
5.1.3使用进程地址空间解释
我们依照一个图的变化来描述这整个过程的变化:
一开始只有父进程,数据区的g_val==1。
之后父进程fork出子进程,子进程的进程地址空间和父进程的进程地址空间是极其相似,我们这里看做是一样的,也就是说在子进程的进程地址空间的初始化全局数据区,父进程的进程地址空间的初始化全局数据区,这两个区域的同一个虚拟地址,都映射了物理空间中的同一个g_val的物理地址。同时我们也是通过这种映射的方式来做到父子进程是共享同一块数据区和代码区的。
后面子进程要修改g_val为5,父进程和子进程共享的数据区,此时就会发生写时拷贝,对数据区进行拷贝,父进程和子进程各自使用一份数据区,此时(物理内存)父进程数据区中g_val==1,子进程数据区中的g_val==5。
同时g_val在物理内存中有两份,一个值为1,一个值为5,他们的物理地址不同,这点我们是可以确定的。但是父进程和子进程的进程地址空间,他们是极其相似的,他们映射到g_val的虚拟地址使用的还是原来初始化全局数据区上,那个相同大小的虚拟地址,当然,虚拟地址映射到的物理内存的g_val已经不是同一个数据区域了。所以此时我们可以看到:父进程和子进程的的相同的虚拟地址,映射到了不同物理地址的g_val。
5.2多个进程共享同一份物理空间
父子进程一般代码是共享的,那是如何做到代码共享呢?
无非就是用他们各自代码区虚拟地址映射到这同一个物理内存上的代码区。所以其实有了进程地址空间+页表之后,很多东西都变得好处理了起来。
我们再看一个例子:
const char* pstr = "hello world";
const char* p = "hello world";
printf("pstr:%p\n",pstr);
printf("p:%p\n",p);
//这个我们之前就了解过,pstr,p指向的是同一个字符串区域
PS:打印出的pstr , p的%p值其实都是指向的物理空间中字符串的虚拟地址。
pstr和p的地址都是一样的,当然打印出的是虚拟地址,不过他们的物理地址都是一样的,所以指向的虚拟地址也是一样的。
那为什么要这么干呢?为什么不能是在物理内存中开辟两块空间,存储两个“hello world”呢?
答:没有必要!因为const char*(“hello world”)是常量,是只读(r)的。只读就意味着没有人修改,操作系统维护一份是成本最低的,OS完全没有必要维护多份。
对常量字符串来说,只需要在数据区提供一份数据即可,这是最小的成本,所以Linux操作系统搞出了字符常量区,s,p他们指向的都是同一块物理内存,他们的物理地址和虚拟地址都是相同的,其实就是同一份同一个"hello world"。
5.3 外界统一访问进程地址空间
通过地址空间存在的第三个理由,进程地址空间的固定-页表>物理空间的自由,我们知道,不同进程的代码区(其实不止代码区)的地址都是相同的,虚拟地址空间所有的进程的划分是一样的,其实是方便外部统一固定的访问。
6.地址空间的划分问题(再完善)
还有两个数据:进程的命令行参数和环境变量,也就是这些传入进程的这两个变量。
直接的体现是char* argv[],char* env[],当然即使没有main函数参数接收传参,命令行参数和环境变量这些也是被传入到进程当中了,那究竟是传入到了进程的哪个区域中存储的呢?也就是说,进程地址空间中的哪个区域是统一固定存储这两种变量的呢?
我们看如下代码:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
//程序地址空间从低地址到高地址依次是
//代码区,字符常量区,未初始化全局变量区,已初始化全局变量区,堆区(向上增长),栈区(向下增长)
int g_uninit_val; //未初始化全局变量
int g_init_val = 5; //已初始化全局变量
int main(int argc,char* argv[],char* env[])//main函数地址即代码区的地址
{//argv命令行参数 env环境变量
const char* s = "hello world\n"; //字符常量
printf("code address: %p\n",main);
printf("string readonly address: %p\n",s);
printf("uninit g_val address: %p\n",&g_uninit_val);
printf("init g_val address: %p\n",&g_init_val);
char* pheap1 = (char*)malloc(10);
char* pheap2 = (char*)malloc(10);
char* pheap3 = (char*)malloc(10);
char* pheap4 = (char*)malloc(10);
printf("heap address: %p\n",pheap1);
printf("heap address: %p\n",pheap2);
printf("heap address: %p\n",pheap3);
printf("heap address: %p\n",pheap4);
printf("stack address: %p\n",&s);
printf("stack address: %p\n",&pheap1);
int a = 0; short b = 1;
printf("stack address: %p\n",&a);
printf("stack address: %p\n",&b);
for(int i=0;i<argc;++i){
printf("argv[%d] address:%p\n",i,argv+i);
}
for(int i=0;env[i];++i){
printf("env[%d] address:%p\n",i,env+i);
}
return 0;
}
所以我们可以发现,在进程地址空间当中,命令行参数和环境变量的地址,位于栈区的上方。所以我们进一步完善C/C++程序地址空间。
所以由此我们已经清晰了最下面的3GB,还有最上面的1GB,叫做内核空间,这个我们后面再讲。