Linux sparsemem/mem_section:从地址网格到 struct page 的完整心智模型 -- 笔记版

5 阅读7分钟

「Linux sparsemem/mem_section 深度笔记:从地址网格到 struct page 的完整心智模型」

以下是基于你提到的这些点,整理的一份Linux sparsemem/mem_section 笔记,并在需要的地方做了实现层面的纠正和补充。


1. sparsemem 与 mem_section 的角色

  • sparsemem 的目的:应对物理地址空间碎片化(大洞、多节点、热插拔等),不再假设物理内存连续。
  • mem_section 的定义
    • 固定大小的物理地址区间管理单元(例如 128MB、256MB、1GB,取决于架构和配置)。
    • 内核启动时按整个物理地址空间划分一个个 section:section0 管理 [0, SECTION_SIZE), section1 管理 [SECTION_SIZE, 2*SECTION_SIZE),以此类推。
    • 关键点:mem_section 管的是“覆盖的地址范围”,不保证这段范围内全部都是真实内存。

2. mem_section 与物理内存的关系

2.1 mem_section 不等于“整块内存”

  • 一个 mem_section 不要求被真实物理内存“填满”
  • 在一个 mem_section 覆盖的地址范围内,可能出现:
    • 完全是可用 RAM;
    • RAM + 内存空洞(设备 MMIO / 保留区域)混合;
    • 完全没有 RAM,仅仅是“洞”(此时整个 section 甚至可以不被标记为 present)。

结论:
mem_section 是“网格”;真实内存是“散落在网格里的土地”。网格可满可空可半满。

2.2 起始位置不必对齐到 section 开头

  • 一块真正插在主板上的内存块(比如一个 DIMM 条),在物理地址上的起始地址和长度完全由硬件+固件决定,和 mem_section 的边界无关。
  • 因此:
    • 一个 section 覆盖 [A, A+SECTION_SIZE)
    • 可用 RAM 可能只在其中的一段,例如 [A+64MB, A+128MB)
    • section 前半段可能是 MMIO/保留区域(内存空洞)。

结论:
“一块内存如果不足以填满一个 section,也不一定从这个 section 的第一个物理页开始”,它可以从 section 中间任意一个 pfn 开始,只要固件这么报告即可。


3. 内存热插拔与非 SECTION_SIZE 倍数

  • 固件(通常 ACPI)在热插拔内存时,报告的是一个任意起始地址 + 任意大小的内存区域,例如:
    • 起始地址:start
    • 大小:size
  • 内核处理流程(逻辑粒度):
    1. 根据 [start, start + size) 计算涉及到的 mem_section 下标集合
    2. 为这些 section 分配/初始化 struct mem_sectionsection_mem_map(如有必要)。
    3. 在这个地址区间内,按 页 (page) 粒度遍历:
      • 对每一个可用的 page(4KB 等),初始化对应的 struct page
      • 把这些 page 加入伙伴系统。

重要区分:

  • 管理粒度:mem_section(例如 128MB/256MB/1GB),便于批量标记 online/offline。
  • 操作粒度:page(例如 4KB),真正初始化和参与分配的是 page。

结论:
热插拔内存绝不要求是 SECTION_SIZE 的整数倍,只要是页粒度对齐即可,内核可以精确到每一页管理。


4. struct mem_section 里有没有 start_pfn?

4.1 概念层面 vs 实现层面

  • 概念层面,描述流程时常会说:
    “为 section 设置 start_pfn”
    实际含义是:“把这个 section 与它负责的起始物理页号关联起来”。

  • 实现层面:

    • struct mem_section 中并没有 start_pfn 这个字段。

    • 起始页帧号是通过 下标计算 获得的:

      • PAGES_PER_SECTION = SECTION_SIZE / PAGE_SIZE

      • i 个 section 的起始 pfn:

        start_pfn(i)=i×PAGES_PER_SECTION\text{start\_pfn}(i) = i \times PAGES\_PER\_SECTION
      • 给定一个 pfn,要找到它属于哪个 section:

        section_index=pfnPAGES_PER_SECTION=pfn>>PFN_SECTION_SHIFT\text{section\_index} = \frac{\text{pfn}}{\text{PAGES\_PER\_SECTION}} = \text{pfn} >> PFN\_SECTION\_SHIFT
    • 由于这种关系是固定可计算的,所以没必要在结构体里再存一个 start_pfn 字段。

总结:
“设置 start_pfn”是逻辑动作;实际代码是“通过 section 下标 + 常量计算 start_pfn”,不需要成员变量。


5. mem_section 的 present / section_mem_map

5.1 mem_sections 全局数组

  • 内核维护一个全局的 section 数组(逻辑上):
    struct mem_section *mem_sections[NR_MEM_SECTIONS];
    
  • 每个下标 i 对应一个覆盖固定地址范围的 section。

5.2 present 的“隐式”表示

  • 概念上,会说 mem_section[i].present = 1 表示这个 section 里有内存。
  • 实现上,通常通过:
    • mem_sections[i] 是否为 NULL
    • 或者在 section_mem_map 指针的低位上编码一些 bit; 来表示这个 section 是否 present / online

结论:
“present 字段”一般也是概念描述;实际使用的是指针是否为空、指针上的标志位等手段。

