Linux 内核 Memory Hotplug 与 viritio-mem 深度实战:从 DIMM 热插拔到云原生弹性内存的架构与工程
引言
现代数据中心对内存的弹性需求从未停止增长——无论是物理服务器的 DIMM 在线扩容,还是云环境中虚拟机的动态内存调整(Memory Hotplug/Hotremove),都要求 Linux 内核在不重启的前提下完成物理内存的添加与移除。然而,内存热插拔远比 CPU 热插拔复杂:新加入的页面必须被内核伙伴系统接管、迁移到正确的 NUMA 节点、建立反向映射;而热移除前必须将所有页面迁移出去,释放内存块。本文深入剖析 Linux 内核 Memory Hotplug 子系统的完整架构链路,从物理 DIMM 的 ACPI/eFUH 通知到内核 memory_block 状态机,再到 viritio-mem 协议与 QEMU/KVM 云场景的实战集成,并提供生产级参数调优与故障诊断指南。
一、Memory Hotplug 架构全景
1.1 为什么内存热插拔如此复杂
与 CPU 热插拔只需调度器感知核心上下线不同,内存热插拔涉及伙伴系统整条链路:
新内存上线必须完成:物理页面加入伙伴系统 → 建立 struct page → 初始化 pgmap → 设置 zone → 可选 NUMA 节点分配 → 触发 kswapd 监控。移除则必须反向执行:逐页迁移(准备迁移页表映射)→ 从 buddy 释放 → 清除 struct page → 从 zone 移除。关键难点在于:如果已有页面被进程持有(匿名映射、Page Cache、页表映射),迁移必须能断开并重建映射,这要求内存已可被迁移,若存在不可迁移页面(如持久映射、大页、slab 内核对象),热移除将失败。
1.2 两种热插拔模式
ACPI Memory Hotplug(物理服务器):通过 ACPI 6.0+ 的 _HID:ACPI000E 设备触发,固件通过 ACPI Notification(Notify Code 0x80)通知内核内存配置变化。内核通过 acpi_memory_get_device() 获取设备句柄,调用 acpi_memory_device_add() 注册。典型路径:BMC/IPMI 触发ACPI SCI中断 → acpi_bus_notify() → acpi_memory_device_notify() → 触发上线/下线。
viritio-mem(虚拟化/云原生):基于 viritio 1.1 规范,通过 MMIO 配置空间暴露可调整内存块大小。QEMU 后端将 guest 物理地址(GPA)映射到 memfd/hugepage 文件描述符,前端通过 viritio 协议协商配置内存拓扑。优势:超分(overcommit)、NUMA 感知、与 memory balloon 互补。
1.3 核心数据结构
内存热插拔的核心抽象是 struct memory_block:每个块对齐到Memory Block Size(由固件决定,x86-64 通常 128MB,可通过/sys/devices/system/memory/block_size_bytes 查询)。
struct memory_block {
unsigned long start_section_nr; // 起始 mem_section 编号
unsigned long state; // 状态机 (offline/online/going-offline)
int unit_count; // 包含 unit 数量
struct memory_block *units; // unit 数组(viritio-mem)
struct zone *zone; // 关联的 NUMA node zone
struct device dev; // 内嵌设备模型
};
struct dev_pagemap {
devmap_pgmap_ops *ops;
unsigned long flags;
unsigned long vmemmap_shift;
};
当 viritio-mem 配置 1TB 内存时,memory_block 会被划分为 8192 个 128 MB 单元,每个块独立触发上线/下线,组成一个"sub-block array"。
二、内核上线(Online)路径
2.1 状态机与迁移
memory_block 的状态机转移路径:offline → going-online → online(或失败回到 offline)。入口点:memory_block_change_state() → memory_block_online()。
// 核心调用链
memory_block_online()
→ memory_block_online_blocks()
→ online_memblk()
→ online_page_ranges()
→ prep_new_page() // 准备每个 struct page
→ move_pfn_range_to_zone()// 将 PFN Range 挂入 zone
→ memmap_init_range() // 初始化 page 描述符
→ zone_span_writelock() // 管理 zone->spanned_pages
→ onlined_pages() // 触发页面计数更新
→ trigger_mem_online() // 通知驱动层
在 move_pfn_range_to_zone() 中,内核完成:zone 容量增长(zone→present_pages += 页面数)、span 扩展(管理连续物理内存)、kswapd 唤醒或迁移目标(若 zone 已有足够空闲,则 zone→lowmem_reserve 重算)。对于 viritio-mem 场景,page 取自 memfd 已有的物理页,不会触发内存分配。
2.2 viritio-mem 前端路径
viritio-mem 请求内存增加时,流程:前端驱动 viritio_mem_run() 读取配置空间 new_requested_size → 计算需要激活的 sub-block 数量 → 遍历每个未上线 sub-block → viritio_mem_online_request() → 通过 virtio 队列发送 VIRTIO_MEM_T_UNPLUG 命令 → QEMU 后端通过 memory_region_add_subregion() 扩大后端 MR → 前端调用 memory_block_online() 将内存块上线。
2.3 SLUB 分配器的应对
内存上线后,SLUB 分配器的每 CPU 缓存(kmem_cache_cpu→freelist)不会自动刷新。实际场景中,新 zone 的首次分配会触发 cache_grow()——伙伴系统通过 alloc_pages_node() 分配新 slab,调用 new_slab() 初始化。若开机时内存按需增长(lazy SLUB init),首次访问 kmem_cache 时首次会调用 cache_init_objs()。
三、内核下线(Offline)与页面迁移
3.1 不可迁移页面与失败条件
热移除的前提是所有页面可迁移。不可迁移页面包括:
- Unevictable LRU 页面(被 mlock() 锁定的匿名页)
- 内核线性映射(kernel linear address space 页面的 struct page 本身)
- Page Table pages、持久映射(kmem_cache 的内核对象)
- HugeTLB 大页(某些配置下不可迁移)
核心检查函数:test_and_unmap_pages()。流程:遍历 memory_block 内所有 page → 通过 rmap 获取反查映射 → 未映射页面直接迁移 → 页面表存在则调用 try_to_migrate() 创建新页面(new_page = __alloc_pages())→ 复制内容(copy_highpage())→ PFN 反向更新(page→mapping、新 page→mapping)→ 删除旧 rmap。
3.2 迁移失败锁定机制
当遇到不可迁移页面时,内核将 memory_block 标记为 PG_offline,随后的 offline_page_ext 尝试不再重复测试此块(通过 memory_block.state & MB_offline_lock)。超时时间由 CONFIG_MEMORY_HOTPLUG_RETRY_DELAY(默认 1 min)控制。
3.3 viritio-mem 下线路径
请求内存缩减时:前端驱动遍历 sub-block 数组,选择未映射、空闲的块 → 调用 memory_block_offline() → QEMU 后端缩小相应 memfd 区域 → 前端将块标记为 offline。若任何映射页面无法迁移,前端向 guest 内核报错(VIRTIO_MEM_RESP_BUSY)。
3.4 关键缓冲区更新
下线后必须完成的内核数据更新:zone→present_pages 减少 → free_area 中的 buddy 计数器同步 → nodemask/NULL节点集更新(若全部下线,触发 unmap_kernel_range())。
四、NUMA 拓扑感知与策略
4.1 多节点上线
在多 NUMA 节点服务器上,每个 DIMM 通过异或二叉树表(HMAT/SRAT)映射到不同节点。memory_block_online() 通过 pfn_to_nid() 确定目标节点。x86-64 中通过 e820/ACPI SRAT 映射。viritio-mem 允许前端通过配置空间设置每个 memory_block 的 node id,实现 NUMA 拓扑变化。
4.2 Auto-Online Policies
Linux 提供 memory_block 上线策略(/sys/devices/system/memory/auto_online_blocks):
- offline:不自动上线,需手动 echo online > /sys/devices/system/memory/memoryN/state
- online:boot 时新增块自动上线(默认)
- online_movable:新增块挂入 ZONE_MOVABLE,便于迁移
生产默认推荐 online_kernel(内核对象分配来自 ZONE_NORMAL,用户匿名页分配来自 ZONE_MOVABLE),配合 ZONE_MOVABLE 大小静态设置(kernelcore= 或 movablecore=)。
4.3 ZONE_MOVABLE 与内核内存水位
当手动挂入 ZONE_MOVABLE 时,必须保证 ZONE_NORMAL 保留足够内核数据空间。kernel 参数 kernelcore=nn[KMG] 定义 ZONE_NORMAL 最小保留量,movablecore=nn[KMG] 定义 ZONE_MOVABLE 大小。不设置时内核自动根据 SKB/mempool 需求估算。
五、viritio-mem 配置与调优
5.1 QEMU 启动参数
-object memory-backend-file,id=mem0,mem-path=/dev/hugepages/shared,size=256G,share=on,prealloc=on \
-device virtio-mem-pci,id=vmem0,memdev=mem0,block-size=128M,requested-size=64G,maxmem=256G,node=0,unplugged-inaccessible=on
关键参数:
- block-size=128M:与 x86-64 memory_block 对齐,必须 ≥ 内核 blocks(建议 128 MB)
- requested-size:guest 已上线内存(其余为可弹出的"空闲")
- unplugged-inaccessible=on:阻止 kernel 直接访问未上线区域(避免 #MC MCE 错误)
5.2 Guest 侧动态调整
通过 QEMU Monitor 热调整内存请求:
# 扩大至 128 GB(guest 随即看到新内存)
(qemu) object_add memory-backend-ram,id=aux0,size=64G
(qemu) device_add virtio-mem-pci,id=vmem_aux,memdev=aux0,requested-size=128G,block-size=128M
# 缩回至 32 GB(必须等 guest 迁移完成页面)
(qemu) object_del aux0
(qemu) device_del vmem_aux
5.3 与 Memory Balloon 对比
| 特性 | viritio-mem | virtio-balloon |
|---|---|---|
| 粒度 | 128 MB block | 4 KB 页面 |
| 地址空间 | 可插入/移除固定物理区域 | 页面回收不改变地址空间 |
| pf 精度 | 按块(折中) | 按页(精确) |
| 内核响应 | memory_block 状态机 | PF 后 balloon 回收 |
| 适用场景 | 云原生扩容/缩容、NUMA 变化 | 超分配内存超额回收 |
生产推荐:二者配合使用。viritio-mem 负责 NUMA 拓扑与 block 级弹性,balloon 负责页级精确回收与页共享去重(KSM)。
六、生产级调优参数矩阵
6.1 启动参数
CONFIG_MEMORY_HOTPLUG=y(必须): 支持块级上线
CONFIG_MEMORY_HOTPLUG_DEFAULT_ONLINE=y(默认): 新增块自动挂入
CONFIG_ACPI_HOTPLUG_MEMORY=y: 物理服务器需要
CONFIG_VIRTIO_MEM=y: viritio-mem 支持
CONFIG_ZONE_DMA 系列: 确保系统内存拓扑可用
6.2 sysctl 与 runtime 调优
# 查看 memory block 大小(依赖硬件/平台)
cat /sys/devices/system/memory/block_size_bytes # 通常 8000000 = 128 MB
# 当前块上线状态
cat /sys/devices/system/memory/memory100/state # online/offline
# 在线手动上线
echo online > /sys/devices/system/memory/memory102/state
# 自动上线策略
echo online_kernel > /sys/devices/system/memory/auto_online_blocks
# ZONE_MOVABLE 配置(仅 boot 参数有效)
kernelcore=4G movablecore=128G
6.3 关键 timeout 与重试
MEMORY_HOTPLUG_RETRY_DELAY (CONFIG_HZ derived):迁移失败再次尝试延迟(默认 1 SYS)。可调整 CONFIG_HZ(通常 250/300/1000)影响整体内存迁移性能与延时。
VIRTIO_MEM_DEFAULT_TIMEOUT:viritio-mem 请求操作建议超时(内部 40s)。
6.4 NUMA balancing 联动
新增 node 上线后立即触发:/sys/devices/system/node/nodeN/auto_online。建议搭配 numa_balancing=disable 用于缓存预热场景,避免迁移抖动。
七、eBPF 与 Memory Hotplug 追踪
7.1 Tracepoint 事件
内核提供关键 memory hotplug tracepoint:
trace_memory_hotplug:recordonline/offline 的时间戳、目标节点、块大小、结果。
trace_migrate_pages:每次迁移页面数(from_nid、to_nid、success/failure)。
7.2 Kprobe 实时诊断
# 监控 memory_block_online 耗时
bpftrace -e 'kprobe:memory_block_online { @start[tid] = nsecs; }
kretprobe:memory_block_online /@start[tid]/ {
@us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
# 跟踪 memory_block_offline 失败原因
bpftrace -e '
kprobe:memory_block_offline {
printf("OFFLINE block %lu: state=%lu\n", arg0, arg1);
}
kprobe:test_and_unmap_pages /@fail_count++/ {
printf("UNMAP failed at pfn %lu: ret=%d\n", arg0->start_pfn, retval);
}
'
7.3 生产级追踪管线
结合 BCC/BPF CO-RE,构建 memory hotplug 性能观测线:
步骤 1:采集事件流 → trace.py memory_hotplug
步骤 2:关联 QEMU 侧决策 → 通过 QEMU QMP 协议监控 virtio-mem-change 事件
步骤 3:负载前后对比 → 通过 vmstat -s 查看 hot-added pages / hot-removed pages 计数
八、故障诊断与排查手册
8.1 常见错误码
ENOTSUPP:mem_section 不支持(CONFIG_MEMORY_HOTPLUG 未编译)
EBUSY-TYPE:存在 un-movable 页面(必须检查 dmesg)
ENOSPC:目标 node 不存在或无法创建 node
ETIMEDOUT:migration 超时,重试迁移但被阻止
8.2 故障排查流程
# Step 1: 查看系统错误日志
dmesg | grep -iE "memory_hotplug|memory block|pmem|virtio_mem"
# Step 2: 确认块是否可上线
cat /sys/devices/system/memory/memory200/state_valid
# 返回值:online, offline, going-offline
# Step 3: 手动触发诊断态
echo online > /sys/devices/system/memory/memory200/state
# 失败时查看详细原因
# dmesg | tail -20
# Step 4: 检查页面迁移状态
cat /proc/buddyinfo | head -5
# Step 5: 检查_zone 容量变化
grep -E "present_pages|spanned_pages" /proc/zoneinfo
8.3 viritio-mem 特别排查
viritio-mem 场景的特殊问题:
"requested-size changed but still inaccessible":guest 侧事件通知延迟 → 解决方案:关闭 unplugged-inaccessible(性能折衷)或增大 timeout
"page count mismatch between QEMU request and guest":memfd 区域扩展失败 → 解决方案:检查主机 hugepage 剩余量
"uncorrectable memory error after plugging":#MC MCE 被注入 → 解决方案:开启 ACPI APEI memory error injection 测试
九、架构级策略与展望
9.1 CXL 融合展望
CXL Type 3 设备(CXL Memory Expander)将引入热插拔新维度:新上线区域作为S-Linux 的 "Slow Mem Tier"(慢层级)。Linux 6.x+ 通过 Migrate Hierarchy 机制,将 SLRU 中的页面在本地 DRAM 与 CXL 内存之间迁移。这导致 Memory Hotplug 上线路径必须新增 state: CXL_ATTACHED 和 TIER_PROMOTION。
9.2 PMEM 交互
NVDIMM-N/pmem 的 Memory Hotplug 处理与 DRAM 更接近,且额外支持 dax-mapping。内核模块 nd_blk 在块设备层包装 DRAs,其 sub-block 大小由 NFIT 表决定(通常 2 MB 与 1 GB huge)。Linux 5.15+ 支持 MEMORY_HOTPLUG_PMEM_MIGRATION,在 pmem 下线前将 DAX 文件迁移回块 IO。
9.3 云原生最佳实践
KubeVirt/virtIODriver 集成:通过 Kubernetes VirtualMachine CRD 配置 domain.memory.guest 和热插拔生命周期事件,viritio-mem 在 controller 驱动下使 pod 在水平扩展时自动响应内存变化。
与 largepage 共存:使用 hugepage 时 memory_block 必须与 hugepage size 对齐(通常 2 MB 或 1 GB),否则无法迁移(因为混合 slab 紧张)。hugepage pool 的物理内存必须 boot 时预留。
十、总结
Memory Hotplug 是 Linux 内核弹性资源管理的核心能力之一,其复杂性源于伙伴系统初始化、页面迁移与 NUMA 感知的深度耦合。在 viritio-mem 与 CXL 类型设备的推动下,这一子系统正从"实用运维工具"演进为**云原生弹性基础设施**的基石。通过 ms flush、tracepoint 与 eBPF 宿主机协同观测,工程师可以构建从 DIMM 到 viritio-mem 的端到端可控体系,为 Kubernetes 的 Vertical Pod Autoscaler 等高级调度场景提供底层支撑。

发表评论 取消回复