1.1 虚拟地址空间与 VMA:每个进程的私有世界与划分管理方法
本篇目标:理解 Linux 如何为每个进程维护独立的虚拟地址空间,以及地址空间如何被划分为一个个 VMA(Virtual Memory Area)。VMA 是内核管理虚拟内存的基本单元,也是 HMM 判断地址范围权限和类型的依据。
1. 虚拟地址空间:进程眼中的世界
每个 Linux 进程都拥有自己独立的虚拟地址空间。从进程的视角看,它独占一整块连续的地址范围(在 64 位系统上通常是 128TB 用户空间),完全不知道其他进程的存在。
这种"私有世界"的抽象由内核的mm_struct结构体维护:
// include/linux/mm_types.hstructmm_struct{structmaple_treemm_mt;// VMA 的 maple tree(地址区间管理)unsignedlongmmap_base;// mmap 区域的基地址unsignedlongtask_size;// 用户空间大小(进程可用地址上限)pgd_t*pgd;// 页表根指针(第 3 篇详解)atomic_tmm_users;// 使用该地址空间的线程数atomic_tmm_count;// mm_struct 自身的引用计数intmap_count;// VMA 的数量structrw_semaphoremmap_lock;// 保护 VMA 操作(HMM 必须持有)unsignedlongstart_code,end_code;// 代码段范围unsignedlongstart_data,end_data;// 数据段范围unsignedlongstart_brk,brk;// 堆的起始和当前位置unsignedlongstart_stack;// 栈的起始地址// ...};关键字段解读:
| 字段 | 含义 | HMM 相关性 |
|---|---|---|
mm_mt | 存储所有 VMA 的 maple tree | HMM 通过 VMA 判断地址范围的权限 |
mmap_base | mmap 区域基地址 | 决定了动态映射区域的位置 |
task_size | 用户空间边界(通常 128TB) | 虚拟地址不能超过这个范围 |
mmap_lock | 保护地址空间的读写信号量 | HMM 所有操作都需要持有此锁 |
pgd | 页表根指针 | hmm_range_fault()遍历的起点(第 3 篇详解) |
map_count | VMA 数量 | 反映地址空间的碎片化程度 |
mm_struct中的start_code/end_code、start_brk/brk、mmap_base、start_stack等字段精确描述了进程地址空间中各区域的位置——它们与下一节的地址空间布局图一一对应。
2. 64 位进程地址空间布局
这样一个虚拟地址空间,被划分为了多个不同用途的地址区域。
在 x86_64 架构上,128TB 用户空间的典型布局如下(低地址在下,高地址在上):
图中有几个值得注意的要点:
非规范地址(Non-canonical Address):x86_64 虽然有 64 位地址线,但硬件实际只使用低 48 位(或 57 位,取决于是否开启 5 级页表)。CPU 要求地址的高位必须是低 48 位最高位的符号扩展——不满足这个规则的地址称为"非规范地址"。任何对非规范地址的访问都会触发硬件异常,因此用户空间(低地址)和内核空间(高地址)之间天然存在一个巨大的不可用空洞。
已映射区域 vs 未映射区域:128TB 的用户空间并不是全部可用的。只有进程明确请求使用的地址范围(通过加载程序、分配堆、映射文件等操作)才是"已映射"的——CPU 访问这些地址时能正常读写数据。而那些从未被使用的地址范围就是"未映射"的空洞,访问它们会触发段错误(SIGSEGV)。
从图中可以看出,地址空间并非铁板一块——它由若干不连续的已映射区域组成,中间穿插着大片未映射的空洞。内核需要一种方式来精确记录"哪些地址范围是已映射的、各自有什么权限"。这就是下一节要介绍的VMA(Virtual Memory Area):每个已映射区域(代码段、数据段、堆、mmap、栈)对应一个或多个 VMA,而未映射区域没有 VMA。
3. VMA:地址空间的管理单元
虽然地址空间看起来是一整块连续的范围,但内核并不是整体管理它的。而是将其划分为若干个VMA(Virtual Memory Area)——每个 VMA 描述一段连续的、具有相同属性的虚拟地址区间。
进程地址空间中的 VMA 示意: 地址 ────────────────────────────────────────────── │ VMA1: [0x400000, 0x401000) 代码段 r-x │ │ VMA2: [0x601000, 0x602000) 数据段 rw- │ │ VMA3: [0x602000, 0x623000) 堆 rw- │ │ ... 未映射 ... │ │ VMA4: [0x7f..., 0x7f...) libc.so r-x │ │ VMA5: [0x7f..., 0x7f...) libc.so rw- │ │ VMA6: [0x7fff..., 0x7fff...) 栈 rw- │ ─────┴─────────────────────────────────────────────3.1 struct vm_area_struct
每个 VMA 由内核的vm_area_struct描述:
// include/linux/mm_types.hstructvm_area_struct{unsignedlongvm_start;// VMA 起始地址(含)unsignedlongvm_end;// VMA 结束地址(不含)structmm_struct*vm_mm;// 所属的地址空间pgprot_tvm_page_prot;// 页面保护属性(给硬件 MMU 用)vm_flags_tvm_flags;// VMA 属性标志(给内核逻辑用)structanon_vma*anon_vma;// 匿名页的反向映射conststructvm_operations_struct*vm_ops;// 操作回调unsignedlongvm_pgoff;// 文件映射的偏移(页单位)structfile*vm_file;// 映射的文件(NULL = 匿名映射)void*vm_private_data;// 驱动私有数据// ...};关键字段解读:
| 字段 | 含义 | HMM 相关性 |
|---|---|---|
vm_start/vm_end | VMA 覆盖的虚拟地址范围 [start, end) | HMM 按 VMA 边界确定操作范围 |
vm_flags | 权限和类型标志 | HMM 通过它判断可读/可写/是否 PFNMAP 等 |
vm_page_prot | 硬件级页保护 | 最终写入 PTE 的保护位 |
vm_file | 关联的文件(或 NULL) | 区分文件映射 vs 匿名映射 |
vm_ops | VMA 操作回调 | fault回调用于缺页时分配页面 |
vm_mm | 所属的 mm_struct | HMM 通过它获取 mmap_lock 和 pgd |
3.2 vm_flags:VMA 的属性标志
vm_flags是一个位图,编码了 VMA 的权限和行为特征:
// include/linux/mm.h(部分)#defineVM_READ0x00000001// 可读#defineVM_WRITE0x00000002// 可写#defineVM_EXEC0x00000004// 可执行#defineVM_SHARED0x00000008// 共享映射(vs 私有/COW)#defineVM_MAYREAD0x00000010// 允许 mprotect 设为可读#defineVM_MAYWRITE0x00000020// 允许 mprotect 设为可写#defineVM_MAYEXEC0x00000040// 允许 mprotect 设为可执行#defineVM_IO0x00004000// 设备 I/O 映射(不可迁移)#defineVM_PFNMAP0x00000400// 纯 PFN 映射(无 struct page)#defineVM_MIXEDMAP0x10000000// 混合映射(有些页有 struct page)HMM 特别关注的 vm_flags:
| 标志 | HMM 行为 |
|---|---|
VM_READ/VM_WRITE | 决定hmm_range_fault()输出的 PFN 是否标记为可写 |
VM_IO | HMM 跳过——I/O 映射不参与 HMM 管理 |
VM_PFNMAP | HMM 跳过——纯 PFN 映射没有 struct page |
VM_SHARED | 影响 COW 行为和迁移策略 |
3.3 VMA 的类型
根据vm_file和vm_flags的组合,VMA 可分为几种类型:
| 类型 | vm_file | 特征 | 示例 |
|---|---|---|---|
| 匿名私有 | NULL | 进程独占,COW | 堆、栈、malloc |
| 匿名共享 | NULL + VM_SHARED | 多进程共享 | POSIX 共享内存 |
| 文件私有 | 非 NULL | COW,延迟加载 | mmap(MAP_PRIVATE, fd) |
| 文件共享 | 非 NULL + VM_SHARED | 多进程共享,回写文件 | mmap(MAP_SHARED, fd) |
| 设备映射 | 非 NULL + VM_IO | 直接映射设备 BAR | GPU framebuffer |
HMM 主要工作在匿名私有和文件私有VMA 上——这些才是需要在 CPU 和设备之间迁移的页面。
4. VMA 的组织:Maple Tree
一个进程可能有几十到几百个 VMA。内核需要一种高效的数据结构来管理它们,支持:
- 按地址快速查找VMA(
find_vma(mm, addr)) - VMA 的插入和删除(
mmap/munmap) - 相邻 VMA 的合并(减少碎片)
Linux 6.x 使用Maple Tree(一种 B-tree 变体)来组织 VMA:
// 查找包含 addr 的 VMAstructvm_area_struct*find_vma(structmm_struct*mm,unsignedlongaddr);// 遍历指定范围内的所有 VMAVMA_ITERATOR(vmi,mm,start);for_each_vma_range(vmi,vma,end){// 处理 [vma->vm_start, vma->vm_end) 范围}HMM 的 VMA 查找流程:
当hmm_range_fault(start, end)被调用时,内核需要:
- 用
find_vma()或 VMA iterator 找到覆盖[start, end)的所有 VMA - 检查每个 VMA 的
vm_flags——跳过VM_IO、VM_PFNMAP等 - 如果存在地址空洞(无 VMA 覆盖),返回错误
4.1 VMA 合并
当新创建的 VMA 与相邻 VMA 具有相同属性时,内核会自动合并它们以减少碎片:
合并前: VMA1: [0x1000, 0x2000) rw- 匿名 VMA2: [0x2000, 0x3000) rw- 匿名 ← 属性相同且地址连续 合并后: VMA1: [0x1000, 0x3000) rw- 匿名 ← 合并为一个 VMA这对 HMM 是有利的——更大的 VMA 意味着更少的 VMA 遍历和边界检查。
5. 用户态如何创建 VMA
用户态通过系统调用操作 VMA:
| 系统调用 | 效果 |
|---|---|
mmap() | 创建新 VMA(文件映射或匿名映射) |
munmap() | 删除 VMA(或拆分现有 VMA) |
mprotect() | 修改 VMA 权限(可能拆分 VMA) |
mremap() | 移动或扩展 VMA |
brk() | 扩展/收缩堆 VMA |
每次这些操作修改地址空间时,都需要持有mmap_lock。HMM 也持有同一把锁来保证遍历时 VMA 不会突然变化。
6. 实验:用 /proc/pid/maps 观察 VMA
Linux 通过/proc/[pid]/maps暴露进程的所有 VMA:
$cat/proc/self/maps555555554000-555555558000 r--p 00000000 08:011234/usr/bin/cat555555558000-555555560000 r-xp 00004000 08:011234/usr/bin/cat555555560000-555555563000 r--p 0000c000 08:011234/usr/bin/cat555555564000-555555565000 rw-p 0000f000 08:011234/usr/bin/cat555555565000-555555586000 rw-p 00000000 00:000[heap]7ffff7d00000-7ffff7d28000 r--p 00000000 08:015678/usr/lib/libc.so.6 7ffff7d28000-7ffff7e80000 r-xp 00028000 08:015678/usr/lib/libc.so.6... 7ffffffde000-7ffffffff000 rw-p 00000000 00:000[stack]每一行就是一个 VMA,格式为:
起始地址-结束地址 权限 偏移 设备号 inode 路径名权限字段含义:
r/-:可读 / 不可读 →VM_READw/-:可写 / 不可写 →VM_WRITEx/-:可执行 / 不可执行 →VM_EXECp/s:私有 / 共享 →VM_SHARED
更详细的用法请参加后面的关联阅读。
6.1 动手实验:创建不同类型的 VMA
下面这个小程序创建了几种典型的 VMA,然后打印/proc/self/maps让你直观看到它们:
// vma_demo.c// 编译: gcc -o vma_demo vma_demo.c// 运行: ./vma_demo#include<stdio.h>#include<stdlib.h>#include<string.h>#include<sys/mman.h>#include<fcntl.h>#include<unistd.h>intmain(void){// 1. 匿名私有映射(类似 malloc 大块分配)void*anon_priv=mmap(NULL,4096*4,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);// 2. 匿名共享映射(可用于父子进程通信)void*anon_shared=mmap(NULL,4096*2,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,-1,0);// 3. 文件私有映射(COW)intfd=open("/etc/hostname",O_RDONLY);void*file_priv=NULL;if(fd>=0){file_priv=mmap(NULL,4096,PROT_READ,MAP_PRIVATE,fd,0);close(fd);}// 4. 堆分配(brk 管理的 VMA)void*heap=malloc(1024);// 写入数据使页面真正分配memset(anon_priv,'A',4096);memset(anon_shared,'B',4096);// 打印所有 VMAprintf("=== PID %d 的 VMA 列表 ===\n\n",getpid());printf("anon_priv @ %p (匿名私有, rw-p)\n",anon_priv);printf("anon_shared @ %p (匿名共享, rw-s)\n",anon_shared);if(file_priv)printf("file_priv @ %p (文件私有, r--p)\n",file_priv);printf("heap @ %p (堆, rw-p)\n",heap);printf("\n--- /proc/self/maps ---\n\n");// 读取并打印 mapsFILE*f=fopen("/proc/self/maps","r");charline[256];while(fgets(line,sizeof(line),f))fputs(line,stdout);fclose(f);// 清理munmap(anon_priv,4096*4);munmap(anon_shared,4096*2);if(file_priv)munmap(file_priv,4096);free(heap);return0;}运行示例输出(关键部分):
=== PID 12345 的 VMA 列表 === anon_priv @ 0x7f8a00010000 (匿名私有, rw-p) anon_shared @ 0x7f8a00008000 (匿名共享, rw-s) file_priv @ 0x7f8a00007000 (文件私有, r--p) heap @ 0x5555555592a0 (堆, rw-p) --- /proc/self/maps --- 555555559000-55555557a000 rw-p 00000000 00:00 0 [heap] 7f8a00007000-7f8a00008000 r--p 00000000 08:01 1234 /etc/hostname ← 文件私有 7f8a00008000-7f8a0000a000 rw-s 00000000 00:00 0 ← 匿名共享 7f8a00010000-7f8a00014000 rw-p 00000000 00:00 0 ← 匿名私有 ... 7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack]注意观察:
- 匿名私有:
rw-p,无文件路径 - 匿名共享:
rw-s,无文件路径(注意s而非p) - 文件私有:
r--p,有文件路径 - 堆:
rw-p,标记为[heap]
7. mmap_lock:保护地址空间的锁
我们已经知道 VMA 是地址空间的管理单元,而用户态随时可能通过mmap()、munmap()等系统调用增删 VMA。与此同时,内核的其他路径(如缺页处理、HMM 的hmm_range_fault())也需要遍历 VMA。如果这些操作并发执行,没有保护机制的话,正在遍历的 VMA 可能突然被删除——这会导致内核崩溃。
Linux 用mm_struct中的mmap_lock解决这个问题。它是一个读写信号量(rw_semaphore),保护整个地址空间的 VMA 结构:
| 操作 | 锁模式 | 示例 |
|---|---|---|
| 读取/遍历 VMA | 读锁(mmap_read_lock) | hmm_range_fault()、page fault |
| 修改 VMA(增删改) | 写锁(mmap_write_lock) | mmap()、munmap()、mprotect() |
HMM 的锁规则:
// 驱动使用 HMM 的典型模式mmap_read_lock(mm);hmm_range_fault(&range);// 遍历 VMA + 页表mmap_read_unlock(mm);如果不持有mmap_lock,VMA 可能在遍历过程中被munmap()删除,导致内核崩溃。这也是 HMM 使用mmu_interval_notifier序列号协议的原因之一——即使持有锁,页表仍可能在锁释放的瞬间被修改。
8. 与 HMM 的联系
hmm_range_fault()接收一个虚拟地址范围[start, end),其内部流程的第一步就是 VMA 相关的检查:
// 简化的 hmm_range_fault 逻辑inthmm_range_fault(structhmm_range*range){// 1. 确认 mmap_lock 已持有// 2. walk_page_range() 内部会遍历 VMA:// - 跳过 VM_PFNMAP / VM_IO 类型的 VMA// - 根据 VMA 的 vm_flags 判断读写权限// - 对于地址空洞(无 VMA),报告错误// 3. 在有效 VMA 范围内遍历页表、提取 PFN}没有 VMA,就没有合法的地址范围;没有 vm_flags,HMM 就不知道设备能以什么权限访问。
9. 本篇关键代码路径
| 文件 | 核心内容 |
|---|---|
include/linux/mm_types.h | struct mm_struct、struct vm_area_struct定义 |
include/linux/mm.h | VM_READ、VM_WRITE等 vm_flags 定义 |
mm/mmap.c | VMA 的创建、合并、拆分逻辑 |
include/linux/maple_tree.h | Maple Tree 数据结构 |
mm/vma.c | VMA 查找、遍历(find_vma等) |
fs/proc/task_mmu.c | /proc/pid/maps实现 |
10. 下篇预告
第 2 篇:struct page 与 PFN——VMA 背后的物理存储
我们现在知道了地址空间如何被划分为 VMA,但 VMA 只是虚拟的描述——它说"这块地址可读可写",但实际的数据存储在哪里?答案是物理页帧,由struct page管理。
下一篇我们将深入struct page(以及现代的folio),理解内核如何为每个物理页帧维护元数据。HMM 的一个关键创新是:让设备内存(GPU VRAM)也拥有struct page,从而能被内核框架统一管理。
11. 思考题
- 一个进程最多能有多少个 VMA?内核有限制吗?(提示:
/proc/sys/vm/max_map_count) - 为什么 HMM 需要跳过
VM_PFNMAP类型的 VMA? - 如果 GPU 驱动想让一块设备内存被 CPU 进程
mmap,应该创建什么类型的 VMA? - 为什么64位地址,只使用48或者57位?
📚 关联阅读
- linux VMA创建场景详解:分析了用户态的哪些函数调用会操作VMA
- Linux /proc/<pid>/maps 内存映射调试指南:详细分析了maps用途
