☰
Linux内核MMU notifier机制详解:从mmu_notifier到mmu_interval_notifier的演进与实践
2026/9/28 1:06:00 网站建设 项目流程

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/下翻任何一个支持 HMM(Heterogeneous Memory Management)的驱动,几乎都能看到mmu_interval_notifier的身影。

这篇文章不打算照本宣科地翻译内核文档,而是想把这套机制为什么这么设计、几个 notifier 变体的区别、驱动里到底该怎么用、以及踩过的坑讲清楚。适合已经有一定内核基础、正在做设备驱动或者虚拟化相关工作的读者。如果你只是好奇"MMU notifier 是个啥",前两节看完也能有个整体印象。

2. 拆开看 MMU notifier 的家族:从 mmu_notifier 到 mmu_interval_notifier

MMU 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:单个页失效。
  • release:mm 即将销毁。
  • 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,让内核稍后再来。

还有一个seq(sequence 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)映射到设备页表。正确流程是这样的:

  1. 调用mmu_interval_notifier_insert()注册区间,拿到一个mmu_interval_notifier。
  2. 调用mmu_interval_read_begin(),它会返回当前的 seq,并且如果区间正在被失效处理,会阻塞等待。
  3. 拿着这个 seq,去建立设备侧页表项(PTE),把 CPU 页的物理地址填进去。
  4. 建立完成后,调用mmu_interval_read_retry(),传入第 2 步拿到的 seq。
  5. 如果返回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 的开源内核模块)普遍支持 UVA(Unified Virtual Addressing),也就是 CPU 和 GPU 共享同一个虚拟地址空间。这天然需要 MMU notifier。

GPU 场景的特殊性在于:

  • 映射量巨大:一个深度学习训练任务可能映射几十 GB,但活跃的只是一小部分。
  • 失效延迟敏感:GPU kernel 执行时间可能很长,不能因为一次失效就全部停掉。
  • 需要与 HMM 协同:当 CPU 页被换出时,GPU 访问会触发 HMM 的 fault,把页换回来。

所以 GPU 驱动用 mmu_interval_notifier 时,通常会把区间切得很细,按需注册。比如 amdgpu 的amdgpu_mn就是按 BO(Buffer 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 之前没有确保设备已经停止访问该区间,就会出现这样的时序:

  1. 驱动决定销毁映射,调用 remove。
  2. remove 内部等待当前 invalidate 完成。
  3. 但设备此时还在 DMA 写这块内存。
  4. remove 返回,驱动释放了相关资源。
  5. 设备 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,我的排查顺序是:

  1. 确认 seq 使用是否正确:read_begin/retry 是否成对,中间是否有睡眠。
  2. 确认锁顺序:用 lockdep 打开,看有没有报告。
  3. 确认 remove 时序:设备是否真的停了。
  4. 加 tracepoint:内核自带的mmu_notifiertracepoint 很好用,能看到每次回调的区间和 seq。
  5. 压力测试:用mmap+madvise疯狂折腾地址空间,配合设备访问,最容易复现。

注意:调试这类问题时,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、寄存器状态)抓全,再慢慢推理。急着改往往会把真正的根因掩盖掉,后面更难查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询