做了10年Linux内核开发,踩过最坑的坑,一半都和VMA有关——当年刚接触内核内存管理,对着struct vm_area_struct看了一周,还是没搞懂malloc到底怎么通过它申请内存,现在回头看,其实就是把简单的逻辑搞复杂了。
你有没有思考过:当一个进程启动后,那几G的虚拟地址空间是怎么被内核“切块”管理的?为啥malloc随便一调,就能从堆里抠出一块内存,却不会跟栈或者代码段打架?
这一切的核心,就是VMA——Virtual Memory Area,虚拟内存区域。简单说,它就是内核给进程地址空间里每一段连续虚拟地址打上的“标签合同”。每一段都有自己的起始地址、结束地址、权限、是不是映射了文件……缺一不可。
本文内核版本为Linux5.6.4。
一、VMA是啥?
咱们写用户态程序时,总爱用malloc、mmap申请内存,可你们有没有想过,这些函数底层到底在搞啥?真不是直接从物理内存里划一块出来那么简单,内核得先给进程的地址空间“分区”,而负责描述这些分区的,就是struct vm_area_struct,咱们简称VMA。
每个进程都有自己的内存描述符mm_struct,里面就管着所有VMA——一方面靠mm->mmap链表把所有VMA串起来,另一方面用mm->mm_rb红黑树存着,链表方便遍历,红黑树方便快速查找合并。
不得不说,内核这设计是真的绝了。
struct mm_struct {struct vm_area_struct *mmap; /* 所有VMA的链表头,串起所有分区 */struct rb_root mm_rb; /* VMA红黑树的根节点,查得快 */int map_count; /* VMA的数量,多了也会影响性能 */ ……}// 补充一句,当年我调试一个内存泄漏bug,就是因为map_count异常飙升,查了半天才发现是VMA没释放干净
你看啊,mmap链表是按VMA->vm_start递增排序的,从头到尾走一遍,就能拿到进程所有的地址分区;而mm_rb红黑树,就是为了解决链表查找慢的问题——要是进程有上百个VMA,链表遍历得耗多久?红黑树O(logn)的效率,瞬间就把速度提上来了。map_count更简单,就是记着当前进程有多少个VMA,超过阈值内核还会报警。
说了这么多,VMA本身到底长啥样?
看下真正的 VMA 本体:
/* * 这个结构体就是VMA的核心,每个VMA对应进程地址空间的一块区域 * 比如代码段、数据段、共享库,都有自己的VMA,各自有不同的访问规则 */struct vm_area_struct {/* 第一行缓存里存的是红黑树遍历需要的信息,不用深究,知道就行 *//* VMA描述的线性地址范围,起始和结束都是虚拟地址,重点! */unsigned long vm_start; /* 分区起始地址,比如0x100000 */unsigned long vm_end; /* 分区结束地址,注意是结束地址的下一个字节 比如vm_start=0x100000,vm_end=0x200000,实际范围是0x100000~0x1fffff *//* 进程的VMA链表,前后相连,按地址大小排序,遍历全靠它 */ struct vm_area_struct *vm_next, *vm_prev; /* 把VMA当作一个节点,挂到mm->mm_rb红黑树里,用于快速查找 */ struct rb_node vm_rb; /* * 这个字段是用来优化内存分配的,记录当前VMA左边最大的空闲内存间隙 * 内核找空闲内存时,靠它能快速定位合适大小的区域,省得瞎找 */ unsigned long rb_subtree_gap; /* 第二行缓存开始,下面的字段也很重要 */ /* 指向当前VMA所属的mm_struct,相当于给VMA找个“主人” */ struct mm_struct *vm_mm;数据段是可读可写,违规访问就会报段错误 */ pgprot_t vm_page_prot; /* 访问权限,底层和页表相关 */ unsigned long vm_flag * 针对文件映射的VMA,会挂到文件的地址空间树里 * 比如mmap映射一个文件,这个结构体就用来关联文件和VMA */ struct { struct rb_node rb;; /* * 关的,用来通过物理页找到对应的VMA * 内存回收的时候特别有用,当年我做内存优化,天天 */ struct list_head anon_vma_chain; /* 受mmap_sem和page_table_lock保护,不用纠结锁机制 */ _vma *anon_vma; /* 同样是RMAP相关,锁保/* VMA的操作方法,比如打开、关闭VMA,内核会调用这些函数 */ t struct vm_operations_struct *vm_ops; 文件的偏移量,单位是页大小(PAGE_SIZE) */ /* 匿名映射(比如m的内存),这里存的是vm_start / PAGE_SIZE */ unsigned long vm_pgoff; /* stle * vm_file; 映射为NULL */ oid * vm_private_data; vm_pte,现在用途更灵活 */#ifdef CONFIG_SWAP long_t swap_readahead_info; /读相关,不用深究 */#endif#ifndef CONFIG_MMU strregion *vm_region; /* 的,大部分场景用不到 */#endif#ifdef CONFIG_NUMA ruct mempolicy *vm_policy; * NUMA架构的内存策略,非NUMA系统忽略 */#endif ct vm_userfaultfd_ctx vm_userfaultfd_ctx; 用户态缺页相关,新手可以先跳过 */} __randomize_layout; * 地址随机化,防溢出攻击,内核安全特性 */ / /* stru / st无MMU架构用uct vm_* 交换分区预atomic_ /* 私有数据,早年是 v /* 指向映射的文件,匿名ruct fi 文件映射偏移,匿名映射有特殊含义 */alloc申请 /* 映射 cons护 */ struct anon 跟这个打交道这个是反向映射(RMAP)相 unsigned long rb_subtree_last; } shareds; /* VMA的标志位,后面专门讲,重点中的重点 */ /* /* 所属的进程内存描述符 */ /* * VMA的访问权限,比如可读、可写、可执行,内核会根据这个做权限检查 * 比如代码段是可读可执行,
vm_start到vm_end定义了一段连续虚拟地址,属性必须一致。代码段通常r-x,堆rw,栈也差不多。文件映射的so库或者数据文件,就通过vm_file挂上去。
注释我已经尽量写详细了。接触内核久了,看这些结构体就像看老朋友的脸,每个字段后面都有无数踩坑的故事。vm_page_prot 和 vm_flags 这种权限控制,当年我在一个写时复制的 bug 上栽过跟头,就是没有仔细区分 VM_WRITE 和真正页表项的可写位,导致 fork 出来的子进程写数据把父进程给搞崩了。
二、VMA的那些Flag
这一堆 flag 到底都是啥意思呢?
VMA的标志位vm_flags直接决定这块内存到底能不能读、能不能写、能不能执行。
内核里定义了一堆宏,比如:
/* * vm_flags的定义,在mm_types.h里,改的时候要同步更更新trace里的定义 * 下面只列常用的,冷门的就不写了,写了也用不上,还占地方 */#define VM_NONE x00000000 任何权限,基本不用 *//* 核心权限标志,可读、可写、可执行、可共享,这四个最常用 */#define VM_READ x00000001 /* 页面可读,少了这个读内存会报段错误 */#define VM_WRITE 0x00000002 页面可写,写只读内存会报段错误 */#define VM_EXEC 0x00000004 行,代码段必须有这个标志 */#define VM_SHARED 0x00000008 可共享,多个进程能共用这个VMA,比如共享库 *//* 权限限制标志,mprotect函数会用到,和上面的权限标志对应 */#define VM_MAYREAD 010 /* 允许设置为可ect时用 */#define VM_MAYWRITE 0000020 /*写 */#define VM_MAYEXEC 0x0 /* 允许设置为可执行 ine VM_MAYSHARE 0x00000080 /* 允许设置为可共享 *//* 通用标志,日常调试会遇到 */#define VM_GROWSDOWN 栈空间就是这个标志,栈是从高地址往低地址扩的 */#define VM_UFFD_MISSING 0x00000200 /* 缺页跟踪相关,用户态缺页用 */#define VM_PFNMAP 0x00000400 /* 直接用物理页号管理,不通过struct page,特殊场景用 */#define VM_DENYWRITE 0x00000800 /* 禁止写操作,比如映射的只读文件,写的时候会报ETXTBSY */#define VM_UFFD_WP 0x00001000 /* 写保护跟踪,和用户态缺页配合 */#define VM_LOCKED 0x00002000 /* 锁定页面,不让页面被换出到交换分区,实时进程会用 */#define VM_IO 0x00004000 /* 内存映射I/O,比如硬件寄存器映射,特殊场景 */ /* sys_madvise函数用到的标志,提示内核内存访问模式 */#define VM_SEQ_READ 0x00 /* fork时不复制这个VMA,父子进程不共享 */#define VM_DONTEXPAND 0040000 /* 不能用mremap扩展大小,固定分区 */#define VM_LOCKONFAULT 0x00080000 缺页时自动锁定页面,不用手动lock */#define VM_ACCOUNT 100000 * 内存记账,内核会统计这个VMA的内存使用 */#define VM_NORESERVE 0x0 不预留内存,内存不足时会报OOM */#define VM_HUGETLB 000 大页VMA,用大页内存,提升性能 */#define VM_SYNC 0x00800000 /* 同步缺页,缺页时阻塞等待,不异步处理 */#define VM_ARCH_1 01000000 /* 架构相关标志,不同架构用途不一样 */#define VM_WIPEONFORK 0x02000000 * fork时清空VMA内容,子进程看不到父进程的内容 */#define VM_DONTDUMP 0x04000000 /* core dump时不包含这个VMA,比如敏感数据 */#ifdef CONFIG_MEM_SOFT_DIRTY# define VM_SOFTDIRTY x08000000 * 软脏标记,内存跟踪用,不用管 */#else# define VM_SOFTDIRTY 0#endif#define VM_MIXEDMAP 000000 ,既有struct page,又有纯物理页号 */#define VM_HUGEPAGE x20000000 MADV_HUGEPAGE标记的VMA,启用大页 */#define VM_NOHUGEPAGE 0x40000000 /* MADV_NOHUGEPAGE标记的VMA,禁用大页 */#define VM_MERGEABLE x80000000 * 可合并,KSM会合并相同内容的页面,节省内存 */ / 0 /* 0/* 混合映射 0x10 / 0 / 0x /*0x00400 /*0020000 / 0x00 /* 0x00008000 /* 顺序读取,比如读文件,内核会预读优化 */#define VM_RAND_READ 0x00010000 /* 随机读取,预读没用,内核会关闭预读 */#define VM_DONTCOPY 0x0002000 0x00000100 /* 向下生长,*/#def0000004 允许设置为可 0x0读,mprotx000000 /* /* 页面可执 /* 0 /* 无 0
其实平时我们写应用,mprotect 或者 mmap 里传的 prot 到不了这么底层,但你要理解,最终 mmap 的 flag 和 prot 都会转成这些位组合。
比如说你 mmap 了一块匿名内存,可读可写,那么 VM_READ | VM_WRITE | VM_MAYREAD | VM_MAYWRITE 这些都会被置上。而 VM_GROWSDOWN 只在主线程栈那个区域能看到,我当初发现这个的时候,恍然大悟为什么 /proc/pid/maps 里栈区域叫做 [stack],而且还可以自动扩展。
三、VMA 的查找
VMA 的查找是内核里的一个高频操作。缺页异常时,内核拿到出错的虚拟地址,得在几十上百个 VMA 里迅速判断这个地址合不合法。要是每个缺页都去遍历链表,那系统啥也别干了。所以 find_vma 及其兄弟函数被设计得非常精悍,内核给我们提供了三个常用的查找函数:
// 三个核心查找函数,记熟这三个,基本能应对大部分场景struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr);static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr);struct vm_area_struct *find_vma_prev(struct mm_struct *mm, unsigned long addr,struct vm_area_struct **pprev);
最常用的就是 find_vma(struct mm_struct *mm, unsigned long addr),它要找的是第一个满足 vma->vm_end > addr 的 VMA。注意啊,它不要求 addr 一定落在找到的 VMA 里面。比如地址在某个空洞里,找到的就是紧跟着这个空洞、且结束地址大于 addr 的那块 VMA。我以前调试一个栈溢出问题,误以为 find_vma 返回 NULL 才表示地址无效,结果死在空指针上,又交了一波学费。
举个例子,就像下面这张图,addrA和addrB都调用find_vma,返回的都是VMA2。addrB在VMA2里,没问题;可addrA不在任何VMA里,为啥也返回VMA2?因为VMA2是第一个vm_end大于addrA的VMA啊,这逻辑,当年我愣了半天才想明白,是不是有点绕?
find_vma_intersection这个函数是找和指定地址区间重叠的第一个VMA。入参是mm、start_addr和end_addr,只要这两个地址组成的区间,和某个VMA的vm_start、vm_end有重叠,就返回这个VMA,没有就返回NULL。
还是看下图,用addrA_start和addrA_end调用这个函数,会返回VMA2,因为两者有重叠的部分[vma_start, addrA_end];但用addrB_start和addrB_end调用,就返回NULL,因为addrB的区间和VMA2完全不搭边。这个函数在处理内存映射重叠检查时特别常用,我当年做共享内存开发,天天用它判断映射是否冲突。
find_vma_prev,这个最简单,就是先调用find_vma找到VMA,然后返回这个VMA的前一个节点(vma->vm_prev)。其实find_vma_intersection都是基于find_vma实现的,咱们看代码就懂了,特别简单。
/* 查找和start_addr~end_addr-1区间重叠的第一个VMA,没有就返回NULL,前提是start_addr < end_addr */static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr){ truct vm_area_struct * vma = find_vma(mm,start_addr); 找到了VMA,但指定区间的结束地址比VMA的起始地址还小,说明没有重叠,返回NULL */ * 就像上图里的addrB,end_addr < VMA2->vm_start,所以返回NULL */ if (vma && end_addr <= vma->vm_start) a = NULL; eturn vma;}// 是不是很简单?核心就是先找start_addr对应的VMA,再判断是否重叠 r vm / /* s
这里划个重点,find_vma的实现逻辑,这里面藏着一个优化点——vma cache,当年我就是没注意这个,调试时走了很多弯路。咱们先看代码,再慢慢说。
/* 查找第一个满足addr < vm_end的VMA,没有就返回NULL */struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr){ struct rb_node e; ruct vm_area_struct *vma; /* 先查vma cache,缓存里有就直接返回,省得遍历红黑树 */ /* 这个缓存是关键,能提升查找效率,当年我没注意,以为每次都走红黑树,调试半天没找到问题 */ vma = vmacache_find(mm, addr); f (likely(vma))/* likely表示大概率能找到,内核常用的优化手段,提示编译器 */ return vma; de = mm->mm_rb.rb_node;/* 缓存没找到,就遍历红黑树,从根节点开始 */ hile (rb_node) { uct vm_area_struct *tmp; tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);/* 从红黑树节点拿到VMA */ if (tmp->vm_end > addr) { = tmp;/* 找到一个vm_end大于addr的,先暂存 */ art <= addr)/* 如果addr在这个VMA里,直接退出,就是它了 */ break; /* 不在的话,继续向左遍历,红黑树左子树是小地址,右子树是大地址 */ e = rb_node->rb_left; else * vm_end <= addr,说明这个VMA在addr左边,向右遍历找更大的VMA */ node = rb_node->rb_right; * 红黑树里找到了,就更新vma cache,下次查找能直接用 */ if (vma) acache_update(addr, vma); turn vma;}// 这里有个小细节,红黑树的遍历逻辑,左小右大,记准这个,就能看懂查找过程 re vm } / rb_ / } rb_nod if(tmp->vm_st vma str w rb_no i st*rb_nod
vma cache 这个优化太经典了。每个线程的 task_struct 里嵌了一个 struct vmacache,可以缓存最近用过的 4 个 VMA。搞程序的都知道局部性原理,大多数缺页访问的地址要么在刚才的 VMA 旁边,要么就是同一个,,避免每次都遍历红黑树,所以这个 cache 命中率奇高。实现方式是用地址的 PMD(如果没有 MMU 就用页)做 hash 索引。
#define VMACACHE_BITS 2#define VMACACHE_SIZE (1U << VMACACHE_BITS) 4个缓存项,1左移2位就是4 */#define VMACACHE_MASK (VMACACHE_SIZE - 1) 掩码,用于计算缓存索引 */struct vmacache { eqnum;/* 序列号,用于同步VMA链表和缓存,避免缓存失效 */ uct vm_area_struct *vmas[VMACACHE_SIZE];/* 缓存数组,存4个VMA指针 */};struct task_struct { /有自己的vmacache,线程共享 */ struct vmacache ache;}// 这里要注意,线程是共享进程的vmacache的,所以多线程访问时要注意同步,但内核已经处理好了,咱们不用管 vmac * 每个进程都 str u64 s /* /*
缓存的更新和查找,靠的是两个函数:vmacache_update和vmacache_find。先看更新函数,找到VMA后,用HASA算法(其实就是哈希)计算索引,把VMA存到缓存数组里。
/* 1. 有MMU的架构,用PMD_SHIFT计算哈希,命中率更高(空间局部性好) 2. 没有MMU的,用PAGE_SHIFT,这个不用深究,知道就行 */#ifdef CONFIG_MMU#define VMACACHE_SHIFT _SHIFT#else#define VMACACHE_SHIFT SHIFT#endif#define VMACACHE_HASH(addr) ((addr >> VMACACHE_SHIFT) & VMACACHE_MASK)/* 计算缓存索引 */voidvmacache_update(unsignedlong addr, struct vm_area_struct *newvma){ 检查缓存对应的mm是否有效,有效就更新缓存 if (vmacache_valid_mm(newvma->vm_mm)) urrent->vmacache.vmas[VMACACHE_HASH(addr)] = newvma;}// current是当前进程的指针,内核里常用,新手记着就行 c // PAGE_ PMD
里面这个计算缓存槽位的算法叫VMACACHE_HASH(估计原作者当时手滑想打HASH来着,写成了HASA。管他呢,源码里就是这么叫的)。它根据地址的PMD或者PAGE级别做移位操作算个索引出来,命中率奇高。
再看查找函数,先计算索引,再检查缓存是否有效(如果VMA有变化,缓存会失效),然后遍历缓存数组,找到匹配的VMA。遍历4个元素,比遍历红黑树快多了,这就是缓存的意义。
struct vm_area_struct *vmacache_find(struct mm_struct *mm, unsigned long addr){ = VMACACHE_HASH(addr);/* 用同样的哈希算法,找到缓存索引 */ nt i; /* 检查缓存是否有效,如果VMA链表变了(比如新增、删除VMA),缓存就失效了 */ 话,就返回NULL,去遍历红黑树 */ if (!vmacache_valid(mm)) urn NULL; 遍历缓存数组,4个元素,很快 */ for (i = 0; i < VMACACHE_SIZE; i++) { struct *vma = current->vmacache.vmas[idx]; if (vma) { ……// 省略一些权限检查,核心是判断addr是否在VMA里 (vma->vm_start <= addr && vma->vm_end > addr) { return vma; /* 找到匹配的,直接返回 */ if (++idx == VMACACHE_SIZE) dx = 0;/* 循环遍历,索引到4就回到0 */ eturn NULL;/* 缓存里没找到,返回NULL,去遍历红黑树 */}// 这里有个小坑,当年我调试时,VMA变了但缓存没更新,导致找到的是旧VMA,后来才知道是seqnum同步的问题 } r i } } if …… vm_area_struct /* ret /* 失效的 iint idx
四、VMA插入
前面我们找到位置了,得把新的VMA塞进去吧?VMA插入,就是把struct vm_area_struct插入到mm->mmap链表和mm->mm_rb红黑树里,内核用insert_vm_struct函数实现。
/* 把VMA插入到进程的链表和红黑树里,按地址排序 1. 如果VMA关联了文件,会在这里加锁(i_mmap_rwsem),保证线程安全 2. 插入失败返回错误码,成功返回0 */intinsert_vm_struct(struct mm_struct *mm, struct vm_area_struct *vma){ uct vm_area_struct *prev;/* 待插入VMA的前一个节点 */ ct rb_node **rb_link, *rb_parent;/* rb_link是插入位置,rb_parent是父节点 */ 找插入位置,靠find_vma_links函数,这个函数是核心 */ find_vma_links(mm, vma->vm_start, vma->vm_end, rev, &rb_link, &rb_parent)) -ENOMEM;/* 找到位置失败,比如VMA重叠,返回内存不足错误 */ 查内存是否足够,有VM_ACCOUNT标志的话,要做内存记账 f ((vma->vm_flags & VM_ACCOUNT) && urity_vm_enough_memory_mm(mm, vma_pages(vma))) return -ENOMEM; * 匿名VMA的vm_pgoff,其实没啥用,直到第一次写缺页才会设置 这里提前设置成vm_start对应的页号,是为了/proc/pid/maps显示一致 不然你看maps文件时,匿名VMA的偏移会乱,不好调试 名映射(没有关联文件),设置vm_pgoff */ if (vma_is_anonymous(vma)) { _ON(vma->anon_vma);/* BUG_ON,条件成立就崩溃,用于调试 */ m_pgoff = vma->vm_start >> PAGE_SHIFT;/* 右移PAGE_SHIFT,就是除以页大小 */ 第二步:把VMA插入链表和红黑树,靠vma_link函数 */ nk(mm, vma, prev, rb_link, rb_parent); return 0;}// 插入的核心就是两步:找位置、插节点,和链表、红黑树的基础操作一样,只是内核做了更多安全检查 vma_li } /* vma->v BUG /* 匿 */ * * /* sec i // 检 return &p if (/* 第一步: stru str
找插入位置的find_vma_links函数,参数有点多,6个。
入参是mm、vma的起始地址addr、结束地址end;出参是prev(前一个节点)、rb_link(插入位置)、rb_parent(父节点)。
逻辑很简单:从红黑树根节点开始遍历,插入的VMA起始地址小于当前节点的vm_end,就向左遍历(左子树是小地址);否则向右遍历(右子树是大地址),直到找到没有子节点的位置,这个位置就是插入点。父节点就是最后遍历到的节点,插入位置就是父节点的左或右子节点。
staticintfind_vma_links(struct mm_struct *mm, unsignedlong addr,unsigned long end, struct vm_area_struct **pprev,struct rb_node ***rb_link, struct rb_node **rb_parent){struct rb_node **__rb_link, *__rb_parent, *rb_prev; __rb_link = &mm->mm_rb.rb_node;/* 从红黑树根节点开始 */ rb_prev = __rb_parent = NULL;/* 遍历红黑树,找合适的插入位置 */while (*__rb_link) {struct vm_area_struct *vma_tmp; __rb_parent = *__rb_link; vma_tmp = rb_entry(__rb_parent, struct vm_area_struct, vm_rb);/* 拿到当前节点的VMA *//* 插入的VMA起始地址小于当前节点的vm_end,向左遍历 */if (vma_tmp->vm_end > addr) {/* 如果当前节点和插入的VMA重叠,返回失败,VMA不能重叠 */if (vma_tmp->vm_start < end)return -ENOMEM; __rb_link = &__rb_parent->rb_left; } else {/* 插入的VMA起始地址大于等于当前节点的vm_end,向右遍历 */ rb_prev = __rb_parent;/* 更新前一个节点,就是当前节点 */ __rb_link = &__rb_parent->rb_right; } }/* 给pprev赋值,就是前一个节点的VMA,如果rb_prev为空,说明插入的是头节点 */ *pprev = NULL;if (rb_prev) *pprev = rb_entry(rb_prev, struct vm_area_struct, vm_rb); *rb_link = __rb_link;/* 插入位置,父节点的左或右子节点指针 */ *rb_parent = __rb_parent;/* 父节点 */return 0;}// 这里要注意,VMA不能重叠,一旦重叠就返回-ENOMEM
可能有人还是没明白prev、rb_link、rb_parent和待插入VMA的关系,看下面这张图就清楚了——待插入VMA的父节点是rb_parent,插入位置是rb_parent的左子节点(rb_link=rb_parent->rb_left),前一个节点pprev就是父节点对应的VMA,是不是一下子就懂了?
找到位置后,就靠vma_link函数把VMA插入链表和红黑树,它会调用__vma_link做具体的插入操作,还会处理文件映射相关的逻辑(如果VMA关联了文件)。
staticvoidvma_link(struct mm_struct *mm, struct vm_area_struct *vma,struct vm_area_struct *prev, struct rb_node **rb_link,struct rb_node *rb_parent){struct address_space *mapping = NULL;// 如果VMA关联了文件,加写锁,保证文件映射的线程安全if (vma->vm_file) { mapping = vma->vm_file->f_mapping;i_mmap_lock_write(mapping); } __vma_link(mm, vma, prev, rb_link, rb_parent);/* 插入链表和红黑树 */ __vma_link_file(vma);/* 把VMA添加到文件的地址空间树里,文件映射用 */// 解锁if (mapping)i_mmap_unlock_write(mapping); mm->map_count++;/* VMA数量加1 */validate_mm(mm);/* 验证mm_struct的有效性,调试用 */}// __vma_link又调用两个函数,分别处理链表和红黑树static void__vma_link(struct mm_struct *mm, struct vm_area_struct *vma,struct vm_area_struct *prev, struct rb_node **rb_link,struct rb_node *rb_parent){ __vma_link_list(mm, vma, prev);/* 插入mm->mmap链表 */ __vma_link_rb(mm, vma, rb_link, rb_parent);/* 插入mm->mm_rb红黑树 */}// 是不是很清晰?分层设计,各司其职,内核代码都这样,看着复杂,拆解开就简单了
链表插入很简单,就是普通的双向链表操作,__vma_link_list函数实现就更直白了,我就不啰嗦了,直接上代码,大家一看就能明白。
void __vma_link_list(struct mm_struct *mm, struct vm_area_struct *vma, struct vm_area_struct *prev){ struct vm_area_struct *next; vma->vm_prev = prev;/* 给VMA的前一个节点赋值 */if (prev) {/* prev不为空,插入到prev后面 */next = prev->vm_next; prev->vm_next = vma; } else {/* prev为空,说明插入的是链表头,更新mm->mmap */next = mm->mmap; mm->mmap = vma; } vma->vm_next = next;/* 给VMA的后一个节点赋值 */if (next) /* next不为空,更新next的前一个节点为当前VMA */ next->vm_prev = vma;}// 双向链表的标准插入操作,没什么复杂的
红黑树插入稍微复杂一点,但内核已经给我们封装好了,__vma_link_rb函数里,调用rb_link_node把VMA的vm_rb节点插入到指定位置,然后更新红黑树的平衡,不用我们自己处理红黑树的旋转,是不是突然觉得内核大佬们很贴心?哈哈哈
void __vma_link_rb(struct mm_struct *mm, struct vm_area_struct *vma,struct rb_node **rb_link, struct rb_node *rb_parent){/* 更新VMA后面的空闲间隙,优化内存分配 */if (vma->vm_next) vma_gap_update(vma->vm_next);else mm->highest_vm_end = vm_end_gap(vma);/* * 这段注释不用深究,大概意思是:插入前不知道VMA的前一个节点,所以先把rb_subtree_gap设为0 * 插入后再更新为正确的值,最后再平衡红黑树 */ rb_link_node(&vma->vm_rb, rb_parent, rb_link);/* 插入红黑树节点 */ vma->rb_subtree_gap = 0; vma_gap_update(vma);/* 更新b_insert(vma, &mm->mm_rb);/* 平衡红黑树 */}// 红黑树的平衡操作,内核已经实现好了,我们不用关心细节,知道怎么调用就行空闲间隙 */ vma_r
五、VMA的合并
进程运行过程中,会频繁申请、释放内存,导致地址空间里有很多相邻的VMA,如果不合并,VMA数量会越来越多,查找、管理都会变慢。内核会自动合并前后衔接、权限和属性相同的VMA,靠的就是vma_merge函数。
合并的情况简单并复杂着?得看这俩块地的权限一样不、映射的文件一样不、偏移量连不连得上。
源码里硬生生列出了8种合并的场景。你没看错,8种!
/* The following mprotect cases have to be considered, where AAAA is * the area passed down from mprotect_fixup, never extending beyond one * vma, PPPPPP is the prev vma specified, and NNNNNN the next vma after: * * AAAA AAAA AAAA * PPPPPPNNNNNN PPPPPPNNNNNN PPPPPPNNNNNN * cannot merge might become might become * PPNNNNNNNNNN PPPPPPPPPPNN * mmap, brk or case 4 below case 5 below * mremap move: * AAAA AAAA * PPPP NNNN PPPPNNNNXXXX * might become might become * PPPPPPPPPPPP 1 or PPPPPPPPPPPP 6 or * PPPPPPPPNNNN 2 or PPPPPPPPXXXX 7 or * PPPPNNNNNNNN 3 PPPPXXXXXXXX 8 */
跟前面的兄弟合体。
跟后面的兄弟合体。
前后通吃,三合一。
把前兄弟的一部分割给后兄弟。
把后兄弟的一部分割给前兄弟。
把后兄弟整个吞了,跟前兄弟和下下兄弟连在一块。
把后兄弟吞了,跟前兄弟合体。
把后兄弟吞了,跟下下兄弟合体。
合并后的效果,看下面这张图,每种情况对应的合并结果都标得很清楚,不用死记硬背,知道有这些情况就行,实际调试时,遇到合并相关的问题,回头看这两张图就够了。

struct vm_area_struct *vma_merge(struct mm_struct *mm, truct vm_area_struct *prev, unsigned long addr, signed long end, unsigned long vm_flags, truct anon_vma *anon_vma, struct file *file, pgoff, struct mempolicy *policy, truct vm_userfaultfd_ctx vm_userfaultfd_ctx){ goff_t pglen = (end - addr) >> PAGE_SHIFT;/* 计算VMA的页面数量,右移PAGE_SHIFT就是除以页大小 */ vm_area_struct *area, *next; int err; * 标志的VMA不合并,比如VM_SPECIAL,这些VMA有特殊用途,合并会出问题 */ if (vm_flags & VM_SPECIAL) return NULL; * 找到后一个VMA,prev为空,next就是链表头 */ rev) next = prev->vm_next; lse ext = mm->mmap; a = next; (area && area->vm_end == end) /* 对应case6、7、8,后一个VMA的结束地址和新VMA一样 */ xt = next->vm_next; 面这几个警告,是调试用的,参数不对就会报警,内核常用的调试手段 */ _WARN_ON(prev && addr <= prev->vm_start); VM_WARN_ON(area && end > area->vm_end); M_WARN_ON(addr >= end); * 第一步:判断能不能和前一个VMA合并 前一个VMA的结束地址 == 新VMA的起始地址,且权限、属性都相同,就能合并 */ * mpol_equal在非NUMA系统中始终返回TRUE,不用纠结 */ /* can_vma_merge_after判断前一个VMA能不能和后面的合并 */ if (prev && prev->vm_end == addr && ol_equal(vma_policy(prev), policy) && an_vma_merge_after(prev, vm_flags, anon_vma, file, pgoff, vm_userfaultfd_ctx)) { /* * 能和前一个合并,再看看能不能和后一个也合并 */ * 后一个VMA的起始地址 == 新VMA的结束地址,且属性相同 */ if (next && end == next->vm_start && _equal(policy, vma_policy(next)) && an_vma_merge_before(next, vm_flags, anon_vma, file, pgoff+pglen, m_userfaultfd_ctx) && _mergeable_anon_vma(prev->anon_vma, next->anon_vma, NULL)) { /* 对应case1、6,和前后都合并 */ = __vma_adjust(prev, prev->vm_start, ->vm_end, prev->vm_pgoff, NULL, } e /* 对5、7,只和前一个合并 */ = __vma_adjust(prev, prev->vm_start, ev->vm_pgoff, NULL, prev); if (err) eturn NULL; hugepaged_enter_vma_merge(prev, vm_flags);/* 大页相关,不用管 */ return pMA,就是前一个VMA(已经扩展了) */ /* 二步:判断能不能和后一个VMA合并 */ if (next && end == next->vm_start && mpol_equal(policy, vma_policy(next)) && can_vma_merge_before(next, vm_flags, anon_vma, file, pgoff+pglen, vm_userfaultfd_ctx)) { /* 前一个VMA的结束地址 > 新VMA的起始地址,重叠部分覆盖,和后一个合并(case4) */ if (prev && addr < prev->vm_end) /* case 4 */ err = __vma_adjust(prev, prev->vm_start, r, prev->vm_pgoff, NULL, next); /* 对应case3、8,只和后一个合并 */ err = __vma_adjust(area, addr, next->vm_end, next->vm_pgoff - pglen, NULL, next); a = next;/* 合并后的VMA是后一个VMA(已经扩展了) */ if (err) urn NULL; khugepaged_enter_vma_merge(area, vm_flags); turn area; urn NULL;/* 不能合并,返回NULL */}// 这段代码看着长,其实逻辑很简单:先试能不能和前一个合并,再试能不能和后一个合并,能合并就调用__vma_adjust调整,不能就返回NULL// 当年我调试一个VMA合并失败的bug,就是因为vm_flags不一样,导致can_vma_merge_after返回false,查了好久才发现} ret re ret } are else { add * 第 } rev;/* 返回合并后的V k r end, pr err 应case2、lse prev); next err is v c mpol / c mp /*/ /* /* V VM /* 下 ne if are n e if (p / * 特殊 / struct p s pgoff_t s un s
其实你看啊,写内核代码的大佬也是普通人,无非就是被极其苛刻的性能和碎片问题逼出来的强迫症。为了快那么一点点,加缓存、用红黑树;为了省那么一点点内存,搞出8种合并姿势。这帮老炮儿!!
写到这儿,我忽然有点感慨。当年刚入行翻《Understanding the Linux Kernel》,VMA那一章真看得我头大。现在回想,其实核心很简单:VMA是内核跟进程签的“内存使用合同”,每一段虚拟地址的规则都写得清清楚楚。malloc背后glibc小块走brk/堆,大块偷偷mmap,内核插VMA、可能merge,一切就这么运转。
我也不知道这么说对不对,反正这些年被VMA坑过也爽过。VMA数量爆炸、merge失败、flags设错……每个坑都让我多长点记性。
深入浅出 Linux 内核(进程篇):进程调度
深入浅出 Linux 内核(进程篇):CFS调度
深入浅出 Linux 内核(进程篇):进程创建与退出
深入浅出 Linux 内核(进程篇):进程切换之ARM体系架构