内核使用 STRUCT mm_struct 维护用户进程的地址空间,并将用户进程所有 VMA 都维护在双链表 mmap 上,这样做的好处是既有个途径将进程所有的 VMA 管理起来,另外链表上的 VMA 都是按区域起始虚拟地址从低到高进行排列,那么可以通过双链表的属性知道 VMA 前一个 VMA(PREV) 和下一个 VMA(NEXT), 这样可以快速知道 PREV、VMA、以及 NEXT 之间的 GAP 大小,那么内核需要为进行分配一块可用的虚拟区域,只需遍历链表并找到两个 VMA 之间 GAP 满足分配大小的区域即可。STRUCT vm_area_struct 数据结构的 vm_next 和 vm_prev 成员指向 VMA 的后一个和前一个 VMA.
VM_NEXT/VM_PREV 创建
当调用 mmap 系统调用分配一段虚拟内存时,内核会按上图函数调用逻辑分配虚拟内存,内核在 get_unmapped_area 函数里已经找到一块可用的区域,那么说明这块区域的 PREV VMA 和 NEXT VMA 之间的 GAP 满足分配需求,那么新的 VMA 将位于这个 GAP 上. 内核接着在 FIND PREV NODE 处调用 find_vma_links 函数,根据 addr 找到对应的 PREV VMA,然后在 LINK NODE 处调用 vma_link 函数将新分配的 VMA 插入到双链表里:
内核调用 find_vma_link 函数查找新分配可用虚拟地址 addr 的 PREV VMA,这里由于速度考虑,并不是从进程地址空间维护的双链表 mmap 处开始查找,而是从进程地址空间维护的区间树基于 addr 进行查找,这样会节省查找的时间,当遍历到区间树的叶子节点,并且在遍历过程中,如果有右子树存在,那么该右子树的 Parent 节点就是 PREV VMA. 如果一直没有遍历右子树,那么 PREV VMA 将为空. 由于区间树是特殊的红黑树,那么必定存在 Parent 大于 LEFT 而小于 RIGHT,因此 PREV VMA 只能存在于遍历到 RIGHT 的场景.
基于对 find_vma_link 函数逻辑的分析,接下来结合上图案例进行实际分析具体过程:
- A: 内核获得一个可用的虚拟地址 addr,此时从区间树的 ROOT 开始查询,此时 addr 小于 A-VMA 的结束地址,因此其进入 LEFT
- B: 由于 B-VMA 的结束地址小于 addr,那么此时进入 RIGHT,并将 PREV 指向 B-VMA
- D: 由于 D-VMA 的结束地址大于 addr,那么进入 LEFT,并不更新 PREV
- E: 由于 E 节点已经是叶子节点,并且 E 节点的 rb_subtree_gap 符合分配需求,那么查找结束,此时 PREV 指向 B-VMA,此时 B-VMA 的结束地址确实是最接近 addr.
内核分配新的 VMA 之后,接下来会调用 __vma_link_list 函数将新的 VMA 插入到进程地址空间维护的双链表里,由于之前已经找到了 PREV VMA,那么这里只需将其插入到 PREV VMA 之后,其逻辑为 282-283 行代码; 如果 PREV VMA 是空的,那么说明新分配的 VMA 是最靠前的一个,逻辑执行 285-286 行代码,其直接将 VMA 插入到 mm->mmap 双链表表头. 插入完毕之后,其就受进程地址空间管理.
VM_NEXT/VM_PREV 使用场景
VM_NEXT/VM_PREV 成员或者 VMA 双链表多使用在需要按顺序遍历整个进程地址空间的场景,例如 ‘/proc/PID/maps’ 这样的接口,其将进程地址空间虚拟区域全部按顺序打印出来. 那么接下来也通过一个实践案例介绍 VM_NEXT 和 VM_PREV 的使用逻辑,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Memory Mapping Mechanism --->
[*] Memory Mapping: VMA VM_NEXT/VM_PREV --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-VMA-VM-NEXT-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-MEMORY-MMAP-VMA-VM-NEXT-default Source Code on Gitee
实践案例由两部分组成,其中一部分是一个应用程序,其逻辑是在 26 行通过 open 函数打开 “/dev/BiscuitOS-VMA” 文件,然后在 33 行基于 ioctl 函数向文件发送 TRAVER_VMA 请求,处理完毕之后关闭文件.
实践案例的另外一部分是内核模块,其由 MISC 驱动框架构成,并向用户空间提供 “/dev/BiscuitOS-VMA” 文件,文件实现了 ioctl 回调,即用户进程打开该文件,并通过 ioctl 向该文件发送请求时,BiscuitOS_ioctl 函数会被调用。BiscuitOS_ioctl 函数只处理 TRAVER_VMA 请求,当用户进程发起 TRAVER_VMA 请求时,进入 24 行分支进行处理. 函数首先在 26 行获得当前进程的虚拟地址空间,然后在 28 行先检查用户进程的地址空间是否为空,如果空说明用户进程里没有任何可用的虚拟内存,接着函数在 34 行使用 FOR 循环遍历进程地址空间的 mmap 链表,此时可以看到 FOR 循环通过 vm_next 获得下一个 VMA,并且这些 VMA 的按 vm_start 从低到高排序. 以上便是实践案例的内容,接下来在 BiscuitOS 上实践该案例:
BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本即可,脚本里包含了实践案例运行的所有命令,可以看到实践案例成功运行,并将应用程序对应的虚拟区域全部打印出来,并且这些虚拟区域都是按顺序排列的. 以上便是 VM_NEXT 和 VM_START 的一种使用场景.