文章目录
- 前言
- 一、简介
- 1. mmap 是什么?
- 2. Linux 进程虚拟内存空间
- 二、mmap 内存映射
- 1. mmap 内存映射的实现过程
- 2. mmap 内存映射流程
- 2.1 mmap 系统调用函数
- 2.2 ksys_mmap_pgoff 函数
- 2.3 vm_mmap_pgoff 函数
- 2.4 do_mmap_pgoff 函数
- 2.5 do_mmap 函数
- 2.6 get_unmapped_area 函数
- 2.7 arch_get_unmapped_area 函数
- 2.8 find_vma_prev 函数
- 2.9 find_vma 函数
- 2.10 vm_unmapped_area 函数
- 2.11 unmapped_area 函数
- 2.12 mmap_region 函数
- 2.13 may_expand_vm 函数
- 2.14 find_vma_links 函数
- 2.15 vma_merge 函数
- 2.16 vma_link 函数
- 总结
前言
说到内存映射,很多人或多或少都了解过,笔者也看过很多文章,但总感觉晦涩难懂,甚至是越看越迷糊,不知道有没有同学跟我有同感的。本着做笔记的原则,也为了方便以后的复习,把近几天的学习成果及心得做一个记录。由于 Linux 内核有关的知识点太多,虚拟内存、物理内存等,不可能一篇文章就能理清,本文仅对 mmap 内存映射的学习做个探索总结,如有不足还望一起讨论学习。
一、简介
1. mmap 是什么?
mmap 的全称是 memory map,中文意思是内存映射或地址映射,是 Linux 操作系统中的一种系统调用,其作用是将一个文件或者其它对象映射到进程的虚拟地址空间,实现磁盘地址和进程虚拟地址空间一段虚拟地址的一一对应关系。通过 mmap 系统调用我们可以让进程之间通过映射到同一个普通文件实现共享内存,普通文件被映射到进程虚拟地址空间当中后,进程可以像访问普通内存一样对文件进行一系列操作,而不需要通过 I/O 系统调用来读取或写入。
mmap ( ) 函数 声明如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
函数各个参数的含义如下:
- addr:待映射的虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),通常设置成 NULL,意思就是完全交由内核来帮我们决定虚拟映射区的起始地址(要按照 PAGE_SIZE(4K) 对齐)。
- length:待申请映射的内存区域的大小,如果是匿名映射,则是要映射的匿名物理内存有多大,如果是文件映射,则是要映射的文件区域有多大(要按照 PAGE_SIZE(4K) 对齐)。
- prot:映射区域的保护模式。有 PROT_READ、PROT_WRITE、PROT_EXEC等。
- flags:标志位,可以控制映射区域的特性。常见的有 MAP_SHARED 和 MAP_PRIVATE 等。
- fd:文件描述符,用于指定映射的文件 (由 open( ) 函数返回)。
- offset:映射的起始位置,表示被映射对象 (即文件) 从那里开始对映,通常设置为 0,该值应该为大小为PAGE_SIZE(4K)的整数倍。
mmap ( ) 函数会将一个文件或其他对象映射到进程的地址空间中,并返回一个指向映射区域的指针,进程可以使用指针来访问映射区域的数据,就像访问内存一样。系统会自动回写脏页面到对应的磁盘文件上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段映射区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
port 取值的说明:
PORT_EXEC:映射的区域具有可执行权限
PROT_READ:映射的区域具有可读权限
PROT_WRITE:映射区域具有可写权限
PROT_NONE:映射区域不可被访问;
flags 取值的说明:
MAP_SHARED:共享映射(用于多进程之间的通信),对映射区域的写入操作直接反映到文件当中
MAP_FIXED:若在 start 上无法创建映射则失败(如果没有此标记会自动创建)
MAP_PRIVATE:私有映射,对映射区域的写入操作只反映到缓冲区当中不会写入到真正的文件
MAP_ANONYMOUS:匿名映射将虚拟地址映射到物理内存而不是文件(忽略fd、offset)
MAP_DENYWRITE:拒绝其它文件的写入操作
MAP_LOCKED:锁定映射区域保证其不被置换
MAP_POPULATE:内核在分配完虚拟内存之后,会立即分配物理内存,并在进程页表中建立起虚拟内存与物理内存的映射关系
MAP_HUGETLB:用于大页内存映射;
mmap 内存映射建立后的示意图如下:
2. Linux 进程虚拟内存空间
由上小节的示意图可以看出,进程的虚拟地址空间是由多个虚拟内存区域构成的。虚拟内存区域是进程的虚拟地址空间中的一个同质区间,即具有同样特性的连续地址范围。Linux 内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间,分别为:
- 代码段:存放进程程序二进制文件中的机器指令的存储区域
- 数据段:也叫初始化数据段,代码中被指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域
- bss段:代码中没有被指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域
- 堆:程序运行过程中动态申请的内存在虚拟内存空间中的存储区域
- 文件映射与匿名映射:存放动态链接库中的代码段,数据段,bss段,以及通过 mmap 系统调用映射的共享内存区的存储区域
- 栈:存放函数调用过程中的局部变量和函数参数的存储区域
Linux 内核中使用结构体 vm_area_struct (以下简称 vma)来描述这些虚拟内存区域,每个 vma 结构对应于虚拟内存空间中的唯一虚拟内存区域。vm_area_struct 结构体如下:
struct vm_area_struct {/* The first cache line has the info for VMA tree walking. */unsigned long vm_start; /* Our start address within vm_mm. */unsigned long vm_end; /* The first byte after our end address within vm_mm. *//* linked list of VM areas per task, sorted by address */struct vm_area_struct *vm_next, *vm_prev;struct rb_node vm_rb; // 红黑树struct mm_struct * vm_mm; /* The address space we belong to. */pgprot_t vm_page_prot; /* Access permissions of this VMA. */unsigned long vm_flags; /* Flags, see mm.h. */struct list_head anon_vma_chain; /* Serialized by mmap_sem & page_table_lock */struct anon_vma *anon_vma; /* Serialized by page_table_lock *//* Function pointers to deal with this struct. */const struct vm_operations_struct * vm_ops;unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units,*not* PAGE_CACHE_SIZE */ struct file * vm_file; /* File we map to (can be NULL). */void * vm_private_data; /* was vm_pte (shared mem) */
}
vm_area_struct 结构体成员分析:
-
vm_start:指向虚拟内存区域的起始地址(最低地址),其本身包含在这块虚拟内存区域内。
-
vm_end:指向虚拟内存区域的结束地址(最高地址),而其本身包含在这块虚拟内存区域之外,所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。
-
vm_next:后继结点,指向下一个 vm_area_struct 的指针。
-
vm_prev:前驱节点,指向前一个 vm_area_struct 的指针,与 vm_next 共同构建进程的虚拟内存区域的链表(按地址排序)。
-
vm_rb:红黑树的一个叶节点,用来将多个 vma 连接成红黑树以便快速查询。
-
vm_mm:反向指针,指向内存描述符 mm_struct 结构体,即虚拟内存区域所属的进程的用户虚拟地址空间。
vm_page_prot 和 vm_flags 都是用来标记 vm_area_struct 结构表示的这块虚拟内存区域的访问权限和行为规范。
- vm_page_prot:偏向于定义底层内存管理架构中页这一级别的访问控制权限,可以直接应用在底层页表中,它是一个具体的概念。
- vm_flags:偏向于定义整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面,它是一个抽象的概念。
常用 vm_flags 访问权限的取值说明:
VM_READ:可读
VM_WRITE:可写
VM_EXEC:可执行
VM_SHARD:可多进程之间共享
VM_IO:可映射至设备 IO 空间
VM_RESERVED:内存区域不可被换出
VM_SEQ_READ:内存区域可能被顺序访问
VM_RAND_READ:内存区域可能被随机访问
- anon_vma:如果该内存区域不与任何文件相关联,也就是匿名映射,则用 struct anon_vma 结构体来指向关联的匿名内存对象,用来组织匿名页被映射到的所有的虚拟地址空间。
- vm_ops:指向针对虚拟内存区域的相关操作的函数指针,如:open、close、fault 函数等。
- vm_file:进行文件映射时,关联被映射的文件,如果是匿名映射则为 null。
- vm_pgoff:表示映射进虚拟内存中的文件内容,在文件中的偏移,如果是匿名映射则无效。
- vm_private_data:存储虚拟内存中的私有数据,具体的存储内容和内存映射的类型有关。
虚拟内存空间中的 vma 是通过一个双向链表(早期的内核实现是单向链表)串联组织起来的,已有的 vma 按照低地址到高地址以递增次序被归入链表中,每个 vma 是这个链表里的一个节点。同时,为了快速在进程虚拟地址空间中查找 vma,vma 又通过红黑树(red black tree)组织起来,每个 vma 又是这个红黑树里的一个叶节点(使用红黑树查找的时间复杂度是O( l o g 2 N log_2N log2N)),尤其是在 vma 数量很多的时候,可以显著减少查找所需的时间(数量翻倍,查找次数也仅多一次)。
总的来说,vma 是 Linux 内核中非常重要的一个数据结构,承担着描述进程虚拟内存区域的重要任务。当用户进程调用 mmap 系统调用来映射文件时,系统会在当前进程的虚拟地址空间中,遍历 vma 链表为其寻找一段连续的空闲地址。当找到合适的一段区间之后,会为其建立一个 vma 结构,完成这些后,该进程就有了一个专门用于 mmap 映射的虚拟内存区。此时进程页表中,当前区域的线性地址还没有对应的物理页,接着系统会调用内核空间的系统调用函数 mmap,也就是需要我们在 file operations(f_op) 结构体中定义的这个 mmap,它将要完成对 vma 结构中的虚拟地址建立其相应的页表项,完成其与文件的物理磁盘地址的映射关系。
二、mmap 内存映射
1. mmap 内存映射的实现过程
mmap 内存映射的实现过程,总的来说可以分为三个阶段:
- 用户进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
- 进程在用户空间调用库函数 mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
- 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址区域;
- 为此虚拟内存区域分配一个 vm_area_struct 结构,接着对这个结构的各个域进行初始化;
- 将新建的虚拟区结构 vm_area_struct 插入进程的虚拟地址区域链表或红黑树中;
- 调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
- 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件的相关各项信息;
- 通过该文件的文件结构体,链接到 file_operations 模块,调用内核函数 mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数;
- 内核 mmap 函数通过虚拟文件系统 inode 模块定位到文件磁盘物理地址。
- 通过 remap_pfn_range 函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中;
- 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
- 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常;
- 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程;
- 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用 nopage 函数把所缺的页从磁盘装入到主存中;
- 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程;
2. mmap 内存映射流程
首先看一下 mmap 内存映射的流程图,结合流程图再看其函数实现,会更加清晰明了:
2.1 mmap 系统调用函数
以 arm64 架构为例,系统调用函数 mmap 位于 /arch/arm64/kernel/sys.c 文件中,Linux 的系统调用对应的函数全部都是由 SYSCALL_DEFINE 相关的宏来定义的,有兴趣的同学可自行学习了解,其源码如下:
/arch/arm64/kernel/sys.c
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,unsigned long, prot, unsigned long, flags,unsigned long, fd, unsigned long, off)
{if (offset_in_page(off) != 0)return -EINVAL;return ksys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
}
方法中继续调用了 ksys_mmap_pgoff() 方法,该方法位于 /mm/mmap.c 文件中
2.2 ksys_mmap_pgoff 函数
/mm/mmap.c
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,unsigned long prot, unsigned long flags,unsigned long fd, unsigned long pgoff)
{struct file *file = NULL;unsigned long retval;if (!(flags & MAP_ANONYMOUS)) { // 预处理文件映射// 通过文件 fd 获取映射文件的 struct file 结构audit_mmap_fd(fd, flags);// 通过文件 fd 获取 file,从而获取 inode 信息,关联磁盘文件,后面关闭 fd,仍然可以用 mmap 操作file = fget(fd);......} else if (flags & MAP_HUGETLB) {// 从这里我们可以看出 MAP_HUGETLB 只能支持 MAP_ANONYMOUS 匿名映射的方式使用 HugePagestruct user_struct *user = NULL;struct hstate *hs; // 内核中的大页池(预先创建)// 选取指定大页尺寸的大页池(内核中存在不同尺寸的大页池)hs = hstate_sizelog((flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);if (!hs)return -EINVAL;// 映射长度 len 必须与大页尺寸对齐len = ALIGN(len, huge_page_size(hs));// 在 hugetlbfs 中创建 anon_hugepage 文件,并预留大页内存(禁止其他进程申请)file = hugetlb_file_setup(HUGETLB_ANON_FILE, len,VM_NORESERVE,&user, HUGETLB_ANONHUGE_INODE,(flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);if (IS_ERR(file))return PTR_ERR(file);}flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE);// 开始内存映射retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:if (file)fput(file);return retval;
}
ksys_mmap_pgoff 函数主要是针对 mmap 大页映射的情况进行预处理,通过文件 fd 获取对应的 struct file 结构,然后再将其转发给位于 /mm/util.c 的 vm_mmap_pgoff 函数进行内存映射。
2.3 vm_mmap_pgoff 函数
/mm/util.c
unsigned long vm_mmap_pgoff(struct file *file, unsigned long addr,unsigned long len, unsigned long prot,unsigned long flag, unsigned long pgoff)
{unsigned long ret;struct mm_struct *mm = current->mm; // 获取进程虚拟内存空间// 是否需要为映射的 vma,提前分配物理内存页,避免后续的缺页// 取决于 flag 是否设置了 MAP_POPULATE 或者 MAP_LOCKED,这里的 populate 表示需要分配物理内存的大小unsigned long populate;LIST_HEAD(uf); // 初始化 userfaultfd 链表// security_开头的,都是security linux相关的,应该没有人的服务器会开这个,返回值为 0ret = security_mmap_file(file, prot, flag);if (!ret) {// 对进程虚拟内存空间加写锁保护,防止多线程并发修改if (down_write_killable(&mm->mmap_sem))return -EINTR;// 开始 mmap 内存映射,在进程虚拟内存空间中分配一段 vma,并建立相关映射关系// 返回值 ret 为映射虚拟内存区域的起始地址ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff,&populate, &uf);up_write(&mm->mmap_sem); // 释放写锁userfaultfd_unmap_complete(mm, &uf); // 等待 userfaultfd 处理完成if (populate)// 提前分配物理内存页面,后续访问不会缺页,为 [ret , ret + populate] 这段虚拟内存立即分配物理内存mm_populate(ret, populate);}return ret;
}
vm_mmap_pgoff 函数的核心流程如下:
- 获取进程虚拟内存空间 mm_struct,用于在开始 mmap 内存映射之前,对进程虚拟内存空间加写锁保护,防止多线程并发修改,映射完成后,再释放写锁。
- 调用 do_mmap_pgoff 函数开始 mmap 内存映射,在进程虚拟内存空间中分配一段 vma,并建立相关映射关系。
- 如果设置了 MAP_POPULATE 或者 MAP_LOCKED 属性,则调用 mm_populate 函数,提前为 [ret , ret + populate] 这段虚拟内存立即分配物理内存页面,后续访问不会发生缺页中断异常。
2.4 do_mmap_pgoff 函数
/include/linux/mm.h
static inline unsigned long
do_mmap_pgoff(struct file *file, unsigned long addr,unsigned long len, unsigned long prot, unsigned long flags,unsigned long pgoff, unsigned long *populate,struct list_head *uf)
{return do_mmap(file, addr, len, prot, flags, 0, pgoff, populate, uf);
}
do_mmap_pgoff 函数是一个内联函数,其具体实现位于 /mm/mmap.c 的 do_mmap 函数中
2.5 do_mmap 函数
/mm/mmap.c
unsigned long do_mmap(struct file *file, unsigned long addr,unsigned long len, unsigned long prot,unsigned long flags, vm_flags_t vm_flags,unsigned long pgoff, unsigned long *populate,struct list_head *uf)
{struct mm_struct *mm = current->mm;int pkey = 0;*populate = 0;if (!len)return -EINVAL;// 如果进程带有 READ_IMPLIES_EXEC 标记且文件系统是可执行的,则这段内存空间使用 READ 的属性会附带增加 EXEC 属性if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))if (!(file && path_noexec(&file->f_path)))prot |= PROT_EXEC;/* force arch specific MAP_FIXED handling in get_unmapped_area */if (flags & MAP_FIXED_NOREPLACE)flags |= MAP_FIXED;if (!(flags & MAP_FIXED)) // 如果不是使用固定地址,则使用的 addr 会进行向下页对齐addr = round_hint_to_min(addr);// 申请内存大小页对齐,注意不要溢出len = PAGE_ALIGN(len);if (!len)return -ENOMEM;/* offset overflow? */if ((pgoff + (len >> PAGE_SHIFT)) < pgoff) // 判断申请的内存是否溢出return -EOVERFLOW;// 一个进程虚拟内存空间内所能包含的虚拟内存区域 vma 是有数量限制的// sysctl_max_map_count 规定了进程虚拟内存空间所能包含 vma 的最大个数// 可以通过 /proc/sys/vm/max_map_count 内核参数调整 sysctl_max_map_count// mmap 需要在进程虚拟内存空间中创建映射的 vma,这里需要检查已有 vma 的个数是否超过最大限制if (mm->map_count > sysctl_max_map_count)return -ENOMEM;// 在进程虚拟内存空间中寻找一块未映射的虚拟内存区域,这段虚拟内存区域后续将会用于 mmap 内存映射addr = get_unmapped_area(file, addr, len, pgoff, flags);if (offset_in_page(addr)) // 如果返回的地址不是按照page对齐的,则直接返回return addr;if (flags & MAP_FIXED_NOREPLACE) {struct vm_area_struct *vma = find_vma(mm, addr);if (vma && vma->vm_start < addr + len)return -EEXIST;}if (prot == PROT_EXEC) {pkey = execute_only_pkey(mm);if (pkey < 0)pkey = 0;}// 简单的检查,通过 calc_vm_prot_bits 和 calc_vm_flag_bits 将 mmap 参数 prot , flag 中 // 设置的访问权限以及映射方式等枚举值转换为统一的 vm_flags,后续一起映射进 VMA 的相应属性中,相应前缀转换为 VM_ vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;// 设置了 MAP_LOCKED,表示用户期望 mmap 背后映射的物理内存锁定在内存中,不允许 swapif (flags & MAP_LOCKED)// 检查是否可以将本次映射的物理内存锁定if (!can_do_mlock())return -EPERM;// 进一步检查锁定的内存页数是否超过了内核限制if (mlock_future_check(mm, vm_flags, len))return -EAGAIN;if (file) { // 文件映射struct inode *inode = file_inode(file);unsigned long flags_mask;if (!file_mmap_ok(file, inode, pgoff, len))return -EOVERFLOW;flags_mask = LEGACY_MAP_MASK | file->f_op->mmap_supported_flags;switch (flags & MAP_TYPE) {case MAP_SHARED: // 共享映射// 强制使用带有 non-legacy 标志的 MAP_SHARED_VALIDATE。使用 MAP_SHARED 忽略不受支持的标志,以保持向后兼容性flags &= LEGACY_MAP_MASK;/* fall through */case MAP_SHARED_VALIDATE:if (flags & ~flags_mask)return -EOPNOTSUPP;if (prot & PROT_WRITE) {if (!(file->f_mode & FMODE_WRITE))return -EACCES;if (IS_SWAPFILE(file->f_mapping->host))return -ETXTBSY;}// 确保不向只追加的文件进行写入if (IS_APPEND(inode) && (file->f_mode & FMODE_WRITE))return -EACCES;// 确保文件上没有强制锁。if (locks_verify_locked(file))return -EAGAIN;vm_flags |= VM_SHARED | VM_MAYSHARE;if (!(file->f_mode & FMODE_WRITE))vm_flags &= ~(VM_MAYWRITE | VM_SHARED);/* fall through */case MAP_PRIVATE: // 私有文件映射if (!(file->f_mode & FMODE_READ)) // 文件如果不可读会报错return -EACCES;if (path_noexec(&file->f_path)) {if (vm_flags & VM_EXEC)return -EPERM;vm_flags &= ~VM_MAYEXEC;}if (!file->f_op->mmap)return -ENODEV;if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))return -EINVAL;break;default:return -EINVAL;}} else { // 匿名映射switch (flags & MAP_TYPE) {case MAP_SHARED:if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))return -EINVAL;pgoff = 0; // 忽略 pgoffvm_flags |= VM_SHARED | VM_MAYSHARE;break;case MAP_PRIVATE:pgoff = addr >> PAGE_SHIFT; // 根据匿名 vma 的 addr 设置 pgoffbreak;default:return -EINVAL;}}// 通常内核会为 mmap 申请虚拟内存的时候会综合考虑 ram 以及 swap space 的总体大小。当映射的虚拟内存过大// 而没有足够的 swap space 的时候, mmap 就会失败,设置 MAP_NORESERVE,内核将不会考虑上面的限制因素// 这样当通过 mmap 申请大量的虚拟内存,并且当前系统没有足够的 swap space 的时候,mmap 系统调用依然能够成功if (flags & MAP_NORESERVE) {// 设置 MAP_NORESERVE 的目的是为了应用可以申请过量的虚拟内存,如果内核本身是禁止 overcommit 的// 那么设置 MAP_NORESERVE 是无意义的,如果内核允许过量申请虚拟内存时(overcommit 为 0 或者 1)// 无论映射多大的虚拟内存,mmap 将会始终成功,但缺页的时候会容易导致 oomif (sysctl_overcommit_memory != OVERCOMMIT_NEVER)vm_flags |= VM_NORESERVE; // 设置 VM_NORESERVE 表示无论申请多大的虚拟内存,内核总会答应// 大页内存是提前预留出来的,并且本身就不会被 swap,所以不需要像普通内存页那样考虑 swap space 的限制因素if (file && is_file_hugepages(file))vm_flags |= VM_NORESERVE;}// 内存映射的核心,创建和初始化虚拟内存区域,并加入红黑树管理addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);// 假如没有设置 MAP_POPULATE 标志位内核并不在调用 mmap 时就为进程分配物理内存空间,而是直到下次真正访问// 地址空间时发现数据不存在于物理内存空间时才触发 Page Fault 将缺失的 Page 换入内存空间 if (!IS_ERR_VALUE(addr) &&((vm_flags & VM_LOCKED) ||(flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))// 设置需要分配的物理内存大小*populate = len;return addr;
}
do_mmap 函数代码很长,核心功能如下:
- 调用 get_unmapped_area 函数用于在进程地址空间中寻找出一段长度为 len,并且还未映射的虚拟内存区域 vma 出来,返回值 addr 表示这段虚拟内存区域的起始地址。之后根据不同的文件打开方式设置不同的 vm 标志位 flag;
- 调用 mmap_region 函数,首先会为刚才选取出来的映射虚拟内存区域分配 vma 结构,并根据映射信息进行初始化,以及建立 vma 与相关映射文件的关系,最后将这段 vma 插入到进程的虚拟内存空间中(链表或红黑树进行管理)。
接下来先跟踪查看 get_unmapped_area 函数是如何寻找到合适长度的虚拟内存区域的?
2.6 get_unmapped_area 函数
/mm/mmap.c
unsigned long
get_unmapped_area(struct file *file, unsigned long addr, unsigned long len,unsigned long pgoff, unsigned long flags)
{// 在进程虚拟空间中寻找还未被映射的 VMA 这段核心逻辑是被内核实现在特定于体系结构的函数中// 该函数指针用于指向真正的 get_unmapped_area 函数,在经典布局下,真正的实现函数为 arch_get_unmapped_areaunsigned long (*get_area)(struct file *, unsigned long,unsigned long, unsigned long, unsigned long);unsigned long error = arch_mmap_check(addr, len, flags);if (error)return error;/* Careful about overflows.. */// 映射的虚拟内存区域长度不能超过进程的地址空间if (len > TASK_SIZE) return -ENOMEM;// 如果是匿名映射,则采用 mm_struct 中保存的特定于体系结构的 arch_get_unmapped_area 函数get_area = current->mm->get_unmapped_area;if (file) {// 如果是文件映射,则需要使用 file->f_op 中的 get_unmapped_area 指向的函数来为文件映射申请虚拟内存// file->f_op 保存的是特定于文件系统中文件的相关操作,如 ext4 文件系统下的 thp_get_unmapped_area 函数if (file->f_op->get_unmapped_area)get_area = file->f_op->get_unmapped_area;} else if (flags & MAP_SHARED) {// 共享匿名映射是通过在 tmpfs 中创建的匿名文件实现的,所以这里也有其专有的 get_unmapped_area 函数pgoff = 0;// 共享匿名映射的情况下 get_unmapped_area 指向 shmem_get_unmapped_area 函数get_area = shmem_get_unmapped_area;}// 在进程虚拟内存空间中,根据指定的 addr,len 查找合适的 vmaaddr = get_area(file, addr, len, pgoff, flags);if (IS_ERR_VALUE(addr))return addr;// vma 区域不能超过进程地址空间if (addr > TASK_SIZE - len)return -ENOMEM;// addr 需要与 page size 对齐if (offset_in_page(addr))return -EINVAL;error = security_mmap_addr(addr);return error ? error : addr;
}
由代码的注释以及跟踪查看各分支代码可知:如果是文件映射,则需要使用 file->f_op 中的 get_unmapped_area 指向的函数来为文件映射申请虚拟内存,file->f_op 保存的是特定于文件系统中文件的相关操作,如 ext4 文件系统下的 thp_get_unmapped_area 函数;如果是共享匿名映射的情况下 get_unmapped_area 指向 shmem_get_unmapped_area 函数;上述两种情况下,其最终会跟私有匿名映射一样,都会调用到 mm->get_unmapped_area 函数指针指向的函数,在经典布局下,mm->get_unmapped_area 指向的是 arch_get_unmapped_area 函数。
2.7 arch_get_unmapped_area 函数
/mm/mmap.c
unsigned long
arch_get_unmapped_area(struct file *filp, unsigned long addr,unsigned long len, unsigned long pgoff, unsigned long flags)
{struct mm_struct *mm = current->mm;struct vm_area_struct *vma, *prev;struct vm_unmapped_area_info info;// 进程虚拟内存空间的末尾 TASK_SIZEconst unsigned long mmap_end = arch_get_mmap_end(addr);// 映射区域长度是否超过进程虚拟内存空间if (len > mmap_end - mmap_min_addr)return -ENOMEM;// 如果我们指定了 MAP_FIXED 表示必须要从我们指定的 addr 开始映射 len 长度的区域// 如果这块区域已经存在映射关系,那么后续内核会把旧的映射关系覆盖掉if (flags & MAP_FIXED)return addr;// 没有指定 MAP_FIXED,但指定了 addr,内核从指定的 addr 地址开始映射,内核这里会检查指定的这块虚拟内存范围是否有效if (addr) {addr = PAGE_ALIGN(addr); // addr 先保证与 page size 对齐// 内核这里需要确认一下我们指定的 [addr, addr+len] 这段虚拟内存区域是否存在已有的映射关系// [addr, addr+len] 地址范围内已经存在映射关系,则不能按照我们指定的 addr 作为映射起始地址// 在进程地址空间中查找第一个符合 addr < vma->vm_end 条件的 vma// 如果不存在这样一个 vma(!vma), 则表示 [addr, addr+len] 这段范围的虚拟内存是可以使用的,内核将会从我们指定的 addr 开始映射// 如果存在这样一个 vma ,则表示 [addr, addr+len] 这段范围的虚拟内存区域目前已经存在映射关系了,不能采用 addr 作为映射起始地址// 这里还有一种情况是 addr 落在 prev 和 vma 之间的一块未映射区域// 如果这块未映射区域的长度满足 len 大小,那么这段未映射区域可以被本次使用,内核也会从我们指定的 addr 开始映射vma = find_vma_prev(mm, addr, &prev);if (mmap_end - len >= addr && addr >= mmap_min_addr &&(!vma || addr + len <= vm_start_gap(vma)) &&(!prev || addr >= vm_end_gap(prev)))return addr;}// 如果明确指定 addr 但是指定的虚拟内存范围是一段无效的区域或者已经存在映射关系// 那么内核会自动在地址空间中寻找一段合适的虚拟内存范围出来,这段虚拟内存范围的起始地址就不是指定的 addrinfo.flags = 0;// vma 区域长度info.length = len;// 定义从哪里开始查找 vma, mmap_base 表示从文件映射与匿名映射区开始查找info.low_limit = mm->mmap_base;// 查找结束位置为进程地址空间的末尾 TASK_SIZEinfo.high_limit = mmap_end;info.align_mask = 0;info.align_offset = 0;return vm_unmapped_area(&info);
}
arch_get_unmapped_area 函数的核心作用如下:
- 调用 find_vma_prev 函数,根据指定的映射起始地址 addr,在进程地址空间中查找出符合 addr < vma->vm_end 条件的第一个 vma,然后在进程地址空间 mm_struct 中 mmap 指向的 vma 链表中,找出它的前驱节点 pprev。
- 如果明确指定起始地址 addr ,但是指定的虚拟内存范围有一段无效的区域或者已经存在映射关系,内核就不能按照我们指定的 addr 开始映射,此时调用 vm_unmapped_area 函数,内核会自动在文件映射与匿名映射区中按照地址的增长方向寻找一段 len 大小的虚拟内存范围出来。注意:此时找到的虚拟内存范围的起始地址就不是指定的 addr。
2.8 find_vma_prev 函数
/mm/mmap.c
struct vm_area_struct *
find_vma_prev(struct mm_struct *mm, unsigned long addr,struct vm_area_struct **pprev)
{struct vm_area_struct *vma;// 在进程地址空间 mm_struct 中查找第一个符合 addr < vma->vm_end 的 vmavma = find_vma(mm, addr);if (vma) { // 恰好包含 addr 的 vma 的前一个虚拟内存区域 *pprev = vma->vm_prev;} else {// 如果当前进程地址空间中,addr 不属于任何一个 vma,那这里的 pprev 指向进程地址空间中最后一个 vmastruct rb_node *rb_node = rb_last(&mm->mm_rb);*pprev = rb_node ? rb_entry(rb_node, struct vm_area_struct, vm_rb) : NULL;}// 返回查找到的 vma,不存在则返回 null(内核后续会创建 vma)return vma;
}
继续调用 find_vma 函数查找符合需求的 vma,找到则返回,不存在则返回 null(内核后续会创建 vma)。
2.9 find_vma 函数
/mm/mmap.c
/* Look up the first VMA which satisfies addr < vm_end, NULL if none. */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{struct rb_node *rb_node;struct vm_area_struct *vma;// 进程地址空间中缓存了最近访问过的 vma,首先从进程地址空间中 vma 缓存中开始查找,缓存命中率通常大约为 35%// 查找条件为:vma->vm_start <= addr && vma->vm_end > addrvma = vmacache_find(mm, addr);if (likely(vma))return vma;// 进程地址空间中的所有 vma 被组织在一颗红黑树中,为了方便内核在进程地址空间中快速查找特定的 vma// 这里首先需要获取红黑树的根节点,内核会从根节点开始查找rb_node = mm->mm_rb.rb_node;while (rb_node) {struct vm_area_struct *tmp;// 获取位于根节点的 vmatmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);if (tmp->vm_end > addr) {vma = tmp;// 判断 addr 是否恰好落在根节点 vma 中: vm_start <= addr < vm_endif (tmp->vm_start <= addr)break;rb_node = rb_node->rb_left; // 如果不存在,则继续到左子树中查找} else// 如果根节点的 vm_end <= addr,说明 addr 在根节点 vma 的后边,这种情况则到右子树中继续查找rb_node = rb_node->rb_right;}if (vma)// 更新 vma 缓存vmacache_update(addr, vma);// 返回查找到的 vma,如果没有查找到,则返回 null,表示进程空间中目前还没有这样一个 vma,后续需要新建return vma;
}
由于进程地址空间中缓存了最近访问过的 vma,因此 find_vma 函数首先从进程地址空间中 vma 缓存中开始查找,找到则直接返回。如果找不到则遍历整个 vma 红黑树进行查找,找到则返回查找到的 vma,否则返回 null,表示进程地址空间中目前还没有这样一个 vma,后续需要新建。
回到 2.7 小节 arch_get_unmapped_area 函数,经过查找后,如果找到的这个 vma 与 [addr , addr +len] 这段虚拟地址范围有重叠的部分,那么内核就不能按照指定的 addr 开始映射,此时则调用 vm_unmapped_area 函数,内核会自动在文件映射与匿名映射区中按照地址的增长方向寻找一段 len 大小的虚拟内存范围出来。
2.10 vm_unmapped_area 函数
/include/linux/mm.h
static inline unsigned long
vm_unmapped_area(struct vm_unmapped_area_info *info)
{// 按照进程虚拟内存空间中文件映射与匿名映射区的地址增长方向分为两个函数,用来在进程地址空间中查找未映射的 vmaif (info->flags & VM_UNMAPPED_AREA_TOPDOWN)// 当文件映射与匿名映射区的地址增长方向是从上到下逆向增长时(新式布局),采用 topdown 后缀的函数查找return unmapped_area_topdown(info);else// 地址增长方向为从下倒上正向增长(经典布局),采用该函数查找return unmapped_area(info);
}
vm_unmapped_area 函数是一个内联函数,内部按照进程虚拟内存空间中文件映射与匿名映射区的地址增长方向分为两个函数,这里选择 unmapped_area 函数继续跟踪查看。
2.11 unmapped_area 函数
/mm/mmap.c
unsigned long unmapped_area(struct vm_unmapped_area_info *info)
{/** We implement the search by looking for an rbtree node that* immediately follows a suitable gap. That is,* - gap_start = vma->vm_prev->vm_end <= info->high_limit - length;* - gap_end = vma->vm_start >= info->low_limit + length;* - gap_end - gap_start >= length*/struct mm_struct *mm = current->mm;// 寻找未映射区域的参考 vma (该区域已存在映射关系)struct vm_area_struct *vma;// 未映射区域产生在 vma->vm_prev 与 vma 这两个虚拟内存区域中的间隙 gap 中,length 表示本次映射区域的长度// low_limit ,high_limit 表示在进程地址空间中哪段地址范围内查找,一个地址下限(mm->mmap_base),另一个标识地址上限(TASK_SIZE)// gap_start, gap_end 表示 vma->vm_prev 与 vma 之间的 gap 范围,unmapped_area 将会在这里产生unsigned long length, low_limit, high_limit, gap_start, gap_end;// 调整搜索长度以考虑最坏情况下的对齐开销length = info->length + info->align_mask;if (length < info->length)return -ENOMEM;// 根据需要的长度调整搜索限制if (info->high_limit < length)return -ENOMEM;// gap_start 需要满足的条件:gap_start = vma->vm_prev->vm_end <= info->high_limit - length// 否则 unmapped_area 将会超出 high_limit 的限制high_limit = info->high_limit - length;if (info->low_limit > high_limit)return -ENOMEM;// gap_end 需要满足的条件:gap_end = vma->vm_start >= info->low_limit + length// 否则 unmapped_area 将会超出 low_limit 的限制low_limit = info->low_limit + length;// 首先将 vma 红黑树的根节点作为 gap 的参考 vma,检查根节点是否符合if (RB_EMPTY_ROOT(&mm->mm_rb))goto check_highest;// 获取红黑树根节点的 vmavma = rb_entry(mm->mm_rb.rb_node, struct vm_area_struct, vm_rb);// rb_subtree_gap 为当前 vma 及其左右子树中所有 vma 与其对应 vm_prev 之间最大的虚拟内存地址 gap// 最大的 gap 如果都不能满足映射长度 length 则跳转到 check_highest 处理if (vma->rb_subtree_gap < length)goto check_highest; // 从进程地址空间最后一个 vma->vm_end 地址处开始映射while (true) {// 左子树,获取当前 vma 的 vm_start 起始虚拟内存地址作为 gap_endgap_end = vm_start_gap(vma);// gap_end 需要满足:gap_end >= low_limit,否则 unmapped_area 将会超出 low_limit 的限制// 如果存在左子树,则需要继续到左子树中去查找,因为我们需要按照地址从低到高的优先级来查看合适的未映射区域if (gap_end >= low_limit && vma->vm_rb.rb_left) {struct vm_area_struct *left =rb_entry(vma->vm_rb.rb_left,struct vm_area_struct, vm_rb);// 如果左子树中存在合适的 gap,则继续左子树的查找// 否则查找结束,gap 为当前 vma 与其 vm_prev 之间的间隙 if (left->rb_subtree_gap >= length) {vma = left;continue;}}// 获取当前 vma->vm_prev 的 vm_end 作为 gap_startgap_start = vma->vm_prev ? vm_end_gap(vma->vm_prev) : 0;
check_current:/* Check if current node has a suitable gap */// gap_start 需要满足:gap_start <= high_limit,否则 unmapped_area 将会超出 high_limit 的限制if (gap_start > high_limit)return -ENOMEM;if (gap_end >= low_limit &&gap_end > gap_start && gap_end - gap_start >= length)goto found; // 找到了合适的 unmapped_area 跳转到 found 处理// 当前 vma 与其左子树中的所有 vma 均不存在一个合理的 gap,那么从 vma 的右子树中继续查找if (vma->vm_rb.rb_right) {struct vm_area_struct *right =rb_entry(vma->vm_rb.rb_right,struct vm_area_struct, vm_rb);if (right->rb_subtree_gap >= length) {vma = right;continue;}}// 如果在当前 vma 以及它的左右子树中均无法找到一个合适的 gap// 那么这里会从当前 vma 节点向上回溯整颗红黑树,在它的父节点中尝试查找是否有合适的 gap// 因为这时候有可能会有新的 vma 插入到红黑树中,可能会产生新的 gapwhile (true) {struct rb_node *prev = &vma->vm_rb;if (!rb_parent(prev))goto check_highest;vma = rb_entry(rb_parent(prev),struct vm_area_struct, vm_rb);if (prev == vma->vm_rb.rb_left) {gap_start = vm_end_gap(vma->vm_prev);gap_end = vm_start_gap(vma);goto check_current;}}}check_highest:// 流程走到这里表示在当前进程虚拟内存空间的所有 vma 中都无法找到一个合适的 gap 来作为 unmapped_area// 那么就从进程地址空间中最后一个 vma->vm_end 开始映射// mm->highest_vm_end 表示当前进程虚拟内存空间中,地址最高的一个 vma 的结束地址位置gap_start = mm->highest_vm_end;gap_end = ULONG_MAX; /* Only for VM_BUG_ON below */if (gap_start > high_limit) // 这里最后需要检查剩余虚拟内存空间是否满足映射长度return -ENOMEM;found:/* We found a suitable gap. Clip it with the original low_limit. */// 流程走到这里表示已经找到了一个合适的 gap 来作为 unmapped_area,直接返回 gap_start(需要与 4K 对齐)作为映射的起始地址if (gap_start < info->low_limit)gap_start = info->low_limit;// 调整间隙地址到所需的对齐方式gap_start += (info->align_offset - gap_start) & info->align_mask;VM_BUG_ON(gap_start + info->length > info->high_limit);VM_BUG_ON(gap_start + info->length > gap_end);return gap_start; // 返回找到的地址间隙 gap
}
unmapped_area 函数的核心任务就是在管理进程地址空间这些 vma 的红黑树 mm_struct-> mm_rb 中查找出一个满足条件的地址间隙 gap 用于内存映射。如果能够找到符合条件的地址间隙 gap 则直接返回,否者就从进程地址空间中最后一个 vma->vm_end 开始映射。
回到 2.5 小节 do_mmap 函数,此时内核已经通过 get_unmapped_area 函数在进程地址空间中找出一段地址范围为 [addr , addr + len] 的虚拟内存区域供 mmap 进行映射。接下来跟踪查看 mmap_region 函数具体是如何初始化 vma 并建立映射关系的?首先看一下mmap_region 函数的流程图,结合流程图再看其函数实现,会更加清晰明了:
2.12 mmap_region 函数
/mm/mmap.c
unsigned long mmap_region(struct file *file, unsigned long addr,unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,struct list_head *uf)
{struct mm_struct *mm = current->mm;struct vm_area_struct *vma, *prev;int error;struct rb_node **rb_link, *rb_parent;unsigned long charged = 0;// 再次检查本次映射是否超过了进程虚拟内存空间中的虚拟内存容量的限制,超过则返回 falseif (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {unsigned long nr_pages;// 如果 mmap 指定了 MAP_FIXED,表示内核必须要按照用户指定的映射区来进行映射// 这种情况下就会导致,指定的映射区 [addr, addr + len] 有一部分可能与现有映射重叠// 内核将会覆盖掉这段已有的映射,重新按照用户指定的映射关系进行映射// 所以这里需要计算进程地址空间中与指定映射区[addr, addr + len]重叠的虚拟内存页数 nr_pagesnr_pages = count_vma_pages_range(mm, addr, addr + len);// 由于这里的 nr_pages 表示重叠的虚拟内存部分,将会被覆盖,所以这部分被覆盖的虚拟内存不需要额外申请// 这里通过 len >> PAGE_SHIFT 减去这段可以被覆盖的 nr_pages 在重新检查是否超过虚拟内存相关区域的限额if (!may_expand_vm(mm, vm_flags,(len >> PAGE_SHIFT) - nr_pages))return -ENOMEM;}// 如果当前进程地址空间中存在指定映射区域 [addr, addr + len] 重叠的部分while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,&rb_parent)) {if (do_munmap(mm, addr, len, uf)) // 调用 do_munmap 将这段重叠的映射部分解除掉,后续会重新映射这部分return -ENOMEM;}// 判断将来是否会为这段虚拟内存 vma 申请新的物理内存,比如:私有、可写(private writable)的映射方式,内核将来会通过 cow 重新为其分配新的物理内存。// 私有、只读(private readonly)的映射方式,内核则会共享原来映射的物理内存,而不会申请新的物理内存。// 如果将来需要申请新的物理内存则会根据当前系统的 overcommit 策略以及当前物理内存的使用情况来 // 综合判断是否允许本次虚拟内存的申请。如果虚拟内存不足,则返回 ENOMEM,这样的话可以防止缺页的时候发生 OOMif (accountable_mapping(file, vm_flags)) {charged = len >> PAGE_SHIFT;// 根据内核 overcommit 策略以及当前物理内存的使用情况综合判断,是否能够通过本次虚拟内存的申请// 虚拟内存的申请一旦这里通过之后,后续发生缺页,内核将会有足够的物理内存为其分配,不会发生 OOMif (security_vm_enough_memory_mm(mm, charged))return -ENOMEM;// 凡是设置了 VM_ACCOUNT 的 vma,表示这段虚拟内存均已经过 vm_enough_memory 的检测// 当虚拟内存发生缺页的时候,内核会有足够的物理内存分配,而不会导致 OOM // 其虚拟内存的用量都会被统计在 /proc/meminfo 的 Committed_AS 字段中 vm_flags |= VM_ACCOUNT;}// 为了精细化的控制内存的开销,内核这里首先需要尝试看能不能和地址空间中已有的 vma 进行合并,尝试将当前 vma 合并到已有的 vma 中vma = vma_merge(mm, prev, addr, addr + len, vm_flags,NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);if (vma)goto out; // 如果可以合并,则虚拟内存分配过程结束// 如果不可以合并,则只能从 slab 中取出一个新的 vma 结构来vma = vm_area_alloc(mm);if (!vma) {error = -ENOMEM;goto unacct_error;}// 根据要映射的虚拟内存区域属性初始化 vma 结构中的相关字段vma->vm_start = addr;vma->vm_end = addr + len;vma->vm_flags = vm_flags;vma->vm_page_prot = vm_get_page_prot(vm_flags);vma->vm_pgoff = pgoff;if (file) { // 如果是文件映射if (vm_flags & VM_DENYWRITE) { // 映射的文件不允许写入,调用 deny_write_accsess(file) 排斥常规的文件操作 error = deny_write_access(file);if (error)goto free_vma;}if (vm_flags & VM_SHARED) {error = mapping_map_writable(file->f_mapping); // 映射的文件允许其他进程可见, 标记文件为可写if (error)goto allow_write_and_free_vma;}// 将文件与虚拟内存映射起来vma->vm_file = get_file(file); // 递增 File 的引用次数,返回 File 赋给 vma// 将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)// ext4 文件系统中的操作函数为 ext4_file_vm_ops,此刻开始,读写内存就和读写文件是一样的了error = call_mmap(file, vma);if (error)goto unmap_and_free_vma;WARN_ON_ONCE(addr != vma->vm_start);// 文件系统提供的mmap函数可能会修改映射的一些参数。在这里需要在调用 vma_link 前回置addr = vma->vm_start;vm_flags = vma->vm_flags;} else if (vm_flags & VM_SHARED) { // 共享匿名映射// 共享匿名映射依赖于 tmpfs 文件系统中的匿名文件 dev/zero,父子进程通过这个匿名文件进行通讯// 该函数用于在 tmpfs 中创建匿名文件,并映射进当前共享匿名映射区 vma 中error = shmem_zero_setup(vma);if (error)goto free_vma;} else { // 私有匿名映射// 将 vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来vma_set_anonymous(vma);}// 将当前 vma 按照地址的增长方向插入到进程虚拟内存空间的 mm_struct->mmap 链表// 以及 mm_struct->mm_rb 红黑树中,并建立文件与 vma 的反向映射vma_link(mm, vma, prev, rb_link, rb_parent);/* Once vma denies write, undo our temporary denial count */if (file) {if (vm_flags & VM_SHARED)mapping_unmap_writable(file->f_mapping);if (vm_flags & VM_DENYWRITE)allow_write_access(file);}file = vma->vm_file;
out:perf_event_mmap(vma);// 进程内存状态统计,在开启了 proc 时才会有vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);if (vm_flags & VM_LOCKED) {if ((vm_flags & VM_SPECIAL) || vma_is_dax(vma) ||is_vm_hugetlb_page(vma) ||vma == get_gate_vma(current->mm))vma->vm_flags &= VM_LOCKED_CLEAR_MASK;elsemm->locked_vm += (len >> PAGE_SHIFT);}if (file)uprobe_mmap(vma);vma->vm_flags |= VM_SOFTDIRTY;// 更新地址空间 mm_struct 中的相关统计变量vma_set_page_prot(vma);return addr;
unmap_and_free_vma:vma->vm_file = NULL;fput(file);// 撤销由设备驱动程序完成的映射unmap_region(mm, vma, prev, vma->vm_start, vma->vm_end);if (vm_flags & VM_SHARED)mapping_unmap_writable(file->f_mapping);
allow_write_and_free_vma:if (vm_flags & VM_DENYWRITE)allow_write_access(file);
free_vma:vm_area_free(vma);
unacct_error:if (charged)vm_unacct_memory(charged);return error;
}
mmap_region 函数主要负责创建虚拟内存区域,其核心流程如下:
- 调用 may_expand_vm 函数以检查进程在本次 mmap 映射之后申请的虚拟内存是否超过限制,检查(进程的虚拟内存总数+申请的页数)是否超过地址空间限制,如果是私有的可写映射,并且不是栈,则检查(进程的虚拟内存总数+申请的页数)是否超过最大数据长度;
- 调用 find_vma_links 函数查找当前进程地址空间中是否存在与指定映射区域 [addr, addr+len] 重叠的部分,如果有重叠则需调用 do_munmap 函数将这段重叠的映射部分解除掉,后续会重新映射这部分;
- 调用 vma_merge 函数,内核先尝试看能不能将待映射的 vma 和地址空间中已有的 vma 进行合并,如果可以合并,则不用创建新的 vma 结构,节省内存的开销。如果不能合并,则从 slab 中取出一个新的 vma 结构,并根据要映射的虚拟内存区域属性初始化 vma 结构中的相关字段;
- 调用 vma_link 函数把虚拟内存区域 vma 插入到链表和红黑树中。如果 vma 关联文件,那么把虚拟内存区域添加到文件的区间树中,文件的区间树用来跟踪文件被映射到哪些虚拟内存区域;
- 调用 vma_set_page_prot 函数更新地址空间 mm_struct 中的相关统计变量,根据虚拟内存标志(vma->vm_flags)计算页保护位(vma->vm_page_prot),如果共享的可写映射想要把页标记为只读,其目的是跟踪写事件,那么从页保护位删除可写位。
2.13 may_expand_vm 函数
/mm/mmap.c
// 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存总量的限制,超过则返回 false
bool may_expand_vm(struct mm_struct *mm, vm_flags_t flags, unsigned long npages)
{// mm->total_vm 表示当前进程地址空间中映射的虚拟内存页总数// npages 表示此次要映射的虚拟内存页个数// rlimit(RLIMIT_AS) 表示进程地址空间中允许映射的虚拟内存总量,单位为字节if (mm->total_vm + npages > rlimit(RLIMIT_AS) >> PAGE_SHIFT)return false; // 如果映射的虚拟内存页总数超出了内核的限制,那么就返回 false 表示虚拟内存不足// 检查本次映射是否属于数据区域的映射,这里的数据区域指的是私有,可写的虚拟内存区域(栈区除外)// 如果是则需要检查数据区域里的虚拟内存页是否超过了内核的限制// rlimit(RLIMIT_DATA) 表示进程地址空间中允许映射的私有,可写的虚拟内存总量,单位为字节// 如果超过则返回 false,表示数据区虚拟内存不足if (is_data_mapping(flags) &&mm->data_vm + npages > rlimit(RLIMIT_DATA) >> PAGE_SHIFT) {/* Workaround for Valgrind */if (rlimit(RLIMIT_DATA) == 0 &&mm->data_vm + npages <= rlimit_max(RLIMIT_DATA) >> PAGE_SHIFT)return true;pr_warn_once("%s (%d): VmData %lu exceed data ulimit %lu. Update limits%s.\n",current->comm, current->pid,(mm->data_vm + npages) << PAGE_SHIFT,rlimit(RLIMIT_DATA),ignore_rlimit_data ? "" : " or use boot option ignore_rlimit_data");if (!ignore_rlimit_data)return false;}return true;
}
may_expand_vm 函数的核心逻辑就是判断经过本次 mmap 映射之后,mm->total_vm + npages 是否超过了 rlimit(RLIMIT_AS) 中的限制,mm->data_vm + npages 是否超过了 rlimit(RLIMIT_DATA) 中的限制。如果超过,那么本次 mmap 内存映射流程在这里就会停止进行。注意:npages 是指 mmap 需要映射的虚拟内存页数。
2.14 find_vma_links 函数
/mm/mmap.c
static int find_vma_links(struct mm_struct *mm, unsigned long 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) { // 遍历整棵红黑树,为[addr,addr+len]这段内存区域查找合适的插入位置struct vm_area_struct *vma_tmp;__rb_parent = *__rb_link;vma_tmp = rb_entry(__rb_parent, struct vm_area_struct, vm_rb);// 插入的 vma 起始地址小于当前红黑树节点 vma 结束地址,则遍历红黑树左子树if (vma_tmp->vm_end > addr) {// 如果红黑树中现有 vma 与该映射区域重叠,则返回失败if (vma_tmp->vm_start < end)return -ENOMEM;__rb_link = &__rb_parent->rb_left; // 循环遍历查找左子树} else {// 插入的 vma 起始地址大于当前红黑树节点 vma 结束地址,则遍历红黑树右子树,说明红黑树左子节点到右子节点的VMA区域程递增趋势rb_prev = __rb_parent; // 更新待插入 vma 节点的前一个节点,即其父节点__rb_link = &__rb_parent->rb_right; // 循环遍历查找右子树}}// pprev 待插入 vma 节点的前一个节点的 vma,如果 rb_prev 为空,说明待插入节点是最左子节点,在链表mm->mmap中是头节点*pprev = NULL;if (rb_prev) *pprev = rb_entry(rb_prev, struct vm_area_struct, vm_rb);*rb_link = __rb_link; // 查找到的待插入 vma 节点位置*rb_parent = __rb_parent; // 待插入位置节点的父节点return 0;
}
find_vma_links 函数的作用是在当前进程地址空间中查找是否存在与指定映射区域 [addr, addr+len] 重叠的部分,如果查找到现存的 vma 和该指定映射区域有重叠则返回错误,如果不存在重叠部分,则表示找到 vma 待插入的位置,包括其在链表中的位置 prev 和红黑树中的位置 rb_link 和 rb_parent,分别是待插入节点本身在红黑树中的位置和待插入节点的父节点。
2.15 vma_merge 函数
/mm/mmap.c
struct vm_area_struct *vma_merge(struct mm_struct *mm,struct vm_area_struct *prev, unsigned long addr,unsigned long end, unsigned long vm_flags,struct anon_vma *anon_vma, struct file *file,pgoff_t pgoff, struct mempolicy *policy,struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{pgoff_t pglen = (end - addr) >> PAGE_SHIFT; // 本次需要创建的 vma 区域大小// area 表示当前要创建的 vma,next 表示 area 的下一个 vma// 事实上 area 会在其 prev 前一个 vma 和 next 后一个 vma 之间的间隙 gap 中创建产生struct vm_area_struct *area, *next;int err;// 设置了 VM_SPECIAL 表示 area 区域是不可以被合并的,只能重新创建 vma,并直接退出合并流程if (vm_flags & VM_SPECIAL)return NULL;// 根据 prev vma 是否存在,设置 area 的 next vmaif (prev)next = prev->vm_next; // area 将在 prev vma 和 next vma 的间隙 gap 中产生elsenext = mm->mmap; // 如果 prev 不存在,那么 next 就设置为地址空间中的第一个 vmaarea = next;// 新 vma 的 end 与 next->vm_end 相等,表示新 vma 与 next vma 是重合的// 那么 next 指向下一个 vma,prev 和 next 这里的语义是始终指向 area 区域的前一个和后一个 vmaif (area && area->vm_end == end) /* cases 6, 7, 8 */next = next->vm_next;/* verify some invariant that must be enforced by the caller */VM_WARN_ON(prev && addr <= prev->vm_start);VM_WARN_ON(area && end > area->vm_end);VM_WARN_ON(addr >= end);// 判断 area 是否能够和 prev 进行合并if (prev && prev->vm_end == addr &&mpol_equal(vma_policy(prev), policy) &&can_vma_merge_after(prev, vm_flags,anon_vma, file, pgoff,vm_userfaultfd_ctx)) { // 如果 area 可以和 prev 进行合并,那么这里继续判断 area 能够与 next 进行合并// 内核这里需要保证 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) &&is_mergeable_anon_vma(prev->anon_vma,next->anon_vma, NULL)) { /* cases 1,6 */// 到此则表示 area 可以和它的 prev,next 区域进行合并 // __vma_adjust 是真正执行 vma 合并操作的函数,会重新调整已有 vma 的相关属性,比如:vm_start,vm_end,vm_pgoff。// 以及涉及到相关数据结构的改变err = __vma_adjust(prev, prev->vm_start,next->vm_end, prev->vm_pgoff, NULL,prev);} else /* cases 2, 5, 7 */// 流程到此则表示 area 只能和 prev 进行合并err = __vma_adjust(prev, prev->vm_start,end, prev->vm_pgoff, NULL, prev);if (err)return NULL;khugepaged_enter_vma_merge(prev, vm_flags);return prev; // 返回最终合并好的 vma}// 下面这种情况属于,area 的结束地址 end 与 next 的起始地址是重合的// 但是 area 的起始地址 start 和 prev 的结束地址不是重合的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)) {// area 区域前半部分和 prev 区域的后半部分重合// 那么就缩小 prev 区域,然后将 area 合并到 next 区域if (prev && addr < prev->vm_end) /* case 4 */err = __vma_adjust(prev, prev->vm_start,addr, prev->vm_pgoff, NULL, next);else { /* cases 3, 8 */// area 区域前半部分和 prev 区域是有间隙 gap 的// 那么这种情况下 prev 不变,area 合并到 next 中err = __vma_adjust(area, addr, next->vm_end,next->vm_pgoff - pglen, NULL, next);area = next; // 合并后的 area}if (err)return NULL;khugepaged_enter_vma_merge(area, vm_flags);return area; // 返回合并后的 vma}// prev 的结束地址不与 area 的起始地址重合,并且 area 的结束地址不与 next 的起始地址重合// 这种情况就不能执行合并,需要为 area 重新创建新的 vma 结构return NULL;
}
mmap_region 函数在创建新的 vma 结构之前,内核首先需要尝试看能不能将当前 vma 和地址空间中已有的 vma 进行合并,以避免创建新的 vma 结构,节省内存的开销。内核本着合并最大化的原则,检查当前映射出来的 vma 能否与其前后两个 vma 进行合并,能合并就合并,如果不能合并就从 slab 中申请新的 vma 结构。合并条件如下:
- 新映射 vma 的 vm_flags 不能设置 VM_SPECIAL 标志,该标志表示 vma 区域是不可以被合并的,只能重新创建 vma。
- 新映射 vma 的起始地址 addr 必须要与其前一个 vma 的结束地址重合,这样 vma 才能和它的前一个 vma 进行合并,如果不重合,vma 则不能和前一个 vma 进行合并。
- 新映射 vma 的结束地址 end 必须要与其后一个 vma 的起始地址重合,这样,vma 才能和它的后一个 vma 进行合并,如果不重合,vma 则不能和后一个 vma 进行合并。注意:如果前后都不能合并,则需新建 vma 结构。
- 新映射 vma 需要与其要合并 vma 区域的 vm_flags 相同,否则不能合并。
- 如果两个合并区域都是文件映射区,那么它们映射的文件必须是同一个。并且他们的文件映射偏移 vm_pgoff 必须是连续的。
- 如果两个合并区域都是匿名映射区,那么两个 vma 映射的匿名页 anon_vma 必须是相同的。
- 合并区域的 numa policy 必须是相同的。
- 要合并的 prev 和 next 虚拟内存区域中,不能包含 close 操作,也就是说 vma->vm_ops 不能设置有 close 函数,如果虚拟内存区域操作支持 close,则不能合并,否则会导致现有虚拟内存区域 prev 和 next 的资源无法释放。
2.16 vma_link 函数
/mm/mmap.c
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)
{struct address_space *mapping = NULL; // 文件 page cacheif (vma->vm_file) {mapping = vma->vm_file->f_mapping; // 获取映射文件的 page cachei_mmap_lock_write(mapping);}// 将 vma 插入到地址空间中的 vma 链表 mm_struct->mmap 以及红黑树 mm_struct->mm_rb 中__vma_link(mm, vma, prev, rb_link, rb_parent);__vma_link_file(vma); // 建立文件与 vma 的反向映射if (mapping)i_mmap_unlock_write(mapping);mm->map_count++; // map_count 表示进程地址空间中 vma 的个数validate_mm(mm);
}
vma_link 函数的主要作用如下:
- 调用 __vma_link 函数将 vma 插入到链表和红黑树中,其内部调用 __vma_link_list 函数将 vma 插入到 mm->mmap 链表中,调用 __vma_link_rb 函数将 vma 插入到 mm->rb 红黑树中。
- 调用 __vma_link_file 函数将 vma 添加到文件树中;
总结
在一般情况下,调用 mmap 进行内存映射时,内核只是会在进程的虚拟内存空间中为该次映射分配一段虚拟内存,然后建立好这段虚拟内存与相关文件之间的映射关系,至此流程就结束,完成 mmap 内存映射的实现过程的第一阶段。此时内核并不会为映射分配物理内存,物理内存的分配工作需要延后到这段虚拟内存被 CPU 访问的时候,通过缺页中断来进入内核,分配物理内存,并在页表中建立好映射关系。