4. VMA 操作 API:查找、插入、合并与分裂

VMA(Virtual Memory Area)是 Linux 内核管理进程虚拟地址空间的核心数据结构。内核提供了一组精密的操作 API,用于对 VMA 进行查找、插入、合并与分裂。这些操作是 mmap、munmap、mprotect 等系统调用的底层基石。本章深入剖析这四大操作的实现原理与调用时机。

4.1 VMA 查找:find_vma 与 find_vma_intersection

查找操作是 VMA 管理中最频繁的调用路径。内核使用红黑树(rb_tree)组织进程的 VMA 链表,以实现 O(log n) 的查找效率。

函数 功能 返回值 典型调用场景
find_vma(mm, addr) 查找第一个结束地址大于 addr 的 VMA 找到返回 vma 指针,否则返回 NULL 缺页异常处理、地址合法性检查
find_vma_intersection(mm, start, end) 查找与 [start, end) 区间有交集的 VMA 找到返回 vma 指针,否则返回 NULL munmap 前检查重叠区域
find_vma_prev(mm, addr, *pprev) 查找 addr 对应的 VMA,并返回前一个 VMA 找到返回 vma,pprev 带回前驱 插入新 VMA 时确定位置
性能要点: 内核为每个进程维护了 mmap_cache(最近访问的 VMA 指针)。如果查找地址命中缓存,则直接返回,避免红黑树遍历。在顺序访问模式下,缓存命中率可超过 90%。
编程提示: 驱动开发中调用 find_vma 前必须持有 mmap_lock(读或写),否则可能因并发修改导致空指针或越界访问。
VMA 查找流程(find_vma) 用户地址 addr 检查 mmap_cache 命中缓存 → 直接返回 未命中 → 红黑树查找 返回 vma 或 NULL mmap_lock 保护查找过程

4.2 VMA 插入:vma_link 与 insert_vm_struct

当进程通过 mmap 创建新的映射区域时,内核需要将新 VMA 插入到进程的 VMA 链表和红黑树中。插入操作的核心函数是 vma_link,它封装了三个步骤:

  1. 检查重叠: 调用 find_vma_intersection 确认新区间不与现有 VMA 重叠。
  2. 定位插入点: 使用 find_vma_prev 找到插入位置的前驱 VMA。
  3. 执行插入: 调用 __vma_link 将 VMA 同时加入红黑树和双向链表。
// 简化版 VMA 插入流程(mm/mmap.c)
int insert_vm_struct(struct mm_struct *mm, struct vm_area_struct *vma)
{
    struct vm_area_struct *prev;
    struct rb_node **rb_link, *rb_parent;

    // 1. 查找插入位置
    if (find_vma_links(mm, vma->vm_start, vma->vm_end,
                       &prev, &rb_link, &rb_parent))
        return -ENOMEM;

    // 2. 检查与 stack/gap 的冲突
    if ((vma->vm_flags & VM_GROWSDOWN) &&
        vma->vm_start < stack_guard_gap)
        return -ENOMEM;

    // 3. 执行链接
    vma_link(mm, vma, prev, rb_link, rb_parent);
    return 0;
}
并发风险: vma_link 必须在持有 mmap_lock 写锁的情况下调用。如果两个线程同时插入重叠的 VMA,未加锁会导致红黑树结构损坏,引发内核崩溃。

4.3 VMA 合并:vma_merge 的智能决策

VMA 合并是内核减少 VMA 数量的关键优化。当新映射与相邻 VMA 具有相同的 标志位(flags)、文件偏移 和 匿名映射属性 时,内核会尝试将它们合并为一个连续的 VMA。

合并方向 条件 合并后效果 触发场景
向前合并(与前驱) 前驱 VMA 的结束地址 == 新 VMA 起始地址,且 flags/文件一致 扩展前驱 VMA 的 vm_end 连续 mmap 相同文件区域
向后合并(与后继) 新 VMA 的结束地址 == 后继 VMA 起始地址,且 flags/文件一致 扩展新 VMA 的 vm_end mremap 扩展映射
前后同时合并 同时满足前向和后向条件 删除中间 VMA,合并为一个大 VMA mmap 填充两个 VMA 之间的空隙
VMA 合并决策流程(vma_merge) 新 VMA 区域 与前驱 VMA 相邻且兼容? 是 → 向前合并 否 → 检查后继 与后继 VMA 相邻且兼容? 是 → 向后合并 合并条件:flags、文件、 偏移量、匿名属性均相同

4.4 VMA 分裂:split_vma 的精确切割

当 munmap 或 mprotect 只作用于某个 VMA 的部分区域时,内核必须将原 VMA 分裂 为两个或三个独立的 VMA。分裂操作由 split_vma 函数实现。

分裂场景举例: 假设有一个 VMA 覆盖地址范围 [0x1000, 0x5000),用户调用 munmap(0x2000, 0x1000) 释放中间 4KB。内核需要:
1. 将原 VMA 分裂为 [0x1000, 0x2000) 和 [0x3000, 0x5000) 两个 VMA。
2. 在中间 [0x2000, 0x3000) 区域插入一个空的 VMA 或直接删除该段。
// 分裂 VMA 核心逻辑(mm/mmap.c)
int split_vma(struct mm_struct *mm, struct vm_area_struct *vma,
              unsigned long addr, int new_below)
{
    struct vm_area_struct *new_vma;

    // 1. 分配新的 VMA 结构
    new_vma = vm_area_dup(vma);
    if (!new_vma)
        return -ENOMEM;

    if (new_below) {
        // 分裂点在下方:新 VMA 在低地址
        new_vma->vm_end = addr;
        vma->vm_start = addr;
    } else {
        // 分裂点在上方:新 VMA 在高地址
        new_vma->vm_start = addr;
        vma->vm_end = addr;
    }

    // 2. 调整页偏移(文件映射需要)
    if (vma->vm_file)
        new_vma->vm_pgoff += (new_vma->vm_start - vma->vm_start) >> PAGE_SHIFT;

    // 3. 将新 VMA 插入链表
    vma_link(mm, new_vma, vma, NULL, NULL);
    return 0;
}
VMA 分裂前后对比(split_vma) 分裂前: 单个 VMA [0x1000, 0x5000) ▼ munmap(0x2000, 0x1000) 分裂后: VMA1 [0x1000, 0x2000) 已释放 VMA2 [0x3000, 0x5000) 分裂后 VMA 数量 从 1 变为 2

4.5 四大操作的综合调用关系

在实际系统调用中,查找、插入、合并、分裂四个操作往往协同工作。以 mmap 系统调用为例,其内部流程如下:

步骤 操作 调用的 API 说明
1 查找空闲区间 get_unmapped_area 在地址空间中寻找足够大的空洞
2 检查重叠 find_vma_intersection 确保新区间不与现有 VMA 冲突
3 尝试合并 vma_merge 如果新区间与相邻 VMA 兼容,直接扩展
4 插入新 VMA insert_vm_struct 合并失败时,创建并插入新 VMA
5 更新页表 mmap_region 后续处理 建立虚拟地址到物理页的映射
内核调试技巧: 通过 /proc/<pid>/maps 可以观察 VMA 合并的效果。如果连续多次 mmap 相同属性的区域,内核会合并为一个 VMA 条目;如果属性不同,则会显示为多个独立条目。