从内核世界透视 mmap 内存映射的本质(源码实现篇)

smallmeteror
发布于 2023-10-31 14:41
浏览
0收藏

5.1 may_expand_vm 检查映射的虚拟内存是否超过了内核限制

进程地址空间中对虚拟内存的用量是有限制的,限制分为两个方面:

  1. 对进程地址空间中能够映射的虚拟内存页总数做出限制。
  2. 对进程地址空间中数据区的虚拟内存页总数做出限制。

这里的数据区,在内核中定义的是所有私有,可写的虚拟内存区域(栈区除外):

/*
 * Data area - private, writable, not stack
 */
static inline bool is_data_mapping(vm_flags_t flags)
{
    // 本次需要映射的虚拟内存区域是否是私有,可写的(数据区)
    return (flags & (VM_WRITE | VM_SHARED | VM_STACK)) == VM_WRITE;
}

以上两个方面的限制,我们可以通过修改 ​​/etc/security/limits.conf​​ 文件进行调整。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

内核对进程地址空间中相关区域的虚拟内存用量限制依然保存在 task_struct->signal_struct->rlim 数组中,我们可以通过 RLIMIT_AS 以及 RLIMIT_DATA 下标进行访问。

// 进程地址空间中允许映射的虚拟内存总量,单位为字节
# define RLIMIT_AS  9 /* address space limit */
// 进程地址空间中允许用于私有可写(private,writable)的虚拟内存总量,单位字节
# define RLIMIT_DATA  2 /* max data size */

当前进程地址空间中已经映射的虚拟内存页数保存在 mm_struct->total_vm 中,数据区(私有,可写)已经映射的虚拟内存页数保存在 mm_struct->data_vm 中。

struct mm_struct {
    // 进程地址空间中所有已经映射的虚拟内存页总数
    unsigned long total_vm;    /* Total pages mapped */
    // 进程地址空间中所有私有,可写的虚拟内存页总数
    unsigned long data_vm;     /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
}

may_expand_vm 函数的核心逻辑就是判断经过本次 mmap 映射之后(mmap 需要映射的虚拟内存页数为 npages),mm->total_vm + npages 是否超过了 rlimit(RLIMIT_AS) 中的限制,mm->data_vm + npages 是否超过了 rlimit(RLIMIT_DATA) 中的限制。如果超过,那么本次 mmap 内存映射流程在这里就会停止进行。

// 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存总量的限制,超过则返回 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)
        // 如果映射的虚拟内存页总数超出了内核的限制,那么就返回 false 表示虚拟内存不足
        return 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;
}

5.2 内核的 overcommit 策略

正如前边笔者所介绍到的,内核的 overcommit 策略会影响到进程申请虚拟内存的用量,进程对虚拟内存的申请就好比是我们向银行贷款,我们在向银行贷款的时候,银行是需要对我们的还款能力进行审计的,我们抵押的资产越优质,银行贷款给我们的也会越多。

同样的道理,进程再向内核申请虚拟内存的时候,也是需要物理内存作为抵押的,因为虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,不能无限制的申请。

所以进程在申请虚拟内存的时候,内核也是需要对申请的虚拟内存用量进行审计的,审计的对象就是那些在未来需要为其分配物理内存的虚拟内存。这也是符合常理的,因为只有在未来需要分配新的物理内存的时候,内核才需要综合物理内存的容量来进行审计,从而决定是否为进程分配这么多的虚拟内存,否则将来可能到处都是 OOM。如果未来不需要为这段虚拟内存分配物理内存,那么内核自然不会对虚拟内存用量进行审计。这取决于 mmap 的映射方式。

比如,这段虚拟内存是私有,可写的,那么在未来,当进程对这段虚拟内存进行写入的时候,内核会通过 cow 的方式为其分配新的物理内存,但是这段虚拟内存是共享的或者是只读的话,内核将不会为这段虚拟内存分配新的物理内存,而是继续共享原来已经映射好的物理内存(内核中只有一份)。

