Linux内核分析之内存管理-00

This language version is unavailable; showing the other language.

18.1 物理内存布局 —— E820 与 memblock

内核接管物理内存的第一步不是分配,而是记账:固件在加电自检时探测出哪些物理地址范围是可用 RAM、哪些被设备或 ACPI 表占用、哪些必须保留。x86 平台通过 E820 接口把这张表交给内核;内核将其翻译为 memblock 分配器的内部账本,在伙伴系统就绪之前独自承担全部内存分配。2.9 节已在 setup_arch() 流程中瞥见 e820__memblock_setup() 的调用点,本节完整分析 E820 表的结构、三份副本的用途、memblock 的 region 数组算法,以及 memblock_free_all() 如何把账本上的剩余页整块移交伙伴系统。


18.1.1 E820:固件交出的物理内存地图

三份表的用途

E820 名称来自 INT 15h, AX=E820h 这条 BIOS 查询调用。内核在 arch/x86/kernel/e820.c 中维护三份表:

// arch/x86/kernel/e820.c:58-64
__initdata static struct e820_table e820_table_init;
__initdata static struct e820_table e820_table_kexec_init;
__initdata static struct e820_table e820_table_firmware_init;

__refdata struct e820_table *e820_table         = &e820_table_init;
__refdata struct e820_table *e820_table_kexec       = &e820_table_kexec_init;
__refdata struct e820_table *e820_table_firmware    = &e820_table_firmware_init;
表 副本对象 用途
e820_table 工作副本 内核实际使用的表,启动期不断被修改、净化
e820_table_kexec 内核修改后的快照 供 kexec(内核换入换出)构造新内核的引导参数
e820_table_firmware 固件原始表 供需要"原貌"的代码查询(如 e820__mapped_raw_any())

e820__memory_setup()(e820.c:1272-1285)是交接入口:它先调用 x86_init.resources.memory_setup()——BIOS 路径下由引导协议填表,UEFI 路径下由 efi_init() 从 EFI 内存映射翻译而来——然后把工作表同时拷贝给 kexec 与 firmware 副本,最后打印 BIOS-provided physical RAM map:。启动日志里这段著名的输出正是这里打出的。

条目与类型

每个条目 20 字节(BUILD_BUG_ON(sizeof(struct boot_e820_entry) != 20) 是固件 ABI 的断言,e820.c:1276),类型枚举定义在 arch/x86/include/asm/e820/types.h:

// arch/x86/include/asm/e820/types.h:11 起
E820_TYPE_RAM       = 1,    /* 可用内存 */
E820_TYPE_RESERVED  = 2,    /* 设备保留 */
E820_TYPE_ACPI      = 3,    /* ACPI 数据(可回收) */
E820_TYPE_NVS       = 4,    /* ACPI NVS(睡眠必须保留) */
E820_TYPE_UNUSABLE  = 5,    /* 含坏内存区域,不可用 */
E820_TYPE_PMEM      = 7,    /* 持久内存 */
E820_TYPE_PRAM      = 12,   /* 旧式 /dev/pmem */
E820_TYPE_SOFT_RESERVED = 0xd,  /* 固件提前声明的"软保留" */

只有 E820_TYPE_RAM 会被加入可用内存账本;E820_TYPE_SOFT_RESERVED 是较新的类型,固件用它声明"我可能还需要,但理论上能要回"的区域,内核对其执行 memblock_reserve()(e820.c:1340-1342)——进入保留账本而非可用账本。

一台典型 4GB 机器的 E820 表 (启动日志实况式示例):

 BIOS-provided physical RAM map:
 BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable      ← 传统低 640K
 BIOS-e820: [mem 0x000000000009fc00-0x000000000009ffff] reserved    ← EBDA
 BIOS-e820: [mem 0x00000000000f0000-0x00000000000fffff] reserved    ← BIOS ROM
 BIOS-e820: [mem 0x0000000000100000-0x00000000bffdffff] usable      ← 主力 RAM
 BIOS-e820: [mem 0x00000000bffe0000-0x00000000bfffffff] reserved    ← 固件保留
 BIOS-e820: [mem 0x00000000fec00000-0x00000000ffffffff] reserved    ← MMIO 洞
 BIOS-e820: [mem 0x0000000100000000-0x000000047fffffff] usable      ← 4GB 以上的 RAM

 可读作"内存地图的地质剖面": 洞是 32 位时代 PCI 设备
 MMIO 窗口的历史包袱, 64 位机器仍然保留 (reclaim 由
 固件决定) — 物理地址空间不连续是常态!

表的净化:e820__update_table()

固件交来的表充满毛边:重叠条目、相邻同类型条目、0 长度条目、类型冲突。e820__update_table()(e820.c:344)负责净化:

固件原始表                          净化后
┌──────────────────────────┐      ┌──────────────────────────┐
│ [0x0, 0x9FC00)     RAM   │      │ [0x0, 0x9FC00)     RAM   │  ← 合并相邻同类型
│ [0x9FC00, 0xA0000) RESV  │ ---> │ [0x9FC00, 0xA0000) RESV  │
│ [0x100000, 0x7FFE0000)RAM│      │ [0x100000, 0x7F000000)RAM│  ← 排序 + 裁剪重叠
│ [0x7F000000, 0x7FF00000) │      │ [0x7F000000, ...)   RESV │
│   RESV (与上一条重叠)      │      │ ...                      │
└──────────────────────────┘      └──────────────────────────┘
处理规则: 0 长度条目剔除; 重叠区间按优先级(RESERVED>RAM)裁决;
         相邻同类型合并; 条目按起始地址排序

条目的增删接口是 e820__range_add()(e820.c:185)与 e820__range_remove(),查询接口是 e820__mapped_any()/e820__mapped_all()(e820.c:99/147)。后续小节会看到 memblock_free_all() 内部的 free_unused_memmap() 等代码都依赖这些查询判断某段物理地址是否真实存在。

18.1.2 memblock:伙伴系统之前的分配器

核心数据结构

memblock 用两个 region 数组记账:memory 记录"物理上存在的内存",reserved 记录"已被占用的内存"。可用内存 = memory − reserved。结构定义在 include/linux/memblock.h:

// include/linux/memblock.h:55-63
enum memblock_flags {
    MEMBLOCK_NONE       = 0x0,  /* 无特殊要求 */
    MEMBLOCK_HOTPLUG    = 0x1,  /* 可热插拔区域 */
    MEMBLOCK_MIRROR     = 0x2,  /* 镜像区域(可靠内存优先) */
    MEMBLOCK_NOMAP      = 0x4,  /* 不加入内核直接映射 */
    MEMBLOCK_DRIVER_MANAGED = 0x8,  /* 由驱动探测管理(如 DAX) */
    MEMBLOCK_RSRV_NOINIT    = 0x10, /* 保留区不为其建 struct page */
    MEMBLOCK_RSRV_KERN  = 0x20, /* 内核自身占用的保留内存 */
    MEMBLOCK_KHO_SCRATCH    = 0x40, /* kexec handover 专用暂存 */
};

// include/linux/memblock.h:73-99
struct memblock_region {
    phys_addr_t base;       /* 起始物理地址 */
    phys_addr_t size;       /* 大小 */
    enum memblock_flags flags;
#ifdef CONFIG_NEED_MULTIPLE_NODES
    int nid;            /* 所属 NUMA 节点 */
#endif
};

struct memblock_type {
    unsigned long cnt;      /* 当前 region 数 */
    unsigned long max;      /* 数组容量(可自动扩容) */
    phys_addr_t total_size;
    struct memblock_region *regions;
    char *name;
    ...
};

struct memblock {
    bool bottom_up;         /* 分配方向:自顶向下(默认) / 自底向上 */
    phys_addr_t current_limit;  /* 当前分配上限 */
    struct memblock_type memory;
    struct memblock_type reserved;
};

MEMBLOCK_NOMAP 值得注意:它标记的内存"存在但不映射进直接映射区",典型如某些固件保留区与 32 位平台的特殊内存,内核只能通过 ioremap 访问(20.2.5 节的例外清单)。

memblock_add_range():两遍插入算法

region 数组必须保持有序、不重叠。插入一个新区间的算法 memblock_add_range()(mm/memblock.c:609-745)采用优雅的两遍法:

