目录
前言
一、初始进程地址空间
1、实验引入
2、虚拟地址空间
二、什么是进程地址空间
1、基本概念
2、深入理解进程地址空间
3、进程地址空间的本质
4、遗留问题解决
三、为什么要有进程地址空间
1、知识扩展
2、进程地址空间存在意义
3、重新理解挂起
前言
本章节主要介绍关于进程地址空间相关概念,我们从一个实验引出我们的进程地址空间,接着一步一步深入了解进程地址空间,细化周边概念;
一、初始进程地址空间
1、实验引入
我们有如下代码,观察代码运行现象;
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
int g_val = 10; // 已初始化全局变量
int main()
{
pid_t id = fork();
if(id < 0)
{
// fork执行失败
perror("fork");
exit(-1);
}
else if(id == 0)
{
// 子进程
int cnt = 0;
while(1)
{
if(cnt == 5)
{
g_val = 20;
}
printf("我是子进程,g_val:%d, &g_val:%p\n", g_val, &g_val);
cnt++;
sleep(1);
}
}
else
{
// 父进程
while(1)
{
printf("我是父进程,g_val:%d, &g_val:%p\n", g_val, &g_val);
sleep(1);
}
}
return 0;
}
我们编译运行上述代码,结果如下所示;
这跟我们前面fork函数返回值遗留下的问题一模一样,到底为什么会出现这种神奇现象呢?本文主要探究的就是这个;
2、虚拟地址空间
不知道大家在以前的学习中是否见过下图(32位机器下);
我想这是每一个学计算机的都应该见过的图吧,这就是我们今天的核心虚拟地址空间,我们将空间按上面划分,分配地址,我们可以根据上图写出相应代码,是否如图所示;
#include <stdio.h> #include <stdlib.h>
// 未初始化全局变量
int g_unval;
// 已初始化全局变量
int g_val = 10;
int main(int argc, char* args[], char* env[])
{
// 代码段
printf("code addr: %p\n", main);
// 常量
const char* str = "hello world";
printf("constant quantity: %p\n", str);
// 已初始化全局变量
printf("init global var: %p\n", &g_val);
// 未初始化全局变量
printf("uninit global var: %p\n", &g_unval);
// 堆
char* p1 = (char*)malloc(10);
char* p2 = (char*)malloc(10);
char* p3 = (char*)malloc(10);
printf("heap: %p\n", p1);
printf("heap: %p\n", p2);
printf("heap: %p\n", p3);
// 栈
printf("stack: %p\n", &p1);
printf("stack: %p\n", &p2);
printf("stack: %p\n", &p3);
// 命令行参数与环境变量
printf("args[0]: %p\n", args[0]);
printf("args[1]: %p\n", args[1]);
printf("args[2]: %p\n", args[2]);
printf("env[0]: %p\n", env[0]);
printf("env[1]: %p\n", env[1]);
printf("env[2]: %p\n", env[2]);
return 0;
}
测试结果如下图所示;
结果与我们预料的相同,整体地址都在增大,与我们上面的进程地址空间分布图一样,这是在Linux下的测试结果,在window下测试结果可能会有差异,这是可能由于编译器的优化造成的结果;
二、什么是进程地址空间
1、基本概念
进程地址空间就是从进程的视角看到的内存空间,实际上,我们会通过一种数据结构记录从虚拟地址到物理地址的映射;
2、深入理解进程地址空间
要理解这个,我们首先把时间线拉到以前,计算机刚开始时,没有进程地址空间这一概念,我们写的程序是直接使用内存的物理地址来访问内存上的数据的;如下图所示;
此时,我们要执行一个程序,我们首先将可执行程序加载进内存,并生成对应的PCB控制块,在CPU下的就绪队列排队等待调度;看着好像没啥问题,若此时我们调用A时,A程序越界访问了,直接修改了我们B进程的代码,导致B进程直接崩溃了,这时进程还哪里来的独立性,进程的独立性完全靠程序员代码的正确性;故这种让进程直接访问真实的物理地址是不可靠的;
而我们现代计算机,不会使用上述策略,我们引入了一个虚拟地址,使得程序无法直接访问真实物理内存,如下图所示;
当我们程序加载进内存时,首先,操作系统会生成对应该进程的PCB控制块(task_struct)、进程地址空间(mm_struct)和用户级页表,这些合起来我们称作进程,即 进程 = 内核数据结构 + 代码和数据;对于每个进程来说,它们都认为自己的地址是从 0x0000 0000 到 0x FFFF FFFF,这些地址都是虚拟地址,CPU通过这些虚拟地址经过页表映射到真实的物理地址来操作内存的数据;
问题来了,多搞出了一个虚拟地址,最终还不是通过虚拟地址映射到物理地址来访问内存中数据,那么虚拟地址也是一个非法的地址呢?那不也越界访问了?
实际上,若虚拟地址也是一个非法地址,在页表这就会被发现出来,根本不会映射到非法的物理地址,也就不可能影响到别的进程;
3、进程地址空间的本质
仔细想一下,既然每一个进程都要配一个进程地址空间,那内存中不可能只有一个进程,因此进程地址空间也不可能只有一个,既然有很多个,那么我们的操作系统是否需要将这些进程地址空间管理起来,那么如何管理这些进程地址空间呢?同样,“先描述,再组织”,我们首先用一个结构体将这个进程地址空间描述起来,再用一种数据结构将这些结构体组织起来,方便我们对这些结构体进行增删查改,这不就跟操作系统对PCB控制块的管理同出一辙吗?在Linux下,这个数据结构就叫做mm_struct;
如何描述呢?我们想一想,进程地址空间不就是一个又一个区域吗?那我们可以肯定的是,肯定会进行分区,那么如何进行分区呢?不就是一个记录其实位置一个记录结束位置吗?如下所示;
strcut mm_strcut
{
// 代码段
int code_start, code_end;
// 栈区
int stack_start, stack_end;
// 堆区
int heap_start, heap_end;
// 等等... 其他属性
};
这样不就将进程地址空间描述起来了吗?对于栈区和堆区这种区间会增长的呢?如何维护?我们直接改变其start或end值不就可以了吗?进程地址空间,实际上就是一个结构体,且我们之前学过的PCB控制块 task_struct 中也保存了 mm_struct 的指针;
4、遗留问题解决
我们开始那个实验同一个变量,同一个地址,为什么会有不同的值呢?还有fork函数的返回值,为什么也是一个变量有两个值呢?这些问题就很好进行解答了,如下图;
当创建一个子进程后,若没有进程对g_val进行修改,也就是我们程序的前5秒钟,父进程和子进程都是通过页表映射到同一块物理地址空间,由于子进程是继承于父进程,因此页表、PCB和进程地址空间等信息也有很多是拷贝于父进程,它们的g_val的虚拟地址都是相同的,若此时子进程修改了g_val;如下图;
此时,由于子进程要对数据进行修改,故我们的OS会重新开辟一块空间,并修改页表映射关系,此时我们的子进程就会将数据写入新开辟的那一块空间,而页表项中仅仅更改映射到物理地址那一块数据,所以虚拟地址并没有发生改变,故我们会看到地址相同,里面存的值不同的现象;fork的返回值也是如此,发生了写时拷贝现象;
三、为什么要有进程地址空间
1、知识扩展
在我们回答这个问题之前,我们首先补充一个知识,我们的程序编译完以后,生成可执行程序,那么这个可执行程序里有地址吗?如果有,是什么地址?
实际上,虚拟地址的概念不仅仅只是存在我们的操作系统内部,我们的编译器也要遵守这个,因此我们在编译后,程序中内部已经使用了虚拟地址,也会存在各种段,如数据段,代码段等;直到我们的程序加载进内存后,会给我们的程序分别配物理空间,填充页表的映射;
2、进程地址空间存在意义
其一,一旦我们有了进程地址空间,我们的CPU看到的一切地址都是虚拟地址,所有的地址都需要通过页表映射才可以找到真实的物理地址,这样做可以有效的保护内存,防止越界修改数据,影响其他进程;一旦用户有越界修改数据行为,我们在页表就可以发现这种行为,拒绝进行操作,直接杀死当前进程,从而保证了内存的安全;
其二,有了地址空间后,我们的程序由于页表映射在真实内存中不一定是连续的,如何给我们的程序分配内存可以由我们的内存管理模块决定,如何分配与我们的进程管理模块无关,因为我们有了进程地址空间,我们有了虚拟地址即可,真实物理地址在哪我们并不关心,如何分配我们也不关心,我们只需要通过页表与物理内存空间建立映射即可,这样就完成了 进程管理模块 与 内存管理模块 的解耦合;
补充:
我们C语言的 malloc函数 与C++的 new 申请的空间地址是什么地址?根据上述,我们不难判断,申请的是虚拟地址,那么问题又来了,若我们申请完这块空间不立即使用,OS系统会为我们申请的这块虚拟空间申请物理内存空间吗?答案是否定的,当然不会,若我们就申请malloc虚拟空间不使用,OS同时为我们申请了物理空间,那么这块物理空间也不会被使用,那么资源不是白白的浪费掉了吗?
操作系统的做法是当我们调用 malloc 这种函数时,操作系统首先会为我们申请虚拟内存空间,并填充进页表,但是不会为我们申请真实物理空间,一旦我们要使用时,操作系统会为我们申请真实物理空间,且完成页表映射关系;
这种延时分配的策略大大的提高了内存的利用率,且对进程来说是0感知的,因为我们的进程并不关心 内存管理,只关心虚拟内存空间,在页表中找对应映射;
其三,由于页表+进程地址空间,我们的可以将我们的代码和数据分散放在物理内存的任意位置,但在进程视角看着就像连续的,比如我们创建一个变量a,接着再创建一个变量b,我们这两个变量在虚拟地址上可以看作是连续的,但是再物理内存种不一定连续,因此我们物理地址是通过页表映射到任意位置,可以是无序的;对于计算机里的多个进程来说,只要能保证页表映射的正确性,就能保证进程间的独立性!
3、重新理解挂起
我们可以通过上面理论再次理解挂起现象,所谓挂起,就是由于某种情况,我们需要将内存种某些进程的代码和数据暂存到磁盘中的交换区处;那么我们再次思考一下,我们加载进程的时候有没有可能不将代码数据放进内存中呢?
实际上是有可能的,当我们的内存资源极度紧张时,我们运行一个可执行程序,也就是加载这个程序进内存,可是我们内存已经非常紧张了,我们可以先在OS上创建这个可执行程序对应的PCB控制块、进程地址空间与页表等等,代码和数据暂存在内存中,这不也是我们挂起的本质吗?
有了以上认知,再想一想我们平时在电脑上玩的大型游戏,动辄就是一百多GB,例如GTA5,而我们的内存却只有8G或者16G,那么是如何将这么大的可执行程序加载进内存中的呢?首先,完全加载进内存肯定是不现实的,上面我们谈挂起是只创建内核数据,而不将代码和数据加载进内存,那么我们肯定也可以部分加载呀,当我们需要用哪些代码和数据是就加载进内存,长期不用时,换出内存,这样就可以造成一个我们将整个程序加载进内存的假象了;