Linux 内核 Memory Failure 深度实战:从 MCE 异常到 hwpoison 页面隔离机制
一、为什么需要重新审视 Memory Failure
在现代数据中心中,内存子系统是硬件错误最常见的来源之一。Google 2016 年的大规模部署研究表明,内存 DIMM 的年错误率(Annualized Failure Rate)在 1%–8% 之间,且随容量增长持续上升。一条 128GB 的 DDR5 ECC DIMM,在高负载场景下每隔数天就可能触发一次可纠正的错误(CE),而不可纠正错误(UCE)虽然罕见,但一旦发生就意味着数据已经丢失。
Linux 内核的 Memory Failure 子系统承担着一个看似不可能完成的任务:在硬件已经出错的情况下,尽力保住系统的其余部分。它需要在极短的时间窗口内(硬中断上下文)识别故障页面,隔离它使其不再被分配,并将进程的引用安全地拆除。在虚拟化场景下,还要保证宿主机内存错误不会泄漏到客户机。
这个子系统横跨了 MCE(Machine Check Exception)中断处理、page table 操作、反向映射(reverse mapping)、文件系统 page cache、匿名 page、KSM/THP 合并页面、甚至 hugetlbfs 大页——每一个场景都有不同的处理路径。理解它的完整工作流,是做好数据库内核、DPDK 高页占用应用、以及大规模容器平台稳定性工程的基础。
本文将从硬件中断触发开始,一步步深入内核,剖析 memory failure 的完整链路,并通过多个代码片段和生产案例说明其工程实现。
二、硬件错误事件的入口:MCE 与三种上报通道
x86 架构的硬件错误上报主要依赖三种通道,它们在触发机制、延迟和携带信息量上各有不同:
2.1 MCE(Machine Check Exception)
MCE 是 x86 CPU 在检测到硬件错误(通常是总线、缓存或内存错误)时直接触发的异常向量 18。CPU 内部有一组 MCA(Machine Check Architecture)寄存器:
IA32_MCG_CAP — 全局能力寄存器(bank count、MCE_LOG 等)
IA32_MCi_STATUS — 每个 bank 的状态寄存器(VAL/UC/EN/ADDRVALID 等)
IA32_MCi_ADDR — 故障的物理地址
IA32_MCi_MISC — 附加信息
当 UC(Uncorrected)标志置位且 RIPV(Restart IP Valid)清除时,当前指令不可重启,意味着数据已经损坏。内核的 mce_handler 会扫描所有 bank,读取错误状态,对于内存相关错误会调用 mce_is_memory_error() 加以识别。
MCE 处理的一个重要约束是:大部分 MCE handler 运行在 hardirq 上下文,不可睡眠。这意味着任何需要分配内存、获取睡眠锁或遍历复杂数据结构的操作,都无法在此时执行。
2.2 ACPI HEST/BERT 平台错误记录
在某些平台上,即使 MCE 被 BIOS 屏蔽或路由到了 SMI(System Management Interrupt),错误日志仍然可以通过 ACPI 的 HEST(Hardware Error Source Table)接口获取。Linux 通过 acpi_hest 驱动扫描 HEST 表,将错误记录转换为 struct mce 并通过相同的内部管道处理。在启动早期还可以读取 BERT(Boot Error Recovery Table),以捕获上一次因严重错误导致的重启原因。
2.3 EDAC(Error Detection And Correction)子系统
对于 ECC 内存,EDAC 驱动(如 sb_edac、skl_edac)直接在驱动层轮询或接收中断,读取内存控制器的状态寄存器。EDAC 负责统计 CE/UE 计数,并通过 sysfs 节点向用户态报告。一条 UE 通常会触发 EDAC 通知内核执行页面离线和隔离。
这三种通道最终汇聚到统一的内核管道:mce_schedule_work() 触发 mce_work 工作队列项,后者在进程上下文中执行 do_machine_check() 的后半段,包括调用内存故障处理的核心入口。
三、从硬件中断到 memory_failure 的完整链路
当确认一个未被纠正的内存错误与物理地址相关后,内核通过以下调用链进入实际的处理:
do_machine_check()
└── mce_is_memory_error() && !UC → mce_notify_irq()
└── mce_work (workqueue)
└── mce_disable_error()
└── (触发页面隔离流程)
memory_failure(MFN, flags) ← 核心入口
└── failure_reason = get_reason() # 分析错误原因
└── p = pfn_to_page(pfn) # 获取 struct page
└── if PageHWPoison(p): return -EHWPOISON
└── klist = {NULL, NULL, NULL}
└── if PageLRU(p): # LRU 上的活跃页
│ ├── isolate_lru_page(p)
│ └── kill_page(p, MCE)
├── elif PageHuge(p): # 大页
│ └── hwpoison_thaw_page()
├── elif PageOffline(p): # 已离线
│ └── return 0
└── else:
└── unlock_page(p)
└── return memory_failure_action(p, pfn)
关键的入口函数 memory_failure() 需要处理几种不同的 page 状态:
Page 状态
处理方式
备注
PageLRU
isolate + kill
从 LRU 链表摘除,通知持有映射
PageHuge
拆分为 4KB 页再处理
大页不能在原粒度隔离
PageOffline
直接返回成功
已经隔离,无需重复操作
PageHWPoison
返回 -EHWPOISON
已经标记为硬件中毒
四、关键步骤详解:hwpoison 标记与反向映射
4.1 page 中毒标记
当一个页面被确认无法恢复时,内核会设置 PG_hwpoison page flag,并在 struct page 上递增 poison 计数。这个标志一旦设置:
- 再也不会被伙伴系统分配出去
- 触发了
VM_FAULT_HWPOISON_LARGE/VM_FAULT_HWPOISON 的用户态 SEGV 信号
- 任何对该页的 I/O 操作都会被直接拒绝
标记操作在 memory_failure() 中由以下代码完成:
/* 将 page 从 buddy system 中"计算剔除" */
if (!PageHWPoison(p)) {
num_poisoned_pages_inc();
SetPageHWPoison(p);
}
注意这里没有立即把物理页从内存中移除——物理页仍然存在,只是不再被使用。在虚拟机场景下,QEMU 会通过 madvise(MADV_SOFT_OFFLINE) 将错误页面离线索映射。
4.2 反向映射(Reverse Mapping,RMAP)拆除页表映射
这是 memory failure 中最复杂的步骤之一。当一个 page 被多个进程通过线性映射、mmap 或者共享内存共享时,需要遍历所有 PTE(页表条目)并清除 Present bit。
内核的 RMAP 系统支持两层映射:
- anon_vma 链表:用于匿名页(堆、栈、匿名 mmap),每个进程通过
anon_vma_chain 反向链接
- address_space interval tree:用于文件缓存页,通过
xarray (即新的 page cache 基础结构) 反向索引
/* pseudo-code:遍历 RMAP 拆除映射 */
static int hwpoison_user_mappings(struct page *p, unsigned long pfn,
int flags, struct page *hpage)
{
struct to_kill *tk;
LIST_HEAD(to_kill_list);
unsigned long addr;
/* 1. 遍历页上所有持有该 PTE 的进程 */
tk = collect_procs(p, pfn, &to_kill_list);
/* 2. 对每个进程的每个映射建立 kill 信息 */
list_for_each_entry(tk, &to_kill_list, nd) {
addr = tk->addr;
/* 3. 在页表条目中清除 Present,设置 HWPoison 软件位 */
if (hpage) {
/* 大页:拆分 PMD 条目后逐 PTE 处理 */
split_huge_pmd tk->vma tk->mm addr pmd;
}
ptep = offset_map_lock_init tk->mm addr);
if (!ptep) continue;
if (Present(ptep)) {
/* 将 PTE 清零并设置为 sw-poison */
pte_clear tk->mm addr ptep);
/* 或设置 HWPoison 标记通知 userfault */
}
munmap_notifiers...
}
/* 4. 发送 SIGBUS 或 SIGKILL */
for_each_tk_sigbus tk;
}
工程中一个常见陷阱是:struct page 是共享的,但 struct mm_struct 属于进程,所以在拆卸 PTE 时必须持 mmap_lock。如果此时该进程正在 fork()、exec() 或 exit() 中,mmap_lock 可能长时间阻塞,memory failure handler 需要优雅处理这种竞争。
4.3 通知持有映射的进程
在销毁映射后,内核会对持有该页的进程发送 SIGBUS(对于文件映射)或触发 oom(对于匿名映射)。在 SIGBUS 的 siginfo_t 中,内核通过 si_addr 携带故障地址,通过 si_code 区分 BUS_MCEERR_AR(必须处理,不可忽略)和 BUS_MCEERR_AO(异步,用于 CE 通知)。
用户态进程可以通过注册 SIGBUS handler 来实现"内存泄漏容错"——这是一个生产级高可用架构的关键点。
/* Python 示例:注册 SIGBUS handler 实现容错 */
# 在 C/Rust 示例中
void sigbus_handler(int sig, siginfo_t *info, void *ctx) {
// 1. 记录故障地址
// 2. 尝试刷新该地址所在的事务
// 3. abort 已提交的该页操作
// 4. 服务降级,不 crash
}
五、生产环境实战:Soft-offline 与 Hard-offline
Linux 提供两种主要的页面隔离策略,分别适用于不同的错误严重程度:
5.1 Soft-offline(软隔离)
用于需要自动恢复能力的场景。当一个页面被 health monitoring 标记为可疑(CE 频率超过阈值),但 UCE 尚未发生时,soft-offline 会在内核驱动中通过以下路径执行:
madvise(page_offset, MADV_SOFT_OFFLINE) — 用户态 syscall 触发
- 内核调用
soft_offline_page()
- 检查页面是否属于 RMAP 持有页,触发 RMAP 销毁
- 设置
PG_hwpoison,将页面从 LRU 摘除
- 从 buddy system 中移除该页,不再参与分配
Soft-offline 的核心权衡:尽早隔离可疑页,避免未来 UCE 导致大段数据丢失。但有一个副作用——该物理页将永久从可用内存中减少(除非执行 hot-add 重新插回)。
5.2 Hard-offline(硬隔离)
用于已经发生 UCE 的页面。流程类似但更直接:检测到 UCE → memory_failure() → 设置 hwpoison → 拆除 RMAP → 杀死持有映射的进程。此过程是不可逆的,数据已经丢失。
5.3 Hardware-poison page 在大页的特别处理
对 2MB 大页(THP)或 1GB hugetlbfs 页面的处理有特殊路径:
/* pseudo-code:THP 硬件 poison 处理路径 */
if (PageHuge(page) || PageTransHuge(page)) {
if (SoftOffline) {
// 强制拆分大页,再隔离包含错误的 4KB 子页
split_huge_page(page);
memory_failure(sub_pfn, flags);
} else {
// Hard-offline:整个 2MB 大页都需要隔离
// 因为硬件错误通常是整个 rank(≥2MB)的问题
SetPageHWPoison(head); // head page
SetPageHWPoison(head + i); // tail pages
}
}
生产经验表明,在数据库等 THP 密集场景中,一个 single-bit UCE 可能导致整个 2MB 大页被隔离。这意味着隔离代价极高——宁愿损失 2MB 也不能承受后续的错误扩散。可以通过 /proc/sys/vm/memory_failure_early_kill 来调整优先级。
六、虚拟化场景:宿主机错误不泄漏到客户机
在 KVM/QEMU 虚拟化场景中,宿主机物理内存的错误不应该导致客户机进程崩溃(除非该错误确实影响到了客户机)。内核通过以下机制实现隔离:
- MCE 注入:当宿主机 MCE 涉及的客户机页面时,内核生成一个虚拟 MCE 并注入客户机。KVM 在
kvm_mce_inject() 中通过 VMCS field VM_ENTRY_EXCEPTION_ERROR_CODE 配置,使客户机在 VM-Entry 时处理该 MCE。
- MMIO EOI:对于 SR-IOV 设备触发的 PCIe AER(Advanced Error Reporting),错误处理路径通过 MMIO 访问 GSI(Global System Interrupt),通知客户机驱动执行 err_handler 回调。
- Virtio-mem:通过
virtio-mem 设备,宿主机可以在错误发生时通知客户机热拔出故障页面——客户机 ACPI handler 执行 virtio_mem_offline(),在不影响其他页面的情况下隔离。
关键代码路径:
/* KVM 向客户机注入虚拟 MCE */
struct kvm_x86_ops->inject_mce(struct kvm_vcpu *vcpu)
{
if (vcpu->arch.mce_pending) {
kvm_queue_exception_e(vcpu, MC_VECTOR,
vcpu->arch.mce_error_code);
vcpu->arch.mce_pending = false;
}
}
七、工程实践:配置与监控
7.1 sysctl 关键参数
# 早期杀死持 map 进程,避免重试读取导致更多错误
sysctl -w vm.memory_failure_early_kill=1
# action 表超时(用于 action_on_overflow)
sysctl -w vm.memory_failure_recovery=1
# Offlining 时的内存压力测试(开发用)
sysctl -w vm.memory_failure_print=0
7.2 EDAC sysfs 监控
# 查看 DIMM 错误计数
cat /sys/devices/system/edac/mc/mc0/csrow0/ce_count
cat /sys/devices/system/edac/mc/mc0/csrow0/ue_count
# 查看具体 DIMM 位置
cat /sys/devices/system/edac/mc/mc0/csrow0/ch0_dimm_loc
7.3 MCE 日志解析
# 使用 mcelog 或 rasdaemon 解析 MCE 记录
journalctl -u rasdaemon # systemd service
# 或直接使用 mcelog
mcelog --client
7.4 错误注入测试(开发环境)
内核提供 hardware_poison debugfs 节点用于模拟测试:
# 构建内核时开启 CONFIG_MEMORY_FAILURE 和 CONFIG_HWPOISON_INJECT
# 注入一个测试 fault(模拟 UCE)
echo 0x$(cat /proc/iomem | head -n 1 | awk '{print $1}' | tr -d '- ') \
> /sys/kernel/debug/hardware_poison/corrupt-page-pfn
# 或使用 madvise 测试 soft-offline
gcc -o test_soft_offline test.c
./test_soft_offline --offset 0x100000 --size $((2*1024*1024))
八、生产案例解析
案例 1:PostgreSQL 大页内存下的 UCE 隔离
一台运行 PostgreSQL 16 的服务器启用了 huge_pages = on 和 shared_buffers = 64GB。某 DIMM 容量为 64GB 的 Rank 发生 UCE。UCE 地址落入 PostgreSQL shared buffer 分配给大页的区域。
内核处理路径:
- MCE handler 触发,Bank 状态 UC=1、ADDRVALID=1
do_machine_check() 识别为 memory error
- 调用
memory_failure(pfn)
PageHuge(page) 为 true → 走大页拆开路径
- 拆分为 512 个 4KB 子页后仅隔离故障子页(其余 511 页被浪费)
hwpoison_user_mappings() 拆除所有持有该大页的进程的 PTE
- PostgreSQL postmaster 收到 SIGBUS,触发
プロセス終了 → 子プロセス全部重启
教训:在 UCE 密集场景下,THP 的优势被其隔离代价部分抵消。建议对内存错误敏感的 OLTP 工作负载关闭 THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled),并使用较小的 shared_buffers 单元。
案例 2:多租户 Kubernetes 节点下的内存错误隔离
一个运行 100+ 容器的 Kubernetes worker node 中,一个 UCE 落在某容器的用户态堆上。
Strace 追踪显示:
- UCE 触发宿主机
do_machine_check()
- 反向映射定位到持有该页的 containerd-shim 子进程
memory_failure() 设置 hwpoison,拆除映射
- 容器内 SIGBUS handler 记录日志但继续运行(取决于应用设计)
- kubelet 通过 cgroup
memory.peak 观察到内存减少,调度器触发 Pod Eviction
关键点:在多租户场景下,容器的内存错误会"上浮"到宿主机的 memory failure 处理——如果该容器使用的是 hostNetwork 命名空间甚至会影响同节点其他 Pod。通过 cgroup v2 的 memory.failure_inherit 和 memory.high 配置可以在 cgroup 层面做初步限幅。
九、总结
Linux 的 Memory Failure 子系统是一个横跨硬件异常、页表管理、文件系统、虚拟化的复杂工程系统。它的核心设计原则是在错误已经发生的前提下,做到以下三件事:
- 快速检测:通过 MCE/EDAC/HEST 多通道及时发现硬件错误
- 原子隔离:在 hardirq 上下文中标记 hwpoison,在 workqueue 中拆除引用
- 优雅传播:通过 SIGBUS、VM_FAULT_HWPOISON 或虚拟 MCE 将错误传递给持有进程
理解这个系统,不只是为了"会用 sysctl",更是为了在设计高性能、高可用的存储引擎或容器平台时,从进程到内核到硬件建立完整的认知栈——当真正的 UCE 发生时,你的系统会按照你预期的方式隔离错误、保留其余数据,而不是让整个节点无声地崩溃。
评论列表 共有 0 条评论

发表评论 取消回复