前言
hi~ 大家好呀,欢迎点进我的Linux系列学习笔记!
这篇呢主要介绍关于Linux下的进程相关地址空间的知识,主要了解其是什么,和为什么要设计此地址空间。
我们一起努力吧!
目录
一、初识地址空间
相信大家在深入学习C/C++的时候,就了解到了一些关于程序内存的分布,比如我这篇C++的博客里面就介绍的有,大家感兴趣的可以看看:【C++】内存管理到用new申请堆内存_柒海啦的博客-CSDN博客_申请堆空间
针对于此,此地址空间在Linux下也可划分为如下:
从上到下,我们可以依次知道:
地址:
首先地址从高到低,因为是32位,即32位二进制,所以转化为16进制的最大值就是0xFFFF FFFF 了(F == 1111(2)),最小自然也就是0x0000 0000了。
内核空间:
此空间是给操作系统留的,进程用户等不可访问此处。
环境变量:
main函数可传入的第三个参数char* env[],可以通过接口调用显示继承的环境变量信息。
命令参数:
即main函数的前面两个参数:int argc char* argv[] 用来保存此程序的命令(选项参数)的。
栈区:
函数内定义的变量等。
共享:
构建与物理内存的映射。
堆区:
用户可通过malloc或者new申请的区域。
未初始化数据区:
未初始化的全局变量
初始化数据区:
初始化的全局变量、static静态成员
代码区:
只读数据保存处。(代码、只读常量)
对此,我们可以写出具体的代码进行验证:(Linux下使用C语言进行验证)
#include<stdio.h>
#include<stdlib.h>
int a;
int b = 1;
static int c = 2;
int main(int argc, char* argv[], char* env[])
{
printf("环境变量:%p\n", env);
printf("命令参数:%p\n", argv);
printf("\n");
char* arr = (char*)malloc(sizeof(char) * 12); // 此时此指针变量存储堆空间的地址,本身是一个栈空间的变量
printf("栈:%p\n", &arr);
printf("堆:%p\n", arr);
printf("\n");
printf("未初始化数据:%p\n", &a);
printf("静态数据:%p\n", &c);
printf("初始化数据:%p\n", &b);
printf("\n");
const char* d = "hello";
printf("只读常量:%p\n", d);
printf("代码:%p\n", main); // main本身就是一个函数,函数所处就是原本的代码区域
return 0;
}
结果如下:
可以发现就像前面所说的那样进行分布。(这里是64位机器,所以不是32位的表示方式,但是底层都是一样的)
二、问题的提出
既然我们知道了程序所在地址空间的情况,那么现在提出如下测试程序,看看问题出现在哪里?
#include<stdio.h>
#include<unistd.h>
int num = 1;
int main()
{
pid_t id = fork(); // 开始划分父和子进程,子进程继承父进程的代码
if (id == 0) // 子进程
{
int i = 0;
while (i < 6)
{
printf("子:num:%d, &num:%p\n", num, &num); // 全局变量 全局变量地址
sleep(1); // 休息一秒
if (i == 3)
{
printf("子进程改变num为6值\n");
num = 6; // 发生改变
}
i++;
}
}
else // 父进程
{
int i = 0;
while (i < 6)
{
printf("父:num:%d, &num:%p\n", num, &num); // 全局变量 全局变量地址
sleep(1); // 休息一秒
i++;
}
}
return 0;
}
运行结果如下:
可以发现,在子进程还没有改变值时,看似两者公用的是一个全局数据,因为地址都一样。但是当子进程改变num的时候,一切情况就变得诡异起来,因为在相同地址的条件下,两个值却不一样。
如果这个地址是真实的物理内存的话,那么就不可能出现上面的这种情况。所以,以我们的目前理解来看,似乎父子进程的地址是虚拟的?有两份不一样的地址空间。因为只有这样才可以相同地址反而存的是不同的值。
三、理解地址空间
1.地址空间的认识
通过上面问题的提出,我们明确了一件事情:就是此地址空间是虚拟的,并不是真正的物理内存。但是程序(进程)也能通过其虚拟地址改变到真正的物理地址。
下面引入一个例子就可以更加方便的理解地址空间了:就好比小时候过年,亲朋好友,家人给你拿的压岁钱,有很多,父母担心你乱用,就说“帮你保管”。此时这个状态就好比为地址空间,你心里是以为自己的钱是有那么多的,但是有的时候买东西父母不满意就不能用,或者之后帮你交了学费了.....
那么结合历史上的计算机,地址空间又是如何形成的呢?
历史计算机:
在以前的计算机上,进程写入是直接加载物理内存的,内存本身是可以被随时读写的。如果其中一个进程存在野指针,那么就会影响其他地方的数据以及储存。 -- 特别不安全 不是独立性的,所以就不能直接使用物理地址。
现代计算机:
为了针对不能直接修改物理地址,但是每个进程都可以共用一个物理内存,不能冲突,那么task_struct会引入并且创建一个属于此进程的一个数据结构(先描述,在组织),也就是地址空间。此时32位也就认为是0x0000 0000 ~ 0xffff ffff。
此时就是非物理内存存储。-- 虚拟地址 系统存在一种映射机制(虚拟地址映射到物理内存) 要访问物理内存,就要进行映射。这种机制也就可以类似于上述例子中父母阻止你使用压岁钱买不合适的物品。
那么,我们通过上面的代码测试也可以发现,此地址空间是有划分区域的,那么具体在此数据结构里面是如何进行划分的呢?
其实观察Linux内核或者是根据我们平时的理解,里面就定义两个来存储对应位置数据的结构即可,然后要划分区域的时候赋值即可:
struct destop{
int start;
int end;
}
struct destop one = {1, 50};
struct destop two = {51, 100};
//所谓的区域划分,就是在一个范围内定义一个start和end
综上,我们就可以理解到了地址空间,实际上就是一种内核的数据结构,里面至少有着各个区域的划分:
struct addr_room
{
int code_start;
int code_end;
int init_start;
int init_end;
...
}
当某个地址空间的区域发生变化的时候(区域也就是里面的栈区、初始化数据区、代码区等等)也就是start和end进行加减变化即可。
在Linux中,此数据结构就是struct mm_struct{}。每个进程都有一个,也就是都会在对应的PCB内存在对应对象的指针。
2.页表
既然有了虚拟地址了,那么也就需要一个与物理内存建立映射关系的结构,此结构就是页表。映射关系是操作系统自己维护,是一个页表结构。(同样页表每个进程均有一个)
所以上面的父子进程与物理内存对应的关系可以表示如下:
所以,此时只要保证页表映射的物理内存不同就可以实现进程之间的互不干扰,这也就解释了提出问题里面的父子进程虽然地址一样(子进程继承父进程的地址空间),但是在改变值的时候,页表就会映射到不同的物理内存(写时拷贝),所以就会出现那种情况啦~。这种情况也可以解释fork()返回两个值的问题哦,在fork返回的时候,要对两份地址空间对应值进行赋值,就会发生写时拷贝,所以父子进程在各自的物理内存有属于自己的变量空间,用户层就是同一个虚拟地址。
写时拷贝(在子进程需要修改与父进程类似的的变量的时候,就会重新映射到一个新的物理内存区域进行修改(即页表的指向)) (以后会具体解释,当前只是简单阐述一下)
3.编译及进程虚拟空间的具体状态
那么,这里提出一个问题,在程序编译的时候,存在地址空间吗?也就是说此二进制文件还不是进程,没有进入内存去等待cpu资源。
我们可以使用反汇编工具来简单测试一下:objdump -afh 程序名
我们可以看到VMA就是表示虚拟内存地址的东西,可是此时该程序还没有调度哦~ 所以,就说明了可执行程序,在编译的时候内部就存在地址了。
可以这么理解,编译的时候就需要各种调用之类的,所以如果不存在地址,那么链接的时候又找谁呢?所以:
地址空间不仅仅理解为OS内部需要遵守的,其实编译器也要遵守。编译器在编译的时候已经形成了各种区域。并且采用和Linux内核一样的编址方式,给每一个变量,每一行代码进行了编制,所以程序在运行的时候,已经具有一个虚拟地址了。
程序内部的地址依然使用的是编译器编译好的虚拟地址,加载入内存后,每行代码,每个变量都具有物理地址,然后PCB中加入地址空间就可以按照此进行编排,然后页表一段链接地址空间,另一端连接物理空间。CPU读到指令的时候,指令内部的地址就是虚拟地址。
那么内存、磁盘、cpu调度之间的关系又是如何呢?别急,下面一张图就可以解决问题哦~
所以CPU读到的指令内部,使用的也就是虚拟地址。 cpu从物理空间内读到的虚拟地址也就是原本从地址空间内读取的虚拟地址通过页表虚拟内存映射的物理内存地址找到的虚拟地址。
欢迎各位大佬支持补充呀~