程序地址空间
之前在学习C语言期间,经常听到提及这些区域,我问大家一个问题:这里的地址空间是内存嘛?答案:并不是,这里的地址空间是进程地址空间(后面详细讲)。
我们通过代码来证明上面的地址空间分布图:
运行结果:这段空间中自下而上,地址是增长的,栈是向地址减小方向增长(栈式先使用高地址),而堆是向地址增长方向增长(堆是先使用低地址),堆栈之间的共享区,主要是用来加载动态库的。
更深一步了解,做了如下测试:
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> int a=5; int main() { pid_t ret=fork(); if(ret==0) { while(1) { printf("I am child:pid:%d %d:%p\n",getpid(),a,&a); sleep(1); } } else{ while(1) { printf("I am parent:ppid:%d %d:%p\n",getppid(),a,&a); sleep(1); } } return 0; }
代码运行结果如下:
父子进程中对于同一个变量打印的地址是一样的,这是因为子进程以父进程为模板,因为都没有对数据进行修改,所以这里变量地址也是一样的。
对代码进行修改如下:
可以发现,子进程对数据进行修改后,父子进程打印a的值不一样的,但是地址却是一样的。
分析结果得出
在创建子进程那里说过,父子进程共享代码和数据,但是如果有一方队数据做出了修改,那么修改方将堆被修改的这一份数据写实拷贝,这一份数据各自私有,所以根据我们现有的知识可以推断出变量a在父子进程中的地址不一样的,但是测试发现他们的这个变量地址是一样的,但是内容却不一样。
难道对于同一块空间可以有两份不同的数据吗?
答案是否定的。所以我们可以得出结论,我们上面看到的地址绝对不是物理地址。事实上,在Linux地址下,这种地址叫做虚拟地址(下面讨论)。且平时我们在C/C++语言打印所看到的地址都是虚拟地址,物理地址对于我们用户是看不到的,由OS管理。
进程地址空间
任何学过的语言,里面的地址都不会是物理地址,虚拟地址是由操作系统提供给我们的,操作系统如何给我们提供?既然这种地址是虚拟地址,那么一定有某种途径将虚拟地址转化为物理地址,因为数据和代码是在物理内存上,因为冯诺依曼规定任何数据在启动时必须加载到物理内存,所以肯定需要将虚拟地址转化为物理地址,这里的转化是由操作系统完成,所有的程序都必须运行起来,运行起来之后,该程序立即变成了进程,那么刚刚打印的虚拟地址大概率和进程有某种关系。
举个例子:上学期间,经常会和同桌去画三八线,比如一张课桌是100cm,我们用一把尺子去划分,女孩的区域是[0,50],男孩的区域是[50,100],那么我们在计算机中去叙述这个事情?定义如下:
struct area { unsigned long start; unsigned long end; }; struct area girl={0,50}; struct area boy={50,100};
此时我们划分了区域,这时不管是男孩还是女孩,大脑里都有了这样的区域:
当女孩觉得自己活动范围不够,想扩大自己的区域时,就可以调整自己认为的[start,end],划分三八线的过程,就是划分区域的过程,调整区域的过程,本质就是调整自己认为的[start,end]
其中我们将桌子认为是物理内存,男孩和女孩认为是每一个进程,而男孩和女孩本质上都认为自己有一把尺子(脑海里的尺子),这把尺子就是进程地址空间,男孩想放自己的书包、铅笔等物品时,男孩就在自己的进程地址空间再划分区域放自己的物品。
那么如何划分进程地址空间的区域呢?在Linux当中,进程地址空间本质上是一种数据结构,是多个区域的集合。
在Linux内核中,有这样一个结构体:struct mm_struct,其中对虚拟地址每个区域进行了对应的划分(从0x00000000到0xffffffff)它也是被放在PCB中管理起来的,可以通过PCB找到对应的进程地址空间。每个进程都要一个进程地址空间,也就是每个进程都认为自己独享内存资源。所以说这个进程地址空间其实是一个虚拟地址空间。如何在这个结构体去表示我们开始说的一个一个的区域呢?这样去表示:
struct mm_struct { unsigned long code_start;//代码区 unsigned long code_end; unsigned long init_start;//初始化区 unsigned long init_end; unsigned long uninit_start;//未初始化区 unsigned long uninit_end; unsigned long heap_start;//堆区 unsigned long heap_end; unsigned long stack_start;//栈区 unsigned long stack_end; //...等等 }
在上面的例子中,男孩脑海里有一把尺子,想着自己拥有桌子的一半,女孩脑海里也有一把尺子,想着也拥有桌子的一半,而此时我们改变了:男孩和女孩关系比较好,不进行划分三八线,男孩脑海里有一把尺子,想着自己拥有0-100cm的桌子,女孩脑海里有一把尺子,想着自己也拥有0-100cm的桌子,他们在放东西的时候,只要记住了尺子的刻度就可以了。
我们的进程地址空间就相当于那把尺子,而尺子是有刻度的,进程地址空间也是从0x00000000到0xffffffff,可以在上面进行区域划分:比如代码区[code_start,code_end];那么区间的每一个地址单位就称为虚拟地址。
总结:
进程地址空间本质是进程看待内存的方式,抽象出来的一个概念,内核:struct mm_struct,这样的每个进程,都认为自己独占系统内存资源(每个私生子都认为自己独占10亿家产),地址空间区域划分本质:将线性地址空间划分成为一个一个的area,[start,end]。虚拟地址本质,在[start,end]之间的各个地址叫做虚拟地址
虚拟地址空间每个进程存放的是一个虚拟地址空间,OS会将虚拟地址通过某种映射关系映射到对应的物理地址空间,从而得到自己的那一份数据。不同进程的虚拟地址可以完全一样嘛?答案是可以完全一样,因为每个进程都有各自的页表,每个进程都是独立的进行通过各自页表中虚拟地址和物理内存的映射关系去找代码和数据:
父子进程各种有一份虚拟空间地址,在子进程刚被创建时,父子进程代码和数据共享,所以此时虚拟地址空间的内容是基本一样的(当然有部分数据不同,比如各子的id等),且映射关系也是一样的,但是当子进程对数据进行修改时,子进程对那份数据进行写时拷贝,所以物理空间地址发生了变化,但是虚拟地址还是没有发生变化,只是改变了子进程的页表中那份虚拟地址的映射关系而已,所以两个相同的虚拟地址在父子进程分别看到了不同的物理地址空间。
总结:虚拟地址和物理空间之间是通过页表完成的映射关系
为什么要存在地址空间
为什么进程不直接访问物理内存?这样不行吗?为什么要多此一举,存在虚拟地址空间去映射找到物理内存?
举个例子:小时候每当过年,我们都会收到压岁钱,妈妈都会要帮我们收起来,要买东西的时候再给我们,然而为什么不直接在我这,买的时候更方便囊?其实,在我们这有可能我们会被骗,会乱花钱等问题。
保护物理内存不受任何进程地址的直接访问,在虚拟地址到物理地址的转化过程中方便进行合法校验。
如果进程直接访问物理内存,那么看到的地址就是物理地址,而语言中有指针,如果指针越界了,一个进程的指针指向了另一个进程的代码和数据,那么进程的独立性,便无法保证,因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改,如果里面的数据有账号密码就可以改密码,即使不让改,也可以读取。
- 将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间来屏蔽底层内存申请的过程(进程也不再关心该过程),从而达到进程和OS进行内存管理操作,进行进程调度和内存管理进行解耦。
如果没有进程地址空间,进程直接访问物理内存,当进程退出时,内存管理需要尽快将该进程回收,在这个过程当中必须得保证内存管理得知道某个进程退出了,并且内存管理也得知道某个进程开始了,这样才能给他们及时得分配资源和回收资源,这就意味着内存管理和进程管理模块时强耦合得,也就是说内存管理和进程管理关系比较大,通过我们上面得理解,如果有了进程地址空间,当一个进程需要资源的时候,通过页表映射去要就可以了,内存管理就只需要知道哪些内存区域是无效的,哪些是有效的(被页表映射的就是有效的,没有被页表映射的就是无效的),当一个进程退出时,它的映射关系也就没了,此时没有了映射关系,物理内存这里就将该进程的数据设置为无效,所以第二个好处就是将内存管理和进程管理进行解耦,内存管理是怎么知道有效还是无效的?比如说在一块物理内存区域设置一个计数器count,当页表中有映射到这块区域时,count就++,当一个映射去掉时,就将count--,内存管理只需要检测这个count是不是0,如果是0,说明它是没人用的。
- 虚拟地址空间可以将空间连续化,降低了异常越界访问的概率。
磁盘上的可执行程序分区域的每个大小单位为4KB,每个这个大小的数据称为页帧,在物理内存中的每个大小单位也为4KB,每个这个大小的数据称为页框,那么为什么要分区域呢?因为方便生成可执行程序,在这之后其中有一个链接库的过程,如果可执行程序是乱的,那么这个链接过程非常困难,所以需要分好区域,由此进程地址空间才有了区域划分这样的概念,进程的地址空间连续化,也让顺序语句的执行成为了可能:当前语句的起始地址+当前代码的长度就等于下一条语句的地址。如果没有进程地址空间,因为物理内存空余的地方不一定是连续的空间,可能是零散的,那么将可执行程序的数据加载到内存当中时,那么这些数据就是零散的放在各个位置,而这些位置我们又是不确定的,此时很难找到代码和数据的位置了,进程地址空间的存在,进程地址空间又是进行区域划分的,通过页表的映射关系可以很好的找到物理内存,所以这也是存在地址空间的一个理由:让每个进程,以同样的方式(虚拟地址),明确程序运行的地址
- 让每个进程,以同样的方式(虚拟地址),看待代码和数据,明确程序运行的地址
有了虚拟地址空间,每个进程认为自己独享整个内存资源。每个进程将以相同的方式看待内存,这样就大大地提高了操作系统的工作效率。举例:CPU在对每个进程中的代码执行时,要找到代码的起始地址,且只需要查找固定的虚拟地址,因为对于不同的进程地址空间有不同的映射关系,所以这个固定的虚拟地址在不同的进程中会映射到不同的物理地址中,找到相关代码和数据,所以CPU可以很快地查找到程序运行的起始位置。
- 站在CPU和应用层角度,看待内存的方式是统一的,且每个空间区域的相对位置是比较确定的。
回顾:为什么父进程和子进程的数据不一样,这个我们不意外,因为数据是私有的,但是地址却也是相同的,这是什么原因呢?到达这里我想这个问题已经显而易见了:
此时g_val的虚拟地址没有变化,而子进程的g_val的虚拟地址对物理内存地址的映射已经发生了变化,指向的数据区的g_val已经变为了100。