// mm/memblock.c:609(节选)
static int __init_memblock memblock_add_range(struct memblock_type *type,
                phys_addr_t base, phys_addr_t size,
                int nid, enum memblock_flags flags)
{
    ...
    if (type->cnt * 2 + 1 <= type->max)
        insert = true;                    /* 空间充足则直接插入 */

repeat:
    /* 第一遍 insert=false: 只数出需要多少新 region(nr_new);
       第二遍 insert=true : 真正逐段切割插入 */
    base = obase;
    nr_new = 0;

    for_each_memblock_type(idx, type, rgn) {
        phys_addr_t rbase = rgn->base;
        phys_addr_t rend = rbase + rgn->size;

        if (rbase >= end)   break;
        if (rend <= base)   continue;
        /* 与现有 region 重叠: 重叠左侧的裸露段先插入 */
        if (rbase > base) {
            nr_new++;
            if (insert)
                memblock_insert_region(type, idx++, base,
                               rbase - base, nid, flags);
        }
        base = min(rend, end);    /* 跳过重叠段, 继续找下一段裸露区 */
    }
    if (base < end) {                 /* 尾部剩余的裸露段 */
        nr_new++;
        if (insert)
            memblock_insert_region(type, idx, base, end - base, ...);
    }
    if (!nr_new)    return 0;
    if (!insert) {                    /* 容量不足: 数组容量翻倍后重跑 */
        while (type->cnt + nr_new > type->max)
            if (memblock_double_array(type, obase, size) < 0)
                return -ENOMEM;
        insert = true;
        goto repeat;
    } else {
        memblock_merge_regions(type, start_rgn, end_rgn);  /* 相邻同属性合并 */
        return 0;
    }
}
两遍插入的图解 (插入 [30,70) 到已有 [0,40) [60,100):

 第一遍 (insert=false, 纯计算):
   现有:  [0────────40)      [60──────100)
   新:            [30──────────70)
   重叠分析: [30,40) 与 [60,70) 是裸露段 → nr_new=2
   (结论: 需要 2 个新槽位, 不动数据)

 第二遍 (insert=true):
   [0────40) [30─40)✂  [60─70)✂ [60────100)
             ↑插入段1       ↑插入段2
   memblock_merge_regions: 相邻同属性段合并回
   [0──────────70) [──────100)   ← 结果: 有序不重叠

为什么需要两遍? 最坏情况下新区间横跨所有现存 region,每两个相邻 region 之间都会暴露一段裸露区,需要 cnt + 1 个新槽位。如果先扩容再插入,扩容动作本身可能要分配内存来搬移数组(memblock_double_array 在数组位于低位内存时需要新页)——而在分配器尚未就绪时这是鸡生蛋问题。两遍法先用"纯计算"确定需求,再一次性扩容,避免了边分配边破坏数据结构。

memblock_add()(memblock.c:750)与 memblock_reserve()(经 __memblock_reserve(),memblock.h:130)分别对 memory/reserved 数组调用上述算法。memblock_reserve 是 static __always_inline(memblock.h:133-136),保证启动期热路径零开销。

memory/reserved 数组与可用内存的关系 (图解):

 memory 数组 (物理存在):   [0─9FC00) [1M─4G)
 reserved 数组 (被占用):   [1M─16M 内核映像] [ACPI表] [initrd]
 可用 = memory − reserved (区间代数上的差集)
 ┌──────────────────────────────────────────────┐
 │ ▓▓▓░░░░░░░█████████░░░░░░░░░░░░░░░░░░░░░░░░░ │
 │ reserved  可用   内核映像(占)      其余可用     │
 └──────────────────────────────────────────────┘
   memblock 分配 = 在可用段内挑一块 → 挪进 reserved

分配:find + reserve 的原子组合

memblock 的"分配"= 在 memory 数组中找到合适空闲区,然后立即 memblock_reserve() 记账:

// mm/memblock.c:308(节选)
static phys_addr_t __init_memblock memblock_find_in_range_node(phys_addr_t size,
                    phys_addr_t align, phys_addr_t start,
                    phys_addr_t end, int nid, enum memblock_flags flags)
{
    ...
    /* 从 end 向下扫描: 每次跳到"最近一个可能放得下"的位置,
       再向上爬出 reserved 区, 找不到则前移继续 */
    do {
        ...
        this_start = max(prev_end, start);   /* 上一个已用区间的结尾 */
        this_end   = min(range_end, end);
        ...
        ret = memblock_find_in_range(...);   /* 区间内对齐搜索 */
    } while (...);
}

搜索方向由 memblock.bottom_up 控制:默认自顶向下(top-down),让启动期分配尽量落在物理内存高端,把低位内存留给固定用途(DMA、真实模式 trampoline);当 movable_node 启用时 x86 会调用 memblock_set_bottom_up(true)(e820.c:1308),让内核映像远离可热插拔内存。

记账式分配的完整入口是 memblock_alloc_range_nid()(memblock.c:1474-1526):find_in_range_node 命中后立即 memblock_reserve(found, size),两步失败会按 NUMA 策略回退(先本节点,再任意节点,最后放宽范围,见 memblock.c:1500-1506 的两级重试)。再上层的 memblock_alloc_try_nid()(memblock.c:1716)包装成返回虚拟地址的接口——它内部通过 phys_to_virt() 转换,这要求目标内存在直接映射区内,这也是 memblock 只能在"直接映射建立之后"大规模使用的根本原因(x86 在 init_mem_mapping() 完成前把 current_limit 压在 ISA_END_ADDRESS 即 1MB,见 e820.c:1321)。

memblock 分配的调用栈(启动期)

alloc_pgt_img / early 页表 / percpu 区 / …
        │ 调用 memblock_alloc_try_nid(size, align, min, max, nid)     memblock.c:1716
        v
memblock_alloc_range_nid(size, align, start, end, nid, exact_nid)   memblock.c:1474
        │ 1. memblock_find_in_range_node()   在 memory−reserved 中搜索  memblock.c:308
        │ 2. memblock_reserve(found, size)    立即记账                 memblock.c:1084
        │ 3. phys_to_virt(found)              返回虚拟地址
        v
启动结束后: memblock_free_all() 把 memory−reserved 的剩余部分整块归还伙伴系统

18.1.3 e820__memblock_setup():从固件表到 memblock

setup_arch() 中两条线的汇合点是 e820__memblock_setup()(e820.c:1288-1345):

// arch/x86/kernel/e820.c:1288(节选)
__init void e820__memblock_setup(void)
{
    ...
    /* 热插拔场景: 自底向上分配, 让内核映像远离可热插拔内存 */
    if (movable_node_is_enabled())
        memblock_set_bottom_up(true);

    /* 此刻只有第一个 1MB 确定被映射, 分配上限压在 1MB */
    memblock_set_current_limit(ISA_END_ADDRESS);           // e820.c:1321

    /* EFI 可能传入超过 128 条的 E820 条目, 允许数组自动扩容 */
    memblock_allow_resize();                               // e820.c:1332

    for (idx = 0; idx < e820_table->nr_entries; idx++) {
        struct e820_entry *entry = &e820_table->entries[idx];

        end = entry->addr + entry->size;
        if (end != (resource_size_t)end)
            continue;              /* 超出资源框架范围的条目跳过 */

        if (entry->type == E820_TYPE_SOFT_RESERVED)
            memblock_reserve(entry->addr, entry->size);    // e820.c:1340

        if (entry->type != E820_TYPE_RAM)
            continue;              /* 非 RAM 一律不进可用账本 */

        memblock_add(entry->addr, entry->size);                // e820.c:1344
    }
    ...
}

三个细节体现了启动期的鸡生蛋困境:其一,memblock_set_current_limit(ISA_END_ADDRESS) 把初始分配压在 1MB 内,因为此刻只有低 1MB 有映射,数组扩容需要的内存只能从这里来;其二,memblock_allow_resize() 放开扩容限制时特意注明"此时已知道所有保留区,扩容不会踩到保留内存";其三,E820 的 SOFT_RESERVED 走 reserve 而非 add,固件的"软保留"对内核而言就是硬保留。

18.1.4 memblock_free_all():账本移交伙伴系统

启动接近尾声时,mm_init() 调用 memblock_free_all()(mm/memblock.c:2340-2349)完成历史交接:

// mm/memblock.c:2340-2349
void __init memblock_free_all(void)
{
    unsigned long pages;

    free_unused_memmap();            /* 释放 memmap 数组覆盖的空洞 */
    reset_all_zones_managed_pages(); /* 归零 managed_pages 统计       memblock.c:2316 */

    memblock_clear_kho_scratch_only();
    pages = free_low_memory_core_early();  /* 核心: 逐 region 释放    */
    totalram_pages_add(pages);             /* 累计总可用页数          */
}

free_low_memory_core_early() 用 for_each_free_mem_range() 遍历 memory−reserved 的差集,把每个空闲 region 按 MAX_PAGE_ORDER 对齐切分后交给 __free_pages_core()——后者调用伙伴系统的 __free_pages_ok() 入链。至此可用内存从"memblock 账本"整体划转给"伙伴系统仓库",memblock 代码本身被标记 __init 释放,物理内存管理的控制权永久移交。

交接的账目对账 (对 totalram_pages 的贡献):

 free_all 前: memblock 记账 "可用 = X 页"
 free_all 过程:
   region [1M,16M内核] [16M,4G] ... 逐块 __free_pages_core
     → 每页挂 buddy 链 + adjust_managed_page_count(+1)
 free_all 后: buddy 的 managed_pages 总和 == X
   (reset_all_zones_managed_pages 先归零再累加 —
    防止 NUMA 恢复路径的二次累加, memblock.c:2316)
 /proc/meminfo 的 MemTotal 就来自这笔账

注意:reset_node_managed_pages()(memblock.c:2316)先归零统计是因为 free_low_memory_core_early() 释放过程中会调用 adjust_managed_page_count() 累加;两次调用顺序颠倒会导致 managed_pages 双倍虚增。这一防御性设计源于 NUMA 热插拔恢复路径可能触发二次初始化。


小结

本节梳理了物理内存管理的第一层账本:固件通过 E820 接口交出物理内存地图,内核维护工作/kexec/firmware 三份副本并经 e820__update_table() 净化;e820__memblock_setup() 把 E820_TYPE_RAM 条目翻译进 memblock 的 memory 数组,非 RAM 与软保留条目进入 reserved 或被忽略。memblock 用"有序不重叠 region 数组 + 两遍插入算法"维护账本,分配即"find + reserve"的组合,受 bottom_up 方向与 current_limit 上限约束。启动尾声 memblock_free_all() 把差集页块整块划转伙伴系统,完成控制权交接。下一节将看到这批页帧到达伙伴系统前,如何先被组织进 NUMA 节点与管理区。

18.2 内存节点 (pg_data_t) 与管理区 (zone)

memblock 只回答"有哪些内存",不回答"这一页离这颗 CPU 有多远、能不能满足 DMA 约束、还剩多少才能继续分配"。这些问题由两层运行时结构回答:NUMA 节点 pg_data_t 描述拓扑归属,管理区 struct zone 描述地址约束与水位。3.2 节在 setup_arch() 中见过 initmem_init() 建立节点拓扑,本节结合 Linux 7.0.10 源码,逐字段分析这两个结构与它们之间的 zonelist 分配优先级链。


18.2.1 为什么需要节点与管理区两级划分

物理内存的两级组织

   NUMA 机器                         UMA 机器(单节点)
  ┌──────────┐  ┌──────────┐        ┌─────────────────────────┐
  │ Node 0   │  │ Node 1   │        │ NODE_DATA(0)            │
  │ pgdat    │  │ pgdat    │        │  ┌────────────────────┐ │
  │ CPU0..3  │  │ CPU4..7  │        │  │ ZONE_DMA32 16MB-4G │ │
  │ ┌──────┐ │  │ ┌──────┐ │        │  │ ZONE_NORMAL  >4G   │ │
  │ │DMA32 │ │  │ │DMA32 │ │        │  │ ZONE_MOVABLE 用户划 │ │
  │ │NORMAL│ │  │ │NORMAL│ │        │  └────────────────────┘ │
  │ │MOVABLE│ │  │ │MOVABLE│ │       └─────────────────────────┘
  │ └──────┘ │  │ └──────┘ │
  └──────────┘  └──────────┘
      本地内存        本地内存       zone 存在的理由:
      访问快          访问快        1. DMA 寻址约束(老设备只能访问低地址)
      跨节点访问慢    跨节点访问慢   2. 高内存(32位时代超出内核线性映射)
                                   3. 迁移隔离(ZONE_MOVABLE 保热插拔)

节点的存在源于 NUMA:远端内存访问延迟可能是本地的 1.5-2 倍,分配器必须优先本地。管理区的存在源于地址约束:某些 DMA 设备只能寻址 4GB 以下,32 位时代还存在内核线性映射不完整的高内存——把不同约束的内存分仓管理,分配时按约束筛选,比在每个分配点做特判干净得多。

18.2.2 enum zone_type 与分区策略

// include/linux/mmzone.h:784-871
enum zone_type {
#ifdef CONFIG_ZONE_DMA
    ZONE_DMA,       /* 遗留 16MB 以下: 老 ISA 设备 24 位寻址 */
#endif
#ifdef CONFIG_ZONE_DMA32
    ZONE_DMA32,     /* 4GB 以下: 32 位 DMA 设备 */
#endif
    ZONE_NORMAL,        /* 内核可直接映射的常规内存, 永远存在 */
#ifdef CONFIG_HIGHMEM
    ZONE_HIGHMEM,       /* 32 位时代: 内核线性映射之外的高内存 */
#endif
    ZONE_MOVABLE,       /* 只放可迁移页, 保障热插拔/大页 */
#ifdef CONFIG_ZONE_DEVICE
    ZONE_DEVICE,        /* pmem/DAX 等设备内存的 struct page 域 */
#endif
    __MAX_NR_ZONES
};

枚举顺序即分配优先级:从 ZONE_DMA 到 ZONE_DEVICE,地址约束越来越宽松。分配时指定 highest_zoneidx(能接受的最高即最宽松区),扫描器沿枚举从高约束向低约束遍历——要分配 DMA 内存时只能进 ZONE_DMA,要普通内存时 DMA/DMA32/NORMAL 都可接受。这个顺序也被写进每个节点的 node_zones[] 数组,使 for_each_zone_zonelist 的扫描天然满足"约束优先"。

x86_64 的默认布局:ZONE_DMA(<16MB)、ZONE_DMA32(16MB-4GB)、ZONE_NORMAL(4GB 以上)、ZONE_MOVABLE(可选)。ZONE_HIGHMEM 在 64 位上因直接映射完整覆盖而消失——这是 64 位架构对 18.4 节 page_address() 无需 kmap 的根本原因。

x86_64 物理内存到 zone 的实际划分 (16GB 机器示例):

 物理地址
  0 ────┬──────────────────────────────
        │ ZONE_DMA    [0, 16MB)        ← 老设备 ISA 窗口
 16MB ──┼──────────────────────────────
        │ ZONE_DMA32  [16MB, 4GB)      ← 32 位 DMA 窗口
  4G ───┼──────────────────────────────
        │ ZONE_NORMAL [4GB, 16GB)      ← 主力
 16G ───┴──────────────────────────────
 ZONE_MOVABLE: 从上述范围"再切一刀" (kernelcore=
   参数指定多少内存划为只放可迁移页 — 18.5 节
   热插拔的保障) — movable 是"纵切"不是新区间!

18.2.3 struct zone:管理区账本

struct zone(mmzone.h:879-1056)按访问热度分了三个缓存行区段(CACHELINE_PADDING 显式对齐):

// include/linux/mmzone.h:879(节选, 保留原注释要点)
struct zone {
    /* ---- 读多写少区 ---- */
    unsigned long _watermark[NR_WMARK]; /* :883 水位数组 */
    unsigned long watermark_boost;
    unsigned long nr_reserved_highatomic;   /* HIGHATOMIC 保留页块统计 */
    unsigned long nr_free_highatomic;
    long lowmem_reserve[MAX_NR_ZONES];  /* :898 低区保护水位 */

#ifdef CONFIG_NUMA
    int node;               /* 所属节点号 */
#endif
    struct pglist_data  *zone_pgdat;    /* :903 回指所属 pgdat */
    struct per_cpu_pages    __percpu *per_cpu_pageset;  /* :904 PCP */
    struct per_cpu_zonestat __percpu *per_cpu_zonestats;

    /* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
    unsigned long       zone_start_pfn;     /* :931 起始 PFN */
    /*
     * spanned_pages = zone_end_pfn - zone_start_pfn (含空洞)
     * present_pages = spanned_pages - absent_pages  (去掉空洞)
     * managed_pages = present_pages - reserved_pages(伙伴系统接管数)
     */
    atomic_long_t       managed_pages;      /* :972 */
    unsigned long       spanned_pages;      /* :973 */
    unsigned long       present_pages;      /* :974 */
    const char      *name;

    /* ---- 写密集: 分配路径 ---- */
    CACHELINE_PADDING(_pad1_);
    struct free_area    free_area[NR_PAGE_ORDERS];  /* :1019 伙伴仓库 */
    unsigned long       flags;
    spinlock_t      lock;           /* :1021 保护 free_area */
    struct llist_head   trylock_free_pages; /* :1023 trylock 成功时顺带释放的页 */

    /* ---- 写密集: 压缩与统计 ---- */
    CACHELINE_PADDING(_pad2_);
    unsigned long percpu_drift_mark;    /* :1037 PCP 漂移纠正阈值 */
    unsigned long       compact_cached_free_pfn;    /* 压缩扫描游标 */
    ...
    CACHELINE_PADDING(_pad3_);
    atomic_long_t       vm_stat[NR_VM_ZONE_STAT_ITEMS]; /* 分区统计 */
    atomic_long_t       vm_numa_event[NR_VM_NUMA_EVENT_ITEMS];
} ____cacheline_internodealigned_in_smp;

三个页数统计的语义分工是理解水位的钥匙:spanned_pages 是范围大小(含空洞)、present_pages 是物理存在页数(减空洞)、managed_pages 是伙伴系统实际接管页数(再减保留)。

三种页数的关系图 (一个含空洞的 zone):

 zone_start_pfn                                      zone_end
   ▼                                                  ▼
   ├─────────────┬───────────┬────────────────────────┤
   │  普通可管理  │  空洞      │  普通可管理 (含保留)      │
   └─────────────┴───────────┴────────────────────────┘
   spanned_pages = 全范围页数          (含洞)
   present_pages = spanned − 空洞      (物理存在)
   managed_pages = present − 保留页    (伙伴接管, atomic_long
                                       因热插拔运行时增减)

热插拔运行时增减的是 present_pages 与 managed_pages(18.5 节的 adjust_managed_page_count()),而 zone_start_pfn/spanned_pages 的修改被 span_seqlock(mmzone.h:1007)保护——因为读路径(分配主路径)不持 zone->lock,用 seqlock 允许无锁读、偶发重试。

三个统计量与 watermark 的关系

// include/linux/mmzone.h:708-713
enum zone_watermarks {
    WMARK_MIN,  /* 兜底水位: 低于它必须同步回收, 只许内核关键分配 */
    WMARK_LOW,  /* 唤醒 kswapd: 低于它触发后台回收 (默认 min+1/64) */
    WMARK_HIGH, /* 回收目标: kswapd 回收到它为止 */
    WMARK_PROMO,    /* NUMA promotion 专用水位 */
    NR_WMARK
};

水位初值在 __setup_per_zone_wmarks()(page_alloc.c:6441-6500)计算:min 由 min_free_kbytes(sysctl 可调,内核按内存大小自动估算)按比例分摊到各 zone,并叠加 lowmem_reserve 的贡献;low 与 high 由 min 按比例放大(具体比例见 page_alloc.c:6460-6480 的 mult_frac 计算)。运行时 min_free_kbytes 变化会触发 setup_per_zone_wmarks()(page_alloc.c:6512)重算。

水位与分配路径的对应 (18.3.6 节慢路径的触发源):

 free
  ▲
  │ high ──── kswapd 的回收目标线
  │ low ───── 唤醒 kswapd (分配继续, PCP 还有余粮)
  │ min ───── 直接回收线: get_page_from_freelist
  │            按 ALLOC_WMARK_MIN 检查失败即慢路径
  │ boost ─── watermark_boost: 规整/迁移期间的临时抬升
  ▼ 0

 lowmem_reserve 的保护算式 (zone_watermark_ok 内):
 检查 ZONE_DMA 时要求: free_DMA ≥ min_DMA
                       + lowmem_reserve[DMA][DMA32]  ← 保护额
                       + lowmem_reserve[DMA][NORMAL]
 高区分配者最多把低区吃到"保护额"为止

lowmem_reserve[MAX_NR_ZONES](mmzone.h:898)解决"高区不能把低区吃干"的问题:若允许 ZONE_NORMAL 的分配把 ZONE_DMA32 消耗殆尽,后来的 DMA32 约束分配必然失败。因此每次分配检查 zone 水位时,都会从该 zone 的空闲页中扣掉为更高约束区预留的份额再比较——实现位于 __zone_watermark_ok()(page_alloc.c:3610-3688)的低区循环。

PCP:per-CPU 页缓冲

// include/linux/mmzone.h:744-761
struct per_cpu_pages {
    spinlock_t lock;        /* 保护 lists 字段 */
    int count;          /* 链表当前页数 */
    int high;           /* 高水位: 超过则归还 buddy */
    int high_min;           /* 自适应下界 */
    int high_max;           /* 自适应上界 */
    int batch;          /* 与 buddy 批量交换的块大小 */
    u8 flags;
    u8 alloc_factor;        /* 分配时 batch 扩大因子 */
#ifdef CONFIG_NUMA
    u8 expire;          /* 远端 pageset 排空计时 */
#endif
    short free_count;       /* 连续 free 计数 */
    struct list_head lists[NR_PCP_LISTS];   /* 按迁移类型 x 阶分链 */
} ____cacheline_aligned_in_smp;

单页分配若每次都进 buddy 需要拿 zone->lock,多核下是显著瓶颈。PCP 为每个 CPU × 每个 zone 缓存一批页,单页分配/释放只在 PCP 链表上操作(自旋 pcp->lock),只有 PCP 空/满时才与 buddy 批量交换 batch 页。

PCP 的补货/排空循环:

 分配:  pcp->list ──弹一页──> 调用者
          │ 空?
          ├─ 是 → rmqueue_bulk (page_alloc.c:2555)
          │        持 zone->lock 一次从 buddy 批量
          │        搬 batch 页入 PCP (18.3.4 节)
 释放:  调用者 ──压一页──> pcp->list
          │ count > high?
          ├─ 是 → 连续 free_count 次后批量退回 buddy
 high 自适应: zone 水位低于 high (44 章无关, 见
   page_alloc.c:3915 的 ZONE_BELOW_HIGH) 时调低
   pcp->high — 缓存大小随内存压力伸缩

NR_PCP_LISTS = MIGRATE_PCPTYPES * (PAGE_ALLOC_COSTLY_ORDER + 1) + NR_PCP_THP(mmzone.h:726)——低阶按迁移类型分链防碎片,THP 若配置则再分两条专链。high 不再是固定值:7.0 内核的 PCP 高水位在 [high_min, high_max] 间自适应浮动(zone 水位低于 high 时在释放路径调低 pcp->high,见 get_page_from_freelist 中 ZONE_BELOW_HIGH 标志的设置逻辑,page_alloc.c:3915-3924),让缓存大小随内存压力伸缩。

18.2.4 pg_data_t:NUMA 节点容器

// include/linux/mmzone.h:1381(节选)
typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];   /* :1387 本节点全部 zone(可能空) */
    struct zonelist node_zonelists[MAX_ZONELISTS];  /* :1394 本节点的分配优先级链 */
    int nr_zones;               /* :1396 已填充 zone 数 */
