3、虚拟内存区域 VMA:vm_area_struct 结构体、VMA 链表与红黑树组织、VMA 的查找与遍历

虚拟内存区域(VMA,Virtual Memory Area)是 Linux 内核管理进程地址空间的核心抽象。每个 VMA 描述一段连续的、具有相同权限和映射属性的虚拟地址区间。进程的整个用户空间由若干 VMA 拼接而成,内核通过 vm_area_struct 结构体来刻画每一个区域。

3.1 vm_area_struct 结构体

vm_area_struct 是 VMA 在内核中的具体表示,定义在 include/linux/mm_types.h 中。它包含了 VMA 的起始地址、结束地址、访问权限、标志位、关联的文件映射以及用于组织管理的链表/红黑树节点。

字段 类型 说明
vm_start unsigned long VMA 的起始虚拟地址(包含)
vm_end unsigned long VMA 的结束虚拟地址(不包含)
vm_flags unsigned long VMA 的权限与属性标志位(如读、写、执行、共享、匿名等)
vm_page_prot pgprot_t 页表项使用的物理页保护位(由 vm_flags 转换而来)
vm_file struct file * 映射的文件对象指针(匿名映射为 NULL)
vm_pgoff unsigned long 文件映射的页偏移量(以 PAGE_SIZE 为单位)
vm_ops const struct vm_operations_struct * VMA 操作函数表(open、close、fault 等)
vm_next / vm_prev struct vm_area_struct * 双向链表指针,用于按地址顺序串联所有 VMA
vm_rb struct rb_node 红黑树节点,用于快速查找
rb_subtree_gap unsigned long 子树中最大空闲间隙(用于 VMA 合并与分配优化)
vm_mm struct mm_struct * 指向所属进程的内存描述符
关键标志位(vm_flags 常用值):
  • VM_READ (0x01) — 可读
  • VM_WRITE (0x02) — 可写
  • VM_EXEC (0x04) — 可执行
  • VM_SHARED (0x08) — 共享映射
  • VM_GROWSDOWN (0x100) — 可向下扩展(如栈)
  • VM_ANON (0x800) — 匿名映射(无文件关联)

3.2 VMA 的组织结构:链表与红黑树

每个进程的 mm_struct 中维护了两种 VMA 组织方式:双向链表用于顺序遍历,红黑树用于高效查找。两者共享同一组 vm_area_struct 实例,通过不同的指针字段链接。

双结构设计的原因:
  • 链表:按 vm_start 升序排列,适合遍历全部 VMA(如 /proc/<pid>/maps 读取、fork 时复制)。
  • 红黑树:自平衡二叉搜索树,查找、插入、删除的时间复杂度为 O(log n),适合按虚拟地址快速定位 VMA(如缺页异常处理)。

下图展示了 mm_struct 与 VMA 链表、红黑树之间的关系:

mm_struct 与 VMA 组织关系 mm_struct mmap (链表头) / mm_rb (红黑树根) VMA 1 0x400000 - 0x401000 vm_next → VMA 2 VMA 2 0x401000 - 0x600000 vm_next → VMA 3 VMA 3 0x600000 - 0x601000 vm_next → NULL 红黑树结构(按 vm_start 排序) VMA 2 (根) VMA 1 (左) VMA 3 (右) 链表节点 红黑树节点
一致性维护:链表和红黑树必须始终保持一致。内核在插入或删除 VMA 时,会同时更新 vm_next/vm_prev 指针和红黑树节点。任何不一致都会导致查找错误或内核崩溃。

3.3 VMA 的查找与遍历

3.3.1 按虚拟地址查找 VMA

最常用的查找函数是 find_vma(),它通过红黑树在 O(log n) 时间内找到第一个 vm_end > addr 的 VMA。内核源码实现如下:

/* mm/mmap.c */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
    struct rb_node *rb_node;
    struct vm_area_struct *vma;

    /* 先检查缓存(last_vma) */
    vma = vma_lookup(mm, addr);
    if (vma)
        return vma;

    /* 红黑树查找 */
    rb_node = mm->mm_rb.rb_node;
    vma = NULL;

    while (rb_node) {
        struct vm_area_struct *tmp = rb_entry(rb_node,
                        struct vm_area_struct, vm_rb);

        if (tmp->vm_end > addr) {
            vma = tmp;
            if (tmp->vm_start <= addr)
                break;
            rb_node = rb_node->rb_left;
        } else {
            rb_node = rb_node->rb_right;
        }
    }

    if (vma)
        mm->mmap_last_vma = vma;  /* 更新缓存 */
    return vma;
}
查找优化:内核在 mm_struct 中维护了 mmap_last_vma 缓存,记录上一次查找命中的 VMA。由于缺页异常和访问通常具有空间局部性,缓存命中率很高,可避免频繁的红黑树遍历。

