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 直接返回成功 已经隔离,无需重复操作

四、关键步骤详解:hwpoison 标记与反向映射

4.1 page 中毒标记

当一个页面被确认无法恢复时,内核会设置 PG_hwpoison page flag,并在 struct page 上递增 poison 计数。这个标志一旦设置:

  1. 再也不会被伙伴系统分配出去
  2. 触发了 VM_FAULT_HWPOISON_LARGE/VM_FAULT_HWPOISON 的用户态 SEGV 信号
  3. 任何对该页的 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 系统支持两层映射:

  1. anon_vma 链表:用于匿名页(堆、栈、匿名 mmap),每个进程通过 anon_vma_chain 反向链接
  2. 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 会在内核驱动中通过以下路径执行:

  1. madvise(page_offset, MADV_SOFT_OFFLINE) — 用户态 syscall 触发
  2. 内核调用 soft_offline_page()
  3. 检查页面是否属于 RMAP 持有页,触发 RMAP 销毁
  4. 设置 PG_hwpoison,将页面从 LRU 摘除
  5. 从 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 虚拟化场景中,宿主机物理内存的错误不应该导致客户机进程崩溃(除非该错误确实影响到了客户机)。内核通过以下机制实现隔离:

  1. MCE 注入:当宿主机 MCE 涉及的客户机页面时,内核生成一个虚拟 MCE 并注入客户机。KVM 在 kvm_mce_inject() 中通过 VMCS field VM_ENTRY_EXCEPTION_ERROR_CODE 配置,使客户机在 VM-Entry 时处理该 MCE。
  1. MMIO EOI:对于 SR-IOV 设备触发的 PCIe AER(Advanced Error Reporting),错误处理路径通过 MMIO 访问 GSI(Global System Interrupt),通知客户机驱动执行 err_handler 回调。
  1. 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 分配给大页的区域。

内核处理路径:

  1. MCE handler 触发,Bank 状态 UC=1、ADDRVALID=1
  2. do_machine_check() 识别为 memory error
  3. 调用 memory_failure(pfn)
  4. PageHuge(page) 为 true → 走大页拆开路径
  5. 拆分为 512 个 4KB 子页后仅隔离故障子页(其余 511 页被浪费)
  6. hwpoison_user_mappings() 拆除所有持有该大页的进程的 PTE
  7. 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 追踪显示:

  1. UCE 触发宿主机 do_machine_check()
  2. 反向映射定位到持有该页的 containerd-shim 子进程
  3. memory_failure() 设置 hwpoison,拆除映射
  4. 容器内 SIGBUS handler 记录日志但继续运行(取决于应用设计)
  5. kubelet 通过 cgroup memory.peak 观察到内存减少,调度器触发 Pod Eviction

关键点:在多租户场景下,容器的内存错误会"上浮"到宿主机的 memory failure 处理——如果该容器使用的是 hostNetwork 命名空间甚至会影响同节点其他 Pod。通过 cgroup v2 的 memory.failure_inherit 和 memory.high 配置可以在 cgroup 层面做初步限幅。

九、总结

Linux 的 Memory Failure 子系统是一个横跨硬件异常、页表管理、文件系统、虚拟化的复杂工程系统。它的核心设计原则是在错误已经发生的前提下,做到以下三件事:

  1. 快速检测:通过 MCE/EDAC/HEST 多通道及时发现硬件错误
  2. 原子隔离:在 hardirq 上下文中标记 hwpoison,在 workqueue 中拆除引用
  3. 优雅传播:通过 SIGBUS、VM_FAULT_HWPOISON 或虚拟 MCE 将错误传递给持有进程

理解这个系统,不只是为了"会用 sysctl",更是为了在设计高性能、高可用的存储引擎或容器平台时,从进程到内核到硬件建立完整的认知栈——当真正的 UCE 发生时,你的系统会按照你预期的方式隔离错误、保留其余数据,而不是让整个节点无声地崩溃。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
PageHWPoison 返回 -EHWPOISON 已经标记为硬件中毒