#ifdef CONFIG_FLATMEM
    struct page *node_mem_map;      /* page 数组(非 SPARSEMEM 时) */
#endif
    spinlock_t node_size_lock;      /* :1409 保护以下范围字段(热插拔/延迟初始化) */
    unsigned long node_start_pfn;       /* :1411 */
    unsigned long node_present_pages;   /* :1412 物理存在页数 */
    unsigned long node_spanned_pages;   /* :1413 范围页数(含空洞) */
    int node_id;
    wait_queue_head_t kswapd_wait;      /* :1415 kswapd 睡眠队列 */
    wait_queue_head_t pfmemalloc_wait;
    wait_queue_head_t reclaim_wait[NR_VMSCAN_THROTTLE]; /* 回收节流队列 */
    atomic_t nr_writeback_throttled;
    struct task_struct *kswapd;     /* :1429 本节点回收线程 */
    int kswapd_order;
    enum zone_type kswapd_highest_zoneidx;
    atomic_t kswapd_failures;       /* :1432 连续空转失败计数 */
#ifdef CONFIG_COMPACTION
    int kcompactd_max_order;        /* :1436 压缩线程请求阶 */
    enum zone_type kcompactd_highest_zoneidx;
    wait_queue_head_t kcompactd_wait;
    struct task_struct *kcompactd;      /* :1439 本节点压缩线程 */
