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

smallmeteror
发布于 2023-10-30 11:37
浏览
0收藏

4.3 虚拟内存的分配

get_unmapped_area 主要的目的就是在具体的映射区布局下,根据布局特点,真正负责划分虚拟内存区域的函数。经过上一小节的介绍我们知道,在经典布局下,mm->get_unmapped_area 指向的函数为 arch_get_unmapped_area。

如果 mmap 进行的是私有匿名映射,那么内核会通过 mm->get_unmapped_area 函数进行虚拟内存的分配。

如果 mmap 进行的是文件映射,那么内核则采用的是特定于文件系统的 file->f_op->get_unmapped_area 函数。比如,我们通过 mmap 映射的是 ext4 文件系统下的文件,那么 file->f_op->get_unmapped_area 指向的是 thp_get_unmapped_area 函数,专门为 ext4 文件映射申请虚拟内存。

const struct file_operations ext4_file_operations = {
        .mmap           = ext4_file_mmap
        .get_unmapped_area = thp_get_unmapped_area,
};

如果 mmap 进行的是共享匿名映射,由于共享匿名映射的本质其实是基于 tmpfs 的虚拟文件系统中的匿名文件进行的共享文件映射,所以这种情况下 get_unmapped_area 函数是需要基于 tmpfs 的虚拟文件系统的,在共享匿名映射的情况下 get_unmapped_area 指向 shmem_get_unmapped_area 函数。

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_area
    unsigned long (*get_area)(struct file *, unsigned long,
                  unsigned long, unsigned long, unsigned long);

    // 映射的虚拟内存区域长度不能超过进程的地址空间
    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 保存的是特定于文件系统中文件的相关操作
        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_area = shmem_get_unmapped_area;
    }
    
    // 在进程虚拟内存空间中,根据指定的 addr,len 查找合适的VMA
    addr = 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;

    return error ? error : addr;
}

如果我们仔细观察 ext4 文件系统下的 thp_get_unmapped_area 函数以及 tmpfs 虚拟文件系统下的 shmem_get_unmapped_area,会发现,它们最终都会调用到 mm->get_unmapped_area 函数指针指向的函数。

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


const struct file_operations ext4_file_operations = {
        .mmap           = ext4_file_mmap
        .get_unmapped_area = thp_get_unmapped_area,
};


unsigned long __thp_get_unmapped_area(struct file *filp, unsigned long len,
                loff_t off, unsigned long flags, unsigned long size)
{
        ........... 省略 ........

        addr = current->mm->get_unmapped_area(filp, 0, len_pad,
                                              off >> PAGE_SHIFT, flags);
        return addr;
}

