进程地址空间这个名词可能对于大家来说略显陌生,但是程序地址空间对于学习过C语言的人来说就不陌生。因此,我们首先复习一下程序地址空间。
1.程序地址空间
1.1 空间布局图
相比大家在学习C语言的时候都见过这份图,但是我们对于这个图并不熟悉
首先请问大家,程序地址空间是内存吗?
其实程序地址空间其实叫做进程地址空间,而进程是操作系统上的概念。因此,进程地址空间分为如上图所示的几个部分:
1.2 地址空间验证
为了更好的理解地址空间分布,我们用代码来感受以下:
#include <stdio.h>#include <stdlib.h>
int un_g_val;//未初始化全局变量
int g_val =100; //初始化全局变量
int main(int argc,char *argv[],char* env[])
{
printf("code addr : %p\n",main);//函数
printf("init global addr : %p\n",&g_val);//初始化全局变量
printf("uninit global addr : %p\n",&un_g_val);//未初始化全局变量
char *m1 = (char*)malloc(100);
char *m2 = (char*)malloc(100);
char *m3 = (char*)malloc(100);
char *m4 = (char*)malloc(100);
static int s = 100;
printf("heap addr : %p\n",m1);//堆
printf("heap addr : %p\n",m2);//堆
printf("heap addr : %p\n",m3);//堆
printf("heap addr : %p\n",m4);//堆
printf("stack addr : %p\n",&m1);//栈
printf("stack addr : %p\n",&m2);//栈
printf("stack addr : %p\n",&m3);//栈
printf("stack addr : %p\n",&m4);//栈
printf("s static addr : %p\n",&s);//静态变量
for(int i =0;i<argc;i++)
{
printf("argv addr : %p\n",argv[i]);
}
for(int i = 0;env[i];i++)
{
printf("env addr : %p\n",env[i]);
}
return 0;
}
其中从下向上,地址由低到高。我们也可以通过程序执行结果查看这一规律,其中我们能够发现栈区和堆区中有一个巨大的镂空。
验证堆栈的增长方向问题:
通过代码结果我们验证了堆区向地址增大方向增长,栈区向地址减少方向增长。堆栈相对而生。因此,我们一般在C函数中定义的变量, 通常在栈上保存,那么先定义的一定是地址比较高的!
理解static变量
我们能够发现 变量一旦被static修饰之后,本质是编译器会把该变量编译进全局数据区!
2.感知地址空间的存在
我们仍然使用一段代码来感知地址空间
#include <stdio.h>#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
while(1)
{
printf("我是子进程:%d,ppid:%d,g_val:%d,&g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
else
{
//parent
while(1)
{
printf("我是父进程:%d,ppid:%d,g_val:%d,&g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
return 0;
}
这段代码中,当父子进程都没有修改全局数据时,父子进程共享该数据,我们看着也没有什么问题。而当我们在p子进程中的g_val的值修改成200,会发生什么事情
#include <stdio.h>#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
pid_t id = fork();
int flag = 0;
if(id == 0)
{
//child
while(1)
{
printf("我是子进程:%d,ppid:%d,g_val:%d,&g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
flag++;
if(flag == 5)
{
g_val = 200;
printf("我是子进程,全局数据我已经修改了,用户请注意查看\n");
}
}
}
else
{
//parent
while(1)
{
printf("我是父进程:%d,ppid:%d,g_val:%d,&g_val:%p\n\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
return 0;
}
通过打印结果我们惊奇的发现,子进程读取的g_val是200,父进程读取的g_val是100,但是他居然是同一块地址空间,这与我们之前所学习的相违背。因此我们可以得出,我们在C/C++中使用的地址,绝对不是物理地址!!
(因为如果是物理地址,这种现象是不可能产生的)。那如果不是物理地址,那是什么呢?这种地址叫做虚拟地址。
为什么操作系统不让我直接看到物理内存呢?
其实内存是一个硬件,不能阻拦你访问!只能被动的进行读取和写入!因此如果我们能够直接访问甚至修改物理内存,将会造成不可预料的后果,因此为了安全起见,操作系统不会让我们直接访问物理内存
3.进程地址空间
3.1 概念
概念:每一个进程在启动的时候,都会让操作系统给他创建一个地址空间,该地址空间就是进程地址空间。
每一个进程都会有一个自己的进程地址空间!!! 那么操作系统要不要管理这些进程地址空间呢?虽然我们现在不知道进程地址空间是什么东西,但是我们知道一定是一个数据结构,操作系统肯定会先描述在组织这些数据结构。而进程地址空间是内核的一个mm_struct。
3.2 理解进程地址空间
所以我们验证说程序地址空间是不准确的,准确的应该说成进程地址空间,那么我们应该怎么来理解呢?
进程地址空间在逻辑上是一个抽象的概念,我们在谈这个概念之前我们需要引入一个进程独立性。
- 进程独立性:进程独立性是指多进程运行,需要独享各种资源,多进程运行期间互不干扰。
进程地址空间存在的意义是让每一个进程都认为自己是独占系统中的所有资源的!!所谓的地址空间其实就是OS通过软件的方式,给进程提供一个软件视角,认为自己会独占系统的所有资源(内存)。请看下图:
上面的图就足矣说名问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了
不同的物理地址!
区域
在mm_struct中,我们知道有不同的区域,栈区、堆区、全局数据区等等,在内核中是以下面这段代码所示存储的
struct mm_struct{
long code_start;
long code_end;
long init_start;
long init_end;
long uninit_start;
long uninit_end;
long heap_start;
long heap_end;
long stack_start;
long stack_end;
...
}
因此地址空间就可以被划分为不同的区域,每一个区域范围之内都可以有一套地址作为页表中的虚拟地址和物理地址进行映射。页表映射是将程序加在到内存有内程序变成进程之后,由OS给每个进程构建一个页表结构。
查看内核源码
3.3 程序是如何变成进程的
程序被编译出来,没有被加载的时候,程序内部有地址吗?
答:有的。链接过程是把已有程序和库当中的代码产生关联,没有地址怎么进行调用呢?
程序被编译出来,没有被加载的时候,程序内部有区域吗?
答:有的。我们可以在Linux下使用指令进行查看可执行程序,可以验证每一个可执行程序是有地址的,一个程序没有被加载的时候是有地址的。
readelf -S test
当父进程或者子进程在写入的时候,由于进程具有独立性,如果子进程将同一个变量修改时,操作系统会重新给子进程重新开辟一段空间,建立映射关系,因此,最终我们查看时,虽然地址一样,但是所指的数据已经不同了。这种我们也叫做写实拷贝。
fork有两个返回值,同一个变量怎么会有两个返回值
在之前,我们知道fork有两个返回值,而同一个变量怎么会有两个返回值,这时我们就可以理解pid_t id是属于父进程栈空间中定义的变量,fork内部,return会被执行两次,return的本质,就是通过寄存器将返回值写入到结构返回值的变量中!!当id=fork()的时候,谁先返回,谁就要发生写实拷贝,所以,同一个变量会有不同的内容值,本质是因为大家的虚拟地址是一样的,但是大家对应的物理地址是不一样的!
为什么要有虚拟地址空间?
安全性:
在前面说过如果只有物理内存,没有虚拟地址时,直接让进程访问物理内存是不安全的。那为什么有虚拟地址就是安全的呢?因为通过页表转换,如果是一个不安全或者非法的指针,访问物理内存时,通过页表进行转换过程进行审核,非法的访问就可以直接拦截了。
进程管理:
用多少开多少,什么时候用是么时候开!通过地址空间,进行功能模块的解耦!!
让进程或者程序可以以一种统一的视角看待内存:
方便以统一的方式来编译和加载所有的可执行程序方便简化进程本身的设计和实现。