#endif
    unsigned long       totalreserve_pages; /* :1446 节点保留页(不给用户态) */
    ...
    struct lruvec       __lruvec;   /* :1517 LRU 向量(MEMCG 关闭时使用) */
    ...
};

每个 zone 回指所属 pgdat(zone_pgdat),每个 pgdat 拥有固定长度的 node_zones[MAX_NR_ZONES]——未被填充的 zone 存在但为空(present_pages == 0),这使得 zone 下标稳定、for_each_zone 无需动态表。kswapd/kcompactd 两个内核线程每节点各一对,24.2 节将展开它们的工作循环。

节点描述符的查找入口 NODE_DATA(nid)(mmzone.h:1678):x86_64 经 node_data[] 指针数组二次跳转(数组本身在启动期由 memblock 分配并按 SRAT 拓扑填充),ARM64/RISC-V 也各自实现了同一宏。

18.2.5 zonelist:分配优先级链

分配时如何决定"先试哪个 zone、失败再试哪个"?答案是预编译好的 zonelist:

// include/linux/mmzone.h:1292-1303
enum {
    ZONELIST_FALLBACK,  /* 带回退的完整优先级链 */
#ifdef CONFIG_NUMA
    ZONELIST_NOFALLBACK,    /* 限制在本节点的链 (__GFP_THISNODE 用) */
#endif
    MAX_ZONELISTS
};

struct zoneref {            /* mmzone.h:1308 */
    struct zone *zone;      /* 目标 zone 指针 */
    int zone_idx;           /* 缓存 zone 索引, 免解引用比较 */
};

build_zonelists()(page_alloc.c:5712-5739)在启动期为每个节点各建两条链:

Node 1 上的分配, ZONELIST_FALLBACK 优先级顺序:

  本节点 zones (约束序)        距离排序的远端节点 zones
  ┌──────────────────┐        ┌──────────────────────────┐
  │ node1: DMA,DMA32,│        │ node0(近): DMA,DMA32,    │
  │ NORMAL,MOVABLE   │ --->   │ NORMAL,MOVABLE           │
  └──────────────────┘        │ node2(远): ...           │
   build_thisnode_zonelists   └──────────────────────────┘
   只取本节点                    build_zonelists_in_node_order
   (存入 ZONELIST_NOFALLBACK)     按 node_distance 排序后拼接

 一条完整的 fallback 链示例 (node1 发起普通分配):
  node1/NORMAL → node1/DMA32 → node1/DMA
   → node0/NORMAL → node0/DMA32 → ...
   (同节点内 zone 序; 节点间按距离; 同距离按节点号)

build_zonerefs_node()(page_alloc.c:5559)把一个节点的非空 zone 按枚举序拷入链中;NUMA 版本先按 node_distance 把所有可达节点排序(近者优先、同距按节点号),再依次拼接各节点 zones。单节点(UMA)系统两条链都只指向 NODE_DATA(0),退化后零开销。分配路径 get_page_from_freelist() 沿 for_next_zone_zonelist_nodemask 遍历这条链,18.3 节将展开它。

18.2.6 启动期装配顺序

setup_arch()                             mm_init()                    page_alloc.c
────────────────                        ────────────                 ─────────────
initmem_init()                            │                            │
  → 建 pgdat, 填 node_start_pfn 等        │                            │
x86_numa_init() / SRAT 解析               │                            │
  → NODE_DATA(nid) 数组就位               │                            │
free_area_init()                          │                            │
  → 每 zone 填 zone_start_pfn/            │                            │
    spanned/present_pages                 │                            │
  → free_area[] 数组初始化                │                            │
memblock_free_all() ─────────────────> 页帧入 buddy 仓库             │
                                         build_all_zonelists() ────> build_zonelists()
                                         mem_init() 打印             │
                                         setup_per_zone_wmarks() ──> 水位计算
                                                              (装配完成, 分配器可用)

zone 的范围字段在 free_area_init()(mm/mm_init.c)中填充,ZONE_MOVABLE 的边界由 find_zone_movable_pfns_for_nodes()(mm/mm_init.c:357)依据 kernelcore=/movablecore= 参数划分——它决定"多少内存被划为只放可迁移页",18.5 节的热插拔成功率直接受益于此划分。zonelist 构建与水位计算在 mm_init() 末尾完成(build_all_zonelists() 与 setup_per_zone_wmarks()),此后页分配器进入可用状态。


小结

本节剖析了运行期内存管理的两级容器:pg_data_t 按 NUMA 拓扑持有节点内全部 zone 与两条预编译 zonelist(完整回退链 + 本节点专用链),struct zone 按缓存行热度分区,持有水位(MIN/LOW/HIGH/PROMO)、低区保护 lowmem_reserve、per-CPU 页缓冲与伙伴仓库 free_area[]。三个页数统计(spanned/present/managed)分别服务范围、物理存在与伙伴接管三个语义,zone 枚举顺序即分配约束优先级。zone 划分与水位、zonelist 一起在启动末期装配完毕,为下一节的伙伴系统提供了调度仓库的"货架"。

18.3 伙伴系统 (Buddy System)

18.2 节的 free_area[] 是仓库货架,本节分析仓库本身:伙伴系统如何以 O(log N) 的代价分配任意 2^order 页的连续块、如何在释放时把"凑成对"的块逐级合并,以及 PCP 缓冲、migratetype 防碎片机制如何叠加在这套算法之上。分配接口 alloc_pages() 到慢路径(回收/压缩/OOM)的完整决策链也在本节一并展开。全部行号针对 Linux 7.0.10 本树布局(如 __rmqueue_claim 四级回退状态机、PCP 自适应 high、HIGHATOMIC 1% 保留上限)。


18.3.1 数据结构:阶链表数组

// include/linux/mmzone.h:32-38
#define MAX_PAGE_ORDER CONFIG_ARCH_FORCE_MAX_ORDER  /* x86_64 默认 10 */
#define MAX_ORDER_NR_PAGES (1 << MAX_PAGE_ORDER)
#define NR_PAGE_ORDERS (MAX_PAGE_ORDER + 1)     /* mmzone.h:38 */

// include/linux/mmzone.h:138-141
struct free_area {
    struct list_head    free_list[MIGRATE_TYPES];   /* 每迁移类型一条链 */
    unsigned long       nr_free;        /* 本阶空闲页总数 */
};

伙伴系统把每个 zone 的空闲内存按 2 的幂 分阶管理:free_area[k] 存放所有大小为 2^k 页的空闲块,每块按 migratetype 挂到对应 free_list。x86_64 默认 MAX_PAGE_ORDER = 10,即最大块 1024 页 = 4MB;free_area 数组共 MAX_PAGE_ORDER + 1 = 11 阶。空闲块首页通过 private 字段记住自己的阶(set_buddy_order()),这被 __free_one_page() 与 /proc/buddyinfo 读取。

zone->free_area[] 结构(每 zone 一份)

阶        free_list[MIGRATE_UNMOVABLE]  [...]  [MIGRATE_MOVABLE]   nr_free
 10  →  [4MB 块]→NULL                  ...    [4MB 块]→NULL          2
  9  →  NULL                          ...    NULL                   0
  ...
  1  →  [2页]→[2页]→NULL               ...    NULL                   2
  0  →  [1页]→[1页]→[1页]→NULL         ...    [1页]→NULL             4
        ▲ 每条链由 zone->lock 保护, 页首地址 2^order 对齐

 /proc/buddyinfo 每行即一个 zone 的 11 阶空闲块计数 —
 "还能分配出多大连续块"的直接体检表

18.3.2 分配:__rmqueue_smallest 与 expand

核心查找例程 __rmqueue_smallest()(page_alloc.c:1927-1950):

// mm/page_alloc.c:1927
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
                        int migratetype)
{
    unsigned int current_order;
    struct free_area *area;
    struct page *page;

    /* 从请求阶开始逐阶向上找第一个非空链表 */
    for (current_order = order; current_order < NR_PAGE_ORDERS; ++current_order) {
        area = &(zone->free_area[current_order]);
        page = get_page_from_free_area(area, migratetype);
        if (!page)
            continue;

        /* 摘下大块后劈裂到请求阶 */
        page_del_and_expand(zone, page, order, current_order,
                    migratetype);
        trace_mm_page_alloc_zone_locked(page, order, migratetype, ...);
        return page;
    }
    return NULL;
}

劈裂由 expand()(page_alloc.c:1740-1766)完成——从最高阶往下,每步把后半块放回低一阶链表:

// mm/page_alloc.c:1740
static inline unsigned int expand(struct zone *zone, struct page *page, int low,
                  int high, int migratetype)
{
    unsigned int size = 1 << high;
    unsigned int nr_added = 0;

    while (high > low) {
        high--;
        size >>= 1;
        ...
        __add_to_free_list(&page[size], zone, high, migratetype, false);
        set_buddy_order(&page[size], high);   /* 后半块记住自己的阶 */
        nr_added += size;
    }
    return nr_added;
}
expand 劈裂的全过程 (请求 order=0, 命中阶 3 的 8 页块, 编号 0-7):