unsigned long shmem_get_unmapped_area(struct file *file,
                      unsigned long uaddr, unsigned long len,
                      unsigned long pgoff, unsigned long flags)
{
    unsigned long (*get_area)(struct file *,
        unsigned long, unsigned long, unsigned long, uns

         ........... 省略 ........

    get_area = current->mm->get_unmapped_area;
    
    return addr;
}

在经典布局下,mm->get_unmapped_area 指向的是 arch_get_unmapped_area 函数,mmap 虚拟内存分配的秘密就隐藏在这里:

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

首先我们需要明确一下,mmap 可以映射的虚拟内存范围必须在进程虚拟内存空间 mmap_min_addr 到 mmap_end 这段地址范围内,mmap_min_addr 为 TASK_SIZE 的三分之一,mmap_end 为 TASK_SIZE。

内核需要检查本次 mmap 映射的虚拟内存长度 len 是否超过了规定的映射范围,如果超过了则返回 ENOMEM 错误,并停止映射流程。

如果映射长度 len 在规定的映射地址范围内,内核则会根据我们指定的映射起始地址 addr,以及映射长度 len,开始在文件映射与匿名映射区,为本次 mmap 映射寻找一段空闲的虚拟内存区域 vma 出来。

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

如果在 flags 参数中指定了 ​​MAP_FIXED ​​标志,则意味着我们强制要求内核在我们指定的起始地址 addr 处开始映射 len 长度的虚拟内存区域,无论这段虚拟内存区域 [addr , addr + len] 是否已经存在映射关系,内核都会强行进行映射,如果这块区域已经存在映射关系,那么后续内核会把旧的映射关系覆盖掉。

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

如果我们指定了 addr,但是并没有指定 MAP_FIXED,则意味着我们只是建议内核优先考虑从我们指定的 addr 地址处开始映射,但是如果 [addr , addr+len] 这段虚拟内存区域已经存在映射关系,内核则不会按照我们指定的 addr 开始映射,而是会自动查找一段空闲的 len 长度的虚拟内存区域。这一部分的工作由 vm_unmapped_area 函数承担。

如果通过查找发现, [addr , addr+len] 这段虚拟内存地址范围并未存在任何映射关系,那么 addr 就会作为 mmap 映射的起始地址。这里面会分为两种情况:

  1. 第一种是我们指定的 addr 比较大,addr 位于文件映射与匿名映射区中所有映射区域 vma 的最后面,这样一来,[addr , addr + len] 这段地址范围当然是空闲的了。
  2. 第二种情况是我们指定的 addr 恰好位于一个 vma 和另一个 vma 中间的地址间隙中,并且这个地址间隙刚好大于或者等于我们指定的映射长度 len。内核就可以将这个地址间隙映射起来。

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

// 内核标准实现 
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_SIZE
    const 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 size 对齐
        addr = PAGE_ALIGN(addr);
        // 内核这里需要确认一下我们指定的 [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 但是指定的虚拟内存范围是一段无效的区域或者已经存在映射关系
    // 那么内核会自动在地址空间中寻找一段合适的虚拟内存范围出来
    // 这段虚拟内存范围的起始地址就不是我们指定的 addr 了
    info.flags = 0;
    // VMA 区域长度
    info.length = len;
    // 这里定义从哪里开始查找 VMA, 这里我们会从文件映射与匿名映射区开始查找
    info.low_limit = mm->mmap_base;
    // 查找结束位置为进程地址空间的末尾 TASK_SIZE
    info.high_limit = mmap_end;
    info.align_mask = 0;
    return vm_unmapped_area(&info);
}

4.4 find_vma_prev 查找是否有重叠的映射区域

find_vma_prev 的作用就是根据我们指定的映射起始地址 addr,在进程地址空间中查找出符合 ​​addr < vma->vm_end​​ 条件的第一个 vma 出来(下图中的蓝色部分)。

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

然后在进程地址空间中的 vma 链表 mmap 中,找出它的前驱节点 pprev (上图中的绿色部分)。

struct mm_struct {
    struct vm_area_struct *mmap;  /* list of VMAs */
}

如果不存在这样一个 vma(addr < vma->vm_end),那么内核直接从我们指定的 addr 地址处开始映射就好了,这时 pprev 指向进程地址空间中最后一个 vma。

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

如果存在这样一个 vma,那么内核就会判断,该 vma 与其前驱节点 pprev 之间的地址间隙 gap 是否能容纳下一段 len 长度的映射区间,如果可以,那么内核就映射在这个地址间隙 gap 中。如果不可以,内核就需要在 vm_unmapped_area 函数中重新到整个进程地址空间中查找出一个 len 长度的空闲映射区域,这种情况下映射区的起始地址就不是我们指定的 addr 了。

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

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 中查找第一个符合 addr < vma->vm_end 的 VMA
    vma = find_vma(mm, addr);

    if (vma) {
        // 恰好包含 addr 的 VMA 的前一个虚拟内存区域 
        *pprev = vma->vm_prev;
    } else {
        // 如果当前进程地址空间中,addr 不属于任何一个 VMA 
        // 那么这里的 pprev 指向进程地址空间中最后一个 VMA
        struct 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;
}

根据指定地址 addr 在进程地址空间中查找第一个符合 ​​addr < vma->vm_end ​​条件 vma 的操作在 find_vma 函数中进行,内核为了高效地在进程地址空间中查找特定条件的 vma,会按照地址的增长方向将所有的 vma 组织在一颗红黑树 mm_rb 中。

struct mm_struct {
     struct rb_root mm_rb;
}

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

find_vma 会根据我们指定的 addr 在这颗红黑树中查找第一个符合 ​​addr < vma->vm_end ​​条件的 vma 。

