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(读或写),否则可能因并发修改导致空指针或越界访问。
4.2 VMA 插入:vma_link 与 insert_vm_struct
当进程通过 mmap 创建新的映射区域时,内核需要将新 VMA 插入到进程的 VMA 链表和红黑树中。插入操作的核心函数是 vma_link,它封装了三个步骤:
- 检查重叠: 调用
find_vma_intersection确认新区间不与现有 VMA 重叠。 - 定位插入点: 使用
find_vma_prev找到插入位置的前驱 VMA。 - 执行插入: 调用
__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 之间的空隙 |
4.4 VMA 分裂:split_vma 的精确切割
当 munmap 或 mprotect 只作用于某个 VMA 的部分区域时,内核必须将原 VMA 分裂 为两个或三个独立的 VMA。分裂操作由 split_vma 函数实现。
分裂场景举例: 假设有一个 VMA 覆盖地址范围 [0x1000, 0x5000),用户调用
1. 将原 VMA 分裂为 [0x1000, 0x2000) 和 [0x3000, 0x5000) 两个 VMA。
2. 在中间 [0x2000, 0x3000) 区域插入一个空的 VMA 或直接删除该段。
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;
}
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 条目;如果属性不同,则会显示为多个独立条目。