摘下 [01234567]
high=3→2: 后半块 [4567] 入阶2链        ┌───────────┬───────────┐
high=2→1: 后半块 [23]   入阶1链        │ 0 1 2 3   │ 4 5 6 7   │
high=1→0: 后半块 [1]    入阶0链        └─────┬─────┴───────────┘
返回 page[0]                                  劈裂位置: pfn 与 2^k
                                              的按位异或即伙伴地址

 关键观察: 劈裂产生的半块**立刻可用** —
 后续的相邻小分配会直接命中这些半块
 (空间局部性: 同一大块内的页物理相邻)

page_del_and_expand()(page_alloc.c:1768-1775)把"摘块 + 劈裂 + 计数修正"打包:nr_pages -= expand(...) 算出真正交付的页数做 account_freepages 统计。注意劈裂产生的半块不再需要立刻配对——它们留在各阶链表里,等释放时靠 pfn 异或找到彼此。

18.3.3 释放:__free_one_page 的合并循环

释放路径的核心 __free_one_page()(page_alloc.c:978-1078):

// mm/page_alloc.c:978(节选)
static inline void __free_one_page(struct page *page,
        unsigned long pfn, struct zone *zone, unsigned int order,
        int migratetype, fpi_t fpi_flags)
{
    struct capture_control *capc = task_capc(zone);
    unsigned long buddy_pfn = 0;
    unsigned long combined_pfn;
    struct page *buddy;
    bool to_tail;

    ...
    account_freepages(zone, 1 << order, migratetype);

    while (order < MAX_PAGE_ORDER) {
        int buddy_mt = migratetype;

        if (compaction_capture(capc, page, order, migratetype)) {
            /* 压缩器正等待这个块: 截留, 不再入链 */
            account_freepages(zone, -(1 << order), migratetype);
            return;
        }

        buddy = find_buddy_page_pfn(page, pfn, order, &buddy_pfn);
        if (!buddy)
            goto done_merging;  /* 伙伴不空闲: 停止合并 */

        if (unlikely(order >= pageblock_order)) {
            buddy_mt = get_pfnblock_migratetype(buddy, buddy_pfn);
            /* 迁移类型不可合并(如 ISOLATE/CMA)时停止, 防止统计错乱 */
            if (migratetype != buddy_mt &&
                (!migratetype_is_mergeable(migratetype) ||
                 !migratetype_is_mergeable(buddy_mt)))
                goto done_merging;
        }

        /* 伙伴空闲: 从其链表摘下, 合并升级一阶 */
        if (page_is_guard(buddy))
            clear_page_guard(zone, buddy, order);
        else
            __del_page_from_free_list(buddy, zone, order, buddy_mt);

        if (unlikely(buddy_mt != migratetype))
            change_pageblock_range(buddy, order, migratetype);

        combined_pfn = buddy_pfn & pfn;     /* 合并块首 = 两 pfn 按位与 */
        page = page + (combined_pfn - pfn);
        pfn = combined_pfn;
        order++;
    }

done_merging:
    set_buddy_order(page, order);       /* 合并终态块记录阶 */
    if (fpi_flags & FPI_TO_TAIL)
        to_tail = true;
    else if (is_shuffle_order(order))
        to_tail = shuffle_pick_tail();  /* 随机化入头/尾, 对抗可预测性 */
    else
        to_tail = buddy_merge_likely(pfn, buddy_pfn, page, order);

    __add_to_free_list(page, zone, order, migratetype, to_tail);
    ...
}

伙伴定位的代数:pfn 为 p、阶为 k 的块的伙伴 pfn 是 p XOR (1<<k);两块合并后的新首块是 p & buddy_pfn(两者中较小者,因为合法伙伴只在最低相异位不同)。整个合并循环是滚雪球式的:成功合并一次后块地址与阶都更新,继续向上找更大伙伴,直到伙伴不在链上或到达 MAX_PAGE_ORDER。

释放合并的滚雪球全过程 (释放 [pfn=20, order=0]):

  阶0: 伙伴 = 20 ^ 1 = 21.  21 空闲? 是
        → 摘下 [21], 合并块首 = 20 & 21 = 20, 阶升 1
  阶1: 伙伴 = 20 ^ 2 = 22.  22 空闲? 是
        → 摘下 [22-23], 合并块首 = 20 & 22 = 20, 阶升 2
  阶2: 伙伴 = 20 ^ 4 = 16.  16 空闲? 否 → 停止
  结果: [pfn=20, order=2] (20-23 页块) 入阶2链表

  pfn 的二进制视角 (低 3 位):
   20 = 10100   每次合并消去一个最低的 1
   21 = 10101   XOR 找伙伴: 翻转最低位
   22 = 10110   AND 合并: 保留共同前缀
   23 = 10111
   16 = 10000   ← 不空闲, 合并到此为止

compaction_capture() 截留与 pageblock 迁移类型合并检查是两个边界条件:前者让压缩器直接"预订"刚释放的合适块(免得先入链再扫描);后者防止跨 CMA/隔离区边界合并导致 nr_free 统计与迁移类型语义错位。

18.3.4 防碎片:migratetype 与 fallback

连续物理页的敌人不是总量耗尽,而是碎片:空闲页不少,却找不到连续 512 页。内核的对策是把每个 pageblock(默认 2MB)标注为一种迁移类型,同类页聚居、互不侵染:

// include/linux/mmzone.h:64-89(节选)
enum migratetype {
    MIGRATE_UNMOVABLE,  /* 内核数据: 不可迁移 (page table, slab...) */
    MIGRATE_MOVABLE,    /* 用户页/文件页: 可任意迁移 */
    MIGRATE_RECLAIMABLE,    /* 可回收: 可迁移或直接释放 (文件缓存) */
    MIGRATE_PCPTYPES,   /* = 以上三种的计数边界: PCP 只缓存这三种 */
    MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES,  /* 高阶原子保留区(不上 PCP) */
    MIGRATE_CMA,        /* CMA 区域: 仅 movable 请求可借 */
    __MIGRATE_TYPE_END = MIGRATE_CMA,
    MIGRATE_ISOLATE,    /* 隔离区: 不可分配(热插拔/迁移中) */
    MIGRATE_TYPES
};

pageblock 大小由 pageblock_order(pageblock-flags.h:60-71)决定:x86_64 取 HPAGE_PMD_ORDER(2MB);迁移类型以位图形式存于 zone(非 SPARSEMEM)或 mem_section(mmzone.h:1917)的 pageblock_flags 中。

当首选类型的链表耗尽时,按 fallbacks 表借用其他类型(page_alloc.c:1959-1963):

// mm/page_alloc.c:1959
static int fallbacks[MIGRATE_PCPTYPES][MIGRATE_PCPTYPES - 1] = {
    [MIGRATE_UNMOVABLE]   = { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE   },
    [MIGRATE_MOVABLE]     = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE },
    [MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE,   MIGRATE_MOVABLE   },
};
回退方向的"破坏性递增"图解:

 请求 UNMOVABLE 失败时:
   UNMOVABLE ──借──> RECLAIMABLE ──再借──> MOVABLE
   (借来的块改成 UNMOVABLE: 可迁块变不可迁 = 固化,
    但"不可迁的请求"不会因为借用而更难满足)

 请求 MOVABLE 失败时:
   MOVABLE ──最后才借──> UNMOVABLE
   (把不可迁块标成 MOVABLE 是谎言 — 放最后万不得已)

 原则: 永远朝"更不迁移友好"的方向借用,
 借用本身不可逆 (pageblock 类型被改写)

回退方向严格遵循破坏性递增原则:UNMOVABLE 借 RECLAIMABLE/MOVABLE 的块不会留下"将来没法迁移"的坑(借来的块被改成 UNMOVABLE,本就不可迁);反过来 MOVABLE 借 UNMOVABLE 的块则把可迁块"污染"成不可迁——放在最后万不得已才做,且借取时用 __rmqueue_steal()(page_alloc.c:2449)尽量从整块 pageblock 级别夺取,保持 pageblock 内类型纯度。

__rmqueue 的四级回退状态机

// mm/page_alloc.c:2485(节选, 状态机注释为本书归纳)
static __always_inline struct page *
__rmqueue(struct zone *zone, unsigned int order, int migratetype,
      unsigned int alloc_flags, enum rmqueue_mode *mode)
{
    struct page *page;

    /* CMA 自由内存超过本 zone 一半时, 平衡使用 CMA 区 */
    if (IS_ENABLED(CONFIG_CMA) && (alloc_flags & ALLOC_CMA) &&
        zone_page_state(zone, NR_FREE_CMA_PAGES) >
        zone_page_state(zone, NR_FREE_PAGES) / 2) {
        page = __rmqueue_cma_fallback(zone, order); // :1966
        if (page)
            return page;
    }

    switch (*mode) {            /* mode 跨调用记忆上次成功层级 */
    case RMQUEUE_NORMAL:            /* 1. 首选迁移类型直取 */
        page = __rmqueue_smallest(zone, order, migratetype);
        if (page)
            return page;
        fallthrough;
    case RMQUEUE_CMA:           /* 2. CMA 区 */
        if (alloc_flags & ALLOC_CMA) { ... }
        fallthrough;
    case RMQUEUE_CLAIM:         /* 3. pageblock 声索: 从同类型整块劈分 */
        page = __rmqueue_claim(zone, order, migratetype, alloc_flags);
        if (page) {
            *mode = RMQUEUE_NORMAL;     /* 补货成功回到常规模式 */
            return page;
        }
        fallthrough;
    case RMQUEUE_STEAL:         /* 4. 跨类型夺取 (ALLOC_NOFRAGMENT 时禁用) */
        if (!(alloc_flags & ALLOC_NOFRAGMENT)) {
            page = __rmqueue_steal(zone, order, migratetype);
            if (page) { *mode = RMQUEUE_STEAL; return page; }
        }
    }
    return NULL;
}

rmqueue_mode 作为出参在 rmqueue_bulk()(page_alloc.c:2555,PCP 批量补货)的循环中跨调用传递:补货期间 zone->lock 一直持有、链表无外部变化,上一页的成功层级对下一页同样大概率成立,从而省掉重复的 fallback 搜索。

18.3.5 HIGHATOMIC:高阶原子分配的保留地

不可睡眠的高阶分配(如中断上下文申请 2 阶)无法等待回收,内核为其预留"禁区":

  • 预留:reserve_highatomic_pageblock()(page_alloc.c:3452-3502)把一个 pageblock 改标 MIGRATE_HIGHATOMIC,上限约为本 zone managed pages 的 1%(max_managed = ALIGN(zone_managed_pages(zone) / 100, pageblock_nr_pages),page_alloc.c:3468-3470);
  • 使用:get_page_from_freelist 路径中 __rmqueue_smallest(zone, order, MIGRATE_HIGHATOMIC) 仅对 ALLOC_HARDER 的原子请求开放(page_alloc.c:3263-3276);
  • 解除:常规分配濒临失败时 unreserve_highatomic_pageblock()(page_alloc.c:3504)把保留区还回原迁移类型——宁可伤高阶可靠性,也要避免 OOM。
