💖作者:小树苗渴望变成参天大树🎈
🎉作者宣言:认真写好每一篇博客💤
🎊作者gitee:gitee✨
💞作者专栏:C语言,数据结构初阶,Linux,C++ 动态规划算法🎄
如 果 你 喜 欢 作 者 的 文 章 ,就 给 作 者 点 点 关 注 吧!
文章目录
- 前言
- 一、验证进程地址空间
- 二、进程地址空间
- 2.1什么是地址空间
- 2.2解决一个变量怎么保存不同的值
- 2.3扩展内容
- 三、为什么要有地址空间
- 四、总结
前言
各位友友们好久不见,本人前段时间出去放松了一下,所以就一直没有更新新的文章,现在博主回来了,又可以给大家更新新的文章了,今天我们讲的是进程概念的最后一个专题,进程地址空间,这也是相对来说比较难理解的一个概念,这个概念之前有所了解,但今天需要展开来讲,我会一层层的带大家去理解的,话不多说,我们开始进入正文。
一、验证进程地址空间
1.验证
我们首先来看一个案例,创建父子进程,定义一个全局变量,然后其中一个进程修改全局变量,来看看效果是什么样的:
#include<stdio.h>
#include<unistd.h>
int g_val=100;
int main()
{
pid_t id =fork();
if(id==0)
{
int cnt=0;
while(1)
{
printf("我是子进程:g_val=%d,&g_val=%p,cnt=%d\n",g_val,&g_val,cnt);
sleep(1);
if(cnt++==5)
{
g_val=200;
}
}
}
else
{
while(1)
{
printf("我是父进程:g_val=%d,&g_val=%p\n",g_val,&g_val);
sleep(1);
}
}
return 0;
}
我们通过子进程修改了全局变量,我们一起来看看运行后的效果是什么样的吧
我们发现相同的地址,里面的值不一样,这显然是不符合常理的,我们一个地址对应着一个数据才是正确的,不然就会有二义性,所以这个地址并不是我们所说的内存上真正的地址,现在我先告诉你这是一个虚拟地址,内存上的是物理地址(硬件可以直接访问的地址),所以我们的程序加载到内存上,形成的地址都是一个虚拟地址,下面这个图就是我们平时说到虚拟内存地址分布,这上面的地址都是虚拟地址,这些虚拟地址最后都会通过映射的方式来映射到实际的物理地址上。来进行运算的。
我们上面的代码怎么为什么出现这样的效果呢,看下图:
虚拟地址虽然是相同的,但是通过页表映射到物理地址却是不同的地址,所以相同的是虚拟地址,值不同就是物理地址的不同。
相信大家看到这里,应该了解了一部分,但是还是不明白,博主采取一种先给大家看结论,然后再一点点的去讲细节,所以接下来,我将给大家详细介绍一下。
上面的内存区域分布图是虚拟内存的分布图,而不是真正的内存区域分布图,我们的代码的各个变量都会有对应区域(怎么做到的,后面再说),我们来验证一下,这些区域的到底存不存在。看代码:
#include<stdio.h>
#include<stdlib.h>
int g_val=100;
int g_unval;
int main(int argc,char*argv[],char*env[])
{
printf("code_adrss:%p\n",main);//代码段
printf("init_adrss:%p\n",&g_val);//初始化数据
printf("uninit_adrss:%p\n",&g_unval);//未初始化数据
char*heap_mem=(char*)malloc(10);//malloc申请的空间再堆区,地址保存再变量heap_mem里面
printf("heap_mem:%p\n",heap_mem);
printf("stack_mem:%p\n",&heap_mem);//heap_mem是指针变量,是再main函数里面的局部变量,所以再栈上开辟的
int i=0;
for(;i<argc;i++)
{
printf("argv[%d]:%p\n",i,&argv[i]);
}
i=0;
for(;env[i];i++)
{
printf("env[%d]:%p\n",i,&env[i]);
}
return 0;
}
我们已经看到我们想要的结果了,但是博主还要再扩充一些细节去讲:
1.堆的向上增长,栈的向下增长
向上增长就是往地址变大的方向增长,向下增长就是往地址变小的方向增长
char*heap_mem=(char*)malloc(10);//malloc申请的空间再堆区,地址保存再变量heap_mem里面
char*heap_mem1=(char*)malloc(10);
char*heap_mem2=(char*)malloc(10);
char*heap_mem3=(char*)malloc(10);
printf("heap_mem:%p\n",heap_mem);
printf("heap_mem:%p\n",heap_mem1);
printf("heap_mem:%p\n",heap_mem2);
printf("heap_mem:%p\n",heap_mem3);
printf("stack_mem:%p\n",&heap_mem);//heap_mem是指针变量,是再main函数里面的局部变量,所以再栈上开辟的
printf("stack_mem:%p\n",&heap_mem1);
printf("stack_mem:%p\n",&heap_mem2);
printf("stack_mem:%p\n",&heap_mem3);
我们看到结果和我们想要的是一样的,但是细心的小伙伴有没有发现,我明明申请的是10个字节的空间,为什么差距是20个字节,我们得到的地址是申请空间的首地址,那我们再进行free的时候传进去的也是首地址,那么free怎么知道要释放多少空间呢??原因就是申请空间的时候,不仅仅要申请空间,多出来的空间大小是存放你要申请空间的属性在里面记录着,所以free释放多少空间就通过属性来获取。
2.static
我们使用static修饰的变量,不会因为函数的销毁而被释放,而是再整个程序结束后才会被释放,这是怎么做到的。我们来验证一下:
printf("code_adrss:%p\n",main);//代码段
static int a=10;
printf("static:%p\n",&a);
printf("init_adrss:%p\n",&g_val);//初始化数据
printf("uninit_adrss:%p\n",&g_unval);//未初始化数据
static的本质就是将局部变量变成全局变量(开辟再全局区),使得生命周期是整个程序,只是语法上限制只能再此函数内部进行使用。
3.字符常量区
123
'a'
'10'
上面的代码再Linux下可以直接运行,那这些常量都放在哪里了,我们来验证一下,我们没有办法直接看到这些常量的地址,我们可以放到变量里面,加一个const,本质就是将局部变量的内容变成常量内容,开辟在字符常量区
const char* s="123";
printf("常量区:%p\n",s);
printf("code_adrss:%p\n",main);//代码段
我们看到常量区离代码段区非常接近,原因就是代码段是只读的,而常量也是只读的,所以自然而然的就放在一起了,实际上是代码段里面有一块是属于常量的,如下图:
2.用户空间和内核空间
在32位机器下,一个进程的地址空间的取值范围是0x0000…0000~0xffff…ffff
[0,3GB]是用户空间,就是上面验证的空间分布
[3,4GB]是内核空间,操作系统使用的空间,我们先不做研究。
上面的代码在windows上会有不同的效果,上面的结论默认只在Linux下有效
至此我们的内存地址分布的验证就结束了,说明这不是我们内存条上的物理地址分布,是虚拟内存的地址分布,最后都会转换为物理地址。
接下来我们再来正式介绍什么是程序地址空间,上面的概念还是比较模糊,至少我们知道,我们平时看到的地址不是物理地址,而是虚拟地址,在Linux中应该叫线性地址,为什么要这么设计以及他是怎么做到的,一会都会讲解到
二、进程地址空间
2.1什么是地址空间
小故事时间:
有一个大富翁,他又三个私生子,互相都不认识,在上学的A,在做生意的B,当gai溜子的C。有一天这个大富翁分别对三个儿子说:
对A:你好好上学,以后家里10亿美金让你继承,A听到后努力学习。
对B:你好好管理好你的生意,等做起来了,老爹的10亿美金你来继承
对C:你成为这条街上最靓的仔,10亿美金你继承。
三个儿子听到这里都认为家产是他自己的,所以每天按照老爹的要求努力着
接着,A在学校要买书,说老爹给我200美金我要买书,老爹听说是好事,就给了200美金,B做生意要10万美金周转,也是没问题,老爹给了10万美金,C为了拉拢这条街上的人脉需要400美金买华子,也可以,老爹也给了。
接着,有天,C说老爹给我1亿美金,老爹这时候可不同意了,要这么多干嘛,拒绝了C的请求,但是C还是认为他可以继承者10亿美金。
在上面的故事中,我们的老爹相当于给每个儿子画了一张饼,让他们以为所有的钱都属于他一个的,老爹就相当于操作系统,三个儿子相当于进程,画的饼就是地址空间
现实的饼vs画的饼
现实的饼:就是真实存在的饼(物理地址)
画的饼:就是不存在的(虚拟地址)
画的饼的越多,就要进行管理,要把饼和人对应起来,不能弄错了,不然这三个儿子之间不就穿帮了,既然要管理虚拟地址空间,就是六个字(先描述在组织),内核中的地址空间本质将来也一定是一种数据结构,将来一定要个特定的进程关联起来。这个一会介绍就知道了(地址空间是一个内核中的数据结构)
历史上的地址空间
我们计算机一开始其实就是把进程直接加载到物理内存上进行运行的,
但我们进程1中有出现野指针的情况,访问到进程3里面的地址,那么就会影响到进程3的运行,所以历史上的进程之间不具有独立性的,而且当两个进程之间的空间很小,又来一个进程的没有办法插进去这两个进程中间,导致内存空间碎片的增多,还要重新对进程进行管理,所以历史上直接访问物理内存的方式缺点很多,不安全,空间浪费严重
现代的地址空间
现代计算机的设计思路是适用虚拟地址空间,最终也会转换成物理内存:
想要访问物理内存,需要先转换,才可以。
最终都要访问物理内存,如果虚拟地址是非法的,那么访问到的物理地址不也是非法的,接下来再讲一个小故事:
过年了,大家都有压岁钱,压岁钱再我们身上随便花,区商家老板哪里想买什么就买什么,这时候你妈过来了,说我替你保管,你要花钱跟我说,我给你,你想想也行,就把钱给你妈了,你需要买本书,你妈把钱给你了,你又要买玩具,你妈说家里玩具这么多,不准买,此时在你和商店老板之间多了一个你妈,所以你就相当于虚拟地址,老板相当于物理内存,你妈相当于映射机制,他会根据你虚拟地址的合法性来判断是否可以进行映射到物理内存上,变相的保护了物理内存。
此时大家对虚拟内存是不是有了一定了理解了,接下来再细讲:
内存区域的划分
大家有没有想过为什么我们的地址空间有这么多区域,他是怎么做到的,接下来我再带来一个小故事:
小胖和小花,小胖是一个比较邋遢的人,她两是同桌,一个桌子就100cm长,笑话受不了,就和小胖画了三八线,不许越界
这样的方式就可以瓜分区域了
在上面我们说到过,画的饼是需要管理,就需要先描述在组织,是一种特定的数据结构,所以我们虚拟地址是不是也可以向上面的形式一样进行划分区域
所以每个进程都会对应这样的虚拟地址空间,最终映射到物理地址上。
接下来我们来看看源码:我们看看是不是这样设计的
怎么保证进程之间的独立性
在历史计算机的发展,因为进程之间不具有独立性,导致不安全,那现代的设计是怎么做到的,原因就是地址空间和页表是每个进程都私有一份,只要保证每个进程的页表映射的是物理地址的不同区域,就能做到进程之间不会相互影响,就保证进程之间具有独立性,就安全了
回答一开始的现象:
大家此时再来看这个图是不是就很好理解了,因为都是访问的同一个全局变量,所以虚拟地址是相同的,而子进程虚拟地址中的变量的值,不会形象到父进程的值,你看到的地址相同时虚拟地址相同,看到的值时映射到物理地址上的值时不同的,因为在映射后不是同一块物理地址,所以值不相同,根据fork的特性,如果子进程不修改数据,父子进程的代码和数据是共享的,所以它两映射也是同一块物理地址,但是子进程修改数据,就要发生写时拷贝,导致需要在开辟一块空间将数据拷贝过去,然后子进程在进行修改数据。
相信大家应该明白一开始的现象了吗,地址相同而数据不同的原因了吧
2.2解决一个变量怎么保存不同的值
这个问题我们在学习fork的时候抛出来过,当时没有办法解决,今天可以给大家解决了
在上面我们说到子进程修改了全局变量,发生了写时拷贝,父子进程都有一块自己的空间了,而fork进行返回,是返回了两次,返回就是进行写入,本来是手动修改数据,现在是返回修改数据,父进程返回修改了自己的那一块数据,子进程返回,发生写时拷贝开辟了一块空间,修改了值,只不过用相同的变量进行标识了,就跟上面的全局变量一下,变量名相同,值却不一样。
发生了写时拷贝!,所以父子进程各自其实在物理内存中,有属于自己的变量空间!只不过在用户层用同一个变量(虚拟地址!)来标识了
大家结合这篇文章的开头案例一起来理解。
2.3扩展内容
前期铺垫:
当我们程序在形成可执行程序之前,加载到内存之前,我们程序需要经过编译环节,请问在编译时我们的程序内部有没有形成地址??答案是此时已经形成变地址了
我们的操作系统遵循虚拟地址空间的编址方式,我们的编译器也要遵循此方式,大家有没有发现,我们使用编译器在进行写代码的时候,会有语法错误,因为编译器知道你写的是变量还是代码,在栈区还是堆区啊,而操作系统是不知道,所以编译器要按照Linux内核的一种编址方式在编译的时候就把这些变量,每一行字段都进行编址,等加载到内存的时候,在直接将这些已经形成的虚拟地址来填充到虚拟地址空间上。
这是我们的可执行程序,没有运行起来,就已经有地址了,这就是虚拟内存地址
再来理解一下页表是怎么形成的:
页表就是我们说的映射机制,他是怎么做到的,其实我们的变量,代码在加载到内存上都会有自己的物理地址(外部的),而只是通过虚拟地址(内部的)进行操作,在映射到物理地址上,那他是怎么完成映射的?其实页表就是一个哈希表的结构,key_value模型:
虚拟地址在编译的时候就已经形成了,物理地址在加载到内存的时候形成的,填到页表里面完成映射
深入理解虚拟地址:
相信大家看到这个图应该就可以理解了。
上面讲述了虚拟地址空间和页表一开始数据是怎么来的,并且讲述了cpu是怎么通过虚拟地址来进行读取他的数据,cpu是只读取虚拟地址,来通过映射找到物理地址上的内容。
通过上面的例子,也可以更好的说明我们平时调用库函数的时候是怎么做到,他在编译的时候,就把被调用的库函数地址放到我们要调用函数的内部,像上图一样fun调用fun1这样的操作
三、为什么要有地址空间
我们之前说过现代计算机是如何设计地址空间的,当时已经说了一点点原因,但完全解释不了为什么要有地址空间,接下来从三点来给大家说明原因
- 之前说过物理内存具有读写功能,每一个可执行程序在加载到内存上,每个变量,字段都有自己的物理地址了,那既然物理内存具有读写功能,那是不是就说明代码或者常量也是可以被修改的,但结果往往是不可以被修改,原因是有地址空间的存在,我们访问数据都是通过虚拟地址,在映射到物理地址上的,页表里面不仅仅有虚拟地址和物理地址的映射关系,还有权限的属性。
这样就可以保证我们有的变量,代码是只读的了,之前也说过我们历史上的计算机时直接访问物理内存的,是不安全的,因为会出现地址的非法访问,所以地址空间就解决了这个问题,他通过页表来判断地址是否是非法地址或者映射,从而有效的保护了进程,也有效的保护的物理内存。
因为地址空间和页表是os创建的一种数据结构,是由os进行维护的,所以想要在地址空间和物理内存的之间进行映射,就要在os的监管下进行,从而达到保护物理内存的目的,也保护了物理内存中合法的数据以及每个进程之间的数据。
我们说过形成可执行程序后,未加载到内存时,数据是放在磁盘上的,那么当我们的程序加载到内存的时候,每个变量,每个字段在物理内存都有一个自己的地址,请问这些地址是任意加载的内存上的吗??
答案:是的,是加载到内存的任意位置的,因为我们有页表,我们可以通过映射的方式找到数据在物理内存的数据,学过map或者hash的知道,就算不知道,大家应该知道数组,我们知道了下标就可以以o(1)的时间复杂度来访问到里面的内容。页表你也可以理解为是这样的行为
我们的进程是由操作系统统一管理,既然程序加载到内存的数据是任意位置的,那么就说明进程的管理和物理内存的分配之间就是没有任何关系
内存管理模块和进程管理模块就是解耦合的,没有关联,这样在开发的时候,对每个模块可以单独开发,互相不影响。
如果没有地址空间,那么加载到物理内存的数据是不是就不能随便乱加载,都需要和进程一样被管理起来,这样才能利用好内存,所以地址空间的存在解决了这一系列的问题
我们在c/c++使用new和malloc的时候,申请的是物理内存还是虚拟内存??
答案是虚拟内存,假设申请的物理内存,不立马使用是不是就造成空间资源浪费??答案是的,这里要讲个小故事,假设你家很穷,家里只有500块,结果,你要买玩具,但是此时你叔叔借钱又急事,然后你非要买,但是这时候你就算买回来填已经黑了,你已经玩不了,你妈就说,我们先不买,买了你今天也玩不了,我们把钱先借给你叔叔,他明天一早就把钱还回来,明天在给你买,你一听觉得也可以,但是此时你是认为自己又这个玩具的,只是今天还没买,明天再买,就好比你申请了空间,没有使用,先给别人使用,明天我在使用。
通过上面的故事,我们的申请空间的时候,因为有了地址空间的存在,我们在上层虽然是申请了空间,是在地址空间上申请的,但实际上物理内存连一个字节都没有给你分配,而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系,然后,在让你进行内存的访问,这些是有os自动完成的,用户和进程都是0感知的。
所以这样就保证了我们资源的利用最大化,同样的一笔钱既帮叔叔应急了,也帮你买玩具了,也保证了物理内存的地址几乎是百分之百的有效使用率,每个地址都在使用,不会出现有地址上的数据不在使用的情况,这样可以提搞征集效率,这种方式叫做----延时分配策略
这个也是为什么要有地址空间的一个重要原因!!!!
通过第二点,我们知道,程序在加载到物理内存上的时候,是任意位置进行加载的,那么意味着物理内存中的数据和代码几乎都是乱序的。这样非常不便于管理,但是由于页表和地址空间的存在,因为地址空间的代码和数据都有自己的区域,所以是有序的,在通过页表将其一一映射,页表+地址空间的过程可以将内存分布变得有序化
在一开始的大富翁小故事中,我们的地址空间是画的一块大饼,在结合第二条,进程要访问物理内存的数据和代码,可能目前没有在物理内存中,同样不同进程的数据和代码也会映射到不同的物理地址上,两个进程的数据和代码不会互相影响,这样就很好的做到了进程之间的独立性。页表+地址空间的过程也可以实现进程之间的独立性
通过上面的说明,因为每个进程都私有一份页表和地址空间,而地址空间是0x0000…0000~0xffff…ffff大小,所以有了页表和地址空间的存在,每个进程都认为自己拥有4GB空间大小,并且都是有序的,每个进程不知道其他进程的存在,就好比大富翁的三个儿子是不知道,这样更符合人性,让每个进程都认为所有的物理内存都是自己在使用,就好比大富翁的儿子,每个儿子都认为自己可以继承全部家产,才努力的听老爹话,但凡你老爹说家产只给你一半,你都会不高兴,有自己的想法,这样也是方便操作系统获取每个进程信任,以及任劳任怨完成自己分配任务
到这里,我们为什么要有地址空间就分析完毕了,他有太多的好处,还比较有人性化。设计这样的人才是真正的大佬
接下来带大家再来理解一下什么是挂起!!!
之前也说过挂起的概念,就是在等待队列的进程,后面的队列还在等待,他的数据和代码就不会加载到内存,等等待完毕,要使用了,就把数据和代码加载到内存中,这样的进程就是挂起,今天我们学了进程地址空间,再次来理解一下挂起的概念
加载的本质的就是把可执行程序加载到内存,创建进程,此时是不是立马把代码数据加载带内存,映射关系就要建立好???
答案是,不一定,就算一开始就被加载到内存,有点数据和代码不一定立马就会被使用到,可能过会才会被运行到,这样有的数据和代码就在等待,造成资源浪费,所以在极端情况下,我们加载程序到内存,只有tast_struct被创建好了,其余的工作还没有做,等真的需要的时候在加载到内存上在形成映射关系。此时的状态叫做新建状态
程序运行的本质: 我们的内存最多就16GB,32GB差不多了,我们玩过的最大型游戏上百G都有,请问他是一次性把数据和代码都加载到内存上的吗??
肯定不是的,他是分批加载的,有的时候你已经运行三分之二的代码,后面大概率不会使用到了,os就会把你拿出内存,放回磁盘,映射关系也清除掉,为其他资源做分配,剩下来的三分之一再拿进来在内存上运行,这样看上去是不完整的,我运行的后三分之一的代码,如果使用到前三分之二的数据,页表也会映射到磁盘上的位置,所以页表不仅仅只和内存有映射关系和磁盘也有映射关系 前三分之二代码拿出的过程就是换出,后三分之一的代码拿进来就是换入,这样的分批的换入换出就可以很好的运行整个程序,看着是不完整的,但是通过页表将数据连在一起,内存的数据是正在使用的每次盘的数据是不需要使用的,页表都能映射到。。我们把换出的过程叫做进程的挂起
通过上面的介绍大家更好的理解挂起态了吧
四、总结
这篇博主使用了很长时间总结出来的,内容非常丰富,因为概念的东西非常多,所以文字都很大,不过博主里面搭配了很多小故事,方便大家理解,希望大家看完这篇文章可以更好的理解进程地址空间吗,这篇文章的难度还是有的,有了fork的学习,为这届也做铺垫了,有了这节博客铺垫,下篇在来更好理解fork的写实拷贝算法,帮助大家更好的将知识串连起来,有利于形成知识体系,我们下篇再见了。