虚拟内存以及Linux内存管理详解

1,074 阅读28分钟

理论世界

什么是虚拟内存?

虚拟内存是被抽象出来的,一块块跟物理内存地址相关联的虚拟地址。它给每个进程提供了一种自己的地址空间都是从零开始增长的假象,但实际上这些地址空间都映射在同一片物理内存的不同区域,这就是内存的虚拟化过程

为什么需要虚拟内存?

为了实现操作系统的易用性、隔离性和保护性

易用性:操作系统通过虚拟内存让进程误以为自己操作的是一片连续的空间(实际上这些连续的空间可能映射到了分散的物理内存上)让程序员得以方便地对整块内存进行操作,而不是去操作那些零散的物理内存

隔离性与保护性:操作系统常常需要并发进行多个进程执行多个任务,这些进程的状态信息都保存在内存中,而通过虚拟内存,每个进程所面对的都是从0开始增长的一片完整内存空间,对于进程来说,超出它虚拟内存空间范围的其他内存地址完全是不存在的,也就保证了当前进程不会篡改到其他进程的信息,如此实现了内存隔离

想象有这么一所学校(学校:操作系统),这所学校里有学号从0到1000的1000个学生(学号:物理内存地址)和10个班级(班级:进程),每个班级里随机分配了100个学生,可能学号为12和学号为932的学生都在同一个班级里,班主任如何更好地管理班级里的学生呢?给他们编排一个班级内从0到100的座位号!(座位号:虚拟内存地址)

这样班主任就可以省心地说,“座位号从0到10的同学们去扫地,11到15的同学们去倒垃圾。”,而不是说”学号12、学号932、学号452……学号23的同学去扫地……”(虚拟内存实现的易用性)

并且,如果这是个粗心的班主任,完全有可能喊到学号为11的不在自己班上的学生,而通过座位号来号令就不会有这种烦恼,班主任不可能喊到学号11的学生,因为这个学生在这个班上根本就没有编排座位号(虚拟内存实现的隔离性)

虚拟内存想要实现什么目标?

高效:这就意味着需要利用硬件的支持

控制:操作系统要确保应用程序只能访问自己的内存空间

灵活:希望程序可以用任何方式访问自己的地址空间,方便编程

如何实现内存虚拟化?一:动态重定位

作为OS最初的内存虚拟化尝试,动态重定位技术的基本原理是:在硬件方面准备两个寄存器:基址寄存器和界限寄存器(即MMU内存管理单元),基址寄存器中存放着进程的初始地址,每次执行指令时用程序计数器+基址寄存器就可以得到指令所在的物理地址,而界限寄存器提供了访问保护,当进程访问了超过这个界限的虚拟地址,CPU将触发异常

1.png

这种应用较为简单,但存在严重的效率缺陷,因为它要求将地址空间放在固定大小的槽块中,比如当我们为一个进程映射了16KB的物理内存空间,但它实际上却用不到这么多,导致这块内存中大量空间被浪费(这通常称为内部碎片,即已分配的内存单元内有未使用的空间)

就好像在我们的操作系统学校中,固定为每个班级分配100个学生的名额,但是一些班级实际上只有50个人,那就会有50个名额被浪费了

如何实现内存虚拟化?二:分段

不同于动态重定位以进程为单位分配基址寄存器-界限寄存器,分段技术将各个逻辑段分离,给每一个逻辑段分配一对基址寄存器-界限寄存器对,寻址时物理地址=虚拟地址-逻辑段的起始地址+基址寄存器中的值

比如有一个在堆中的虚拟地址为4200,堆的起始地址为4KB,那么实际物理地址就等于4200先减去4KB得到虚拟地址在该段中的偏移后,再加上基址寄存器中的值

插叙:一个进程的内存空间包含多个逻辑段,如代码段、数据段、BSS段、堆、栈等,其中堆从低地址向高地址增长,栈从高地址向低地址增长,OS需要为这两者之间预留一些内存空间供它们为程序动态分配内存

与动态重定位的区别在于:使用分段方式的话不需要为栈和堆之间还没有使用的区域分配物理内存,它只为已经用到的虚拟地址空间分配物理空间,所以不存在内部碎片的问题