HIGHATOMIC 的供需时序:

 中断上下文 alloc_pages(GFP_ATOMIC, 2)
   └─ ALLOC_HARDER → 允许碰 HIGHATOMIC 区
      └─ 命中保留页块 → 立即成功 (零等待)
 常规分配 (睡眠类):
   不碰 HIGHATOMIC 区 → 保留区始终有货
 系统内存告急:
   unreserve_highatomic → 保留区还仓 → 常规分配续命
 (1% 上限: page_alloc.c:3468 — 保留太多伤常规负载)

18.3.6 分配主路径:从 alloc_pages 到慢路径

alloc_pages(gfp, order)
  └─ __alloc_pages_noprof()                    page_alloc.c:5287
       └─ __alloc_frozen_pages_noprof()        page_alloc.c:5222
            │ prepare_alloc_pages(): 定 zonelist/迁移类型/alloc_flags
            v
            get_page_from_freelist()           page_alloc.c:3816   [快速路径]
            │  沿 zonelist 逐 zone:
            │   cpuset 检查 → dirty 平衡 → ALLOC_NOFRAGMENT 局部性
            │   → zone_watermark_fast 水位检查 → __rmqueue (PCP/四级回退)
            │  成功 ────────────────> 返回 page
            v  失败
            __alloc_pages_slowpath()           page_alloc.c:4718   [慢路径]
              ├─ 唤醒 kswapd (ALLOC_KSWAPD, 24 章接手)
              ├─ __alloc_pages_direct_compact()   :4173  同步压缩出连续页
              ├─ 循环 { __alloc_pages_direct_reclaim()  :4445  同步回收
              │        should_reclaim_retry()       :4608  重试裁决
              │        __alloc_pages_direct_compact() :4298 }
              ├─ 无进展 → __alloc_pages_may_oom()   :4078  OOM Killer (24.4)
              └─ 最终再试快速路径 → 失败返回 NULL

快速路径的关键检查在 get_page_from_freelist()(page_alloc.c:3816-3930):zone_watermark_fast() 快速比较空闲页与水位(含 lowmem_reserve 扣除),不满足时置 ZONE_BELOW_HIGH 标志并降级到 alloc_flags & ALLOC_WMARK_MASK 指定的严格水位重查;ALLOC_NOFRAGMENT 首轮禁止跨类型 fallback 以保 NUMA 局部性(alloc_flags_nofragment(),page_alloc.c:5253-5254),跨到远端节点时反而清掉该标志("局部性优先于防碎片",page_alloc.c:3867-3877)。

PCP 命中判断在最外层 rmqueue()(page_alloc.c:3418-3441):pcp_allowed_order(order)(≤ PAGE_ALLOC_COSTLY_ORDER)先走 rmqueue_pcplist()——单页多数情况下根本不进 buddy;只有 PCP 空/满时才经 rmqueue_bulk() 以 batch 粒度与 buddy 批量交换。出口处顺带检查 ZONE_BOOSTED_WATERMARK 标志唤醒 kswapd(page_alloc.c:3433-3437)——"分配成功也可能是内存问题的信号"。

18.3.7 计数维护:managed_pages 与水位联动

// mm/page_alloc.c:6237-6243
void adjust_managed_page_count(struct page *page, long count)
{
    spin_lock(&managed_page_count_lock);
    page_zone(page)->managed_pages += count;
    totalram_pages_add(count);
    spin_unlock(&managed_page_count_lock);
}
EXPORT_SYMBOL(adjust_managed_page_count);

这是运行时增减"伙伴接管页数"的唯一正门,热插拔上下线(18.5 节)与内核映像释放都经它改账。managed_pages 变化后,水位相应在 setup_per_zone_wmarks() 重算——账本与警界线永远同步。


小结

本节完整走了一遍伙伴系统:free_area[] 按 2 的幂分阶、每阶按 migratetype 分链;分配时 __rmqueue_smallest 从请求阶向上找块、expand 自高向低劈裂放回;释放时 __free_one_page 以 pfn 异或定位伙伴、滚雪球式合并升级。防碎片由 pageblock 迁移类型与 fallbacks 回退表(破坏性递增方向)、__rmqueue 四级状态机(直取/CMA/声明/夺取)、HIGHATOMIC 1% 保留区共同保障。分配主路径分两层:get_page_from_freelist 沿 zonelist 做水位检查的快速路径,以及回收、压缩、OOM 三板斧的慢路径。PCP 缓冲以 per-CPU 粒度吸收了大部分单页流量。下一节转向每页的元数据账本——struct page 与 folio。

18.4 页帧与 page 结构体

伙伴系统管理的是"空闲块",而内核对每一页物理内存都要有一行明细账——这一行就是 struct page。它是整个内存子系统最繁忙的数据结构:page cache 用它挂 LRU、页表用 PG_ 位判断属性、slab 用它找 kmem_cache、网络栈用它管理 page_pool。本节结合 Linux 7.0.10 源码逐字段拆解 struct page 的 memdesc 化布局(7.0 的重要改版:flags 从 unsigned long 改为 memdesc_flags_t 紧凑类型)、两个引用计数的分工、page flags 体系,以及 folio 抽象如何重塑"页"的语义。


18.4.1 struct page 全景

// include/linux/mm_types.h:79(节选, 保留原注释要点)
struct page {
    memdesc_flags_t flags;      /* :80 原子标志位(含类型标签), 可被异步更新 */
    /*
     * 下面 5 个字(20/40 字节)是一个大联合体。
     * 警告: 第一个字的 bit 0 属于 PageTail(), 其他用户不得占用该位。
     */
    union {
        struct {    /* 页缓存与匿名页 */
            union {
                struct list_head lru;       /* LRU 链表节点 */
                struct list_head buddy_list;    /* 或: 伙伴系统节点 */
                struct list_head pcp_list;  /* 或: PCP 链节点 */
                struct llist_node pcp_llist;
            };
            struct address_space *mapping;  /* 所属页缓存 */
            union {
                pgoff_t __folio_index;      /* 映射内偏移 */
                unsigned long share;        /* fsdax 共享计数 */
            };
            unsigned long private;      /* 私有数据(含义随标志变) */
        };
        struct {    /* 网络栈 page_pool 页 */
            unsigned long pp_magic;     /* 防误回收魔数 */
            struct page_pool *pp;
            unsigned long _pp_mapping_pad;
            unsigned long dma_addr;     /* DMA 地址 */
            atomic_long_t pp_ref_count;
        };
        struct {    /* 复合页尾页 */
            unsigned long compound_head;    /* 指头页, bit 0 置 1 */
        };
        struct {    /* ZONE_DEVICE 页 */
            void *_unused_pgmap_compound_head;
            void *zone_device_data;
        };
        struct rcu_head rcu_head;   /* 可经 RCU 释放 */
    };

    union {     /* 4 字节: page_type 与 _mapcount 二选一 */
        unsigned int page_type;     /* 类型化 folio 的类型标签 */
        atomic_t _mapcount;     /* :180 页表映射计数 */
    };

    atomic_t _refcount;         /* :184 引用计数(禁直接访问) */

#ifdef CONFIG_MEMCG
    unsigned long memcg_data;       /* :188 memcg 指针/描述符 */
#endif
#if defined(WANT_PAGE_VIRTUAL)
    void *virtual;              /* 高内存动态映射地址(64位无此项) */
#endif
    ...
};  /* sizeof: x86_64 上 64 字节 */

这个结构的设计哲学是一页多义:内存按用途分属不同子系统,但每个子系统只需要其中几个字段,于是字段被压进联合体——同一块内存,在页缓存语境下是 (lru, mapping, index, private),在 slab 语境下是 (slab_cache, freelist, counters),在 page_pool 语境下是 (pp_magic, pp, dma_addr)。判定当前语义的手段是 flags 中的类型位(PG_slab、PG_swapcache、PageTail 等),这就是为什么 flags 必须与第一个字"结对"。

5 个字的分工

struct page 布局 (x86_64, 8 字节/字)

 字 0   flags (memdesc_flags_t) ─┬─ 低 8 位: PG_locked..PG_waiters
                                 ├─ 中段: PG_active/PG_workingset/PG_dirty...
                                 └─ 高段: 节点号/zone 编号/类型标签
 字 1   ┐
 字 2   │ 联合体: 页缓存五元组 | page_pool | 尾页 compound_head | ...
 字 3   │   (lru/buddy/pcp 链节点复用同一槽位 —— 一页同一时刻只在一个链上)
 字 4   ┘
 字 5   page_type 或 _mapcount (4B) + _refcount (4B)
 字 6   memcg_data (CONFIG_MEMCG)
 字 7   (对齐填充/_last_cpupid)

为什么必须精确 64 字节:page 数组(vmemmap 区,20.4 节)与物理页帧一一对应,系统有 1TB 物理内存时数组就是 16GB——每省 8 字节,超大内存机器就省 2GB。这也是 7.0 把 flags 收紧为 memdesc_flags_t、把 _mapcount/page_type 挤进同一个 4 字节联合体的动机。

同一槽位的多种身份

联合体第一槽(字 1)的复用尤其精妙,注释直接说明约束:"一页不可能同时在 LRU 和 buddy 链上":

身份 槽位内容 判定方式
页缓存/匿名页 lru + mapping + index + private 默认(PG_lru 等)
伙伴系统空闲块 buddy_list + private(阶) PageBuddy
PCP 缓存页 pcp_list/pcp_llist 分配器内部
slab 对象池 (另一套字段) PG_slab
复合页尾页 compound_head(bit0=1) PageTail

private 的多义性同样典型:页缓存页存放 fs 私有数据(配合 PG_private)、swapcache 页存 swp_entry_t、空闲块存阶数(18.3 节的 set_buddy_order() 写的就是它)、PCP llist 存空闲链。

18.4.2 两个计数:_refcount 与 _mapcount

// include/linux/mm_types.h:156-186(要点摘录)
    union {     /* This union is 4 bytes in size. */
        /*
         * 类型化 folio 的头页在此存类型(page_type == _mapcount == -1)...
         */
        unsigned int page_type;

        /*
         * 直接被页表引用的次数。初始化为 -1, 从 -1 出发和回到 -1
         * 的转换分别用 atomic_inc_and_test() 与 atomic_add_negative(-1) 追踪。
         */
        atomic_t _mapcount;
    };

    /* Usage count. *DO NOT USE DIRECTLY*. See page_ref.h */
    atomic_t _refcount;

两个计数回答不同的问题:

