VMA 与 Maple Tree:地址范围如何建立、变化与并发访问

本页目录

只把三页映射的中间一页改成只读,maps 里却可能多出两段;解除中页后,留下的空洞又与 PROT_NONE 不同。下面围绕同一段三页地址空间⁠,追踪范围如何变化,再解释并发查找如何保护这些变化。

要解释的现象阅读位置
mmap 成功为何仍会缺页?第 1 节:VMA、页表与后备内容
改权限、解除映射、移动地址各改变什么?第 2~3 节:三页推演与实验
Maple Tree 快在哪,不能省掉哪些工作?第 4 节:范围查询
刚找到 VMA,另一线程就 munmap 怎么办?第 5~6 节:同步与缺页解释

1. VMA 描述范围,页表描述范围内的当前翻译

一个普通 VMA 覆盖半开区间 [vm_start, vm_end),包含起点而不包含终点。同一 mm_struct 的正常 VMA 不互相重叠;共享地址空间的线程通常共同引用该 mm,所以一个线程的 mprotect 会改变其他线程所见的映射权限。

手算:VMA 中的地址对应文件哪里

设基础页为 P,映射从地址 A 开始,vm_pgoff=8。对地址 A + 2P + 17,分两步定位:

  1. 映射从文件第 8 个基础页开始,再前进 2 页,得到文件第 10 页。
  2. 保留页内偏移 17,文件字节偏移为 10P + 17。

vm_pgoff 的单位是基础页,不是字节;它也不随这个文件描述符之后的 read/seek 推进。

层次此例中记录什么没有直接回答什么
VMA范围 A 起、映射文件、起始文件偏移、共享/私有和权限每页是否驻留、物理页号是多少
页表已建立的虚拟页到物理页/软件状态的映射未来未触碰地址应从哪类后端获取内容
文件页缓存文件各范围当前在内存中的内容哪些进程都已安装了对应 PTE
rmap 等结构从页或后备对象反查可能关联的映射不能单独替代地址空间正向查找

一个文件 MAP_PRIVATE VMA 在写入后可以同时覆盖未改动的文件缓存页和已经分离的匿名 COW 页,因此 vm_file != NULL 不等于“每个脏页都会回写这个文件”。普通共享匿名映射在内核也可能由 shmem 文件对象支撑,用户标志 MAP_ANONYMOUS 与内部 vm_file 是否为空不是简单一一对应。

2. 用三页映射推演四个操作

同一段三页映射:权限改变先改变区间描述 初始 中页只读 中页解除映射 [A, A + 3P) rw-p 一个兼容的地址范围 rw-p r--p rw-p rw-p 空洞:无 VMA rw-p P 为基础页大小。虚拟地址连续不代表物理连续;一次 syscall 不必对应一个永久 VMA。

mmap:先在地址空间中建立承诺

普通的非固定 mmap 可以提供地址提示,内核在不覆盖现有映射的前提下选择可用范围,完成权限、文件能力、偏移、资源限制等检查,并建立映射关系。新区域若与邻居满足合并条件,可能直接扩大已有 VMA,所以“一次 mmap 创建一个可永久识别的 VMA 对象”并不成立。

MAP_FIXED 的意义不同:它要求使用指定范围,并可替换已有映射。它不是普通提示失败后的无害强化版。若目标是“只有这段地址完全空闲才占用”,应了解 MAP_FIXED_NOREPLACE 及返回地址检查;不要先读 maps 看见空洞,再用 MAP_FIXED 假定期间无人占用。

通常数据页按需建立,但 MAP_POPULATE、锁定/预取要求和设备映射等路径可能做额外工作。文件 mmap 的建立经 file_operations 的相关接口接入,vm_operations_struct 的 fault 等回调用于之后的映射操作;不能发明一个通用 vm_ops->mmap() 并宣称所有文件都调用它。见 mmap(2)。

mprotect:为什么只改一页,却多了两个描述对象

假设 [A,A+3P) 原来可读写。把 [A+P,A+2P) 改为只读后,这三段已经不具有相同属性,原来一个 VMA 无法忠实描述它们,需要拆成前、中、后三段。内核还要对已存在的页表应用权限变化,并按要求维护 TLB;没有驻留页的部分则由新的 VMA 语义约束未来缺页。

这解释了一个看似反常的失败:即使没请求增加数据内存,mprotect 也可能因需要更多 VMA 元数据、超过映射数量限制而失败。仅“改 PTE 一个 bit”无法解释这个现象。将中页恢复读写后,兼容的相邻段可能重新合并,但合并还要检查后备对象、偏移连续性、匿名反向映射关系和其他属性,不能只比较 maps 中显示的 rw-p 四个字符。见 mprotect(2)。

munmap:解除的是映射,不必销毁数据本身

解除中页后,中间成为真正空洞。与仅禁止访问相比,它改变了地址空间的占用关系:

操作中间范围仍有映射语义吗?地址空间是否仍被占据?
改成 PROT_NONE有,只是当前禁止访问是
munmap 中页没有,成为空洞否,可被后续分配占用

