
1. 从一个真实的调试场景说起为什么需要 MMU notifier如果你做过 KVM 虚拟化、GPU 驱动或者 RDMA 相关的内核开发大概率遇到过这样一种让人头皮发麻的崩溃设备正在通过 DMA 往一块内存写数据结果那块内存被主 CPU 侧悄悄迁移走了设备还在往旧地址写轻则数据错乱重则直接触发 IOMMU fault 或者 XID 报错整台机器挂掉。这类问题的根源在于设备侧页表IOMMU 页表、GPU 页表和 CPU 侧页表进程页表之间的同步。CPU 侧的内存管理是高度动态的页可以被换出、被迁移、被合并KSM、被madvise(MADV_DONTNEED)回收。而设备侧一旦把某个虚拟地址翻译结果缓存进 TLB 或者直接固化在页表里它并不知道 CPU 那边已经变天了。MMU notifier 就是 Linux 内核给出的答案。它是一套反向映射通知机制当内核准备对某个进程地址空间的页表做改动时会回调注册过的 notifier让设备驱动有机会去失效自己这边的映射。名字里的 MMU 指的是内存管理单元notifier 就是通知者——合起来就是内存管理单元变更通知器。这套机制最早在 2008 年前后随 KVM 的 MMU 影子页表需求进入主线后来被 GPU 驱动尤其是统一虚拟内存 UVA 场景、RDMA、以及各种需要设备共享进程地址空间的子系统广泛采用。今天你在drivers/gpu/drm/下翻任何一个支持 HMMHeterogeneous Memory Management的驱动几乎都能看到mmu_interval_notifier的身影。这篇文章不打算照本宣科地翻译内核文档而是想把这套机制为什么这么设计、几个 notifier 变体的区别、驱动里到底该怎么用、以及踩过的坑讲清楚。适合已经有一定内核基础、正在做设备驱动或者虚拟化相关工作的读者。如果你只是好奇MMU notifier 是个啥前两节看完也能有个整体印象。2. 拆开看 MMU notifier 的家族从 mmu_notifier 到 mmu_interval_notifierMMU notifier 不是一个单一结构而是一个层层演进的家族。理解它们的分工是正确使用这套机制的前提。很多人第一次看代码时被mmu_notifier、mmu_notifier_ops、mmu_interval_notifier、mmu_interval_notifier_ops这几个名字绕晕其实只要抓住粒度这条主线就清楚了。2.1 最原始的 mmu_notifier全地址空间级别的粗粒度通知最早的struct mmu_notifier是挂在struct mm_struct上的。驱动通过mmu_notifier_register()注册一个mmu_notifier_ops之后这个进程地址空间里发生的任何页表变动都会回调到你。它的回调接口大致有这么几类invalidate_range_start/end某个地址范围即将失效start 里通常要阻塞等待设备停止访问end 表示可以恢复。invalidate_page单个页失效。releasemm 即将销毁。clear_flush_young、test_young用于页的年轻位管理配合回收。这套接口的问题在于粒度太粗。只要进程地址空间有任何风吹草动所有注册的 notifier 都会被叫醒。对于一个 GPU 驱动来说进程可能映射了几十 GB 的虚拟地址但实际活跃的只有几 MB每次全局回调都去扫一遍自己的页表开销完全不可接受。更麻烦的是invalidate_range_start的语义它要求驱动在返回前确保设备不再访问该范围。对于 GPU 这种异步执行引擎这意味着要等所有相关命令队列排空延迟极高。所以早期 GPU 驱动用 mmu_notifier 时性能一直上不去。2.2 mmu_interval_notifier区间粒度的精准打击为了解决粗粒度问题内核引入了mmu_interval_notifier。核心思路是驱动只对自己真正映射过的地址区间感兴趣那就按区间注册而不是按整个 mm。每个mmu_interval_notifier描述一个[start, end)区间挂在mmu_interval_notifier_ops上。当内核要改动某个区间时只会回调与该区间有重叠的 notifier。这就把全局广播变成了定向通知。它的关键回调是struct mmu_interval_notifier_ops { bool (*invalidate)(struct mmu_interval_notifier *mni, const struct mmu_notifier_range *range, unsigned long cur_seq); };注意invalidate返回bool返回true表示我处理完了你可以继续返回false表示我这边还有事你得重试。这个设计允许驱动在无法立即失效时比如设备还在用这块内存返回 false让内核稍后再来。还有一个seqsequence number机制这是 interval notifier 的精髓。每次区间状态变化seq 会递增。驱动在建立设备侧映射前先读一次 seq建立完再读一次如果两次不一致说明中间有人动过得重来。这就是所谓的retry loop后面会详细讲。2.3 三者的对比与选型特性mmu_notifiermmu_interval_notifier备注注册粒度整个 mm_struct任意地址区间interval 更精准回调频率高任何变动都触发低仅重叠区间触发性能差异巨大是否支持 retry否是seq 机制interval 可处理竞争典型使用者早期 KVM、部分 RDMAGPU/HMM、现代 KVM新代码优先 interval阻塞语义invalidate_range_start 需阻塞invalidate 可返回 falseinterval 更灵活选型建议很直接新写的驱动只要涉及设备共享进程地址空间一律用 mmu_interval_notifier。除非你有非常特殊的全局需求否则不要碰老的 mmu_notifier。内核社区也在逐步把老接口往 interval 上迁移。提示mmu_interval_notifier依赖CONFIG_HMM_MIRROR现在叫CONFIG_MMU_NOTIFIER下的相关选项编译前确认内核配置否则相关符号根本不存在。3. 核心机制拆解seq 序列号与 retry loop 到底怎么工作这一节是全文的重点。很多人用 mmu_interval_notifier 时代码能跑但偶尔出诡异 bug根源几乎都在 seq 和 retry 的理解上。我把它拆成建立映射和失效映射两条路径来讲。3.1 建立设备侧映射时的 seq 双读校验假设 GPU 驱动要把进程的一段虚拟地址[A, B)映射到设备页表。正确流程是这样的调用mmu_interval_notifier_insert()注册区间拿到一个mmu_interval_notifier。调用mmu_interval_read_begin()它会返回当前的 seq并且如果区间正在被失效处理会阻塞等待。拿着这个 seq去建立设备侧页表项PTE把 CPU 页的物理地址填进去。建立完成后调用mmu_interval_read_retry()传入第 2 步拿到的 seq。如果返回true说明期间区间被改动过第 3 步建立的映射可能已经失效必须回滚并重试返回false才算成功。为什么需要双读因为第 2 步到第 4 步之间CPU 侧可能发生了页迁移。如果只读一次 seq你无法知道建立映射的过程中有没有人插队。双读相当于一个乐观锁读-改-读两次一致才提交。这里有个容易踩的坑第 3 步建立映射的过程不能持有会睡眠的锁因为mmu_interval_read_begin()可能睡眠等待失效完成。如果你在持有自旋锁的情况下调用它直接就是 scheduling while atomic。我见过有同事把这段逻辑塞进中断上下文结果系统随机卡死查了两天才定位到。3.2 invalidate 回调里的不能睡眠约束invalidate回调是在内核的 mmu notifier 调用链里执行的这个上下文不允许睡眠至少在invalidate_range_start的快速路径上。这意味着你不能在回调里等 GPU 命令队列排空。你不能在回调里分配可能触发回收的内存。你只能做标记失效这种轻量操作。那设备还在用这块内存怎么办答案是返回 false。当驱动发现该区间对应的设备映射还在被引用比如有正在执行的命令就返回 false告诉内核我现在没法失效你稍后再来。内核会把这个区间标记为待失效在合适的时机重试。这个设计的好处是把等待的责任从回调上下文转移到了驱动自己的调度逻辑里。驱动可以在自己的 worker 线程里慢慢等设备排空排空后再主动触发一次失效流程。3.3 一个完整的 retry loop 伪代码把上面两条路径合起来驱动里典型的映射建立逻辑长这样int gpu_map_range(struct gpu_ctx *ctx, unsigned long start, unsigned long end) { struct mmu_interval_notifier *mni ctx-notifier; unsigned long seq; int ret; ret mmu_interval_notifier_insert(mni, current-mm, start, end - start, gpu_mni_ops); if (ret) return ret; retry: seq mmu_interval_read_begin(mni); /* 遍历区间内每个页建立设备 PTE */ ret gpu_build_ptes(ctx, start, end); if (ret) goto out_remove; if (mmu_interval_read_retry(mni, seq)) { /* 区间被改动回滚已建立的 PTE重来 */ gpu_teardown_ptes(ctx, start, end); goto retry; } return 0; out_remove: mmu_interval_notifier_remove(mni); return ret; }注意goto retry这个循环。理论上它可能一直循环下去如果 CPU 侧疯狂迁移页但实际中很少见。不过为了健壮性有些驱动会加一个重试次数上限超过就报错返回避免活锁。3.4 seq 与 PTE 更新的内存序问题还有一个隐蔽的点seq 的读写和 PTE 的更新之间需要正确的内存屏障。内核在mmu_interval_read_begin/retry内部已经处理了 acquire/release 语义但驱动自己更新设备 PTE 时如果用了 DMA 描述符之类的异步机制要确保 PTE 对设备可见的顺序正确。这块如果搞错表现是偶尔设备读到旧 PTE非常难查。我的经验是设备 PTE 的更新要么走 MMIO 写加读回确认要么走 coherent 内存加显式 barrier不要想当然地认为 CPU 写完设备就能看到。4. 在 KVM 和 GPU 驱动里的实际落地差异MMU notifier 在不同子系统里的用法差别很大因为它们的设备特性完全不同。KVM 的设备是 guest 的影子页表GPU 的设备是真实的图形引擎。理解这些差异能帮你在自己的场景里做出正确取舍。4.1 KVM 场景影子页表与 EPT 的失效KVM 用 mmu notifier 的核心诉求是guest 的页表是基于 host 进程地址空间构建的host 侧页表一变guest 的影子页表或 EPT 就得跟着失效。在早期的影子页表实现里KVM 注册的是全局 mmu_notifier因为影子页表覆盖整个 guest 地址空间粒度天然是全局的。回调里 KVM 要做的是把对应的影子页表项标记为无效下次 guest 访问时触发缺页重新构建。到了 EPT/NPT 时代情况变了。EPT 是硬件两级页表guest 物理地址到 host 物理地址的映射由硬件走。当 host 侧要迁移一个页时KVM 需要 invalidate 对应的 EPT 项。这时候用 mmu_interval_notifier 就更合适因为可以按 GPA 区间精准失效。KVM 里有个细节值得注意invalidate 回调里不能直接操作 EPT因为 EPT 的修改需要通过kvm_mmu_notifier_invalidate_range_start这类封装还要考虑 vCPU 正在运行的并发。KVM 的做法是发一个 request让 vCPU 在下次进入 guest 前处理。这套延迟失效的思路和 GPU 驱动返回 false 是异曲同工的。4.2 GPU 驱动场景UVA 与 HMM 的配合现代 GPU 驱动比如 amdgpu、i915 的某些路径、以及 NVIDIA 的开源内核模块普遍支持 UVAUnified Virtual Addressing也就是 CPU 和 GPU 共享同一个虚拟地址空间。这天然需要 MMU notifier。GPU 场景的特殊性在于映射量巨大一个深度学习训练任务可能映射几十 GB但活跃的只是一小部分。失效延迟敏感GPU kernel 执行时间可能很长不能因为一次失效就全部停掉。需要与 HMM 协同当 CPU 页被换出时GPU 访问会触发 HMM 的 fault把页换回来。所以 GPU 驱动用 mmu_interval_notifier 时通常会把区间切得很细按需注册。比如 amdgpu 的amdgpu_mn就是按 BOBuffer Object粒度管理区间。当某个 BO 被迁移时只失效对应的区间其他 BO 不受影响。这里有个实战经验区间切分粒度不是越细越好。太细会导致 notifier 数量爆炸注册/注销开销和内存占用都上去了。我的经验值是单个区间不小于 2MB一个 huge page 的量级这样既能保证精度又不至于管理成本过高。4.3 两者对 invalidate 返回值的处理差异KVM 的 invalidate 回调基本总是返回 true它只是标记失效不阻塞而 GPU 驱动经常返回 false。这个差异源于KVM 的设备是 vCPU失效只是让 vCPU 下次访问时重新走页表没有正在进行的 DMA。GPU 有真实的 DMA 引擎可能正在读写这块内存必须等它停。所以如果你在写一个既有 CPU 侧访问又有设备 DMA 的驱动要仔细想清楚invalidate 时设备是否可能正在访问如果是就必须支持返回 false 和后续重试。5. 踩坑实录那些文档里不会写的失效竞争问题前面讲的都是应该怎么做这一节讲实际会怎么错。这几个坑都是我和周围同事真实踩过的每一个都值得单独拿出来说。5.1 坑一忘记在 remove 前停止设备访问mmu_interval_notifier_remove()会等待所有正在进行的 invalidate 回调完成。但如果你在调用 remove 之前没有确保设备已经停止访问该区间就会出现这样的时序驱动决定销毁映射调用 remove。remove 内部等待当前 invalidate 完成。但设备此时还在 DMA 写这块内存。remove 返回驱动释放了相关资源。设备 DMA 写到已释放的内存触发 IOMMU fault。正确做法是先停止设备访问排空命令队列、等待 fence再调用 remove。顺序反了就是灾难。5.2 坑二seq 读取和 PTE 建立之间的窗口这个前面提过但值得再强调。有同事的代码是这样的seq mmu_interval_read_begin(mni); /* 这里做了一堆耗时操作包括睡眠等锁 */ build_ptes(...); if (mmu_interval_read_retry(mni, seq)) { ... }问题在于read_begin和build_ptes之间如果有长时间睡眠区间被改动的概率大增retry 几乎必然触发性能极差。更糟的是如果睡眠期间区间被 remove 了mni可能已经失效后续访问就是 use-after-free。正确做法是read_begin 之后尽快完成 PTE 建立中间不要有可睡眠的操作。如果确实需要分配内存提前分配好。5.3 坑三invalidate 回调里的锁顺序反转invalidate 回调是在内核 mmu notifier 的调用链里跑的此时可能持有 mm 相关的锁。如果你的回调里又去拿驱动自己的锁而驱动另一条路径持有该锁再去拿 mm 锁就死锁了。我遇到过一次GPU 驱动的 fence 完成回调里要拿ctx-lock然后调用某个会触发 mm 操作的函数而 invalidate 回调里先拿了 mm 锁再拿ctx-lock。两条路径锁顺序相反压力测试下必死。解决办法是统一锁顺序要么所有路径都先 mm 后 ctx要么用 trylock 加退避。我倾向于后者因为 mm 锁的持有时间不可控。5.4 坑四把 invalidate 当成同步点有些驱动设计时假设invalidate 回调返回后设备就一定不再访问该区间了。这个假设在返回 true 时成立但如果你返回了 false内核只是记下待处理并不会阻塞。后续设备可能还在访问直到你主动完成失效。所以驱动必须自己维护一个待失效区间列表在自己的 worker 里处理。不能依赖内核帮你同步。5.5 排查这类问题的通用思路遇到 MMU notifier 相关的诡异 bug我的排查顺序是确认 seq 使用是否正确read_begin/retry 是否成对中间是否有睡眠。确认锁顺序用 lockdep 打开看有没有报告。确认 remove 时序设备是否真的停了。加 tracepoint内核自带的mmu_notifiertracepoint 很好用能看到每次回调的区间和 seq。压力测试用mmapmadvise疯狂折腾地址空间配合设备访问最容易复现。注意调试这类问题时CONFIG_DEBUG_ATOMIC_SLEEP和CONFIG_PROVE_LOCKING一定要打开能提前暴露大部分问题。6. 写给准备上手的人从零接入 mmu_interval_notifier 的检查清单如果你正准备在自己的驱动里接入这套机制下面这份清单可以帮你少走弯路。它不是 API 手册而是接入前想清楚、接入后验证到的实操指引。6.1 接入前的设计决策先回答几个问题你的设备是否会 DMA 访问进程地址空间如果只是访问内核分配的内存根本不需要 MMU notifier。访问是同步还是异步同步访问比如 CPU 帮忙拷贝不需要 notifier异步 DMA 才需要。失效时能否立即停止设备能则 invalidate 返回 true不能则要支持 false 和重试。区间粒度怎么定按业务对象BO、MR、vma切还是按固定大小切这几个问题的答案直接决定你的实现复杂度。我见过有人为了通用把区间切得极细结果 notifier 管理代码比业务代码还长得不偿失。6.2 必须实现的回调骨架一个最小可用的实现至少要有static bool gpu_invalidate(struct mmu_interval_notifier *mni, const struct mmu_notifier_range *range, unsigned long cur_seq) { struct gpu_ctx *ctx container_of(mni, struct gpu_ctx, notifier); if (!mmu_interval_notifier_uses(mni, range, cur_seq)) return true; /* 标记该区间需要失效交给 worker 处理 */ mark_for_invalidation(ctx, range-start, range-end); schedule_work(ctx-invalidate_work); /* 如果设备可能正在访问返回 false */ if (gpu_may_be_accessing(ctx, range-start, range-end)) return false; return true; } static const struct mmu_interval_notifier_ops gpu_mni_ops { .invalidate gpu_invalidate, };mmu_interval_notifier_uses()是个辅助函数用来判断这次回调是否真的和你的区间相关考虑 seq 和范围重叠。别自己写这个判断容易错。6.3 验证手段接入后至少要做这几项验证功能验证设备访问进程内存同时用madvise(MADV_DONTNEED)回收看设备是否读到正确数据或触发 fault 重新映射。压力验证多线程疯狂 mmap/munmap配合设备持续访问跑几小时看有没有 crash。性能验证对比接入前后的吞吐确认 notifier 开销可接受。锁验证开 lockdep 跑一遍确认无锁顺序问题。6.4 几个容易被忽略的细节mmu_interval_notifier_insert()失败时要清理已分配资源别漏。区间跨越mmap边界时内核会自动拆分你的回调要能处理任意子区间。release回调mm 销毁里不能睡眠只能做最轻量的清理。如果你的驱动支持 fork注意子进程的 mm 是新的notifier 不会自动继承需要重新注册。7. 我个人在实际项目中的几点体会做了几年设备驱动和虚拟化相关的工作MMU notifier 这块给我的最大感受是它的复杂度不在于 API 本身而在于并发时序的推理。API 就那么几个函数但每个函数背后的什么时候会被调用、调用时持有什么锁、能不能睡眠才是真正的难点。我的建议是接入之前先把内核文档里Documentation/mm/mmu_notifier.rst和mmu_interval_notifier相关的注释读三遍然后找一个成熟的驱动比如 amdgpu 的amdgpu_mn.c对照着看。不要自己凭空设计这套机制的边界条件太多了前人踩过的坑没必要再踩一遍。另外调试这类问题时 tracepoint 比 printk 好用得多。trace_mmu_notifier_invalidate_range_start和trace_mmu_notifier_invalidate_range_end能让你清楚地看到每次失效的区间、seq 和调用者配合perf或者trace-cmd分析效率高很多。最后说一个心态上的事MMU notifier 相关的 bug 往往不是必现的压力测试跑一晚上可能才出一次。遇到这种问题不要急着改代码先把现场信息dmesg、trace、寄存器状态抓全再慢慢推理。急着改往往会把真正的根因掩盖掉后面更难查。