如果进程在向内核申请的虚拟内存在未来是需要重新分配物理内存的话,比如:私有,可写。那么这种虚拟内存的使用量就需要被内核审计起来,因为物理内存总是有限的,不可能为所有虚拟内存都分配物理内存。内核需要确保能够为这段虚拟内存未来分配足够的物理内存,防止 oom。这种虚拟内存称之为 account virtual memory

而进程向内核申请的虚拟内存并不需要内核为其重新分配物理内存的时候(共享或只读),反正不会增加物理内存的使用负担,这种虚拟内存就不需要被内核审计。

/*
 * We account for memory if it's a private writeable mapping,
 * not hugepages and VM_NORESERVE wasn't set.
 */
static inline int accountable_mapping(struct file *file, vm_flags_t vm_flags)
{
    /*
     * hugetlb 类型的大页有其自己的统计方式,不会和普通的虚拟内存统计混合
     */
    if (file && is_file_hugepages(file))
        return 0;
    // 私有,可写,并且没有设置 VM_NORESERVE 的相关 VMA 是需要被 account 审计起来的。这样在后续发生缺页的时候,不会导致 OOM
    return (vm_flags & (VM_NORESERVE | VM_SHARED | VM_WRITE)) == VM_WRITE;
}

由于大页内存都是被预先分配在大页内存池中的,所以针对大页的虚拟内存不需要被审计,另外如果这段虚拟内存 vma 设置了 VM_NORESERVE 标志的话,也不需要被内核审计。

所以 account virtual memory 特指那些私有,可写(private ,writeable)的虚拟内存区域,并且这些虚拟内存区域的 vm_flags 没有设置 VM_NORESERVE 标志位,以及这部分虚拟内存不能是映射大页的

这部分 account virtual memory 被记录在 vm_committed_as 字段中,表示被审计起来的虚拟内存,这些虚拟内存在未来都是需要映射新的物理内存的,站在物理内存的角度 vm_committed_as 可以理解为当前系统中已经分配的物理内存和未来可能需要的物理内存总量。

// 定义在文件:/include/linux/mman.h
extern struct percpu_counter vm_committed_as;

static inline void vm_acct_memory(long pages)
{
 percpu_counter_add_batch(&vm_committed_as, pages, vm_committed_as_batch);
}

static inline void vm_unacct_memory(long pages)
{
 vm_acct_memory(-pages);
}

每当有进程向内核申请或者释放虚拟内存(account virtual memory )的时候,内核都会通过 vm_acct_memory 和 vm_unacct_memory 函数来更新 vm_committed_as 的值。

当我们使用 mmap 进行内存映射的时候,如果映射出的虚拟内存区域 vma 为私有,可写的,并且参数 flags 没有设置 MAP_NORESERVE 标志,那么这部分虚拟内存就需要被记录在 vm_committed_as 字段中。

vm_committed_as 的值最终会反应在 ​​/proc/meminfo ​​中的 Committed_AS 字段上。用来记录当前系统中,所有进程申请到的 account virtual memory 总量。

static int meminfo_proc_show(struct seq_file *m, void *v)
{
    struct sysinfo i;
    unsigned long committed;


    committed = percpu_counter_read_positive(&vm_committed_as);
  
    show_val_kb(m, "Committed_AS:   ", committed);
}

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

现在 account virtual memory 的概念我们清楚了,那么接下来就该来看一下,内核是如何对这部分虚拟内存的申请进行审计的(account)。

如果 accountable_mapping 函数返回值为 true,表示内核需要对当前进程申请的这部分虚拟内存进行审计,审计的逻辑封装在 __vm_enough_memory 函数中,返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足。这里正是内核 overcommit 策略的核心实现。

我们可以通过内核参数 ​​/proc/sys/vm/overcommit_memory​​ 来调整 overcommit 策略 。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

内核定义了如下三种 overcommit 策略:

