一、🌟问题引入
🚩代码一:
#include<stdio.h>
#include<unistd.h>
int g_val=100;
int main()
{
pid_t id=fork();
if(id==0)
{
//子进程
while(1)
{
printf("I am a child pid:%d ppid:%d g_val:%d\n",getpid(),getppid(),g_val);
sleep(1);
}
}
else
{
while(1)
{
printf("I am a parent pid:%d ppid:%d g_val:%d\n",getpid(),getppid(),g_val);
sleep(1);
}
}
return 0;
}
可以看出子进程与父进程中g_val的地址值是一样的,这是因为子进程继承了父进程的代码和数据,两者共享数据
🚩代码二:(子进程修改数据)
#include<stdio.h>
#include<unistd.h>
int g_val=100;
int main()
{
pid_t id=fork();
if(id==0)
{
//子进程
int cnt=5;
while(cnt)
{
printf("I am a child pid:%d ppid:%d g_val:%d &g_val:%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
cnt--;
}
if(cnt==0)
{
g_val=0;
printf("g_val changed: g_val:%d &g_val:%p\n",g_val,&g_val);
while(1)
{
printf("I am a child pid:%d ppid:%d g_val:%d &g_val:%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
}
else
{
while(1)
{
printf("I am a parent pid:%d ppid:%d g_val:%d &g_val:%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
return 0;
}
我们发现,父进程与子进程中g_val的值不一样,这可以理解,因为进程之间具有独立性,但是为什么他们地址是一样的呢?
⚡得出以下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址
- 在Linux地址下,这种地址叫做 虚拟地址
二、🌟地址空间
2.1 💬如何理解地址空间
进程 = 进程控制块(task_struct)+ 代码和数据
操作系统维护进程不止通过进程控制块(task_struct),每个进程其实还存在一个地址空间,它将进程的代码和数据分成不同的区管理起来,地址从低到高有正文区、初始化数据区、未初始化数据区、堆区、共享区、栈区、命令行参数与环境变量区,但真正的数据并不保存在地址空间中,地址空间仅提供连续线性的一串地址,这种地址叫虚拟地址, 通过虚拟地址映射到数据真正存储的物理地址,我们在用C/C++语言所看到的地址,全部都是虚拟地址,物理地址用户一概看不到,由OS统一管理,操作在Linux操作系统中地址空间是用一个叫 mm_struct 的结构体维护起的
虚拟地址映射到物理地址要通过页表,目前可以把他理解为保存着虚拟地址到物理地址映射关系的表
2.2 💬问题解释(写实拷贝)
父进程创建子进程的时候,子进程会将父进程许多的内核数据结构都拷贝一份,其中就包括了页表,父进程在页表中保存了g_val的映射关系,所以子进程也可以访问到g_val,这就是父子进程中g_val的值与地址相同的原因。
当子进程要修改g_val时,由于父子进程对g_val映射到相同的物理地址,父进程中的g_val也会随之修改,但是为了满足进程独立性的特点,操作系统会在物理内存中重新开辟一段新的空间,
并将g_val的值拷贝过来,这时子进程的页表就会重新映射物理地址,这时子进程修改g_val,父进程就不会收到影响了,这个操作全程由操作系统自主完成,称之为写实拷贝
ps:如果一个全局变量父子进程都不进行修改,两者是进行共享的,并不会直接让子进程拷贝一份,这时因为许多变量父子进程一般不会进行修改,但这些数据却很大,比如说环境变量等,直接拷贝一份会很浪费空间,所以当其中一方要修改时,才会进行拷贝,通过调整拷贝的时间顺序,达到节省空间的效果
2.3 💬地址空间的意义
1. 让无序变成有序,让进程以同意的视角看待物理内存,以及自己运行的各个区域
【解释】:
一个进程的代码和数据在物理内存中的存储不一定是连续的,可能会根据数据类型的不同分别存储在物理内存的各个地方,此时进程要管理这些数据,就需要在task_struct中讲这些散乱的数据分别管理起来,当进程很多的时候,就会造成数据混乱,不利于操作系统的内存管理。
而每个进程拥有自己独立的地址空间后,就不会被物理内存中的杂乱的数据影响了,管理数据只需要将虚拟地址通过页表映射到物理内存中即可
2. 进程模块与内存管理模块发生解耦
【解释】:
当我们在写C语言程序时,比方说要使用堆空间,可以利用malloc函数开辟好,但是可能代码跑了很长时间后才会使用这段空间,那这段时间别的进程就无法利用这段空间了,会造成空间的浪费,有了虚拟地址和页表的概念,当进程需要开辟空间时,操作系统只需要将虚拟地址填入页表,但并不构建映射关系,当进程需要使用这段空间时,操作系统才会在物理内存开辟好空间,再构建映射关系,这样就可以大大提高空间的利用率。通过虚拟地址和页表构建映射关系等操作为进程的管理,在物理内存开辟空间等操作为内存管理,地址空间可以使两模块发生解耦,提高系统的资源利用
3. 拦截非法请求,保护操作系统
【解释】:
当进程想访问一个地址的内容时,操作系统会先检查页表中是否存在该虚拟地址,如果不存在,就拦截这个请求,并报错提醒,防止其向物理内存修改数据,这就是为什么我们写程序有非法访问时程序会报错,而不是操作系统直接崩溃的原因
2.4 深入理解页表与写实拷贝
其实页表并没有上述讲的这么简单,其内部还有许多寄存器和字段信息等,例如数据是否存在内存中、rwx权限等,我们可以举两个例子理解一下,本文只是初识地址空间,关于页表的更多信息后续会讲。
(1) 理解常量区修改
我们在写程序时,对常量区的内容进行修改时程序会发生报错,那为什么修改别的区的内容是程序就不报错呢?这是由于页表中存在着对数据rwx权限判断的字段,操作系统会讲常量的数据权限修改为只读,当我们要修改常量区内容时,操作系统拿着虚拟地址在页表中找映射关系进行修改,发现该数据没有写权限,就会拦截这个请求,并报错,保护物理内存。
(2)系统是如何检测写实拷贝的
当父进程创建子进程时,会将父子进程对数据的rwx权限仅保留读权限,当父子任意一方想要修改数据时,就会检测到错误,这时操作系统就会检测是否要发生写实拷贝
(3) 如何理解fork函数返回两个返回值
当fork( )函数运行完代码,会return一个返回值,pid_t id = fork() ,return的本质就是修改id
的值,此时就会发生写实拷贝,父子进程就会各自返回一个值