目录
一、前言
在【Linux】进程-CSDN博客 中,讲fork方法创建子进程的时候曾经提到过,父子进程共享代码和数据,并在修改数据时发生写时拷贝。
写时拷贝使得一个变量能够有两个不同的值,但是如果我们在父子进程中分别打印这个变量的地址的话,会发现二者是相同的。也就是相同的地址竟然有不同的值!
这完全颠覆了过去我们对内存的理解,同样的地址怎么可能读出不同的内容呢?
二、地址区域划分
在过去的学习中我们曾经肯定见过这样的空间布局图
临时变量存储在栈区,动态分配空间创建的变量存储在堆区...这些内容就不过多赘述了
以前我们可能会认为这就是内存,实际真的是这样吗?
如果这就是内存,那么同样的地址必然不可能存储不同的内容
我们可以通过下面这段代码验证:
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0) //子进程
{
g_val = 0;
printf("I am child, pid : %d, ppid : %d, g_val : %d, &g_val = %p\n", getpid(), getppid(), g_val, &g_val);
}
else
{
printf("I am parent, pid : %d, ppid : %d, g_val : %d, &g_val : %p\n", getpid(), getppid(), g_val, &g_val);
}
return 0;
}
其中我们在子进程中对g_val进行修改,同时在父子进程中打印出g_val的值和地址
运行代码,结果如下:
可以看到父子进程中的g_val内容是不一样的,因为发生了写时拷贝
但是父子进程中g_val的地址却是一样的
如果g_val的地址是物理地址,就绝对不可能存在这种情况。
实际上,我们在过去C/C++的学习中见到的地址全都不是物理地址,而是线性地址,又称为虚拟地址。而物理地址,我们是看不见的,由操作系统进行管理。
何为线性地址/虚拟地址?
三、如何进行划分
这里以32位计算机为例,32位计算机中有32根地址总线,每根总线只有0和1两种情况,32根总线就有2^32种排列,每一种排列就是一个地址,对应1byte,所以2^32个地址加起来就是4GB的空间
在划分堆区、栈区等对应的区域时,我们只需要用结构体来维护每个区域的起始地址和结束地址,就可以实现对地址空间的区域划分了。
在Linux中,这些信息都包含在一个叫做mm_struct的结构体中:
四、进程地址空间的基本概念
我们知道一个进程需要包含自己的PCB、代码和数据
为了能够让进程更好的运行,除了需要创建PCB之外,还需要为进程创建一个进程地址空间。
而前面看到的空间布局图,实际上就是进程地址空间。而真正的物理内存则没有这些划分,是一大块空间。
每个进程都完整的拥有自己独立的进程地址空间,这块空间和我们机器的内存一样的大。所以进程地址空间不是物理内存的一部分,而是一个虚拟的空间,其中的地址就是虚拟地址。
进程地址空间是一个抽象的概念,用于描述进程如何看待和使用内存。本质是内核的一个数据结构对象,类似于PCB,也需要被操作系统管理。
前面提到对进程地址空间进行区域划分的方式,就是描述进程地址空间的方式。
五、页表
数据存储在物理内存中,但我们平时使用的又是虚拟地址,所以虚拟地址和物理地址之间一定要建立某种联系。
具体实现,则需要一个叫做页表的东西。进程在创建的时候除了PCB、进程地址空间等,还会创建一个页表。
何为页表?页表是存储虚拟地址和物理地址的映射关系的表,进程地址空间中的虚拟地址可以通过映射来找到物理内存中的地址。
例如我们创建了一个变量,首先在进程地址空间中找到其对应的区域并分配一个虚拟地址,然后在物理内存中开辟一块大小合适的空间并分配一个物理地址,然后在页表中对二者建立映射关系。
这样,我们就可以使用虚拟地址来对物理内存进行修改了。
++权限位++
页表中除了虚拟地址和物理地址,还有一个位置用来标记该地址中的数据是可读写还是只读,即权限位。
例如当我们试图修改一个存储在字符常量区的数据时,程序会崩溃,因为页表中位于该区域的地址的权限被设置为了只读。
为什么代码段和常量这种只读的数据在一开始可以进行写入呢?因为物理内存中是没有这些区分的,只有写入后在页表中标记该数据的权限,才会赋予其可读写或只读的特性。
++惰性加载++
进程在创建的时候先创建内核的数据结构(PCB、进程地址空间和页表),然后再采用惰性加载的方式加载代码和数据。
何为惰性加载?一些几十GB的软件是无法整个加载到内存中的,只能采取分批加载的方式,一次只加载一部分(如几百MB)。
但是进程是有时间片的,在一次运行期间可能只能运行几MB的代码,而剩余加载到内存中的代码就会浪费内存空间,所以采用惰性加载的方式,运行多少代码就加载多少代码。
创建进程的时候可以给程序的所有代码和数据都分配一个虚拟地址,但是每次只给需要运行的部分分配物理地址,其他的部分为空,因此页表中还有另外一个位置用来标记对应的代码和数据是否已经被加载到内存中。
可以说明,挂起状态实际上就是将进程的代码和数据换出到磁盘中,然后将进程页表的物理地址清空,再标记当前进程的代码和数据处于未加载到内存中的状态
六、进程地址空间的作用
现在我们知道了我们看见的地址实际上是进程地址空间中的虚拟地址 ,每个进程都有自己独立的进程地址空间和页表 ,虚拟地址和物理地址会在页表中建立映射关系。
所以在父进程创建子进程的时候,子进程也会有自己独立的进程地址空间和页表。
而子进程的进程地址空间与页表和父进程相同,所以父子进程中对应的地址全都相同,也就能够共享物理内存中的数据和代码了。
当发生了写时拷贝,操作系统会给子进程新开一块空间用于存储修改后的变量,然后修改子进程页表中的物理地址,此时父子进程对应变量的物理地址 就不同了,但是虚拟地址 还是相同的。这就是为什么相同的地址会出现不同的值。
虚拟地址和物理地址的映射使我们可以随意的在物理内存中的任何地方存放数据,然后通过映射在进程地址空间中建立连续线性的地址空间并呈现给进程,将无序变为有序,可以减小内存的管理成本。
给每一个进程创建独立的进程地址空间,就像操作系统给所有进程都画了个大饼。每个进程都认为自己能够独享系统内存资源,实际上操作系统并不会允许进程一次性开过大的空间。
进程地址空间除了能够让进程以统一的视角来看待内存,还能够起到保护物理内存的作用。
增加一个虚拟的进程地址空间可以让我们在访问内存的时候增加一个额外的映射过程,在这个过程中就能够对我们的寻址请求进行审查,一旦异常访问则直接拦截,可以有效的保护物理内存。
如有错误或缺漏欢迎在评论区指出
完.