#define
#define
#define

OVERCOMMIT_GUESS 是内核默认的 overcommit 策略,在这种策略下,进程对虚拟内存的申请不能超过物理内存总大小和 swap 交换区的总大小 之和。

    if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
        if (pages > totalram_pages() + total_swap_pages)
            goto error;
        return 0;
    }

OVERCOMMIT_ALWAYS 策略下应用进程无论申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进。

 if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
        return 0;

OVERCOMMIT_NEVER 策略下,内核会严格控制进程申请虚拟内存的用量,虚拟内存的限制通过 vm_commit_limit 函数计算得出,一般情况下为 ​​(总物理内存大小 - 大页占用的内存大小) * 50% + swap 交换区总大小​​。所有进程申请到的虚拟内存总量不能超过该值。

vm_commit_limit 函数返回值体现在 ​​/proc/meminfo​​ 中的 CommitLimit 字段中。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

注意:只有在 OVERCOMMIT_NEVER 策略下,CommitLimit 的限制才会生效

除此之外,内核会在 CommitLimit 的基础上为进程预留一部分内存,用于在紧急情况下做一些恢复的操作,这部分预留的内存包括两种,一种是 sysctl_admin_reserve_kbytes,另一种是 sysctl_user_reserve_kbytes。它们的大小均可以在 ​​/proc/sys/vm​​ 目录下相应的配置文件中进行调整,单位为 KB。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

  • sysctl_admin_reserve_kbytes 表示当进程拥有 root 权限的时候,内核需要为 root 相关的操作保留一部分内存,这样可以使进程在任何情况下都可以顺利执行 root 权限的相关操作。
  • sysctl_user_reserve_kbytes 用于在紧急情况下用户恢复系统。比如系统卡死,用户主动 kill 资源消耗比较大的进程,这个动作需要预留一些 user_reserve 内存。

所以在 OVERCOMMIT_NEVER 策略下,进程可以申请到的虚拟内存容量需要在 CommitLimit 的基础上再减去 sysctl_admin_reserve_kbytes 和 sysctl_user_reserve_kbytes 配置的预留容量。

注意这里对虚拟内存申请的限制是针对所有进程已经申请到的虚拟内存总量 + 本次 mmap 申请的虚拟内存总和的限制

// 用于检查进程虚拟内存空间中是否有足够的虚拟内存可供本次申请使用(需要结合 overcommit 策略来综合判定)
// 返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
    // OVERCOMMIT_NEVER 模式下允许进程申请的虚拟内存大小
    long allowed;
    // 虚拟内存审计字段 vm_committed_as 增加 pages
    vm_acct_memory(pages);

    // 虚拟内存的 overcommit 策略可以通过修改 /proc/sys/vm/overcommit_memory 文件来设置,
    // 它有三个设置选项:
    // OVERCOMMIT_ALWAYS 表示无论应用进程申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进
    if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
        return 0;
    // OVERCOMMIT_GUESS 则相对 always 策略稍微保守一点,也是内核的默认策略
    // 它会对进程能够申请到的虚拟内存大小做一定的限制,特别激进的申请比如申请非常大的虚拟内存则会被拒绝。
    if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
        // guess 默认策略下,进程申请的虚拟内存大小不能超过 物理内存总大小和 swap 交换区的总大小之和
        if (pages > totalram_pages() + total_swap_pages)
            goto error;
        return 0;
    }

    // OVERCOMMIT_NEVER 是最为严格的一种控制虚拟内存 overcommit 的策略
    // 进程申请的虚拟内存大小不能超过 vm_commit_limit(),该值也会反应在 /proc/meminfo 中的 CommitLimit 字段中。
    // 只有采用 OVERCOMMIT_NEVER 模式,CommitLimit 的限制才会生效
    // allowed =(总物理内存大小 - 大页占用的内存大小) * 50%  + swap 交换区总大小 
    allowed = vm_commit_limit();

    // cap_sys_admin 表示申请内存的进程拥有 root 权限
    if (!cap_sys_admin)
        // 为 root 进程保存一些内存,这样可以保证 root 相关的操作在任何时候都可以顺利进行
        // 大小为 sysctl_admin_reserve_kbytes,这部分内存普通进程不能申请使用
        // 可通过 /proc/sys/vm/admin_reserve_kbytes 来配置
        allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);

    /*
     * Don't let a single process grow so big a user can't recover
     */
    if (mm) {
        // 可通过 /proc/sys/vm/user_reserve_kbytes 来配置
        // 用于在紧急情况下,用户恢复系统,比如系统卡死,用户主动 kill 资源消耗比较大的进程,这个动作需要预留一些 user_reserve 内存
        long reserve = sysctl_user_reserve_kbytes >> (PAGE_SHIFT - 10);

        allowed -= min_t(long, mm->total_vm / 32, reserve);
    }
    // Committed_AS (系统中所有进程已经申请的虚拟内存总量 + 本次 mmap 申请的)不可以超过 CommitLimit(allowed)
    if (percpu_counter_read_positive(&vm_committed_as) < allowed)
        return 0;
