
内核开发者几乎都会在某个阶段被struct page逼疯一次。这玩意儿看着就是个“描述物理页帧”的结构体但等你真要用它去算物理地址、查页帧状态、或者调试内存问题的时候才会发现这里面藏着 Linux 内核内存管理最核心的一套转换逻辑。我最初搞懂这块时踩了不少坑这篇文章就把struct page、物理页帧、物理地址这三者的关系一次讲透顺便把涉及到的常用宏和调试手段也梳理一遍。在正式开始之前先快速敲掉一个概念歧义。“电脑改物理地址”是普通用户江湖里的说法指修改网卡 MAC 地址和内核内存管理里的“物理地址”完全是两回事。本文聊的是 CPU 地址总线上的物理地址也就是 DRAM 上真实的那一排排地址单元。至于网卡 MAC那是链路层的东西别看名字像性质完全不同。1. 为什么内核对每个物理页帧都要建一个“户口本”1.1 物理内存被切成页帧之后总要有人管现代操作系统管理物理内存的基本单位是页帧Page Framex86-64 和 ARM64 下默认 4KB 一个。一台 8GB 内存的机器光 RAM 区域就有大约 2,000,000 个物理页帧。内核对每一帧都要回答三个问题这页是谁的现在什么状态脏不脏没有struct page之前内核想回答这三个问题只能靠一堆散落的全局数组一个数组记页的状态一个数组记引用计数再来一个数组记映射关系。每加一个属性就得多开一个数组拆东墙补西墙。后来干脆把一页相关的所有元数据打包成一个结构体就叫struct page然后把这些结构体排成和物理页帧一一对应的数组。此时第 n 个物理页帧就对应第 n 个struct page线性关系查找效率极高。1.2 struct page 结构体内部到底存了什么以 Linux 5.15 左右的内核源码为例struct page的定义在include/linux/mm_types.h。它并没有把所有字段平铺而是用了一大堆 union 和 struct 嵌套看起来非常劝退。我拆开说struct page { unsigned long flags; // 页状态标志位如 PG_lru、PG_active、PG_dirty union { struct { union { struct list_head lru; struct { struct page *next; int pages; int pobjects; }; }; union { struct address_space *mapping; void *s_mem; }; unsigned long index; unsigned long private; }; struct { // 用于 slab 时的字段 union { struct list_head slab_list; struct { struct page *next; int pages; int pobjects; }; }; struct kmem_cache *slab_cache; void *freelist; union { void *s_mem; unsigned long counters; }; }; struct { // 用于 page table 时的字段 unsigned long pp_magic; unsigned long pp_frag; unsigned long pp_magic_2; union { struct rcu_head rcu_head; struct { void *v; unsigned long parent_pt; int page_type; }; }; }; struct rcu_head rcu_head; }; union { pgoff_t index; // 在 address_space 内的偏移 void *freelist; unsigned long counters; }; atomic_t _refcount; // 页引用计数决定能否释放 union { unsigned int mapcount; // 映射计数多少个 pte 指向它 unsigned int page_type; }; unsigned long private; ... };字段一大堆但核心就几类flags几十个状态位内核对这页的一切判断都先看它。比如 PG_lru 代表在 LRU 链表上PG_dirty 代表需要写回。_refcount引用计数。用户态映射、page cache、slab、内核直接引用都会增加引用。计数归 0 才能释放回 buddy 系统。mapcount有多少个页表项PTE映射到这页。0 表示没有用户映射大于 0 说明有进程的虚拟地址指向它。mapping和index这一页属于哪个 address_space以及在这个 address_space 里的偏移。文件页和匿名页都在用是回收和回写判断的关键证据。lru用于把页挂到 LRU 链表上。内存回收时内核按 LRU 算法决定先回收谁。各种 union 复用slab 场景、页表场景、复合页场景都会复用这些字段因为同一页不可能同时是 page cache 又是 slab所以可以共用内存。1.3 为什么不用物理地址直接索引还要搞出 struct page有人问既然物理地址唯一为什么不直接拿物理地址当索引问题的关键在于内核平时干活时跟它打交道的更多是“页”而不是“地址”。页有状态、有引用计数、有 owner——这些用单个地址表达不了。struct page 是“物理页的元数据实体”物理地址只是这页的一个属性而且是可由 pfn 随时算出来的属性。从另一个角度看如果拿物理地址当索引每次要查一页的状态就得用物理地址去哈希或遍历产生额外开销。而 array indexing 是 O(1) 的直接把 pfn 当下标取出来一条乘法指令就能算偏移访问速度极快。这就是 Linux 选择 struct page 数组作为枢纽的最根本原因——性能。2. 页帧号 pfn 与物理地址的换算逻辑2.1 将物理地址当作一条大街的门牌号把物理内存想成一条大街4KB 的页帧就是一户人家页帧号 pfnPage Frame Number就是门牌编号物理地址则是这条大街的具体坐标。坐标 门牌号左移 12 位因为 4KB 2^12。左移之后页内偏移量PAGE_OFFSET 的那 12 位就是坐标的最后几位。举一个我调试时的实例假设某页的物理地址是 0xffff8f0000100000这是 x86-64 上某个具体物理页的地址其中低 12 位是0x000属于页内偏移。把地址右移 12 位得到 pfn 0xffff8f00001。内核中大量代码在处理页时只存 pfn不存完整物理地址就是为了省空间——一个 32 位或 64 位的整数就能表达位置而物理地址是 64 位且需要页内偏移的组合。2.2 pfn 与物理地址的四个关键宏内核在include/asm-generic/memory_model.h和include/linux/pfn.h里定义了一套转换接口最常用的是这四个__pfn_to_phys(pfn)PFN_PHYS(pfn)即((phys_addr_t)(pfn) PAGE_SHIFT)。pfn 左移 12 位得到物理地址。__phys_to_pfn(phys)PHYS_PFN(phys)即((unsigned long)(phys) PAGE_SHIFT)。物理地址右移 12 位得到 pfn。page_to_pfn(page)((unsigned long)((page) - vmemmap))后面细说。pfn_to_page(pfn)(vmemmap (pfn))。这几个宏把“物理地址 ↔ pfn ↔ struct page”串成了三条可互相推导的边。只要知道其中一个就能算出另外两个。比如给你一个struct page *p想知道它对应的物理页帧物理地址就调用page_to_pfn(p)再调用__pfn_to_phys(pfn)或者直接走page_to_phys(p)的封装一步到位。反过来拿到一个虚拟地址addr先用virt_to_page(addr)得到struct page *再走上面的链条就能拿到物理地址。2.3 x86-64 上的 vmemmap 机制那么page_to_pfn里的vmemmap到底是什么早期的内核2.4、2.6 早期是用mem_map[]全局数组来存放所有 struct page 的数组的大小在启动时根据物理内存大小静态决定。这在大内存时代有严重问题物理内存小的时候数组浪费连续地址空间内存大的时候数组太大容易把内核地址空间的直接映射区撑爆。后来引入了稀疏内存模型SPARSEMEMstruct page 数组被拆成vmemmap一个虚拟地址连续、物理内存按需分配的映射区域。每个 pfn 对应的 struct page 的虚拟地址就是vmemmap pfn * sizeof(struct page)。它既保留了数组索引的 O(1) 查找优势又允许底层 struct page 的内存按 section 动态分配支持内存热插拔。这个 vmemmap 区域在 64 位内核中位于内核地址空间的高位虚拟地址区域用户态程序永远看不到。在 64 位系统上直接看 System.map能找到一个名为vmemmap_base的符号它就是 struct page 数组的起始虚拟地址。2.4 为什么 struct page 的大小决定内存开销struct page 在 64 位系统上大约 64 字节不同内核配置有浮动SLUB 调试开关打开后会更大。那么 8GB 物理内存有多少个 4KB 页帧8GB / 4KB 2,097,152 个。这 200 多万个 struct page 各占 64 字节总大小 2,097,152 × 64 134,217,728 字节约 128MB。也就是说8GB 物理内存需要额外支付 128MB 内核内存来存页元数据占 1.5% 左右。物理内存越大这个比例越稳定因为页帧数量线性增长struct page 大小固定。这也是内核在CONFIG_SPARSEMEM_VMEMMAP下始终保留固定虚拟地址空间的原因——无论你真的装了多少内存vmemmap 映射范围都在那放着实际只有在线内存段的页才分配 struct page。3. 从虚拟地址到 struct page 再到物理地址的完整链路3.1 virt_to_page 和 page_address 的“不可对等性”这里是我当时最困惑的地方。内核里有两个方向看似对称的接口virt_to_page(virt)和page_address(page)。前者传入内核虚拟地址返回 struct page后者传入 struct page 返回内核虚拟地址。但问题在于不是所有虚拟地址都能反向找到 struct page。virt_to_page只适用于**直接映射区线性映射区**的虚拟地址。x86-64 上物理内存直接映射在PAGE_OFFSET开始的线性区域虚拟地址减去PAGE_OFFSET就是物理地址再做 PHYS_PFN 就能得到 pfn接着就能找到 struct page。这是整个链条里最简单的一条路。page_address(page)只对直接映射区内的页有意义。对于通过 vmalloc 分配的、或者内核模块映射的、或者用户态映射的高端内存区域page_address 走的是另外一张哈希表或者是 NULL取决于是否 HIGHMEM。所以如果你拿到一个 vmalloc 区域的地址先做virt_to_page大概率得到错误结果。正确做法是先通过vmalloc_to_page(virt)找到页再走page_to_pfn取物理地址。我当年写模块时拿着 vmalloc 的指针去做virt_to_page结果从 pfn 算出来的物理地址全是错的排查了很久才发现是这里的问题。这个坑必须写进笔记里。3.2 动手实现写内核模块打印一页的完整转换空谈理论没用我手写了一个简单的内核模块接收一个用户传入的虚拟地址打印出虚拟地址、对应的 struct page 地址、pfn、物理地址。这种方式最直观而且可以在实际环境中验证上面说的转换链路。模块代码如下#include linux/init.h #include linux/module.h #include linux/kernel.h #include linux/mm.h #include linux/highmem.h static unsigned long user_virt_addr; module_param(user_virt_addr, ulong, 0644); MODULE_PARM_DESC(user_virt_addr, User virtual address to trace); static int __init page_trace_init(void) { struct page *page; unsigned long pfn; phys_addr_t phys; page virt_to_page(user_virt_addr); pfn page_to_pfn(page); phys page_to_phys(page); pr_info(user virt addr : 0x%lx\n, user_virt_addr); pr_info(struct page addr : 0x%px\n, page); pr_info(pfn : 0x%lx\n, pfn); pr_info(phys addr : 0x%llx\n, (unsigned long long)phys); return 0; } static void __exit page_trace_exit(void) { } module_init(page_trace_init); module_exit(page_trace_exit); MODULE_LICENSE(GPL);编译方式老规矩Makefile 这样写obj-m page_trace.o KDIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean加载前先准备好一个用户态进程的虚拟地址。最简单的方法是先写一个 C 程序分配一页内存把地址和 pid 打印出来然后 sleep 住#include stdio.h #include stdlib.h #include unistd.h int main() { void *p malloc(4096); printf(pid%d addr%p\n, getpid(), p); sleep(30); return 0; }然后手动把打印出来的地址填入模块参数加载insmod page_trace.ko user_virt_addr0x7f1234567000 dmesg | tail -4注意这里模块里直接virt_to_page(user_virt_addr)是有问题的因为用户态虚拟地址根本不在直接映射区。正确做法是先通过进程的页表走follow_page或get_user_pages_fast。我上面这段代码刻意简化了链路真正验证用户地址时需要先借助get_user_pages_fast取得 struct page。更稳妥的版本是用follow_pfn或通过/proc/pid/pagemap读取。这里只是示范“从 page 到 phys 的换算”代码思路实战时千万别直接拿用户地址喂 virt_to_page。3.3 更安全的用户地址转物理地址pagemap 方式其实用户态程序想查自己的虚拟地址对应的物理地址根本不用写内核模块内核已经提供了/proc/self/pagemap。每个虚拟页对应 pagemap 中的一项64 位其中 bit 63 表示页是否驻留bit 0-54 表示页帧号。写个小工具就能算出物理地址#include stdio.h #include stdint.h #include stdlib.h #include unistd.h #include fcntl.h #include string.h int main(int argc, char *argv[]) { unsigned long virt_addr; uint64_t entry; int fd; if (argc ! 2) { fprintf(stderr, usage: %s virt_addr\n, argv[0]); return 1; } virt_addr strtoul(argv[1], NULL, 0); // 假设当前进程用 /proc/self/pagemap fd open(/proc/self/pagemap, O_RDONLY); if (fd 0) { perror(open pagemap failed); return 1; } // 每个虚拟页占 8 字节 off_t offset (virt_addr / 4096UL) * 8; if (pread(fd, entry, sizeof(entry), offset) ! sizeof(entry)) { perror(pread pagemap failed); close(fd); return 1; } close(fd); if (!(entry (1ULL 63))) { printf(page not present\n); return 1; } uint64_t pfn entry ((1ULL 55) - 1); uint64_t phys (pfn 12) | (virt_addr 0xfff); printf(virt0x%lx pfn0x%llx phys0x%llx\n, virt_addr, (unsigned long long)pfn, (unsigned long long)phys); return 0; }注意读取 pagemap 需要权限普通用户只能看自己进程的页而且内核版本和容器环境下可能部分信息被隐藏。但在本机调试时这个工具非常好用基本可以替代内核模块的现场验证。我测试过拿它读出来的物理地址和跨进程 DMA 操作对齐后完全一致可以作为验证其他路径正确性的“参照答案”。4. 深入代码page_to_pfn 背后的稀疏内存模型实现4.1 解析 vmemmap 偏移换算的细节翻开include/asm-generic/memory_model.h在CONFIG_SPARSEMEM_VMEMMAP下page_to_pfn和pfn_to_page的定义是#define __page_to_pfn(pg) \ (unsigned long)((pg) - vmemmap) #define __pfn_to_page(pfn) \ (vmemmap (pfn))这就是指针减法的直接应用。pg - vmemmap得到的是一个元素个数struct page 的个数正好等于 pfn因为 struct page 数组按下标排列vmemmap 就是下标 0。这里有一个隐藏问题struct page占用 64 字节(pg - vmemmap)计算的是按sizeof(struct page)元素个数计的差值编译器会在代码生成时处理缩放因此得到的就是 pfn而不用除以 64。这是 C 语言指针减法的标准语义也是这个宏实现简洁的秘密。4.2 内存热插拔时 vmemmap 怎么处理物理内存热插拔时vmemmap 区域并不能简单作为一个连续数组处理。新插入的内存段memory section对应的 struct page 需要被分配和初始化然后“接入”vmemmap 的对应位置。关键逻辑在mm/sparse-vmemmap.c中的vmemmap_alloc_block和vmemmap_populate。大致流程是内存热插拔触发add_memory进入sparse_add_section。内核为这一段内存分配 vmemmap 页面页表映射到 vmemmap 的虚拟地址区域。初始化这些新 struct page设置标志位纳入 buddy 分配器。这个过程中旧的 pfn 对应的 struct page 位置不变新增 pfn 会映射到新分配的 struct page 内存上。只要内存模型是 SPARSEMEM_VMEMMAPpfn_to_page(pfn)对任何在线 pfn 都能正确返回。这也是现代内核能够支持内存热插拔却依然保持 O(1) 查询的底气。4.3 大内存页和 struct page 的复合页表示物理大页HugeTLB 或 THP会占用多个连续物理页帧但内核并不会为每个页帧单独分配一个完整的 struct page 来“重复描述”。它只用第一个 struct page 作为“头页”剩下的作为“尾页”用_refcount的特殊编码和page_type或compound_head来描述整组页。具体来说头页的compound_head指向自己compound_order存大页的阶数。尾页的compound_head指向头页通过PageTail(page)可判断它是不是尾页。遍历一个复合页的所有子页时从头页的compound_nr页数出发就像查一个家庭户口本找到了户主就能列出所有家庭成员。这种方式让内核可以用少量元数据描述超大物理连续区域避免为每个小页帧都保存完整的映射关系也降低了页表和大页管理结构的开销。5. 实操中的坑从崩溃转储到问题定位的常用手段5.1 通过 crash 工具验证 struct page 和 pfn 关系我调试内核问题用到最多的工具是crash。拿到 vmcore 后可以直接用struct page的地址去反查 pfn 和物理地址。比如我有一块内存损坏的问题vmcore 里看到一个可疑的struct page *crash struct page 0xffffea0004060000 struct page { flags 0x200000000005003, ... }想看它对应哪个 pfn用page_to_pfn的等价操作虚拟地址减去 vmemmap 起始地址再除以 sizeof(struct page)。在 crash 中可以这样搞crash p vmemmap vmemmap $1 (struct page *) 0xffffea0000000000 crash eval 0xffffea0004060000 - 0xffffea0000000000算出来的差值就是 pfn 乘以 64 的字节偏移除以 64 得到 pfn。再把 pfn 左移 12 位就能得到物理页帧起始地址。这个流程我推荐所有内核开发者都练一遍对于理解 struct page 和物理页帧的线性对应关系帮助立竿见影。5.2 通过 kpageflags 查页状态有时候我们要在跑着的内核上核实某页是不是某种状态比如是不是 PageDirty、PageLRU。/proc/kpageflags可以把所有物理页帧的标志位按位罗列出来配合/proc/kpagecount可以知道每页被引用的次数。举个例子想查看 pfn 为 0x1000 的页引用计数和标志位dd if/proc/kpagecount bs8 skip$((0x1000)) count1 | od -An -tu8 dd if/proc/kpageflags bs8 skip$((0x1000)) count1 | od -An -tx8flags 的解码需要对照内核源码里的include/linux/page-flags.h中的enum pageflags。用这种方式可以验证一个页是否常驻、是否属于某个 cgroup 等是做内存回收问题排查的好帮手。不过需要 root 权限而且旧内核上这些文件可能在 4.14 前后才默认开启需要注意内核版本。5.3 一个经典现场page_address 返回 NULL 的排查思路我遇到过模块里调用page_address(page)返回 NULL 的情况。一查因为那个页面来自vmalloc区域它所在的页可能是 highmem 页也可能是非直接映射页此时 page_address 查的是page_address_htable哈希表如果从来没有注册过内核虚拟地址自然返回 NULL。解决办法是如果是vmalloc区域指针用vmalloc_to_page拿页再用page_to_virt或者直接通过 pfn 转物理地址。如果是 highmem需要先kmap再访问或者直接走 pfn 到物理地址的路线交给 DMA 引擎。这类问题最迷惑人的地方在于代码在 x86-64 上大概率不会触发因为 x86-64 没有 highmem全部内存都在直接映射区。换到 ARM32 或者旧式 32 位系统上问题马上浮现。所以写跨架构内核代码时遇到“page_address 返回 NULL”先想想这页到底是不是直接映射区里的这是内存管理中最容易“架构相关”的一个细节。5.4 改物理地址别闹那是 MAC 地址再回到开头的热词“电脑改物理地址”。它指的是修改网卡的 MAC 地址改的是一串 48 位的链路层标识不是内存的物理地址。二者的共同点只在于都叫“物理地址”这四个汉字。内核里你改不了内存物理地址——物理地址是硬件布线决定的事实不是软件配置能改的。如果有软件号称“修改内存条物理地址”可以直接判定是忽悠或恶意软件。顺带科普一句MAC 地址修改在 Linux 下用ip link set dev eth0 address xx:xx:xx:xx:xx:xx就能实现改的是网卡寄存器里的地址重启后或重新加载驱动后通常恢复。而本文讲的物理页帧物理地址是 CPU 访问内存时地址总线上的地址属于系统软件整个地址转换链条的末端真相。两者层次完全不同千万别混。6. 进阶思考struct page 还能怎么用6.1 零拷贝和 DMA 场景下的 struct page 价值做高性能网络收发时经常要直接拿用户的页做 DMA。此时不能直接把用户虚拟地址交给网卡因为 DMA 引擎访问的是物理地址需要先拿到页的物理地址并做 DMA 映射。整个流程离不开 struct page用户进程分配内存后内核对这段虚拟内存建好页表背后就是一堆 struct page。驱动get_user_pages拿到用户虚拟地址对应的 struct page 数组。驱动对这些页做dma_map_page从 struct page 得到物理地址填到 DMA 描述符里。数据完成后dma_unmap_page再put_page减少引用计数。这个链路里 struct page 就是内核和硬件设备之间的“交接凭证”。硬件驱动不关心虚拟地址只认物理地址和页帧号而 struct page 是这个转换三角的核心枢纽。我在写一个 DMA 驱动时就是从get_user_pages开始一路page_to_pfn到物理地址再建 sg list 给网卡用整个过程结构清晰几乎没有歧义。6.2 从 struct page 出发看内存回收流程当内存压力上来时内核回收线程kswapd会优先扫描 LRU 链表上的页它的每一项都是struct page *。回收时内核通过mapping字段决定怎么处理如果 mapping 指向一个文件系统地址空间说明这是文件页可以写回后回收。如果 mapping 是匿名映射的需要先把内容换出到 swap 再回收。这个决策完全靠 struct page 上的信息。没有 struct page内核连“这页是谁的”都查不出更谈不上回收策略。物理页帧是硬件资源struct page 是它的抽象地址只是抽象的一种坐标表达。三者组合成了 Linux 内存管理体系的基石。6.3 学习建议自己动手绘制一张三角关系图我建议每个学习这块的人都亲自画一张图左侧是“物理地址”右侧是“pfn”底部是“struct page 指针”三个角之间用三条边标注转换宏。然后自己拿一个实际内核地址把从虚拟地址一路换算到物理地址的过程在纸上过一遍。再把 debugfs 里的/proc/pagetypeinfo、/proc/buddyinfo和 kpageflags 的输出对照着看慢慢就能培养出对内存布局的直觉。我在带团队时总让新来的内核工程师抄写一遍page-flags.h里所有标志位然后回答一个问题“如果发现一个页同时有 PG_dirty 和 PG_reclaim 标志你认为内核现在正在做什么”这种从页面状态反推系统行为的练习比死记硬背某个函数有效得多。7. 小节经验几条必须记住的实战心得最后分享几条我这些年摔打出来的心得都是文档里不怎么会写明的部分struct page 数组的虚拟地址永远是内核线性地址的高位段不是物理地址的直接映射。你可以在代码里打印 page 的地址比如 0xffffea0000000000 这种那是 vmemmap 的虚拟地址不是 struct page 在物理内存中的位置。别拿它和物理地址直接做运算。用page_to_phys拿物理地址是最稳的。它内部经历了page_to_pfn(pg) PAGE_SHIFT封装完整不会漏转换。除非你明确知道自己在做什么方向的高度优化否则不要自己拼宏。用户态虚拟地址不要直接走 virt_to_page。除非你已经用 get_user_pages 拿到了页否则永远通过内核提供的标准接口走页表翻译。我见过太多模块开发者在这上面翻车包括我早期自己。x86-64 没有 HIGHMEM不代表所有架构都没有。写跨架构代码时记得检查页的来源否则 ARM32 上必然出问题。pfn 到物理地址左移 12 位是固定规则前提是 PAGE_SIZE 4KB。但有些架构或编译配置可能用 64KB 页这时 PAGE_SHIFT 会变成 16。代码里别写死 12直接用PAGE_SHIFT、PAGE_SIZE这类宏。我在实际工作中发现真正能彻底玩转 struct page 的开发者往往不是看书看会的而是靠动手写模块、调试崩溃转储、测量性能一步一步磨出来的。这篇文章把最核心的关系和链路都铺开了剩下的就要靠你在真实内核环境里反复验证。如果后面你对某个具体场景感兴趣比如 THP 的 struct page 怎么组织、slab 的 struct page 复用细节、或者内存热插拔时 vmemmap 的页表迭代逻辑我还可以再展开写。这块内容越挖越深但地基就是“struct page、pfn、物理地址”这个三角关系——把这个夯实了上面盖什么楼都不会塌。