前几天同事调试一个GPU驱动用户态mmap之后一写就SIGBUS跑过来找我。我先让他查/proc/pid/smaps里对应VMA的Flags结果里面出现了io pf两个小标记。再看他驱动的mmap回调先用remap_pfn_range映射了设备BAR又用vm_insert_page塞了两个普通系统页。问题其实已经很清楚了——pf代表这片VMA带着VM_PFNMAP在这个区域里每一页都被内核默认成裸PFN没有struct page你硬塞普通页进去内核vm_normal_page()根本不会把它们当常规页处理。后来去掉那两个普通页改成统一的PFNMAP问题立刻消失。VM_PFNMAP和VM_MIXEDMAP是Linux VMA里最容易把人绕晕的两个映射标记。它们都和“直接把物理帧号PFN塞进页表”有关但语义完全不一样。这篇文章从内核内存管理的底层逻辑讲起把这两个标志的含义、产生方式、对PTE的影响、在用户态如何观测以及驱动开发中常见的翻车点一次性说清楚。适合正在写设备驱动、读内核代码或者排查mmap相关panic的读者也适合准备内核面试的人。1. 为什么VMA要区分“普通页”和“裸PFN”1.1 struct page内存管理系统的身份证Linux内核管理物理内存最核心的对象叫struct page。每个物理页帧只要被内核纳入常规管理都有一个对应的struct page结构。这个结构里记录着引用计数_refcount、映射计数器_mapcount、LRU链表节点、slab信息、甚至还有指向所有映射它的PTE的反向映射信息。用户态的匿名页、文件页走的是同一条路mmap后建立VMA缺页时分配page并建立PTE这些page都挂在LRU上可以被kswapd回收可以被swap换出fork时可以做COW。这一切之所以能自动运转就是因为内核能从PTE反查到struct page。而反查的入口就是vm_normal_page()。1.2 设备内存的映射困境设备驱动想给用户态暴露一块寄存器区域MMIO、一块物理上连续的DMA缓冲区或者一块显存BAR。这些地址绝大部分情况下要么没有对应的struct page要么有struct page但内核不允许把它当普通内存回收。如果直接用普通匿名映射的方式去建页表内核会出现两个问题第一它拿到PTE后想去找struct page找不到或找到的page处于特殊状态引用计数没法正确处理第二后续任何内存管理事件回收、swap、COW、migration都可能对这块设备内存造成不可预期的操作有可能直接造成系统崩溃。所以内核必须给这些VMA打上特殊标记告诉内存管理子系统这片区域里的页不是普通页请不要用常规方式操作。VM_PFNMAP就是干这个的VM_MIXEDMAP则是它的一个进阶变体。1.3 两个标记的分工一句话总结用最简单的话说VM_PFNMAP整个VMA里的PTE都指向裸PFN没有struct page或者说全部当成没有struct page处理。VM_MIXEDMAP同一个VMA里一部分PTE是普通页有struct page另一部分是裸PFN页通过PTE的special位逐页区分。两个标志不能同时设置因为他们要解决的问题虽然相关但层级不同。理解了这一点后面很多判断都会顺理成章。2. VM_PFNMAP整片VMA都是“直通物理内存”2.1 标志位定义与基本语义VM_PFNMAP定义在include/linux/mm.h中老内核里是#define VM_PFNMAP 0x00000400和VM_IO、VM_DENYWRITE排在一起。它表示VMA映射的对象是一组物理页帧号这些页帧可能没有对应的struct page或者有struct page但是内核明确不通过VMA来管理它。注意VM_PFNMAP描述的是“页表条目的性质”不是“这片内存是否属于设备”。有些保留内存、固件占用的RAM区域也可以被映射成PFNMAP并不一定都是IO地址。反过来真正的IO内存通常既带VM_IO又带VM_PFNMAP。2.2 最常见的产生方式remap_pfn_range()绝大多数PFNMAP的VMA来自驱动调用remap_pfn_range()。这个函数在mm/memory.c里实现作用是把从addr开始的用户空间虚拟地址逐一映射到以pfn为起点的物理页帧映射的长度由size决定。函数内部除了建立页表还会修改VMA的标志典型的做法是vma-vm_flags | VM_IO | VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP;所以驱动只要调用remap_pfn_range()这片VMA基本就被打上了PFNMAP的钢印。另一个常见入口是io_remap_pfn_range()它多绕了一层用于处理IO内存的协议转换最终也会走到remap_pfn_range()。值得注意的是remap_pfn_range()建立的是线性映射即VMA的偏移和物理页帧是一一对应的PTE在调用时就一次性填好了。如果驱动希望在缺页的时候动态决定物理帧号那通常不用这个函数而是自己实现vma-vm_ops-fault。2.3 vm_normal_page()对PFNMAP直接返回NULL理解了PFNMAP就能理解Linux内核里那个经典函数vm_normal_page()。它在mm/memory.c中负责从一个PTE反推它对应的struct page。常规路径是先看PFN是否有效有效就把pfn_to_page(pfn)返回。但函数开头就有这样的判断逻辑不同内核版本实现有差异语义一致if (unlikely(vma-vm_flags VM_PFNMAP)) return NULL;也就是说只要VMA带着VM_PFNMAP不管PTE里那个PFN在系统里是否真的对应一个合法的struct pagevm_normal_page()一律返回NULL。这是PFNMAP最核心的语义整片VMA不允许被当作普通内存页映射来处理。这个NULL在后续会产生一系列连锁反应反向映射rmap系统遇到NULL不会做page_remove_rmap()回收系统遇到NULL不会把这个页挂上LRU写保护缺页处理遇到NULL不会执行COW的拷贝逻辑get_user_pages拿不到page指针自然无法为这块区域建立普通内存的GUP映射。2.4 缺少struct page带来的“野生”生命周期PFNMAP的区域被unmap时内核只是简单地清掉PTE不会去put_page()因为压根没有page对象可操作。这也意味着映射这片区域的用户进程即使fork了内核也没法为子进程做页级引用计数管理。很多驱动为了让进程fork后不继承设备映射还会配合设置VM_DONTCOPY。另外如果这片PFNMAP区域因为某种原因触发写保护例如驱动错误地在mmap里允许了VM_WRITE但实际物理内存是只读的内核会发现它在do_wp_page()里无法找到普通page执行COW最终只能返回VM_FAULT_SIGBUS这就是写设备映射直接收到SIGBUS的底层原因。3. VM_MIXEDMAP普通页和特殊页同住一个VMA3.1 为什么需要混合映射PFNMAP是极端方案整个VMA都不要struct page。但有的驱动想要另一种场景——在一个连续的VMA里一部分offset映射系统普通页例如用户态传入的buffer页另一部分offset映射设备保留内存。对用户态来说这只是一块连续地址空间对驱动来说不同的区段对应不同的物理来源。如果直接用PFNMAP所有插进去的普通页都会被内核当成裸PFNvm_normal_page()返回NULL引用计数、反向映射全乱。如果完全不用标记那些设备保留页又会被当作普通页处理一样会出问题。于是内核提供了VM_MIXEDMAP语义是这个VMA允许混插普通页和特殊页内核会在每次建立PTE时通过PTE自己的属性来判断这一页具体是不是普通页。3.2 核心机制PTE的_PAGE_SPECIAL位在x86等支持HAVE_PTE_SPECIAL的架构上页表项里有一个特殊位一般叫_PAGE_SPECIAL即pte_special()宏检查的位。这个位的设计初衷就是标记“这不是一个常规内存页”。当你用vm_insert_page()插入一个普通页时内核建立PTE但不会设置special位。当你用vm_insert_pfn()或vmf_insert_pfn()插入一个裸PFN时内核会建立pte并设置special位。所以这枚位就是区分普通页和特殊页的逐页身份证。因此MIXEDMAP只是告诉内核“这个VMA里两种页都可能出现请你不要只看VMA级别标志就下结论要逐页检查special位”。相比之下PFNMAP不需要任何逐页检查VMA级别的标志就已经把结论定死了。3.3 vm_normal_page()在MIXEDMAP下的判定逻辑在MIXEDMAP下vm_normal_page()不能直接返回NULL也不能直接返回pfn_to_page(pfn)它要走分支判断。典型逻辑可以概括为如果PTE带有special位说明是裸PFN映射直接返回NULL。如果PTE没有special位说明是普通页继续用pfn_valid()和pfn_to_page()返回struct page。如果VMA同时没有PFNMAP和MIXEDMAP却出现了一个带special位的PTE那通常是bug。这里有个容易踩的细节在老一些的内核或没有HAVE_PTE_SPECIAL的架构上MIXEDMAP的判定会退化成“用PTE的present位的其他编码”或者使用vm_normal_page_original()之类的函数但语义不变都是要区分到底是普通页还是特殊页。3.4 子系统的正确处理要求VM_MIXEDMAP的影响不仅停留在vm_normal_page()。内核里许多内存管理代码都会针对MIXEDMAP进行特殊判断。例如get_user_pages()在遍历PTE时如果遇到带special位的PTE且VMA是MIXEDMAP就不会把它当作普通page返回给调用者反过来说如果普通页和特殊页混在一起GUP可能在同一个VMA里先成功拿到一部分page然后在特殊页上返回错误导致用户态误以为整块buffer都可用。所以驱动在设计混合映射时一定要明确告诉使用方这块区域不是普通的可分页内存不能像malloc出来的堆块一样随便注册给RDMA、vfio等需要GUP的子系统。4. 用户态观测与内核态确认从smaps的pf/mm说起4.1 /proc/pid/smaps里的Flags字段很多人第一次注意到这两个标记是看/proc/pid/smaps。VMA的Flags行会以两个字符的缩写显示一部分vm_flags。其中pf对应VM_PFNMAPio对应VM_IOmm对应VM_MIXEDMAP一个典型驱动设备映射的smaps片段可能是这样00400000-00800000 rw-s 00000000 00:00 0 [pcibariov] Flags: rd wr mr mw ms io pf而一个混合映射的VMA可能显示mm而不是pf。注意/proc/pid/maps里不会显示这些标志只有smaps这一类的深度接口才有。要趁进程活着的时候看进程退出后VMA就没了。这里给一个常见的对照表方便排查时一眼定位smaps缩写vm_flags含义rdVM_READ可读wrVM_WRITE可写exVM_EXEC可执行shVM_SHARED共享映射pfVM_PFNMAP裸PFN映射区域ioVM_IOIO内存映射mmVM_MIXEDMAP普通页与特殊页混合区域htVM_HUGETLB大页映射loVM_LOCKED被mlock锁定ddVM_DONTDUMP不参与core dump4.2 用一段驱动代码验证两个标志如果你手头有可用的字符设备驱动可以在mmap回调里直接打印标志位来验证。老内核可以这样写static int my_mmap(struct file *file, struct vm_area_struct *vma) { pr_info(my_mmap: vm_flags0x%lx\n, vma-vm_flags); return remap_pfn_range(vma, vma-vm_start, pfn_base, size, vma-vm_page_prot); }映射结束后去查看进程的smaps你就看到该VMA带pf。如果换成混合映射static vm_fault_t my_fault(struct vm_fault *vmf) { unsigned long off vmf-pgoff; if (off 16) return vmf_insert_page(vmf-vma, vmf-address, system_page); else return vmf_insert_pfn(vmf-vma, vmf-address, reserved_pfn); } static int my_mmap(struct file *file, struct vm_area_struct *vma) { vm_flags_set(vma, VM_MIXEDMAP); vma-vm_ops my_vm_ops; return 0; }vm_flags_set()是较新内核的推荐写法如果你维护老内核可以直接vma-vm_flags | VM_MIXEDMAP。fault里混用vmf_insert_page和vmf_insert_pfn只有设了MIXEDMAPvm_normal_page才能正确地区分它们。4.3 用ftrace/kprobe验证内核判定最直接的内核态确认方式是追踪vm_normal_page()。如果你有bpftrace和对应符号权限可以试试# bpftrace -e kprobe:vm_normal_page { printf(vma_flags0x%lx addr0x%lx\n, ((struct vm_area_struct *)arg0)-vm_flags, arg1); }如果这个函数被inline化导致kprobe挂不上就在驱动自己的fault回调里打印vma-vm_flags和pte_val(pte)看到special位后结合flags判断即可。5. 排障与常见误区这些坑我几乎都踩过5.1 在VM_PFNMAP里插入普通页引用计数和rmap全乱这是我能想起来的最高频翻车点。驱动一开始为了省事对设备BAR调用了remap_pfn_range()后来又想附加几个系统内存页直接调vm_insert_page()把普通页塞进同一个VMA。因为VMA是PFNMAPvm_normal_page()对这些PTE全部返回NULL于是内核在unmap时不会对普通页做put_page()页面的引用计数永远不归零内存泄漏如果后续有反向映射操作则可能出现静默的数据错误甚至panic。正确做法是要么把普通页和裸PFN分开成两个VMA要么从一开始就使用MIXEDMAP用vmf_insert_page和vmf_insert_pfn混合插入。不要在PFNMAP里偷加普通页。5.2 混合映射忘了设VM_MIXEDMAP反过来的错误也常见驱动在fault回调里用了vmf_insert_pfn()但没有给VMA设置VM_MIXEDMAP。这时如果这片VMA是普通文件映射或匿名映射vm_normal_page()观察到PTE带special位就会走上“这是特殊页”的分支但又发现VMA既不是PFNMAP也不是MIXEDMAP于是触发VM_BUG_ON级别的不一致或者更隐蔽地直接返回NULL。我的建议是驱动的mmap回调一开始就要把flags定清楚不要等到fault里再想。判断标准很简单——只要一个VMA里可能有vm_insert_pfn或vmf_insert_pfn的产物就设VM_MIXEDMAP如果整个VMA都是裸PFN且用remap_pfn_range线性填充就直接用VM_PFNMAP。5.3 VM_IO和VM_PFNMAP不是一回事这两者的关系经常被人误解。VM_IO本质是“语义”层面的标记表示这片虚拟地址区域访问的不是常规内存内核在缺页、缓存策略、mlock等行为上会区别对待。VM_PFNMAP是“页表”层面的标记表示PTE直接映射物理帧号没有struct page。设备驱动里两者往往同时出现但老内核里也允许只设置VM_IO不设置VM_PFNMAP的情况。排查时不要因为只看到io而忽略pf也不要因为看到pf就认为底层一定是IO地址。5.4 新内核直接改vm_flags的纪律最后说一个看起来不大但能救命的细节。从Linux 6.x开始内核逐渐把vm_flags的类型和修改方式“管起来”了驱动里直接vma-vm_flags | VM_MIXEDMAP可能会触发编译警告或并发原子性要求。新代码建议统一使用vm_flags_set(vma, VM_MIXEDMAP)等辅助函数。我自己的排查习惯是看到panic栈里出现在vm_normal_page()或do_wp_page()附近第一反应就是去查对应VMA的Flags到底被谁设置过、设置的是哪个位。打印一行vm_flags往往比看几十行函数栈更有用。