munmap 还要撤销翻译并释放相应引用;这一步不直接决定数据页的最终寿命。

如果页被另一个进程共享,或普通文件页还在页缓存中,解除本进程映射并不把它直接变成 buddy 空闲页。反过来,物理页已被回收换出时,合法 VMA 仍可存在。VMA 寿命、页表项寿命和数据页寿命必须分别推理。

mremap:地址可变,数据不必逐字节复制

扩大映射时,邻接范围若无法使用,允许移动的 mremap 可以选择新位置并搬移页表映射等状态。它能够避免某些用户数据复制,但仍有元数据、页表和失效成本,也不是所有映射都可随意移动。成功移动后,旧地址及其中保存的绝对自引用指针都需要按应用设计处理;“内容还在”不等于“原来的所有指针仍有效”。

把三种寿命放在同一画面中:先读取中央页,再解除它的映射,看看哪些范围、PTE 和缓存真的消失。恢复映射与恢复访问权限也可以分别操作。

这条式子用于该 VMA 内的文件地址;a 必须先满足范围与访问条件,空洞不参与计算。

正在呈现知识画面
改一页权限,哪些对象会一起变化?

mprotect 可以拆分范围,却不必分配数据页。PROT_NONE 仍占据虚拟范围,munmap 留下空洞;撤销映射也不必丢弃文件缓存。

3. 可运行实验:直接观察这几个边界

下面保留五页 PROT_NONE 空间,用中间三页作实验,两边作为地址边界。每一步读取自身 maps 验证指定地址的属性;不访问只读页的写入地址,也不制造进程崩溃。

#define _GNU_SOURCE
#include <assert.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