/* 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 > addr
    vma = 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;
        // 获取位于根节点的 VMA
        tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);

        if (tmp->vm_end > addr) {
            vma = tmp;
            // 判断 addr 是否恰好落在根节点 VMA 中: vm_start <= addr < vm_end
            if (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 与 [addr , addr +len] 这段虚拟地址范围有重叠的部分,那么内核就不能按照我们指定的 addr 开始映射,内核需要重新在文件映射与匿名映射区中按照地址的增长方向,找到一段 len 大小的空闲虚拟内存区域。这一部分的逻辑由 vm_unmapped_area 函数承担。

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

4.5 vm_unmapped_area 寻找未映射的虚拟内存区域

/*
 * Search for an unmapped address range.
 *
 * We are looking for a range that:
 * - does not intersect with any VMA;
 * - is contained within the [low_limit, high_limit) interval;
 * - is at least the desired size.
 * - satisfies (begin_addr & align_mask) == (align_offset & align_mask)
 */
static inline unsigned long
vm_unmapped_area(struct vm_unmapped_area_info *info)
{
    // 按照进程虚拟内存空间中文件映射与匿名映射区的地址增长方向
    // 分为两个函数,来在进程地址空间中查找未映射的 VMA
    if (info->flags & VM_UNMAPPED_AREA_TOPDOWN)
        // 当文件映射与匿名映射区的地址增长方向是从上到下逆向增长时(新式布局)
        // 采用 topdown 后缀的函数查找
        return unmapped_area_topdown(info);
    else
        // 地址增长方向为从下倒上正向增长(经典布局),采用该函数查找
        return unmapped_area(info);
}

本文是以 AMD64 体系为例展开讨论的,在 AMD64 体系结构下,文件映射与匿名映射区的布局采用的是经典布局,地址的增长方向从低地址到高地址增长。因此这里我们选择 unmapped_area 函数。

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

我们苦苦寻找的 unmapped_area 一定是在文件映射与匿名映射区中某个 vma 与其前驱 vma 之间的地址间隙 gap 中产生的。

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

所以这就要求这个 gap 的长度必须大于等于映射 length,这样才能容纳下我们要映射的长度。gap 的起始地址 gap_start 一般从 prev 节点的末尾开始:gap_start = vma->vm_prev->vm_end 。gap 的结束地址 gap_end 一般从 vma 的起始地址结束:gap_end = vma->vm_start 。

在此基础之上,gap 还会受到 low_limit(mm->mmap_base)和 high_limit(TASK_SIZE)的地址限制。

因此这个 gap 的起始地址 gap_start 不能高于 high_limit - length,否则我们从 gap_start 地址处开始映射长度 length 的区域就会超出 high_limit 的限制。

gap 的结束地址 gap_end 不能低于 low_limit  + length,否则映射区域的起始地址就会低于 low_limit 的限制。

unmapped_area 函数的核心任务就是在管理进程地址空间这些 vma 的红黑树 mm_struct-> mm_rb 中找到这样的一个地址间隙 gap 出来。

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

首先内核会从红黑树中的根节点 vma 开始查找,判断根节点的 vma 与其前驱节点 vma->vm_prev 之间的地址间隙 gap 是否满足上述条件,如果根节点 vma 的起始地址 vma->vm_start 也就是 gap_end 低于了 low_limit  + length 的限制,那就说明根节点 vma 与其前驱节点之间的 gap 不适合用来作为 unmapped_area,否则 unmapped_area 的起始地址 gap_start 就会低于 low_limit 的限制。

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

由于红黑树是按照 vma 的地址增长方向来组织的,左子树中的所有 vma 地址都低于根节点 vma 的地址,右子树的所有 vma 地址均高于根节点 vma 的地址。

现在的情况是 vma->vm_start 的地址太低了,已经小于了 low_limit  + length 的限制,所以左子树的 vma 就不用看了,直接从右子树中去查找。

如果根节点 vma 的起始地址 vma->vm_start 也就是 gap_end 高于 low_limit  + length 的要求,说明 gap_end 是符合我们的要求的,但是目前我们还不能马上对 gap_start 的限制要求进行检查,因为我们需要按照地址从低到高的优先级来查看最合适的 unmapped_area 未映射区域,所以我们需要到左子树中去查找地址更低的 vma。

