欢迎来到博主的专栏:从0开始linux
博主ID:代码小豪
文章目录
- 进程空间地址
还记得博主在之前介绍子进程时说过的话吗?子进程与父进程共享代码,而数据却不共享;这很好理解,因为子进程和父进程是不同的进程,根据进程的独立性,子进程和父进程不能相互影响,所以数据是不能互通的,但是这个观点我们好像一直没有去证实过,现在我们来写一个如下的代码,以证明博主所言非虚。
#include<stdio.h>
#include<unistd.h>
int gval=100;//全局变量
int main()
{
pid_t ID=fork();
if(ID==0)
{
//子进程执行
gval++;//子进程中修改数据,看看会不会影响父进程
printf("i'm subprocess PID:%d PPID:%d gval=%d &gval=%p\n",getpid(),getppid(),gval,&gval);
}
else if(ID>0)
{
//父进程执行
printf("i'm parent process PID:%d PPID:%d gval=%d &gval=%p\n",getpid(),getppid(),gval,&gval);
}
sleep(1);//让进程休眠一秒在关闭,避免看不到子进程的打印信息
return 0;
}
接着我们运行,查看打印结果。
可以看到,子进程的gval被修改了,并不会影响到父进程,因为它们的数据不共享。换句话说,父进程中的gval和子进程的gval压根就不是同一个变量。但是我们往后看,咦?父进程和子进程的gval的地址竟然是一致的。
两个相同地址的变量,其数据一定是共享的,如果一个地址的数据竟然能读出两种结果,这就好像两个人竟然有一样的身份证号一样不可思议!因为我们知道,内存中的数据实际是0和1组成的,而变量显示的数据其实是读取内存空间块的结果,这就说明,在同一个内存空间块中,不会读取出两种数据。
但是事实就摆在眼前,相同地址的变量竟然被读取出了不同的数据,但是实际上,相同地址的变量可以被读取出不同的数据,而在同一个内存块中,不会读取出两种结果。这并非互相矛盾,而是因为:变量的地址都是假的!!!
这里就有人说了,怎么可能地址是假的,我都用指针修改过无数次数据了,地址不可能是假的,别急,我们慢慢来说。
进程空间地址
相信大家在学c\c++的时候都听过这么一句话,全局变量和静态变量位于静态区,局部变量在栈区,而动态管理的内存开辟在堆区,好吧,我们说了这么久这些东西,但是似乎还未真正的去认识它们。
首先,这些分区其实和语言并没有太大关系,而是和进程相关。这些变量都是在进程运行的过程不断被生成或者销毁的,因此这些不同的分区,实际上就是进程空间地址。
我们从低地址到高地址开始介绍,首先,最底下部分存储的正文代码,比如说函数的地址。往上则是静态区,其中,初始化与未初始化的变量又存在于不同的分区中,堆区主要存储动态内存,随着生成在堆区的数据不断变多,堆区会向上扩展。栈区则是存储局部变量,或者函数栈帧的分区,大部分都是一些临时的数据存储于此。最顶部的数据则是命令行参数和环境变量了,这两也是进程重要的数据。
那么如何证明呢?很简单,我们将对应的变量的地址打印出来呗,看看高低。
#include<stdio.h>
#include<stdlib.h>
int val=100;//声明了的全局变量
int unval;//未声明
int main()
{
int a=100;
int* pb=malloc(sizeof(int));
printf("stack addr:%p\n",&a);//栈区
printf("heap addr:%p\n",pb);//堆区
printf("static addr:%p\n",&unval);//静态区的未初始化数据
printf("static addr:%p\n",&val);//静态区的初始化数据
printf("code addr%p\n",main);//代码正文区
return 0;
}
既然这个进程中的地址是假的,那么我们就将其称为“虚拟地址”吧。
那么为什么进程放着好好的真实地址(物理地址)不用,要用虚拟地址呢?我们先来搞清楚这个虚拟地址到底是什么东东。
我们的物理地址,其实就是存储器当中的内存空间(即硬件),而虚拟地址,则是一个程序(即软件)。在前面不是提到了,一个进程是由task_struct管理的,而在这个task_struct当中有一个成员,名为mm_struct,实际上这个mm_struct就是管理进程虚拟空间的成员。空口无凭,博主在这里放上linux的源码。
struct task_struct
{
//省略前面的内容
struct mm_struct *mm,*active_mm;
};
这个mm_struct本质上就是上图所说的进程空间,即栈区,堆区等等的分区,都在这个mm_struct当中。
我们可以设想一下,这个mm_struct到底是怎么管理进程空间的,比如栈区中的地址,堆区中的地址,到底是什么样的数据,什么样的方法将它们记录下来的。
最容易想到就是用指针来指向每个地址,毕竟指针就是这样用的(笑)。这个方法实际想想就不太可能,以32位的计算机为例,想要将32位计算机的内存物理地址一 一存储起来,就需要创建2^32个指针(因为32计算机的地址是由32个二进制表示的)。而每个指针都有8字节,那么就需要内存4*2*1024*1024*1024(即4*2^32)个字节,这就要16G的内存空间。那么如果要内存分出16个g来管理进程,那么这个进程还要不要运行了?所以关于如何管理进程的内存空间,肯定有更高效的方法。
既然我们连地址都虚拟了,那不如更彻底一点,我们将整个空间都虚拟了,程序说它有一个变量要放在堆区间当中,要操作系统为其开辟一个在堆区的空间,那么操作系统答应下来,但是实际上,这个变量最终还是要放在物理地址(存储器中),而不是放在mm_struct。那么操作系统也就选择放开自我,既然进程想要一个堆区间,那么就满足它,给它一个对区间,这个堆区间是一个连续的区间,只需要用两个指针,来指向堆区间的起始地址,与结束地址就好了!!!
当进程中的变量要想存储在堆区间,那么就要有对应的地址,操作系统会在heap_start和heap_end之中选一个地址给它,如果堆区间的地址都用完了,就让heap_end往上指,那么新的空间不就又出来了吗?
这可不是博主在吹牛,而是代码真是这样写的,我们来看看mm_struct的代码。
比如start_brk就是堆区的起始位置,start_stack是栈区的起始位置,这里博主就不挨个说明了,虽然这些数据的类型是unsigned long,但是实际上,这些数据存储的是地址,因为unsigned long和指针类型的变量,存储的数据位数是相同的。
这就说明了,我们在进程中创建的变量,函数以及各种与有地址的东西,它们的地址值都是假的,是操作系统给的虚拟地址,通过mm_struct来管理。
但是不管这些有的没的,进程中的一个变量不管用的是什么虚拟地址,它最终还是放在存储器中,也就是说这个变量一定要有真实的地址值。那么虚拟地址与真实地址的关系是什么?
在虚拟地址与物理地址之间存在一个东西链接着它们,那就是页表。
这个页表会记录一些进程信息,比如说虚拟地址与真实地址之间的映射。
但是由此,我们好像还没解释完最初的那个东西,即为什么子进程中变量的地址,会与父进程变量的地址一致,这其实也与页表有关,我们已知gval在静态区中。那么在页表中的映射则是这样
而创建子进程时,操作系统为了省事,直接就将父进程的页表给了子进程用。因此父进程与子进程的gval的虚拟地址是一致,但是真实地址则不是。
但是并非所有的数据的真实地址都要改变,因为我们在之前的文章提到了,父进程与子进程的代码是互通的,只有数据不互通,因此父进程与子进程的代码的真实地址则不用变。
实际上刚刚创建页表的时候,父子进程虚拟地址与真实地址的映射关系都是一致的,包括变量(比如gval),但是,如果某个进程,将这个变量的值改变了,此时系统为了保持进程的独立性,即不让一个进程的运行影响到另一个进程,于是就会将被修改的变量的地址,移动到其他地方,这种方法我们称为:写时拷贝。
换句话说就是,如果我们不在子进程中修改gval的值,那么实际上父子进程将会共用一个gval,包括它的真实地址,当gval被修改时,操作系统就会办gval重新找个真实地址,但是gval虚拟地址则不会改变。