进程地址空间
我们之前在学习CPP的时候,了解过C++的程序地址空间,我们现在通过代码将不同区域的数据地址打印出来,看看有没有上面规律?
我们查看的地址有,代码地址,全局定义区地址,全局未定义地址,堆区,栈区地址,字符常量地址,环境变量和命令行参数地址,我们通过地址的大小比较,我们可以知道我们的此处的地址空间是下图结构的!从正文代码到命令行参数环境变量的地址,是依次增大的!
我们以前叫这个叫程序地址空间,上图也可以知道,全局变量为什么又全局性!因为全局变量存储在整个地址空间的初始化和未初始化区内,所以具有全局性。
然后栈区内,栈是向地址减小方向增长的!我们调用函数,要形成栈帧,不断的入栈,最后形成临时变量,当我们的函数调用完了,就需要弹栈,要释放临时变量,所以栈结构是向地址减小方向增长 堆区就是向上增长,并且堆栈之间的地址差别特别大,所以堆栈之间有一大块的镂空空间,叫共享区!
还有一个就是"helloworld"这个字符串是不可以被修改的,所以是被放在字符常量区,我们看看这个字符串的地址,发现这个字符串常量的地址在正文代码区附近,所以我们平时写的字符串常量,其实是被硬编码到代码的!因为代码是只读的,所以字符串也是只读的了!只不过在代码区会有个区域叫字符常量区!
我们上面的一个变量static int test = 10;这个变量若不是静态就是一个函数内的局部变量!但是加上static就是一个静态局部变量(作用域依旧不变,只是生命周期是全局的了!),我们来看看这个变量的地址!发现静态局部变量的地址就在全局区,甚至这个变量的地址,就在全局已初始化变量和全局未初始化变量之间!,所以静态局部变量就是全局变量,只是要求这个全局变量只作用于当前所在函数中!但是这个变量为什么生命周期又是全局的呢?因为这个变量已经被编到了已初始化数据中了!
所以现在问一个问题:程序地址空间是内存吗?不是!
(如果一个进程的数据是按这样排布的!其他的进程怎么办?计算机运行进程会有很多个!进程都按这样规律的排布,其他进程要如何放?有进程创建就有退出,所以这么多的进程,内存空间不能满足每个进程都这样规律的排布!)还有就是要更正一下概念,我们学习的这个地址排布不叫程序地址空间,应该叫
进程地址空间(虚拟地址空间), 这应该是系统层的概念,不是语言层的概念!这也是为什么在学习语言或者语言的书籍上,看不到这样的地址排布图片,只是单词的介绍有什么区域!(堆区,栈区......),以往提要这个图,也是为了让这部分的学习有立脚点,不那么抽象!那么我们先证明一下这个进程地址空间不是内存吧!
这个实验代码做的是,让子进程循环输出gval全局变量,输出全局变量地址,其pid和ppid,并且每次循环都让gval++,父进程就是只做输出,但是不做gval++,我们来看看,gval的地址怎么样?
令我们感到震惊的是,父子进程的gval全局变量的地址居然相同!按理说,子进程对父进程全局变量gval++,那就要写时拷贝一份gval来++呀!
如果是内存地址,那此时就会出bug的,我们现在做到例子就摆在面前,实践出真知,如果这里是内存地址,那绝对不可能是这样的情况,数据会覆盖才对的!但是现在是父子进程访问同一个地址,但是数据不一样!所以我们断定这地址一定不是内存地址!那这个地址到底是什么呢?我们现在只能告诉你,这个地址根本不是物理内存的地址,这个地址叫做虚拟地址!是OS为了进程所虚拟出来的全新的地址
所以我们之前学习c/c++指针用到的地址,全部都是虚拟地址!
新概念——虚拟地址空间
我们上面得出的结论,虚拟地址空间不是内存!
一个进程,一个虚拟地址空间
每一个进程需要用一个task_struct来组织,而每一个task_struct最终都会指向虚拟地址空间
虚拟地址空间的单位,即宽度的单位为1字节
在32位机器下,虚拟地址空间的范围为 2^32^个
在64位机器下,虚拟地址空间的范围为 2^64^个
就32位机器来讲,虚拟地址空间从低到高,会有2^32^种组合,即虚拟地址空间的范围为2^32^个,而宽度为1字节,所有虚拟地址空间的容量为 2^32^ X 2^8^ = 2^40^ = 4GB
1K = 1024B = 2^10^B
1MB = 1024K = 2^20^B
1GB = 1024MB = 2^30^B
所以容量为 4GB在这里因为64位的容量太大了,不方便讲解,所有一般都是用32位来讲解
所以这里会有2^32^个地址,一个地址表示1字节!
而虚拟地址有4GB,那么从0~3GB被称为用户空间,3 ~4GB被称为内核空间,我们目前不能理解内核空间,我们现在重点学习用户空间!
我们可以发现,用户空间的3G空间,用户是可以直接通过地址来直接访问!
我们编写的程序,编译好后,有些变量名不在了,被编译器转换成内存地址或寻址方式!
所以用户空间就是可以通过地址直接去访问的空间!这里不是重点,接下来才是
一个进程,一套页表(页表是用来做虚拟地址和物理地址映射)
不管这么说,程序的数据肯定是要存储在物理存储空间的!那么物理存储空间和虚拟地址空间是如何联系的!
就以全局变量g_val来讲解
全局变量g_val数据位100,在内存中的物理存储地址为0x112233,而在虚拟地址空间中,也会有4字节的变量g_val,其起始的虚拟地址为0x111111
那这两者之间是如何联系的呢?
关键在于OS在创建进程的时候,要为进程创建一个页表
这个页表就是链接虚拟地址和物理地址的桥梁!
我们现在还没有能力展开页表,了解内部原理,我们只能理解表层
进程地址空间可以找到其对应的页表,页表左侧填写进程中某个变量的虚拟地址,右侧填写变量所对应的物理地址!所以进程访问虚拟地址时,OS会自动将虚拟地址通过查表的方式找到虚拟地址并转化成物理地址进而找到访问到指定变量!
即:页表是用来做虚拟地址和物理地址映射
所以我们程序中,所以的代码、变量、未初始化变量,对象、堆、栈....,这些都会有地址,每个元素都会有对应的虚拟地址,都会填入到页表,虚拟地址都会加载到物理内存,通过页表映射找到物理内存!
我们来说是虚拟地址!
我们上面例子,g_val是int型的全局变量!按理说应该有四个地址的,但是虚拟地址和物理地址只提供了一个地址,这个地址是这个变量最低位的那一个地址!只提供一个地址只能访问一个字节,那要如何访问这整个变量呢?所以我们会有类型!这就一般会通过了解变量类型来获取偏移量,这时访问变量,我们知道低位的起始地址,起始地址加上偏移量,这样就可以读取这个变量所以的地址了!
回顾例子(Figure 335)
我们回到上面开头的例子(父子进程输出全局变量)
有了上面的学习认识,我们可以知道
一个进程一个虚拟地址空间,一个进程一套页表!
父进程有虚拟地址空间,有页表,那么子进程也要有,而我们之前讲父子进程的时候,子进程是拷贝父进程的PCB,所以子进程也是拷贝父进程的虚拟地址空间和页表内的数据!因为子进程拷贝父进程虚拟地址!所以父进程和子进程全局变量g_val输出的地址相同!
而子进程页表也是拷贝父进程的,所以子进程对应的变量,访问的实际物理地址是父进程对应变量的实际物理地址!
(上面的操作感觉非常像C++的浅拷贝!)所以全局变量默认下是父子进程共享的!他们的映射关系是一样的(从虚拟到物理),对子进程来说就是指针发生浅拷贝!代码也同理,所以默认情况下,代码和数据对应父子进程来说是共享的!
那子进程修改全局变量!(那就需要深拷贝了)
我们都清楚,进程具有独立性!子进程对g_val++,指向这个代码,子进程在页表通过虚拟地址映射到物理地址,访问物理地址,做++操作,这里问题就来了,此时g_val对应父子进程来说是共享的!子进程对g_val当前物理地址操作,父进程的数据也会修改!但是父进程认为自己的g_val没用改动,这样就没用保证独立性了!
这里其实有别的做法!得到g_val++代码,子进程访问g_val时候,先不做操作,OS介入,对被操作空间的数据拷贝到新的物理地址空间(获得了新的物理地址0x223344!)OS此时修改子进程页表的映射关系,访问新变量!0x111111虚拟地址映射到0x223344,构建全新的映射关系!这时候子进程修改变量,是修改新变量!对父进程变量没用影响,这样的操作是写实拷贝!这里就解释了,g_val数据不一样,但是地址一样,就是因为虚拟地址一样,但是物理地址不一样
写时拷贝OS会自动做
虚拟地址与进程地址空间 是什么?✨
通过一个例子来了解知识点
一个大富翁,有10亿 这个大富翁有很多私生子,这个大富翁对每个私生子都承诺让他们继承这10亿,这里大富翁给每个私生子画大饼!
让每个私生子都认为自己有10亿,但我们断定你不敢一次性把10亿都要走!要也要上零零散散的要,用完还要还!大富翁 —— OS
10亿 —— 物理内存
私生子 —— 进程
大饼 —— 虚拟地址空间虚拟地址空间本质上是大饼,让每个进程都认为自己有4GB的物理内存!或者,每个进程都认为自己在独占物理内存!
现在再来看这个图,我们更能明显感受到!OS告诉进程,你有4GB的内存可以用!
画大饼这样的事很常见
公司老板给员工画饼,如果公司人数很多,老板连饼是谁的都不知道!这要怎么办?我们知道员工要管理,那大饼也要管理!一个进程一个虚拟地址空间,而我们OS上有很多进程,那就是有很多个虚拟地址空间,所以虚拟地址空间需要被管理起来,不然会认错!
如何管理?先描述,再组织!
描述虚拟地址空间属性,构建虚拟地址空间类!在类内设置指针,定义一个虚拟地址空间指针作为入口!创建一个虚拟地址空间对象,就用指针链接,把所有的对象用链表管理起来,老板对大饼的管理转化成立老板对链表的管理
所以虚拟地址空间本质上是一个数据结构!struct mm_struct
创建一个进程,就要有一个虚拟地址空间,所以这个虚拟地址空间就是一个结构体对象,task_struct通过指针链接
综上,虚拟地址空间是在内核中为进程创建的结构体对象!
怎么做?
虚拟地址空间是如何实现的,即讨论类的组成有些什么属性!
什么叫做区域划分?
例子:
幼儿园里面有小张和小美,他两个小张是同桌!小张有点邋遢,小妹有点嫌弃,而小张又一直缠着小美,小美生气了打了小张,并且在桌子上画了一条‘3 8 线’,小张越过这个线,小美又要打他
小美做的线,本质就是区域划分
小女孩区域划分,用计算机量化一下
struct Destop { int size; int xz_start; int xz_end; int xm_start; int xm_end; } struct Destop area = {100, 0 , 49 , 50 , 99};区域划分只需要确认区域的开始和结束即可!
小张的区域范围为0~49,所以在这张100cm的桌子,小张在他的区域范围内任何位置可以使用
100cm的桌子,用cm为单位,有100个刻度!张三就非常精确的使用这个刻度,铅笔盒放到第二个刻度,铅笔放到第三个刻度,橡皮擦在第四个刻度
所以这里的刻度就相当于地址!只要小张在范围内,里面的地址可以随便用! 我们也只需要记录起始位置,期间是线性连续的!
这里我们知道桌子长度,我们对桌子进行0~99的刻度设置,这样的操作叫做对桌子进行统一编地!
所以现在我们要访问桌面上的位置,就直接使用地址来访问,地址可以直接使用int来保存桌子 —— 地址空间
100cm —— 2^32^
刻度 —— 地址空间上的地址
孩子个数 —— 地址空间的区域
所以现在我们可以知道虚拟地址空间结构体内部有些什么属性了!
struct mm_struct //内存描述符 { long code_start; long code_end; long init_start; long init_end; long uninit_start; long uninit_end; ...//结构体内存储的是每个区域开始和结束虚拟地址 }所以将虚拟地址空间结构体内输入对应的起始与终止地址,就可以划分出对应的区域!
通过以上例子的讲解,OS要对进程虚拟地址空间要做管理,虚拟地址空间是内核的一种结构体!这个结构体中大部分属性都是各个区域的起始与终止地址
再讲讲调整区域!
小美忍无可忍,小张屡次越过38线,侵入到小美的活动范围,小美直接下掉现有的55开的38线,将线左移到37开的38线,这就是在调整区域,计算机如何实现呢?
area.xm_start-=20; area.xz_end -=20;所以区域调整,只需要对区域内整数进行加减调整即可!
看源码
这个就是进程的地址空间!(mm_struct--->内存描述符)
total_vm 虚拟地址空间的总大小
start_code end_code 代码区起始与终止
start_data end_date 数据区起始与终止
start_brk 堆区的开始 brk 堆区的顶部 start_stack栈区的开始
arg_start arg_end 命令行参数区的开始与结束
env_start env_end 环境变量区的开始与结束
再来谈谈地址空间
创建进程,会创建虚拟地址空间与页表,进程PCB内会有struct mm_struct * mm,mm指针会指向当前进程所属的虚拟地址空间
虚拟地址空间内,堆、栈、共享区这些是程序运行后才开辟的动态内存我们先不谈,但是程序编译好了,虚拟内存空间内正文代码、初始化和未初始化数据肯定是会有数据的!这些区域内的大小与程序的体量有直接影响!(我们写的helloworld输出语句,这个程序代码就100字节,使用这个程序正文代码也是100字节 如果这个程序是王者这样体量的,正文代码可能就1G了,所以虚拟地址空间内部正文代码也要有1G的空间)
当进程被调用,代码和数据都要被加载到物理内存中!在物理内存中被加载的物理内存字节,要同批次的在虚拟地址空间内部创建数量同等的地址(物理内存100字节,虚拟内存也会开辟100字节的地址)
此时填写页表代码和虚拟地址就可以一一对应联系起来
1、虚拟地址空间中申请指定大小的空间9(如何申请:调整区域划分)
2、加载程序,申请物理空间
1 —> 2 —> 页表进行映射 ——> 物理地址转化成虚拟地址
虚拟地址提供给上层用户使用,上层用户使用虚拟地址查,虚拟地址通过页表可以找到实际的物理地址!
mm_struct
1、开辟空间
2、初始化的值从哪里来?——> 数据从磁盘到内存加载的时候,进行初始化
最终我们再回到父子进程的例子讲讲
父进程创建时候,会创建PCB,创建虚拟地址空间,创建页表,然后将代码和数据加载到内存,在加载的过程中,OS对加载所消耗的物理内存空间,相应的转换到虚拟地址空间上的线性地址!从而可以给上层提供很多的虚拟地址,而页表可以将虚拟地址和物理地址的映射关系都构建好
子进程就会把父进程使用的数据结构都拷贝一份,PCB、虚拟地址空间、页表,所以子进程会有和父进程相同的g_val虚拟地址,到页表映射虚拟地址,默认下会映射到相同的物理空间,但是一旦子进程修改,OS就会维护进程独立性,继续写时拷贝
为什么?✨
虚拟地址空间也是先描述再组织,描述我们知道了,是描述为结构体,那怎么组织呢?
>
所以是用一个mm_struct链表来组织管理的
接下来我们来讲讲为什么要有虚拟地址空间!
将地址从无序变有序(有序化)
我们之前讨论了那么多,现在有个问题!从磁盘加载到内存的代码和数据,是要有序的加载还是无序的加载?很显然,现在是可以无序的加载,因为有虚拟地址空间!通过虚拟地址空间划分有序的空间,通过页表将无序的代码和数据地址找到,从此上层用户看到虚拟地址空间内的代码和数据都是连续且有序的!
地址转换的过程中,也可以对你的地址和操作进行合法性判定,进而保护物理内存
用户调用虚拟地址去访问实际数据,虚拟地址需要转化成物理地址,怎么转化的? OS查找页表(实际上和硬件有关,但是不谈)
为什么我们访问地址不直接访问物理地址,要是要通过虚拟地址转化再来呢?
例子:
小明过年拿到了压岁钱,得到了200块,之后小明就去小店随意购买东西,辣条、玩具什么的!
这个时候小明妈就发现,小明妈那些玩家就算了,他居然买辣条这些垃圾食品,这个时候小明妈就和小明说“来,小明,把压岁钱给妈来管理,你要用啥,跟我讲,我给你钱”!
为什么要有虚拟地址空间,就像上面为什么小明和小店之间要加入一个妈妈的关系?
小明妈只和小明讲表明的,真正的意图是控制小明花钱的对象!比如,小明找妈要钱去买辣条,小明妈说辣条吃了不健康,不给!
这里小明妈就是驳回了小明错误的请求!
同理虚拟地址空间就起到了一个对虚拟地址以及用户操作的合法性判断!
这里的判定操作都在页表实现,我们来拓展一下页表的知识,页表除了有记录虚拟地址和物理地址,还会记录权限!rwx
此时要对一个代码区地址继续写入!但是发现这个用户的操作权限只要读权限,OS查表就会发现你没用写的权限,OS就不给你向这个地址写甚至把你这个进程杀掉!
这就是虚拟地址对物理内存的保护
以及用户访问错误的虚拟地址,错误的访问错误的虚拟地址,查页表的时候就发现找不到对应的虚拟地址,页表就查找失败了,这就是野指针
问题:
什么是野指针?
使用未被定义的地址空间,或是一个指向区域已被释放的内存区域,已被释放则地址空间需要被释放,物理空间也需要被释放,映射关系也要去掉,我们现在对一个已释放的内存区域进行访问,到页表中根本发现不了虚拟内存和物理内存之间的映射关系,查页表失败,所以OS会把进行杀掉,所以访问野指针可能会让程序崩溃
char *str = "helloworld"; *str = 'H'我们对字符串常量进行修改这样的操作,虽然编译能过,但是启动就会崩溃!
字符常量区就在正文代码区和初始化代码区之间中,被硬编码到其中的!所以字符串常量是只读的!
这里要修改字符串常量,页表转化失败,所以OS不让你转,这就解释了为什么字符常量区写入,就会崩溃!
查找页表的时候,权限拦截了!
让进程管理 和 内存管理 进行一定程度的解耦合
现在有一个程序,这个程序代码部分就有2G的内存!此时虚拟地址空间的正文代码区域就给程序开辟2G的地址空间,但到实际的物理地址,我们只加载代码内存的1/4,所以在页表查询中,正文代码虚拟地址都可以找到全部,但只有1/4的物理地址才可以找到对应的物理内存,让程序开始运行,当前1/4的内存要用完时,暂停进程,在把之后1/4的内存加载到内存!把物理地址与虚拟地址的映射构建好!在将进程切换回来运行
上面这样的操作就是缺页中断
上面磁盘加载数据到内存,以及OS申请内存并向页表进行映射关系构建,属于OS的内存管理模块
PCB、虚拟地址模块就属于进程管理模块
这样就可以让这二者之间解耦合,如果没用虚拟地址空间和页表,进程地址直接指向物理内存,这样二者就强相关了,申请了物理内存还要去进程PCB内修改指针
而有进程地址空间和页表,加载内存就修改页表就行,进程的管理和物理内存没用任何关系
扩展
我们可以不加载代码和数据,只有PCB、mm_struct、页表
创建进程的时候,我们可不可以只创建PCB,虚拟地址空间,页表,然后物理内存内不加载进程是数据!这样做可以吗?
答案是可以!
进程通过可执行程序内的代码和数据,在虚拟地址空间开辟空间!,并在页表虚拟地址部分写入(main函数的物理地址还是要获得的)当进程运行时,CPU拿到PCB就直接访问虚拟地址,OS发现虚拟地址到物理地址的映射转换不过来,所以页表内还有一个标记位记录虚拟地址对应的物理地址是否在内存里面
若不在,OS就会自动开展缺页中断,自动加载数据到内存,并且重新填充页表,做缺页中断时,进程是暂停的,当缺页中断做完进程继续运行!
创造进程,先有PCB、mm_struct等,还是先加载代码和数据?
很明显上一个问题解决了这个问题,要现有PCB这类的数据结构,之后才会陆陆续续的加载数据!
如何理解进程挂起
进程阻塞,并且OS内存资源严重不足,此时OS就要把阻塞进程的代码和数据唤出到磁盘上,需要的时候再唤入!
那要如何理解进程挂起呢?
OS访问进程,在PCB内查看进程状态,发现进程阻塞状态,此时OS就对进程做挂起操作,清空页表,并把对应的物理内存都唤出到磁盘的swap交换分区!只保留进程管理部分,内存就腾出来了!
进程挂起的时候,OS中进程还存在!这里就解耦的非常好,进程可以分批加载内存,也可以分批唤出内存 ,只对物理操作,不会影响到进程管理!
堆区内不只有一个起始虚拟地址吧?(堆区细节性话题)
堆区虚拟地址有一个起始和终止的虚拟地址,但是我们平时申请的堆区空间都可以获得对应的起始虚拟地址呀!但是堆区只有一个起始地址!堆区是离散的呀,这要怎么解释?(栈有栈顶和栈底,只需要移动栈顶需要移动,代码区和数据区都是固定的,命令行环境变量区是表,也只需要在表内动,但是堆区是离散的,一个起始地址这么够?)
在
mm_struct中有一个vm_area_struct * mmap这样的链表,vm_area_struct这个结构体中有vm_start、vm_end
所以现在不用担心堆区不是连续的问题了!因为虚拟地址空间内有mmap这样的一个结构体链表,维护了vm_area_struct这个结构体,这个链表会记录下来每一个子区域的起始和终止地址!所以堆区空间不连续也没关系,可以通过vm_area_struct记录下不同堆区的地址位置,以分段不连续的方式吧地址空间划分好!
所以堆区可以有很多份,每一份就有个vm_area_struct维护
说到底,mm_struct中只有代码段的vm_area_struct结点,其他区域都会有对应的vm_area_struct节点维护,记录区域的开始和结束,而mm_struct是对整个虚拟地址空间的整体描述!
mm_struct对整体描述,vm_area_struct是对具体区域进行描写这两者是一块的,他们的地址数据可能有重复的,但是mm_struct是整体的排布,堆区有多个分散的,那么mm_struct就记录整体堆区的开始和结束的地址!而对应的子区域都有对应的vm_area_struct,所以进程查地址的时候,都可以不用关心mm_struct的起始与终止地址,只需要在mmap的vm_area_struct中找到对应结构体中区域的起始和终止地址!
如果虚拟地址空间数据过多,在链表中查看的效率会不太高!所以会更改成红黑树来存储!
总结
我们在Linux上使用地址都是虚拟地址,在虚拟地址空间中提供的内存,虚拟地址会通过页表映射到物理地址, 每一行代码和数据加载到内存都会有自己的地址,所以到虚拟地址空间内也要开辟同等规模的地址,然后通过页表构建映射关系,进而完成虚拟到物理的转化
进程 = 内核数据结构(PCB,mm_struct、页表) + 代码和数据,每一个进程都会有自己独立的PCB,虚拟地址空间和页表,而页表通过映射,可以找到不同位置的物理空间!从而进程就具有独立性
根据这节课,也解决了之前很多的问题!(fork....)