如果我们在左子树中找到了一个地址最低的 vma,并且这个 vma 与其前驱节点vma->vm_prev 之间的地址间隙 gap 符合上述的三个条件:

  1. gap 的长度大于等于映射长度 length : gap_end - gap_start >= length
  2. gap_end >=  low_limit  + length 。
  3. gap_start  <= high_limit - length。

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

这里内核还有一个小小的优化点,如果我们遍历完了当前 vma 节点的所有子树(包括左子树和右子树)依然无法找到一个 gap 的长度可以满足我们的映射长度: gap_end - gap_start < length。那我们不是白白遍历了整棵树吗?

能否有一种机制,使我们通过当前 vma 就可以知道其子树中的所有 vma 节点与其前驱节点 vma->vm_prev 之间的地址间隙 gap 的最大长度(包括当前 vma)。

这样我们在遍历一个 vma 节点的时候,只需要检查一下其左右子树中的最大 gap 长度是否能够满足映射长度 length ,如果不能满足,说明整棵树中的 vma 节点与其前驱节点之间的间隙都不能容纳我们要映射的长度,直接就不用遍历了。

事实上,内核会将一个 vma 节点以及它所有子树中存在的最大间隙 gap 保存在 struct vm_area_struct 结构中的 rb_subtree_gap 属性中:

struct vm_area_struct {


    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;

    // 在当前 vma 的红黑树左右子树中的所有节点 vma (包括当前 vma)
    // 这个集合中的 vma 与其 vm_prev 之间最大的虚拟内存地址 gap (单位字节)保存在 rb_subtree_gap 字段中
    unsigned long rb_subtree_gap;
}

当我们遍历 vma 节点的时候发现:​​vma->rb_subtree_gap < length​​。那么整棵红黑树都不需要看了,我们直接从进程地址空间中最后一个 vma->vm_end 处开始映射就好了。

当前进程虚拟内存空间中,地址最高的一个 VMA 的结束地址位置保存在 mm_struct 结构中的 highest_vm_end 属性中:

struct mm_struct {
    // 当前进程虚拟内存空间中,地址最高的一个 VMA 的结束地址位置
    unsigned long highest_vm_end;   /* highest vma end address */
}

以上就是内核在文件映射与匿名映射区寻找 unmapped_area 的核心逻辑,我们明白了这些,在看源码就会清晰很多了:

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;

    // gap_start 需要满足的条件:gap_start =  vma->vm_prev->vm_end <= info->high_limit - length
    // 否则 unmapped_area 将会超出 high_limit 的限制
    high_limit = info->high_limit - length;

    // 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))
        // 'empty' nodes are nodes that are known not to be inserted in an rbtree
        goto check_highest;
    // 获取红黑树根节点的 vma
    vma = 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)
        // 从进程地址空间最后一个 vma->vm_end 地址处开始映射
        goto check_highest;

    while (true) {
        // 获取当前 vma 的 vm_start 起始虚拟内存地址作为 gap_end
        gap_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_start
        gap_start = vma->vm_prev ? vm_end_gap(vma->vm_prev) : 0;
check_current:
        // 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)
            // 找到了合适的 unmapped_area 跳转到 found 处理
            goto 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 插入到红黑树中,可能会产生新的 gap
        while (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)
        // ENOMEM 表示当前进程虚拟内存空间中虚拟内存不足
        return -ENOMEM;

found:
    // 流程走到这里表示我们已经找到了一个合适的 gap 来作为 unmapped_area 
    // 直接返回 gap_start (需要与 4K 对齐)作为映射的起始地址
    /* We found a suitable gap. Clip it with the original low_limit. */
    if (gap_start < info->low_limit)
        gap_start = info->low_limit;

    /* Adjust gap address to the desired alignment */
    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;
}

5. 内存映射的本质

流程走到这里,我们就来到了 mmap 系统调用最为核心的部分了,在之前的内容中,内核已经通过 get_unmapped_area 函数为我们在进程地址空间中挑选出一段地址范围为 [addr , addr + len] 的虚拟内存区域供 mmap 进行映射。

注意:这里的 addr 并不一定是我们指定的映射起始地址。

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

