目录
2:增加一个转换的过程,便于我们对寻址请求的审核,保护物理内存。
3:进程地址空间和页表将进程管理模块和内存管理模块进行解耦合。
内存空间的划分
相信大家在学习的时候都听说过这样的一个概念:变量分为静态变量,全局变量,普通变量等。不同类型的变量会存储到不同的内存空间的位置当中。
就比如说动态开辟出来的变量需要存储到堆区,全局变量会存储到全局变量区,普通变量会存储到栈区。同样的我们内存当中的还有已初始化全局变量区,未初始化全局变量区等等的划分。所示结构大致如下:
其中的栈区的空间是向下进行增长的,而堆区和栈区增长的方向相反,是从下向上进行增长的。我们的内存一共被划分为了如上的不同的空间。由我们的机器决定我们表示地址的二进制的位数。我们还可以通过创建变量的形式进行验证不同种类的变量在内存中存储的位置:
代码运行的效果如下:
我们会发现变量在内存当中所处的空间和我们预期的相同,是按照图中的形式进行增加的。
同地址不同值的现象
之前我们提到过一个遗留的问题——一个变量地址当中有可能存储两个不同的值。也就是在创建子进程步骤当中所出现的问题。
我们来重新看一下这个问题:
首先编写好如下的一段代码:
编译运行,检查程序运行的结果:
我们会发现打印相同的一个变量,即使地址相同的情况下里面存储的值也是不相同的。但是在我们的常识当中一个地址当中存储的一个值怎么可能不同呢?
所以我们可以得出一个结论:这个地址或许不是我们常规意义上的地址。
这个时候就需要涉及到我们进程地址空间的概念了。
虚拟地址
在我们程序的编写过程当中,其实我们所有能够看到的地址都不是实际的物理地址,而是被我们创建出来的虚拟的地址。也就是说我们所认识到的不论是堆区变量,还是栈区变量,亦或是全局变量都是我们虚拟出来的。我们通过虚拟出来的地址和我们实际的地址相结合,才有了我们看到的变量的地址。
虚拟出来的地址属于单个进程。举一个简单的例子:在翻斗花园有十号五单元,那么在春日部同样可以有十号五单元。虽然他们都是十号五单元但是由于属于不同的小区所以对应的具体的地址也是不同的。而我们的进程就相当于小区的作用。
对于虚拟地址来说一台机器虚拟出来的地址是固定的。32位机器虚拟出来的地址也就是 0-2^32 换算成内存也就是4G的大小。这个大小是我们的进程创建出的虚拟地址的总大小,也是我们计算机内存的大小。(硬盘等辅助存储器属于外存,内存掉电即失)同样也是我们CPU的大小。
进程地址空间
所以当我们的进程在被运行的时候会创建出一个虚拟的内存。这个虚拟的内存就叫做进程地址空间。在进程被CPU进行处理的时候,就会将进程地址空间加载到CPU上面供我们和物理上的内存进行对应写入或者读取。
页表
用于虚拟内存和物理内存相映射的工具就叫做页表。其结构如下:
所谓的页表就是一个表格,其中存在两列分别为虚拟内存和物理内存。我们可以通过访问左侧的虚拟内存直接找到右侧的物理内存,进而拿到我们想要的数据。
既然每一个进程都需要创建一个独立的进程地址空间,并且在运行的时候需要将进程地址空间加载到内存里。所以我们很容易联想到我们之前所学到的PCB结构体。同样是在进程运行的时候需要加载到内存当中。
事实也正是如此。我们可以修正一下我们之前对进程的理解。
之前我们认为进程=PCB结构体+程序的代码和数据,但是通过现在的学习我们又知道了进程地址空间和页表。这些都是需要在程序运行之前就要加载到内存当中的。所以我们针对于进程的理解就可以修改为:进程=PCB结构体+进程地址空间+页表+程序的代码和数据。
进程地址空间的本质
我们在知道了进程地址空间的概念之后又会想:进程地址空间是怎样实现的呢?进程地址空间其实和我们的PCB结构体相同,也是一个结构体。我们将控制进程地址空间划分的结构体命名为:mm_struct 在这个进程地址空间当中我们可以规定某段区域的开头和结尾。通过指针进行对应数据的存储。所示结构如下:
诸如此类的开始和结尾。我们可以通过这种形式将不同种类的变量存储到不同的区域空间当中。
子进程的创建
在认识完运行一个进程所需要的一系列步骤之后,我们就可以通过这一系列思路来学习子进程创建的时候所需要进行的操作了。
想要创建一个子进程和我们之前说到的一样,首先需要创建一个PCB结构体。我们将这个PCB结构体加入到运行队列当中,子进程就可以运行。同时我们还会将代码和数据指向相同的空间,等到我们需要对代码和数据进行修改的时候,在进行复制等操作即可。
对于我们的进程地址空间和页表来说也是如此。当我们的进程在运行的时候会创建出一个属于自己的进程地址空间,并且完全复制一份父进程的页表。当我们需要对子进程页表当中的数据进行修改的时候再重新创建一个新的空间,进行映射。
但是在我们虚拟地址和物理地址的映射过程中,虚拟地址是进程独有的,但是物理地址是公共的。所以我们只需要修改物理地址即可,虚拟地址即使相同也没有关系。这也是我们一个相同的地址当中可以保存两个不同的数据的原因,相同的只是我们虚拟出来的地址,真正存储数据的物理地址实质上还是不同的。
父进程创建子进程流程示意图:
为什么要有进程地址空间
可能还会有人会问:那么有这个虚拟内存有什么用处呢?我们直接访问物理内存不是也可以达到同样的效果吗?那么我们接下来就来认识一下进程地址空间的作用:
1:有了进程地址空间可以让我们的程序统一的看待内存。
我们在上面说到过:进程地址空间是属于每个进程所独有的,每个进程在运行的时候都会创建一个属于自己的进程地址空间。这个进程地址空间会占据我们所有的内存。让一个CPU完全为我们该进程服务。所以当运行一个进程的时候可以调度使用CPU的所有资源,不用考虑资源被占用的情况。
假如没有进程地址空间这个虚拟地址的话,我们怎么分配内存又是一个问题。因为对于我们的物理地址来说,所有的地址都是一样的。因为有了不同的定义才有了不同的区域。(mm_struct)
当然,我们也可以使用一个结构体将物理地址进行区域的划分,但是这样的过程远没有虚拟出一个内存,将物理地址进行逐个使用来的方便。
2:增加一个转换的过程,便于我们对寻址请求的审核,保护物理内存。
想象一下,如果没有虚拟出来的地址空间。我们想要访问数据会直接读取物理地址上的数据,但是我们又不想让这个数据被读取。我们在还没有进行处理的时候,数据就已经传到用户那里了。这样就会危害我们操作系统的安全。
进程地址空间还发挥一个中转器的作用。我们可以先访问进程地址空间,之后再返回一个状态,如果允许就继续执行相应的操作。如果不允许就直接驳回。增加了我们操作系统的安全性。
3:进程地址空间和页表将进程管理模块和内存管理模块进行解耦合。
就像我们在上面画的进程在执行的时候所进行的操作示意图中所展示的那样:
我们将上面红色的部分成为进程管理模块,蓝色的部分成为内存管理模块。其中进程地址空间和页表的作用就是降低进程和内存管理的耦合程度。便于我们对错误的查找以及修改。
你可以想象一下:如果没有进程地址空间和页表,我们进程出现错误就会产生一个错误的数据,由于错误的数据又会直接向物理内存中写入数据。更有甚者,进程崩溃,也有可能导致内存的崩溃。一旦一方有问题就会引起一连串的连锁反应。
有了进程地址空间和页表之后,我们这两部分就会在交互的时候有一个中转的机制。在确认无误之后再进行下一步操作。不会因为一方的失误引起另一方的损失。
页表细节补充
由于我们每一个进程都需要创建一个属于自身的进程地址空间以及页表。当我们在需要进行进程切换的时候,就会需要访问自身相对应的数据。所以我们的进程地址空间和页表当中的数据就会和我们进程当中的其他数据一样需要被带走,等到我们的进程再一次需要运行的时候再次被切换到CPU当中。其中进程地址空间和页表的数据也保存在相应的寄存器当中,同样成为进程的上下文数据。
权限控制
在我们的页表当中不仅仅有虚拟地址和物理地址两个字段还有很多其他的字段。比如权限字段。想象一下:假如我们想要区分一个文件的权限是只读还是只写应该怎么办呢?
由于我们的文件同样是存储在内存当中的,所以也需要通过页表进行映射操作。在通过页表进行查询文件的地址的时候,还会访问文件的权限。我们在页表当中规定一列存储着rwx相关的字样。如果只为r就代表只读文件,rw就代表可读可写文件。通过这个字段就可以进行文件权限的区分。
进程挂起状态的判断
还有就是我们之前学到的进程的挂起状态。当我们的内存不够的时候,就会将我们进程所对应的数据加载到磁盘当中进行存储,等到我们需要运行该进程的时候重新将数据加载到我们的内存当中。
其中判断一个进程是否处于挂起状态也就是一个进程的代码和数据是否在内存当中的判断同样是通过页表所执行的。
当我们的进程被设置为挂起状态的时候,就会将虚拟地址所映射的物理地址删除。并采用一个字节的大小设置数据的状态。比如1表示数据在内存当中,0表示不在内存当中。当经过判断之后数据不在内存当中就会暂停数据的访问。会在物理内存当中开辟一段空间,将数据重新加载到内存之后再继续进行判断。这个暂停的过程就叫做缺页中断。
我们写时拷贝的原理也是缺页中断。当我们需要对一个变量进行修改的时候,就先进行暂停,开辟一段新的空间复制变量,之后再进行数据的修改。
操作系统对大文件的处理
在我们的日常的生活当中经常会玩一些大型的游戏。这些游戏往往十几个G甚至几十个G。大小远远大于我们的内存,我们又是怎么运行这些大文件的呢?
我们的操作系统对于大文件会优先将一部分文件加载进入内存当中,当我们对该部分数据进行操作完毕之后,再将其他部分数据更换到内存当中。这个更换的过程实质上也是使用了缺页中断的原理。我们在更换数据的时候会暂时暂停操作。也就是我们平常遇到的更换地图加载进度条的原理。