前几天同事调试一个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 page,vm_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:普通页和特殊页同住一个VMA
3.1 为什么需要混合映射
PFNMAP是极端方案:整个VMA都不要struct page。但有的驱动想要另一种场景——在一个连续的VMA里,一部分offset映射系统普通页(例如用户态传入的buffer页),另一部分offset映射设备保留内存。对用户态来说,这只是一块连续地址空间;对驱动来说,不同的区段对应不同的物理来源。
如果直接用PFNMAP,所有插进去的普通页都会被内核当成裸PFN,vm_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 | 含义 |
|---|---|---|
| rd | VM_READ | 可读 |
| wr | VM_WRITE | 可写 |
| ex | VM_EXEC | 可执行 |
| sh | VM_SHARED | 共享映射 |
| pf | VM_PFNMAP | 裸PFN映射区域 |
| io | VM_IO | IO内存映射 |
| mm | VM_MIXEDMAP | 普通页与特殊页混合区域 |
| ht | VM_HUGETLB | 大页映射 |
| lo | VM_LOCKED | 被mlock锁定 |
| dd | VM_DONTDUMP | 不参与core dump |
4.2 用一段驱动代码验证两个标志
如果你手头有可用的字符设备驱动,可以在mmap回调里直接打印标志位来验证。老内核可以这样写:
static int my_mmap(struct file *file, struct vm_area_struct *vma) { pr_info("my_mmap: vm_flags=0x%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,只有设了MIXEDMAP,vm_normal_page才能正确地区分它们。
4.3 用ftrace/kprobe验证内核判定
最直接的内核态确认方式是追踪vm_normal_page()。如果你有bpftrace和对应符号权限,可以试试:
# bpftrace -e 'kprobe:vm_normal_page { printf("vma_flags=0x%lx addr=0x%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是PFNMAP,vm_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往往比看几十行函数栈更有用。