Linux 地址空间与 mremap:从页表、VMA 到 MREMAP_DONTUNMAP

导读

mremap 是 Linux 系统提供的一个内存管理系统调用,用于调整(扩大、缩小或移动)已有的内存映射区域,它在 mmap 建立映射之后使用,能避免重新映射带来的数据拷贝开销。mremap 在动态数组/分配器扩容场景中很常见。例如一段以 mmap 映射的连续内存需要追加空间时,调用 mremap(..., MREMAP_MAYMOVE) 可让内核尝试原地扩展或高效迁移,避免了显式的数据拷贝。它也是内存管理相关实现(如某些自定义分配器、部分运行时库)用来优化大块内存重分配的底层手段之一。

mremap 看起来只是"把一段映射搬到另一个地址",但它搬完之后新旧两个地址看到的数据是否一致、会产生几个 VMA、对性能有什么影响,全都取决于更底层的东西:页表和 VMA 的分工、缺页时物理页从哪里来、VMA 什么时候能合并。

为了内容的自成一个体系,因此本文分两部分:

  • 第一部分(§1--§8) 建立地址空间的基础模型,得出三个关键结论;
  • 第二部分(§9--§16) 用这三个结论逐步拆解 mremap,特别是 MREMAP_FIXED 和 MREMAP_DONTUNMAP。

如果有些细节不清楚,可以参考本专栏的其他文章。

本文回答的问题

# 问题 位置
1 CPU 访问一个虚拟地址时,硬件和内核分别做了什么?VMA 扮演什么角色? §1
2 MAP_SHARED / MAP_PRIVATE × 匿名 / 文件,四种映射在缺页时物理页从哪里来? §2--§4
3 什么条件下,两个不同的虚拟地址会映射到同一个物理页? §5
4 两个相邻 VMA 什么时候能合并?THP 对 VMA 有什么要求? §6--§7
5 mremap 到底搬了什么?会不会复制数据? §10、§13
6 MREMAP_FIXED 对目标地址做了什么?新映射的属性从哪里来? §11--§12
7 MREMAP_DONTUNMAP 之后,新旧两个 VA 看到的是不是同一份数据? §14
8 循环多次 mremap 把离散段拼到一段连续 VA 上,最后 VMA 是什么形状?代价是什么? §15--§16
9 哪些情况内核会直接拒绝? §8、§9

第一部分:地址空间基础

1. 三层模型:VA → 页表 → 物理页

要点:

  • 页表(PTE/PMD/PUD) 是硬件可见的数据结构,决定 VA → PA 的映射。
  • TLB 是页表的缓存。
  • VMA(struct vm_area_struct) 是纯软件结构,描述"这一段虚拟地址应该 映射什么"。硬件从不读 VMA。
  • VMA 只在以下场景被使用:缺页处理、mmap/munmap/mremap/mprotect 等系统调用、/proc/<pid>/maps、GUP/HMM 等内核遍历用户页的路径。

推论:已经建立好页表之后,VMA 的数量和形状不影响正常访存性能。它只影响慢路径(缺页、系统调用),以及间接影响能否使用大页(见 §7)。


2. VMA 的关键字段

字段 含义
vm_start / vm_end 这段 VA 的范围 [start, end)
vm_flags 权限与属性:VM_READ/WRITE/EXEC、VM_SHARED、VM_MAYSHARE、VM_LOCKED、VM_IO、VM_PFNMAP、VM_DONTEXPAND、VM_ACCOUNT 等
vm_file 后备文件(文件映射、memfd、共享匿名都有;私有匿名为 NULL)
vm_pgoff vm_start 对应的文件页偏移(单位:页)。文件映射里是真实偏移;私有匿名里是创建时的 vm_start >> PAGE_SHIFT,用于反向映射
anon_vma 私有匿名页(以及私有文件映射里 COW 出来的页)的反向映射根
vm_ops 缺页等回调(->fault、->mremap 等)

一个重要的不变式:VMA 内地址与"后备对象偏移"是线性关系:

pgoff ( a d d r ) = vm_pgoff + a d d r − vm_start PAGE_SIZE \text{pgoff}(addr) = \text{vm\_pgoff} + \frac{addr - \text{vm\_start}}{\text{PAGE\_SIZE}} pgoff(addr)=vm_pgoff+PAGE_SIZEaddr−vm_start

缺页时内核正是用这个公式,在 vm_file 的 page cache 里找到对应的页。这个公式在后面会反复出现:VMA 合并(§6)要求合并后仍满足它,mremap 创建新 VMA(§12)也要按它计算新的 vm_pgoff。

mmap 时 VMA 的类型如何确定

mm/vma.c:

c 复制代码
if (map->file)
        error = __mmap_new_file_vma(map, vma);   /* 文件 / memfd */
else if (map->vm_flags & VM_SHARED)
        error = shmem_zero_setup(vma);           /* MAP_SHARED|MAP_ANONYMOUS */
else
        vma_set_anonymous(vma);                  /* MAP_PRIVATE|MAP_ANONYMOUS */

注意第二行:MAP_SHARED | MAP_ANONYMOUS 在内核里并不是"匿名"的 ,它通过 shmem_zero_setup 创建一个 shmem 内部文件,此后行为和 memfd 一样,属于"文件映射"。


3. 缺页:物理页从哪里来

入口 do_pte_missing:

c 复制代码
if (vma_is_anonymous(vmf->vma))
        return do_anonymous_page(vmf);   /* 只有 MAP_PRIVATE|MAP_ANONYMOUS 走这里 */
else
        return do_fault(vmf);            /* 文件、memfd、共享匿名(shmem) */

do_fault 再按读写与 VM_SHARED 分三路:

c 复制代码
else if (!(vmf->flags & FAULT_FLAG_WRITE))
        ret = do_read_fault(vmf);        /* 读:映射 page cache 页 */
else if (!(vma->vm_flags & VM_SHARED))
        ret = do_cow_fault(vmf);         /* 私有写:复制一份匿名页 */
else
        ret = do_shared_fault(vmf);      /* 共享写:直接写 page cache 页 */

4. 四种映射的对比

根据是否共享和匿名,组合成了四种典型的映射,总结如下:

映射 内核类型 缺页时物理页来源 写入的去向 物理页的"身份"
**A. `MAP_PRIVATE MAP_ANONYMOUS`** 匿名 新分配的零页 该私有页
**B. `MAP_SHARED MAP_ANONYMOUS`** shmem 文件 shmem page cache 直接写 page cache
C. MAP_SHARED + 文件/memfd 文件 文件 page cache 直接写 page cache (inode, pgoff)
D. MAP_PRIVATE + 文件/memfd 文件 读:page cache;写:COW 出私有页 写入私有副本,文件不变 未写:(inode, pgoff);写过:私有匿名页

后文用 A、B、C、D 指代这四种映射。


5. 多个 VA 映射同一物理页的条件

核心结论:只有当物理页拥有可被重新查到的"身份"(inode + pgoff)时,多个 VA 才能稳定地共享它。

  • B、C(共享映射) :任何 VMA,只要 vm_file 相同、计算出的 pgoff 相同,缺页时都会在 page cache 中找到同一个页。
    因此:对同一 memfd 多次 mmap(MAP_SHARED),或在不同进程间共享 shm,都天然是同一批物理页。
  • A(私有匿名) :页没有文件身份,缺页只会分配新页。
    唯一的"共享"是 fork() 后的 COW 共享,且第一次写就会分裂。
  • D(私有文件):未写过的页暂时共享 page cache;一旦任何一方写入就 COW 成私有页,从此分离。

小实验

examples/share_demo.c:对同一个 memfd 建立两个 MAP_SHARED 映射 a、b 和一个 MAP_PRIVATE 映射 c。

复制代码
X X      ← a 写入后,b 和未写过的 c 都读到 page cache 中的同一页
Z Y      ← c 写入触发 COW 后,a 再写只有 b 能看到,c 已与 page cache 分离

6. VMA 合并

