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)连同其动态机制全部就绪;下一章进入地址翻译的另一端——多级页表。