物理内存的地图:node、zone、page frame 和 struct page
上一篇讲的是进程眼里的地址空间:用户程序拿到的是虚拟地址,/proc/self/maps 看到的是 VMA,页表负责把已经兑现的虚拟页翻译到物理页框。
这篇把视角翻到另一边:当页表真的需要指向一页物理内存时,Linux 到底从哪里拿出这一页?
直觉上,物理内存像是一整条连续 RAM。但 Linux 并不会把它当成"一大坨内存"直接管理。真实结构更像一张分层地图:
rust
机器
│
├─ NUMA node 0
│ ├─ ZONE_DMA
│ ├─ ZONE_DMA32
│ └─ ZONE_NORMAL
│
└─ NUMA node 1
├─ ZONE_DMA32
└─ ZONE_NORMAL
每个 zone 里再按页框管理:
page frame 0 -> struct page
page frame 1 -> struct page
page frame 2 -> struct page
...
理解这张地图,后面看 buddy、页面回收、slab、缺页分配才不会把层次混在一起。
一、物理内存先按 NUMA node 分组
在单路、内存结构简单的机器上,你可以暂时把物理内存想成一个整体。但在多路服务器上,CPU 和内存不是全等距离的:某些 CPU 访问某些内存更近,访问另一些内存更远。这就是 NUMA。
Linux 用 NUMA node 表示一组距离更近的 CPU 和内存。
markdown
CPU0 / CPU1 ── 近 ── 内存 node 0
│
└────── 远 ────── 内存 node 1
CPU2 / CPU3 ── 近 ── 内存 node 1
│
└────── 远 ────── 内存 node 0
内存分配时,内核会尽量从当前 CPU 本地 node 分配页,这样缓存局部性和访问延迟都更好。如果本地 node 压力大,再根据策略去远端 node 找。
普通笔记本、虚拟机或小型云主机常常只有一个 node。即便如此,内核内部仍然沿用 node 这一层抽象,只是你看到的通常是 Node 0。
可以用这些命令观察:
bash
ls /sys/devices/system/node/
cat /sys/devices/system/node/online
如果只有 node0,说明这台机器在 Linux 看来只有一个在线 NUMA node。
node 在内核里的静态结构
Linux 内核里描述一个内存 node 的核心结构通常叫 pg_data_t,它是 struct pglist_data 的 typedef。可以把它理解成:
text
一个 NUMA node 的物理内存管理总入口。
它不是"某段内存内容本身",而是管理这个 node 上 zone、页统计、回收线程和 PFN 范围的结构。按 Linux 6.12 的实现抽象,可以画成:
text
pg_data_t / struct pglist_data
│
├─ node_id
│ └─ 这个 node 的编号,例如 0、1、2
│
├─ node_start_pfn
│ └─ 这个 node 覆盖的起始 PFN
│
├─ node_spanned_pages
│ └─ 从 start_pfn 到 end_pfn 跨过的页数,包含内存洞
│
├─ node_present_pages
│ └─ 这个 node 里实际存在的物理页数,不包含内存洞
│
├─ nr_zones
│ └─ 这个 node 里实际 populated 的 zone 数量
│
├─ node_zones[MAX_NR_ZONES]
│ ├─ ZONE_DMA
│ ├─ ZONE_DMA32
│ ├─ ZONE_NORMAL
│ └─ ZONE_MOVABLE ...
│
├─ node_zonelists[MAX_ZONELISTS]
│ └─ 分配页时按优先级扫描的 zone 列表
│
├─ kswapd / kswapd_wait / kswapd_order
│ └─ 这个 node 的后台页面回收线程和等待队列
│
└─ node_size_lock / reclaim_wait / compaction 相关字段 ...
其中最重要的是 node_zones[]。前面说"node 里面再切 zone",落到结构上就是:一个 pg_data_t 里直接嵌着这个 node 的一组 struct zone。
text
Node 0 的 pg_data_t
│
├─ node_zones[ZONE_DMA]
├─ node_zones[ZONE_DMA32]
├─ node_zones[ZONE_NORMAL]
└─ node_zones[ZONE_MOVABLE]
不是所有槽位都一定有真实内存。比如某个架构没有 ZONE_DMA32,或者某个 node 的 ZONE_MOVABLE 没有页,那么对应 zone 可以存在于数组里,但不是 populated zone。nr_zones 和 zone 自己的页数统计会告诉内核哪些 zone 真正可用。
另一个重要字段是 node_zonelists[]。它不是"这个 node 自己有哪些 zone"的简单重复,而是分配器用来按顺序尝试 zone 的扫描表。内核分配页时会结合 GFP 标志、NUMA 策略和 zonelist,决定先从本地 node 的哪些 zone 找,失败后是否 fallback 到其他 node。
可以把 pg_data_t 和前面的抽象图对上:
text
NODE_DATA(0)
│
▼
pg_data_t for node 0
├─ PFN 范围:node_start_pfn + node_spanned_pages
├─ 真实页数:node_present_pages
├─ zones:node_zones[]
└─ 回收:kswapd
NODE_DATA(1)
│
▼
pg_data_t for node 1
├─ PFN 范围
├─ zones
└─ kswapd
内核代码里经常用 NODE_DATA(nid) 从 node id 找到对应的 pg_data_t *:
text
node id -> NODE_DATA(nid) -> pg_data_t -> node_zones[] -> struct zone
在非 NUMA 配置下,系统也通常会有一个全局的 contig_page_data 来表示单 node 的物理内存管理入口。所以即使机器只有一个 node0,这层结构仍然存在,只是没有多个 node 之间的距离和 fallback 问题。
二、node 里面再切 zone
有了 node 还不够。即使在同一个 node 里,物理内存也不是所有页都能被所有用途随便使用。历史硬件限制、设备 DMA 能访问的地址范围、内核迁移页面的需要,都会让 Linux 把物理内存继续切成 zone。
常见 zone 有这些:
ZONE_DMA:给只能访问低地址内存的老式 DMA 设备使用。ZONE_DMA32:给只能访问 32 位地址范围内内存的设备使用,常见于 x86-64。ZONE_NORMAL:普通内核分配主要使用的区域。ZONE_MOVABLE:尽量放可迁移页面,便于内存热插拔和减少碎片。ZONE_HIGHMEM:32 位系统上常见,表示内核不能永久直接映射的高端内存;64 位系统通常不需要这个 zone。
不同架构、不同配置下 zone 名字和数量会不同。不要把某台 x86-64 机器上的 zone 列表当成 Linux 的统一模板。关键是理解它的职责:
zone 是 Linux 进行物理页分配和回收时最核心的区域单位。
抽象图如下:
css
NUMA node 0
┌───────────────────────────────────────────┐
│ ZONE_DMA │ ZONE_DMA32 │ ZONE_NORMAL │
└───────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
page[] page[] page[]
分配一页物理内存时,内核不是直接问"系统还有没有空闲 RAM",而是先根据分配约束选 node 和 zone,再在目标 zone 里找空闲页。
三、物理内存按 page frame 切块
Linux 管理物理内存的基本单位是页框,英文通常叫 page frame。在常见 4KB 页大小的机器上,一个页框就是 4KB 物理内存。也有 16KB、64KB 页大小的架构配置,不能把 4KB 写死成所有 Linux 的事实。
每个物理页框都有一个编号:PFN,page frame number。
如果页大小是 4KB,那么物理地址和 PFN 的关系可以简单理解成:
text
PFN = 物理地址 / 4096
物理地址 = PFN * 4096 + 页内偏移
更一般地说:
text
PFN = 物理地址 >> PAGE_SHIFT
页内偏移 = 物理地址 & (PAGE_SIZE - 1)
PAGE_SIZE 是一页的字节数,PAGE_SHIFT 是页大小对应的位移量,二者关系是 PAGE_SIZE = 1 << PAGE_SHIFT。例如 4KB 页时,PAGE_SIZE = 4096,也就是 2^12,所以 PAGE_SHIFT = 12。右移 12 位就等价于除以 4096,低 12 位就是页内偏移。
这和虚拟地址拆分页号、页内偏移是同一类思想,只是发生在物理地址侧。
物理地址
┌──────────────────────┬────────────┐
│ PFN │ 页内偏移 │
└──────────────────────┴────────────┘
PFN n 表示第 n 个物理页框
页表项里真正指向物理内存的核心信息,本质上就是 PFN 加上一组权限和状态位。CPU 翻译虚拟地址时,先用虚拟页号查到页表项,再把页表项里的 PFN 和原地址的页内偏移拼起来,得到最终物理地址。
四、struct page:物理页在内核里的身份证
物理页框只是硬件层面的连续字节。内核还需要知道每个页框现在处于什么状态:
- 是否空闲。
- 是否属于某个进程的匿名页。
- 是否属于 page cache,也就是内核缓存的文件内容。
- 是否是 slab 里的对象页。
- 引用计数是多少。
- 属于哪个 zone。
- 是否在 LRU 链表上。
- 是否 dirty、writeback、locked。
这些元数据不能放在页框内容里,因为页框内容要留给用户数据、文件缓存或内核对象。Linux 为每个物理页框维护一个 struct page,用它描述这个物理页的身份和状态。
arduino
PFN n
│
▼
struct page
├─ flags
├─ _refcount
├─ mapping
├─ index / private
└─ lru / slab / buddy 相关字段
│
▼
物理页框 n:真正的 4KB 数据
struct page 不是"页里的数据",而是"内核放在别处的一份描述符"。一台机器有多少可管理物理页,内核就要为它们准备相应数量的 struct page 元数据。这也是为什么物理内存越大,内核管理内存本身也会消耗更多内存。
这里先不展开 struct page 的字段复用。它在不同状态下会用同一块字段表达不同含义:作为 page cache、匿名页、slab page、buddy 空闲页时,关注点都不同。现在只要先把它当成物理页的身份证。
五、PFN、物理地址、struct page 之间怎么互相找到
这三者的关系可以这样串起来:
scss
物理地址
│
│ 去掉页内偏移
▼
PFN
│
│ pfn_to_page()
▼
struct page
│
│ page_to_pfn()
▼
PFN
│
│ 左移 PAGE_SHIFT
▼
物理页框起始地址
在内核代码里,经常会在这些表示之间转换:
- 有 PFN,找
struct page:pfn_to_page(pfn)。 - 有
struct page,找 PFN:page_to_pfn(page)。 - 有
struct page,想临时访问它的内容:需要映射到内核虚拟地址,常见路径之一是 direct map。
那它们为什么能互相找到?
可以先把 struct page 想成一个大数组:每个可管理物理页框都有一个对应的数组元素。PFN 像数组下标,struct page * 像指向某个元素的指针。
rust
vmemmap / mem_map
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ struct page │ struct page │ struct page │ struct page │
│ for PFN 0 │ for PFN 1 │ for PFN 2 │ for PFN 3 │
└──────────────┴──────────────┴──────────────┴──────────────┘
▲ ▲ ▲ ▲
│ │ │ │
PFN 0 PFN 1 PFN 2 PFN 3
在这种模型下,转换本质上就是数组寻址:
text
pfn_to_page(pfn) ≈ &vmemmap[pfn]
page_to_pfn(page) ≈ page - vmemmap
现代 64 位 Linux 常用 SPARSEMEM_VMEMMAP 模型:内核预留一段虚拟地址空间叫 vmemmap,专门用来线性映射这些 struct page 元数据。这样即使物理内存本身可能有洞、不连续,struct page 描述符在内核虚拟地址里仍然可以按 PFN 做接近数组下标的换算。
这个"数组起始地址"不放在每个 struct page 里,而是内核全局知道。代码层面通常表现为一个全局符号或架构定义的固定虚拟地址基址:
text
vmemmap ──► struct page 元数据区域的起始虚拟地址
pfn_to_page(pfn)
= vmemmap + pfn
也就是说,vmemmap 本身是内核地址空间里的一个基准点。启动阶段,内核会为 vmemmap 这段虚拟地址建立页表映射,让它指向真正存放 struct page 数组的物理页。之后 pfn_to_page() 只需要拿这个基准点加上下标。
一次系统启动完成后,这个基准点在内核虚拟地址空间里通常就是稳定的:内核代码可以一直用同一个 vmemmap 虚拟地址范围访问 struct page 元数据。但它不是用户地址,也不是物理地址;它属于内核虚拟地址空间中的 vmemmap 区域。
也不要把它理解成跨机器、跨启动都固定的绝对数值。具体地址由架构和内核配置决定,还可能受 KASLR 影响;内存热插拔时,内核也可能为新增 PFN 对应的 struct page 元数据补上映射。但对已经在线的物理页来说,pfn_to_page() 看到的是一个稳定的内核虚拟地址索引。
vmemmap 和后面要讲的 direct map 也不是一回事:
text
direct map:映射真实物理页内容,方便内核读写页里的数据
vmemmap :映射 struct page 元数据,方便内核管理每个物理页
老内核或不同配置下还可能看到 mem_map、FLATMEM、SPARSEMEM 等名字,细节不同,但核心思路一致:内核维护一套"PFN 到 struct page 描述符"的元数据索引,所以 pfn_to_page() 和 page_to_pfn() 通常不需要遍历查找,而是常数时间计算。
用户态通常拿不到这些东西。用户态的指针是虚拟地址;PFN 和 struct page 是内核管理物理内存的视角。
六、direct map:内核访问大部分物理内存的捷径
先说为什么需要 direct map。
开启分页以后,CPU 访问内存时用的都是虚拟地址。内核当然知道很多物理页的位置,比如某个页框的 PFN、某段物理 RAM 的起始地址,但它不能把"物理地址"直接当成 C 指针解引用。内核如果要读写某个物理页里的内容,也必须先有一个能翻译到这个物理页的内核虚拟地址。
内核又经常需要访问物理页内容:
- 缺页时刚分配了一页,要把它清零。
- 从磁盘读入文件页,要把数据放进 page cache 对应的物理页。
- 做页面回收、写回、迁移时,要检查或搬运页里的数据。
- 内核拿到一个
struct page后,需要访问这个页框里的真实字节。
如果每次访问物理页都临时建一段映射,成本太高。于是 Linux 在启动阶段就做了一件事:提前把大部分普通 RAM 映射到一段固定的内核虚拟地址区域 。这段区域就是 direct map。
它解决的问题可以一句话概括:
内核在分页开启后,需要用虚拟地址访问物理内存;direct map 就是内核为普通 RAM 预先建立的一段长期线性映射。
direct map 怎么建立
抽象上可以理解成:
物理地址 0x0000_0000 ◄──页表── 内核虚拟地址 DIRECT_MAP_BASE + 0x0000_0000
物理地址 0x0000_1000 ◄──页表── 内核虚拟地址 DIRECT_MAP_BASE + 0x0000_1000
物理地址 0x0000_2000 ◄──页表── 内核虚拟地址 DIRECT_MAP_BASE + 0x0000_2000
启动时,内核会从固件拿到物理内存布局,知道哪些物理地址范围是 RAM。然后它在内核页表里为 direct map 区域填入映射:DIRECT_MAP_BASE + phys 这个虚拟地址,映射到 phys 对应的物理页框。
假设有一段物理 RAM:
text
物理 RAM:0x0010_0000 - 0x0020_0000
内核会在自己的页表里建立类似这样的映射:
text
虚拟地址 DIRECT_MAP_BASE + 0x0010_0000 -> 物理地址 0x0010_0000
虚拟地址 DIRECT_MAP_BASE + 0x0010_1000 -> 物理地址 0x0010_1000
虚拟地址 DIRECT_MAP_BASE + 0x0010_2000 -> 物理地址 0x0010_2000
...
实际内核通常不会一律用 4KB 页表项逐页填完。direct map 覆盖的是大段连续物理内存,能用大页时会尽量用 PMD/PUD 级别的大页映射,减少页表占用和 TLB 压力。遇到内存洞、权限特殊的区域、调试特性或后续内存热插拔时,再拆成更细的页表映射。
注意,"direct" 不是说绕过页表。CPU 访问 direct map 地址时仍然查页表。它只是说这段虚拟地址和物理地址之间有直接、规则的线性关系。
direct map 地址什么时候出现
direct_map_addr 不是内核提前为每个物理页保存好的字段。启动时保存下来的是映射关系:direct map 这段虚拟地址范围已经在页表里映射好了。
运行时如果内核拿到一个物理地址、PFN 或 struct page,才按公式算出对应的 direct map 虚拟地址:
text
direct_map_addr = DIRECT_MAP_BASE + phys_addr
phys_addr = direct_map_addr - DIRECT_MAP_BASE
这里的 DIRECT_MAP_BASE 是 direct map 区域在内核虚拟地址空间里的起始基址。它和前面讲的 vmemmap 类似,都是内核虚拟地址空间中的一段稳定区域;区别是 DIRECT_MAP_BASE 后面映射的是真实物理页内容,vmemmap 后面映射的是 struct page 元数据。
一次系统启动完成后,DIRECT_MAP_BASE 对内核来说通常是稳定的。但具体数值不是跨所有 Linux 固定的常量,x86-64、arm64、不同页表级数、KASLR、内核配置都会影响实际布局。这里重要的是这个关系:内核虚拟地址 = direct map 基址 + 物理地址偏移。
内核代码里常见的 phys_to_virt()、virt_to_phys(),在能走 direct map 的普通 RAM 上,做的就是这类换算。
direct map 什么时候用
这个地址是内核地址,用户态拿不到,也不能直接访问。它主要在内核需要读写"页里的数据"时使用。
常见路径可以近似理解成:
text
struct page *page
│
├─ page_to_pfn(page)
├─ phys = PFN << PAGE_SHIFT
└─ addr = phys_to_virt(phys) // 得到 direct map 里的内核虚拟地址
有些内核代码也会通过 page_address(page) 这类接口拿到可直接解引用的内核虚拟地址。对普通 RAM 来说,这个地址通常就在 direct map 里。
于是有了这条链:
arduino
PFN n
│
├─ pfn_to_page()
│ └─ 找到 struct page
│
└─ 物理地址 n << PAGE_SHIFT
│
└─ direct map
└─ 内核可访问的虚拟地址
direct map 的意义是:内核不需要"使用物理地址指针"访问内存。CPU 仍然使用虚拟地址,只是这段内核虚拟地址和物理地址之间有一套直接、规则的映射。
这也解释了上一篇的高地址内核空间为什么重要:内核运行时需要在自己的虚拟地址空间里放下代码、数据、vmalloc 区、模块区,也要放下能访问物理页的 direct map。
七、分配一页时,内核大概走哪条路
先看一个简化流程:
markdown
内核需要 1 个物理页
│
▼
根据分配标志和 NUMA 策略选择 node
│
▼
根据用途选择允许的 zone
│
▼
检查 zone watermark
│
├─ 水位足够
│ └─ 从 buddy 分配页框
│
└─ 水位不足
├─ 唤醒 kswapd 后台回收
└─ 必要时进入 direct reclaim
这里先把 buddy 当成 zone 内部的空闲页管理器。下一篇会专门拆它:free_area[order] 怎么保存连续页块,为什么高阶连续页更容易因为碎片分配失败,水位线怎样控制回收时机。
这一篇只强调层级:
scss
alloc_pages()
│
▼
选择 node
│
▼
选择 zone
│
▼
在 zone 内找 page frame
│
▼
返回 struct page / 建立映射
如果这页是给用户缺页用的,后续还会把它清零,然后填入进程页表;如果这页是给内核分配页表页、slab page 或 page cache 用的,路径又会接到不同子系统。但底层拿物理页的地图层次是同一套。
八、可以自己看的几个入口
看 node:
bash
ls /sys/devices/system/node/
cat /sys/devices/system/node/online
看 zone、水位线和每个 zone 的页统计:
bash
cat /proc/zoneinfo
看 buddy 当前各阶空闲块数量:
bash
cat /proc/buddyinfo
看不同迁移类型的页分布:
bash
cat /proc/pagetypeinfo
看物理地址资源布局:
bash
cat /proc/iomem
这些文件的输出会因机器差异很大。读它们时,不要先盯着某个数字背答案,而要按层级看:
css
Node 0
└─ Zone DMA / DMA32 / Normal / Movable
├─ managed pages
├─ free pages
├─ min / low / high watermark
└─ free_area by order
九、把这一篇收束成一张图
arduino
进程虚拟地址
│
│ 页表翻译
▼
PFN:物理页框号
│
├─ page_to_pfn / pfn_to_page
▼
struct page:内核描述这个物理页的元数据
│
▼
所属 zone:这个页从哪个物理内存区域来
│
▼
所属 node:这个页属于哪组 NUMA 内存
这一层讲清楚后,后面的 struct zone 就有位置了:它不是抽象名词,而是 Linux 物理页分配的核心管理单元。下一篇继续往 zone 里面走,看水位线、free_area、buddy 和页面回收入口如何决定"一页物理内存能不能现在分给你"。