linux入门---程序地址空间

之前学习的地址空间

在之前的学习中我们知道操作系统将内存划分为好几个区域,比如说栈区,堆区,未初始化区,已初始化区,代码区,每个区的大小不同所对应的功能也不同,并且在内存中每个字节大小的空间都有一个属于自己的地址,通过这个地址我们就可以访问到内存中具体某个位置所记载的内容,地址从0x00000000开始一直到0xFFFFFFFF结束所以在内存中又有高地址和低地址之分,那这里我们就可以将内存画成这样的形式:
在这里插入图片描述
其中堆区和栈区的空间可以随着程序的改变而发生相应的改变,栈区向着低地址的方向生长,堆区向着高地址的方向生长,而未初始化区已初始化区代码区的大小则不会随着程序的变化而发生改变,那么这就是我们之前学的内存地址空间的分布,可是这里就有一个问题我们上面所说的内存指的是装在电脑上的那个内存吗?再或者说我们在程序中使用的取地址操作符取的是内存中的地址吗?如果真的是内存中的地址的话,那么这个地址所对应的内容就一定是一个固定值,不可能出现第一次查看是一个值紧接着第二次查看就成为了其他值的现象,比如说下面的代码:

 include<stdio.h>
 #include<stdlib.h>
#include<string.h>
 int main()
{
     char a ='a';
     char*pa=&a;
     printf("变量a的地址为:%p\n",pa);
     printf("地址pa所对应的内容为:%d\n",*pa);                                                                                     
     return 0;                                                                                                               
 }             

这里采用的是64位的编码方式所以打印出来的地址由12个数字组成,这段代码运行的结果如下:
在这里插入图片描述

