目录
- 程序地址空间
- 虚拟地址
- 进程地址空间
- 虚拟内存管理
- 为什么要有虚拟地址空间
程序地址空间
程序地址空间回顾
我们在讲C语⾔的时候,⽼师给⼤家画过这样的空间布局图.
可是我们对他并不理解!可以先对其进⾏各区域分布验证:
来理解一下语言层的一些概念,之前可能只是记住了这些语言的用法,但是不知道是为什么会这么用,现在来浅浅的理解一下。
之前我们说字符串常量不能修改,为什么呢?我们可以看到字符串常量的地址和正文代码区的地址很接近,因为它被硬编码到正文代码区了,正文代码区的内容是只读的,不能做修改,因此字符串常量不能修改。
const char *str = "helloworld";
之前我们说static修饰的变量生命周期是全局的,做不过作用域在函数内部而已,这又是为什么呢?我们可以看到static常量的地址和正文代码区的地址很接近,其实static修饰的变量在初始化数据区内。
static int test = 10;
虚拟地址
来段代码感受⼀下。
本来父子进程应该看到的是同一个变量,但是子进程修改了变量的值,所以会发生写时拷贝,但是!!!为什么父子进程中的g_val的是地址相同的,所以说这还是物理内存吗?怎么可能一个地址会有两个不同的值?没错!这个不是物理内存中的地址,是虚拟内存地址。
我们发现,⽗⼦进程,输出地址是⼀致的,但是变量内容不⼀样!能得出如下结论:
- 变量内容不⼀样,所以⽗⼦进程输出的变量绝对不是同⼀个变量
- 但地址值是⼀样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做虚拟地址
- 我们在⽤C/C++语⾔所看到的地址,全部都是虚拟地址!物理地址,用户⼀概看不到,由OS统⼀管理。
OS必须负责将 虚拟地址 转化成 物理地址 。
进程地址空间
所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?看图:分⻚&虚拟地址空间
虚拟地址空间和页表,物理地址三者之间的动态过程!!!
数据和代码加载到物理内存,根据代码和数据的大小,来初始化虚拟地址空间(本质上就是调整划分区域空间),此时页表对虚拟地址和物理地址进行映射,物理地址转化为虚拟地址,提供给上层使用。
创建进程的同时,struct mm_struct也会被创建,开辟空间,怎么初始化呢?加载代码和数据到内存的时候进行初始化(根据他们的大小)。
父进程创建时,会同时创建PCB,struct mm_struct,页表,并加载父进程对应的代码和数据,将物理内存中的地址转化为虚拟地址,再构建页表映射,子进程拷贝父进程的PCB,struct mm_struct,页表,当子进程修改数据时,OS为了进程的独立性,发生写时拷贝,再修改子进程虚拟地址映射的物理地址,所以虚拟地址是相同的,物理地址不同。
上⾯的图就⾜矣说明问题,同⼀个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
结论:
- 一个进程一个虚拟地址空间。
- 一个进程,一个页表
- 页表是用来虚拟地址胡物理地址映射的。
- 一个整形变量是4个字节,可以通过该变量的虚拟起始地址加上偏移量,就可以找到访问全部地址了。
- 打印的地址是虚拟地址,这就是为什么子进程的g_val改变了,同时也发生了写时拷贝,但是父子进程的g_val的地址是相同的。
那么每一个进程的虚拟地址空间需要管理吗?答案是需要。
虚拟内存管理
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的结构。
structtask_struct{structmm_struct*mm;//对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。structmm_struct*active_mm;//表示当前任务运行时,CPU 实际激活、正在使用的地址空间。}可以说,mm_struct结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴的mm_struct,这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:
定位mm_struct⽂件所在位置和task_struct所在路径是⼀样的,不过他们所在⽂件是不⼀样的,mm_struct所在的⽂件是mm_types.h。
1structmm_struct2{3/*...*/4structvm_area_struct*mmap;/* 指向虚拟区间(VMA)链表 */5structrb_rootmm_rb;/* red_black树 方便查找mmap中的节点,也就是堆区*/6unsignedlongtask_size;/*具有该结构体的进程的虚拟地址空间的⼤⼩*/7/*...*/8// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。9unsignedlongstart_code,end_code,start_data,end_data;10unsignedlongstart_brk,brk,start_stack;11unsignedlongarg_start,arg_end,env_start,env_end;12/*...*///简化版本。可以根据这个来理解,上面那个是源码里面的内容structmm_struct{pgd_t*pgd;// 页表根地址unsignedlongstart_code;// 代码段起始地址unsignedlongend_code;unsignedlongstart_data;// 数据段起始地址unsignedlongend_data;unsignedlongstart_brk;// 堆起始地址unsignedlongbrk;// 当前堆顶unsignedlongstart_stack;// 栈起始地址// 虚拟内存区域 VMA 等};每一个进程都会有自己独立的 mm_struct 来统筹整个内存空间。但由于一个进程的虚拟空间被划分成了众多不同属性的虚拟内存区域(VMA),操作系统需要将该进程内所有的 vm_area_struct 高效地组织起来!在 mm_struct 内部,虚拟空间的组织方式同时采用了以下两种数据结构:
1.为了方便从头到尾遍历所有的内存区域,内核使用双向链表将它们串起来,由 mm_struct 里的 mmap 指针指向链表头;
2.为了在程序发生缺页中断时能极快地查找到某个内存地址所在的区域,内核同时使用红黑树(较新内核为 Maple Tree)进行管理,由 mm_struct 里的 mm_rb 指针指向这棵树的根节点。”
linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型的虚拟内存区域。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,⽅便进程快速访问。
structvm_area_struct{unsignedlongvm_start;//虚存区起始unsignedlongvm_end;//虚存区结束structvm_area_struct*vm_next,*vm_prev;//前后指针structrb_nodevm_rb;//红⿊树中的位置unsignedlongrb_subtree_gap;structmm_struct*vm_mm;//所属的 mm_structpgprot_tvm_page_prot;unsignedlongvm_flags;//标志位struct{structrb_noderb;unsignedlongrb_subtree_last;}shared;structlist_headanon_vma_chain;structanon_vma*anon_vma;conststructvm_operations_struct*vm_ops;//vma对应的实际操作unsignedlongvm_pgoff;//⽂件映射偏移量structfile*vm_file;//映射的⽂件void*vm_private_data;//私有数据atomic_long_tswap_readahead_info;#ifndefCONFIG_MMUstructvm_region*vm_region;/* NOMMU mapping region */#endif#ifdefCONFIG_NUMAstructmempolicy*vm_policy;/* NUMA policy for the VMA */#endifstructvm_userfaultfd_ctxvm_userfaultfd_ctx;}__randomize_layout;所以我们可以对上图在进⾏更细致的描述,如下图所示:
注意:虚拟地址是一块整体的空间,然后vm_area_struct是里面不同的区域块。
为什么要有虚拟地址空间
这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?
在早期的计算机中,要运⾏⼀个程序,会把这些程序全都装⼊内存,程序都是直接运⾏在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运⾏多个程序时,必须保证这些程序⽤到的内存总量要⼩于计算机实际物理内存的⼤⼩。
那当程序同时运⾏多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存⼤⼩是128M,现在同时运⾏两个程序A和B,A需占⽤内存10M,B需占⽤内存110。计算机在给程序分配内存时会采取这样的⽅法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。
这种分配⽅法可以保证程序A和程序B都能运⾏,但是这种简单的内存分配策略问题很多。
- 安全⻛险
◦ 每个进程都可以访问任意的内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。 - 地址不确定
◦ 众所周知,编译完成后的程序是存放在硬盘上的,当运⾏的时候,需要将程序搬到内存当中去运⾏,如果直接使⽤物理地址的话,我们⽆法确定内存现在使⽤到哪⾥了,也就是说拷⻉的实际内存地址每⼀次运⾏都是不确定的,⽐如:第⼀次执⾏a.out时候,内存当中⼀个进程都没有运⾏,所以搬移到内存地址是0x00000000,但是第⼆次的时候,内存已经有10个进程在运⾏了,那执⾏a.out的时候,内存地址就不⼀定了 - 效率低下
◦ 在没有引入分页机制的早期内存管理中,一个进程是被作为一个完整的独立内存块进行操作的。当物理内存不够用时,为了给新程序腾出空间,操作系统只能将某个暂时不活跃的进程整个拷贝到磁盘的交换分区中。由于进程占用的空间往往很大,这种把整个进程在内存和磁盘之间来回搬运的方式极其耗时,导致系统运行效率极低。而现代的虚拟内存技术通过引入‘分页机制’解决了这个问题,它允许操作系统以极小的‘页’(通常为 4KB)为单位将不常用的内存数据换出到磁盘,再也不需要笨重地搬运整个进程了(页表的功能!)。
存在这么多问题,有了虚拟地址空间和分⻚机制就能解决了吗?当然!
- 地址空间和⻚表是OS创建并维护的!是不是也就意味着,凡是想使⽤地址空间和⻚表进⾏映射,也⼀定要在OS的监管之下来进⾏访问!!也顺便 ,包括各个进程以及内核的相关有效数据!保护了物理内存中的所有的合法数据。
- 因为有地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配 和 进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。
- 因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚⾄⼀个字节都不给你。⽽当你真正进⾏对物理地址空间访问的时候,才执⾏内存的相关管理算法,帮你申请内存,构建⻚表映射关系(延迟分配),这是由操作系统⾃动完成,⽤⼾包括进程完全0感知!!
- 因为⻚表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进⾏映射,在进程视⻆所有的内存分布都可以是有序的。
总结:
1.将内存地址从无序变有序。虚拟地址是有序的,物理地址是无序的。
2.虚拟地址转化为物理地址(OS查找页表)的过程中,也可以对你的地址和操作进行非法判定(页表中有读写权限),从而实现对物理内存的保护。
例如:野指针,访问地址空间,但是页表无法转换,页表构建映射时,该地址没有被使用,可以直接杀掉你的进程。
char* str = "hello",*str = "world";这个代码编译会通过,但是运行崩溃,因为常量字符串是在字符串常量区,也就是正文代码区,而正文代码区是只读的,如果修改的话,程序会运行崩溃,查找页表时,权限会被拦截。3.进程管理模块和内存管理模块就完成了解耦合。
例如:加载代码和数据量大时,只加载一部分到物理内存,但此时虚拟地址已经初始化好了,页表也已经构建好了。把加载到内存中代码运行完之后,虚拟地址映射物理地址时,发现页表上只有虚拟地址,没有物理地址,OS会再加载剩下的代码到内存,同时构建好页表,这个也叫做动态内存,这个一整个过程叫做缺页中断。这也做到了进程管理模块和内存管理模块的解耦合。
澄清一些细节问题?
- 如何理解挂起?
把对应的物理内存中的数据唤出到磁盘中(swap交换分区)。
- 我们可以不加载数据和代码到物理内存,只有PCB,struct mm-struct,页表。
- 创建进程时,先有PCB,struct mm-struct,页表,再加载代码和数据。