但是由于每个逻辑段的长度并不固定,所以当一些进程退出后逻辑段的内存被释放,会出现一块块小的、不连续的物理内存块,导致新程序无法在这些小块内存上装载(这通常被称为外部碎片

就好像在学校中,按照分段技术的原理,一班和三班按需各分配了50名学生的名额,二班和四班各分配了20个学生的名额,后来二班和四班的学生们都毕业了,之后又来了30名新学生,虽然二班和四班空出来的总名额有40个,但是这新来的30个学生却无法分配到任何一个班(因为这两个班都只有20个名额)

可能有人会问,把二班和四班合并成一个班不就好了吗?

实际上,这种合并也就是解决外部碎片的方法,OS将小块内存都换入硬盘的交换空间中(swap space),在交换空间中合并成一整块大内存再从硬盘中换出,即可整合这些内存碎片,但OS访问硬盘的速度是很慢的,频繁的换入换出将导致OS遭遇性能瓶颈,与我们"高效"的虚拟化目标就相悖了

如何实现内存虚拟化?三:分页

在分段的方式中,之所以会出现外部碎片的问题核心在于:分段是将空间分割成不同长度的分片,所以必然会在释放和申请的过程中产生外部碎片问题。

为了彻底解决这个问题,诞生了分页的方式,它将进程的地址空间分割成固定大小的单元,并相应地把物理内存看成是定长槽块的阵列,每个槽块叫做一个页帧,而每个页帧则对应着一块虚拟内存页

虚拟地址 = VPN页表号 + offset偏移量

同时每个进程里有一个数据结构叫做页表,记录虚拟页在内存页中的位置,里面的每一条记录叫做一条PTE,一条PTE存储了虚拟页号VPN到物理页号PFN的转换,并且设置了诸如有效位(未使用的空间被标记为无效,访问之将触发page_fault);保护位(表明页的执行权限);存在位(表明页是在物理存储器还是磁盘上,判断它是否已被换出);脏位(表明页被带入内存后是否被修改过);参考位(追踪页是否被访问,以及判断哪些页经常被访问)等标志位,记录这块地址的相关信息

操作系统通过VPN页表号和offset偏移量来定位虚拟地址在哪个虚拟页的哪个位置,即检索到页表中对应的那条PTE,通过PTE将VPN转换为PFN,而偏移量不变即得到了物理地址

物理地址 = PFN物理页号 + offset偏移量

这是一个解决虚拟内存很好的思路,比如可以避免外部碎片,也可以非常灵活地支持稀疏虚拟地址空间,但是目前存在两方面明显的缺陷

  1. 页表存储在物理内存中,而对于每个内存引用,分页都需要执行一个额外的内存引用去频繁访问页表才能得到指令真实的物理地址,这降低了执行速度,产生了效率问题

    解决效率问题:TLB快表

    由于空间局部性原理,整个程序的执行部分往往局限于一部分内存区域,可以将常访问的页表项存储到访问速度更快的硬件,这个硬件就是TLB快表

    一条TLB内容包括:VPN | PFN | 有效位 | 保护位……

    这里的有效位不同于页表中的有效位,页表的有效位指出该地址是否在被进程使用;而TLB的有效位仅指出该项是不是有效的地址映射,并不意味着该地址无效,可能只是这个地址映射还没有被加载到TLB里而已

    当需要访问页表查找对应的物理内存帧号PFN时,先在TLB里寻找

    • 如果命中就可以不访问内存

    • 如果未命中再去访问内存取得信息,并在TLB里缓存那个PTE所在页的所有映射

    为什么缓存所有映射?:基于空间的局部性原理,让每个页的访问最多只会遇到一次未命中

    当TLB未命中时,硬件系统将抛出一个异常暂停当前的指令流,提升特权级别到内核模式,并跳转到trap handler跳入内核中处理未命中的对应代码,在这段代码中将查找页表的转换映射,然后更新TLB,最后返回,随后重试指令再从TLB中取出PTE

    同时,为了防止TLB未命中无限递归,OS会把处理TLB未命中的程序直接放在物理内存上,不通过地址转换,并在TLB中保留一些记录永久有效的地址转换项,并把这些PTE对应的物理帧页留给处理代码本身

    当发生上下文切换(也就是进程切换)时,由于不同进程拥有不同内容的TLB快表, OS一般来说有两种 策略来更新TLB

    1. 清空TLB,然后重新触发TLB未命中,慢慢填满TLB

    2. 在硬件上为TLB添加一个地址空间标识符ASID,标识当前这条TLB内容是属于哪 个进程的,这样就可以让TLB存储不同ASID下相同的虚拟页的映射

    当在TLB插入新项时,会替换出一个旧项,一般来说又有两种典型策略

    1. LRU,替换最少使用过的项,运用了局部性原理,将尾部的页淘汰

    2. 随机策略,替换随机选择的一项,可以避免一些极端情况下LRU性能不佳的情况

      比如说:程序循环访问n+1个页,但TLB大小只能存放n个页,比如说访问到n+1页时,必须把尾部的第0页替换出去,但访问完n+1页后,程序又循环去访问0页,导致每次都会触发TLB未命中

  2. 如果按照最原始的页表,因为每个进程的每个地址都需要一条PTE记录去覆盖到,所以将耗费大量的空间去存储页表,产生了内存消耗问题

    解决消耗问题:多级页表

    以数组为架构的分页将使得我们需要很多页表去覆盖所有进程里所有的地址空间,而多级分页将线性页表变成了类似于树的东西

    2.png

    多级页表首先将页表分成一个个页单元,若整个页单元里的页表项均无效,则完全不分配该页的页表,相当于为一个个页单元创建了多层的目录,在翻译虚拟地址时会循着一层一层的目录逐层地寻找相应的目录项,最终索引到目标PTE

    虚拟地址 = 页目录项 + 页表项 + offset

    把虚拟地址空间想象成一本100页的书,线性页表需要实打实地创造出100页,才能让我们知道它有100页;而多级页表增添了一个目录,对于空白页来说,可以只在目录上记录它们的存在,如果读者准备翻到空白页时再创造出这个空白页(通过触发pagefault创建)

真实世界

linux的虚拟空间内存结构

3.png

OS的内存分为两种空间,一种是内核空间,这部分空间关联着相同的物理内存,方便切换到内核态后访问内核;另一部分是用户空间,这部分空间里的每个进程有独立的虚拟内存

32位机器下:指针的寻址范围为2^32,所能表达的虚拟内存大小为4GB

64位机器下:只使用48位描述虚拟空间,寻址范围为2^48,所能表达的虚拟内存空间为256TB

  • 低128T表示用户态,其中高16位全为0,以此可以判断一个虚拟内存地址是用户态
  • 高128T表示内核态,其中高16位全为1,以此可以判断一个虚拟内存地址是内核态
  • 中间的canonical address空洞,其高16位不全为1也不全为0,对其的访问是非法的

用户态空间(从低地址到高地址)

  • 保留区:很小的地址空间,不允许使用,是空指针所指向的区域

  • 代码段:用于存放进程程序二进制文件中的机器指令

  • 数据段、BSS段:用于存放程序二进制文件中定义的全局变量和静态变量

    其中BSS段没有初值,在物理内存中有一块全为0的page,所有为0的虚拟内存page都指向该空间且设置成只读,发生写操作时触发page fault再重新开辟一段物理内存,映射虚拟内存

  • 堆:用于在程序运行过程中动态申请内存

  • 待分配区域:用于拓展堆空间

  • 文件映射与匿名映射区:用于存放动态链接库以及内存映射,地址增长方向从高地址向低地址

  • 待分配区域:用于拓展栈空间

  • 栈:用于存放函数调用过程中的局部变量和函数参数,地址增长方向从高地址向低地址

    为什么设计栈为从高地址往低地址增长?:因为这样做可以让栈固定起始位置在高地址,如果设计为从低地址增长到高地址,那只能固定结束位置了,而因为栈和堆的申请在不同程序中都不相同,就难以固定它的起始位置

    4.png

内核态空间(从低地址到高地址)

  • 32位机器下:

    直接映射区:总大小为896M,前1M被系统占用,这块区域中映射关系为一比一映射且固定不变

    • 3G(TASK_SIZE)~3G+16M:DMA映射区,用来为DMA控制器分配内存

      内核空间起始点是3G,前3G是用户态空间,其中TASK_SIZE定义了用户和内核的分界点

    • 3G+16M~3G+896M(high_memory):NORMAL映射区

    高端内存映射区:总大小为128M,需要映射到物理内存上3200M的ZONE_HIGHMEM高端内存区

    这里就是32位机器内核态和64位机器内核态结构不同的主要原因之一:由于32位机器上这块映射区大小只有128M,却需要负责3200M的物理内存,故需要使用动态映射的方式分批映射,使用完毕后随机解除映射腾出空间

    • high_memory~VMALLOC_START:8M空洞

    • VMALLOC_START~VMALLOC_END:vmalloc动态映射区,利用vmalloc通过页表建立起连续的虚拟内存跟不连续的物理内存的映射

    • PKMAP_BASE~FIXADDR_START:永久映射区,这片虚拟地址空间允许建立与物理高端内存长期的映射关系,通过alloc_pages()申请到的高端物理内存页可通过kmap映射到永久映射区

      LAST_PKMAP表示永久映射区可映射的页数限制

    • FIXADDR_START~FIXADDR_TOP:固定映射区,内核在启动时分配的一些固定虚拟地址,相当于一个个指针常量,可自由映射到不固定的物理地址

    • FIXADDR_START~0xFFF FFFF:临时映射区,用作临时映射物理内存的缓存页到虚拟地址上供内核操作,操作结束后即解除映射

  • 64位机器下

    虽然只用到了低48位来表示虚拟内存地址,但64位机器的内核态空间依旧足足有128T的大小,所以不需要像32位机器那样“精打细算”,直接全部直接映射物理内存也是足够的

    • 0xFFFF 8000 0000 0000~0xFFFF 8800 0000 0000(PAGE_OFFSET):空洞,大小为8T
    • 0xFFFF 8800 0000 0000~0xFFFF C800 0000 0000:直接映射区,大小为64T,这个区域内的虚拟内存地址减去PAGE_OFFSET就直接得到了物理内存地址
    • VMALLOC_START~VMALLOC_END:vmalloc映射区,大小为32T,类似于用户态中的堆供内核使用vmalloc申请内存
    • VMEMMAP_START~XXX:虚拟内存映射区,大小为1T,用来存放物理页描述符struct page结构
    • _START_KERNEL_map~xxx:代码段,大小为512M,用于存放内核代码段、全局变量、BSS等,和直接映射区类似,这个区域内的虚拟内存地址减去 _START_KERNEL_map即物理地址

Linux的虚拟内存管理

分配机制

首先,让我们从程序猿最熟悉的视角引入:在编写程序中,我们如何分配内存?

答案很简单,通过malloc申请堆内存;通过free释放堆内存

对于堆内存来说,由程序猿显式地完成它的申请和释放;而对于栈内存来说,由编译器隐式地管理它的申请和释放,故它也被叫做自动内存,比如当进入函数时将在栈上开辟空间,退出函数释放空间。

接下来我们再看看当我们调用一个malloc时,OS究竟发生了什么

  • 当通过malloc()申请的内存 < 128KB时,OS将通过brk()系统调用在堆上申请一块空间

    brk即堆的堆顶指针,申请一块空间就是让brk指向一个更高的地址5.png

    以这种方式申请的内存,free掉内存之后,堆内存将缓存在内存池中,等待下一次申请时可直接复用,这提升了性能也带来了弊端:如果每次申请的内存都比空闲内存大,就需要不断地申请新空间,使得小块的空闲内存越来越多最终导致内存泄漏

    空闲内存空间管理

    操作系统会通过空闲列表寻址那些空闲内存,方便日后分配

    6.png

    在空闲内存池中寻找空间分配内存有如下几种基本策略

    1. 最优匹配:遍历空闲列表找到最小的满足分配需求的内存块:空间效率高,性能低下

    2. 最差匹配:遍历空闲列表找到最大的内存块;空间浪费严重并且性能低下

    3. 首次匹配:找到第一个满足分配需求的内存块;性能较好,但会让空闲列表开头部分有很多小块——通过管理空闲列表的顺序来优化,比如说基于地址排序,保持空闲块按内存地址有存方便合并操作

    4. 下次匹配:首次匹配+多维护一个指针指向上一次查找结束的位置,避免对列表开头频繁的分割

    5. 分离空闲列表:用一个独立的列表去管理应用程序经常申请的几种大小的内存空间,解决碎片化问题,同时由于没有复杂的列表查找过程,特定大小的内存分配和释放都很快

      用多大的内存来专门为某种大小的请求服务?:厚块分配程序:在内核启动时,为可能频繁请求的内核对象创建对象缓存,在这些对象缓存了分离了特定大小的空闲列表,如果列表耗尽,就像通用内存分配程序申请一些厚块(即总量是页大小和对象大小的公倍数)

    6. 伙伴系统:将空闲空间将递归地一分为二,直到刚好可以满足分配需求的大小7.png

      比如在这里深色的8KB被分配,当这8KB被释放时,会检查伙伴8KB是否空闲,若空闲就合二为一,然后一层一层地递归向上

  • 当通过申请的内存 > 128KB时,OS将通过mmap()的私有匿名映射,在文件映射区分配一块内存

    以这种方式申请的内存,free后会立刻归还给操作系统,每次申请都需要执行系统调用,虽然不会引起brk()的内存泄漏隐患,但增加了系统调用次数;且由于每次mmap()分配出的虚拟地址都是缺页状态,首次访问时都会触发缺页中断(page fault),消耗了CPU资源

需要注意到的是,通过malloc()只会分配虚拟内存,而不会映射到具体的物理内存,只有到用户实际使用时访问到该虚拟内存时触发缺页中断,page fault handler切换到内核态,由内核态处理中断时才实际分配物理内存,并建立虚拟内存到物理内存的映射

也正是因为分配虚拟内存时不会映射到物理内存,所以OS是可以申请超过当前物理内存的值的,但如果要访问超过物理内存的虚拟内存的话,除非开启了回收机制,否则会触发OOM杀死进程

中断:硬件引起OS的注意,OS保存当前状态,处理中断后再恢复状态

对于32位系统来说,最多可申请3GB的虚拟内存空间;

对于64位系统来说,最多可申请128T的虚拟内存空间

回收机制

OS倾向于提供比物理内存更大的虚拟内存地址假象给进程,让进程可以更方便地创造各种数据结构,而不需要考虑它们是否有足够的空间存储

为了实现这个目的,OS在硬盘上开辟了一块交换空间(swap space),用来以页大小为单元读取或写入交换空间,这一块交换空间就相当于是物理内存的扩容,用来临时存放那些被释放的内存

主要有两种可回收的内存页,分别是文件页和匿名页

  • 文件页:内核缓存的磁盘数据 以及 内核缓存的文件数据。对于干净页来说可直接释放内存,而对于被修改过但还没写入磁盘的脏页需要先写入磁盘,再释放内存
  • 匿名页:没有实际载体的内存,比如堆栈的数据,通过回收机制,把不常用的匿名页写入磁盘,释放内存,需要时再从磁盘中读回内存

8.png

最小阈值=min_free_kbytes;页低阈值=5/4 *最小阈值;页高阈值=3/2 *最小阈值

基于剩余内存的大小不同,分为了三种不同的触发场景,分别是

  1. kswapd后台回收:剩余内存在【最小阈值,页低阈值】间触发,由内核线程进行异步的后台回收

  2. 直接内存回收:剩余内存在【最小阈值,0】间触发,阻塞进程的执行,同步地直接内存回收

  3. OOM kill进程:通过oom_badness()函数从两个方面评估进程,然后不断地杀掉占用物理内存较高的进程,直到释放足够的内存

    oom_badness()评分=「系统总的可用页面数」* 「OOM 校准值 oom_score_adj」/ 1000 + 进程已经使用的物理页面数

    计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大;如果不希望服务被杀死,可以将校准值设置为-1000,比如sshd

为了优化回收内存的性能,大概有如下策略可供我们选择

  1. FIFO策略:每次把最早进入内存的页换出,属于简单策略的一种,但效率不高

  2. 随机策略:每次随机选择一个页换出,属于简单策略的一种,效率比FIFO好一点,比最优策略差一点

    在存在局部性原则的大部分工作负载下,LRU性能都比FIFO或随机策略更优,但在一种特殊工作负载中除外:循环顺序工作负载,依次引用n个页,从0开始到n,然后循环重复访问,在这种工作负载下,LRU和FIFO策略的命中率都会是0%,而随机策略的性能较好,贴近了最优策略

  3. LRU策略:由于局部性原则,越近被访问过的页,再次被访问的可能性就越大,故基于历史信息的LRU策略(最少最近使用Least-Recently-Used)每次都将最少最近使用的页换出

    传统的LRU机制是设计一个链表,当访问的page在内存中,就将该page移动到链表的头部;若不在则插入到链表头部,并淘汰尾部,但由于预读机制,这种LRU设计可能会导致预读失效问题

    预读机制:应用程序读取一个page内容,内核会读取相邻的更多page,减少磁盘IO次数

    预读失效:预读的数据没有被访问,却持续占用LRU链表的前排数据

    为了解决这个问题,LRU进行了第一次改进:维护active和inactive两个双向链表分别代表活跃和非活跃的内存页;预读的数据只会插入到inactive的头部,真正被访问才进入active,而active尾部数据被淘汰后将到inactive的头部(Linux)

    然而,在这种设计下的LRU算法依旧会导致缓存污染问题

    缓存污染:批量读取数据时,大量只访问一次的数据占据链表头,热点数据被淘汰

    于是,LRU又进行了第二次改进:提高进入active链表门槛,当page被第二次访问时才从inactive升级到active

具体到linux系统上来说,也有一些参数可供我们修改以优化回收内存的性能

  1. 通过/proc/sys/vm/swappiness设置回收倾向,0~100,数值越低越倾向使用文件页回收,而文件页回收性能>匿名页回收性能

    匿名页必然涉及到了磁盘I/O,而如果是干净的文件页不需要磁盘I/O,故性能更优

  2. 通过调高min_free_kbytes,使得尽早触发kswapd异步的后台内存回收,不会阻塞进程操作

  3. 在NUMA架构下,通过设置/proc/sys/vm/zone_reclaim_mode=0设置在回收本地内存前,先在其他Node寻找空闲内存

Linux相关源码分析

进程在内核源码里表现为一个名叫task_struct的结构体

该结构体随着fork()系统调用创建进程时,调用copy_process()拷贝父进程资源而形成

其中主要成员如下:

struct task_struct{
    pid_t pid; 
    pid_t tgid;
    struct mm_struct *mm;
    ……
}
  • pid:进程标识符

  • mm_struct:每个进程中有且仅有的,一个描述进程虚拟内存地址空间的内存描述符

    子进程通过vfork()clone()首先设置CLONE_VM,在随后fork()调用执行copy_mm()时将进入判断条件,会将父进程该结构体中的相关页表等信息直接拷贝给子进程,并由load_elf_binary()完成其中相关成员的初始化

    其中主要成员如下:

    struct mm_struct{
        unsigned long task_size; //虚拟空间大小
        unsigned long start_code,end_code,start_data,end_data;
        //代码段的始末点,数据段的始末点
        unsigned long start_brk,brk,start_stack;
        //堆起始点和堆顶brk,栈的起点
        ……
            
        unsigned long total_vm;
        //虚拟内存和物理内存已映射的page总数
        unsigned long locked_vm;
        //被锁定不能换出的page数
        unsigned long pinned_vm;
        //不能换出也不能移动的page数
        ……
        //其余名为 xxx_vm的成员都是表示虚拟内存的相关使用情况
            
        mmap;
        //存储vm_area_struct链表形式下的头指针
        mm_rb;
        //存储vm_area_struct红黑树形式下的根节点
    }
    

    9.png

    • vm_area_struct:每个mm_struct里都有多个该结构体,用来管理虚拟内存空间

      其中主要成员如下:

      struct vm_area_struct{
          struct vm_area_struct *vm_next,*vm_prev;
          //双链表形式下的vm_area_struct相关成员
          //vm_next:当前节点的后继节点
          //vm_prev:当前节点的前驱结点
          
          struct rb_node vm_rb;
          //红黑树形式下的vm_area_struct相关成员,用来串联起各个红黑树节点
          struct list_head anon_vma_chain;
          
          struct mm_struct *vm_mm;
          //指向该vm_area_struct所属的mm_struct
          
          unsigned long vm_start;
          unsigned long vm_end;
          //分别指向虚拟内存的起始位和结束位
          
          unsigned long vm_flags;
          //定义整个虚拟内存区域的内存页权限
          pgprot_t vm_page_prot;
          //定义page级别的内存页访问控制权限,比vm_flags粒度更小
          
          struct anon_vma* anon_vma;
          //用来表示通过mmap在文件映射与匿名映射区里分配出来的那块虚拟内存区域
          struct file* vm_file;
          //用来关联通过mmap进行文件映射时,被映射的那个文件(如果是匿名映射该值为NULL)
          
          const struct vm_operations_struct *vm_ops;
          //用来指向定义了针对VMA相关操作的结构体指针
          //在vm_operations_struct中定义open、close、fault等函数
      }
      

      vm_area_struct有两种不同的组织形式,分别适用于不同的场景

      双向链表结构,用于高效的遍历,在内核中通过这个有序的双向链表串联起所有的VMA区域,其中所有节点从低地址到高地址增长

      红黑树结构,用于高效的查找,在内核中通过vm_rb将各个VMA区域连接到红黑树中

      vm_flags的可选值如:VM_READ可读;VM_WRITE可写;VM_EXEC可执行;VM_SHARD可多进程共享等

      为什么有了mm_struct,还需要vm_area_struct?:mm_struct只定义了各个虚拟内存区域块的起始位和结束位,而vm_area_struct里还定义了各个内存区域的相关信息(比如控制权限等),比起mm_struct粒度更小

fde06e4e-e6be-47c9-aa72-8d532ef3dbec