目录
- Linux—进程学习—4
- 1.程序地址空间
- 1.1虚拟地址空间的现象
- 1.2虚拟地址空间的理解(感性)
- 2.进程地址空间
- 2.0 mm_struct结构体
- 2.1 mm_struct结构体的源代码
- 2.2分页&虚拟地址空间
- 解释前面的实验现象
- 2.3进程地址空间存在的原因
- 2.3.1第一个原因
- 2.3.2第二个原因
- 2.3.3第三个原因(小难)
- 2.4关于虚拟地址、线性地址、物理地址、逻辑地址
- 3.总结
- 4.内核进程调度队列(拓展)
Linux—进程学习—4
学习了环境变量和进程的一些基础概念之后,现在要来学习一个难点,就是进程地址空间
1.程序地址空间
之前在学习c/c++的时候,有提到一个c/c++程序的内存分布这样一个知识,忘记了可以看这个C&C++内存管理复习
当时提到了数据在内存中的分布情况,也就是数据可能会在栈区,堆区,代码段,数据段。
但是当时这只是一个便于理解代码的浅学习,这个叫做程序地址空间,现在来看看到底是怎么一回事。
下图是一个程序地址空间的一个图:
其实之前对其程序地址空间的理解是不到位的,比如我之前认为这个程序地址空间是内存,其实并不是。这个东西是虚拟地址空间!
怎么理解呢?可以通过下面一个实验来理解虚拟地址空间的存在
1.1虚拟地址空间的现象
一个有趣的小tip:
如果想在vim中批量化替换一个名字,可以使用
%s/将被替换的/替换的/g
如下图所示:
可以发现实现批量化替换了,将mycode批量化替换成mytest【这里的c99模式编译记得取消掉,不然无法编译后面的程序】
下面做个实验来感受一下虚拟地址空间。
代码如下:
执行结果如下:
注意:父进程和子进程谁先执行是不知道的,完全由系统的调度器决定
我们分析程序的执行结果会发现:
有个现象很奇怪:父子进程读取同一个地址的变量,出现了不一样的值!
在子进程修改了VAL的值后,明明父子进程的VAL地址指向的还是同一个地址,但是VAL的值却不一样,这不是很奇怪吗?和以前学习的知识不一样。
值不一样我们还能理解,之前说过父子进程之间应该是有独立性的,值不一样可以理解,但是VAL的地址确实一样的,这就很奇怪了。
因此这里的地址——绝对不是物理地址!
即是之前在c/c++所指的地址(指针),并不是物理地址!而是虚拟地址(线性地址)【在Linux中也会叫逻辑地址】
总结:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
现在已经知道了虚拟地址空间的存在了,也感受了它的存在,看到了现象,现在需要的是理解它,要理解它就得知道它是怎么来的
1.2虚拟地址空间的理解(感性)
进程会认为它自己是独占了系统的资源,但是实际上并不是。
下面是一个小故事来便于理解:
一个富豪有三个私生子,而私生子是不知道其他私生子的存在的。这三个私生子各自都有各自的生活。这三个私生子一个是工厂老板,一个是老师,一个是还在读博。
这个富豪答应3个私生子,死后的家产就属于它们。
而三个私生子自然会认为富豪的家产已经属于了自己,但是每次向富豪要钱的时候,只能向富豪要一些小钱来完成自己的工作。如果要家产的话,富豪就会拒绝。
在这个故事中:
- 富豪就是操作系统,在它看来,家产是不可能只给一个私生子的,家产只是给私生子的一个大饼。
- 私生子就是进程,在它看来,它认为富豪的家产已经被自己独占了,它可以通过向富豪索取家产来完成自己的工作
- 而家产就是虚拟地址空间,富豪给私生子画的大饼,而虚拟地址空间,就是操作系统给进程画的大饼
其实严格来说,这个虚拟地址空间(家产),这个大饼,叫做进程地址空间!
站在进程的角度上,它是认为它独占了所有的系统资源(进程地址空间)的,
站在操作系统角度上,进程地址空间是操作系统给每个进程”画的一个大饼“
一个进程实际上占用了操作系统的部分资源,进程也可以向操作系统申请一定的资源,但是进程如果要全部的资源(地址空间),操作系统只会拒绝
那这个饼(进程地址空间)是怎么画的呢?
-
画饼对于人来说:就是在人的脑海中构建一个蓝图,让人记住
-
画饼对于计算机来说:一个数据结构对象,让每个进程都指向一个数据结构的对象。
每个进程都需要给其画饼(地址空间),因此每个进程都指向属于自己的一个饼(地址空间)、而每个进程都需要被管理(PCB)。
自然,给每个进程的饼(地址空间)也需要被管理,这个饼(地址空间)也是结构体对象,也得通过先描述后组织的方式管理。
地址空间的本质: 内核的一种数据结构【在Linux内核中是 mm_struct
】
2.进程地址空间
之前叫做程序地址空间,是不太准确的,严格来说实际上应该叫做进程地址空间。
由于这个进程地址空间,是完成的连续的地址,很多教材和官方都管它不叫虚拟地址,管它叫做线性地址
2.0 mm_struct结构体
PCB里面有一个struct mm_struct* mm指针,这个mm指向的这个结构体划分了进程地址空间的区域分配情况
那这个mm_struct
结构体内部会有什么成员呢?
我们现在已经知道了,对于地址空间,操作系统也是需要管理的
在地址空间上是有各种区域的【堆,栈,等区域】,这个不同的区域要怎么理解呢?
其实就是区域划分,在32位的环境下,从低地址到高地址一共有2^32次方个地址,每个地址1字节。而区域的划分就是在这个地址上划出一个个范围,规定一个个范围分别属于谁的。
而地址一共有2^32次方个,在mm_struct
中是用unsigned long存储的
因此,mm_struct
结构体 肯定会有很多跟区域划分有关的成员
而一个mm_struct
结构体对象mm,就会开始具体的区域划分。
每个区域的起始地址和结束地址当中就会有很多个地址,每个地址都代表着一个位置,比如代码段区域有N个地址,4个地址(字节)存在一个int类型的对象。每个区域中间的这N个地址就可以拿来给用户存放数据。
而这每个区域中的地址,就叫做虚拟地址!也就是这2^32个地址都是虚拟地址!
之前说了,操作系统会给每个进程画大饼,这个大饼就是mm_struct
结构体对象(进程地址空间),每个进程都会指向一个mm_struct
结构体对象,这个指针存在于进程的PCB中。这意味着每个进程都可以拥有2^32个地址空间吗?不是的,只是一个大饼罢了。
既然有了区域划分,就会有区域调整,比如一个区域的范围要扩大,就叫区域扩大,区域要缩小,就叫区域缩小。
我们知道,栈区和堆区这两个区域的范围不是固定的,有些时候会扩大和缩小。这就涉及到了区域调整。而区域调整的本质:就是调整mm_struct
结构体对应区域的start和end
比如之前在C/C++中,使用new/malloc申请空间 ,其实就是在扩大堆区,而定义很多局部变量,就是在扩大栈区。当申请的空间被free/delete掉之后,扩大的堆区就会缩小回原来的大小,当栈帧销毁的时候,因局部变量扩大的栈区也会缩小回原来的大小
2.1 mm_struct结构体的源代码
下图是Linux内核中,mm_struct
结构体的源代码:
这个有很多看不懂很正常,只需要看下面这个就行
除了对应的代码段,栈、堆等区域的划分,甚至连命令行参数都有划分区域
2.2分页&虚拟地址空间
现在已经知道了,每个进程的PCB中都有一个指针指向进程地址空间(mm_struct
结构体对象),每个进程都拥有了自己的进程地址空间了,但是这个是虚拟的地址空间。
此时有个问题,那进程要如何找到加载到物理内存中的代码呢?
此时就需要一个新的东西——页表
注意:(下面这个只是了解,不详细学习,等到后面学文件系统的时候会详细学习)
内存在和磁盘这种外设做数据的输入输出的时候,叫做IO。而IO一般来说一次是4KB,也就是4096个字节。而每一次加载到内存,一般都直接加载成一个page单位,一个page大小为4KB,占据内存4KB的空间,其实可以认为内存会把自己看做一个个4KB大小的page。【在32位环境下,一共会有4GB/4KB个page】
继续回到页表,虚拟地址空间是通过页表来实现与物理内存的联系的。如何联系呢?——通过映射,看下图:
可以举个实例:
我们在C语言中写了一个代码
int a = 10
,如果此时取地址,前面也实验过,取的其实是虚拟地址上的地址,但是如果是修改其值,就会通过页表来找到虚拟地址所对应的物理地址,也就是真正存储a的物理地址,然后访问这个地址并修改所存储的值
注意:这里页表看似很简单,实际上页表是一个复杂的东西,它是一个类似于map的树状数据结构。涉及到多级页表【并且页表的功能也不仅仅只有映射】
解释前面的实验现象
知道了进程地址空间和物理内存是如何联系起来的之后,就可以解释上面实验的现象了**【父子进程取同样的地址,但是值不一样】**
由于子进程会直接按照父进程为模版,进行拷贝【这里子进程的PCB和进程地址空间拷贝的都是父进程的】,因此父子进程的关于VAL变量的虚拟地址是相同的。并且通过页表的映射关系找到的存储VAL的物理地址也是一个地址
但是在子进程运行过程中,修改了VAL这个共享变量,如果两个页表的映射仍然指向一个地址,那么父子进程的独立性就会被破坏、
因此Linux为了保持父子进程的独立性。在VAL这个共享变量被任何一方修改的时候,都会单独在内存中找一个一样大的地方,拷贝VAL到新地址,并且修改对应进程的页表的映射关系。如果是子进程修改那就修改子进程的页表映射关系,父进程就修改父进程的**【这种操作叫做写时拷贝】**
这样处理的话,尽管在进程地址空间上,父子进程的VAL的地址是一样的。但是通过各自的页表所找到的物理地址是不同的,不同的物理地址存放着父子进程各自的VAL变量
下图是关于这个现象的解释:
这上面的所有操作和功能,都是OS帮我们做的、
2.3进程地址空间存在的原因
2.3.1第一个原因
- 首先就是直接访问物理内存这种方式,对于在内存的数据来说非常不安全
- 自己写的程序,如果越界访问了,会直接修改到其他地址的数据
- 如果有恶意进程可以扫描物理内存,那就可以恶意修改数据或者获得关键数据并上传。
但是只能说直接访问物理内存不行,并没有说进程地址空间就行。
- 访问进程地址空间配上页表就安全非常多
访问进程地址空间,由于是虚拟的,最终数据的存取仍然要通过访问物理内存实现,但是页表可以判断本次操作是否合法。如果合法就通过页表来映射物理内存,从而访问物理内存。如果不合法那就拒绝访问物理内存。
因此哪怕进程的执行内容会有野指针和越界以及恶意程序的情况,影响的也只是这个进程对应的进程地址空间,不会影响到物理内存。
2.3.2第二个原因
进程地址空间的存在——可以更方便的进行不同进程之间数据和代码的解耦,保证了进程的独立性。
具体的案例分析在上面解释实验现象【父子进程取同样的地址,但是值不一样】
2.3.3第三个原因(小难)
- 让进程以统一的视角来看待进程对应的代码和数据等各个区域(也就是每个进程都有代码区和数据区等区域),方便使用【可以做到10个进程的main函数地址都是一样的】
- 让编译器以统一的视角来编译代码【编译完即可直接使用】
这两个原因其实有点难理解,可以看下面的分析来理解、
首先要知道一个点——不要认为虚拟地址空间只有在OS中才会用到,编译器也会遵守对应的规则
我们自己写的程序,在加载到物理内存之前,在编译的时候,每句代码和数据就已经存在地址了.【程序需要经过编译和链接才能生成可执行文件!】
什么意思呢?可以看下面这个图:
这个编址是编译器在编译的时候编址的,可以说是按照虚拟地址空间一样的方式去编址的,一样会有代码段,数据段等区域的划分。【栈区和堆区在编译的时候是不会开辟出这个区域的(这两个区域是程序执行之后动态生成的)】
在磁盘中这个地址说是虚拟地址不太准确,严格来说应该是逻辑地址。但是在Linux中是一回事
然后呢,编译完成之后,就要链接并加载到物理内存。
这个时候需要注意,虽然代码其本身自带逻辑地址,在加载到内存之后,天然的就会被赋予物理地址
此时有两套地址
- 物理地址:标识代码和数据在物理内存的位置
- 代码的逻辑地址:标识程序内部代码和数据的地址【比如函数跳转时,用的地址就是逻辑地址】
由于此时加载到物理地址,因此OS就可以拿到代码和数据在物理内存的位置,此时就会将这个位置记录下来并记录在页表中
那如果此时有一个进程,要执行这个程序,这个进程该如何找到程序的位置呢?
之前说了每个进程都有其对应的mm_struct
进程地址空间,因此这个进程地址空间在初始化的时候,在划分区域的时候,就会拿这个加载到内存的可执行程序的逻辑地址来加载进程地址空间中的虚拟地址。
通过这样的方式,页表就被OS建立好了。进程地址空间可以通过页表找到代码的物理地址。
下面CPU调度该进程并执行代码的过程:
此时如果CPU在调度该进程,并且到了要执行该程序的时候,就会将进场地址空间中的
code_start
等区域的起始地址交给CPU,然后CPU就会根据该虚拟地址,去找到对应的main函数【OS会将页表的映射关系处理,找到虚拟地址对应的物理地址】,找到之后,开始执行代码。如果中间碰到函数跳转,进程地址空间有对应的虚拟地址,页表也记载了对应的物理地址,CPU可以继续根据虚拟地址来完成代码的执行
要注意:CPU只能接收指令!通过虚拟地址然后经过页表的映射在物理地址找到代码指令,然后执行指令。而这个指令就自带地址,这个地址是虚拟地址!【因为加载到内存了,如果在磁盘上就是逻辑地址】
而**CPU在执行完整个进程的这段时间,是见不到物理地址的!它一直都在用虚拟地址!**中间通过进程地址空间的虚拟地址并通过页表找到物理地址的过程,是OS完成的,CPU并不知道。
在VS2022中为什么说程序要在32位或者64位环境下编译?因为就跟编译器要在编译程序的时候为程序以什么环境编址有关,也就是逻辑地址。
2.4关于虚拟地址、线性地址、物理地址、逻辑地址
由于这个进程地址空间,是完成的连续的地址,很多教材和官方都管它不叫虚拟地址,管它叫做线性地址
在Linux中我们认为虚拟地址和线性地址是一样的
而虚拟地址和物理地址之间的联系是通过页表完成映射关系的
逻辑地址:前面在第三个原因处提到的逻辑地址,这只是逻辑地址的一种形成方案,它多种形成方式
下面是两种典型的逻辑地址的形成方案:
- 新的是直接按照线性去编址,code区域假如是0~100,那么data区直接从101开始编址,这样的好处是,形成的逻辑地址和进程地址空间的虚拟地址比较相似,加载到内存可以直接当做虚拟内存看待。
上面在第三个原因所讲述的逻辑地址,就是按照新的方式去编址的【它恰好和虚拟地址非常像】
- 旧的是按照偏移量的方式去编址的,code区域假如是0~100,那么data区域是从起始地址+偏移量100,然后再从0开始编址。这样的坏处是,形成的逻辑地址加载到物理内存后,仍然需要在物理地址去加上偏移量才能得到代码的虚拟地址。
3.总结
经过上述的理解和学习,现在需要知道3个问题的答案
- 进程地址空间是什么?
进程地址空间是OS为了管理进程的一种虚拟化解决方案,实际上进程地址空间是为进程所画的"大饼",让进程认为自己独占了系统资源(地址空间)、
- 进程地址空间为什么存在?
仔细说就看上面讲述的三个原因,这里大概总结一下
三个原因:
防止进程中存在越界访问和野指针等非法操作,保证物理内存的其他数据的安全性。
保证进程与进程之间数据和代码的解耦,保证了进程与进程之间的独立性
让进程以统一的视角来看待进程对应的代码和数据等各个区域(也就是每个进程都有代码区和数据区等区域),方便使用
让编译器以统一的视角来编译代码【编译完的逻辑地址即可直接使用】
- 进程地址空间如何实现的?
为了管理进程需要有PCB,进场地址空间自然也需要管理,要遵守先描述后组织,因此进程地址空间就是内核的一种数据结构结构体
mm_struct
,每个进程PCB中都有一个mm指针指向一个mm_struct
结构体对象,结构体对象会实现地址空间的区域划分。如果需要到物理内存寻址,OS会通过页表来实现进程地址空间与物理内存之间的联系
现在回过来看这个图,理解就多了一些。
但是需要注意的是,32位环境下为进程准备的进程地址空间,并不是全部空间都是给用户准备的,这里有1/4的空间是给内核准备的,这个内核空间后面会讲到
4.内核进程调度队列(拓展)
下图是Linux2.6内核中进程队列的数据结构
一个CPU拥有一个runqueue(运行队列)
如果有多个CPU就要考虑进程个数的负载均衡问题
优先级普通优先级:100~139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
实时优先级:0~99(不关心)
关于优先级的详细解答:Linux的进程优先级 NI 和 PR - 简书
活动队列
时间片还没有结束的所有进程都按照优先级放在该队列
nr_active: 总共有多少个运行状态的进程
queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!
从该结构中,选择一个最合适的进程,过程是怎么的呢?
从0下表开始遍历queue[140]
找到第一个非空队列,该队列必定为优先级最高的队列
拿到选中队列的第一个进程,开始运行,调度完成!
遍历queue[140]时间复杂度是常数!但还是太低效了!
- bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率!
过期队列
过期队列和活动队列结构一模一样
过期队列上放置的进程,都是时间片耗尽的进程
当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
active指针和expired指针
active指针永远指向活动队列
expired指针永远指向过期队列
可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,称之为进程调度O(1)算法!