vm_operations_struct深度解析:VMA虚拟内存操作核心机制

发布时间:2026/10/5 0:11:10
vm_operations_struct深度解析:VMA虚拟内存操作核心机制 做过嵌入式Linux驱动或者仔细读过内核源码的朋友一定见过vm_operations_struct这个结构体但很多人对它的理解停留在“mmap的VMA操作集”这个层面。说实在的这个结构体是用户态与内核态虚拟内存交互的命门搞懂它你才算真正迈过了内核内存管理这道坎。它定义了虚拟内存区域VMA从创建、映射、缺页处理到销毁整套生命周期里的回调动作而驱动的mmap实现、文件系统的page cache映射、甚至一些高性能用户态框架与内核的交互都依赖这套机制。这篇文章我会从实践角度把这个结构体拆开揉碎讲清楚每个回调函数什么时候触发、该干什么、怎么安全地干再给你一个可以直接跑的驱动示例把完整流程走一遍。1. 先搞懂VMAvm_operations_struct是挂在谁身上的1.1 从进程地址空间说起每个用户态进程都有一套独立的虚拟地址空间从低位的代码段、数据段到高位的栈、vsyscall区域零零散散几十段起步。每段连续的、属性一致的虚拟内存就是所谓的VMAVirtual Memory Area内核用一个vm_area_struct来描述它包含起始地址vm_start、结束地址vm_end、权限标志vm_flags以及指向所属进程mm_struct的指针。你可以在终端敲一句cat /proc/self/maps看看效果每一行就是一个VMA第一列是区间第二列是权限后面还有映射的文件、偏移量等附加信息。密集场景下有几十个VMA甚至更多所以内核用红黑树加双向链表双结构管理它们保证按地址查找时能做到对数级复杂度。VMA有合有分两个相邻且权限、文件偏移完全一致的VMA会被合并成一个反过来当你在一个已有映射中间插入一段新映射原VMA就可能被拆成两半。这里要建立第一个关键认知vm_area_struct描述的是“这段虚拟空间是什么”而vm_operations_struct描述的是“这段虚拟空间应该怎么操作”。一个管数据一个管行为这就是C语言视角下“面向对象”的典型体现。内核里凡是需要多态的地方基本都是函数指针的集合vm_operations_struct正是这种设计范式最典型的范例。1.2 为什么必须抽象出一组操作函数你可以想想内存映射的来源多到让人头大普通文件的mmap需要从磁盘页缓存里读数据匿名内存比如malloc大块内存需要分配物理页设备驱动的mmap可能要直接映射寄存器物理地址或者DMA缓冲区hugetlbfs大页内存要走巨型页的分配逻辑。这些映射的差异太大了如果内核在缺页异常处理路径里硬编码每一种情况代码会变成一团没人能维护的脏乱差。于是内核抽象出了vm_operations_struct里面塞满了函数指针。核心处理逻辑只认这些指针缺页了调用fault。要写时复制了调用page_mkwrite。VMA被复制了调用open。这套设计让子系统之间解耦也让驱动开发者和文件系统作者能够自定义自己的VMA行为而不需要改写内核核心代码。你甚至可以把它理解为“给VMA装了一层回调插件”。// include/linux/mm.h 中的定义简化版 struct vm_operations_struct { void (*open)(struct vm_area_struct *area); void (*close)(struct vm_area_struct *area); int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf); int (*page_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); int (*may_split)(struct vm_area_struct *area, unsigned long addr); int (*mprotect)(struct vm_area_struct *vma, unsigned long start, unsigned long end, unsigned long newflags); int (*mremap)(struct vm_area_struct *vma); int (*huge_fault)(struct vm_area_struct *vma, struct vm_fault *vmf); int (*pmd_huge)(struct vm_area_struct *vma, unsigned long addr); int (*pud_huge)(struct vm_area_struct *vma, unsigned long addr); int (*check_vma)(struct vm_area_struct *vma); };结构体本身不大但每个成员背后都牵扯到复杂的内存管理流程。接下来我们逐个击破重点讲那些你在驱动开发和文件系统实现中真正会碰到的回调。2. 核心成员函数逐一拆解2.1 open与closeVMA生命周期的守门员当一个VMA被创建或复制时会调用open。这里说的创建不是指用户态调用mmap那一刻——因为mmap创建VMA并设置好操作集之后open会被稍后在mmap_region里触发。另外fork时子进程复制父进程的VMA也会触发一次open。每次VMA要么被释放、要么因为munmap被移除时会调用close。这两个回调的典型用途是什么引用计数管理。比如你的驱动在mmap时分配了一个私有数据结构然后把指针存在VMA的vm_private_data里那open和close就该各自负责增引用和减引用。如果你不做这步fork出来的子进程可能因为父进程退出导致悬空指针直接崩溃或触发内核Oops。踩过的坑是很多人只在mmap回调里初始化资源却忽略了open。mmap只在用户态主动调用mmap时执行一次但fork之后父子进程各持有一份VMA它们各自的vm_file都指向同一个文件而分配的资源如果没做好引用计数一旦父进程执行munmap或退出close调用太早子进程再去访问就是use-after-free。所以做驱动时凡是mmap里自定义了资源就必须配套一个会做计数的open并在close里对称释放。2.2 fault整个结构体的灵魂回调fault是缺页异常处理的核心入口也是绝大多数驱动、文件系统作者打交道最多的函数。简单讲当一个进程访问了某段虚拟地址而对应的物理页尚未存在或者页表项无效CPU就会触发缺页异常内核走完异常处理流程后最终会找到VMA进而调用它的fault回调。fault回调负责干什么把对应的物理页面准备好插入页表返回状态给内核。核心参数是struct vm_fault *vmf里面包含发生缺页的虚拟地址、对应的page指针、页表项、以及辅助信息。你在回调里通常要做这些事检查发生缺页的偏移量vmf-pgoff是否合法。分配物理页或从缓存中取得已有的页。如果需要读文件内容把数据从磁盘读入页缓存。调用vmf-page page设置好页面的引用。返回合适的VM_FAULT_*常量告诉内核后续流程如何处理。内核会根据fault的返回结果来做后续收尾比如返回VM_FAULT_MAJOR就是主缺页费时费力从磁盘读的返回VM_FAULT_MINOR就是次缺页页已经在内存里只是还没映射。你要是有代码性能统计需求这俩标志很有用。一个最典型的fault实现示例static vm_fault_t my_fault(struct vm_area_struct *vma, struct vm_fault *vmf) { struct page *page NULL; void *kaddr; // 从VMA私有数据中获取驱动自定义状态 struct my_dev *dev vma-vm_private_data; // 检查偏移范围 if ((vmf-pgoff PAGE_SHIFT) dev-buf_size) return VM_FAULT_SIGBUS; // 分配物理页并在页中填写数据 page alloc_page(GFP_KERNEL); if (!page) return VM_FAULT_OOM; kaddr kmap(page); strcpy(kaddr, Hello from kernel fault handler); kunmap(page); get_page(page); vmf-page page; return 0; }这里的get_page是为了增加页引用计数防止后续页被回收。注意fault里是可以睡眠的比如分配内存、获取信号量、读取磁盘因为它运行在进程上下文。但有一个极其重要的限制你不能在持有mmap_lock旧内核叫mmap_sem写锁的情况下做那些会反向申请锁的操作否则可能死锁。这点后面讲排查时会展开。2.3 page_mkwrite写保护触发时要通知谁page_mkwrite的语义是页面已经有物理页映射了但是页表项是只读的进程试图写入时内核会把它改成可写并在改之前呼叫这个回调。哪里会用到最典型的就是写时复制COW和文件系统的延迟分配。以文件映射为例多个进程共享同一个文件映射的页面内核把这些页面标记为只读。有人要写的时候需要先做COW复制一个副本给写入方。此时内核调用page_mkwrite让文件系统有机会在页面变成“可写并即将被修改”之前做一些准备工作。比如ext4这类支持延迟分配的文件系统可以在这里为页申请磁盘块、做日志记录、设置脏标记。如果你在page_mkwrite里不做准备直接让内核硬改权限那将来页被回写磁盘时文件系统根本不知道这个页已被修改数据一致性就炸了。在驱动开发里page_mkwrite也有用。假设你要实现一个用户态直接操作设备缓冲区的框架缓冲区页面固定在物理内存中首次读取没问题但一旦进程尝试写入你需要重新映射到设备真正可写的物理区域同时更新DMA映射关系。此时page_mkwrite就是你动手脚的地方。static vm_fault_t my_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf) { struct page *page vmf-page; // 通常必须在这里获取页面锁 lock_page(page); // 做驱动自定义的“即将写入”处理 // 标记脏页、刷新DMA缓存等 unlock_page(page); return 0; }关于VM_FAULT_LOCKED和页面锁的问题要反复强调在内核老版本里fault返回前内核会自动帮你锁页而在新版通用流程里如果你返回的是VM_FAULT_LOCKED说明你已经自己持锁了内核就不再加锁。具体要看你的内核版本对应流程别照搬别人的代码不思考。page_mkwrite里则强烈建议返回VM_FAULT_LOCKED并自己处理好页锁避免后续重入。2.4 access让调试接口能够穿透VMAaccess回调是给调试器和/proc访问用的。内核里访问进程内存的接口比如/proc/pid/mem、ptrace最终都可能触发这个回调使得即便页面没有映射到用户空间外部也能读到或写入该VMA对应的内核侧数据。access的典型场景核心转储、调试器查看内存映射区域、live migration工具读取进程内存。你可能觉得这个回调用得少但研究过DRM驱动的朋友会发现GPU驱动的access实现让调试器可以读取显存映射内容。实现access时要注意地址和偏移的转换addr是用户空间地址你需要用它换算出VMA内的页偏移然后定位真正的数据源再通过copy_to_user或copy_from_user完成数据传输。如果某类VMA不希望被调试器随意读取你把access设为NULL内核就会对这片VMA的调试访问返回错误。这种“默认拒绝”的策略对安全角度来说反而更合理。2.5 其余成员mremap、mprotect、may_split等mremap在VMA发生重映射比如mremap()系统调用让映射移动位置或改变大小时通知。驱动如果持有与地址相关硬件资源比如I/O映射就可能需要在这里做对应迁移。mprotect则是在VMA保护位改变时调用文件系统可以利用这个时机做属性变更不过现实中实现它的子系统不多。may_split决定了某VMA在指定地址处是否允许被拆分。普通驱动没必要管但文件系统可能因为块分配粒度问题而禁止在某个地址拆分。huge_fault用于大页映射如果你的驱动直接映射的是巨型页这里就要接管。pmd_huge、pud_huge则负责判断某地址在大页层级上是否命中通常配合huge_fault一起实现。还有一个极易被忽视的就是check_vma。它在新版本内核中用于在VMA绑定到操作集时做合法性校验。如果你不想让别人在某类特殊VMA上随便操作就可以用这个回调做拦截。关于这些函数指针的完整语义建议直接看对应内核版本的Documentation/filesystems/locking.rst和mm/下的核心实现。3. 动手实现一个vm_operations_struct3.1 设计目标一个“会说话”的内存设备驱动我准备用一个完整的小项目把上面的概念串起来。目标是在Linux下实现一个简单的字符设备驱动用户态用mmap映射它当进程访问对应区间时内核中的fault回调动态填充一个物理页写入一串字符。进程端读到的就是内核端塞进去的数据。同时实现open、close和page_mkwrite把生命周期管理和写保护的逻辑也走一遍。这个例子的价值在于你写驱动时mmap回调本身通常只是设置VMA标志、指定vm_ops真正跑起来靠的是fault。理解了这层关系DRM、UIO、RDMA这类框架的映射思路也就打开了一角。3.2 驱动代码主体框架首先定义一个基本结构体#include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/mm.h #include linux/slab.h #include linux/uaccess.h #define DEV_NAME vmop_demo #define BUF_SIZE (PAGE_SIZE * 4) struct vmop_dev { struct cdev cdev; dev_t devno; struct class *cls; size_t size; unsigned char *buf; // 模拟设备缓冲区 };这里我分配一个4页的缓冲区实际写数据可能只有一页的字符串其余部分可以是零。接下来定义VMA操作集static void vmop_vma_open(struct vm_area_struct *vma) { struct vmop_dev *dev vma-vm_private_data; pr_info(vmop: vma open\n); // 此处可以增加一个引用计数保护dev不提前被销毁 } static void vmop_vma_close(struct vm_area_struct *vma) { pr_info(vmop: vma close\n); // 释放资源、减少引用计数 } static vm_fault_t vmop_vma_fault(struct vm_area_struct *vma, struct vm_fault *vmf) { struct vmop_dev *dev vma-vm_private_data; struct page *page; void *kaddr; unsigned long offset; offset vmf-pgoff PAGE_SHIFT; pr_info(vmop: fault called, pgoff %lu, offset %lu\n, vmf-pgoff, offset); if (offset dev-size) return VM_FAULT_SIGBUS; page alloc_page(GFP_KERNEL); if (!page) return VM_FAULT_OOM; kaddr kmap(page); if (offset 0) { snprintf(kaddr, PAGE_SIZE, Hello from kernel fault handler [demo]); } else { memset(kaddr, 0, PAGE_SIZE); } kunmap(page); get_page(page); vmf-page page; return 0; } static vm_fault_t vmop_vma_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf) { struct page *page vmf-page; pr_info(vmop: page_mkwrite called\n); lock_page(page); // 这里可以做真正的“准备写入”工作比如和硬件状态同步 unlock_page(page); return VM_FAULT_LOCKED; } static struct vm_operations_struct vmop_vm_ops { .open vmop_vma_open, .close vmop_vma_close, .fault vmop_vma_fault, .page_mkwrite vmop_vma_page_mkwrite, };mmap回调里把它绑定到VMAstatic int vmop_mmap(struct file *file, struct vm_area_struct *vma) { struct vmop_dev *dev file-private_data; // 只允许映射整个缓冲区大小以内的范围 if (vma-vm_end - vma-vm_start dev-size) return -EINVAL; // 把驱动私有数据挂到VMA上fault里才能找到 vma-vm_private_data dev; // 设置只读共享映射测试COW和page_mkwrite用 vma-vm_flags | VM_IO | VM_DONTEXPAND | VM_DONTDUMP; // 绑定操作集这是整个机制的核心 vma-vm_ops vmop_vm_ops; // 显式触发一次open让VMA生命周期从开始就受管 vma-vm_ops-open(vma); pr_info(vmop: mmap called\n); return 0; }这里要解释几个关键点VM_IO告诉内核这段映射不涉及正常的页缓存管理回写和换出都别碰。VM_DONTEXPAND禁止它被mremap扩展。VM_DONTDUMP表示核心转储时不要导出这片内容对于设备映射很合理。由于我们设置的是共享映射用户空间会自然带上VM_SHARED来自mmap的MAP_SHARED标志此时写入操作进入COW路径时page_mkwrite就会被触发。我调用了vma-vm_ops-open(vma)模拟内核在mmap_region成功后的行为一次性把引用计数衔接上这是很多样例驱动漏掉的细节。3.3 用户态验证流程驱动注册完成后用户态程序可以这样验证#include stdio.h #include fcntl.h #include sys/mman.h #include string.h #include unistd.h int main(void) { int fd open(/dev/vmop_demo, O_RDWR); if (fd 0) { perror(open); return 1; } char *mapped mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (mapped MAP_FAILED) { perror(mmap); return 1; } printf(Read: %s\n, mapped); strcpy(mapped, Write test); printf(After write: %s\n, mapped); munmap(mapped, 4096); close(fd); return 0; }第一次访问mapped[0]时由于页面还没建立页表项必然落到fault回调内核填充字符串后映射到用户空间。打印输出后我们执行了一次写入因为页面此时只读且为共享映射内核触发COW路径page_mkwrite被调用。整个过程中/var/log/kern.log或dmesg会打印所有回调记录你翻一下日志就能清晰看到每个阶段的时序vmop: mmap called vmop: vma open vmop: fault called, pgoff 0, offset 0 vmop: page_mkwrite called vmop: vma close这里最值得琢磨的是page_mkwrite触发时机它并不是每次写入都触发只发生在页表项是只读并且要变可写的那一瞬间。后续再写就是直接写页面不再触发。如果你加了mprotect把页面重新设成只读那下回写又会触发一次。了解这个规律驱动里做“首写特殊处理”就有的放矢了。3.4 如果想映射物理地址怎么办很多硬件驱动mmap的目标是寄存器地址或DMA缓冲区物理地址。此时fault回调通常用vm_insert_pfn或remap_pfn_range来建立映射而不是分配新页面。老式做法是拿remap_pfn_range直接在mmap里把整段物理地址一次性映射出去新式做法建议在fault里用vm_insert_pfn(vma, vmf-address, pfn)做单页映射粒度更细也更容易管理。比如你要映射物理地址0x20000000附近的一页static vm_fault_t phys_vma_fault(struct vm_area_struct *vma, struct vm_fault *vmf) { unsigned long pfn 0x20000000 PAGE_SHIFT; pfn vmf-pgoff; // 检查pfn是否在合法范围内 if (!pfn_valid(pfn) !is_iomem_pfn(pfn)) return VM_FAULT_SIGBUS; if (vm_insert_pfn(vma, vmf-address, pfn)) return VM_FAULT_SIGBUS; return VM_FAULT_NOPAGE; }注意VM_FAULT_NOPAGE的含义是缺页由vm_insert_pfn这类操作处理了我没有提供新的struct page但页表项已经更新。返回这个值时传回的vmf-page是NULL但内核知道映射已经生效。理解了VM_FAULT_NOPAGE和普通fault返回0即VM_FAULT_SUCCESS之间的区别你对整个fault返回语义才算是通透的。4. 常见问题与排查技巧实录4.1 页面引用计数导致的内存泄漏或use-after-freefault回调里分配了struct page很多新手忘记做get_page或者做了但没有在合适的时候put_page。要知道vmf-page被内核拿去插入页表后内核只负责页表项的管理页的引用计数要你来维护。少了get_page页可能在插入页表和进程访问的间隙被回收表现为偶发的内核崩溃或数据错乱多了则永久泄漏。排查方法用/proc/vmstat观察pgalloc和pgfree计数是否严重失衡配合kmemleak和page_owner特性来追踪。在实际工程中真的见过有人上线半年后系统内存慢慢被吃光的案例最后定位就是fault里漏了get_page。这种问题最恶心的地方在于编译不会报错运行不一定会崩只有内存统计曲线露馅。4.2 fault里睡眠和锁的顺序问题fault运行时是进程上下文可以睡眠。但你要特别小心与mmap_lock的交互。内核在缺页异常处理过程中持有mmap_lock的读锁而很多驱动在fault里会调用copy_from_user或访问用户内存这些操作本身又可能需要获取mmap_lock。如果同一个人在同一进程上下文中拿读锁又等待写锁同一个锁的读写优先级反转就会导致死锁。常用的排查手段是打开CONFIG_PROVE_LOCKING和CONFIG_DEBUG_ATOMIC_SLEEP让内核帮你检查在不可睡眠的上下文中是否发生了睡眠、锁顺序是否正确。在fault里最忌讳的是盲目调用可能引起内存重度回收或文件系统直接IO的函数它们可能隐式等待锁。这个问题的理解和排查经验直接是内核面试时大杀四方的谈资。4.3 VMA标志位的组合陷阱VM_IO加共享映射会让page_mkwrite路径完全不一样。如果你的设备驱动想支持写时复制语义设置共享映射的同时还带着VM_IO内核会认为这段区域是“不可COW的I/O区域”COW逻辑会被绕过behavior和你的预期差距很大。所以映射普通内存缓冲区时不要加VM_IO映射寄存器或DMA一致性缓冲区时VM_IO则是必要的。再一个标志配合问题VM_DONTEXPAND和VM_DONTDUMP通常一起设置但在某些内核版本中如果你想允许mremap移动这块映射就不该设置VM_DONTEXPAND。移动会调用mremap操作集钩子你没实现的话直接走默认逻辑很可能被跳过或失败。说白了不同标志组合决定了回调是否会触发必须反复阅读mm/mmap.c和mm/mremap.c里的条件判断代码而不是凭感觉。4.4 缺页处理返回值的误区很多驱动开发者在fault里返回VM_FAULT_ERROR或VM_FAULT_OOM时没有传递更多的细分errno。用户态看到的往往只有段错误排查起来很痛苦。实际上你有两个手段把错误码传递出去一是通过vmf-error字段设置具体错误值比如-EIO、-ENOMEM二是合理使用VM_FAULT_SIGBUS访问超出设备缓冲区末尾、VM_FAULT_OOM内存分配失败、VM_FAULT_SIGSEGV非法访问。判断一个fault实现健壮不健壮最直观的就是把边界条件全部测一遍映射末尾访问、越界偏移、只读页面写操作、fork后的COW。好的返回值设计不会让用户态只看到一个光秃秃的SIGSEGV而是能通过sigaction区分出SIGBUS还是SIGSEGV从而快速定位错误。4.5 速查表核心要点建议收藏场景回调函数返回值注意事项VMA创建/复制openvoid做引用计数别漏VMA销毁closevoid与open对称释放页面不存在faultVM_FAULT_*可睡眠但警惕mmap_lock死锁COW写前page_mkwriteVM_FAULT_LOCKED拿页锁准备脏页调试访问access字节数或负数转换地址偏移防止越界重映射mremap0或负数需要同步物理映射权限变更mprotect0或负数一般驱动可以不管检查是否可切分may_split0可切负数拒绝注意文件系统块对齐约束5. 最后再聊聊我的一点实际体会这个结构体把内核内存管理“多态”的美感体现得淋漓尽致。我见过太多人只会在驱动里把file_operations的mmap填一下、然后remap_pfn_range一把梭式地把物理内存映射给用户态从来不实现vm_operations_struct。一开始确实能跑但只要涉及到fork、写时复制、mmap区域的动态扩展、调试器读取各种莫名其妙的问题就会冒出来。花一个下午把mm/memory.c里do_fault、do_page_mkwrite、handle_mm_fault这几个核心函数顺着走一遍再对照这里讲的操作集语义你会对“缺页异常到底是怎么一步步走到驱动代码里的”有一个贯穿始终的清晰脉络。如果还想继续深入建议再研究下struct vm_fault里各种pgoff、address、pmd字段的流转配合CONFIG_PAGE_TABLE_ISOLATION去真机上追踪页表变化对整个内存子系统的认知又会拉高一个台阶。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询