现在我们只是确定了  [addr , addr + len] 这段虚拟内存区域是可以映射的,这段区域只是被内核先划分出来了,但是还未分配出去,在 mmap_region 函数中,需要为这段虚拟内存区域分配 vma 结构,并根据映射方式对 vma 进行初始化,这样这段虚拟内存才算真正的被分配给了进程。

而在进程虚拟内存空间中允许被映射的虚拟内存总量是有限制的,所以在 mmap_region 开始分配虚拟内存之前,内核需要通过 may_expand_vm 检查本次需要映射的虚拟内存页数 len >> PAGE_SHIFT 是否已经超过了进程地址空间中可以被映射的虚拟内存总量限制。

如果未超过,则内核可以顺利的进行后续的内存映射流程,如果已经超过,内核则需近一步考虑能否消减一下不必要的虚拟内存用量。那么什么可以算作是不必要的虚拟内存用量呢?

#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

比如,我们在 mmap 系统调用的 flags 参数中指定了 MAP_FIXED,强制内核从我们指定的 addr 地址处开始映射。

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

这样一来,[addr , addr + len] 这段范围的虚拟内存就会有很大的可能与现有虚拟内存映射区 vma(上图中蓝色部分)发生重叠,因为这里我们指定的是强制映射 MAP_FIXED,所以内核会将这部分重叠的部分通过 do_munmap 函数先解除映射,然后建立新的映射关系,效果就是将这部分重叠的虚拟内存覆盖掉了。

由于这部分重叠的虚拟内存部分是之前已经分配出去的,本次映射不需要再重新申请,所以真实虚拟内存的用量需要减去这部分重叠的部分。

内核通过 count_vma_pages_range 函数计算出这部分重叠的虚拟内存页个数,然后用本次申请的虚拟内存页个数 len >> PAGE_SHIFT 减去重叠的页数就是本次映射真实的虚拟内存用量。

最后重新通过 may_expand_vm 函数判断是否超过进程地址空间中可以被映射的虚拟内存总量限制,如果依然超过,则返回 ENOMEM 异常。如果没有超过,则正式进入虚拟内存分配的流程。

说到虚拟内存的分配,我们不由的会想到进程的虚拟内存空间,每个进程的虚拟内存空间都是独立的,而且虚拟内存空间的容量非常巨大,在 64 位系统中进程的虚拟内存空间为 128T,在这么巨大的虚拟内存空间下申请虚拟内存,我们想当然的会认为,进程可以随意申请,随意折腾。

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

理论上是这样,但是事实上,虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,如果进程申请的虚拟内存远远超过物理内存大小,那么在运行的过程中就会导致部分内存被 swap 来 swap 去,甚至频繁的发生 oom,导致性能下降严重。

进程申请虚拟内存的过程就好比我们向银行贷款一样,进程的虚拟内存空间好比是现实中的银行,虚拟内存空间中的虚拟内存非常庞大,银行里的钱也非常多,但这并不意味着我们要多少银行就会贷给我们多少,银行需要对我们的资产进行审计,我们的资产越多,银行给我们贷款也会越多,我们的资产越少,银行给我们的贷款也越少。

同样的道理,内核也会对进程申请的虚拟内存进行审计(account),物理内存空间越大,swap 交换区越大,进程能能够申请到的虚拟内存也就越多。内核对虚拟内存申请的审计(account)策略就是我们前面提到的 overcommit_memory 策略,后面的相关章节笔者会详细的介绍,这里大家只需要知道内核的这个 overcommit_memory 策略会影响到进程申请虚拟内存大小。

内核通过 accountable_mapping 函数来判断是否需要对进程申请的虚拟内存进行审计,这就好比我们去银行贷款,如果客户的信用值一般,银行就需要对客户进行审计,如果客户端的信用值很高,资产优质,那么银行就不需要对客户的贷款进行审计。进程对虚拟内存的申请也是一样。

如果需要对虚拟内存进行审计,那么内核接着会调用 security_vm_enough_memory_mm 函数根据 overcommit_memory 策略判断是否允许进程申请这么多的虚拟内存,如果不通过,则返回 ENOMEM 停止虚拟内存申请流程。如果通过则将虚拟内存分配给进程。

