文章目录
- 1. 进程地址空间
- 1.1 进程地址空间是什么
- 1.2 Linux中的进程地址空间
- 2. 虚拟地址
- 3. 进程地址空间存在的意义
- 3.1 安全性
- 3.2 有序性
- 3.3 解耦合
1. 进程地址空间
1.1 进程地址空间是什么
在学习语言的时候,我们见过这么一张图:
当时,我们说,这是一个程序所对应的地址空间,我们称之为程序地址空间,而这个程序地址空间,就是我们接下来要介绍的进程地址空间,进程所对应的代码和数据在进程地址空间中都有相应的存储位置。
需要说明的是,上述进程地址空间基于32位的操作系统,因此,相应的空间大小为4GB左右。其中,用户可以直接访问的空间为3GB,还有1GB是内核空间,必须要通过系统调用才能访问。
1.2 Linux中的进程地址空间
与进程的task_struct相同,Linux中,同样使用一个结构体mm_struct来描述进程地址空间。
每一个进程,都有一个自己的task_struct,同时也有一个自己的mm_struct,在task_struct结构中,会有一个指针指向mm_struct。
可具体是如何描述的呢?进程地址空间内部有不同的空间划分,也就是说不同的空间区域都有其对应的不同的起始和结束地址,而地址本质上就是一个数字,因此就可以使用两个数字,一个代表起始处,另一个代表结束处,来确定一个空间区域的范围。
Linux的内核源码中,确实也是这么做的。
但是这样的mm_struct结构会存在一个问题,它只能确定一个空间的整体范围是多少,但是并不能做到对这个空间做细分,而实质上,我们申请空间时(比如多次malloc),都是去拿到一个大空间内部的小空间,可上述做法显然不能对空间做进一步的细分。
所以,Linux中,又提供了这样一个结构vm_area_struct,来实现对大块空间的细分操作。
struct vm_area_struct {unsigned long vm_start; //虚存区起始unsigned long vm_end; //虚存区结束struct vm_area_struct *vm_next, *vm_prev; //前后指针struct rb_node vm_rb; //红⿊树中的位置unsigned long rb_subtree_gap;struct mm_struct *vm_mm; //所属的 mm_structpgprot_t vm_page_prot;unsigned long vm_flags; //标志位struct {struct rb_node rb;unsigned long rb_subtree_last;} shared;struct list_head anon_vma_chain;struct anon_vma *anon_vma;const struct vm_operations_struct *vm_ops; //vma对应的实际操作unsigned long vm_pgoff; //⽂件映射偏移量struct file * vm_file; //映射的⽂件void * vm_private_data; //私有数据atomic_long_t swap_readahead_info;#ifndef CONFIG_MMUstruct vm_region *vm_region; /* NOMMU mapping region */#endif#ifdef CONFIG_NUMAstruct mempolicy *vm_policy; /* NUMA policy for the VMA */#endifstruct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
在上图中的这个结构体中,存在两个成员vm_start和vm_end,这两个成员就是用来对空间做进一步细分的。
因此,每申请一块小空间,就要创建对应于这个小空间的vm_area_struct。所以,对于一个进程的mm_struct而言,肯定会存在多个vm_area_struct,因此mm_struct必须要将这些对象管理起来,一般会有两种管理方式:使用双链表(vm_area_struct较少时)进行管理或使用红黑树(vm_area_struct较多时)进行管理。
从上述的结构体中,vm_prev和vm_next所对应的前后指针,以及struct rb_node vm_rb所对应的红黑树结点结构,也可以看出这两种管理方式。
2. 虚拟地址
在大致了解进程地址空间是什么后,我们来思考一个问题,进程地址空间中对应的地址,是实际的物理地址,还是虚拟地址呢?
先说结论,进程地址空间中对应的地址,全部都是虚拟地址。
我们先来看一个示例:
在之前的学习中,我们是这样说的。父子进程之间会共享代码和数据,但由于进程之间要保持独立性,如果父子进程在后续的代码执行中,同一数据出现分歧,那么就要对这个数据变量进行分离,实际变为两个变量,一个对应父进程,一个对应子进程。
可问题是,我们在上述代码中,子进程对g_val变量进行了修改,故此时子进程中的g_val和父进程中的g_val,实质上并不是相同的变量,可是我们将其取地址并打印后,发现打印结果居然是一样的。这就说明一个事实,进程地址空间是虚拟地址,因为一个物理地址上不可能能够存储两个独立变量。
既然进程地址空间是虚拟地址,而实际存储应存储到物理内存上,这样的操作具体是如何完成的呢?
在Linux中,使用了页表这种数据结构进行虚拟内存到物理内存的映射,进而完成进程代码和数据的存储。
由于每一个进程都有其对应的页表,我们可以来更新一下关于进程的认知:进程=task_struct + mm_struct + 页表 + 相关的代码和数据。
现在,我们就能理解上述代码的运行结果了。
父进程创建子进程,子进程大部分内容都是父进程的拷贝。因此,对于g_val全局变量,它在父进程mm_struct中的虚拟地址与子进程mm_struct中的虚拟地址是相同的,在子进程未修改g_val前,它们页表中的映射也是相同的,也就是说父子进程在物理内存中,拿到的是同一个g_val。而在g_val被子进程修改后,操作系统会在这个写入操作完成之前,为子进程在物理内存中申请一块空间,用以存储另一个g_val,同时更改子进程页表,不改变虚拟地址,而是更改映射到的物理地址。而取地址操作,取出的是变量的虚拟地址,这也就是为什么上述代码运行结果中,g_val地址相同,但是值不同的原因——父子进程中g_val的虚拟地址相同,但是映射到的物理地址处不同,实际已是两个变量。
上述操作实现了进程间数据层面的分离,维护了进程的独立性,而由于该操作是在子进程进行写入时发生的,因此又被叫做写时拷贝。写时拷贝是完全由操作系统自主完成的。
3. 进程地址空间存在的意义
通过上面的学习,我们明白了,进程地址空间实际上就是虚拟地址空间,因为其中的地址都是虚拟地址,并不对应物理内存。
那现在我们来思考,为什么要设计这个虚拟地址空间呢,进程直接对物理内存进行操作不行吗?
下面,我们从三个角度来阐明进程地址空间的意义。
3.1 安全性
进程地址空间的存在,使得在访问物理内存时,都要经过一次转换,即利用页表,从虚拟内存映射到物理内存。而在这个转换过程中,会由操作系统进行安全性检查,一旦断定为非法危险操作,便拒绝该操作,甚至杀死相关进程。
所以,进程地址空间的存在,使得物理内存空间不能随意被进程访问,既维护了进程的独立性,又保护了物理内存空间的安全。
3.2 有序性
我们知道,创建一个进程时,现在内存中创建相应的内核数据结构,再加载相应的代码和数据。那现在有一个问题,进程的代码和数据会被立刻全部加载吗?
一般情况下,一个进程被创建后,是不会立刻得到调度的,会有一段等待时间,而**为了提高内存空间的利用率,进程的相应代码和数据,不会立刻加载,而是等到进程即将被调度时,再加载到内存中。**同时,进程在CPU上运行时,不可能一次性用到所有代码和数据,所以进程的代码和数据也没必要一次性全部加载完,可以在进程的运行过程中,边运行边加载,这就是一种惰性加载。
所以,进程的代码和数据是分批加载到物理内存中的,物理内存是无序的,并没有严格的空间划分,理论上,进程的代码和数据可以加载到物理内存中任意还未利用到的地方。这就导致一个问题:同一进程的代码和数据加载到物理内存后,可能是散乱的。
在这种情况下,如果直接对物理内存进行管理,由于散乱的存储,相关代码和数据直接管理起来会很不方便。
而进程地址空间是有序的,有严格的空间划分,因此不管物理内存中如何存储,只要页表处理好相应的映射关系,进程从进程地址空间的角度来看待其代码和数据,始终是有序的。
所以,进程地址空间的存在,保证了进程看待其代码和数据的有序性。
3.3 解耦合
由于进程地址空间和页表的存在,实际上进程申请相关内存时,并不需要知道物理内存中的具体位置,只要管理好自身的进程地址空间,再由操作系统管理好相应页表中的映射关系,即可实现虚拟内存到物理内存之间的转换。
此时,操作系统对进程的管理与内存(物理内存)的管理之间便不再紧密联系,只要维护好相应的用于存储映射关系的页表即可。
所以,进程地址空间的存在,使得操作系统的进程管理与内存管理解耦合。