3.3.2 遍历所有 VMA

遍历 VMA 通常使用链表,因为顺序访问不需要搜索特性。内核提供了 vma_next() 宏和 for_each_vma() 遍历宏:

/* 使用链表遍历所有 VMA */
struct vm_area_struct *vma;

for (vma = mm->mmap; vma; vma = vma->vm_next) {
    /* 处理每个 VMA */
    printk("VMA: 0x%lx - 0x%lx, flags: 0x%lx\n",
           vma->vm_start, vma->vm_end, vma->vm_flags);
}

/* 内核提供的遍历宏(include/linux/mm.h) */
#define for_each_vma(vmi, vma) \
    while ((vma = vma_iter_next(vmi)) != NULL)

3.3.3 查找与遍历的对比

操作 数据结构 时间复杂度 典型场景
按地址查找单个 VMA 红黑树 O(log n) 缺页异常、权限检查、地址合法性校验
遍历全部 VMA 双向链表 O(n) 进程地址空间转储(/proc/<pid>/maps)、fork 时复制、mremap 合并
查找空闲地址区间 红黑树 + rb_subtree_gap O(log n) mmap 分配新 VMA 时寻找合适空洞

3.4 VMA 操作流程示例

下图展示了缺页异常发生时,内核如何通过红黑树查找 VMA 并判断访问权限:

缺页异常 VMA 查找流程 1. CPU 触发缺页异常 地址 addr 不在页表中 2. 调用 find_vma(mm, addr) 红黑树查找 3. 找到 VMA? vm_end > addr 未找到 4. 发送 SIGSEGV 信号 非法地址访问 找到 5. 检查 vm_flags 权限 是否允许本次访问? 权限不足 6. 发送 SIGSEGV 段错误 权限通过 7. 调用 vma->vm_ops->fault 分配物理页并建立映射

3.5 VMA 的合并与分裂

当新的 VMA 插入或现有 VMA 的属性发生变化时,内核会尝试与相邻 VMA 进行合并,以减少 VMA 数量、降低管理开销。合并条件如下:

  • 两个 VMA 的 vm_start 和 vm_end 地址连续(无间隙)。
  • 两个 VMA 的 vm_flags 完全相同。
  • 两个 VMA 的 vm_file 指向同一个文件(或均为匿名映射)。
  • 两个 VMA 的 vm_pgoff 连续(文件映射时)。
  • 两个 VMA 的 vm_ops 相同。
合并示例:假设进程已有 VMA [0x400000, 0x401000) 和 [0x401000, 0x402000),两者权限相同且均为匿名映射。当内核检测到这种连续性时,会将它们合并为一个 VMA [0x400000, 0x402000),并释放第二个 VMA 结构体。

分裂操作则发生在 mprotect、munmap 等系统调用需要改变部分 VMA 属性时。内核将原 VMA 拆分为两个或三个独立的 VMA,分别应用不同的权限或映射策略。

3.6 实战:通过 /proc/<pid>/maps 观察 VMA

用户态可以通过 /proc/<pid>/maps 文件查看进程的 VMA 布局。内核在读取该文件时,正是通过遍历 mm->mmap 链表来逐行输出的:

$ cat /proc/self/maps
00400000-00452000 r-xp 00000000 08:01 1234567    /usr/bin/cat
00651000-00652000 r--p 00051000 08:01 1234567    /usr/bin/cat
00652000-00653000 rw-p 00052000 08:01 1234567    /usr/bin/cat
7f8c1a200000-7f8c1a3b1000 r-xp 00000000 08:01 7654321    /usr/lib/libc-2.31.so
7f8c1a5b0000-7f8c1a5b1000 r--p 001b0000 08:01 7654321    /usr/lib/libc-2.31.so
7f8c1a5b1000-7f8c1a5b4000 rw-p 001b1000 08:01 7654321    /usr/lib/libc-2.31.so
7ffd8e200000-7ffd8e421000 rw-p 00000000 00:00 0          [stack]
7ffd8e5e8000-7ffd8e5eb000 r--p 00000000 00:00 0          [vvar]
7ffd8e5eb000-7ffd8e5ed000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0  [vsyscall]

每一行对应一个 VMA,格式为:起始地址-结束地址 权限 偏移 主设备号:次设备号 inode 路径。权限字段的 r/w/x/p/s 分别对应 VM_READ、VM_WRITE、VM_EXEC、私有映射 (VM_SHARED 未设置) 和共享映射 (VM_SHARED 已设置)。

调试技巧:使用 strace -e mmap,munmap,mprotect 跟踪程序的 VMA 操作,结合 /proc/<pid>/maps 观察 VMA 的创建、删除和权限变化,是理解 VMA 管理最直观的实战方法。