内核总是尽量把相邻、属性一致的 VMA 合并,以减少 VMA 数量。判断逻辑见 mm/vma.c:

  1. is_mergeable_vma:

    • vm_flags 相同(忽略少数无关位)
    • vm_file 相同
    • NUMA mempolicy、userfaultfd 上下文、anon_vma_name 相同
  2. is_mergeable_anon_vma:两边的 anon_vma 兼容(相同,或一方为空且不是 fork 子进程的情形)

  3. can_vma_merge_before / after:vm_pgoff 必须线性连续

    c 复制代码
    if (vmg->prev->vm_pgoff + vma_pages(vmg->prev) == vmg->pgoff)   /* 与前一个合并 */
    if (vmg->next->vm_pgoff == vmg->pgoff + pglen)                  /* 与后一个合并 */

第 3 条的含义:合并后整个 VMA 仍要满足 §2 的线性公式。也就是说,只有"VA 连续且后备对象偏移也连续"的两段才能合并。

场景 能否合并
同一 memfd,VA 相邻,文件偏移也相邻 能
同一 memfd,VA 相邻,但文件偏移不连续(离散页拼在一起) 不能
一个 memfd + 一个私有匿名 不能(vm_file 不同)
权限不同(一个 RW,一个 RO) 不能

7. 透明大页(THP)与 VMA

PMD 级大页(x86-64 上 2 MiB)能否用于某个地址,由 thp_vma_suitable_order 判断:

c 复制代码
/* 非匿名 VMA:VA 与文件偏移必须按大页对齐 */
if (!vma_is_anonymous(vma)) {
        if (!IS_ALIGNED((vma->vm_start >> PAGE_SHIFT) - vma->vm_pgoff,
                        hpage_size >> PAGE_SHIFT))
                return false;
}
/* 对齐后的 2M 区间必须完整落在同一个 VMA 内 */
haddr = ALIGN_DOWN(addr, hpage_size);
if (haddr < vma->vm_start || haddr + hpage_size > vma->vm_end)
        return false;

所以:

  • VMA 被切成小于 2 MiB 的碎片 → 该区间只能用 4 KiB PTE 映射。
  • 对 memfd/shmem 来说,还要求 VA - pgoff 按 2 MiB 对齐。
  • 大页映射可以显著减少 TLB miss。这是 VMA 形状间接影响访存性能的主要途径。

8. 特殊映射:VM_PFNMAP / VM_IO

设备 mmap(如 /dev/mem、GPU 显存 BAR)通常带有 VM_PFNMAP | VM_IO | VM_DONTEXPAND:

  • PTE 直接指向 PFN,可能没有对应的 struct page,或不应被内核当作普通页管理;
  • GUP / HMM 等需要 struct page 的路径会拒绝或受限;
  • mremap 的部分功能被禁止(见 §9)。

排查时可在 /proc/<pid>/smaps 中查看对应映射的 VmFlags 行(pf 表示 VM_PFNMAP,io 表示 VM_IO,de 表示 VM_DONTEXPAND,sh 表示 VM_SHARED)。


承上启下:带入第二部分的三个结论

第二部分分析 mremap 时,会反复用到第一部分的三个结论:

  1. 硬件只看页表 (§1):mremap 只要搬动页表项,就能让新地址指向原来的物理页,不需要复制数据。
  2. 共享靠 page cache,不靠映射手段(§4--§5):一个 VA 的 PTE 被搬走之后,它再次缺页能否找回原来的物理页,只取决于映射类型是 A、B、C 还是 D。
  3. VMA 合并要求 vm_pgoff 连续 (§6--§7):把离散段拼到连续 VA 上,VA 连续了,但 vm_pgoff 通常不连续,因此 VMA 合并不了,也影响 THP。

第二部分:mremap

9. 接口与参数约束

c 复制代码
void *mremap(void *old_address, size_t old_size, size_t new_size,
             int flags, ... /* void *new_address */);
flag 含义
MREMAP_MAYMOVE 允许把映射搬到新地址
MREMAP_FIXED 必须搬到 new_address(需同时带 MAYMOVE)
MREMAP_DONTUNMAP 搬移后保留 源 VMA(需带 MAYMOVE,且 old_size == new_size)

