欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客
https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (bingbingsupercool) - Gitee.com
https://gitee.com/bingbingsurercool
我们在学习C语言、C++等编程语言时,或多或少都学过内存分布的知识点。操作系统将内存从低地址到高地址划分为不同的区域,用来存储不同的变量类型,例如临时变量分配在高地址的栈区,动态申请的内存分配在低地址的堆区。不仅如此,我们还学习了每个变量对应一个独立的地址空间,对这块内存进行更改将会影响变量的值。但是我们在学习fork函数的时候却发现同一个id变量即然可以存有不同的值,这又是怎么回事呢?这就需要我们深入理解地址空间的分布。
内存中,地址空间按照低地址到高地址划分为不同的数据存储区域,如下图所示。

内存中的地址空间真的是这样分配的吗,接下来我们使用代码进行验证。
- #include
- #include
- #include
- int un_gval;
- int g_val=100;
- int main(int argc,char*argv[],char* env[])
- {
- char* p= "hello";
- // 10;
- // 'a';
- //int val=0;
- static int val=0;
- printf("code addr :%p\n",main);
- printf("init global addr:%p\n",&g_val);
- printf("uninit flobal addr:%p\n",&un_gval);
- char*heap_mem1=(char*)malloc(10);
- char*heap_mem2=(char*)malloc(10);
- char*heap_mem3=(char*)malloc(10);
- printf("heap_mem1 addr:%p\n",heap_mem1);
- printf("heap_mem2 addr:%p\n",heap_mem2);
- printf("heap_mem3 addr:%p\n",heap_mem3);
- printf("static_val:%p\n",&val);
- printf("str:%p\n",p);
- printf("stack_mem1 addr:%p\n",&heap_mem1);
- printf("stack_mem2 addr:%p\n",&heap_mem2);
- printf("stack_mem3 addr:%p\n",&heap_mem3);
- int i=0;
- for( i=0; i
- {
- printf("argv[%d]:%p\n",i,argv[i]);
- }
- for( i=0;env[i];i++)
- {
- printf("env[%d]:%p\n",i,env[i]);
- }
-
- return 0;
- }
代码中分别将代码区地址main函数的地址,以及未初始化变量,已初始化变量,栈空间变量,堆空间变量,环境变量,命令行变量的地址分别打印出来,看起地址大小的分布。打印地址变量,本质是进程在打印。

通过图片我们可以很清楚的看到代码区的地址最小,因此代码区分布在低地址空间,其次是未初始化变量的地址,在上面是已初始化变量的地址,但是两个变量的地址空间相距不大, 未初始化变量上面是堆的区域,再往上是栈区,二者之间具有非常大的空间。最上面是环境变量和命令行参数的存储区域。
这里还有几个值得注意的小知识点:
(1)堆、栈相对而生
堆区与栈区之间具有非常大的区域,这些区域是代码共享区。栈区是从高地址向低地址开辟空间,向下生长;堆区是从低地址向高地址开辟空间,向上增长。

(2)static修饰局部变量本质是将局部变量的存储位置更改,从栈区改到全局数据区。
例如我们先创建变量val,然后将其地址打印出来,会发现val的地址分布在栈区;当我们使用static修饰变量val后在将其地址打印出来,我们会发现val的地址更改为低地址,分配在全局数据区。

(3)堆区连续申请的空间之间彼此不会连续
例如我们使用malloc每次开辟10个字节的空间,连续开辟三次,但是三次开辟出来的空间地址并不会连续存放。为什么呢?

不仅如此,我们在使用free函数对开辟的空间进行释放的时候只传入了空间起始地址,free又怎么知道需要释放多少个字节的呢?
这是因为,操作系统开辟空间的时候会多开辟一部分空间来存储这块堆区申请空间的属性信息,比如大小等,所以连续开辟的空间之间不会紧挨着,会有空间来存储属性。
(3)字面常量代码可以编过,字符串存储在代码区
代码中直接输入字符串,常数等语句编译是可以通过的,其中常量字符串存储在代码区。

注意:
内存空间分为用户空间和内核空间。在32位机器下,一个进程的地址空间取值范围是0x0000 0000 ~ 0xFFFF FFFF ,其中用户空间【0,3GB】,内核空间【3GB,4GB】。
以上内容默认只在Linux下有效,Windows不同。

2.虚拟内存地址与物理内存地址
通过上面的学习验证,我们现在对地址空间的分配有了一定的了解,现在有一个问题,那我们开辟的变量在内存中就是这么存储的吗,对应的就是操作系统中的一个个地址空间吗?
如果你的答案是:没错就是这么存储的。那请看下面的例子。
- int g_val=100;
- int main(int argc,char*argv[],char* env[])
- {
- pid_t id = fork();
- if(id==0)
- {
- //child
- int i=0;
- while(1)
- {
-
- printf("i am child,pid:%d,ppid:%d,g_val:%d,&g_val:%p\n", \
- getpid(),getppid(),g_val,&g_val);
- sleep(1);
- i++;
- if(i==3)
- {
- g_val=200;
- printf("child change g_val:100->200,g_val:%d\n",
- g_val);
- }
- }
- }
- else if (id>0)
- {
- //father
- while(1)
- {
- printf("i am father,pid:%d,ppid:%d,g_val:%d,&g_val:%p\n",\
- getpid(),getppid(),g_val,&g_val);
- sleep(1);
- }
- }
- else
- {
- printf("进程创建失败\n");
- }
- return 0;
- }
-
我们使用fork创建了一个子进程,子进程与父进程在前三秒的工作一样,将全局变量g_val的地址和数值打印出来,在三秒后子进程将g_val的值进行更改,然后继续打印。

什么情况?同一个地址的一个变量居然出现了两个不同的值 !!!
这就说明了,父子进程输出的绝对不可能是一个变量,因为一个变量只能有一个值。但是地址一样啊,这也就说明了我们的地址空间不是实实在在的物理地址。
在Linux地址下,这种地址叫做虚拟地址,我们在使用C语言C++等语言时,使用的也是虚拟地址。物理地址,用户根本看不到,由操作系统统一管理,操作系统会将虚拟地址通过 “桥梁” 转化到物理地址上去存储。
因此我们之前说的“程序的地址空间”并不准确,实际上应该是“进程地址空间”。
实际上在一开始计算机的设计中,进程访问的就是物理内存,但是这种访问方式特别不安全。由于每个地址都是实实在在的物理地址,当一个进程中的某个变量地址错误时有可能访问到其他进程中的变量,并对其进行更改,这就存在安全问题,恶意程序可以随意的修改其他进程。
为了解决这种问题,现代的计算机就采用虚拟地址空间来存储,并通过某种映射机制将虚拟地址空间上的变量映射到实际的物理空间中进行存储。这样最终还是访问到了物理内存,难道不会有恶意程序进行更改吗?不会的,因为当虚拟地址空间中的地址不合法时,映射机制不会发生映射,就不会访问到物理内存空间中。
3.什么是进程地址空间
前面论述了这么多,现在进程地址空间的概念开始浮现了。实际上我们通常谈论的内存地址空间,并非实际物理内存,而是虚拟地址内存空间。操作系统要想管理进程地址空间就需要先描述,在组织。
因此内核中的地址空间,本质上是操作系统为进程虚拟抽象出来的一种描述内存的方案,通过一种数据结构以及页表映射到物理内存中。
每个进程都有自己的PCB,以Linux为例,描述进程task_struct结构体中有一个指针指向 struct mm_struct,这个结构体就是对虚拟地址空间的描述。每一个区域的划分实际上是用变量标记出地址空间的起始位置,对其进行更改可以控制范围。

为了确保进程的独立性,每个进程都有属于自己的一个虚拟地址空间,并对应独一无二的映射关系---页表。这些虚拟地址通过页表映射到不同的物理地址空间中进行存储。
因此内存中数据的存储实际上是下图这样。

接下来我们解决为什么值不一样,地址一样的问题。
使用fork创建子进程后,子进程是用复制一份父进程的基本属性信息来创建的进程,因此子进程与父进程的虚拟地址空间一样,页表一样,映射的物理地址也是同一份,所以打印的g_val是一模一样的。当子进程对g_val进行更改时,将发生写时拷贝,由于两个进程之间共用一份地址空间,子进程想对其更改就必然影响到父进程,因此子进程将会从新开辟一份空间来存储g_val的值,并对其进行更改,然后更新页表的物理地址,使其重新映射,但是并不会更改虚拟地址空间中的地址。每个虚拟地址空间和页表都是是独立的,因此就算虚拟地址一样,映射的也不一样。这样就会存在两个g_val,虽然他们的虚拟地址一样,但实际上对应的是两个不同的变量。

4.深入理解虚拟地址
现在有一个问题,我们的程序在编译的时候,形成可执行程序的时候,但是我们没有运行,没有被加载到内存的时候,程序内部有地址吗?
答案是有的!
地址空间不仅操作系统要遵守,编译器也需要遵守。编译器在编译代码的时候,就已将给我们形成了各个区域,代码区,数据区等等,并且采用和Linux内核一样的编址方式,给每个变量,每一行代码都进行了编址,所以程序在编译的时候,每个字段都已具备了一个虚拟地址。当程序被加载到内存中,物理空间就会实际存储这些代码,就行成了实际存在的物理地址。
物理地址:加载到内存时产生
虚拟地址:代码编译时产生
所以我们之前说地址空间会通过页表映射到物理地址空间,那么mm_struct结构体和页表中最初的数据又是从哪里来的呢?答案是,在编译阶段每行代码会产生虚拟地址然后填充到mm_struct结构体的各个位置,以及页表的虚拟地址空间位置,代码加载到内存后产生的物理地址填充到页表的物理内存位置。

因此进程被CPU运行的时读到的执行地址一定是虚拟地址。

5.为什么要有虚拟地址空间
(1)安全性
前面就提到过,非法访问,页表不会进行映射。实际上是操作系统会识别检测到非法的地址访问,并且直接将进程终止,至于OS怎么识别怎么终止我们以后再讲。这就意味着操作系统有效的拦截了进程对物理空间的访问。
那么是如何拦截的呢?
页表不仅仅具备对虚拟地址和物理地址之间的映射功能,页表还维护物理空间的访问权限,物理内存是具备读写权限的,要不然我们的代码数据是如何加载到内存中呢。但是页表会对权限进行管理,例如常量字符串,不具备写的权限,只有读的权限,实际上是页表对这块物理内存映射时就设置了只读权限;当某个进程试图去更改常量字符串的内容时,操作系统通过页表想访问物理内存并对其进行更改,但是发现并不具备写入权限,因此操作系统终止访问,随即进程退出。
所以说由于地址空间和页表是操作系统创建并维护的,这就意味着凡是想使用地址空间和页表来访问物理内存就一定需要被操作系统监管,从而保护了物理内存中的所有合法数据,包括各个进程,以及内核的相关有效数据。
(2)提高效率
我们知道,虚拟地址空间中每个区域都是有序的划分出来并通过页表映射到物理内存中,那是不是意味着物理内存可以随意存储,只要页表的映射关系存在,每个数据都会被找到?
是的!!!
物理内存的分配可以和进程的管理做到毫无关系,物理内存随意分配,只要页表的映射没有错,虚拟地址空间一定可以对应物理内存!
这其实就是一种提高效率的决策,内存管理模块只管理物理内存不用关心虚拟地址空间怎么分布,进程管理模块只用来创建PCB,虚拟地址空间也不需要关心物理内存存在哪里。二者之间只通过页表进行联系,完成解耦合,减少模块之间的关联性。

所以我们之前所用的内存申请函数new,malloc等函数都是在虚拟地址空间上进行申请的。本质上,因为有地址空间的存在,上层申请的空间都是在虚拟地址空间申请,物理空间可以一个字节都不给你分配,只有你的进程真正对物理空间进行访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表的映射关系。然后在进行内存的访问。
操作系统在执行这些工作的时候是自动进行的,用户以及进程完全0感知!!!这种延迟分配的策略让内存的使用率几乎达到100%,内存使用率高了,计算机的效率也就极大的提升了。
换言之,如果不执行这种策略,当我们的进程申请了空间操作系统直接分配给我们,如果我们一直没有使用这块空间,那么操作系统分配给我们的这块空间是不是就浪费了呢?是的,只要你不释放,他就一直在那里,其他进程需要空间操作系统就会再分配,效率越来越低。
(3)便于管理
物理内存中理论上可以在任意位置加载数据,那么是不是物理内存中的几乎所有数据和代码都是无序的呢?
是的!!!
但是由于页表的存在,虚拟地址上的数据和代码并非无序,通过页表的映射可以一一对应物理内存中的数据和代码,所以在进程的视角里,所有的内存分布都是有序的。
相比于对物理内存中无序数据的管理,进程对有序数据的管理可以更加方便快捷,内存是否越界,越界多少都一目了然。
因此,虚拟地址空间以及页表的存在可以使内存分布有序化!
(4)独立性
页表和虚拟地址空间是每个进程独有的,虚拟地址空间是操作系统给进程画的一张大饼。进程在访问物理内存中的数据和代码时,可能这些东西并没有在物理内存中,不同的进程通过独有的页表映射的物理内存也是不同的,这就更加容易做到进程的独立性!!!
正是应为有虚拟地址空间的存在,所以每个进程都认为自己独享4GB(32位)的内存空间,而且各个区域都是有序分布,进而可以通过页表映射到不同的区域,从而实现进程的独立性。每个进程互不干扰,甚至都不知道其他进程的存在!
所以页表以及虚拟地址空间可以实现进程的独立性!
6.再次理解进程状态
我们之前说,进程是内核数据结构+代码和数据组成,其中内核数据结构我们只学习了PCB,今天我们知道,内核数据结构还包含了虚拟地址空间mm_struct以及页表。
前面我们提到过新建状态就是进程刚被创建的状态!刚被创建的时候进程是什么样的呢?
程序加载的本质就是创建进程,那么是不是必须要立马把程序代码和数据加载到内存中,并创建内核数据结构建立映射关系呢?
并不是!在最极端的情况下,甚至只有进程的内核数据结构被创建出来,只有所谓的task_struct,mm_struct等结构,这就是所谓的进程新建状态!
并且,程序加载到内存中是分批次加载的,当然也可以分批次的换出,当一个进程数据短时间内不会被执行的时候,比如阻塞状态时,进程的代码和数据就会被换出形成挂起状态!
真的是被换出了吗?不一定,有可能直接释放了!!!页表不仅仅能够映射物理内存地址,也可以对磁盘中的数据进行映射,当数据需要换出时,直接将其释放掉,然后页表中重新填上磁盘中的位置即可!