目录
前言
进程虚拟地址空间的引入
进程地址空间的概念
进一步理解进程地址空间
为什么需要进程地址空间?
系统层面理解malloc/new内存申请
前言
首先,在我们学习C语言的时候一定会见过如下这张图。(没见过也没关系,接下来会介绍)
对于上述这张图,内核部分我们先不管,先介绍其他
命令行参数环境变量:在我上一期博客中已经详细说明了,有兴趣可以看看命令行参数/环境变量https://blog.csdn.net/m0_73904148/article/details/139305194
栈:用于存储临时变量或者局部对象这种出了作用域就销毁的数据
堆:这里的空间一般是通过申请来获得的,比较常用的地方是一些常见的数据结构、有些算法需要动态开辟空间,并且这个空间用完是要手动释放(c语言中)。
数据段:存储的是一些整个程序运行期间都要存在的数据,例如全局数据和静态数据
代码段:存储的主要是两种,一种是不能进行修改的数据,如只读常量等,一种是可执行代码,这种代码就是我们常说的二进制代码
接下来我会对上图分两个问题进行验证
问题1、验证地址大小:命令行参数/环境变量的地址 > 栈的地址 > 堆的地址 > 未初始化数据段 > 已初始化数据段 > 代码段
问题2、验证栈的地址是不是由高到低,堆的地址是否是由低到高
那么接下来就开始验证,如下为demo代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;//已初始化数据段
int g_unval; //未初始化数据段
int main(int argc,char* argv[],char* env[])
{
printf("code addr:%p\n",&main);//main实际上就是正文代码,在代码段
printf("init data addr:%p\n",&g_val);//已初始化数据段
printf("uninit data addr:%p\n",&g_unval);//未初始化数据段
char* heap = (char*)malloc(1);
printf("heap addr:%p\n",heap);//heap中的值就是堆的空间
printf("stack addr:%p\n",&heap);//heap是一个临时变量,这个变量是保存在栈上的
printf("command addr:%p\n",argv);//命令行参数
printf("environ addr:%p\n",env);//环境变量
return 0;
}
运行结果:
code addr:0x40057d
init data addr:0x60103c
uninit data addr:0x601044
heap addr:0x1946010
stack addr:0x7ffd2c06e298
command addr:0x7ffd2c06e388
environ addr:0x7ffd2c06e398
经测试,问题1所说的大小关系是和图上相符的
接下来要验证第二个问题,也就是验证栈的地址是不是由高到低,堆的地址是否是由低到高
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;//已初始化数据段
int g_unval; //未初始化数据段
int main()
{
char* heap1 = (char*)malloc(1);
char* heap2 = (char*)malloc(1);
char* heap3 = (char*)malloc(1);
char* heap4 = (char*)malloc(1);
char* heap5 = (char*)malloc(1);
//先验证堆
printf("heap1 addr:%p\n",heap1);
printf("heap2 addr:%p\n",heap2);
printf("heap3 addr:%p\n",heap3);
printf("heap4 addr:%p\n",heap4);
printf("heap5 addr:%p\n",heap5);
printf("\n\n");
//再验证栈
printf("stack1 addr:%p\n",&heap1);
printf("stack2 addr:%p\n",&heap2);
printf("stack3 addr:%p\n",&heap3);
printf("stack4 addr:%p\n",&heap4);
printf("stack5 addr:%p\n",&heap5);
return 0;
}
运行结果:
heap1 addr:0x18a2010
heap2 addr:0x18a2030
heap3 addr:0x18a2050
heap4 addr:0x18a2070
heap5 addr:0x18a2090
stack1 addr:0x7ffdf0646e98
stack2 addr:0x7ffdf0646e90
stack3 addr:0x7ffdf0646e88
stack4 addr:0x7ffdf0646e80
经测试,栈确实是从高地址向低地址递减,而堆确实是从低地址像高地址递增
最终得出结论:其实栈和堆是相对而生的
进程虚拟地址空间的引入
我们在前言说了一大堆关于地址空间的概念,那么我现在问一个问题,我们在前言说的所有的地址,是否是实际的内存地址呢?
对于这个问题的答案,我们必须通过实验才能知道
之前我们说过fork创建子进程后会和父进程共享一段代码,但虽然共享一段代码,但如果子进程对代码中的变量进行了修改以后,子进程会发生写实拷贝,所以接下来我们的设计思路就是让fork创建子进程,并且让父子进程读到同一个变量,然后子进程对这个变量进行修改,然后观察这个变量的地址,如下为demo代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
int val = 0;
if(id == 0)
{
//子进程
printf("child change before pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);
sleep(1);
val = 100;
printf("child change after pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);
}
else if(id < 0)exit(0);
else
{
//父进程
printf("father process pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);
sleep(5);
printf("father process pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);
}
return 0;
}
运行结果如图:
当修改val之前父子进程看到的是同一个val,且val=0,所以父子进程val的地址是一样的,这没问题
但我们惊奇的发现,子进程修改了val之后发生了写实拷贝,父子进程的val的值不同但地址却还是一样的
实际上如果我们看到的地址是物理内存地址的话,绝对不可能一个相同地址出现两个不同的值
所以我们得出结论,这个地址绝对不是物理内存地址
那么它是什么呢?实际上它是虚拟地址,我们也可以称呼它为线性地址
并且我们在语言中所使用到的地址全部都不是物理内存地址,而是虚拟地址
所以,我们在前面看到的那张地址空间图并不是物理内存,它叫做进程地址空间
进程地址空间的概念
我们先给出结论,之后再解释一些问题
首先,进程地址空间是每一个进程都有的,而在进程概念时我们说过每一个进程都有它所对应的PCB,进程地址空间就是保存在PCB当中的
而一个进程中所有对地址的操作都是先在进程地址空间给它一个地址的,但进程地址空间并不会实际存储变量,实际存储的是我们的物理内存,那么接下来就是如何通过虚拟地址找到物理内存中的地址
所以在Linux操作系统中,定义了一个表,这个表的功能是把虚拟地址映射到物理地址中,也就是如图这样
有了这个表以后,当用户想访问物理内存,那么用户就只需要拿到进程地址空间的地址给操作系统,然后操作系统去这个表里面找这个虚拟地址所对应的物理地址,找到了以后就取出这个物理内存地址之后就可以访问物理内存了
上面我说的都是一个进程的情况,那么假如有父子两个进程呢?也就是解释一下我们在引入进程地址空间时的问题
实际上我们的每一个进程都有PCB,父进程有,子进程有,而进程地址空间是PCB当中的一个字段,所以进程地址空间父进程有,子进程也有
当一个父进程创建子进程时,由于子进程会以父进程为模版,所以子进程除了会开辟一个新的与父进程一样的进程地址空间以外,那个映射的表被继承下来,所谓的继承也就是把映射表的地址给子进程
所以当父子进程定义同一个变量,而这个变量父子进程都没有进行重新写入时,它们的进程地址空间是一样的,映射的表是同一张,也就是如下这种情况
而当我们子进程对这个变量进行写入时,它不会直接通过这张表对物理内存直接写入,因为我们每个进程都是需要有独立性的,不能一个进程影响另一个进程,如果我们直接写入了,那么子进程的写入就会影响父进程。
所以操作系统会为子进程重新开一张映射表,并且在物理内存中开辟一个新的空间,新表的其他内容与旧表大致相同,而不同的是子进程中这个变量的虚拟地址所映射的物理内存改为了新开辟的这个空间,所以写入这个变量的时候也就变为了对新开辟空间的写入,也就是如下这样
但需要注意啊,新的映射表中的虚拟地址可是从没有发生改变的,写入之前是0x123456,写入以后也是如此,改变的仅仅是映射表中的物理地址。而我们说过所有语言中访问的地址都是虚拟地址,所以这也就解释了引入进程地址空间时我们说的那个问题:为什么父子进程访问同一个变量地址时是一样的但是值却不相同。并且在往期博客进程创建中我埋了一个坑,也就是fork的返回值为什么即等于0又大于0,实际上虽然从表面上看它的返回值既等于0又大于0,但本质上只是操作系统在物理内存中开辟了两个变量,一个等于0,一个大于0,仅此而已
进一步理解进程地址空间
什么是进程地址空间,带着这个问题,我们先讲个故事方便大家理解:
有一个富翁, 它有10亿的家产,这个富翁呢,有3个私生子,所谓私生子的意思就是它的3个儿子之间不知道彼此的存在。
有一天呢,这个富翁跑到小a这个私生子的面前,小a是一个法律系的博士,这个富翁就跟小a说,儿子你得好好加油争取早日毕业,然后找一个好工作,如果资金上有什么困难就跟我说,我转给你,我就只有你一个儿子,等我老了以后我的10亿家产都是你的!小a听了就很开心,因为他知道他以后会有10个亿
过了几天呢,这个富翁又跑到小b这个私生子面前,小b是一个医生,这个富翁就跟小b说,儿子你得好好工作,我就你这么一个儿子,那么等我老了以后我的十个亿家产都是你的,小b听了也很高兴,因为他知道他以后会有10个亿
不久后,小c这个私生子回家了,小c正在创业,这个富翁就跟小c说,儿子,不要有太大的心理负担,你创业的时候呢钱不钱的无所谓,重要的是提高自己的能力,需要持之以恒的学习,我只有你这么一个儿子,哪怕你创业失败我以后的10个亿家产都是你的!小c听了也很高兴,因为他知道他以后会有10个亿
有一天,小a突然打电话给这个大富翁说:爸,能不能转给我5000块钱,我没有生活费了
大富翁一听,立马转了过去
又过了几天,小b突然打电话给这个大富翁说:爸,能不能转给我100万,我觉得我现在医术比较高了,我想自己开一家诊所,大富翁一听当即把100万打过去了
过了不久,小c突然打电话给这个大富翁说:爸,能不能转给我500万,我最近创业资金有点周转不过来了,大富翁很爽快,立马把500万打过去了
过了几天,小c又打电话给这个大富翁说:爸,我又创业失败了,创业太累了,你能不能直接让我继承10个亿家产算了。大富翁一听一个嘴巴子就扇了过去,我现在身强力壮的你都开始惦记我的家产了,立马给他驳回了,小c虽然蛮委屈的,但也没多说什么
看了上述故事,接下来我要问一个问题,当小a、小b、小c跟大富翁要钱的时候他们是否认为自己以后有10亿呢?
显然是的,他们每个人都认为自己以后可以有10个亿。但大富翁以后真的会给他们每个人10个亿吗?显然不会,富翁自己都才10亿家产呢
那么大富翁这个行为我们日常生活中称之为什么呢?
其实本质就是大富翁给他的每一个私生子都画了一个大饼
但我画归我画,但你真要的话下场就是小c那样子
并且实际上大富翁真的给小a、小b、小c钱的时候,也就是给了他们一些饼渣子,这个饼渣子就不是画的,是实际给出去的
其实在上述故事中,大富翁对应的就是我们的操作系统,而小a、小b、小c对应的是一个一个的进程,而10个亿的家产就是我们的物理内存,而给每一个私生子画的那块饼就是进程地址空间
所以,我们所谓的进程地址空间,就是操作系统给每一个进程画的饼,你们每一个进程我都给你们画一个饼
操作系统给进程画了这么大的一块饼,操作系统跟进程说我就只有你一个进程,我有4G内存给你用,那么假如我现在没那么多内存,但是这个饼要兑现了的话怎么办
实际上,对于这种情况没关系的,操作系统说过段时间给你或者直接拒绝掉进程的请求就好了,进程也没啥办法
就好比小c要大富翁10个亿,大富翁直接拒绝了一样
实际上,画饼这个行为我们日常中也经常看到,就好比你们的老板,跟你们说,张三啊,你以后好好干,明年我给你加多少多少薪资,李四啊,你好好干,等我们公司好起来了让你做经理!一个老板可能给员工们画了各种各样不同的饼,那么接下来问题就来了
我们需要对饼做管理吗?
显然是要的,如果不对这些饼进行管理,就会出现老板跟张三说,张三啊,你好好干,明年我给你涨2000块钱工资,张三一脸懵逼的说,老板上次你不是说给我涨5000吗?
那么如何对这些饼做管理呢?
答案是先描述、后组织(这个在前面已经说过很多次了,不知道的可以看看管理这一篇博文)
而进程地址空间是操作系统给进程画的一张饼,所以它也会用先描述、后组织的方式进行管理
最终结论就来了,进程地址空间实际上就是数据结构,具体到每一个进程,就是数据结构的对象
所以,它的伪代码就如下
struct 进程地址空间
{
//进程地址空间的属性
struct 进程地址空间* next;
}
也就是说,所有的进程地址空间我们可以弄成一张链表,而每个进程的进程地址空间就是链表的结点 ,此时就由对进程地址空间的管理变为了链表的增删查改
当然,我们描述一个进程用的是PCB,那么我怎么知道哪个进程地址空间对于哪个进程呢?
所以,在我们进程的PCB当中一定会有一个指针指向进程所对应的进程地址空间,如下为伪代码
struct task_struct
{
//进程属性
struct 进程地址空间* xxx;
}
我们了解了上述内容之后,我们就需要了解一个新的内容,进程地址空间的属性是什么呢?进程地址空间中有栈、堆等,这些如何表示呢?
接下来,带着上述的问题,我再讲一个故事,方便理解
在我上小学的时候,那时候的女生一般比男生要大个,所以男生经常受女生欺负,而偏偏我那时候的同桌就是女生,所以我也没少被欺负QwQ,我那时候经常喜欢睡觉,由于我们那时候的课桌是连在一起的,所以睡着睡着整张课桌就被我占了,因为这个事还没少被我同桌骂,有一天,我来到学校,看到我的同桌在桌子上画了一条线,我问她这是干嘛的,她跟我说以后你不许过这条线,不然后果自负,我当时不以为然,在大课间的时候我又睡觉了,睡的正香着呢,突然我同桌就照着我后脑勺来了一下,怒气冲冲的说你越界了。我当时哗的一下就哭了。但我这个人吧哭是哭,但不长记性,又过了几天,我睡觉的时候又被我同桌打醒,原因还是我越界了,但她这次特别生气,她又重新画了一条线,这个线把我的活动范围压缩的更小了导致我只能勉强放的下一双手,她跟我说,下次你再越界,我就再重新画条线,到时候你手都别想放,这次以后,我终于长记性了。
我的同桌在画那条线的时候是在干嘛呢?
其实本质是进行区域划分
那么如何使用计算机语言描述一下我的同桌做的事情呢?如下为伪代码
struct desk_area
{
//我的区域
int myStart;
int myEnd;
//我的同桌的区域
int deskmateStart;
int deskmateEnd;
}
//如果要描述的话,我们如下这样描述
struct desk_area d = {1,50,51,100};//也就是把桌子区域划分为了[1,50][51,100]
那么接下来问题就来了,在上述例子中如果用算法进行判断的话怎么知道我越界了的?
实际上,我们可以把一张假设是1米长的桌子以厘米为单位分成100份,假设我同桌是在50这里画了条线,把我的区域分为[1,50],把她的区域划分为[50,100],如果我的手现在位于60,而60是在她的区域,那么就可以根据这点判断出我越界了
所以,只要有了区域划分,我们就可以判断是否越界行为
而我的同桌,在第二次非常生气的时候,把我的空间进行了缩小,这个本质上是什么呢?
实际上就是把她的区域进行扩大,而把我的区域进行缩小
转化为计算机语言实际上就是如下
struct desk_area
{
//我的区域
int myStart;
int myEnd;
//我的同桌的区域
int deskmateStart;
int deskmateEnd;
}
struct desk_area d = {1,50,51,100};//生气前[1,50][51,100]
d.myEnd-=20,d.deskmateStart-=20;//生气后[1,30][31,100]
所以最终结论为:
1.对区域进行划分后,我们可以判断是否越界
2.区域并不一定是相等的,可以对区域进行扩大与缩小
实际上,我们的进程地址空间也是通过我同桌的这种区域划分最终划分出来了一条一条的线,我们只需要规定出这些线,也就可以把进程地址空间进行区域划分出栈、堆等
所以,进程地址空间的属性当中一定会有整数划分出来的个个区域,也就是如下伪代码
struct 进程地址空间
{
//其他属性
int stackStart;
int stackEnd;
int heapStart;
int heapEnd;
//....
struct 进程地址空间* next;
}
而区域划分完以后实际上也就是在这个区域当中的空间你都能够使用,就好比我的同桌给我划分了[1,50]的区域,那么我此时把手放在30这个位置就是合法的,所以此时有一个临时变量,那么我就可以给这个变量赋予[stackStart,stackEnd]这个区域中的任意虚拟地址。虽然给了这个变量一个虚拟地址,但虚拟地址不具备保存代码和数据的能力,我们就需要通过映射表找到它的物理内存地址
而这个映射表我们一般称之为页表
在我们CPU当中有一个寄存器CR3,当进程调度CPU时会把自己的页表物理地址放到这个寄存器当中,当需要用到页表的时候,就可以根据这个寄存器中的地址找到页表
为什么需要进程地址空间?
上述,我们相信的讲了什么是进程地址空间,接下来要解决的问题是,为什么需要进程地址空间呢?为什么进程不直接访问物理内存呢?
首先,第一个理由:进程地址空间可以将物理内存从无序变为有序,让进程以统一的视角,看待内存
什么意思呢?
假设没有进程地址空间,进程直接访问物理内存的话那么可能就会出现一块空间别的进程已经开辟了,那么就得找一个新得内存,那么我们这个进程的变量可能分散到物理内存的各个地方,它们之间并不连续,此时操作系统如果要管理这个进程的话成本就会很大
而有了进程地址空间以后,进程只能看到虚拟地址,而这块进程地址空间是我这个进程自己私有的,所以就不会出现这块进程地址空间还被其他进程抢先开辟,那么此时在开辟空间时就可以连续开辟,也就是如下图这样
可以看到,哪怕abc是物理内存当中并不连续的空间,但进程看到的却是三个连续的空间
并且每一个进程都以这种统一的方式对自己的内存进行管理
实际上我们从上图中也能得出第二个理由,就是可以将进程自己的内存管理和物理内存管理进行解耦合
也就是说进程地址空间和物理内存之间不会互相影响,我甚至可以在申请完虚拟地址以后先不开辟物理内存空间,当要用的时候再开辟。并且哪怕我进程在申请完空间以后没有在实际的物理内存当中开辟空间,但在上层看来这个空间已经被开辟出来了
此时就方便了操作系统的设计!
第三个理由:地址空间+页表是保护内存安全的重要手段
什么意思呢?
举个例子,当我还小的时候,那时候过年大人会给我压岁钱,但这个钱一般是放在我妈那的,当我需要的时候我妈再给我。有一天,我想去商店里面买东西,于是我就跟我妈说我之前还有压岁钱在你那,我现在想买xx东西,你去帮我付款。
也就是说,商店和我之间有了新的一层:我的妈妈,那么为什么我的钱需要放到我妈那里,然后让我妈去付款呢?我直接拿钱去商店里买不就行了吗?
实际上,我把钱放在我妈那里重点不是说买东西的时候她给我付钱,而是当我买一些不好的东西的时候她有权利拦截我的请求,就比如我突然跟她说我想吃一包辣条,能不能帮我去买一包时,我妈就会拒绝我的请求。
同样的,进程地址空间和页表的组合也是如此,当你的访问是合法的时,你就可以映射出物理地址,但当你访问不合法的区域时,页表和进程地址空间就把你的映射请求给拒绝了,这样子就维护了内存安全。
所以,我们在语言层面的空指针和越界访问会导致该进程崩溃但不会影响其他进程的原因,因为你虽然进行了非法访问,但进程地址空间和页表就把你的请求拦下来了,影响的仅仅是你这个进程罢了
系统层面理解malloc/new内存申请
首先,先问一个问题,当你申请内存的时候,我们一定会立马使用这块空间吗?
实际上是可能会也可能不会,那么如果是可能会也可能不会立马使用这块空间的话,操作系统会怎么处理呢?
其实就好比当老板跟你说:张三啊,我这里设计了一个项目方案,但具体会不会采纳还不清楚。那么如果你是张三你会立马拿着这个项目方案去写代码吗?
实际上肯定不会的, 而你当然可以直接去写代码,但你开发这个代码是需要成本的,如果老板之后说这个项目没有采纳,那么你写代码就是无效操作。
而操作系统就是张三,用户就是老板
当操作系统拿到用户的申请空间的请求时,它不会直接在物理内存中开辟空间,而是在进程地址空间中先选出一个地址返回给用户,此时操作系统没有把这个虚拟地址映射到物理内存中,当用户对这个虚拟地址进行写入时,操作系统会先拦截用户的写入操作,然后在物理内存中开辟空间,开辟好之后把虚拟地址和物理地址进行映射,之后再对物理内存进行写入操作。而用户不关心你底层到底是不是malloc之后就开辟空间,还是怎么怎么样,反正我需要使用的时候你给我就完事了!
这么做不会导致程序运行较慢,因为当你使用这个空间的时候,开辟物理空间都是必须得,无非就是你先开辟和后开辟的区别
但带来的好处就是new和malloc的效率就得到了提升,并且充分保证了内存的使用率
而所谓的写入操作其实说的就是老板的项目方案已经被采纳了,这个项目确实需要使用,此时张三才会去写代码
而以上我们说的,当申请空间的时候先给虚拟地址,尝试进行写入时再开辟物理内存的方式我们称之为缺页中断