参数检查见 check_mremap_params 与 check_prep_vma,几个容易踩的点:

  • 一次 mremap 不能跨越源 VMA 的末尾:

    c 复制代码
    if (old_len > vma->vm_end - addr)
            return -EFAULT;

    如果要搬的源区间由多个 VMA 组成,必须分段调用。

  • MREMAP_DONTUNMAP 不支持 VM_PFNMAP / VM_DONTEXPAND(即 §8 的特殊映射):

    c 复制代码
    if ((vrm->flags & MREMAP_DONTUNMAP) &&
                    (vma->vm_flags & (VM_DONTEXPAND | VM_PFNMAP)))
            return -EINVAL;
  • old_size == 0(复制映射)只允许共享映射,这与 §5 的结论一致------只有共享映射才能被另一个 VA 稳定地共享:

    c 复制代码
    if (!old_len && !(vma->vm_flags & (VM_SHARED | VM_MAYSHARE)))
            return -EINVAL;
  • MREMAP_DONTUNMAP 相当于地址空间增加了 old_len,需要通过 may_expand_vm 检查(mremap.c);对带 VM_ACCOUNT 的私有可写映射还会额外计入 commit 计数。

历史:MREMAP_DONTUNMAP 在 5.7 引入时只支持私有匿名映射,5.13 起扩展到共享映射和文件映射。


10. 整体流程

对应代码:move_vma → copy_vma_and_data → dontunmap_complete。

接下来按流程依次展开:目标地址(§11)→ 新 VMA(§12)→ 搬页表(§13)→ 源 VMA 的去留(§14)。


11. 目标地址:MREMAP_FIXED 先清空

mremap_to 中会先对目标区间做 do_munmap。

因此目标地址上原来是什么映射、带什么 flag,都不影响结果------它只起到"占住一段地址,不让别人用"的作用。

常见的占位写法:

c 复制代码
/* 只占地址:不占 commit 额度,误访问立即 SIGSEGV */
void *placeholder = mmap(NULL, total, PROT_NONE,
                         MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);

用 MAP_SHARED | MAP_ANONYMOUS 做占位并不会让结果变成"共享",反而会额外创建一个 shmem 对象(§2)并计入 commit。


12. 新 VMA:完全继承自源 VMA

copy_vma_and_data 按 §2 的线性公式计算新 VMA 的 pgoff:

c 复制代码
unsigned long internal_offset = vrm->addr - vrm->vma->vm_start;
unsigned long internal_pgoff  = internal_offset >> PAGE_SHIFT;
unsigned long new_pgoff       = vrm->vma->vm_pgoff + internal_pgoff;
...
new_vma = copy_vma(&vma, vrm->new_addr, vrm->new_len, new_pgoff, ...);

copy_vma 先尝试与目标位置的邻居合并(按 §6 的规则),失败则 vm_area_dup(vma) 复制一份:

  • vm_flags(包括 VM_SHARED、读写权限)、vm_file、vm_ops、mempolicy 全部来自源 VMA;
  • vm_pgoff 保持与源相同的"后备对象偏移",这样缺页时仍能找到同一个 page cache 页;
  • 私有匿名映射会 anon_vma_clone,让搬过去的匿名页仍能被反向映射找到。

一个特例:源是尚未缺页过 的私有匿名 VMA(anon_vma == NULL)时,pgoff 会改成新地址对应的值,以提高与邻居合并的机会。

结论:mremap 无法改变映射类型。新映射属于 A、B、C、D 中的哪一种,完全由源决定。


13. 搬移页表:不复制数据

move_page_tables:

c 复制代码
mmu_notifier_range_init(&range, MMU_NOTIFY_UNMAP, 0, mm,
                        pmc->old_addr, pmc->old_end);
