1. 地址空间的验证
之前我们在学习语言时,曾知道有下面这张图
对于这个图我们可以用下面的代码验证
运行后我们可以发现
其对应关系如下
我们使用fork函数,来分别对父子进程中的g_val进行修改,即
运行后我们可以发现
在子进程修改了g_val后,父子指向同一地址时,但是读取到了不同的内容,我们可以知道如果变量的地址是物理地址,那么上面的情况不可能发生,因此这里的地址绝对不是物理地址,我们将其称为线性地址(或虚拟地址)。
2. 地址空间
对每一个task_struct,Linux都会为其单独创建一个进程地址空间与页表,页表中存放着虚拟地址与物理地址的映射关系,当fork创建一个子进程后,子进程会将父进程的进程地址空间和页表给自己拷贝一份,然后在修改g_val时,他会通过虚拟地址找到物理地址请求向这个物理地址进行写入,操作系统在发现这块物理地址是共享的之后,会开辟一份新空间并放入50,然后修改子进程中页表的映射关系。在这里,g_val修改后由操作系统自动完成写时拷贝,而在重新开辟新空间这个过程中,页表左侧的虚拟地址是0感知,即不会影响它。而在打印的时候,打印出来的虚拟地址相同而映射关系不同,因此访问出来的空间(结果)不同。这也侧面解释了之前讲解fork时,为什么id既可以>0又可以=0。
3. 进程地址空间的细节
①什么叫做地址空间?
首先我们要知道,在32位计算机中,有32位的地址的数据总线,内存与CPU之间为系统总线,内存与外设之间为I/O总线,
由于每一根总线为0/1,一共32根总线,因此一共有2^32种情况,每种情况为1byte计算下来便是4GB,对于每一根总线来说为0或者为1在硬件的体现上就是充放电,如果最后每一根组合出来的为1111 .... 1111,那么它的意思就是要访问地址为1111 ,,,, 1111的那一块空间。所有的总线排列组合形成的地址范围就是地址空间,范围为[0, 2^32]。
那么地址空间为何要对不同的区域作出划分呢?
在这里我们举一例子来帮助我们理解,在小时候我们都曾有过划三八线的经历,而划出来的三八线的本质就是进行区域划分,对于一个100cm的桌子我们规定属于自己(mine)的范围为[0, 50],属于同桌(mate)的范围为[51, 100]。即我们可以设计一个struct结构体来描述它,即
在定义对象并初始化后,对于我来说我可以访问[0, 50]的任意位置,那么对于空间区域调整(即变大变小),我们又该如何理解呢?非常简单,我们只需要修改mine的end和mate的begin即可解决。
说了这么多,那到底什么是地址空间呢?
地址空间本质是描述一个进程可视范围的大小,地址空间内一定会存在各种区域的划分,对线性地址进行start与end标记即可,它是内核的一个数据结构对象,类似于PCB,地址空间也是需要被操作系统管理的。
其结构大致如下
那么在知道了地址空间的存在后,我们如何判断数据或者指针是否越界呢?——判断其是否落在对应区间内。
②什么叫进程地址空间?为什么需要它的存在?
在这里我们先举一个例子来方便我们理解,有一个富人拥有100亿的财富,他有3个私生子(互相不知道彼此存在)并且它对每一个儿子说我为你留了100亿的遗产,此时对于每一个私生子来说都认为自己会拥有100亿的财富。而在操作系统中,这个富人就是操作系统,这100亿的财富就是进程地址空间,每个私生子就是一个一个的进程。在进程被创建时,会先创建其内核数据,再加载对应的可执行程序。
那么为什么需要进程地址空间的存在呢?
1. 首先,有了进程地址空间中的页表,可以让所有进程以统一的视角看待内存;
2. 增加进程虚拟地址空间可以让我们在访问内存时,增加一个转换的过程,在这个转化的过程中,操作系统可以对我们的寻址请求进行审查,一旦访问异常就会直接拦截,该请求不会到达物理内存进而可以保护物理内存;
3. 由于有地址空间和页表的存在,可以将进程管理模块和内存管理模块。
此外,我们就能更加具体地解释之前的一些问题了,如我们知道代码和字符常量区是只读的,那么它是如何做到的呢?——在页表中有第三栏的选项,在代码和字符常量区所匹配的页表中,将其对应权限设置为"r"(只读)即可。还有,我们知道进程可以被挂起,那么我们如何知道代码和数据在不在内存呢?——在页表中有第四栏的选项,这一栏能判断对应的代码和数据是否已经加载到内存。
其实,在虚拟地址试图访问物理地址时,检测到物理地址为0时,此时会发生缺页中断,操作系统就会从磁盘中向物理内存申请一块空间,并将其地址填到对应的页表中,这样在访问虚拟地址时,就能够访问到对应内容了。从本质上来说,写实拷贝也会触发缺页中断。
此时,我们对于进程具有独立性是如何做到的便有了一个新的理解,首先是每一个进程都有其独特的PCB,其次每个进程的页表中的映射关系不同,而页表存在意义就是将物理内存中的无序变为进程地址空间中的有序。