error:
    vm_unacct_memory(pages);

    return -ENOMEM;
}

下面我们来看一下,OVERCOMMIT_NEVER 策略下,CommitLimit 的计算逻辑。

有两个内核参数会影响 CommitLimit 的计算,它们分别是 sysctl_overcommit_kbytes 和 sysctl_overcommit_ratio,可通过 ​​/proc/sys/vm ​​目录下相应的配置文件中进行调整。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

如果我们配置了 overcommit_kbytes (单位为 KB), CommitLimit (单位为页)的值就是 ​​sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10) + total_swap_pages​​。

如果我们没有配置 overcommit_kbytes,内核则会根据 overcommit_ratio 的值(默认为 50)计算 CommitLimit :​​(总物理内存大小 - 大页占用的内存大小) * overcommit_ratio % + total_swap_pages​​。

overcommit_kbytes 的优先级要大于 overcommit_ratio

/*
 * Committed memory limit enforced when OVERCOMMIT_NEVER policy is used
 */
unsigned long vm_commit_limit(void)
{
    // 允许申请的虚拟内存大小,单位为页
    unsigned long allowed;
    // 该值可通过 /proc/sys/vm/overcommit_kbytes 来修改
    // sysctl_overcommit_kbytes 设置的是 Committed memory limit 的绝对值
    if (sysctl_overcommit_kbytes)
        // 转换单位为页
        allowed = sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10);
    else
        // sysctl_overcommit_ratio 该值可通过 /proc/sys/vm/overcommit_ratio 来修改,设置的 commit limit 的比例
        // 默认值为 50,(总物理内存大小 - 大页占用的内存大小) * 50%
        allowed = ((totalram_pages() - hugetlb_total_pages())
               * sysctl_overcommit_ratio / 100);

    // 最后都需要加上 swap 交换区的总大小
    allowed += total_swap_pages;
    // (总物理内存大小 - 大页占用的内存大小) * 50%  + swap 交换区总大小 
    return allowed;
}

5.3 vma_merge 函数解析

经过前面的介绍我们知道,当 mmap 在进程虚拟内存空间中映射出一段 [addr , end] 的虚拟内存区域 area 时,内核需要为这段虚拟内存区域 area 创建一个 vma 结构来描述。

而在创建新的 vma 结构之前,内核会在这里尝试看能不能将 area 与现有的 vma 进行合并,这样就可以避免创建新的 vma 结构,节省了内存的开销。