mmu_notifier_invalidate_range_start(&range);
for (...) {
        /* 对齐时尽量整级搬移 PUD / PMD */
        if (pmd_trans_huge(*old_pmd)) {
                if (extent == HPAGE_PMD_SIZE &&
                    move_pgt_entry(pmc, HPAGE_PMD, old_pmd, new_pmd))
                        continue;
                split_huge_pmd(pmc->old, old_pmd, pmc->old_addr);  /* 不对齐则拆分大页 */
        } else if (extent == PMD_SIZE) {
                if (move_pgt_entry(pmc, NORMAL_PMD, old_pmd, new_pmd))
                        continue;
        }
        move_ptes(pmc, extent, old_pmd, new_pmd);                  /* 逐个搬 PTE */
}
mmu_notifier_invalidate_range_end(&range);

要点:

  1. 只搬页表项,物理页不动,数据不复制。由于硬件只看页表(§1),搬完后新 VA 就指向原来那些物理页。
  2. 源区间会触发 MMU_NOTIFY_UNMAP 通知------任何通过 MMU notifier 跟踪源区间的设备(例如 GPU userptr、HMM 镜像)都会收到失效通知。
  3. 源区间中是 THP 的部分,只有在 2 MiB 对齐且整段搬移时才会保持大页,否则会被拆成 4 KiB(对照 §7)。
  4. 源中从未被访问过的页没有 PTE,自然什么也不搬;新 VA 将来访问时按新 VMA 的类型缺页(§3)。

14. MREMAP_DONTUNMAP:源 VMA 保留但变空

不带该 flag 时,源区间被 unmap;带该 flag 时走 dontunmap_complete:

c 复制代码
/* 源 VMA 已没有页表项,无法再保持 mlock 状态 */
vm_flags_clear(vrm->vma, VM_LOCKED_MASK);

/* 整个源 VMA 都被搬走时,解除其 anon_vma 关联 */
if (new_vma != vrm->vma && start == old_start && end == old_end)
        unlink_anon_vmas(vrm->vma);

源 VMA 本身不会被拆分 (除非它带 VM_LOCKED 需要清标志,或带 VM_ACCOUNT 需要调整计费),属性不变,只是对应范围里已经没有 PTE。之后访问源地址会重新缺页,缺页结果取决于源 VMA 的类型------这正是第一部分 §3--§5 的内容。

14.1 四种映射下的结果

把 §4 的四种映射逐一代入:

源映射 已搬走的页,再访问源 VA 源中未访问过的页 新旧 VA 是否一直同步
**A. `MAP_PRIVATE MAP_ANONYMOUS`** do_anonymous_page 分配新零页 新旧两边各自缺页、各分配一页
**B. `MAP_SHARED MAP_ANONYMOUS`** 在 shmem page cache 中找到同一页 两边缺页落到同一 shmem 页
C. MAP_SHARED + 文件/memfd 在 page cache 中找到同一页 同上 是
D. MAP_PRIVATE + 文件/memfd 映射 page cache 中的文件原始内容(写过的私有副本已在新 VA) 暂时共享 page cache,任一方写入后 COW 分离 否

本质:MREMAP_DONTUNMAP 只是"搬走 PTE、留下空 VMA"。两个 VA 能否共享同一份数据,取决于物理页是否有 page cache 身份(§5),而不是 mremap 本身。

14.2 实验验证

examples/mremap_demo.c 的 run() 在 v6.18.46 上的输出:

复制代码
private anon     dst[0]=W src[0]=0 | dst写p1→src见0 | src写p2→dst见0
shared anon      dst[0]=W src[0]=W | dst写p1→src见D | src写p2→dst见S
shared memfd     dst[0]=W src[0]=W | dst写p1→src见D | src写p2→dst见S
private memfd    dst[0]=W src[0]=F | dst写p1→src见F | src写p2→dst见F

解读(memfd 预先填充为 F;搬移前对源第 0 页写了 W):

  • dst[0]:所有情况下新 VA 都拿到了原物理页(W),证明搬的是 PTE。
  • src[0]:私有匿名得到零页;共享映射重新找到同一页(W);私有文件回落到文件原始内容(F)。
  • 之后的双向写入:只有共享映射(B、C)两边能互相看到。

15. 循环 mremap 拼接离散段