内核为进程分配虚拟内存的本质其实就是在进程的虚拟内存空间中,找出一段未被映射的空闲虚拟内存地址范围 [addr , addr + len],就像之前介绍的 get_unmapped_area 函数那样。

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

然后再 mmap_region 函数中为这段空闲的虚拟内存地址范围  [addr , addr + len],创建 vma 结构,并初始化 vma 相关的属性。然后将这个 vma 结构插入到进程的虚拟内存空间中。

内核为了精细化的控制内存的开销,避免创建没有必要的 vma 结构,内核会本着能省则省的原则,在创建新的 vma 之前,按照最大程度合并的原则,内核会尝试看能不能将当前寻找出来的空闲虚拟内存区域  [addr , addr + len] 与其前一个 vma 以及后一个 vma 进行合并,然后重新调整合并后的 vma 相关属性,比如:vm_start , vm_end , vm_pgoff,以及涉及到相关数据结构的改变。这样一来,内核就不需要为这段空闲虚拟内存创建新的 vma 了。

如果不能合并,内核则只能从 slab 缓存中拿出一个 vma 结构来描述这段虚拟内存地址范围  [addr , addr + len]。并根据 mmap 映射的这段虚拟内存区域属性初始化 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;

如果 mmap 进行的是文件映射,那么这里内核会将映射的文件与虚拟映射区关联起来。

vma->vm_file = get_file(file);

然后内核会通过 call_mmap 函数,将虚拟内存的相关操作函数映射成文件相关的操作函数,大家或多或少在网上看到过这样的论述——" 通过内存文件映射可以将磁盘上的文件映射到内存中,这样我们就可以通过读写内存来完成磁盘文件的读写 ",其实本质就在 call_mmap 函数中,因为经过该函数处理之后,虚拟内存相关的操作函数已经变成文件相关的操作函数了。

struct vm_area_struct {

    struct file * vm_file;      /* File we map to (can be NULL). */

    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
}

struct vm_operations_struct {

    vm_fault_t (*fault)(struct vm_fault *vmf);

    void (*map_pages)(struct vm_fault *vmf,
            pgoff_t start_pgoff, pgoff_t end_pgoff);

    vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
}

我们接着来看 call_mmap 函数,mmap 文件映射的本质就在这里:

static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
{
    return file->f_op->mmap(file, vma);
}

内核将文件相关的操作全部定义在 struct file 结构中的 f_op 属性中:

struct file {
    const struct file_operations  *f_op;
}

文件的操作与其所在的文件系统是紧密相关的,在 ext4 文件系统中,相关文件的 file->f_op 指向 ext4_file_operations 操作集合:

const struct file_operations ext4_file_operations = {
 .mmap  = ext4_file_mmap,
};

其中 file->f_op->mmap 函数专门用于文件与内存的映射,在这里内核将 vm_area_struct 的内存操作 vma->vm_ops 设置为文件系统的操作 ext4_file_vm_ops,当通过 mmap 将内存与文件映射起来之后,读写内存其实就是读写文件系统的本质就在这里。

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
        ........ 省略 ........
        
      vma->vm_ops = &ext4_file_vm_ops;
      
        ........ 省略 ........    
}

static const struct vm_operations_struct ext4_file_vm_ops = {
    .fault      = ext4_filemap_fault,
    .map_pages  = filemap_map_pages,
    .page_mkwrite   = ext4_page_mkwrite,
};

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

如果 mmap 进行的是共享匿名映射,父子进程之间需要依赖 tmpfs 文件系统中的匿名文件对共享内存进行访问,当进行共享匿名映射的时候,内核会在 shmem_zero_setup 函数中,到  tmpfs 文件系统里为映射创建一个匿名文件(shmem_kernel_file_setup),随后将 tmpfs 文件系统中的这个匿名文件与虚拟映射区 vma 中的 vm_file 关联映射起来,当然了,vma->vm_ops 也需要映射成 shmem_vm_ops。

当父进程调用 fork 创建子进程的时候,内核会将父进程的虚拟内存空间全部拷贝给子进程,包括这里创建的共享匿名映射区域 vma,这样一来,父子进程就可以通过共同的 vma->vm_file 来实现共享内存的通信了。