计数 问题 增减者 归零语义
_refcount 有多少持有者(页表映射、page cache 挂链、GUP pin、bio in-flight…) get_page/put_page, folio_get/folio_put 最后一引用释放 → 可还给伙伴系统
_mapcount 被多少张页表直接映射(用户态映射数) ptl 内 map/unmap 回到 -1 → 从 rmap 摘除,可进入回收流程

典型协作:进程 fork 时页表复制使 _mapcount++;进程退出 unmap 使 _mapcount-- 回到 -1;但 page cache 还持 _refcount,页不会被释放,而是进入 24 章的回收候选。COW 判断(25.3 节)依赖 _mapcount > 1 判定"多进程共享",这要求拷贝页表时必须先增 _mapcount——顺序错乱会造成错误 COW 副本。

两个计数的生命周期时间线 (一个页缓存页):

 挂入页缓存      映射进进程A      fork→进程B      进程B退出      进程A退出     A/B都munmap
  _refcount 1 ─→ 2 ──────────→ 2              ─→ 2          ─→ 2          ─→ 1
  _mapcount -1 ─→ 0 ──────────→ 1              ─→ 0          ─→ -1         ─→ -1
                                                                  │             │
   (mapcount 回 -1: 页可被回收扫描)                    (refcount 仍 1:      (此时才可释放,
                                                       页缓存还持有)        回伙伴系统)

page_type 与 _mapcount 互斥的原因也在注释中:类型化 folio(如 PG_buddy、PG_offline、PG_table 等空闲/元数据页)不参与 rmap,其 _mapcount 恒为 -1,这个值恰好是类型标签的哨兵——两种语义共用 4 字节零成本切换。

18.4.3 页标志体系

标志定义在 include/linux/page-flags.h:94-194:

// include/linux/page-flags.h:94(节选)
    PG_locked,      /* 页被锁定(IO/页表操作), 别动 */
    PG_writeback,       /* 正在回写 */
    PG_referenced,
    PG_uptodate,
    PG_dirty,
    PG_lru,
    PG_head,        /* 复合页头页(必须在 bit 6) */
    PG_waiters,     /* 页有等待者(必须在 bit 7, 与 PG_locked 同字节) */
    PG_active,
    PG_workingset,
    ...
    PG_reserved,        /* :107 特殊保留页(内核映像/固件区...) */
    PG_private,     /* 页缓存页有 fs 私有数据(见 ->private) */
    PG_reclaim,     /* 尽快回收 */
    PG_swapbacked,      /* 匿名页: 有 swap 后备 */
    PG_unevictable,     /* 不可回收页(mlock 等) */
    ...
    PG_hwpoison,        /* :118 硬件损坏页, 别碰 */
    /* 位不足, 借用别名: */
    PG_readahead = PG_reclaim,
    PG_swapcache = PG_owner_priv_1, /* private 存 swp_entry_t */
    PG_anon_exclusive = PG_owner_2, /* :146 匿名页独占映射(COW 优化) */
    PG_reported = PG_uptodate,
    PG_has_hwpoisoned = PG_active,
    PG_large_rmappable = PG_workingset, /* :193 大页支持 rmap */
    PG_partially_mapped = PG_reclaim,

高位编码地址信息:page->flags 的高位段同时存放节点号(NODE_SHIFT 起)与 zone 编号,page_to_nid()/page_zone() 据此 O(1) 反查归属——这是"page 自述归属"设计的关键,使回收路径无需额外映射表。

flags 的分区图 (64 位):

  63                    ...                0
 ┌──────────┬─────────┬──────────────────────┐
 │ 编号段    │ 类型/   │  低位标志 (PG_locked   │
 │ (节点号 + │ 状态段  │  ..PG_hwpoison 等)    │
 │  zone号) │         │                      │
 └──────────┴─────────┴──────────────────────┘
 page_to_nid(page): 取节点号段 (内存上线的页
   自报家门 — 回收/迁移路径免查表)
 硬布局承诺: PG_head=bit6 / PG_waiters=bit7 —
   PageWaiters 的无锁探测只检查该字节

标志的操作宏由 __SET_BIT 家族展开(SetPageDirty/TestClearPageWriteback…),配合 VM_BUG_ON_PGFLAGS 在调试内核下校验非法组合(如对尾页设 PG_slab)。PG_head 必须在 bit 6、PG_waiters 必须在 bit 7 且与 PG_locked 同字节,是因为 PageWaiters 的无锁探测只检查该字节的标志字节组合——硬性布局是协议的一部分。

18.4.4 folio:让"页"重新变大

struct page 设计于"页 = 4KB"的年代。大页(THP/hugetlb)出现后,一个 2MB 物理块由 512 个 struct page 描述,多数操作却只想表达"这一整块"——folio 应运而生(mm_types.h:358-509):

// include/linux/mm_types.h:401(节选)
struct folio {
    /* private: 必须与 page 的 flags 起始处对齐 */
    union {
        struct {
            unsigned long flags_;
            ...
        };
        struct page page;   /* 头 64 字节复用 struct page */
    };
    union {
        struct {    /* 私有 folio 字段(匿名/文件页) */
            struct address_space *mapping;
            pgoff_t index;      /* 映射内偏移(覆盖 page 的 index) */
            unsigned long private;
        };
        struct {    /* slab 模式 */
            struct kmem_cache *slab_cache;
            ...
        };
    };
    atomic_t _mapcount;     /* 整个 folio 的映射计数 */
    atomic_t _refcount;     /* 整个 folio 的引用计数 */
    ...
};

folio 把头页 page 与管理元数据合并表达:_refcount/_mapcount 从"每尾页一份"合并为"每 folio 一份",rmap、回收、迁移都以 folio 为单位(18.5 节 folio_migrate_mapping)。page_folio(page) 经 compound_head(尾页字 1 的指头指针)把任意子页归一到 folio——这正是 compound_head 占据联合体 bit 0 的原因:bit 0 为 1 即"我是尾页"。7.0 内核中页缓存、回收、迁移路径已全面 folio 化,struct page 保留为兼容层与"每 4KB 粒度的物理描述符"。

page 视角 vs folio 视角 (一个 2MB THP):

 page 视角 (旧):  512 个 page, 每个独立 refcount/mapcount
   → "这个 THP 被引用几次?" = 遍历 512 个计数 (不可能!)
 folio 视角 (新):  1 个 folio, 一对 refcount/mapcount
   → 整块语义; 512 个 page 退化为 "每 4KB 粒度的存在标记"

 page_folio() 的归一:
   任意子 page → compound_head (bit 0) → 头 page = folio
   (头 page 与 folio 内存重叠 — 18.4.1 节布局承诺)

18.4.5 page 与物理地址的互译

物理地址 ↔ struct page 的双向 O(1) 映射 (x86_64, SPARSEMEM_VMEMMAP)

 物理地址 addr
   pfn = addr >> PAGE_SHIFT
   page = vmemmap_base + pfn * sizeof(struct page)   ← 线性换算(20.4 节)
   vmemmap 区本身是虚拟映射: 按需把物理页映射进来

 反向:
   pfn = (page - vmemmap_base) / sizeof(struct page)
   addr = pfn << PAGE_SHIFT
   虚拟地址 = page_address(page) = direct_map_base + pfn*PAGE_SIZE
             (ZONE_NORMAL 内: 直接映射区, 20.2 节; 无需 kmap)

 完整链路示例 (回收器要写一页到 swap):
   folio → page_to_pfn() → (vmemmap 减法) → pfn
   → __va(pfn) → 直接映射区地址 → 内核拷贝源

node_mem_map(mmzone.h:1392)是 FLATMEM 布局下的同一数组;SPARSEMEM 布局下按 mem_section(mmzone.h:1917)分块,每区段一个 section_mem_map 指针——热插拔(18.5 节)按区段粒度建立/撤销 page 数组,无需全局重建。

互译成本的意义:page_to_pfn/pfn_to_page 出现在 GUP、回收扫描、迁移、DMA 映射一切热路径上——三种布局(FLATMEM 查数组 / SPARSEMEM 区段偏移 / VMEMMAP 乘加)中只有 VMEMMAP 是纯算术,这正是 64 位内核的默认选择(20.4 节)。


小结

本节拆解了每页一行的明细账:struct page 以 flags + 5 字联合体 + _mapcount/page_type + _refcount 压缩进 64 字节,联合体槽位按 PG_slab/PageBuddy/PageTail 等类型位切换语义;_refcount 统计全部持有者、_mapcount 统计页表直接映射,两者分别主宰释放与回收/COW 决策;flags 高位自编码节点与 zone 归属。folio 抽象在大页时代恢复"块"语义,把头页元数据与整块计数统一。page 与物理地址通过 vmemmap 线性互译,与直接映射区配合完成 page_address() 的 O(1) 换算——这两个地址空间话题将在第 20 章展开。下一节回到运行时:页面如何搬家、内存如何在线增删。

18.5 页面迁移与内存热插拔

前几节描述的账本是静态的;本节展示内核如何让物理内存动起来:页面迁移把已分配的页从一块物理地址搬到另一块(内存规整、NUMA 平衡、大页合并、故障页隔离都依赖它),内存热插拔则在系统运行中增删整段物理内存(云主机内存弹性伸缩的基石)。两者互为依赖——热插拔下线前必须先把这段内存上的所有页迁移走,而迁移的可行性又由 18.2 节的 ZONE_MOVABLE 与 18.3 节的 migratetype 机制共同保障。


18.5.1 页面迁移的三段式协议

迁移一页不是"复制字节"那么简单:这一页可能被页表映射、挂在页缓存、正在回写。内核把迁移拆成找新家 → 建新家 → 换身份三段:

migrate_pages(from, get_new_folio, ...)            mm/migrate.c:2089
   │ 对链表中每个 folio:
   v
[1] alloc_migration_target(src, private)           migrate.c:2186
   │  按源页属性(节点/zone/迁移类型/大页阶)分配目标 folio
   v
[2] move_to_new_folio(dst, src, ...)               migrate.c:1090
   │  对每个映射此页的页表: 换掉 pte 指向 + 刷 TLB
   │  (rmap 反向映射逐 VMA 找到全部映射点, 22 章展开)
   v
[3] migrate_folio_move()                           migrate.c:1353
   └─ folio_migrate_mapping() + folio_migrate_flags()
      旧 folio 交还原分配器, 新 folio 继承全部身份

18.5.2 folio_migrate_mapping:身份的原子移交

// mm/migrate.c:708-718
int folio_migrate_mapping(struct address_space *mapping,
        struct folio *newfolio, struct folio *folio, int extra_count)
{
    int expected_count = folio_expected_ref_count(folio) + extra_count + 1;

