
第一次在内核源码里看到 vm_operations_struct 这个长名字时我下意识觉得它跟 file_operations、inode_operations 一样又是一个“操作集结构体”。后来真正做驱动 mmap 改造和排查匿名映射缺页性能问题时才意识到这组回调才是理解 Linux 虚拟内存机制绕不开的核心接口。每个 VMA虚拟内存区域的缺页处理、写保护转换、区域拆分、生命周期结束最终都会落到 vm_operations_struct 里的函数指针上。这篇重点讲清楚它每个回调分别在什么时机被调、实现时要遵守哪些约定再给一个可以直接抄作业的驱动示例最后把自己踩过的坑一起写出来。适合准备搞 mmap 相关驱动开发、读内核内存管理源码、或者面试前想系统性捋一遍这块知识的工程师。1. vm_operations_struct 基础VMA 的“行为接口”是如何设计的1.1 VMA 是什么进程地址空间里的“区块”进程的虚拟地址空间不是一整块平坦的内存而是被划分成很多个 VMAVirtual Memory Area。每个 VMA 就是一段连续的虚拟地址区间带着自己的权限和映射属性。栈是一个 VMA堆是一个 VMAmmap 出来的共享库是一个 VMA文件映射是另一个 VMA。VMA 是虚拟内存层的抽象与物理页是多对一、一对多的关系。同一个 VMA 里的虚拟地址可能一部分已经映射到物理页另一部分还是空白的。CPU 访问到还没建立映射的地址时会触发缺页异常page fault。内核需要先根据出问题地址找到所在 VMA再根据 VMA 的类型决定如何补上这个映射。vm_operations_struct 就挂在每个 VMA 的 vm_ops 指针上。它可以为空也可以为不同映射挂完全不同的实现。文件映射有文件页缓存的实现匿名映射有匿名页的实现设备驱动可以写一套自己的实现。通过这层函数指针内核内存管理的主路径比如缺页处理就复用了同一套逻辑差别只在最终调用的回调函数不同。这种设计在内核里很常出现内核的通用框架加子系统定制回调本质就是策略模式。1.2 结构体定义内核提供给你的“函数指针表”以 Linux 6.x 的代码为准vm_operations_struct 定义在 include/linux/mm.h 里大致长这样struct vm_operations_struct { void (*open)(struct vm_area_struct *area); void (*close)(struct vm_area_struct *area); vm_fault_t (*fault)(struct vm_fault *vmf); vm_fault_t (*huge_fault)(struct vm_fault *vmf, unsigned int order); vm_fault_t (*map_pages)(struct vm_fault *vmf, pgoff_t start_pgoff, pgoff_t end_pgoff); unsigned long (*pagesize)(struct vm_area_struct *area); vm_fault_t (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf); vm_fault_t (*pfn_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf); int (*access)(struct vm_area_struct *vma, unsigned long addr, void *buf, int len, int write); const char *(*name)(struct vm_area_struct *vma); int (*find_special_page)(struct vm_area_struct *vma, unsigned long addr, struct page **page); vm_fault_t (*mcopy_atomic)(struct vm_area_struct *vma, unsigned long dst, unsigned long src, int mode); vm_fault_t (*mcopy_continued)(struct vm_area_struct *vma, unsigned long dst, unsigned long src, int mode); vm_fault_t (*mcopy_zeropage)(struct vm_area_struct *vma, unsigned long dst); int (*split)(struct vm_area_struct *vma, unsigned long addr); int (*mremap)(struct vm_area_struct *vma); int (*madvise)(struct vm_area_struct *vma, unsigned long start, unsigned long end, int advice); };如果你翻的是老代码字段会少一些。name 是后来加的mcopy_* 这几个是配合 userfaultfd 的huge_fault 的 order 参数在不同版本之间也调整过。所以读老博客时发现对不上是常事以当前源码为准。这个结构体的设计思路类似 C 虚函数表内核只定义接口具体的 VMA 行为由实现者决定。一个驱动或文件系统需要定制 VMA 行为时分配一个 static const 的 vm_operations_struct填上需要的函数指针然后在 mmap 回调里把 vma-vm_ops 指过去就行。1.3 回调的触发时机从 mmap 到缺页的完整路径搞清楚这些回调何时被调用比记住它们叫什么更重要。整个生命周期可以用 mmap 这条主线串起来。用户态调用 mmap 系统调用内核经过 do_mmap 进入 mmap_region。如果映射的是一个文件mmap_region 会调用 file-f_op-mmap。普通文件系统通常用 generic_file_mmap这个函数做的事很简单设置 vma-vm_ops generic_file_vm_ops。设备驱动则是在自己的 mmap 回调里设置 vm_ops有些驱动还会在这里设 vma-vm_private_data。mmap 回调返回后mmap_region 会检查 vma-vm_ops-open 是否存在存在就调用。与 open 对应的 close在 VMA 被销毁时触发路径是 remove_vma。munmap、进程退出释放地址空间最终都会走到这里。fault 回调的触发点则是 CPU 访问了还没建立映射的地址。缺页异常进入内核后一路分发到 handle_mm_fault最终在处理“文件/设备映射但页表项为空”的场景时调用 vma-vm_ops-fault。所以一次完整流程是mmap 系统调用命中了驱动的 mmap 回调设置 vm_ops随后 VMA 生命周期开始时 open 被调用用户访问页面触发缺页fault 被调用最后 munmap 或进程退出时close 被调用。心里有了这条线后面就顺了。2. 核心回调逐个拆解从生命周期到缺页主战场2.1 open 与 closeVMA 生老病死的两个钩子open 回调最常见的用途是增加设备引用计数、初始化 vma-vm_private_data 指向的资源、记录调试统计。比如驱动里有多个 VMA 同时在映射同一片物理内存每次 open 都做一次 atomic_incclose 做一次 atomic_dec最后用计数判断什么时候能释放底层资源。这里容易有个误区以为 open 回调与驱动 file_operations 里的 open 是同时触发的。实际上两者完全独立。驱动自己的 file-f_op-open 在用户 open(2) 设备文件时调用而 vm_operations_struct 里的 open 是 mmap 成功后由地址空间管理代码调用的。用户拿到 mmap 返回值之前vma-vm_ops-open 已经执行完了。close 的调用时点也值得注意。进程可能因为调用 munmap 显式删除 VMA也可能在 exit 时由 exit_mmap 一次性清理所有 VMA。只要是删除这个 VMA内核都会在 remove_vma 里调用 close。但如果 VMA 被 split 成两段情况就复杂了内核会先调用 split 回调让实现者决定是否同意拆分拆分后的新 VMA 不会自动再次调用 open需要自己在 split 回调里做资源分配。对简单驱动来说不实现 split、不开放任何会触发 split 的 mprotect 场景就能省掉这部分逻辑。2.2 fault缺页异常处理的核心回调fault 是 vm_operations_struct 里最重要的字段没有之一。签名是 vm_fault_t (*fault)(struct vm_fault *vmf)其中 struct vm_fault 携带了大量上下文触发缺页的地址是多少、属于哪个 VMA、页偏移是多少、原始页表项内容是什么。实现 fault 的基本职责很明确根据缺页地址找到一个 struct page填到 vmf-page 里让内核把这个 page 安装到页表或者对于特殊映射返回 VM_FAULT_NOPAGE表示“我已经处理好了不需要再给我 page”。如果是访问了非法区域返回 VM_FAULT_SIGBUS进程会收到 SIGBUS 信号。具体流程上缺页处理会进入 do_read_fault 或 do_cow_fault再通过 __do_fault 调用 vma-vm_ops-fault。fault 返回后如果带上 VM_FAULT_LOCKED 标志说明返回的 page 是锁定状态内核在完成页表安装后会负责解锁。这个约定直接决定了实现里的动作顺序根据 vmf-pgoff 找到对应页面get_page 增加引用计数把 page 填进 vmf-page返回 VM_FAULT_LOCKED如果驱动要分配一块新的内存页还可以先用 alloc_page 分配然后把返回的 struct page 直接交给 vmf-page。自动分配的页面自带引用计数和锁状态比较省事。fault 回调里最容易出问题的就是锁和睡眠。现代内核的缺页路径大部分运行在进程上下文允许一定程度的睡眠但绝不意味着你可以在这里为所欲为。一些老版本的缺页路径在持有自旋锁的情况下执行那种环境下一睡眠就直接 panic。稳妥的做法是fault 里只做轻量操作复杂的 I/O 前置到文件读写或 mmap 阶段处理。从 Linux 4.20 开始fault 的参数从单独的 vma、address 改成 struct vm_fault旧式写法在 6.x 上已经编不过了。网上很多老文章用 int (*fault)(struct vm_area_struct *, struct vm_fault *)照着写会直接编译报错。2.3 page_mkwrite只读变可写前的关键通知page_mkwrite 处理的是写保护页面的场景。进程以只读方式映射了一个文件然后往里写数据就会触发 write-protect fault。内核进入 do_wp_page在处理写时复制或修改只读页面前会先调用 vma-vm_ops-page_mkwrite。这个回调对文件系统非常关键。比如 ext4 在页面要从只读变成可写之前需要更新日志、记录元数据、确认磁盘状态普通文件系统用它来通知“这个页面马上要被修改了”。对设备驱动来说状态机的推进也常用到它相当于把“允许写入”这个动作交给驱动裁决。与 page_mkwrite 配对的是 pfn_mkwrite。某些设备映射走的是 PFN 方式而不是 struct page 方式比如 VFIO 和 GPU 驱动它们会把 PFN 直接写入页表。这种 VMA 上如果要做写保护处理用的就是 pfn_mkwrite。自己实现 page_mkwrite 时返回值和 fault 类似。返回 VM_FAULT_LOCKED 表示页面已锁定、可以变可写返回错误码则让写操作失败。要注意page_mkwrite 会被频繁调用每次从只读改可写都可能触发所以实现要快不能在里面做慢速 I/O 或长时间锁等待。2.4 map_pages、huge_fault 与批量性能优化map_pages 用于批量填充页表。缺页处理时内核发现一个 VMA 里多个连续页面都没映射如果每次都单独调 fault效率太低。map_pages 可以一次调用把 start_pgoff 到 end_pgoff 范围内符合条件的页面全部插入页表。filemap_map_pages 就是 page cache 场景的默认实现对顺序读性能提升很明显。设备驱动如果也是连续物理页、又追求缺页性能可以实现 map_pages否则直接留空内核会退回逐页 fault 的老路。对大多数 demo 级驱动不实现完全没问题。huge_fault 是透明大页THP场景下的回调。系统开启 THP且映射满足大页条件时缺页可以按 2MB 的粒度处理而不是一次 4KB。huge_fault 的 order 参数表示请求的页阶order 为 0 时是普通单页THP 场景通常是 9对应 2MB。驱动不处理大页的话不实现这个回调缺页路径会自动回退到普通 fault。pagesize 回调也很少见用在某些特殊设备上告诉内核这个 VMA 的页面大小偏好。一般设备驱动不需要管。2.5 剩下的配角split、mremap、madvise、access、name 等split 在内核想把一个 VMA 拆开时被调用。最典型的场景是 mprotect 只修改一段地址的权限原来的 VMA 需要被切成三段中间那段的权限变了。内核会在拆分前回调 split实现者可以返回非零拒绝拆分或趁机调整驱动内部的映射状态。简单驱动如果不允许这种变化可以在 mmap 时设置 VM_DONTEXPAND 一类标志或者干脆实现 split 返回 -EINVAL。mremap 在 VMA 被扩展时调用。如果用户调用 mremap 扩大了设备映射驱动需要知道否则可能出现底层资源不够映射的情况。madvise 对应 madvise(2) 系统调用。用户在映射区上调用 MADV_DONTNEED、MADV_WILLNEED 等操作时内核会把这个请求转发给 VMA 的回调。匿名映射的内核处理逻辑已经内置了这些策略设备驱动如果希望自定义可以实现这个回调。access 回调用于跨进程访问典型场景是调试器用 ptrace 读目标进程的内存或者读取 /proc/pid/mem。当目标地址所在 VMA 有特殊映射内核无法通过普通页表直接访问时会调用 access。实现了它gdb 就能调试你的进程并看到这块内存内容。对驱动调试很有用。name 回调返回一个字符串会被拼接到 /proc/pid/maps 里。匿名 VMA 默认显示 [anon]实现了 name 就可以改成容易识别的名字比如 [my_device_buffer]。find_special_page 和 mcopy_* 系列更冷门。find_special_page 用于某些内核组件按地址反查特殊 struct pagemcopy_* 是 userfaultfd 机制里用户态把页面内容填充到目的地时用到的回调。日常驱动开发中用到这些的概率很小知道存在即可。3. 实战为设备驱动实现一套 vm_operations_struct3.1 场景设计4 页物理内存映射给用户态用一个最小驱动来演示完整用法驱动在文件 open 时分配 4 个物理页用户通过 mmap 把这 4 页映射到进程地址空间然后向里面写字符串。驱动侧通过 debugfs 把每个页的内容和缺页次数导出来用来验证“用户写进去的数据确实落到了驱动管理的物理页上”。选择 4 页而不是 1 页可以演示 vmf-pgoff 在不同页之间的索引逻辑也可以验证越界访问时返回 VM_FAULT_SIGBUS 的效果。模块基于 miscdevice 来实现代码量小适合快速验证。这个例子覆盖 open、close、fault 三个核心回调又展示了 vma-vm_private_data 的用法。其他回调的注册方式完全一致需要时在同一个 vm_operations_struct 里补上对应函数指针即可。3.2 驱动基础代码分配页面与注册 mmap先定义设备和基础文件操作#define NR_DEMO_PAGES 4 struct demo_dev { struct page *pages[NR_DEMO_PAGES]; unsigned long fault_count; atomic_t use_count; }; static int demo_open(struct inode *inode, struct file *file) { struct demo_dev *ddev; int i; ddev kzalloc(sizeof(*ddev), GFP_KERNEL); if (!ddev) return -ENOMEM; for (i 0; i NR_DEMO_PAGES; i) { ddev-pages[i] alloc_page(GFP_HIGHUSER); if (!ddev-pages[i]) goto err_free_pages; } atomic_set(ddev-use_count, 0); file-private_data ddev; return 0; err_free_pages: while (i--) __free_page(ddev-pages[i]); kfree(ddev); return -ENOMEM; } static int demo_release(struct inode *inode, struct file *file) { struct demo_dev *ddev file-private_data; int i; for (i 0; i NR_DEMO_PAGES; i) { if (ddev-pages[i]) __free_page(ddev-pages[i]); } kfree(ddev); return 0; }接着是 mmap 回调。这里把 vm_ops 挂到 VMA 上同时设置 vm_private_datastatic int demo_mmap(struct file *file, struct vm_area_struct *vma) { struct demo_dev *ddev file-private_data; vma-vm_ops demo_vm_ops; vma-vm_private_data ddev; vma-vm_flags | VM_DONTEXPAND | VM_DONTDUMP; return 0; }VM_DONTEXPAND 防止用户用 mremap 扩大映射后驱动底层只有 4 页却要映射更多区域VM_DONTDUMP 防止进程 coredump 时把这个设备映射区导出来。这两个标志是设备映射的常见选择。3.3 实现 demo_vma_opsopen、close、fault核心操作集的完整代码static void demo_vma_open(struct vm_area_struct *vma) { struct demo_dev *ddev vma-vm_private_data; atomic_inc(ddev-use_count); } static void demo_vma_close(struct vm_area_struct *vma) { struct demo_dev *ddev vma-vm_private_data; atomic_dec(ddev-use_count); } static vm_fault_t demo_vma_fault(struct vm_fault *vmf) { struct vm_area_struct *vma vmf-vma; struct demo_dev *ddev vma-vm_private_data; pgoff_t idx vmf-pgoff; struct page *page; if (idx NR_DEMO_PAGES) { pr_info(demo: access beyond region, idx%lu\n, idx); return VM_FAULT_SIGBUS; } page ddev-pages[idx]; if (!page) return VM_FAULT_OOM; get_page(page); vmf-page page; ddev-fault_count; return VM_FAULT_LOCKED; } static const struct vm_operations_struct demo_vm_ops { .open demo_vma_open, .close demo_vma_close, .fault demo_vma_fault, };fault 里没有做 lock_page是因为这些页是驱动用 alloc_page 分配的不是 page cache 管理的文件页。alloc_page 返回的新页默认处于 locked 状态所以可以直接返回 VM_FAULT_LOCKED。如果驱动从文件页缓存里取页面就必须先 lock_page 再返回否则会破坏页面锁的平衡。vmf-pgoff 的含义是从 VMA 映射起点开始的页偏移。因为用户 mmap 时传的 offset 是 0所以 pgoff 0 对应驱动 pages[0]pgoff 1 对应 pages[1]。如果用户 mmap 时传了 offset这个值会包含 offset 对应的页数索引时要减去 vma-vm_pgoff。对于这个 demo不需要额外处理。3.4 验证用户态写数据内核侧看物理页为了让内核侧能看到页面内容加一个 debugfs 文件导出每个页的字符串和统计计数static int demo_status_show(struct seq_file *m, void *v) { struct demo_dev *ddev m-private; int i; seq_printf(m, fault_count%lu use_count%d\n, ddev-fault_count, atomic_read(ddev-use_count)); for (i 0; i NR_DEMO_PAGES; i) { void *kaddr kmap_local_page(ddev-pages[i]); seq_printf(m, page[%d] content: %s\n, i, (char *)kaddr); kunmap_local(kaddr); } return 0; }用户态测试程序很简单#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include sys/mman.h int main(void) { int fd; char *p; fd open(/dev/demo, O_RDWR); if (fd 0) { perror(open); return 1; } p mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (p MAP_FAILED) { perror(mmap); return 1; } strcpy(p, hello from user); printf(read back: %s\n, p); madvise(p, 4096, MADV_DONTNEED); strcpy(p, second round); printf(read back: %s\n, p); munmap(p, 4096); close(fd); return 0; }第一次写入会触发一次 demo_vma_fault进程读取同一地址时页表已有映射不会再触发。第二次 strcpy 前先调 madvise(MADV_DONTNEED) 把页表清掉再次写入会重新触发 demo_vma_fault。此时看 debugfs 里的 fault_count应该是 2page[0] content 是 second round。这能证明缺页回调确实参与到了映射过程中。把模块加载后cat /sys/kernel/debug/demo/status就能看到驱动侧保存的物理页内容。实际跑一遍会发现用户态 poll 出来的字符串正是内核导出的内容整个数据通路就闭环了。4. 排错与避坑我在 fault 回调里踩过的坑4.1 fault 里不要再抢 mmap_lock这个坑我栽过不止一次。fault 执行时当前路径可能已经持有了 mmap_lock 的读锁。如果在 fault 里又去调用 mmap、munmap、mprotect 这类需要 mmap_lock 写锁的操作就会形成“自己等自己”的死锁。更隐蔽的场景是 fault 里通过某些函数间接触发地址空间操作。比如错误地调用了 remap 相关函数或者调用了试图扩展/收缩 VMA 的 helper。排查这种死锁非常痛苦因为调用栈很长而且不是每次都能复现。建议从一开始就规定fault 和 page_mkwrite 只做与 page 相关的操作不要碰 VMA 管理。4.2 返回值位掩码别写错fault 返回值是一组位掩码不是简单的成功或失败。常见组合有返回值含义注意事项VM_FAULT_LOCKED页面已锁定内核完成后解锁必须同时设置 vmf-pageVM_FAULT_NOPAGE缺页已处理但不产生 struct page适合 PFN 类映射直接填充 PTEVM_FAULT_SIGBUS非法访问向进程发 SIGBUS越界、设备拔除等场景VM_FAULT_OOM内存耗尽触发 OOM 处理VM_FAULT_RETRY请求重试通常伴随未消费页面旧代码里常见的 return 0 配合 vmf-page在 6.x 内核里语义不明确不如直接写 VM_FAULT_LOCKED。一旦返回错误内核会向用户态进程发信号应用可能直接挂掉所以越界检查一定要做。4.3 页面引用计数与 Lock 状态的约定fault 里交给 vmf-page 的页面引用计数一定要对。用 get_page 确保驱动和页表各持有一份引用。如果不 get_page内核把页装进页表后一旦驱动释放页面进程还在访问就会出现 use-after-free。Lock 状态的处理则取决于页面来源。alloc_page 分配的页天然是 locked 的可以直接返回 VM_FAULT_LOCKED。从 page cache 拿的页要用 lock_page 手动锁或者让 filemap_fault 这类现成实现去管理。把一个不是 locked 的页面返回 VM_FAULT_LOCKED后续 unlock_page 会触发校验告警甚至破坏锁状态。4.4 调试手段kprobe、smaps、crash调试 vm_operations_struct 相关的逻辑我最常用的四个手段trace-cmd 加 kprobe。内核缺页路径入口 handle_mm_fault 以及自定义的 demo_vma_fault 都可以挂 kprobe打印参数和返回值。/proc/pid/smaps 可以查看每个 VMA 的大小、权限、映射文件、脏页状态。确认自己的设备映射是否真的生效。crash 工具在有 vcore 文件时很好用遍历 task_struct 里的 mm检查 vma-vm_ops 指向的地址反查是不是模块里的 demo_vm_ops确认 open/close/fault 三个指针是否被正确初始化。bpftrace 做快速现场确认比如统计 fault 调用次数bpftrace -e kprobe:demo_vma_fault { [comm] count(); }还有一个很小但很实用的技巧在 open、close、fault 里加 pr_debug 或 trace_printk配合动态调试在运行时开关。只加在调用次数少的地方否则刷屏会刷到怀疑人生。4.5 VMA 标志别乱加mmap 回调里设置 VMA 标志看起来简单有些标志是互斥的效果互相掩盖。特别提醒不要随手设置 VM_IO 或 VM_PFNMAP 来“模拟”设备映射。VM_PFNMAP 会让内核认为这个 VMA 的内存是 PFN 直接映射缺页路径完全不走 fault 回调而是走 remap_pfn_range 那套逻辑。如果驱动想提供 struct page 给用户设置了 VM_PFNMAP 反而要踩一堆坑包括内核无法正确管理引用计数。如果只是想禁止 mremap 扩容设置 VM_DONTEXPAND 就够了不想进 coredump设置 VM_DONTDUMP。大多数设备映射场景用这两个足够。5. 从源码入口继续深入5.1 缺页路径的调用链想快速验证回调调用链可以按这个顺序读代码handle_mm_fault缺页处理的总入口位于 mm/memory.chandle_pte_fault根据 PTE 状态分发do_fault处理文件/设备映射但缺 PTE 的情况do_read_fault / do_cow_fault区分读缺页和写时复制__do_fault最终执行 vma-vm_ops-fault然后搜索 vm_ops-open、vm_ops-close、vm_ops-page_mkwrite可以精准找到生命周期和写保护路径上的调用点。比如 mmap_region 里的 open 调用、remove_vma 里的 close 调用把这些调用点看一遍VMA 操作集的整体画面就完整了。5.2 在内核源码里找真实案例源码里现成的 vm_operations_struct 实现非常多是比任何文档都好的学习材料。建议搜索字符串.fault 会有大量结果generic_file_vm_ops普通文件映射fault 是 filemap_fault页面从 page cache 获取shmem_vm_opstmpfs 和共享匿名映射的核心实现hugetlb_vm_ops大页映射对应 huge_fault 回调ext4_file_vm_ops、xfs_file_vm_ops在文件页缓存基础上加了各自的 page_mkwritemspec_vm_ops某个特殊内存设备驱动的实现直接分配真实物理页并返回 VM_FAULT_LOCKED特别建议看 mspec.c它和我们 demo 的做法非常接近又额外处理了更多边界条件比如映射范围检查、睡眠锁、open 引用计数等。把它和 demo 对比读你会发现自己写的代码还有哪些考虑不周。我在实际项目中体会最深的一点是vm_operations_struct 的复杂度不是来自结构体本身而是来自它背后的 VMA 生命周期和缺页路径。先搞懂回调触发时机再看真实实现最后自己写一个最小示例跑通整个过程大概需要一个下午。以后遇到缺页性能、mmap 行为异常、驱动映射踩内存这类问题你就有清晰的下手方向了。