这里可以看出 mmap 的共享匿名映射其实本质上还是共享文件映射,只不过这个文件比较特殊,创建于 ​​dev/zero​​ 目录下的 tmpfs 文件系统中。

int shmem_zero_setup(struct vm_area_struct *vma)
{
    struct file *file;
    loff_t size = vma->vm_end - vma->vm_start;
    // tmpfs 中获取一个匿名文件
    file = shmem_kernel_file_setup("dev/zero", size, vma->vm_flags);
    if (IS_ERR(file))
        return PTR_ERR(file);

    if (vma->vm_file)
        // 如果 vma 中已存在其他文件,则解除与其他文件的映射关系
        fput(vma->vm_file);
    
    // 将 tmpfs 中的匿名文件映射进虚拟内存区域 vma 中
    // 后续 fork 子进程的时候,父子进程就可以通过这个匿名文件实现共享匿名映射 
    vma->vm_file = file;
    // 对这块共享匿名映射区相关操作这里直接映射成 shmem_vm_ops
    vma->vm_ops = &shmem_vm_ops;

    return 0;
}

static const struct vm_operations_struct shmem_vm_ops = {
 .fault  = shmem_fault,
 .map_pages = filemap_map_pages,
#ifdef CONFIG_NUMA
 .set_policy     = shmem_set_policy,
 .get_policy     = shmem_get_policy,
#endif
};

如果 mmap 这里进行的是私有匿名映射的话,情况就变得简单了,由于私有匿名映射并不涉及到与文件之间的映射,所以只需要简单的将 vma->vm_ops 设置为 null 即可。

流程走到这里,本次 mmap 映射所产生的虚拟内存区域 vma 结构就被初始化好了,整个内存映射的核心工作就此完成了,剩下要做的事情就是将这个 vma 结构插入到进程虚拟内存空间中。

经过前面的介绍我们知道,在进程的虚拟内存空间中,所有的 vma 结构是被两种数据结构来组织管理的。一种是  mm_struct->mmap 指向的链表结构,另一种是 mm_struct->mm_rb 指向的红黑树结构。

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

vma_link 要做的工作就是按照虚拟内存地址的增长方向,将本次映射产生的 vma 结构插入到进程地址空间这两个数据结构中。

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)
{
    // 文件 page cache
    struct address_space *mapping = NULL;

    if (vma->vm_file) {
        // 获取映射文件的 page cache
        mapping = vma->vm_file->f_mapping;
        i_mmap_lock_write(mapping);
    }
    // 将 vma 插入到地址空间中的 vma 链表 mm_struct->mmap 以及红黑树 mm_struct->mm_rb 中
    __vma_link(mm, vma, prev, rb_link, rb_parent);
    // 建立文件与 vma 的反向映射
    __vma_link_file(vma);

    if (mapping)
        i_mmap_unlock_write(mapping);

    // map_count 表示进程地址空间中 vma 的个数
    mm->map_count++;
    validate_mm(mm);
}

除此之外,vma_link 还做了一项重要工作,就是通过 __vma_link_file 函数建立文件与虚拟内存区域 vma (所有进程)的反向映射关系。说起反向映射,笔者在之前的文章 ​​《一步一图带你深入理解 Linux 物理内存管理》​​ 中的 “6.1 匿名页的反向映射” 小节中为大家介绍过关于匿名页的反向映射过程,感兴趣的同学可以回看下。

匿名页的反向映射还是相对比较复杂的,文件页的反向映射就很简单了,在之前的文章中笔者曾介绍过,struct file 结构中的 f_maping 属性指向了一个非常重要的数据结构 struct address_space。

struct address_space {
    struct inode        *host;      /* owner: inode, block_device */
    // page cache
    struct radix_tree_root  i_pages;    /* cached pages */
    atomic_t        i_mmap_writable;/* count VM_SHARED mappings */
    // 文件与 vma 反向映射的核心数据结构,i_mmap 也是一颗红黑树
    // 在所有进程的地址空间中,只要与该文件发生映射的 vma 均挂在 i_mmap 中
    struct rb_root_cached   i_mmap;     /* tree of private and shared mappings */
}

