ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核MMU notifier机制详解:从KVM到GPU驱动的页表同步实践

Linux内核MMU notifier机制详解:从KVM到GPU驱动的页表同步实践 1. 从一个真实场景说起为什么需要 MMU notifier搞过 KVM 或者 GPU 驱动开发的人大概率都碰到过这样一个场景虚拟机里跑着一个进程它的内存页被换出到了磁盘或者被迁移到了别的地方但与此同时宿主机上的某个设备比如 GPU、网卡、加速器正通过 DMA 直接访问这块物理内存。如果设备还在往一个已经失效的物理地址上写数据轻则数据错乱重则整机崩溃。这就是MMU notifier要解决的核心问题。简单来说MMU notifier 是 Linux 内核提供的一套回调机制。当内核要修改某个进程的页表映射时——比如 munmap、mremap、页面回收、KSM 合并、热迁移——它会通过这套机制通知所有关心这些映射的“订阅者”。订阅者收到通知后就可以及时更新自己的影子页表、TLB 缓存或者设备侧的地址翻译表保证设备访问的地址始终是有效的。这套机制最初是为 KVM 设计的。KVM 需要维护一份影子页表shadow page table来加速虚拟机内存访问当 Guest 的页表发生变化时KVM 必须同步更新影子页表。后来 GPU 驱动、RDMA 驱动、FPGA 加速器等也发现它们面临同样的问题设备通过 IOMMU 或者自己的 MMU 做地址翻译一旦 CPU 侧的页表变了设备侧也得跟着变。于是 MMU notifier 逐渐演变成了一个通用的“页表变更通知框架”。如果你正在做 KVM 开发、GPU 驱动开发、或者任何涉及设备直接访问用户态内存的工作MMU notifier 是你绕不开的一个知识点。这篇文章我会从设计思路、核心数据结构、实操要点、常见坑几个角度把 MMU notifier 拆开讲透。2. 核心设计思路从 KVM 的影子页表说起2.1 问题的本质两份页表如何保持一致要理解 MMU notifier 的设计得先理解它要解决的根本矛盾。在虚拟化场景下Guest 操作系统以为自己拥有一套完整的页表它把虚拟地址翻译成 Guest 物理地址。但实际上真正访问内存时CPU 用的是 Host 页表把 Host 虚拟地址翻译成 Host 物理地址。KVM 为了加速这个翻译过程会维护一份影子页表直接把 Guest 虚拟地址映射到 Host 物理地址。问题来了Guest 修改自己的页表时KVM 怎么知道答案是 KVM 会把 Guest 的页表设为只读Guest 一写就触发缺页异常KVM 捕获后同步更新影子页表。这套机制在纯虚拟化场景下工作得很好。但还有另一条路径Host 侧的进程地址空间发生变化时比如 Host 上的 qemu 进程执行了 munmap或者内核回收了某个页面影子页表中对应的映射就失效了。KVM 必须知道这件事否则影子页表里还留着一条指向已释放物理页的映射Guest 一访问就是 use-after-free。MMU notifier 就是在这个背景下诞生的。它让 KVM 能够“订阅”Host 进程地址空间的变化事件在页表被修改之前或之后收到回调从而同步更新影子页表。2.2 为什么不用其他机制有人可能会问为什么不用缺页异常为什么不用 rmap反向映射缺页异常只能捕获“访问时”的失效但设备 DMA 不经过 CPU 的缺页处理路径。GPU 通过自己的 MMU 直接访问物理内存CPU 根本不知道它访问了哪里也就无法触发缺页。rmap 虽然能追踪物理页被哪些虚拟地址映射但它的粒度是物理页而且主要用于页面回收不适合做设备侧地址翻译表的同步。MMU notifier 的设计巧妙之处在于它把“页表变更”这个事件抽象出来让任何关心页表映射的子系统都能注册回调。回调的时机可以精确控制——有些操作需要在页表修改之前通知比如 invalidate_range_start有些需要在修改之后通知比如 invalidate_range_end。这种灵活性是其他机制不具备的。2.3 两代接口mmu_notifier 与 mmu_interval_notifierMMU notifier 的接口经历过一次重要演进。早期只有mmu_notifier这一套接口它的粒度比较粗当进程地址空间发生任何变化时所有注册的 notifier 都会收到回调回调里带一个地址范围。KVM 收到回调后需要自己判断这个范围是否影响到了影子页表如果影响到了就更新。这套接口的问题是对于 GPU 驱动这种需要维护大量映射的场景每次回调都要遍历所有映射来判断是否受影响效率很低。而且有些驱动只关心特定地址范围的变化不关心整个地址空间。于是后来引入了mmu_interval_notifier。它的核心思想是驱动可以针对特定的地址区间注册 notifier只有这个区间发生变化时才会收到回调。这大大减少了无效回调也简化了驱动的处理逻辑。mmu_interval_notifier的内部实现依赖于 interval tree区间树内核会把所有注册的区间插入一棵树中当页表变更发生时快速查找受影响的区间并触发回调。这个设计在 GPU 驱动中特别有用因为 GPU 通常只映射用户态的一小部分地址空间不需要关心整个进程的地址变化。3. 核心数据结构与回调时机详解3.1 mmu_notifier_ops回调函数的集合mmu_notifier_ops是 MMU notifier 的核心结构体定义了一组回调函数。驱动或子系统需要实现自己关心的回调然后通过mmu_notifier_register注册到指定进程的mm_struct上。关键回调包括invalidate_range_start在页表变更之前调用通知订阅者“这个范围即将失效”。订阅者需要在这个回调里完成所有必要的同步操作比如清除影子页表项、刷新 TLB。invalidate_range_end在页表变更之后调用通知订阅者“这个范围已经失效”。通常用于释放临时资源或恢复状态。invalidate_page针对单个页面的失效通知粒度更细。release当 mm_struct 被销毁时调用订阅者需要在这里释放所有相关资源。注意invalidate_range_start和invalidate_range_end之间不能睡眠因为页表锁可能还被持有。如果驱动需要睡眠必须在invalidate_range_start里启动一个异步任务在invalidate_range_end里等待它完成。3.2 mmu_interval_notifier区间粒度的订阅mmu_interval_notifier的结构体定义如下简化版struct mmu_interval_notifier { struct interval_tree_node interval_tree; const struct mmu_interval_notifier_ops *ops; struct mm_struct *mm; unsigned long start; unsigned long last; };驱动需要实现mmu_interval_notifier_ops中的invalidate回调。当注册的区间内发生页表变更时内核会调用这个回调并传入一个mmu_interval_notifier指针和一个mmu_notifier_range结构体后者描述了具体的变化范围。mmu_interval_notifier的一个关键特性是它支持“重试”机制。如果驱动在invalidate回调中发现自己无法立即处理比如需要等待 GPU 完成当前操作它可以返回MMU_INTERVAL_NOTIFIER_RETRY内核会稍后再次调用回调。这给了驱动更大的灵活性。3.3 回调时机的选择start 与 end 的取舍为什么需要 start 和 end 两个回调这涉及到页表变更的原子性问题。以 munmap 为例内核在解除映射时需要先让所有订阅者知道“这个范围要失效了”然后才能修改页表。如果在修改之后才通知订阅者可能已经通过旧映射访问了内存导致数据损坏。所以invalidate_range_start必须在页表修改之前调用。但有些操作需要在页表修改之后才能完成。比如 KVM 在收到invalidate_range_start后会清除影子页表项但此时 Host 页表还没改Guest 可能还在访问。KVM 需要在invalidate_range_end里确认所有 vCPU 都已经退出了相关代码路径才能安全地释放资源。这种“先通知、后修改、再通知”的模式是 MMU notifier 保证一致性的关键。4. 实操从零实现一个简单的 MMU notifier4.1 环境准备与内核版本选择MMU notifier 的接口在不同内核版本之间有差异。mmu_interval_notifier是在 Linux 5.8 左右引入的如果你用的是更早的内核只能用mmu_notifier。建议至少使用 5.15 或更新的 LTS 内核接口更稳定文档也更全。编译内核模块需要安装对应内核版本的 headerssudo apt install linux-headers-$(uname -r)如果你是在做 KVM 或 GPU 驱动开发通常不需要自己写 MMU notifier而是使用子系统已经封装好的接口。但理解底层实现对于调试问题非常有帮助。4.2 注册与注销的完整流程下面是一个简化的mmu_interval_notifier注册示例#include linux/mmu_notifier.h #include linux/mm.h struct my_device { struct mmu_interval_notifier notifier; struct mm_struct *mm; }; static bool my_invalidate(struct mmu_interval_notifier *mni, const struct mmu_notifier_range *range, unsigned long cur_seq) { struct my_device *dev container_of(mni, struct my_device, notifier); if (!mmu_interval_notifier_uses(mni, range, cur_seq)) return true; /* 在这里更新设备侧的地址翻译表 */ update_device_page_table(dev, range-start, range-end); return true; } static const struct mmu_interval_notifier_ops my_ops { .invalidate my_invalidate, }; int my_device_init(struct my_device *dev, struct mm_struct *mm, unsigned long start, unsigned long length) { int ret; dev-mm mm; ret mmu_interval_notifier_insert(dev-notifier, mm, start, length, my_ops); if (ret) return ret; return 0; } void my_device_exit(struct my_device *dev) { mmu_interval_notifier_remove(dev-notifier); }这段代码的关键点mmu_interval_notifier_insert会把 notifier 插入到 mm 的区间树中并立即触发一次回调让驱动有机会初始化设备侧的映射。mmu_interval_notifier_uses用于判断当前回调是否真的影响到了这个 notifier 的区间。如果返回 false说明变化范围与注册区间没有交集可以直接返回。invalidate回调返回true表示处理成功返回false表示需要重试。4.3 参数计算区间对齐与页大小注册区间时start 和 length 需要按页对齐。内核的页大小通常是 4KB但有些架构支持 64KB 或更大。如果传入的地址没有对齐mmu_interval_notifier_insert会返回-EINVAL。对齐的计算方式unsigned long aligned_start start PAGE_MASK; unsigned long aligned_end (start length PAGE_SIZE - 1) PAGE_MASK; unsigned long aligned_length aligned_end - aligned_start;提示如果你的设备支持大页2MB 或 1GB注册区间时最好按大页对齐这样可以减少回调次数提升性能。4.4 与 IOMMU 的配合MMU notifier 只负责通知页表变更实际的设备侧地址翻译表更新需要驱动自己完成。如果设备支持 IOMMU驱动通常需要在invalidate回调中找到受影响的 IOMMU 映射项。调用 IOMMU 子系统的接口使对应的 IOTLB 项失效。如果需要重新映射在回调返回后重新建立映射。这个过程涉及 IOMMU 的 SVAShared Virtual Addressing特性。SVA 允许设备直接使用进程的页表但前提是设备侧的 TLB 和 CPU 侧的 TLB 保持一致。MMU notifier 就是保证这种一致性的桥梁。5. 常见问题与排查技巧实录5.1 回调死锁为什么我的驱动卡住了MMU notifier 的回调是在持有页表锁的情况下调用的所以回调里不能做任何可能睡眠的操作。常见的死锁场景包括在invalidate_range_start里调用kmalloc(GFP_KERNEL)如果内存紧张可能触发页面回收而页面回收又需要获取页表锁导致死锁。在回调里等待 GPU 完成操作如果 GPU 操作又依赖于 CPU 侧的页表形成循环等待。解决方法在回调里只做最必要的同步操作把耗时的任务放到工作队列里异步执行。如果必须等待使用invalidate_range_end来做等待因为此时页表锁已经释放。5.2 回调丢失为什么设备访问了已释放的内存如果驱动注册了 notifier但设备仍然访问了已释放的内存可能的原因有注册区间没有覆盖到实际使用的地址范围。检查mmu_interval_notifier_insert的 start 和 length 参数是否正确。invalidate回调返回了true但实际上没有完成同步。检查回调里的同步逻辑是否真的生效。设备侧的 TLB 没有刷新。MMU notifier 只通知页表变更不会自动刷新设备 TLB驱动需要自己调用 IOMMU 的失效接口。排查方法在invalidate回调里加打印确认回调是否被触发以及传入的 range 是否覆盖了预期地址。5.3 性能问题回调太频繁怎么办如果设备映射的地址范围很大而进程频繁修改页表回调可能会非常频繁导致性能下降。优化思路使用mmu_interval_notifier而不是mmu_notifier减少无效回调。按大页对齐注册区间减少回调次数。在回调里做批量处理而不是逐页更新。如果设备支持使用“延迟失效”策略在回调里只标记失效等设备真正访问时再更新。5.4 常见问题速查表问题现象可能原因排查方法驱动卡死回调里睡眠或等待检查回调中是否有kmalloc、mutex_lock等可能睡眠的调用设备访问已释放内存回调未触发或未完成同步在回调里加打印确认 range 是否覆盖性能下降回调过于频繁改用mmu_interval_notifier按大页对齐注册失败地址未对齐检查 start 和 length 是否按 PAGE_SIZE 对齐回调返回重试但一直不成功区间被频繁修改检查是否有其他进程在频繁修改该区间6. 进阶话题GPU 驱动中的 MMU notifier 实践6.1 GPU 为什么需要 MMU notifier现代 GPU 通常支持统一虚拟内存Unified Virtual Memory, UVM允许 GPU 直接访问 CPU 侧的虚拟地址。这要求 GPU 的 MMU 和 CPU 的 MMU 保持一致。当 CPU 侧页表发生变化时GPU 必须同步更新自己的页表否则就会出现 GPU 访问错误地址的情况。NVIDIA 的 GPU 驱动、Intel 的 i915 驱动、AMD 的 amdgpu 驱动都使用了 MMU notifier。以 i915 为例它使用mmu_interval_notifier来跟踪用户态提交的 GPU 任务所涉及的地址范围当这些范围发生变化时驱动会更新 GPU 页表并刷新 GPU TLB。6.2 GPU 场景下的特殊挑战GPU 场景比 KVM 更复杂因为GPU 通常有多个引擎渲染、计算、拷贝每个引擎有自己的 TLB。GPU 任务可能是异步执行的回调触发时GPU 可能还在执行旧的任务。GPU 页表的更新可能涉及显存分配和释放操作比较重。因此GPU 驱动通常采用“两阶段”策略在invalidate回调里只做标记和必要的同步把实际的页表更新放到工作队列里异步执行。同时驱动需要维护一个“活跃任务列表”确保在更新页表之前所有使用旧映射的任务都已经完成。6.3 与 HMMHeterogeneous Memory Management的关系HMM 是内核提供的一套框架用于简化设备访问 CPU 内存的编程模型。它内部也使用了 MMU notifier 来跟踪页表变更。如果你的驱动使用了 HMM那么 MMU notifier 的注册和管理由 HMM 框架负责驱动只需要实现 HMM 的回调即可。HMM 的核心接口是hmm_range_fault它会在页表变更时自动重试直到获取到稳定的页表快照。这大大简化了驱动的开发但也要求驱动理解 HMM 的重试机制和锁规则。7. 调试与验证如何确认 MMU notifier 工作正常7.1 使用 ftrace 跟踪回调内核提供了 ftrace 来跟踪 MMU notifier 的回调。可以这样启用echo 1 /sys/kernel/debug/tracing/events/mmu_notifier/enable cat /sys/kernel/debug/tracing/trace_pipe这会打印每次回调的详细信息包括 mm 指针、start、end、event 类型。通过分析这些信息可以确认回调是否按预期触发以及 range 是否正确。7.2 使用 debugfs 查看注册的 notifier如果内核编译时启用了CONFIG_DEBUG_FS可以在/sys/kernel/debug/mmu_notifier/下查看当前注册的所有 notifier。这对于确认驱动是否正确注册非常有帮助。7.3 压力测试模拟频繁的页表变更写一个简单的用户态程序频繁地 mmap 和 munmap 一块内存同时让设备访问这块内存。观察驱动是否会出现异常。这个测试可以暴露回调同步不完整、TLB 刷新不及时等问题。#include sys/mman.h #include unistd.h int main() { while (1) { void *addr mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (addr MAP_FAILED) return 1; /* 让设备访问 addr */ munmap(addr, 4096); } return 0; }注意这个测试需要配合设备驱动使用确保设备在 mmap 之后、munmap 之前访问了该地址。如果驱动没有正确处理 MMU notifier很快就会触发崩溃或数据损坏。8. 个人经验踩过的坑与实用建议我在实际做 GPU 驱动开发时在 MMU notifier 上踩过不少坑这里分享几个印象深刻的。第一个坑是回调里的锁顺序问题。当时我们在invalidate_range_start里获取了一个驱动内部的互斥锁而这个锁在另一个路径上会被持有并等待页表锁结果形成了 AB-BA 死锁。排查了很久才定位到。教训是回调里尽量不要获取新的锁如果必须获取要确保锁的顺序与页表锁一致。第二个坑是区间注册的粒度。一开始我们按 4KB 页注册区间结果回调太频繁性能下降明显。后来改成按 2MB 大页注册回调次数减少了 90% 以上。但要注意大页对齐要求用户态的地址分配也按大页对齐否则会出现部分覆盖的情况。第三个坑是mmu_interval_notifier_uses的返回值处理。这个函数返回 false 时表示当前变化范围与注册区间没有交集可以直接返回 true。但有些开发者会误以为返回 false 表示失败结果返回了错误码导致内核反复重试。实际上返回 true 才是正确的处理方式。最后再分享一个小技巧在调试 MMU notifier 问题时可以在回调里加一个计数器统计每个区间的回调次数。如果某个区间的回调次数异常高说明这个区间被频繁修改可能需要优化用户态的内存分配策略。这个计数器可以通过 debugfs 暴露出来方便实时观察。MMU notifier 这套机制看起来复杂但核心思想很朴素让关心页表的人都能收到通知。理解了这一点再看那些回调函数和数据结构就会清晰很多。希望这篇内容能帮到正在做相关开发的你。
返回列表