在 KVM 的代码里泡久了你会发现一个很有意思的现象:真正决定虚拟机内存语义的往往不是 vCPU 那套热路径,而是那些平时不怎么被注意的“旁路机制”。MMU Notifier 就是其中最典型的一个。它要解决的是 KVM 虚拟化里非常底层的同步问题——宿主机想回收、迁移、重新映射某个物理页之前,必须先让 KVM 把 EPT 里对应的影子页表项拆掉或更新,否则 Guest 会继续访问一块已经不属于自己的内存。换句话说,它定义的是操作系统页表改动方和 KVM 之间“谁负责通知、谁负责善后”的边界。
这篇文章我从 KVM 的角度把这条线完整拆一遍:为什么需要它、内核的观察者框架怎么搭、KVM 注册了哪些回调、每个回调在什么场景触发,以及实际调试中容易踩的锁和竞态。适合正在啃 KVM 内存虚拟化源码、准备处理 virtio-mem、内存热插拔、KSM、设备直通相关问题的内核开发者。全文基于主线内核 x86 实现的通用逻辑,具体函数名以你手上的内核版本为准。
1. 先说清楚一个核心问题:Guest 物理内存的页表由谁在管
1.1 两层页表叠起来之后,问题就变了
普通进程的地址翻译只有一级:MMU 把虚拟地址 VA 经过页表翻译成物理地址 PA,内核负责维护这张页表。到了 KVM 虚拟机里,地址翻译变成了两级:Guest 虚拟地址 GVA 先经过 Guest 自己的页表翻译成 Guest 物理地址 GPA,再经过 EPT/NPT 二级地址翻译翻译成宿主机物理地址 HPA。硬件在 CPU 内部完成这两级翻译,对 Guest 来说它看到的是一块连续的“物理内存”,但实际上这块内存映射到宿主机某个进程地址空间的若干物理页上。
问题就出在这:宿主机的物理页不是静态的。内存回收可能把匿名页换出到 swap,内存规整可能移动页的物理位置,mprotect 可能把页表改成只读,KSM 可能把内容相同的匿名页合并成一个只读共享页。这些操作发生的时候,内核维护的是宿主机进程的页表,而 KVM 的 EPT 页表是独立存在的一套影子结构。如果没有一套同步机制,EPT 里就会残留指向旧物理页的映射,Guest 一旦访问,访问的就是已经被回收或迁移走的旧页。
1.2 反向映射机制先定位到 KVM 的影子页表项
KVM 建立 EPT 映射的时候,不只是往页表里填一个 entry 就完事了,还必须把这段映射关系挂到目标物理页的反向映射链上。Linux 内核的 rmap(reverse mapping)机制遍历某个物理页的所有映射时,通过 page->mapping 和 anon_vma 可以找到所有映射它的 VMA,再通过 VMA 里的 pte 定位到具体页表项。
KVM 的影子页表项和普通进程页表项长得完全不一样,它由 KVM 自己管理,内核对它“只可远观不可亵玩”。所以 rmap 遍历到 KVM 的映射时,不能像处理普通 PTE 一样直接置空或者改标志位,它需要通过 mmu_notifier 注册的回调函数,让 KVM 自己在合适的时机去拆 SPTE、刷 TLB。把“发现页表变化”和“处理页表变化”拆成两端,中间用事件回调衔接,是这套设计最核心的抽象。
1.3 MMU Notifier 本质上是观察者模式
把 mmu_notifier 理解成发布-订阅模型最直观:mm 结构体是被观察对象,KVM 是观察者,内核页表操作路径是事件源。某个进程地址空间的页表发生特定变化时,事件源调用 mmu_notifier 的一系列辅助函数,把事件广播给所有注册在这个 mm 上的观察者。
搞清楚这个模型之后,你排查问题的时候思路就会清晰很多:事件没有发出来,那是内核核心路径的问题;事件发出去了但 KVM 处理漏了路径,那是 KVM 回调的问题;事件触发了但锁顺序不对导致死锁,那是两个模块之间的问题。我自己遇到的大部分疑难杂症,最后都归到了第三类。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心数据结构与回调注册:一次内核级“订阅”过程
2.1 mmu_notifier_ops 里到底有哪几类回调
KVM 在创建虚拟机的时候,会把自己的 mmu_notifier 实例注册到 QEMU 进程的 mm_struct 上。注册时传的核心是一个 ops 结构体,里面定义了 KVM 对各种页表事件的响应函数。主线内核上 KVM x86 的注册大概是这个样子:
c复制static const struct mmu_notifier_ops kvm_mmu_notifier_ops = {
.invalidate_range_start = kvm_mmu_notifier_invalidate_range_start,
.invalidate_range_end = kvm_mmu_notifier_in
