SPARSEMEM_VMEMMAP 详解:Linux 如何用虚拟地址高效索引 struct page
本篇目标:理解 Linux 的内存模型(Memory Model)演进,以及当前主流的 SPARSEMEM_VMEMMAP 模型如何通过虚拟地址映射,让
pfn_to_page(pfn)只需一次指针加法就能找到任意物理页帧的struct page。
1. Linux 的内存模型:从 FLATMEM 到 SPARSEMEM_VMEMMAP
什么是"内存模型"?
内核需要为每个物理页帧维护一个 struct page 描述符。内存模型(Memory Model)回答的核心问题是:这些 struct page 在内存中如何组织,以及如何通过 PFN 快速找到对应的 struct page?
不同的组织方式在空间效率和查找速度上有不同的取舍。Linux 历史上经历了三种内存模型:
三种模型一览
| 模型 | pfn_to_page 实现 |
适用场景 | 缺点 |
|---|---|---|---|
| FLATMEM | mem_map + pfn |
物理内存连续、无空洞的简单系统 | 空洞区域也要分配 struct page,浪费内存 |
| SPARSEMEM | 先查 section 数组,再偏移 | 有空洞、支持热插拔 | 两次内存访问(间接查找) |
| SPARSEMEM_VMEMMAP | vmemmap + pfn |
64 位系统(现代默认) | 需要页表支持虚拟映射 |
它们的定义在 include/asm-generic/memory_model.h 中一目了然:
// include/asm-generic/memory_model.h
#if defined(CONFIG_FLATMEM)
#define __pfn_to_page(pfn) (mem_map + ((pfn) - ARCH_PFN_OFFSET))
#elif defined(CONFIG_SPARSEMEM_VMEMMAP)
#define __pfn_to_page(pfn) (vmemmap + (pfn)) // ← 最简洁!
#elif defined(CONFIG_SPARSEMEM)
#define __pfn_to_page(pfn) (__section_mem_map_addr(__pfn_to_section(pfn)) + pfn)
#endif
为什么需要演进?
FLATMEM 是最早的模型:一个全局 mem_map[] 数组,pfn_to_page 就是数组下标访问。简单高效,但物理地址空间有空洞时(比如中间有 MMIO 设备区域),空洞部分的 struct page 也必须分配,白白浪费内存。
SPARSEMEM 把物理地址空间切成固定大小的 section(x86_64 上每个 section 128MB),每个 section 独立管理自己的 struct page 数组。空洞区域的 section 不分配,解决了浪费问题。但 pfn_to_page 需要先查 section 数组再偏移,多了一次内存访问。
SPARSEMEM_VMEMMAP 是最终的"两全其美"方案——它在内核虚拟地址空间预留一段连续区域,假装那里有一个巨大的 struct page 数组,但只为实际有物理内存的部分建立页表映射。这样 pfn_to_page 回归到一次加法的 O(1) 操作,同时不浪费物理内存。
💡 SPARSEMEM_VMEMMAP 是当前 x86_64 的默认内存模型,在
mm/Kconfig中定义:config SPARSEMEM_VMEMMAP def_bool y depends on SPARSEMEM && SPARSEMEM_VMEMMAP_ENABLE它的名字拆开看就是:SPARSEMEM(稀疏内存)+ VMEMMAP(虚拟 memmap),继承了 SPARSEMEM 的 section 管理能力,又用虚拟映射达到了 FLATMEM 的查找性能。
接下来,我们深入看 SPARSEMEM_VMEMMAP 是如何实现的。
2. 问题的起源
上一篇我们知道了:每个物理页帧都有一个对应的 struct page,内核通过 PFN 索引它们。最直观的实现是一个 C 数组:
struct page memmap[MAX_PFN]; // 假设的简单实现
struct page *pfn_to_page(pfn) { return &memmap[pfn]; }
但这有两个问题:
- 物理内存有空洞:一台机器可能有 0~4GB 和 8~16GB 的 DRAM,中间 4~8GB 是设备 MMIO 区域。如果用连续数组,4~8GB 对应的
struct page会白白浪费内存。 - 内存可以热插拔:服务器可以在线添加内存条,数组大小不能在编译时确定。
vmemmap 的解决方案:不真正分配一个连续数组,而是在内核虚拟地址空间中预留一大段地址范围,假装那里有一个巨大的 struct page 数组。然后只为实际存在物理内存的区域建立页表映射,将虚拟地址指向真正存放 struct page 的物理内存。
3. 核心思想:虚拟连续,物理按需
内核虚拟地址空间(简化):
高地址
┌────────────────────────────────┐
│ ...其他内核区域... │
├────────────────────────────────┤
│ │
│ vmemmap 区域 │ ← 从 VMEMMAP_START 开始
│ "假装"这里有一个巨大的 │ 大小足以覆盖所有可能的 PFN
│ struct page 数组 │
│ │
│ vmemmap[0] │ → 通过页表映射到真实物理内存 A
│ vmemmap[1] │ → 通过页表映射到真实物理内存 A
│ ... │
│ vmemmap[1048575] │ → 映射到物理内存 B
│ vmemmap[1048576] │ → 【未映射!】物理内存空洞
│ ... │
│ vmemmap[2097151] │ → 【未映射!】
│ vmemmap[2097152] │ → 映射到物理内存 C
│ ... │
├────────────────────────────────┤
│ ...其他内核区域... │
└────────────────────────────────┘
低地址
关键要点:
- 虚拟地址是连续的:
vmemmap[0]到vmemmap[MAX_PFN-1]在虚拟地址空间中紧密排列 - 物理存储是按需的:只有对应真实物理内存的 PFN 范围,才会分配物理页来存放
struct page,并建立页表映射 - 空洞区域不映射:对应物理空洞的 vmemmap 虚拟地址没有页表映射——如果错误访问会产生页错误(但正常情况下不会访问不存在的 PFN)
4. x86_64 上的具体布局
在 x86_64 上,内核虚拟地址空间有明确的功能划分:
x86_64 内核虚拟地址空间(4级页表,简化):
0xffff800000000000 ┬─ 直接映射区(direct map): 物理内存的线性映射
│ 大小 = 物理内存大小
0xffffc90000000000 ┬─ vmalloc 区域:动态内核虚拟内存分配
│ 大小 = 32TB
0xffffea0000000000 ┬─ vmemmap 区域:struct page 虚拟数组 ← 就是这里!
│ 大小 = 1TB(足以覆盖 64TB 物理内存)
0xffffeb0000000000 ┴─
vmemmap 的起始地址定义在:
// arch/x86/include/asm/pgtable_64_types.h
#define __VMEMMAP_BASE_L4 0xffffea0000000000UL // 4级页表
#define __VMEMMAP_BASE_L5 0xffd4000000000000UL // 5级页表
// 实际使用的起始地址(考虑 KASLR 随机化)
#define VMEMMAP_START vmemmap_base // 运行时确定
// arch/x86/include/asm/pgtable_64.h
#define vmemmap ((struct page *)VMEMMAP_START)
所以 vmemmap 就是一个类型为 struct page * 的常量指针,指向 vmemmap 区域的起始位置。
5. pfn_to_page 的工作原理
理解了上述布局,pfn_to_page 就非常简单了:
static inline struct page *pfn_to_page(unsigned long pfn)
{
return vmemmap + pfn;
}
这行代码做了什么?
1. vmemmap 的值 = VMEMMAP_START = 0xffffea0000000000(举例)
2. pfn = 1000(要查找第 1000 个页帧的 struct page)
3. 指针算术: vmemmap + pfn = 0xffffea0000000000 + 1000 * sizeof(struct page)
4. 假设 sizeof(struct page) = 64 字节:
结果 = 0xffffea0000000000 + 64000 = 0xffffea000000fa00
5. CPU 访问这个虚拟地址时,MMU 通过页表翻译,找到真正存放该 struct page 的物理地址
整个过程不需要任何内存访问来做"查找"——只是一次加法和一次乘法(编译器优化为移位)。真正的内存访问发生在你后续读写该 struct page 的字段时。
6. vmemmap 的建立过程
那么,vmemmap 区域的页表映射是何时建立的?
5.1 系统启动时
内核启动时,会扫描物理内存布局(通过 e820 表或设备树),然后为每个有效的内存 section 建立 vmemmap 映射:
// mm/sparse.c(简化流程)
for_each_present_section(section) {
pfn = section_nr_to_pfn(section_nr);
// 为这个 section 的 struct page 数组分配物理内存并建立页表
__populate_section_memmap(pfn, PAGES_PER_SECTION, nid, ...);
}
5.2 具体实现:vmemmap_populate
// mm/sparse-vmemmap.c
// 为 [start, end) 虚拟地址范围建立页表映射
int vmemmap_populate_basepages(unsigned long start, unsigned long end, int node)
{
// 遍历这个范围内的每个页表项
for (addr = start; addr < end; addr += PAGE_SIZE) {
// 1. 分配一个物理页(用来存放 struct page 数据)
void *p = vmemmap_alloc_block(PAGE_SIZE, node);
// 2. 在页表中建立映射: addr(虚拟)→ p(物理)
vmemmap_set_pte(addr, mk_pte(page, PAGE_KERNEL));
}
}
简单来说:
- 计算需要多少物理内存:每个
struct page约 64 字节,每个 4KB 物理页可存放 64 个struct page - 分配物理页:用 memblock(早期)或伙伴系统(热插拔时)分配
- 建立页表映射:在内核页表中,将 vmemmap 区域的虚拟地址映射到刚分配的物理页
5.3 内存热插拔时
当新内存条插入时,内核也会为新的 PFN 范围建立 vmemmap 映射:
热插拔前: vmemmap[0..4M] 已映射
插入新内存: 物理地址 16GB~32GB(PFN 4M~8M)
热插拔后: vmemmap[0..4M] 已映射, vmemmap[4M..8M] 新建映射
7. 一个具体的例子
假设一台机器有如下物理内存布局:
物理地址范围 用途 PFN 范围
0x0000_0000_0000 - 0x0001_0000_0000 DRAM (4GB) PFN 0 ~ 1048575
0x0001_0000_0000 - 0x0002_0000_0000 MMIO 空洞 PFN 1048576 ~ 2097151
0x0002_0000_0000 - 0x0004_0000_0000 DRAM (8GB) PFN 2097152 ~ 4194303
vmemmap 的映射情况:
vmemmap 虚拟地址 映射状态
vmemmap + 0 ~ vmemmap + 1048575 ✓ 已映射(对应 4GB DRAM)
vmemmap + 1048576 ~ vmemmap + 2097151 ✗ 未映射(MMIO 空洞,无 struct page)
vmemmap + 2097152 ~ vmemmap + 4194303 ✓ 已映射(对应 8GB DRAM)
内存消耗计算:
- 有效物理内存 = 4GB + 8GB = 12GB
- 页帧数 = 12GB / 4KB = 3,145,728
- struct page 大小 ≈ 64 字节
- vmemmap 消耗 = 3,145,728 × 64 = 192MB
注意:MMIO 空洞对应的 vmemmap 区域不消耗任何物理内存——因为没有建立映射,也就不需要分配存储。
8. 为什么用 2MB 大页优化 vmemmap
在上面的例子中,vmemmap 自身需要 192MB 的物理内存来存放 struct page 数据。如果 vmemmap 的页表用 4KB 小页映射,还需要额外的页表页。
为了减少页表开销和 TLB 压力,内核通常用 2MB 大页来映射 vmemmap 区域:
// arch/x86/mm/init_64.c(简化)
int vmemmap_populate(unsigned long start, unsigned long end, int node) {
// 尝试用 PMD(2MB)大页映射
if (can_use_2m_page(start, end)) {
p = vmemmap_alloc_block(PMD_SIZE, node); // 分配 2MB
set_pmd(pmd, __pmd(phys | _PAGE_PSE)); // PMD 直接映射
} else {
vmemmap_populate_basepages(start, end, node); // fallback 到 4KB
}
}
一个 2MB 大页可以存放 2MB / 64B = 32768 个 struct page,对应 32768 个页帧 = 128MB 物理内存。这意味着每 128MB 的物理内存只需要一个 TLB 条目来访问其 vmemmap。
9. vmemmap 与 ZONE_DEVICE
当 GPU 驱动通过 memremap_pages() 注册设备内存时,内核也会为设备内存的 PFN 范围建立 vmemmap 映射:
// mm/memremap.c(简化)
void *memremap_pages(struct dev_pagemap *pgmap) {
// 1. 为设备内存的 PFN 范围分配 vmemmap 空间
// 注意:struct page 存储在系统 DRAM 中,不是设备内存中!
vmemmap_populate(start_pfn, end_pfn, ...);
// 2. 初始化这些 struct page
memmap_init_zone_device(zone, start_pfn, nr_pages, pgmap);
}
关键点:设备内存(如 GPU VRAM)的 struct page 存储在系统 DRAM 中(通过 vmemmap 映射),而不是设备内存自身中——因为 CPU 需要能快速访问这些元数据。
10. page_to_pfn:反向转换
既然 pfn_to_page 是 vmemmap + pfn,反向转换就是指针减法:
static inline unsigned long page_to_pfn(struct page *page)
{
return page - vmemmap;
}
C 语言的指针减法会自动除以 sizeof(struct page),所以结果就是 PFN。
11. 总结
vmemmap 机制的本质:
┌─────────────────────────────────────────────────────┐
│ 在内核虚拟地址空间预留一大段连续区域 │
│ 假装那里有一个 struct page 大数组 │
│ 只为实际存在物理内存的部分建立页表映射 │
│ 空洞部分不映射,不浪费物理内存 │
│ pfn_to_page = 一次指针加法,O(1) │
└─────────────────────────────────────────────────────┘
这个设计的精妙之处在于:利用虚拟内存本身的特性(虚拟连续但物理可以不连续)来管理物理内存的元数据——这是一个"用虚拟内存管理物理内存"的递归式优雅。
12. 关键源码文件
| 文件 | 内容 |
|---|---|
arch/x86/include/asm/pgtable_64_types.h |
VMEMMAP_START 定义 |
arch/x86/include/asm/pgtable_64.h |
vmemmap 宏定义 |
include/asm-generic/memory_model.h |
pfn_to_page / page_to_pfn |
mm/sparse-vmemmap.c |
vmemmap 页表建立逻辑 |
mm/sparse.c |
SPARSEMEM section 管理 |
mm/memremap.c |
ZONE_DEVICE 的 vmemmap 建立 |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)