典型用法:先用一段 PROT_NONE 占位映射(§11)占住目标区间,再对每段离散区间依次调用 mremap(..., MREMAP_MAYMOVE | MREMAP_FIXED | MREMAP_DONTUNMAP, dst + off),把它们顺序搬到这段连续 VA 上。实现见 examples/mremap_demo.c 的 loop_demo()。

每次调用都会:

  1. MREMAP_FIXED 清掉目标子区间里的占位映射(§11);
  2. copy_vma 为这一段建一个新 VMA,并尝试与左边(上一轮放进来的段)合并(§12);
  3. 合并条件就是 §6:vm_flags、vm_file、anon_vma 一致,且 vm_pgoff 连续。

因此:

源段的分布 目标区间的 VMA 数量
来自同一后备对象,偏移离散(常见) N 个,无法合并
来自同一后备对象,偏移恰好首尾相接,且按顺序搬移 合并为更少的 VMA
来自不同文件/不同类型 N 个

实验结果(loop_demo():同一 memfd,取每隔一页的离散页 vs. 连续页):

复制代码
scattered: src VMAs 1 -> 1, dst VMAs 8
contiguous: dst VMAs 1

可以看到:

  • 偏移离散时,目标区间有 8 个 VMA;偏移连续时合并为 1 个。
  • 源 VMA 没有被拆分(仍是 1 个),与 §14 的分析一致。

注意:目标区间最终 VA 是连续的,但 VMA 不一定是一个 。许多内核子系统(例如 HMM 的 hmm_range_fault、GUP)只要求 VA 区间连续、无空洞,可以跨越多个 VMA 工作。


16. 开销与限制

项目 影响
系统调用次数 每段一次,N 段就是 N 次
mmap_lock 每次都持写锁,会与同进程其他线程的缺页、mmap 竞争
TLB 刷新 每次搬移后需要刷新源区间的 TLB,多线程进程会触发 IPI
MMU notifier 每次都会对源区间发出失效通知(§13)
VMA 数量 目标侧最多增加 N 个;进程总数受 vm.max_map_count(默认 65530)限制
THP 段不按 2 MiB 对齐时,源中的大页被拆分;目标侧 VMA 碎片化后也无法再使用 PMD 映射(§7)
稳态访存 搬移完成后与普通映射无差别(硬件只看页表,§1)

17. 总结

地址空间基础

  1. 硬件只看页表/TLB;VMA 是软件描述,只在慢路径使用。
  2. 四种映射中,只有共享映射(含 MAP_SHARED|MAP_ANONYMOUS、memfd)的物理页有可重新查找的身份,因此能被多个 VA 稳定共享。
  3. VMA 合并要求属性一致且 vm_pgoff 线性连续;VMA 被切碎或不对齐会使 THP 失效。
  4. VM_PFNMAP/VM_IO 映射是一类特殊对象,许多通用内存操作对其受限。

mremap

  1. mremap 搬的是页表项,物理页不动、数据不复制。
  2. MREMAP_FIXED 会先 munmap 目标区间,占位映射的类型和 flag 无关紧要。
  3. 新 VMA 的所有属性继承自源 VMA,mremap 无法改变映射类型。
  4. MREMAP_DONTUNMAP 留下一个空的源 VMA:共享映射(B、C)的新旧 VA 通过 page cache 共享同一批物理页;私有映射(A、D)的新旧 VA 立即或最终分离。
  5. 循环拼接离散段时,目标区间 VA 连续,但通常是 N 个 VMA;只有后备偏移连续时才会合并。
  6. VM_PFNMAP/VM_DONTEXPAND 映射不能用 MREMAP_DONTUNMAP;单次 mremap 不能跨 VMA。

附录:实验程序

文件 对应章节 内容
examples/share_demo.c §5 同一 memfd 的 shared / private 映射是否共享物理页
examples/mremap_demo.c §14.2、§15 run():四种映射下 MREMAP_DONTUNMAP 的结果;loop_demo():循环拼接后的 VMA 数量

编译运行:

bash 复制代码
cd docs/mm-basics/examples
gcc -o share_demo  share_demo.c  && ./share_demo
gcc -o mremap_demo mremap_demo.c && ./mremap_demo

注:上述代码程序无法附加到文件,有需要的私我。