目录
一、C/C++普遍认知
1.1 空间布局图
C/C++程序员普遍认为代码编译后运行起来是遵守下图的空间布局的。
接下来我将使用代码来证明这个图片中各个区域的变量是否符合我们的预期。我从低地址的区域到高地址区域中的变量地址打印出了,通过下图我们可以发现,这些变量地址高低确实是从符合在上图中各个区域的画法。(字符常量被保存在代码区中)
其实除了上述的几个区域外,还有一个区是用来存储命令行参数和环境变量的
通过下面代码运行出来的结果来看,确实有一个区域用来存储环境变量与命令行参数,并且它们的地址都比栈区变量的地址要高。
1.2 堆栈相对而生
在学习C语言的时候,我们还知道堆栈相对而生,也就是在堆区中申请的空间,后申请的地址一定比先申请的高,在栈区中申请的空间,后申请的地址一定比先申请的低。通过下面代码的运行结果来看,确实符合我们学习到的知识。
int un_gval;int init_gval =520;intmain(){printf("code addr:%p\n",main);constchar* str ="Hello Linux";printf("character constant addr:%p\n",str);printf("init global value addr:%p\n",&init_gval);printf("uninit global value addr:%p\n",&un_gval);char* heap1 =(char*)malloc(100);char* heap2 =(char*)malloc(100);char* heap3 =(char*)malloc(100);char* heap4 =(char*)malloc(100);printf("heap addr:%p\n",heap1);printf("heap addr:%p\n",heap2);printf("heap addr:%p\n",heap3);printf("heap addr:%p\n",heap4);printf("stack addr:%p\n",&str);printf("stack addr:%p\n",&heap1);printf("stack addr:%p\n",&heap2);printf("stack addr:%p\n",&heap3);printf("stack addr:%p\n",&heap4);return0;}
1.3 栈区申请空间向下申请,访问时从访问空间的最小地址处向上访问
1.3.1 定义数组
当我们定义一个数组时,操作系统会为数组在栈区申请一个空间,我们也可以发现,数组0下标出的元素地址永远是数组中最小的,随着下标的增大,地址随之增大,并且取整个数组的地址与数组中下标为0的元素的地址相同,也是这个空间中最小的地址。
1.3.2 定义结构体变量
当我们定义一个结构体变量时,操作系统会为结构体变量在栈区申请一个空间,我们也可以发现结构体成员中第一个成员的地址永远是最小的,随着成员的顺序地址越来越大,并且取整个结构体的地址与结构体中第一个成员的地址相同,也是这个空间的最小地址。
1.3.3 定义内置类型变量
这里以整形int举例,一个int类型的变量的大小是4个字节,CPU在访问内存时,访存的基本单位是字节,当我们定义一个int类型的变量时,操作系统会在栈区为这个变量开辟一个4字节的连续空间,当我们想取出变量的地址时,取出的是连续空间中地址最小的。不止int类型是这样的,而是所有内置类型都是这样的,只是各个类型的变量大小有所不同。
1.3.4 小结
无论是上面的数组、结构体还是内置类型,当我们定义的时候,都会在栈区申请一个空间,每申请一个空间,这个空间相比于上一个空间的地址更小,并且取出它们的地址时,永远是这个空间最小的地址,但是当我们要访问这个空间的时候,需要从最小的地址慢慢向上访问。这就是栈区申请空间向下申请,访问时从访问空间的最小地址处向上访问。所以类型的本质就是起始地址+偏移量,可以用这样的方式访问任意变量。
二、进程地址空间
2.1 重新理解地址
那么上面的空间布局图到底是不是内存呢?我将从下面的这个程序开始讲述。
在下面的程序中,我定义了一个全局变量global_val,再让这个程序启动后创建一个子进程,让两个进程分别循环访问global_val,当循环一定的时间后,让子进程修改全局变量global_val的值,再让两个进程分别循环访问global_val,对程序的结果进行分析。
#include<stdio.h>#include<unistd.h>int global_val =123;// 定义一个全局变量intmain(){int id =fork();if(id ==0){// 子进程int count =3;while(1){printf("child: pid:%d ppid:%d global_val:%d &global_val:%p\n",getpid(),getppid(),global_val,&global_val);sleep(1);if(count ==0){
global_val =456;printf("child change global_val 123 -> 456\n");}
count--;}}else{// 父进程 while(1){printf("father: pid:%d ppid:%d global_val:%d &global_val:%p\n",getpid(),getppid(),global_val,&global_val);sleep(1);}}return0;}
接下来对程序结果进行分析,当程序刚刚运行起来后,可以发现子进程和父进程访问global_val的值与global_val的地址都相同,当过一段时间后,子进程和父进程访问global_val的值就改变了,因为父子进程是独立的,虽然父子进程共用代码,并且数据指向的是同一个位置,但是当其中的一方对数据进行修改时,操作系统就会对数据进行写实拷贝,让这个数据父子进程各一份,所以父子进程分别访问global_val的值会不同,但是我们还发现了,父子进程读取global_val的地址是相同的,而global_val的值确不同,这就很匪夷所思了。
到目前为止来说,我们发现同一个地址,却读出了不同的值,可以得到一个结论就是:在C/C++中看到的地址绝对不是物理地址。
其实在C/C++中看到的地址都是虚拟/线性地址。而上面的空间布局图也不是内存,它的名字叫做进程地址空间。
在计算机中,任何的变量和数据都必须在内存里,这是冯诺依曼体系规定的,所以物理内存必须存在。
每一个进程运行起来后,都会有一个自己的进程地址空间,进程的PCB中有一个字段是用来指向进程地址空间的,需要注意的是进程地址空间是没有保存数据的能力的,所以进程中代码和数据需要保存在物理内存中的。
进程地址空间中的地址是虚拟地址,物理内存中的地址是物理地址,我们在访问数据的时候就存在两个地址,而进程地址空间中并不能存储数据,所以就需要通过虚拟地址来找到物理地址。
操作系统为每个进程能够找到自己的数据,操作系统会为每一个进程构建一张页表(映射表),页表中存储了每个虚拟地址与物理地址的对应关系,进程就可以通过虚拟地址找到物理地址,从而访问数据。
接下来我将要通过进程地址空间与页表来讲述,为什么同一个地址却访问出了不同的数据。
我们都知道每个进程是独立的,我们通过父进程来创建子进程,子进程就需要有自己的PCB、进程地址空间与页表,而这些都需要以父进程为模版,创建PCB时大部分内容与父进程相同,小部分内容需要个性化,但是以父进程为模版创建进程地址空间与页表时,父子进程的进程地址空间和页表的内容在初始情况下是一模一样的,虽然父子进程的进程地址空间不同,但是内容相同,父进程可以通过虚拟地址找到物理地址从而得到数据,子进程也可以通过相同的映射关系找到数据,这就是为什么父进程创造子进程后,两个进程读到的数据和数据的地址都是相同的原因。
当子进程需要对数据进行修改时,操作系统会为该数据进行写时拷贝,在物理内存中重新申请一个空间,新申请的空间就有了新的物理地址,再将子进程中页表的映射关系进行修改,最后子进程再对新空间的内容进行修改,但是需要注意页表中的映射关系只是修改虚拟地址的映射关系,也就是修改物理地址,所以在后续父子进程读取数据时,它们的虚拟地址是相同的,但是映射出的物理地址却不相同,读取出来的数据也就不同了,这就解释了为什么同一个地址却能读取到不同的数据。
前面在fork中也讲到过,将fork函数的两个返回值给一个int类型的变量id,在语言层面来说一个变量不能有两个值,而在系统层面上,这两个值分别在父子进程的进程地址空间中,所以两个进程分别读取id时,数据不同但是地址相同的原因。
2.2 什么是进程地址空间
什么是进程地址空间,我将以一个故事开始讲述:
在国外有一个富豪,他有4个私生孩子,并且这四个孩子互相不知道其他孩子的存在,富豪有100亿的资产,有一天富豪分别对孩子说等他去世了,孩子就能继承他的全部财产。
在后面的日子中,私生子1对富豪说自己需要100万资金来周转工厂,富豪给了私生子1。私生子2对富豪说自己需要500万来买车子,富豪也给了私生子2。私生女1对富豪说自己需要150万去全球旅行,富豪也给了私生女1。私生女2对富豪说自己需要50万买相机、玩人机以及各种镜头来拍照片,富豪也给了私生女2。
这四个孩子都不会向富豪索要100亿,就算要了富豪也不会给,毕竟富豪还没走,但四个孩子在潜意识中都认为自己有100亿,我们从上帝视角来看,这四个孩子最多平分到25亿,所以这100亿就是富豪对他的四个孩子画的大饼,在往后的日子中,只要孩子们不提出太过分的要求,富豪都会满足他们。
到这里,我将要使用Linux中的角色来代替这个故事中的角色了,在故事中的富豪就是操作系统,富豪的资产就是物理内存,富豪的孩子就是进程,而富豪的许诺(画的大饼)就是进程地址空间,每个进程都知道操作系统中的内存有多大,但是自己却不能使用全部的内存。
每个进程都有属于它自己的进程地址空间,为了防止进程地址空间之间混乱了,操作系统就需要对操作地址空间进行管理,就需要对进程地址空间先描述再组织,所以进程地址空间最终一定是一个内核的数据结构对象,也就是一个内核结构体。在Linux操作系统中进程地址空间是一个结构体
struct mm_struct{ //.. }
,在进程的PCB中有一个指针是用来指向进程地址空间的。
2.3 什么是区域划分
什么是区域划分,我也将以一个故事开始讲述:
在学校中有一对同桌分别是小A与小B,小A与小B的桌子总长为100cm,在日常的生活中小B认为小A总是占自己桌子的位置,所以小B为两人画了一个三八线,小A桌子范围是0 ~ 50,小B桌子的范围是50 ~ 100,在这里小B画三八线的本质就是区域划分。
接下来通过计算机语言来描述一下小B的行为:
structarea{int total_size;// 桌子的总长度int xiaoA_start;// 小A的起始位置int xiaoA_end;// 小A的终止位置int xiaoB_start;// 小B的起始位置int xiaoB_end;// 小B的终止位置};// struct area AREA={100,0,50,50,100};
当小B画完三八线过了一段时间后,发现小A还是经常占用自己的位置,小B就生气了,重新画了一个三八线,小A桌子范围是0 ~ 45,小B桌子的范围是45 ~ 100。小B重新画三八线的本质就叫做区域调整,在进程地址空间中讲到过堆栈相对而生,本质上就是区域调整。
通过计算机语言来描述一下小B的行为:
AREA.xiaoA_end =45;
AREA.xiaoB_start =45;
到这里,将故事中的桌子范围切换为系统中的空间,当划分完空间范围后就不要只想到范围,范围内的每一个值都是一个地址,每一个地址都是能够使用的。
总结
- 设置int或long类型的变量(start与end)对线性空间设置开始和结束就是区域划分,也就是将线性空间拆分为多个细小的范围,需要区域调整时,只需要调整start与end即可。
- 当划分完空间范围后就只想到范围,范围内的每个地址都能够使用。
2.4 为什么要有进程地址空间
2.4.1 将乱序的的内存空间变得有序
每个进程都有属于自己的PCB、进程地址空间和页表,进程地址空间中虚拟地址可以通过页表映射到内存中的物理地址,进程地址空间中很多区域,区域中的数据与代码都是需要在内存中的,这些数据与代码都是通过可执行程序加载进来的,而程序的代码数据是可以加载到内存的任意位置的,我们并不能保证代码与各种各样的数据谁在高地址谁在低地址,但是有进程地址空间与页表以后,进程就不关心代码与数据在内存的什么位置,进程只会看到进程地址空间内的各种区域,而内存中的代码和数据则可以通过页表与进程地址空间进行映射。
进程地址空间能够让统一的视角来看待内存,所以任意一个进程都可以通过进程地址空间与页表将乱序的的内存空间变得有序,分门别类的规划好。
2.4.2 有效的进行进程访问内存的安全检查
页表中除了虚拟地址与物理地址的映射关系意外,还存在一个访问权限字段,这里从一个故事进行展开,一个小孩过年有很多压岁钱,但是妈妈却对小孩说,钱我替你保存起来,将来你想要什么东西了找妈妈要,小孩也没办法只有将钱给妈妈,后面小孩对妈妈说我要买书,妈妈给了小孩钱去买书,再后来小孩对妈妈说我要买游戏机,这时候妈妈说不行学习最重要。
明明小孩可以不将钱给妈妈,想买游戏机就买,而现在妈妈却不让买游戏机了,这是因为妈妈认为游戏机对小孩有害,不让小孩去买。
在程序中也有这种情况,以下面这段代码为例,str指向的字符串在字符常量区中,而
*str = 'h'
则是想将字符常量区中的 ‘H’ 修改为 ‘h’ ,我们都知道运行程序后程序会崩溃,这是为什么呢?大家学到过,字符常量区在代码区中,代码区中的代码与数据无法被修改,所以字符常量无法被修改。但是如果字符常量无法被修改,那么字符常量是如何被写入到内存中的呢?其实在内存中,内存的任何位置都能被修改,但是页表中有一个访问权限字段,进程需要修改字符常量时,会通过虚拟地址映射到物理地址,但是对应的访问权限字段的权限是只读,会导致对应的位置无法被修改,最终导致程序崩溃,代码区无法被修改也是同样的道理。
#include<stdio.h>intmain(){char* str ="Hello Linux\n";*str ='h';return0;}
所以,存在进程地址空间能够有效的进行进程访问内存的安全检查,不仅保护了程序的代码和数据不被非法修改,还防止了恶意程序或错误代码对系统资源的滥用。
2.4.3 实现了进程管理与内存管理在操作系统层面上的解耦
每个进程都有属于自己的PCB、进程地址空间和页表,PCB中有一个指针是用来指向进程地址空间的,那么如何保证进程与页表之间相互对应呢?
进程进行各种转换(虚拟地址映射物理地址),各种访问内存时,那么这个进程一定是正在运行的。
CPU中有一个寄存器CR3是用来存储页表的地址的,一个进正在CPU上运行,那么寄存器CR3中的页表地址就是来自于进程的硬件上下文中的,当进程的时间片结束后需要切换进程,那么寄存器CR3中页表的数据需要保存到进程的硬件上下文中。
之前我们讲到过进程切换,就是将进程的PCB进行切换,但是现在进程又有了进程地址空间与页表,进程切换的操作就被改变了呢?其实不然,进程完整的切换需要将进程的PCB、进程地址空间和页表进行切换,但是进程的PCB存储了进程地址空间的地址,并且进程的硬件上下文也与PCB相关,切换了PCB就是切换了进程的PCB、进程地址空间和页表。
注意:寄存器CR3存储的地址是物理地址。
在我们的生活中有很多程序是非常大的,甚至是内存大小的很多倍,但是这些程序依然可以运行,这是因为加载一个程序到内存中的时候,并不需要将整个程序加载到内存中,可以先加载程序的一部分。
在我们学习进程状态的时候,直到操作系统中有一个挂起状态,但是在Linux操作系统中好像没有类似的状态,那么Linux操作系统是如何体现挂起状态的呢?
在页表中还有一个字段使用两个0/1用来代表是否为虚拟地址分配对应的物理地址与对应物理地址中是否有内容,第一个0/1代表是否为虚拟地址分配对应的物理地址,第二个0/1代表对应物理地址中是否有内容,当进程查询页表时,发现绝大多数虚拟地址未分配对应的物理地址并且无内容的情况下,该进程就是挂起状态。
当有一个虚拟地址将要被访问了,但是它没有分配它对应的物理地址,操作系统会暂停访问请求,在物理内存中申请一个空间,将进程需要访问的代码加载到内存中,再填充修改的页表,最后再继续访问请求,这个过程叫做缺页中断。
进程管理是操作系统中负责创建、调度、监控、终止进程以及处理它们之间交互的一系列功能和机制的集合,内存管理是操作系统中负责分配、回收、保护和优化内存资源的一系列功能和机制的集合,有了进程地址空间与页表的存在,实现了进程管理与内存管理在操作系统层面上的解耦。
2.4.4 实现进程的独立性
我们在学习进程的时候知道了进程具有独立性,那么独立性是如何体现的呢?
进程 = 内核数据结构 + 进程的代码和数据,每个进程有自己的PCB、进程地址空间和页表,每个进程的内核数据结构都是不同的,有了进程地址空间与页表的存在,虚拟地址可以通过页表映射到物理内存中,每个进程的虚拟地址可能相同,映射到物理地址时不相同,那么进程的代码和数据就不相同,一个进程挂了只影响自己,所以通过页表让进程映射到不同的物理内存,从而实现进程的独立性。
2.5 进程地址空间的区域划分
在32位下,进程地址空间为4G,其中只有3G是供用户使用叫做用户空间,还有1G是供操作系统使用的叫做内核空间。
在进程地址空间中还存在一个区域叫做共享区,我们在上面测试过各个区域中地址,发现栈区与堆区中有很大一个间隔,中间就存在一个共享区。在未来进程地址空间时,我们不仅仅想使用代码区、数据区、堆区、栈区,也想想堆区和栈区间的空间也好好使用,例如我们想在共享区或其他空余区域中来划分出一个区域,但是在进程地址空间(mm_struct)中并没有发现更多能够划分区域的字段。
在mm_struct中,不仅仅有各个区域的start与end划分空间,还有一个指针
vm_area_struct* mmap
,我们通过对结构体vm_area_struct的查看,发现该结构体中也有start与end字段,其实vm_area_struct结构体出现就说明了还可以划分更多的子区域。
当我们想划分多个子区域时,就可以创建多个vm_area_struct对象,并用指针将对象们连接起来,就可以形成一个子区域划分的线性链表结构,将mm_struct(内存描述符)与vm_area_struct(线性空间)结合起来就是真正的进程地址空间。
结尾
如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹
版权归原作者 是阿建吖! 所有, 如有侵权,请联系我们删除。