物理内存的地图:node、zone、page frame 和 struct page

物理内存的地图: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 pagepfn_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_mapFLATMEMSPARSEMEM 等名字,细节不同,但核心思路一致:内核维护一套"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 和页面回收入口如何决定"一页物理内存能不能现在分给你"。

相关推荐
阿昭L9 小时前
操作系统复习(九)
操作系统
阿昭L9 小时前
操作系统复习(七)
操作系统
小宇子2B1 天前
虚拟地址不是内存:Linux 如何切开一个进程的地址空间
操作系统
饼干哥哥3 天前
ChatGPT会员掉了,代充黑幕藏不住了
人工智能·操作系统·产品
小宇子2B3 天前
五、内核里的 GS / swapgs,与现代 TSS
操作系统
小宇子2B4 天前
四、x86-64 的简化:段机制基本退场,FS/GS 为什么留下
操作系统
小宇子2B4 天前
二、保护模式的段:选择子、GDT,与那张 64 位的段描述符
操作系统
小宇子2B7 天前
三、内核入口 el0_svc / entry_SYSCALL_64 的汇编做了什么——从异常向量到 C 函数
操作系统
小宇子2B8 天前
四、从 write(1, "hello", 5) 到 ksys_write() —— sys_call_table 怎么路由的
操作系统