5.3 section_mem_map:指向 struct page 数组

  • struct mem_section 的核心字段是 section_mem_map
    • 指向一个 struct page 数组。
    • 数组长度覆盖整个 section 范围的所有页数:PAGES_PER_SECTION
  • 后续所有关于该 section 内 page 的信息(是否空洞、是否可用、属于哪个 zone/node 等)都通过这些 struct page 的字段来表达。

6. 内存空洞与 struct page 的分配

6.1 “空洞也会分配 page 吗?”

更精确的说法:

  • 对于已经标记为 present 的 section
    • 内核会为该 section 覆盖的整个地址范围分配一整块 struct page 数组。
    • 其中:
      • 位于真实 RAM 范围的那些页,对应的 struct page 会被初始化为 可用内存页
      • 位于内存空洞(MMIO/保留区域)范围的那些页,对应的 struct page 会被标记为 PG_reserved,不会加入伙伴系统。
  • 对于完全没有 RAM 的 section(整个 section 是洞):
    • 该 section 通常不会被标记为 present,也不会分配 struct mem_sectionsection_mem_map,自然也就没有 struct page

所以:

  • “空洞也会分配相应的 page” 这句话在上下文上,应理解为:
    “在一个已存在(present)的 section 内部,即便某些子范围是内存空洞,也会有对应的 struct page,但这些 page 会标记为 PG_reserved,不参与分配。”

6.2 这样做的核心理由:pfn_to_page / page_to_pfn 的极致效率

  • 核心需求:
    pfn_to_page(pfn)page_to_pfn(page) 必须非常快。

  • 在 sparsemem 下,pfn_to_page() 的典型计算逻辑:

    1. 计算 section 下标:
      section=pfn>>PFN_SECTION_SHIFT\text{section} = \text{pfn} >> PFN\_SECTION\_SHIFT
    2. 计算在 section 内的页偏移:
      offset=pfn&(PAGES_PER_SECTION1)\text{offset} = \text{pfn} \& (PAGES\_PER\_SECTION - 1)
    3. 找到该 section 的 section_mem_map
      mem_map = __nr_to_section(section)->section_mem_map;
      
    4. 返回对应页:
      page = &mem_map[offset];
      
  • 之所以能做到纯算术 + 数组下标访问,前提就是:

    • 对于一个 present 的 section,整个 PRFN 范围都有一个连续的 struct page 数组与其一一对应,无论是否洞。
  • 对于洞中的页,struct page 仍然存在,但通过 flags(如 PG_reserved)表示“不可用”。

这是一种典型的“空间换时间”设计:

  • 空间成本:
    每个 struct page 占几十字节,一个大 section 会用掉几百 KB 或几 MB 的内核内存。
  • 时间收益:
    换来 pfn/page 双向转换在内核核心路径上的极高性能,避免复杂查表和搜索逻辑。

7. SPARSEMEM_VMEMMAP vs 传统 SPARSEMEM(简要)

  • SPARSEMEM_VMEMMAP
    • 将所有 struct page 自身放进一个专门的虚拟地址空间(vmemmap)。
    • 对于“可能存在的所有 PFN”都有一条 struct page,洞则用 PG_reserved 等标识。
    • pfn 与 struct page* 之间几乎是一一映射(加偏移即可),更统一。
  • 传统 SPARSEMEM(非 vmemmap)
    • 每个 present 的 section 单独分配一块 struct page 数组。
    • Section 内部的洞也有对应 struct page,用于保持索引连续,但完全空的 section 不分配。

共同点:
只要 section 被认为是 present,其覆盖范围内的 PFN 一般都会有对应的 struct page 对象;“是否真实内存”则通过 struct page 的标志位区分。


8. 总结思维模型

可以用以下模型在脑子里“画图”:

  1. 第一层:mem_section 网格

    • 整个物理地址空间被均匀划分为一个个网格(mem_section)
    • 每个网格编号为 i,负责 [i * SECTION_SIZE, (i+1) * SECTION_SIZE)
  2. 第二层:section_mem_map 账本

    • 每个**存在内存的网格(present section)**拥有一本“账本”(section_mem_map),账本的每一页是一个 struct page,对应网格里的一个 PFN。
    • 账本是连续页码 0 ~ PAGES_PER_SECTION-1,可以通过简单算术找到某一页。
  3. 第三层:page 标志位

    • 真实内存页:账本该页记录为“可用”,会被加入伙伴系统。
    • 内存空洞页:账本该页记录为“保留/无效”(例如 PG_reserved),不会参与分配。
  4. 逻辑上的 start_pfn

    • 概念上:“为 section 设定 start_pfn”。
    • 实现上:通过 section 下标 * PAGES_PER_SECTION 计算,不存成字段。

这样,你之前那套叙述:

  • “mem_section 不需要是完整的 256M 内存”
  • “热插拔不要求是 SECTION_SIZE 的倍数”
  • “内存不一定从 section 起始 PFN 开始”
  • “空洞也会有对应 page(在 present section 内部)”
  • “start_pfn 是通过位置计算出来的,并不真有字段”

在逻辑层面都是自洽且与实现吻合的。只要加上一个小修正:
“完全空的 section 一般连 mem_section/section_mem_map 都不会分配,自然也没有 struct page”,空洞有 page 这件事是“存在于已经 present 的 section 内部”的语境下成立的。