1.背景
在C/C++语言学习阶段,我们知道程序对于的内存图如下:
- 但是这些变量存储的位置,真的是物理地址吗?答案为不是,下面便让我们一步步来解开这个问题
2.问题
我们先在Linux系统上编写如下代码:
运行该代码一段时间后,终止对应的进程观察结果如下:
我们可以看到,不论是子进程还是父进程中,全局变量flag
的地址都是相同的,按照我们以往的认识,在两个进程中的flag共用同一块空间,他们对应的flag值应该是相同的,但现在随着循环的进行,子进程的flag每循环一次加1,而父进程保持不变。
首先,我们知道,进程具有独立性,一个进程改变其对应的数据不会影响到其他进程,而子进程创建后,当数据发生改变时,进行了写实拷贝,它对应的代码和数据和父进程分离,所以我们可以解释为什么父子进程的falg
值是不同的。
其次,因为父子进程的flag
变量的地址是相同的,而如果该地址为物理地址,那么不可能出现地址相同而地址内存放的数据不同的现象,这就说明,该地址并非物理地址。所以在我们之前学习到的语言(C/C++)的层面上用到的地址并非物理地址。
实际上,这里真正用到的地址为虚拟地址 。
对此我们可以得到如下结论:
- 变量内容不一样,所以父子进程输出的变量绝不是同一个变量
- 但是地址值是相同的,说明,该地址绝不是物理地址
- 在Linux地址下,这种地址叫做
虚拟地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!而物理地址用户一概看不到,有OS统一管理
综上: OS必须负责将虚拟地址
转化成 物理地址
那接下来让我们看一下Linux进程的地址空间的具体情况!
3.虚拟地址
3.1 概念
此时我们已经知道,我们在编写C/C++程序时,我们看到的地址都是虚拟地址 ,而虚拟地址就是我们已经了解到的地址空间,也就是栈、堆等等,在其中的地址都是虚拟地址 ,而这些地址的集合即为进程地址空间,每个进程都有自己的地址空间。
以32位机器为例,有32条地址总线,每条线只能传输0和1,共有232(4G)的可能,意味着内存的大小为4G。上图的内存分配图表示一个进程的地址空间的情况,而其中的地址都为虚拟地址不是真是存在的。
注意: 上图是将内存4G的空间全部做出分配,而实际上,每个进程都有自己对应的进程地址空间,一个进程对应的进程地址空间没有分配到这么大的空间,否则其它进程无法在分配空间,就好比一个富翁有很多孩子,每个孩子都需要富翁给予帮助,他不可能将钱全部交给一个孩子,让其他孩子喝西北风。
了解到这里,我们大致能得出一个进程对应代码在内存中是如何运行下去的。
首先,系统将要执行的程序调入内存,会将其以进程的形式加载到内存,然后操控PCB来完成代码的推进。
其次,当PCB想要运行一段代码和调取相应的数据时,通过指向虚拟内存,由虚拟内存经过一系列操作,指向物理内存,调用所需的数据,完成代码的运行。
3.2 描述方式
我们知道了进程地址空间在内存中的形式是什么,接下来在来看一下一个操作系统是如何描述一个进程的地址空间。
在外面学习了解进程后,应该知道,进程的PCB在内存中就是一个struct结构体的对象,通过进程的不同属性创建不同的对象自然产生了不同的进程,而虚拟地址也是以结构体对象的方式,在内存中出现,只要我们抽象出虚拟地址的属性,即可确定该结构体。
生活中,我们想要知道一个桌子的长度时,只需要选择一个起始点,确定它的终点用尺子将其量一下即可,对于存储地址也是相同的,我们只需将虚拟地址的栈、堆、共享区等等,一个个确定其起点和重点,起点和终点之间的范围就是对应的代码或变量代表的虚拟地址,我们编写程序时看到的地址也就是这里的地址 ,对应的结构体如下:
struct mm_struct{
long code_start;
long code_end;
long init_start;
long init_end;
....
long brk_start;
long brk_end;
long stact_start;
long stack_end;
}
- 在这里,我们就不难理解堆区和栈区的扩大和缩小是则么一回事了,只需要将两个区域对应的
_end
值调整后即可完成区域的扩大和缩小。(此为原理,具体的操作不是这么简单)
4.页表
上面我们讲了,进程的PCB通过虚拟地址经过操作后指向物理地址的方式来找到对应的数据和代码,而这个操作就是经过页表的转换,这里简单的简绍一下这个知识点。
这里的页表我们可以简单的将其理解为一个key-value
的结构,左侧存放虚拟地址,右侧存放虚拟地址,这么一个一一映射的关系。
当想要访问一个变量或代码的时,由CPU通过页表将其虚拟地址转化为物理地址。
- 除了页表之外,CPU中还有一个硬件MMU(memory manage unit) ,用来完成虚拟地址向物理地址的转换
- 每个进程都有自己对应的页表
5.解决问题
之前我们已经发现了,下面代码的问题,并对其做出了分析,知道了虚拟地址的存在,结果了解进程地址空间和页表后,我们来具体看一下该代码是运行的。
-
我们知道,
fork()
函数运行后产生了子进程,且子进程是在fork函数内产生的,子进程以父进程为模板创建(其中内核数据结构的属性字段绝大部分继承自父进程),所以它们的虚拟地址是相同的,父子进程第一次循环且打印出flag
值是相同的。 -
那在fork函数return之前,该运行的代码已经运行完,子进程也必然已经产生,在运行到return时,已经有了两个进程,所以返回的是两个不同的值。
-
这里我们要明白两点
- 进程是具有独立性的,一个进程的改变不能影响到另一个进程,所以在数据或代码发生变化时,系统会重新找一个物理空间将原来的内容拷贝后移动到新空间。
- 操作系统可以看作是一个吝啬的人,它尽可能的不会将一模一样的空间创造两次,来占用内存的空间,除非是两个有区别的空间,这也是写实拷贝的思想。在fork函数内return时,父子进程存放代码和数据的物理地址是相同的,此时可将它们勉强看作是一个进程,而return后,父子进程的返回值不同,造成了变量
id
的值不同,同时改变页表的映射关系使其不在指向相同的物理空间(虚拟地址不变),使得两个进程不在相同,发生写实拷贝父子进程彻底分为两个进程。
如此,便有了虚拟地址相同而值却不同的情况。
注意: 该代码中父子进程,谁先让物理地址的数据发生改变,谁就发生写实拷贝
6.进程地址空间为什么存在
我们已经了解了Linux的地址空间到底是什么样子,那它为什么要存在呢?原因分为以下几点
-
防止地址随意访问,保护物理内存和其他进程不被修改
我们试着想一下,没有地址空间,存储会变成什么样子?
- 在我们编写代码时,可以使用指针随意的访问和修改物理地址的内容,这会影响到其他进程或操作系统的正常运行。
- 物理地址所有内容暴漏在用户之下,不在有可读可写的区别。
而有了进程地址空间,这些问题得以解决
-
使用进程地址空间,让虚拟内存限定指针的活动范围,由页表经过映射到物理地址访问具体数据,会更加的安全。
-
其中,虚拟内存既然是分为各个区域存放数据的,每个区域的职责和特性都不相同,而虚拟内存只是定义了范围,具体的职责和特性功能在页表中实现(页表可不止key和value),比方说常量字符串,不能被修改的特性由页表实现,当PCB指向对应的虚拟内存,之后经过页表转换找到对应的物理地址,在转换的过程中,页表会查看操作是否合理,若合理则就绪否则不予理会,就像下面的代码,无法将其实现一样。
char* pt = "hello world!"; pt[0] = 'H';
-
将进程管理和内存管理进行解耦合
从我们之前所学内容可以看到,内存和进程管理有很强的联系,也就是它们的耦合度很高,而对于一款软件来说,低耦合才是我们所需要的,而降低耦合的方法正是进程的地址空间。
-
malloc申请空间的本质
操作系统是一个吝啬的人,当我们使用malloc函数向操作系统申请内存时,它不会直接为我们分配一块物理内存,因为我们得到之后不一定就是立马拿来用,哪怕下一行代码就用到了分配的空间,也有可能因为进程的时间片用完,进程暂时终止等待下一次CPU资源,导致分配的内存空间暂时被浪费无法使用。
而遇到malloc申请地址时,进程会修改虚拟地址对应的范围,页表有对应的key值产生,而value值却没有(此时这种情况叫页表缺页),当CPU运行需要申请的空间时,才会赋予对应的value值,分配对应的内存。
-
虚拟地址对应的物理地址的位置
不同于虚拟地址的按部就班,不同的数据和变量存储于不同的位置而井然有序,对应的物理地址就不在乎这些,因为页表的存在,不论存放在哪里都可以通过映射找到对应的地址。
这就是双方的耦合度降到很低,进程不在关心物理地址存放的位置,物理地址也不用担心进程找不到对应的数据,各行其是。
-
-
让进程以统一的视角看待自己的代码和数据
存放在内存中的程序的代码和数据未必是按照顺序存储的,可能按照不同的特性和功能存放在不同的位置,而进程的虚拟地址则是线性的,因为页表的存在,不用去管物理内存的具体的情况,通过转化虚拟内存的方式找到对应物理地址中的数据,使得所有进程都可以按照统一的视角看待自己的代码和数据。
也就是说,所有进程都认为自己的数据存储的方式是以进程的地址空间也就是虚拟地址的方式存储,这样减少了很多麻烦,使得各种软件都已该方式建立,将剩余工作交给操作系统。