1. 这组宏到底在干啥——从内存管理底层看PFN系列宏的真实用途你第一次在Linux内核源码里看到PFN_UP、PFN_DOWN、PFN_PHYS、PHYS_PFN、PFN_ALIGN这几个宏大概率会愣一下这堆全大写的缩写既不像函数调用也不像变量定义更不带括号参数提示光看名字根本猜不出它们在做什么。我刚接触内核内存管理时也这样翻了三遍include/asm-generic/memory_model.h和include/linux/mm.h才真正搞懂——它们不是“功能模块”而是物理内存地址与页帧号Page Frame Number之间最基础、最频繁、最不容出错的双向换算工具。关键词就三个PFN、物理地址、页对齐。它们不处理页表、不分配内存、不触发缺页异常只做一件事在两个整数之间做一次精准、无损、可逆的数学映射。为什么这个映射如此关键因为内核所有内存管理操作都建立在这个基础上。比如alloc_pages()返回的是一个struct page *它内部通过page_to_pfn()转成页帧号再用pfn_to_page()反向查表remap_pfn_range()需要把用户传入的物理地址先转成PFN再填进页表项甚至memmap初始化时也要把BIOS报告的物理内存范围如0x10000000–0x20000000全部转换成PFN区间才能构建mem_section数组。而所有这些转换背后都离不开这五个宏。它们不是炫技的高级API而是内核内存子系统里最底层的“加减乘除”——没有它们整个内存管理框架连编译都过不去。你不需要记住每个宏的实现细节但必须理解PFN_UP(x)的本质是(x PAGE_SIZE - 1) PAGE_SHIFT它解决的是“向上取整到页边界”的问题PFN_PHYS(pfn)就是pfn PAGE_SHIFT纯粹的位移运算。这种设计不是为了优雅而是为了极致的性能和确定性在中断上下文、原子操作中不能有分支、不能有函数调用开销、不能有任何不确定性。所以它们全是纯宏展开后就是几条汇编指令。如果你正在调试一个page fault错误或者发现dma_alloc_coherent分配的地址无法被设备访问十有八九问题就出在某处手动计算PFN时忘了用PFN_DOWN而直接用了整除导致地址偏移了一页——这种错误不会报错只会让数据写到隔壁页去排查起来极其痛苦。2. 核心设计逻辑与选型依据为什么是宏为什么是位运算2.1 宏 vs 函数性能与确定性的生死线第一反应可能是“为什么不写成内联函数”答案很直接在内核最关键的路径上连一次函数调用的栈帧压入/弹出开销都承受不起。我们来算一笔账。假设你在__alloc_pages_slowpath()中需要将一个物理地址phys_addr转为PFN用于快速查找mem_map数组索引。如果用函数static inline unsigned long phys_to_pfn(phys_addr_t phys) { return phys PAGE_SHIFT; }编译器在优化级别-O2下大概率会内联但仍有风险如果该函数被放在另一个编译单元或者编译器因某些原因未内联就会产生真实的call指令。而在ARM64上一次call指令涉及PC值保存、跳转、返回地址压栈至少消耗3–5个CPU周期。而PHYS_PFN(phys)展开后就是一条lsr x0, x0, #12假设PAGE_SHIFT12单周期完成。更关键的是确定性宏展开是预处理器阶段完成的100%可预测而函数内联是编译器行为受优化级别、链接时优化LTO影响可能在不同配置下行为不一致。内核要求“同一份代码在任何配置下行为绝对一致”这是稳定性的基石。所以所有涉及地址换算、位操作、常量计算的底层工具一律用宏。这不是风格选择而是硬性约束。2.2 位运算 vs 除法硬件原语的必然选择第二个问题是为什么非得用 PAGE_SHIFT而不是phys / PAGE_SIZE答案在于硬件支持与编译器生成代码的质量。现代CPU的整数除法指令如x86的idiv是微码实现的延迟高达20–40个周期且无法流水线。而右移指令shr是组合逻辑电路直接实现的1个周期搞定。更重要的是PAGE_SIZE在编译时就是已知常量通常是4096、2M或1G编译器看到x / 4096会自动优化为x 12。但这个优化不是100%可靠的如果x是有符号数除法和移位在负数时结果不同C标准规定/向零取整是算术右移结果取决于编译器实现。而内核中所有物理地址和PFN都是无符号的用不仅快而且语义绝对清晰、无歧义。PAGE_SHIFT这个常量本质上就是log2(PAGE_SIZE)它把“除以页大小”这个算术概念彻底降维成“右移多少位”这个硬件原语。这也是为什么内核代码里几乎看不到#define PAGE_SIZE 4096这种写法而是#define PAGE_SHIFT 12因为位移才是真正的底层操作。2.3 五宏分工各司其职覆盖所有边界场景这五个宏不是随意凑数而是精准覆盖了内存管理中所有可能的地址换算与对齐需求PFN_UP(addr)当你要把一个任意字节地址比如DMA缓冲区起始地址向上取整到下一个页边界时用。典型场景驱动申请一块DMA内存硬件要求起始地址必须页对齐但你传入的size可能不是页的整数倍这时PFN_UP(size)就告诉你至少要申请多少页。PFN_DOWN(addr)与PFN_UP对应向下取整到当前页起始地址。这是最常用的几乎所有virt_to_phys()后的转换都走这里确保你拿到的是页首地址对应的PFN。PFN_PHYS(pfn)PFN转物理地址。这是pfn_to_page()、pfn_to_virt()等函数的基石。没有它你就无法从页帧号定位到实际的物理内存位置。PHYS_PFN(phys)物理地址转PFN。它是phys_to_page()、memblock_phys_mem_size()等函数的核心。注意phys必须是页对齐的否则结果无意义。PFN_ALIGN(addr)强制地址页对齐。它等价于((addr) PAGE_SIZE - 1) PAGE_MASK比PFN_UP更进一步直接给出对齐后的地址值而非PFN。常用于初始化mem_map时对BIOS传入的mem_start地址进行清洗。它们共同构成了一个封闭、无损、可逆的换算环PFN_DOWN(phys) → pfnPFN_PHYS(pfn) → physPFN_ALIGN(phys) → aligned_phys。任何试图绕过这个环的手动计算都是在给未来的调试埋雷。3. 深度解析每个宏的实现与原理3.1PFN_DOWN(addr)向下取整的数学本质与陷阱PFN_DOWN的定义非常简洁#define PFN_DOWN(x) ((x) PAGE_SHIFT)表面看就是右移但它的数学本质是floor(x / PAGE_SIZE)即向下取整。为什么这个操作如此重要因为内核中绝大多数“地址”都是虚拟地址或物理地址而struct page数组是以页为单位索引的。假设你有一个虚拟地址0xffff888000001234通过virt_to_phys()得到物理地址0x0000000000001234此时你不能直接用0x0000000000001234去查mem_map因为mem_map[0x0000000000001234]显然是越界的。你必须先把它“归一化”到页首0x0000000000001000然后PFN_DOWN(0x0000000000001000) 0x1这才对应mem_map[1]。这就是PFN_DOWN的核心价值它把一个连续的地址空间离散化为一个个页单元让数组索引成为可能。但这里有个经典陷阱addr必须是无符号类型。如果addr是signed long而值为负数比如-1在某些架构上是算术右移高位补1结果完全错误。内核中所有地址类型phys_addr_t,unsigned long都明确定义为无符号就是为了规避这个问题。实操中我曾在一个自定义的DMA映射函数里错误地将int offset直接传给PFN_DOWN导致在32位系统上当offset 0x7fffffff时offset被解释为负数PFN_DOWN结果全乱。修复方法很简单强制类型转换PFN_DOWN((unsigned long)addr)。这个教训告诉我看懂宏的定义只是第一步理解它对输入类型的隐含契约才是避免线上事故的关键。3.2PFN_UP(addr)向上取整的两种实现与性能差异PFN_UP的定义有两种常见变体一种是主流的#define PFN_UP(x) (((x) PAGE_SIZE - 1) PAGE_SHIFT)另一种是更“数学化”的#define PFN_UP(x) ((((x) - 1) | (PAGE_SIZE - 1)) 1) PAGE_SHIFT)第一种更易读第二种利用了位运算的特性| (PAGE_SIZE - 1)会把低PAGE_SHIFT位全置1再1就实现了进位理论上少一次加法。但在现代编译器优化下两者生成的汇编代码几乎一样GCC 12 会把第一种也优化成位运算序列。所以内核选择了第一种因为它语义清晰维护者一眼就能看出“加PAGE_SIZE-1再右移”就是在做向上取整。PFN_UP最典型的使用场景是计算页数。比如驱动需要为一个10KB的DMA缓冲区分配内存PAGE_SIZE4KB那么PFN_UP(10240) (10240 4095) 12 14335 12 3即需要3页。注意这里10240是字节数不是地址。很多新手会混淆PFN_UP的输入可以是字节数也可以是地址只要单位是字节即可。但务必记住PFN_UP的结果是页数或PFN不是字节数。我见过有人写char *buf dma_alloc_coherent(...); size_t aligned_size PFN_UP(buf);这显然是错的buf是指针PFN_UP对它操作毫无意义。正确的做法是PFN_UP(size)。3.3PFN_PHYS(pfn)与PHYS_PFN(phys)双向映射的严格对称性这两个宏是互逆的定义如下#define PFN_PHYS(pfn) ((phys_addr_t)(pfn) PAGE_SHIFT) #define PHYS_PFN(phys) ((unsigned long)((phys) PAGE_SHIFT))它们的对称性是内存管理正确性的基石。例如pfn_to_page(pfn)的实现是static inline struct page *pfn_to_page(unsigned long pfn) { return mem_map pfn; }而page_to_pfn(page)是static inline unsigned long page_to_pfn(const struct page *page) { return page - mem_map; }这里mem_map是一个全局的struct page *数组其基地址mem_map对应的物理地址必须等于PFN_PHYS(mem_map_pfn)其中mem_map_pfn是mem_map所在页的页帧号。如果PFN_PHYS和PHYS_PFN不严格对称整个mem_map查找机制就会崩溃。我在某次移植一个新平台时因为PAGE_SHIFT定义错了写成了13而不是12导致PFN_PHYS(1)算出来是0x2000而不是0x1000mem_map[1]指向了错误的物理页系统启动后不久就发生NULL pointer dereference。这种错误极其隐蔽因为mem_map初始化本身是成功的问题只在后续的alloc_pages()调用中才暴露。所以验证PFN_PHYS和PHYS_PFN的对称性应该是新平台bringup的第一步测试。3.4PFN_ALIGN(addr)对齐操作的终极形态PFN_ALIGN的定义是#define PFN_ALIGN(addr) (((addr) PAGE_SIZE - 1) PAGE_MASK)PAGE_MASK是~(PAGE_SIZE - 1)即一个低PAGE_SHIFT位全为0的掩码。这个宏的作用是将任意地址addr对齐到下一个页边界并返回对齐后的地址值。它和PFN_UP的区别在于PFN_UP返回的是页数PFN而PFN_ALIGN返回的是对齐后的物理地址。例如addr 0x1234PAGE_SIZE 0x1000则PFN_ALIGN(0x1234) (0x1234 0xfff) 0xfffffffffffff000 0x2000。这个宏在memblock子系统中被重度使用。memblock是内核早期使用的内存分配器在buddy system初始化之前工作。它管理的内存块memblock_region的base字段必须是页对齐的否则memblock_add()会拒绝添加。所以在setup_arch()中BIOS传入的e820内存映射表里的addr都会先经过PFN_ALIGN(addr)清洗再交给memblock_add()。我曾经在一个嵌入式项目中因为忘记对memblock_reserve()的地址参数做PFN_ALIGN导致保留的内存区域起始地址不是页对齐的memblock内部的二分查找算法失效后续memblock_phys_mem_size()返回的总内存大小比实际小了几MB花了整整两天才定位到这个PFN_ALIGN的缺失。4. 实操全流程从源码定位到调试验证4.1 如何在源码中快速定位和理解这组宏当你第一次在某个驱动代码里看到PFN_UP(size)想立刻知道它在哪定义、怎么工作的最快的方法不是全局搜索而是利用内核的头文件包含关系。所有PFN宏都定义在include/asm-generic/memory_model.h中而这个文件又被include/linux/mm.h包含mm.h又是几乎所有内存相关代码的标配头文件。所以标准路径是打开你看到宏的源文件比如drivers/net/ethernet/intel/igb/igb_main.c找到#include linux/mm.h这一行在mm.h中搜索PFN_UP你会看到类似#include asm/memory.h的行进入arch/x86/include/asm/memory.h或你的目标架构它会#include asm-generic/memory_model.h最终在include/asm-generic/memory_model.h中找到所有宏的定义。这个过程看似繁琐但一旦熟悉10秒内就能定位。更重要的是不要只看定义要看它的使用上下文。比如在igb_main.c的igb_setup_tx_resources()函数中size sizeof(struct igb_tx_buffer) * tx_ring-count; size PAGE_ALIGN(size); tx_ring-size size; tx_ring-desc dma_alloc_coherent(pdev-dev, size, tx_ring-dma, GFP_KERNEL); ... /* 计算描述符环的页数 */ tx_ring-pages PFN_UP(size);这里size已经是页对齐的PAGE_ALIGN但PFN_UP(size)依然必要因为dma_alloc_coherent返回的是一块连续的DMA内存而驱动需要知道这块内存占用了多少个页帧以便后续做cache一致性操作如dma_sync_single_for_device。这个例子完美展示了PFN_UP的真实用途它不是为了“对齐”而是为了“计数”。4.2 编写一个最小可验证测试MVT来验证宏行为纸上谈兵不如亲手验证。下面是一个可以在内核模块中运行的最小测试用来验证所有五个宏的正确性#include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/mm.h static int __init pfn_test_init(void) { unsigned long addr 0x1234; unsigned long pfn, phys; unsigned long aligned_addr; printk(KERN_INFO PFN Macro Test \n); printk(KERN_INFO Original addr: 0x%lx\n, addr); // Test PFN_DOWN pfn PFN_DOWN(addr); printk(KERN_INFO PFN_DOWN(0x%lx) %lu (should be %lu)\n, addr, pfn, addr PAGE_SHIFT); // Test PFN_UP pfn PFN_UP(addr); printk(KERN_INFO PFN_UP(0x%lx) %lu (should be %lu)\n, addr, pfn, (addr PAGE_SIZE - 1) PAGE_SHIFT); // Test PFN_PHYS and PHYS_PFN symmetry phys PFN_PHYS(pfn); unsigned long pfn2 PHYS_PFN(phys); printk(KERN_INFO PFN_PHYS(%lu) 0x%lx\n, pfn, phys); printk(KERN_INFO PHYS_PFN(0x%lx) %lu (symmetry check: %s)\n, phys, pfn2, (pfn pfn2) ? PASS : FAIL); // Test PFN_ALIGN aligned_addr PFN_ALIGN(addr); printk(KERN_INFO PFN_ALIGN(0x%lx) 0x%lx (should be 0x%lx)\n, addr, aligned_addr, (addr PAGE_SIZE - 1) PAGE_MASK); return 0; } static void __exit pfn_test_exit(void) { printk(KERN_INFO PFN Macro Test unloaded.\n); } module_init(pfn_test_init); module_exit(pfn_test_exit); MODULE_LICENSE(GPL);编译并加载这个模块insmod pfn_test.kodmesg输出会清晰显示每个宏的计算结果。你可以修改addr的值比如0x1000,0x1fff,0x2000观察PFN_DOWN和PFN_UP在边界上的行为。这个测试的价值在于它把抽象的宏定义变成了可触摸、可验证的具体数字。我每次接手一个新内核版本第一件事就是跑一遍这个测试确认PAGE_SHIFT是否被意外修改以及所有宏是否按预期工作。这比读文档可靠一万倍。4.3 调试实战一个由PFN计算错误引发的DMA超时案例去年我参与调试一个PCIe设备DMA超时的问题。现象是设备发起DMA读请求后迟迟收不到完成中断dmesg里不断刷DMA timeout on device X。初步排查DMA地址和长度看起来都没问题。最终我在设备驱动的prepare_desc()函数里发现了这个代码// 错误代码 dma_addr_t dma_addr dma_map_single(dev, buf, len, DMA_FROM_DEVICE); unsigned long pfn dma_addr PAGE_SHIFT; // 手动计算PFN // ... 后续用pfn做了一些cache操作问题就出在dma_addr PAGE_SHIFT这一行。dma_map_single返回的dma_addr是设备视角的总线地址它不一定等于物理地址在有IOMMU的系统上比如Intel VT-ddma_addr是IOMMU页表映射后的IOVA地址而 PAGE_SHIFT得到的只是一个毫无意义的数字。正确的做法是如果确实需要PFN应该先用phys_to_dma()或dma_to_phys()如果平台支持转换回物理地址再用PHYS_PFN()。但更合理的做法是完全避免手动计算PFN直接使用内核提供的dma_sync_single_for_device()等API它们内部已经处理了所有地址转换。这个案例深刻说明PFN_*宏只适用于物理地址到PFN的转换绝不适用于DMA地址。把它们用在错误的地址类型上是内核调试中最常见的逻辑错误之一。我的经验是只要看到代码里有 PAGE_SHIFT或 PAGE_SHIFT的手动位运算就要打一个大大的问号立刻检查操作数的类型和语义。5. 常见问题与独家避坑指南5.1 “为什么我的PFN计算结果和pfn_to_page()不一致”——mem_map偏移的隐秘真相这是一个高频问题。新手常写phys_addr_t phys virt_to_phys(ptr); unsigned long pfn PHYS_PFN(phys); struct page *page pfn_to_page(pfn); // 但page-flags是乱码或者page NULL原因几乎总是mem_map数组的基地址并不对应物理地址0。mem_map是内核在初始化时从可用内存中分配出来的一块连续内存用来存放所有struct page。它的起始地址mem_map对应的物理地址是PFN_PHYS(mem_map_pfn)而mem_map_pfn通常远大于0。所以pfn_to_page(pfn)的公式是mem_map (pfn - mem_map_pfn)而不是简单的mem_map pfn。内核通过page_to_pfn和pfn_to_page的宏定义已经把这个偏移封装好了你不需要关心。但如果你手动计算就必须考虑这个偏移。验证方法在init/main.c的start_kernel()开头加一句printk(mem_map_pfn %lu\n, page_to_pfn(mem_map));你就能看到mem_map的起始PFN。然后任何你计算出的pfn都必须大于等于这个值pfn_to_page(pfn)才有意义。低于这个值的pfn指向的是内核代码段或数据段struct page结构体根本不存在。5.2 “PFN_UP(0)返回0但我想让它返回1”——零值边界的特殊处理PFN_UP(0)的结果是0因为(0 PAGE_SIZE - 1) PAGE_SHIFT (PAGE_SIZE - 1) PAGE_SHIFT 0。这符合数学定义0向上取整还是0但在某些业务逻辑里你可能期望“哪怕大小是0也要分配1页”。这时不要修改PFN_UP宏而是在调用处加判断size_t pages (size 0) ? 1 : PFN_UP(size);强行修改内核宏是危险的会破坏所有其他依赖它的代码。我见过有人为了图省事定义了一个MY_PFNU宏结果在某个子系统里MY_PFNU(0)返回1导致memblock试图分配1页内存但实际size0造成了内存泄漏。记住内核宏是基础设施你的业务逻辑应该适配它而不是改造它。5.3 架构差异陷阱ARM64的PAGE_SHIFT可能是21这是最容易被忽略的坑。x86_64 默认PAGE_SIZE4KPAGE_SHIFT12。但ARM64 支持4K、16K、64K页甚至2MB大页PAGE_SHIFT21。如果你的代码硬编码了 12在ARM64上就会彻底错误。内核提供了PAGE_SHIFT这个宏就是为了让你永远不要写死数字。我曾经在一个跨平台驱动里为了“性能”把PFN_PHYS(pfn)替换成了pfn 12结果在ARM64平台上所有DMA地址都偏移了设备根本无法通信。修复就是一句话pfn PAGE_SHIFT。这个教训让我养成了一个习惯在任何涉及页大小的计算中第一个动作就是grep -r PAGE_SHIFT include/asm-generic/确认当前配置。5.4 内存模型的影响SPARSEMEM vs FLATMEM最后PFN_*宏的行为还受内存模型影响。在FLATMEM模型下mem_map是一个巨大的连续数组pfn_to_page(pfn)就是简单的mem_map pfn。但在SPARSEMEM模型下现代服务器和大部分ARM平台默认内存被划分为多个sectionmem_map是分散的pfn_to_page(pfn)需要先通过pfn_to_section(pfn)找到对应的section再在该section的mem_map中索引。PFN_*宏本身不受影响但pfn_to_page()的实现变得复杂。这意味着如果你在SPARSEMEM平台上手动计算mem_map pfn结果一定是错的。解决方案只有一个永远使用pfn_to_page()和page_to_pfn()这些标准API不要自己算。这些API内部已经根据当前内存模型做了适配。提示可以通过CONFIG_SPARSEMEMy或CONFIG_FLATMEMy在.config中查看当前内存模型。cat /proc/kcore的大小也能间接反映SPARSEMEM下kcore会包含大量hole而FLATMEM下是连续的。注意PFN_*宏是通用的但它们的“下游消费者”如pfn_to_page的行为高度依赖于内存模型和架构配置。理解这一点是写出可移植内核代码的前提。6. 进阶思考这组宏在现代内核中的演进与替代方案6.1memmap的动态化与PFN的“去中心化”随着CONFIG_MEMORY_HOTPLUG和CONFIG_SPARSEMEM_VMEMMAP的普及mem_map数组本身已经不再是静态分配的。VMEMMAP方案将struct page数组映射到一个虚拟地址空间其物理存储可以是分散的。这意味着pfn_to_page(pfn)不再是简单的地址加法而是一次虚拟地址转换。但PFN_*宏的地位丝毫没有动摇因为它们处理的仍然是最底层的地址换算物理地址 ↔ 页帧号。VMEMMAP只是改变了page结构体的存储位置没有改变pfn作为页索引的本质。所以PFN_PHYS和PHYS_PFN这对基石依然是不可替代的。6.2ZONE_DEVICE与devm_memremap_pagesPFN的新疆域在ZONE_DEVICE设备内存场景下PFN的概念被扩展了。devm_memremap_pages()允许将设备内存如NVDIMM、GPU显存注册为内核内存它会为这些设备内存分配struct page并赋予它们特殊的pfn。这些pfn不再对应传统的DRAM页帧而是设备地址空间的映射。PFN_*宏在这里依然工作但pfn_to_page(pfn)返回的page的page-pgmap字段会被设置为指向设备的struct dev_pagemap。这说明PFN已经从单纯的“物理内存页编号”演变为一个更广义的“内存资源索引”。PFN_*宏的接口没变但其承载的语义已经更加丰富。6.3 用户空间的启示libpfm与perf中的类比虽然PFN_*是内核专属但其设计哲学在用户空间也有体现。perf工具在解析perf.data文件时需要将采样得到的ip指令指针地址映射到具体的二进制文件偏移。它内部也有一套类似的“地址 ↔ 偏移”换算逻辑只不过单位是“字节”而非“页”。libpfm库中也有pfm_get_num_events()这样的函数其命名风格和PFN_*如出一辙全大写、下划线分隔、直指核心功能。这印证了一个普适原则在系统编程中最底层、最频繁的操作往往拥有最简洁、最机械的命名和实现。它们不追求优雅只追求正确、快速、无歧义。我个人在实际开发中发现真正掌握这组宏不是靠死记硬背而是靠“肌肉记忆”。每当看到一个地址我的第一反应就是“它需要被对齐吗需要转成PFN吗还是需要从PFN转回来”这种条件反射是在无数次调试page fault、DMA error和memory corruption中练出来的。现在我已经很少需要去查include/asm-generic/memory_model.h了因为PFN_DOWN和PFN_UP的行为已经像和-一样成为我思维的一部分。如果你也正走在内核开发的路上我的建议是从今天开始把你代码里所有手动的 12和 12全部替换成PFN_PHYS和PHYS_PFN。这个替换过程就是你和内核内存管理哲学握手言和的开始。
阅读完成 · 觉得有帮助?