    if (folio_ref_count(folio) != expected_count)
        return -EAGAIN;     /* 有人正在持有/访问: 本次迁移失败 */

    return __folio_migrate_mapping(mapping, newfolio, folio, expected_count);
}

-EAGAIN 而非 -EBUSY 是协议的一部分:调用方(规整器、热插拔)把失败页放回链表稍后重试,整体推进而非卡死。folio_expected_ref_count() 把"正常应该有几个引用"算清楚——页缓存挂链 +1、mapcount 折算 +extra——只有恰好等于期望值时才动手,任何额外引用者(如 GUP 长期 pin)都会让计数不等而拒绝迁移。这也解释了 18.2 节 ZONE_MOVABLE 注释里"长期 pin 的 movable 页实质不可迁移"的告警。

真正的搬运在 __folio_migrate_mapping()(migrate.c:571-706):

// mm/migrate.c:571(要点)
static int __folio_migrate_mapping(struct address_space *mapping,
        struct folio *newfolio, struct folio *folio, int expected_count)
{
    ...
    if (!mapping) {         /* 匿名页: 无页缓存归属 */
        ...
    } else {
        /* XArray 双锁: 在 xas_store 换掉页缓存槽位中的 folio 指针 */
        xas_lock_irq(&xas);
        ...
        xas_store(&xas, newfolio);
        ...
        xas_unlock_irq(&xas);
    }
    /* 标志/私有数据/统计: 归属换成新 folio */
    if (folio_test_private(folio)) ...
    folio_ref_add(newfolio, expected_count);    /* 新 folio 接管全部引用 */
    folio_ref_sub(folio, expected_count);       /* 旧 folio 摘掉身份引用 */
}
迁移中"引用交接"的账目图:

 迁移前:  旧 folio refcount = expected_count
          (页缓存挂链 + 页表映射 + 迁移器自持)

 换头手术: XArray 槽位指针 旧→新 (页缓存视角切换)
   new.refcount += expected_count   (新家接管全部引用)
   old.refcount -= expected_count   (旧家只剩"临时持有旧指针者")

 迁移后:  新 folio 拥有原身份 (页缓存/映射数)
          旧 folio refcount → 0 → 归还伙伴系统

页缓存槽位(XArray,第 36 章展开)中的指针原子替换是迁移的"换头手术":此后 find_get_page() 拿到的是新 folio,而旧 folio 的剩余引用只属于"还拿着旧指针的临时用户"(正在运行的 IO),等它们 put 完旧 folio 自然回收。

随后 folio_migrate_flags()(migrate.c:760-849)把语义标志逐个搬家:PG_referenced/PG_active(回收年龄,24 章)、PG_unevictable(mlock)、PG_swapbacked、memcg 归属、工作集信息,以及 PG_dirty 的补迁(migrate.c:786 "Move dirty on pages not done by folio_migrate_mapping()"——回写竞态下遗漏的脏标志在此兜底)。标志搬家必须在计数交接之后,否则回收扫描器可能以"旧 folio 的年龄"操作"新 folio 的链表节点"。

18.5.3 谁在调用迁移

场景 入口 目标页来源
内存规整(抗碎片) compact_zone()(mm/compaction.c) alloc_migration_target(同 zone 内)
热插拔下线 do_migrate_range()(memory_hotplug.c:1792) 指定其他节点/zone
NUMA 平衡 migrate_misplaced_page()(migrate.c) 进程倾向节点
大页升级(khugepaged) collapse 路径 THP 阶连续块
硬件故障隔离 soft_offline 路径 任意健康页

alloc_migration_target()(migrate.c:2186-2236)按源 folio 的属性查迁移目标节点/zone 掩码,再以与源相同的迁移类型调 alloc_folio——迁移产出绝不破坏 18.3 节的 migratetype 聚居格局,否则规整器自己就成了碎片制造者。

compaction (内存规整) 如何用迁移抗碎片:

 规整前的 zone:
  [U][M][M][U][M][U][U][M][M][M]     U=不可迁 M=可迁
  空闲页不少, 但找不到连续 2MB (高阶分配失败)

 规整 (两把扫描刷相向而行):
  迁移刷 ─────────────▶        ◀───────── 空闲刷
  把扫到的 M 页搬到空闲刷扫到的位置
  规整后:
  [U][U][U][U]│[M][M][M][M][M][M]│[空白连续区]
              └── 分界线 ──┘  └─ 大块连续空闲 → 高阶分配成功
 (ZONE_MOVABLE / pageblock 迁移类型保证 M 页真的搬得动)

18.5.4 内存热插拔:add/online/offline 的状态机

             __add_pages()                      online_pages()                系统可用
 物理插入    mm/memory_hotplug.c:389            memory_hotplug.c:1144      ┌─────────┐
 ────────>  [无 memory] ──add_memory──> [有 memory 无 managed] ────────> │ managed │
            建段表/建 vmemmap                 move_pfn_range_to_zone      └─────────┘
                                                                          ^   │
             __remove_pages()  <─────────────── offline_pages()  <────────┘   │ 迁走+清账
             mm/memory_hotplug.c:593           memory_hotplug.c:1901          │
            撤 vmemmap/删段                    scan_movable_pages:1750        │
                                              do_migrate_range:1792 ─────────┘
                                              adjust_managed_page_count:2061

内核侧约定三个状态(与 sysfs /sys/devices/system/memory/memoryN/{state} 的 online/offline 一一对应):

[1] add:建立账本基础设施。 __add_pages()(memory_hotplug.c:389)为新增 pfn 区间建立 SPARSEMEM 区段(struct mem_section,mmzone.h:1917)并按需搭建 vmemmap——注意 7.0 的 memmap_on_memory 变体:page 数组本身占用了新增内存的头部(memory_block_memmap_on_memory_pages(),memory_hotplug.c:58-60),因此对齐检查放行"块内 pageblock 对齐"而非整 section 对齐(online_pages:1173-1176 的 WARN_ON_ONCE 条件)。

[2] online:并入伙伴系统。 online_pages()(memory_hotplug.c:1144-1260)核心三步:

// mm/memory_hotplug.c:1144(节选)
int online_pages(unsigned long pfn, unsigned long nr_pages,
               struct zone *zone, struct memory_group *group)
{
    ...
    if (WARN_ON_ONCE(!nr_pages || !pageblock_aligned(pfn) ||
             !IS_ALIGNED(pfn + nr_pages, PAGES_PER_SECTION)))
        return -EINVAL;     /* 只接受整段/整块粒度 */

    /* associate pfn range with the zone */
    move_pfn_range_to_zone(zone, pfn, nr_pages, NULL, MIGRATE_MOVABLE,
                   true);       /* :1174 新内存默认标 MIGRATE_MOVABLE */
    ...
    /* online_pages() 末尾: adjust_managed_page_count(+nr_pages)
       + build_all_zonelists()(若新节点) + kswapd 唤醒 */
}

move_pfn_range_to_zone()(memory_hotplug.c:748-790)调用 resize_zone_range()(:703)扩展 zone_start_pfn/spanned_pages(写 span_seqlock),再为每页初始化 page 结构。新内存页默认标 MIGRATE_MOVABLE——如果上线后立即被内核数据占据,这段内存将来就拔不掉了;只有 movable 聚居的内存,offline 才有成功可能。

[3] offline:清空后撤账。 offline_pages()(memory_hotplug.c:1901-2080)是一个可能重试的循环:

// mm/memory_hotplug.c:1901(要点)
int offline_pages(unsigned long start_pfn, unsigned long nr_pages,
            struct zone *zone, struct memory_group *group)
{
    ...
    do {
        /* 1. 扫描本区间哪些页是可迁移的(匿名/页缓存) */
        ...scan_movable_pages(pfn, end_pfn, &pfn);      // :1750

        /* 2. 把它们迁走 */
        ...do_migrate_range(pfn, end_pfn);          // :2019 → :1792
           /* 不可迁移页(slab/内核数据): isolate 失败
              → 本次 offline 失败 */

        /* 3. 循环直到区间内没有 movable 页 */
    } while (...);

    /* 4. 清账: managed_pages 扣减, 页帧还给隔离区 */
    adjust_managed_page_count(pfn_to_page(start_pfn), -managed_pages);  // :2061
    ...
}

失败的真实原因往往在步骤 2:do_migrate_range() 遇到 slab 页(不可迁移)或被长期 pin 的页时无法搬走,offline_pages 返回 -EBUSY。这就是 18.2 节 ZONE_MOVABLE 设计的全部意义——把"绝对不能失败"的约束前移到"分配时只放可迁移页",offline 才有确定性保障。

18.5.5 粒度与 sysfs 接口

热插拔的最小粒度是 memory block(memory_block_size_bytes(),x86 常见 128MB),而不是 18.3 节的 pageblock(2MB):

物理内存          section (128MB 架构相关)      memory block (sysfs 单元)
┌────────┐      ┌─────────────────┐          ┌─────────────────────┐
│ ...    │      │ mem_section      │          │ /sys/devices/system/ │
│ 128MB  │      │  section_mem_map │          │ memory/memory32/     │
│ block  │ =    │  + page 数组      │  组成     │  state: online       │
│ ...    │      │ (vmemmap 映射)    │          │  valid_zones: ...    │
└────────┘      └─────────────────┘          └─────────────────────┘
用户态: echo online > state → online_pages()
        echo offline > state → offline_pages()

pageblock_flags 位图随区段存放(mmzone.h:927-929 非 SPARSEMEM 时在 zone、SPARSEMEM 时在 mem_section),保证迁移类型标注随内存段一起生灭。

三机制的依赖关系总图:

 ZONE_MOVABLE (18.2) ─── 保证 offline 有确定性 ───┐
 pageblock migratetype (18.3) ── 保证迁移不破坏碎片格局 ─┤
 folio 引用协议 (18.4) ── 保证迁移无竞态 ───────────┤
                                                 ▼
                     migrate_pages (本节) ── compaction/热插拔/NUMA 平衡

小结

本节展示了两项让账本"活"起来的机制。页面迁移走"找新家→换页表→换身份"三段协议:folio_migrate_mapping 以期望引用计数检查保证无竞态,XArray 槽位原子替换完成页缓存换头,folio_migrate_flags 在计数交接后搬走全部语义标志;内存规整、NUMA 平衡、热插拔下线共享这套协议。内存热插拔按 add/online/offline 状态机推进,move_pfn_range_to_zone 扩 zone 范围并以 MIGRATE_MOVABLE 建账,offline_pages 以"扫可迁页→迁移→清账"循环撤账,失败点集中在不可迁移的 slab 与被 pin 的页。至此物理内存管理的四层账本(memblock→zone→buddy→page)连同其动态机制全部就绪;下一章进入地址翻译的另一端——多级页表。