static int permission(uintptr_t address, char output[5]) {
    FILE *file = fopen("/proc/self/maps", "r");
    assert(file != NULL);
    char line[1024];
    int found = 0;
    while (fgets(line, sizeof(line), file)) {
        uintptr_t start, end;
        char mode[5];
        if (sscanf(line, "%" SCNxPTR "-%" SCNxPTR " %4s",
                   &start, &end, mode) == 3 &&
            start <= address && address < end) {
            memcpy(output, mode, 5);
            found = 1;
            break;
        }
    }
    assert(fclose(file) == 0);
    return found;
}
static void check(unsigned char *base, size_t page,
                  const char *middle) {
    for (size_t i = 0; i < 3; ++i) {
        char mode[5] = "";
        int found = permission((uintptr_t)(base + i * page), mode);
        const char *expected = i == 1 ? middle : "rw-p";
        if (expected) {
            assert(found && strcmp(mode, expected) == 0);
        } else {
            assert(!found);
        }
        printf("page %zu: %s\n", i, found ? mode : "hole");
    }
}
int main(void) {
    long size = sysconf(_SC_PAGESIZE);
    assert(size > 0);
    size_t page = (size_t)size;
    unsigned char *whole = mmap(NULL, 5 * page, PROT_NONE,
                               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    assert(whole != MAP_FAILED);
    unsigned char *base = whole + page;
    assert(mprotect(base, 3 * page, PROT_READ | PROT_WRITE) == 0);
    puts("初始:"); check(base, page, "rw-p");
    assert(mprotect(base + page, page, PROT_READ) == 0);
    puts("中页只读:"); check(base, page, "r--p");
    assert(mprotect(base + page, page, PROT_READ | PROT_WRITE) == 0);
    puts("恢复权限:"); check(base, page, "rw-p");
    assert(munmap(base + page, page) == 0);
    puts("中页解除映射:"); check(base, page, NULL);
    assert(munmap(whole, 2 * page) == 0);
    assert(munmap(whole + 3 * page, 2 * page) == 0);
}

以 cc -std=c11 -Wall -Wextra -Werror vma-demo.c -o vma-demo 编译,不加 -DNDEBUG。检查按地址定位,不假定实际 VMA 边界完全等于 mmap 请求边界,也不强行断言恢复权限后内核一定选择某一种合并布局。maps 呈现的是 VMA 属性,无法据此断言中间三页已经实际分配了物理页。

4. Maple Tree 解决的是范围组织问题

地址空间需要回答多种查询:这个地址落在哪个映射中,下一段映射在哪里,某个区间与哪些 VMA 相交,以及哪里有足够大的空洞放置新映射。旧实现用红黑树和链表协作;Maple Tree 用面向范围的多分支结构及迭代接口承担这些工作,在减少树高、改进局部性和维护范围信息方面提供收益。

先明确查询要返回多少结果,再讨论树的收益:

查询至少要完成的工作
查一个地址沿范围边界定位候选
输出 K 个交叠 VMA找到起点后,还需输出 K 个对象
遍历全部 N 个 VMA至少输出 N 项,不能变成 O(log N)

Maple Tree 改善树高、局部性与范围组织;跨越大量映射的删除和遍历,仍要支付与涉及对象规模相关的工作。

范围索引:先选分支,再取得覆盖范围中的 VMA 内部节点:按边界划分地址范围 多个子槽减少树高;空洞信息支持地址分配 [0x1000, 0x3fff] VMA A 指针 [0x4000, 0x7fff] 空洞 [0x8000, 0xafff] VMA B 指针 图是范围检索模型,不是实际节点内存布局;树查到指针后仍需保证 VMA 寿命与稳定性。

VMA 使用半开区间,Maple Tree 范围接口使用含末端的索引范围时,会相应表达为 [vm_start, vm_end-1]。值是 VMA 指针,范围边界用于决定搜索方向。节点类型、槽数和 pivot 布局属于实现细节;树不是像页表一样每一级固定消费地址的某 9 位,也不能按页表层次给 Maple Tree 发明固定地址覆盖表。

存储空洞信息让分配搜索有机会跳过不可能容纳请求的子树,而不必依次试每个字节。例如需要 4P 连续虚拟空间时,总空闲 8P 仍可能由多个 1P 空洞组成;范围索引需要寻找足够长的单段。这是虚拟地址碎片⁠,与 buddy 找不到连续物理页的碎片属于不同问题。见 Maple Tree 设计与 API。

“找到后继”不等于“地址已被覆盖”

一些查找接口允许在空洞中返回下一个 VMA。以 find_vma(mm, address) 的常见契约为例,结果可为第一个 vm_end > address 的 VMA;若 address < vm_start,地址仍在空洞中。调用者必须按接口语义再判定覆盖,不能只判断返回非空。只需要精确覆盖时,应选择对应接口并遵守其锁定要求。

把探针停在区间末端和空洞中,比较“返回了哪个候选”与“候选是否覆盖”。结束边界不属于原范围。

正在呈现知识画面
找到 VMA 指针,地址就一定有效吗?

find_vma 风格查询可以在空洞中返回后继。半开范围的覆盖还要求 start ≤ address < end;返回非空不等于访问合法。

5. 读树、锁 VMA 与锁页表为什么不能合成一把锁

考虑线程 R 在地址 A 缺页,线程 W 同时对 A 执行 munmap。R 从树中得到 VMA 指针后,若 W 把范围摘除并回收对象,R 再访问字段就可能 use-after-free。即使 RCU 延迟了对象回收,R 读到的起点、终点、后备对象也仍需组成允许当前缺页处理的稳定状态。

三层保护,各自稳定不同对象

同步层主要保护什么不能据此推断什么
mmap_lockmm 级范围关系及相关操作不自动代替所有页表修改协议
RCU 查找与 per-VMA 锁支持路径中的候选查找、VMA 状态稳定不保证对象内容永远不变
页表锁PTE/PMD 等实际修改不单独解决范围撤销和对象寿命

rmap 相关锁还服务于从页反查映射。支持的缺页路径可尝试 per-VMA 方案,无法建立保证时回到更广泛的协调路径;获得一层读锁不自动授予另一层写权限。

竞争发生时:验证失败就重试

读者 R 先找到候选,尝试稳定 VMA 并验证地址;写者 W 在撤销范围前按协议取得排他权。R 若无法建立所需保证,就重试或采用更保守的 mm 级锁路径。

耗时 I/O 还可能要求释放、重取锁并重新验证。等待期间,原先成立的“地址仍在这个 VMA 内”可能已经改变,不能拿旧查找结果直接继续。

Maple Tree 的 RCU 能力只解决其中一部分:遍历节点的并发安全不自动证明数据对象长期不变,也不意味着所有写者都复制整棵树再替换。当前支持的架构已经有 per-VMA 缺页路径,不能继续写成遥远未来;但也不能反过来宣称 mmap_lock 已经消失。锁契约见 Process Addresses,目标内核可从 lock_vma_under_rcu() 等入口定位。

6. 最后把缺页处理接回这个模型

同样是写保护异常,处理结果取决于软件语义:

当前状态对这次写入的解释
VMA 允许写,PTE 因 COW 暂时只读可以进入私有页处理路径
mprotect 已把 VMA 改成只读不能用 COW 绕过禁止写入的语义

硬件报告现象,VMA 与后备状态决定解释。handle_mm_fault() 因而不是“找到范围就分配一页”的统一动作。

即使 VMA 合法,文件映射访问超出后端文件有效范围也可能产生 SIGBUS;分配失败、特殊映射和换入也有各自处理结果。handle_mm_fault() 因而不能概括为“找到 VMA 就分配一个新页”。可能复用页缓存、处理换入、分离私有页,或按操作契约拒绝访问。

排查时先用 maps 判断范围和权限,再用 smaps/smaps_rollup 区分驻留与共享占用,最后结合 fault、I/O 与内存压力定位成本。大量 mmap/mprotect 可能增加 VMA 数和元数据开销;但 maps 很长并不独自证明它是性能瓶颈。物理分配侧的问题接 物理内存管理,驻留页如何被回收接 页面回收与交换。