内核会本着合并最大化的原则,检查当前映射出来的 area 能否与其前后两个 vma 进行合并,能合并就合并,如果不能合并就只能从 slab 中申请新的 vma 结构了。合并条件如下:

  1. area 的 vm_flags 不能设置 VM_SPECIAL 标志,该标志表示 area 区域是不可以被合并的,只能重新创建 vma。
  2. area 的起始地址 addr 必须要与其 prev vma 的结束地址重合,这样,area 才能和它的前一个 vma 进行合并,如果不重合,area 则不能和前一个 vma 进行合并。
  3. area 的结束地址 end 必须要与其 next vma 的起始地址重合,这样,area 才能和它的后一个 vma 进行合并,如果不重合,area 则不能和后一个 vma 进行合并。如果前后都不能合并,那就只能重新创建 vma 结构了。
  4. area 需要与其要合并区域的 vm_flags 必须相同,否则不能合并。
  5. 如果两个合并区域都是文件映射区,那么它们映射的文件必须是同一个。并且他们的文件映射偏移 vm_pgoff 必须是连续的。
  6. 如果两个合并区域都是匿名映射区,那么两个 vma 映射的匿名页 anon_vma 必须是相同的。
  7. 合并区域的 numa policy 必须是相同的。关于 numa policy 的介绍,感兴趣的同学可以查看笔者之前的文章​​《一步一图带你深入理解 Linux 物理内存管理》​​ 第 “3.2.1 NUMA 的内存分配策略” 小节的内容。
  8. 要合并的 prev 和 next 虚拟内存区域中,不能包含 close 操作,也就是说 vma->vm_ops 不能设置有 close 函数,如果虚拟内存区域操作支持 close,则不能合并,否则会导致现有虚拟内存区域 prev 和 next 的资源无法释放。

can_vma_merge_after 函数用于判断其参数中指定的 vma 能否与其后一个 vma 进行合并。can_vma_merge_before 的逻辑也是一样,用于判断参数指定的 vma 能否与其前一个 vma 合并。

static int
can_vma_merge_after(struct vm_area_struct *vma, unsigned long vm_flags,
            struct anon_vma *anon_vma, struct file *file,
            pgoff_t vm_pgoff,
            struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
    // 判断参数中指定的 vma 能否与其后一个 vma 进行合并
    if (is_mergeable_vma(vma, file, vm_flags, vm_userfaultfd_ctx) &&
        is_mergeable_anon_vma(anon_vma, vma->anon_vma, vma)) {
        pgoff_t vm_pglen;
        // vma 区域的长度
        vm_pglen = vma_pages(vma);
        // 判断 vma 和 next 两个文件映射区域的映射偏移 pgoff 是否是连续的
        if (vma->vm_pgoff + vm_pglen == vm_pgoff)
            return 1;
    }
    return 0;
}

is_mergeable_vma 函数用于判断两个 vma 是否能够合并:

static inline int is_mergeable_vma(struct vm_area_struct *vma,
                struct file *file, unsigned long vm_flags,
                struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
    // 对比 prev 和 area 的 vm_flags 是否相同,这里需要排除 VM_SOFTDIRTY
    // VM_SOFTDIRTY 用于追踪进程写了哪些内存页,如果 prev 被标记了 soft dirty,那么合并之后的 vma 也应该继续保留 soft dirty 标记
    if ((vma->vm_flags ^ vm_flags) & ~VM_SOFTDIRTY)
        return 0;
    // prev 和 area 如果是文件映射区的话,这里需要检查两者映射的文件是否相同
    if (vma->vm_file != file)
        return 0;
    // 如果 prev 虚拟内存区域中包含了 close 的操作,后续可能会释放 prev 的资源
    // 所以这种情况下不能和 prev 进行合并,否则就会导致 prev 的资源无法释放
    if (vma->vm_ops && vma->vm_ops->close)
        return 0;
    // userfaultfd 是用来在用户态实现缺页处理的机制,这里需要保证两者的 userfaultfd 相同
    // 不过在 mmap_region 中传入的 vm_userfaultfd_ctx 为 null,这里我们不需要关注
    if (!is_mergeable_vm_userfaultfd_ctx(vma, vm_userfaultfd_ctx))
        return 0;
    return 1;
}

