本篇目标:理解 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]; }

但这有两个问题:

  1. 物理内存有空洞:一台机器可能有 0~4GB 和 8~16GB 的 DRAM,中间 4~8GB 是设备 MMIO 区域。如果用连续数组,4~8GB 对应的 struct page 会白白浪费内存。
  2. 内存可以热插拔:服务器可以在线添加内存条,数组大小不能在编译时确定。

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));
    }
}

简单来说:

  1. 计算需要多少物理内存:每个 struct page 约 64 字节,每个 4KB 物理页可存放 64 个 struct page
  2. 分配物理页:用 memblock(早期)或伙伴系统(热插拔时)分配
  3. 建立页表映射:在内核页表中,将 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 = 32768struct 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_pagevmemmap + 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 建立
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