通过取地址操作符能够取到变量a的地址,并且多次运行该程序pa所对应的内容也没有发生变化,那这能不能说明平时在程序中使用的地址就是物理内存上的地址呢?答案是不行的我们来看看下面这段代码:

  1 #include<stdio.h>
  2 #include<stdlib.h>
  3 #include<string.h>
  4 #include<unistd.h>
  5 #include<sys/types.h>
  6 int val=520;
  7 int main()
  8 {
  9     int i=0;
 10     pid_t id= fork();
 11     if(id<0)
 12     {
 13         printf("创建子进程失败\n");
 14     }
 15     else if(id!=0)
 16     {
 17         while(i<=4)
 18         {   
 19             printf("我是一个父进程,pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);
 20             ++i;
 21             sleep(1);
 22         }
 23     }
 24     else 
 25     {   
 26         while(i<=4)
 27         {   
 28         if(i!=3)
 29         {                                                                                                                           
 30             printf("我是一个子程,pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);        
 31         }    
 32         else     
 33         {        
 34             val =5201314;    
  			    printf("我是一个子程,pid:%d,ppid:%d,val=%d,&val=%p\n",getpid(),getppid(),val,&val);        
 36         }
 37         sleep(1);
 38         ++i;
 39         }
 40     }
 41     return 0;
 42 }

我们来看看这段代码的运行结果
在这里插入图片描述
这段代码通过循环语句不停的打印全局变量val的值,在子进程中第4次循环改变val的值,修改完之后父进程和子进程打印出来变量的值是不同的这个我们可以理解因为进程具有独立性,可是这里父子进程在读取该变量地址的时候为什么会是一样的呢?我们上面说物理内存中同一个地址所对应的内容是相同的,但是这里不同的进程读取内存上同一个位置的内容却是不相同的,那么这就说明进程使用的内存绝对不是物理内存,我们平时学的语言上所使用的基本地址(指针)也不是物理地质而是虚拟地址(也可以称为逻辑地址,线性地址),使用printf函数打印出来的地址是虚拟地址,那我们使用printf函数将所有地址空间排布全部都打印出来,那么这不就是构成虚拟地址空间了嘛,好看到了这里大家初次见到了虚拟内存那接下来我们就来看看虚拟内存是什么?

举例了解什么是虚拟地址

首先系统中的每个进程都会认为自己是独占系统资源的,比如说cpu资源内存资源等等,因为进程向操作系统要各种资源的时候操作系统都会爽快的提供给这个进程,所以每个进程都会认为自己是独占整个系统资源的,并且操作系统为了让进程怀疑有其他人会跟他抢夺资源,他还会给进程画一个大饼来告诉它我所有的东西都是你的,那么这个大饼就是虚拟地质空间。看到这里想必大家还是不大懂什么是虚拟地址空间,那么接下来我来给大家讲个故事来理解理解,在美国有很多的亿万富翁,这些富翁的私生活一般都十分的乱会有很多的私生子,比如说大富翁A有3个私生子分别为B C D,并且每个孩子都十分的优秀,A为了不影响他们的前程就没有告诉他们,你们其实是私生子你们还有个同父异母的哥哥弟弟啥的,所以这些儿子之间互相是不知道不认识的,儿子B是一个中型工厂的老板,儿子C是一个律师事务所的CEO,儿子D是一个顶级院校的学生,那么大富翁A为了激励他的三个儿子更加努力的工作就一个一个的跟他们说:儿子你要更加努力啊等你以后取得更大的成就的时候我把我的全部家产给你,这家产可有10亿美元啊,但是你要是不努力想要躺平的话这10个亿你可一个都没有的啊我全拿去捐给慈善机构。
在这里插入图片描述

三个儿子听到父亲这么跟他每个人都干劲十足疯狂的工作和学习,因为他们知道只要他们不停的努力就能获得父亲的全部遗产价值10个亿,但是努力的过程并不是一帆风顺,有一天儿子B经营的工厂出现了债务危机急需500w美元来进行资金周转,可是儿子B身上又没有那么多钱,于是儿子B就找他的富翁父亲要钱但是儿子B会一口气要10个亿吗?他会跟他父亲说:爸爸反正这钱最终都是要全部给我的你要不现在全部给我得了我最近刚好遇到了经济流转问题吗?肯定是不会的,因为儿子B知道他要是这么跟他爸爸说那他到最后肯定是一分钱没有的,所以就算他的父亲非常有钱,就算他的父亲答应他我死后把所有的家产全部给你,但是他现在找他爸爸要钱也不能全部都要过来而是一次要一点比如说这次给你500w解决资金流转问题,下次再给你50w拿去解决其他问题等等,其他的儿子也是一样虽然他们都知道到最后这些钱肯定都是我的,但是他们一次也只敢找父亲要一点钱,比如说儿子C找父亲要100w美元开个律师事务所的分店,儿子C找父亲要个2w美元报个课外补习班之类的但是他们绝对不敢一口气找父亲要10亿美元,就好比你使用malloc函数绝对不会一口气申请16G大小的空间内存一样,那么这里的大富翁父亲就是操作系统,父亲的10亿美元家产就是计算机中的内存,三个互不相识的儿子就是内存中相互独立的子进程,而给每个儿子画的10亿美元家产大饼就相当于操作系统给每个进程的虚拟地址空间,看到这里我们似乎知道了虚拟地质空间是什么,那么接下来我们就要聊聊操作系统是如何画大饼的。
在这里插入图片描述

进程地址空间的本质

一个公司里面一般都会有很多的员工,为了让这些员工能够更好的被公司压榨公司的领导层一般都会给这些员工画很多的大饼,比如说给销售部的员工画饼说你们好好干在多少个月以后给你们升职成为销售经理,让你的工资有多少多少,让你一个月休多少多少天的假,让你每天只工作多少多少天等等,给技术部门的成员画饼说你现在好好干在多少个月以后给你升职为技术经理,让你工资多少多少,让你的补贴怎么怎么样等等,那这里的每个员工就相当于内存中的每个进程,公司领导给你画的升职大饼就相当于操作系统给进程的虚拟地址空间,那这里就有个问题公司画的大饼需不需要管理,如果不管理的话会不会出错,比如说昨天跟销售部门的员工说1年后给你升职成为销售部经理,后天就变成一个月后让你成为技术总监,如果真这么做的话,那么这个大饼不就一下字被员工识破了吗对吧,所以给员工画的大饼是要被记住要被管理起来的,同样的道理操作系统给进程画的进程地址空间也是要被管理起来的,管理的本质是对数据进行管理,管理的方法就是先描述再组织,那如何描述虚拟地址空间呢?这个大家可能有点不大好想我们来换一个思路如何来描述领导给员工画的大饼呢?是不是首先得告诉员工下一个职位是啥,还得告诉员工什么时候能晋升到这个职位,工资是多少,每个星期休息几天,那么描述这个大饼的结构体是不是就得包含这几个信息啊比如说下面的图片:
在这里插入图片描述
我们给进程画的大饼是虚拟地址空间,那描述这个大饼的结构体是不是就得包含虚拟地址空间里面的内容啊,比如说栈区是什么范围,堆区是什么范围,未初始化空间又是什么范围等等,所以这里就又出现了一个问题如何描述内存上的一个空间呢?方法也很简单我们把这个内存看成一条直线那么地址就是这条直线上的点,如果我们想描述一段空间的话是不是只用确定两个点就可以了对吧比如说下面的图片:
在这里插入图片描述

那这里就使用两个指针变量用来表示一段空间,一个指针变量表示空间的开始一个指针变量表示空间的结束,如果通过这种方式来描述一整个虚拟地址空间的话是不是就得在结构体中创建多个指针变量啊比如说下面的代码:

struct mm_struct
{
	uint32_t code_start,code_end;
	uint32_t date_start,date_end;
	uint32_t heap_start,heap_end;
	uint32_t stack_start,stack_end;
}

所以地址空间的本质上就是内核的一种数据结构mm_struct,定义局部变量,malloc,new堆空间会扩大栈区或者堆区,函数调用完毕,free申请的动态空间使得栈区或者堆区变小,实际上就是调整该进程对应的mm-struct对象里面区间指针的值使得两个指针维护的空间变大或者变小,每当加载一个程序到内存的时候操作系统就会发一个mm_struct结构体对象给这个进程,这个mm_struct对象就是虚拟地址空间。其中操作系统为了能够更快的找到给每个进程分配的虚拟地址空间,就会在每个进程对应的PCB中放置一个指向mm_strcut结构体对象的指针,虚拟地址空间的本质就是一个mm_struct内核数据结构,该结构维护着虚拟地址空间。
在这里插入图片描述

虚拟地址空间和物理内存的转换

当一个程序被加载进内存时操作系统会给这个进程分发一个虚拟地址空间,但是进程在内存上肯定是有实际空间的,当cpu要执行某个程序的指令时肯定得在物理内存上查找这个该进程对应的代码和数据,所以这个过程中肯定存在虚拟地址空间和物理内存的转换问题,那么接下来我们就来看看虚拟内存和实际内存是如何转换的。要执行某个程序肯定得把这个程序加载进内存里面
在这里插入图片描述
程序加载进内存之后会有对应的起始地址和该程序对应的大小,所以当操作系统想访问该程序的数据时就可以直接根据程序在内存上的起始地址加上对应的字节的大小来访问到对应位置的数据,比如说起始位置加1个字节就是访问的第一个字节的数据,加2个字节就是访问第二个字节的数据。当一个程序加载进内存之后操作系统为了管理这个进程就会创建对应的PCB并且还会给这个进程分配一个虚拟地址空间,PCB可以通过内部的指针找到该程序所对应的虚拟地址空间
在这里插入图片描述
左侧是操作系统的内核数据结构右侧是硬件,所以操作系统为了能够通过内核数据结构找到硬件上的内容就提出了页表这个概念
在这里插入图片描述
程序A加载进内存的地址为0x11223344,在虚拟地址空间上的地址为0x55667788,那么页表的左侧就会记录A在虚拟地址空间上的地址,页表的右侧就会记录A在物理内存上的地址
在这里插入图片描述
我们平时在程序中创建一个变量然后在取这个变量的地址取得就是这个变量在虚拟内存上对应得地址:

int i =10int *p =&i;//这里取得是变量i在虚拟内存上对应得地址
printf("%p",p);

当程序想要通过虚拟地址访问变量内容时,就要先找到这个地址在页表上的位置,然后再根据页表找到虚拟地址所对应的物理地址,然后再通过这个物理地址找到内存中具体的内容。PCB找到虚拟地址空间,虚拟地址空间找到页表,最后通过页表找到数据内存的位置,上面所述的全部过程都是操作系统帮我们做的我们程序员只用将程序写好将其加载到内存即可:
在这里插入图片描述
进程地址空间上的地址从全0到全1都是按正常的顺序进行排布的,1 2 3 4 5一直到42亿多全部都是连续的地址,所以在很多的文档和教材里面又把虚拟地址叫做线性地址。

为什么会存在虚拟地址空间

保护内存上的数据

我们先来想象一下没有地址空间的场景,要是没有地址空间的话进程就可以通过PCB直接访问到物理内存上的内容,那万一越界非法访问了怎么办?万一系统中存在着恶意进程怎么办?比如说内存中的某个空间存储着用户重要的密码,但是一个恶意程序却能直接访问到该空间并修改这里的密码话是不是就非常的不安全啊,所以就有了虚拟地址空间,进程还是认为自己拥有着整个内存,内存上的每个位置还是能够被自己所使用,进程要是合法安全的访问或修改内存上的内容话那一切安好页表的映射正常运行,如果进程一旦非法访问了,那么这个访问是得经过页表的,页表在映射的时候是会进行检查的,一旦非法访问那么这个访问就会在页表映射的时候被拦截下来,所以虚拟地址空间变相的就保护了内存数据的安全,就好比小时候过年长辈们给晚辈发压岁钱,这些压岁钱我们可以随便花但是这些钱一般都是被家长保管的,每次花钱的时候都得向家长提出申请,你要是拿这钱买书买吃的买文具那一般都是允许的,但是你要是拿这钱去买一个游戏机买一把杀伤力十足的仿真枪那家长一定是不允许的,那么这里的钱就相当于内存,我们小孩子就相当于一个一个的进程,我们向父母提出申请拿钱买东西就相当于进程向页表提出申请访问内存上的某个数据,虽然不是所有的小孩子的压岁钱都要被父母管理,但是系统中的每个进程都得遵守页表映射的规则,那么这就是虚拟地址空间存在的一个重要性,他可以保护我们内存上的数据不被恶意进程任意破坏。

维护进程的独立性

我们上面写一段代码这段代码的运行结果让我们十分的差异,访问同一个地址却能够打印出来两个不一样的值,之所以会出现这样的现象就是因为虚拟地址空间存在,一开始父进程可以通过虚拟地址空间访问到内存上的一个数据变量在虚拟内存上的地址为:0x11223344,该变量在内存上的地址为0x55667788,那么在父进程页表的左边就是0x11223344,页表的右边就是0x55667788比如说下面的图片:

在这里插入图片描述
同样的道理子进程也是一个进程,所以子进程也有对应的内存地址空间,页表和PCB,由于子进程是由父进程创建的,所谓的创建就是将父进程的PCB拷贝给子进程,将父进程的页表拷贝给子进程,将父进程的虚拟地址空间也拷贝给进程,所以此时的图片也就变成了这样:
在这里插入图片描述所以子进程在打印myval的值和地址时会跟父进程打印的结果一摸一样,当子进程想要改变这个全局变量的值时因为进程具有独立性,如果一个进程对一个数据进行修改会影响另外一个进程的话,那么这就不能称之为独立性,所以操作系统为了保证进程的独立性,当子进程或者父进程有一方想要对共享数组进行写入的话,操作系统就会在内存上面重新开辟一段空间然后将原来的内容拷贝到新的空间里面去,并且修改页表的映射关系,比如说子进程要是想修改myval的话就会操作系统就会再开辟一个空间,将这个空间里面的内容覆盖成myval并修改子进程页表的映射使其指向内存上的新空间,
在这里插入图片描述

这里大家要注意的一点就是当操作系统因为进程的独立性而要开辟一个新空间时会将页表的内容进行修改,但是这里修改的是页表指向内存的那一部分,指向虚拟空间的那部分是不会修改的,比如说父进程在虚拟空间上的0x11223344指向的是内存上的0x55667788,而子进程在虚拟空间上的0x11223344指向的却是内存上的0x00112233,所以这就导致了程序上面运行的结果是取得同一个地址但是这个地址里面装的两个不同的值,取的是虚拟地址,打印的却是内存上的物理地址里面的内容。当任意一个进程尝试修改一个变量的值时,操作系统会先进行数据拷贝,更改页表映射,然后再让进程进行修改,那么我们把这种行为称之为写时拷贝,而上面的写时拷贝就是操作系统干的,操作系统为了保证进程的独立性,做了很多工作,其中之一就是通过虚拟地址空间,通过页表让不同的进程映射到不同的物理内存处,每个进程都有独立的内核数据结构(PCB,页表,虚拟地址空间),在数据层面又会通过写时拷贝的方法将不同进程的数据进行分离,所以进程的内核数据结构与是独立的,进程的数据也是独立的,因为父子进程的代码不会被修改所以是共享,但是两个好不相关的进程的代码一定是分开的,进程等于内核数据结构+进程对应的代码和数据,加号的两边都是独立的所以进程具有独立性,那么这就是虚拟地址空间的一大好处,地址空间的存在,可以更加方便的进行进程和进程之间的数据代码的解耦,保证了进程独立性这样的特征。

以统一的视角看到内存和硬盘

首先大家要知道的一点就是我们写的可执行程序就算没有加载进内存在硬盘上也是有地址的,这里的地址不是磁盘上的地址而是程序内部的地址,比如说某个函数所对应的地址某个全局变量所对应的地址,那我们如何来证明这一点呢?很简单我们之前在调试代码的时候讲过汇编,通过汇编指令我们可以看见调用函数的地址,
在这里插入图片描述

我们之前也学过翻译的过程,生成一个可执行程序之前得经过预处理,汇编,编译,链接,然后才生成一个可执行程序并加载进内存执行内部的指令,但是我们在汇编期间就发现了这个代码中存在地址,所以当一个程序在磁盘中时就已经存在地址了,那么这里的地址就是逻辑地址,虚拟地址空间,这里的地址是为了方便程序之间的跳转,大家不要以为只有操作系统会遵守对应的规则,在磁盘中的程序也会遵循对应的规则,编译器在编译你的代码的时候,就是按照虚拟地址空间的方式对我们的代码和数据进行编址的,所以一个程序在操作系统和磁盘中遵循着同一个地址规则,所以程序在磁盘中的虚拟地址和在操作系统中的虚拟地址是一样的,比如说下面这段程序:

int a =10;
func()
{
    //对应功能
}
int main()
{
	func();
	return 0;
}

当这段代码在磁盘中时变量a可能就存在着对应的地址比如说0x11223344同样的道理func函数,main函数在磁盘中也会有自己的地址0x22334455,0x33445566
在这里插入图片描述
当我们将磁盘中的程序加载进内存时,这个程序中的函数或者全局变量在内存中也会存在着物理地址
在这里插入图片描述

所以此时内存中的进程就会有两个地址,一个是在程序内部方便跳转时的虚拟地址,一个是在内存中的物理地址,当程序加载进内存的时候操作系统会为这个进程创建PCB和虚拟地址空间,由于操作系统中的虚拟地址空间和磁盘中的虚拟地址空间遵循的是同一个规则,所以操作系统就会拿着磁盘上的地址直接填入操作系统的虚拟地址空间,比如说哪里到哪里是栈区,哪里到哪里是堆区等等

在这里插入图片描述

为了方便虚拟地址空间和内存上的地址相互访问操作系统就会创建一个页表,在页表的左右两端记录着双方的地址,比如说页表的第一行左边记录的是虚拟内存中变量a的地址,那么这一行的右边就记录的是物理内存中变量a的地址:
在这里插入图片描述
当cpu开始执行这段程序时,cpu会首先通过PCB找到虚拟地址空间中main函数的地址,然后再通过页表找到物理地址,最后再执行内存上的main函数里面的内容,这时main函数里面会调用func函数虽然cpu不知道func函数在哪里的,但是内存中存在着func函数的虚拟地址,所以操作系统就将func函数的虚拟地址加载进cpu里面,然后cpu再使用该地址通过页表映射找到该函数在物理内存上的位置,在调用func函数的时候可能会用到全局变量a,变量a也存在着自己的虚拟地址,所以cpu在执行与变量a有关的指令时又会将a的虚拟地址加载进cpu,cpu又会使用该地址通过页表找到这个地址在内存上的内容,所以程序在整个运行的过程中完全见不着物理地址,这里进程地址空间的最后一个重要性就是为了让进程以统一的视角来看待进程对应的代码和数据的各个区域,方便编译器也以统一的视角来进行编码。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

叶超凡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值