在我们清楚了 vma 之间的的合并条件之后,接下来我们来看一下 vma 的合并过程,整个合并过程其实还蛮复杂的,总共涉及到 8 种场景,不过大家别担心,笔者会带着大家从最简单的场景出发来逐渐演变。

经过前面内容的介绍,我们知道,通过 mmap 在进程地址空间中映射出的这个 area 一般是在两个 vma 中产生的,内核源码中使用 prev 指向 area 的前一个 vma,使用 next 指向 area 的后一个 vma,这个原则请大家务必牢记。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

如果我们在 mmap 系统调用参数 flags 中设置了 MAP_FIXED 标志,表示需要内核进行强制映射,在这种情况下,area 区域有可能会与 prev 区域和 next 区域有部分重合。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

如上图所示,如果 area 区域的结束地址 end 与 next 区域的结束地址重合,内核会将 next 指针继续向后移动一下,指向 next->vm_next 区域。保证 area 始终处于 prev 和 next 之间的 gap 中。

 if (area && area->vm_end == end)        
        next = next->vm_next;

以上这两种基本布局,大家要好好记住,多看几眼,后面 8 种合并情况基本都是脱胎于这两个基本布局。

下面即将要介绍的这 8 种合并情况从总体上来讲会分为两个大的类别:

  1. 第一个类别是 area 的前一个 prev vma 的结束地址与 area 的起始地址 addr 重合,判断条件为:​​prev->vm_end == addr​​。
  2. 第二个类别是 area 的后一个 next vma 的起始地址与 area 的结束地址 end 重合,判断条件为:​​end == next->vm_start ​​。

其中这两个大的类别将会分别根据前面两个基本布局展开进行,下面我们来看源码中的 case 1 。

注意下面的 8 种 case,笔者按照从简单到复杂的顺序来展示。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

case 1 是在基本布局 1 中,area 的起始地址 addr 与 prev vma 的结束地址重合,同时 area 的结束地址 end 与 next vma 的起始地址重合,内核将会删除 next 区域,扩充 prev 区域,也就是说将这三个区域统一合并到 prev 区域中。

case 1 在基本布局 2 下,就演变成了 case 6 的情况,内核会将中间重叠的蓝色区域覆盖掉,然后统一合并到 prev 区域中。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

如果只是 area 的起始地址 addr 与 prev vma 的结束地址重合,但是 area 的结束地址 end 不与 next vma 的起始地址重合,就会出现 case 2 , case 5 , case  7 三种情况。

其中 case 2 的情况是  area 的结束地址 end 小于 next vma 的起始地址,内核会扩充 prev 区域,将 area 合并进去,next 区域保持不变。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

case 5 的情况是  area 的结束地址 end 大于 next vma 的起始地址,内核会扩充 prev 区域,将 area 以及与 next 重叠的部分合并到 prev 区域中,剩下的继续留在 next 区域保持不变。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

case 2 在基本布局 2 下又会演变成 case 7 , 这种情况下内核会将下图中的蓝色区域覆盖,并扩充 prev 区域。next 区域保持不变。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

如果只是 area 的结束地址 end 与 next vma 的起始地址重合,但是 area 的起始地址 addr 不与 prev vma 的结束地址重合,同样的道理也会分为三种情况,分别是下面介绍的 case 4 , case 3 , case 8。

case 4 的情况下,area 的起始地址 addr 小于 prev 区域的结束地址,那么内核会缩小 prev 区域,然后扩充 next 区域,将重叠的部分合并到 next 区域中。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

如果 area 的起始地址 addr 大于 prev 区域的结束地址的话,就是 case 3 的情况  ,内核会扩充 next 区域,并将 area 合并到 next 中,prev 区域保持不变。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

case 3 在基本布局 2 下就会演变为 case 8 ,内核继续保持 prev 区域不变,然后扩充 next 区域并覆盖下图中蓝色部分,将 area 合并到 next 区域中。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

