在第一讲中会有一点陌生的概念,之后的讲解中都会逐渐引出,循序渐进。
一、引入
我们先直接让大家看一个现象,来认识一下什么是地址空间。
先看代码:
代码描述:
我们定义一个全局变量g_val,让父子进程同时打印这个数和这个数的地址,当子进程中的cnt计数到达5次时,我们在在进程中将g_val的值改成300,来观察一下现象。
运行结果:
我们可以看到,在子进程没有改变g_val之前,父子进程打印出来的值都为100,地址也都是一样的,但是当子进程改变g_val的值之后,父进程打印出来的值仍为100,子进程的值变成了300,但是,他们的地址居然是一样的,这就很令人疑惑:为什么地址一样,但是数据不一样。
首先,父子进程中的g_val值不同我们理解:父子进程是相互独立的,既然是相互独立,那么每个进程所拥有的内核数据结构task_struct和其所拥有的代码和数据都应该是相互独立的。
这都好理解,主要是为什么地址一样呢?虽然我们现在不清楚,但是我们知道这个地址绝对不是物理地址,如果是物理地址,那么它在内存的一块空间存着,怎么可能会出现g_val的值既是100又是300的呢?所以这个地址不会是物理地址,那么我们这里要提出一个概念:虚拟地址。
二、对程序空间的基本理解
1、操作系统中的地址空间
在可执行程序被执行之前,我们的可执行程序在磁盘当中,执行时,会将进程对应的代码和数据在内存中开辟一段空间存放进去,这个过程我们已经很熟悉了。
我们的操作系统,在进程运行时会给每个进程分配一个地址空间,里面所存放着的是我们所有变量的地址,这个地址空间在32位下是标准的4G,每一种变量规规矩矩的在地址空间中排列着,我们可以通过这个地址空间中的地址,去找到内存中变量的值。这个地址,就是虚拟地址。
在我们的计算机体系结构中,还有一种与地址空间对应的东西,叫做页表(之后做详细讲解)。
这个页表的工作原理,就是将地址空间的虚拟地址,与内存中变量所在的位置建立映射关系,之后进程通过虚拟地址访问时,操作系统会查页表来找到你要访问的内存中的位置。
2、对上述代码的问题做出解释
程序地址的本质也就是内核数据结构中的一个对象,这样才能先描述,再管理,让操作系统更好的去管理,当进程执行的时候操作系统会将进程代码中与地址空间对应的变量全放到地址空间中,也就是地址空间的属性。我们父进程中有一个已经初始化的g_val全局变量,那么,这个变量的虚拟地址,应该在地址空间的初始化数据模块,并且在内存对应的数据中,有一块g_val的空间。
创建子进程后,我们的子进程会将父进程的地址空间和页表都拷贝一份,这样,我们的子进程也能通过虚拟地址在去页表中在内存中找到对应的g_val的物理地址,进而拿到g_val的值。
所以,在g_val的值没有改变之前,父子进程拿到的g_val的值都是100。
等到子进程要修改g_val的值的时,因为父进程与子进程之间应该是相互独立的,不能被对方影响,所以当g_val的值要被修改时,父进程会看到子进程的这一做法,进而操作系统会介入,会在内存中另一块区域新开辟一块空间,存放的也是g_val的值,并将页表中原来虚拟空间对应的那块物理空间删除,将新开辟空间的地址存入,接着再执行子进程修改g_val的动作,将100变成300。
这个操作流程下来全部由操作系统自主完成的,我们把这一操作叫做写时拷贝。
我再讲一下这个过程:
我们的进程是要通过虚拟地址,在页表中找到对应的物理地址后,才能在内存中拿到一个变量的值,由于子进程的地址空间和页表都是拷贝的父进程的,所以最开始页表的虚拟地址和指向的物理空间位置都是一样的。
后来改变值时,为了让父进程不受影响,OS会开辟一个新的空间,把变量的旧的物理地址替换成这个新开辟的空间的物理地址。
父子进程之所以该变量的地址相同,是因为他们的虚拟地址一样,值不相同,是因为他们的物理地址不同,指向的同一内存位置不同。
3、写时拷贝存在的意义
这里我有一个问题,为什么不在创建进程时,让子进程把父进程的所有数据都拷贝一份,这样不就不用非得开辟空间了吗?
首先,我们并不能保证父进程中的变所有变量对子进程都有用,当没有用又拷贝了时,无疑是对空间的一种浪费。
其次下,写时拷贝,能做到按需分配,你需要的话我就给你开辟对应变量的空间,不需要则不会开辟,完美的避免了空间浪费的问题。
三、更进一步理解地址空间和写时拷贝
1、什么是区域划分
在地址空间中,有着堆区、栈区等等被划分成一块一块的区域,那这是怎么划分的呢?
地址空间的本质其实就是内核的一个struct结构体,名叫mm_struct,管理着各个区域的区域划分。内部有很多的属性表示strat,end的范围。
那栈举例:
因为地址空间由低到高,所以start指向下面低地,end指向高地址
为了更加证实这一点,我们看一下内核数据结构 :
划分的这段区域,我们是都可以使用的
2、 为什么要有地址空间
2.1 将无序变成有序
如果没有地址空间时,我们的众多进程直接指向物理空间内存的一块区域,鱼龙混杂。
并且进程去内存中找对应的变量时,又是没有顺序的在里面寻找。
但是当有地址空间时,每个进程的地址空间内的区域划分顺序都是一定的,我们想要找哪个变量,就去地址空间中,这个变量对应的存储区找就可以,不用管代码数据在内存中是怎样的,因为找到这个变量的虚拟地址后,根据页表映射,就可以直接找到变量在内存中的位置。
这样以来,就将无无序变成有序,让进程以同一的视角看待物理内存以及自己运行的各个区域。
2.2 进程管理模块和内存管理模块进行解耦
如果没有地址空间,进程在申请空间(比如malloc)时,申请后会立马在内存空间开辟一块内存,等待进程的使用。
那如果我们进程申请后在很长一段时间都没用呢?又恰好内存空间不足,需要释放一些空闲的内存,好让其它进程申请空间呢?
进程申请了一块内存,但很长时间不适用,这无疑会造成空间的浪费,因为在这段等待使用的时间段内,万一有别的进程需要申请空间,并且这个进程申请完立马会用呢?
地址空间的存在可以避免这个问题:
我们需要申请空间时,可以不直接申请内存的空间,而是先申请对应的虚拟地址空间,在页面中记录下这个虚拟地址,但是物理内存地址不做分配,等到进程要使用这个空间时,操作系统再在内存中申请对应的空间,这样就避免了空间的浪费。
2.3 对非法请求进行拦截
当我们访问堆区或者栈区时,如果我们访问越界,超出这个空间的区域,那么这个请求就是非法的。
当访问虚拟空间越界时,操作系统(不仅仅是操作系统,之后再细说)仍然会去页表中查找虚拟地址对应的物理空间地址,如果查不到,那么就会立马对这个请求进行拦截。
所有的非法请求,都不能通过物理空间到内存上,所以,拦截非法请求,本质上就是在保护内存。
3、进一步理解页表
我们不要把页表想象的过于简单,具体详细内容在之后都会讲解。
在操作系统中,找到虚拟地址由页表找到对应的物理地址这一操作其实是CPU进行的,在CPU内有一个MMU工作单元,和一些寄存器。
寄存器会将当前页表保存在CPU内,MMU这个内存管理单元会将虚拟地址结合页表快速的转化出对应的物理地址。
在页表中其实还有着标志位和读写权限的概念,
- 标志位:来确定当前的物理内存是否在内存当中,用1表示是,0表示否。
- 读写权限:权限越界,终止违规请求的进程。
先简单说说标志位:
刚开始,我们的进程对应的代码和数据都在内存里,所以页表中的标志位都是1,但是如果进程在等待某些硬件,处于等待状态时,我们为了避免内存紧张,我们需要将这个进程挂起,也就是将这个进程的代码和数据暂存到磁盘,都等待完成时再放入内存。
接着了解一下读写权限:
用一个代码举例:
*str可以修改吗?答案是不可以,因为*str在常量区,不可以被修改,也就是说,它只有读权限,没有写权限。
当操作系统发现这个值要被修改时,会去页表中查看其对应的权限,如果发现只读,那么就会立马终止这个进程。
如上是对程序地址空间的初步了解,之后会给大家带来更深入的认识