struct address_space 结构中有两个非常重要的属性,其中一个是 i_pages ,它指向了我们熟悉的 page cache。另一个就是 i_mmap,它指向的是一颗红黑树,这颗红黑树正是文件页反向映射的核心数据结构,反向映射关系就保存在这里。

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

我们知道,一个文件可以被多个进程一起映射,这样一来在每个进程的地址空间 mm_struct 结构中都会有一个 vma 结构来与这个文件进行映射,与该文件发生映射关系的所有进程地址空间中的 vma 就挂在 address_space-> i_mmap 这颗红黑树中,通过它,我们可以找到所有与该文件进行映射的进程。

__vma_link_file 函数建立文件页反向映射的核心其实就是将 mmap 映射出的这个 vma 插入到这颗红黑树中。

static void __vma_link_file(struct vm_area_struct *vma)
{
    struct file *file;

    file = vma->vm_file;
    if (file) {
        struct address_space *mapping = file->f_mapping;
        // address_space->i_mmap 也是一颗红黑树,上面挂着的是与该文件映射的所有 vma(所有进程地址空间)
        // 这里将 vma 插入到 i_mmap 中,实现文件与 vma 的反向映射
        vma_interval_tree_insert(vma, &mapping->i_mmap);
    }
}

好了,mmap 内存映射最为核心的部分,到这里笔者就为大家介绍完了,映射原理我们清楚了,接下来我们跟着这副 mmap_region 流程图,来看源码实现就很清晰了:

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

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;

    // 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存容量的限制,超过则返回 false
    if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
        unsigned long nr_pages;

        // 如果 mmap 指定了 MAP_FIXED,表示内核必须要按照用户指定的映射区来进行映射
        // 这种情况下就会导致,我们指定的映射区[addr, addr + len] 有一部分可能与现有映射重叠
        // 内核将会覆盖掉这段已有的映射,重新按照用户指定的映射关系进行映射
        // 所以这里需要计算进程地址空间中与指定映射区[addr, addr + len]重叠的虚拟内存页数 nr_pages
        nr_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] 重叠的部分
   // 则调用  do_munmap 将这段重叠的映射部分解除掉,后续会重新映射这部分
    while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
                  &rb_parent)) {
        if (do_munmap(mm, addr, len, uf))
            return -ENOMEM;
    }
   
    /*
     * 判断将来是否会为这段虚拟内存 vma ,申请新的物理内存,比如 私有,可写(private writable)的映射方式,内核将来会通过 cow 重新为其分配新的物理内存。
     * 私有,只读(private readonly)的映射方式,内核则会共享原来映射的物理内存,而不会申请新的物理内存。
     * 如果将来需要申请新的物理内存则会根据当前系统的 overcommit 策略以及当前物理内存的使用情况来  
     * 综合判断是否允许本次虚拟内存的申请。如果虚拟内存不足,则返回 ENOMEM,这样的话可以防止缺页的时候发生 OOM
     */
    if (accountable_mapping(file, vm_flags)) {
        charged = len >> PAGE_SHIFT;
        // 根据内核 overcommit 策略以及当前物理内存的使用情况综合判断,是否能够通过本次虚拟内存的申请
        // 虚拟内存的申请一旦这里通过之后,后续发生缺页,内核将会有足够的物理内存为其分配,不会发生 OOM
        if (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) {
        // 将文件与虚拟内存映射起来
        vma->vm_file = get_file(file);
        // 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
        // ext4 文件系统中的操作函数为 ext4_file_vm_ops
        // 从这一刻开始,读写内存就和读写文件是一样的了
        error = call_mmap(file, vma);
        if (error)
            goto unmap_and_free_vma;

        addr = vma->vm_start;
        vm_flags = vma->vm_flags;
    } else if (vm_flags & VM_SHARED) {
        // 这里处理共享匿名映射
        // 前面提到共享匿名映射依赖于 tmpfs 文件系统中的匿名文件
        // 父子进程通过这个匿名文件进行通讯
        // 该函数用于在 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);

    file = vma->vm_file;
out:
    // 更新地址空间 mm_struct 中的相关统计变量
    vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
    return addr;
}

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

分类
标签
已于2023-10-30 11:37:05修改
收藏
回复
举报
回复
    相关推荐