好了,现在 vma 合并的流程我们也清楚了,合并的条件也清楚了,接下来在看这部分源码就很简单了。

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)
{
    // 本次需要创建的 VMA 区域大小
    pgoff_t pglen = (end - addr) >> PAGE_SHIFT;
    // 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 vma,基本布局 1
    if (prev)
        // area 将在 prev vma 和 next vma 的间隙 gap 中产生
        next = prev->vm_next;
    else
        // 如果 prev 不存在,那么 next 就设置为地址空间中的第一个 vma。
        next = mm->mmap;

    area = next;
    // 新 vma 的 end 与 next->vm_end 相等 ,表示新 vma 与 next vma 是重合的,基本布局 2
    // 那么 next 指向下一个 vma,prev 和 next 这里的语义是始终指向 area 区域的前一个和后一个 vma
    if (area && area->vm_end == end)        /* cases 6, 7, 8 */
        next = next->vm_next;
 
    // 判断 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)) {
            // 流程走到这里表示 area 可以和它的 prev ,next 区域进行合并  /* cases 1,6 */
            // __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);
        // 返回最终合并好的 vma
        return prev;
    }

    // 下面这种情况属于,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
            area = next;
        }
        if (err)
            return NULL;
        khugepaged_enter_vma_merge(area, vm_flags);
        // 返回合并后的 vma
        return area;
    }
    
    // prev 的结束地址不与 area 的起始地址重合,并且 area 的结束地址不与 next 的起始地址重合
    // 这种情况就不能执行合并,需要为 area 重新创建新的 vma 结构
    return NULL;
}

总结

到现在为止,笔者通过两篇文章,一篇​​原理​​,一篇源码,深入到内核世界中,将 mmap 内存映射的本质给大家呈现了出来,知识点比较密集且比较烧脑,因此笔者又画了一副 mmap 内存映射的整体思维导图方便大家回顾。

从内核世界透视 mmap 内存映射的本质(源码实现篇)-鸿蒙开发者社区

在原理篇中笔者首先通过五个角度为大家详细介绍了 mmap 的使用方法及其在内核中的实现原理,这五个角度分别是:

  1. 私有匿名映射,其主要用于进程申请虚拟内存,以及初始化进程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。
  2. 私有文件映射,其核心特点是背后映射的文件页在多进程之间是读共享的,但多个进程对各自虚拟内存区的修改只能反应到各自对应的文件页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中。我们可以利用这些特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。
  3. 共享文件映射,多进程之间读写共享(不会发生写时复制),常用于多进程之间共享内存(page cache),多进程之间的通讯。
  4. 共享匿名映射,用于父子进程之间共享内存,父子进程之间的通讯。父子进程之间需要依赖 tmpfs 中的匿名文件来实现共享内存。是一种特殊的共享文件映射。
  5. 大页内存映射,这里我们介绍了标准大页与透明大页两种大页类型的区别与联系,以及他们各自的实现原理和使用方法。

介绍完原理之后,在本文的源码实现篇中笔者花了大量的篇幅介绍了 mmap 在内核中的源码实现,其中最核心的两个函数是:

  1. get_unmapped_area 函数用于在进程虚拟内存空间中为本次 mmap 映射寻找出一段未被映射的空闲虚拟内存地址范围。其中笔者还为大家介绍了文件映射与匿名映射区在进程虚拟内存空间的布局情况。
  2. map_region 函数主要是对这段空闲虚拟内存地址范围进行映射,在映射过程中涉及到的重要内容有:
  • 内核的 overcommit 策略
  • vm_merge 合并的流程,其中涉及到 8 种合并场景和 2 中基本布局。

好了,本文的内容到这里就结束了,感谢大家的收看,我们下篇文章见~


文章转载自公众号:bin的技术小屋

分类
标签
已于2023-10-31 14:41:26修改
收藏
回复
举报
回复
    相关推荐