5分钟搞懂ARM64的SPARSEMEM模型:从vmemmap到buddy系统的内存魔法
如果你刚接触Linux内核的内存管理,特别是ARM64架构,可能会被那些抽象的概念搞得晕头转向。SPARSEMEM、vmemmap、section、PFN……这些术语听起来像是某种神秘的魔法咒语。别担心,这其实是一套精巧的设计,为了让内核能够高效地管理海量且可能不连续的物理内存。想象一下,你有一个巨大的仓库(物理内存),里面堆满了各种大小的箱子(内存页)。你需要一个智能的库存管理系统,能快速找到任何一个箱子,知道它的位置、状态和归属。Linux内核的SPARSEMEM模型就是这样一个系统,而vmemmap则是它的核心“地址簿”。今天,我们就用生活化的比喻和可视化的思路,拆解这套“内存魔法”,让你在5分钟内建立起清晰的理解框架。
这篇文章主要面向嵌入式开发者、内核初学者,或者任何对Linux内存管理底层机制感兴趣的朋友。我们会避开枯燥的代码堆砌,聚焦于核心概念和设计逻辑,并通过与x86架构的对比,让你看清ARM64的独特之处。理解这套机制,不仅能帮你更好地调试内存相关的问题,也能让你在阅读内核源码时,不再对struct page和PFN的转换感到困惑。
1. 内存管理的核心挑战与SPARSEMEM的登场
在早期的计算机系统中,物理内存通常是连续的一块。内核管理起来相对简单,可以用一个巨大的struct page数组(称为mem_map)来一一对应所有的物理页帧。数组的索引(即下标)直接对应物理页帧号(PFN),要找到某个PFN对应的struct page,只需简单的数组访问:mem_map[PFN]。这种方式被称为FLATMEM模型。
然而,现代计算机系统,尤其是服务器和高端嵌入式平台,内存配置变得异常复杂:
- 物理地址空间不连续:由于PCIe BAR空间、预留内存、NUMA节点等因素,系统的物理内存可能存在多个“空洞”(Hole)。
- 内存热插拔:支持动态添加或移除内存条。
- 海量内存:动辄数百GB甚至TB级别的内存,如果为每一个可能的物理页帧(包括空洞)都分配一个
struct page结构体,将造成巨大的内存浪费。
这就好比你的仓库不是一整间,而是由多个分散的、大小不一的隔间组成,中间还有不少空置区域。如果还用一本按顺序编号的、包含所有可能位置(包括空置区)的厚账簿,那这本账簿本身就会占用大量空间,且大部分条目是无效的。
SPARSEMEM(稀疏内存模型)就是为了解决这个问题而生的。它的核心思想是:只为实际存在的物理内存区域分配struct page结构,并建立高效的索引机制来定位它们。这就像你只为仓库里实际有箱子的隔间建立库存卡片,并用一个高效的索引表来管理这些卡片盒的位置。
在SPARSEMEM模型中,引入了 section(节) 的概念。你可以把一个section想象成一个固定大小的内存管理单元。在ARM64(48位地址空间)的典型配置下,一个section管理1GB的物理地址空间。内核使用struct mem_section结构来描述一个section,其核心成员section_mem_map指向这个1GB空间内所有struct page构成的数组的地址。
那么,内核如何管理所有这些section呢?它使用了一个二级指针struct mem_section **mem_section。你可以把它理解为一个动态的、可伸缩的二维目录:
- 第一级目录(ROOT)管理一组
section。 - 第二级目录指向具体的
section描述符。 - 系统的物理内存有多大,就需要多少个
section,这个二维结构就相应地扩展。
这样,给定一个物理页帧号(PFN),SPARSEMEM模型通过三级分解来定位其struct page:
- ROOT索引:PFN的哪一部分属于哪个一级目录?
- Section索引:在该目录下,属于第几个
section? - Page偏移:在该
section内部的第几个页?
这个过程,与CPU的多级页表将虚拟地址转换为物理地址的思路异曲同工,都是通过分级索引来高效管理巨大且稀疏的地址空间。
提示:
FLATMEM模型就像一本连续的通讯录,而SPARSEMEM模型更像一个分地区、分楼层的公司电话分机表,只为有人的座位分配分机号。
2. vmemmap:虚拟内存映射的“魔法地址空间”
理解了section是管理单元,下一个问题就是:这些struct page数组存放在哪里?它们是如何被内核访问的?
这就是vmemmap的用武之地。vmemmap是内核虚拟地址空间中的一块特殊区域,专门用来线性映射所有的struct page结构。在ARM64架构中,这块区域的起始地址是VMEMMAP_START,大小通常为2TB。这是一个非常关键的设计:内核为所有物理页的元数据(struct page)预留了一块固定的、巨大的虚拟地址空间。
我们可以用一个简单的公式来理解vmemmap的核心指针:
#define vmemmap ((struct page *)VMEMMAP_START - (memstart_addr >> PAGE_SHIFT))
这里memstart_addr是系统物理内存的起始地址(例如0x8000_0000)。这个公式做了什么呢?它计算出一个基准的vmemmap指针,这个指针所指向的虚拟地址,对应着物理地址为0的那个struct page。即使物理内存并非从0开始,通过这个偏移,内核也在虚拟地址空间建立了一个从“虚拟物理地址0”开始的、连续的struct page视图。
有了这个基准点,PFN与struct page之间的转换就变得极其简单和快速:
#define __pfn_to_page(pfn) (vmemmap + (pfn))
#define __page_to_pfn(page) (unsigned long)((page) - vmemmap)
__pfn_to_page(pfn):要找到PFN对应的struct page,只需从vmemmap基准指针向前走pfn步(每一步是一个struct page的大小)。这本质上是一次指针加法,是**O(1)**复杂度的操作。__page_to_pfn(page):反之,给定一个struct page指针,减去vmemmap基准指针,就得到了它的PFN。这是一次指针减法。
为什么这种方式如此高效?
因为它完全规避了通过mem_section二维数组进行多次查表的过程。只要知道了vmemmap基准地址,任何PFN和struct page的转换都是一次算术运算。这就像你知道了一条街的起点门牌号(vmemmap),要找第N户(PFN),直接“起点 + N”即可,无需查阅分区地图。
那么,vmemmap区域的页表映射是如何建立的呢?这是在系统启动早期,由sparse_init()函数完成的。其核心函数调用链如下:
sparse_init()
-> sparse_init_nid() // 按NUMA节点初始化
-> __populate_section_memmap() // 为每个section的page数组申请“虚拟地址范围”
-> vmemmap_populate() // 为上述虚拟地址范围建立页表映射到物理内存
-> vmemmap_populate_basepages() // 实际填充各级页表条目(PTE)
在vmemmap_populate_basepages函数中,内核会为vmemmap区域的每一页虚拟地址,逐级分配并设置页表条目(PGD->P4D->PUD->PMD->PTE),最终将struct page的虚拟地址映射到实际存放struct page结构体的物理内存页上。这些物理内存页是通过早期的memblock分配器申请的。
| 关键概念 | 比喻 | 在ARM64 SPARSEMEM中的作用 |
|---|---|---|
| Section | 仓库的固定大小隔间(如1GB一个) | 物理内存的管理单元,一个section对应一个struct page数组。 |
| mem_section | 隔间的管理卡片 | 描述一个section,记录其struct page数组的地址。 |
| vmemmap区域 | 整个仓库的中央地址簿墙 | 一段2TB的虚拟地址空间,专门线性映射所有struct page。 |
| vmemmap指针 | 地址簿的“0号位”基准线 | 指向虚拟地址中对应物理地址0的struct page位置。 |
| PFN -> Page转换 | 根据货物编号计算地址簿页码 | vmemmap + pfn,一次加法获得struct page地址。 |
| Page -> PFN转换 | 根据地址簿页码反推货物编号 | page - vmemmap,一次减法获得物理页帧号。 |
3. 启动流程揭秘:从物理内存探测到buddy系统就绪
现在,让我们跟随内核启动的脚步,看看这套机制是如何一步步搭建起来的。这个过程就像搭建一个仓库管理系统:
-
物理内存探测 (
memblock):内核启动最早阶段,通过设备树或ACPI,获取物理内存的布局信息,哪里是可用内存,哪里是预留空间或空洞。这些信息记录在memblock分配器中。此时,还没有struct page,也没有buddy系统。 -
确定内存模型并初始化vmemmap (
sparse_init):在setup_arch->bootmem_init中,内核调用sparse_init()。这是魔法生效的关键时刻。- 函数遍历所有存在的物理内存段(
present sections)。 - 对于每个
section(即每1GB物理内存),通过__populate_section_memmap计算其在vmemmap区域中对应的虚拟地址范围。 - 调用
vmemmap_populate为该虚拟地址范围建立页表映射,背后会通过memblock分配物理页来存放struct page结构体本身。 - 将分配并映射好的
struct page数组地址,经过编码后存入对应mem_section的section_mem_map字段。
此时,
vmemmap虚拟地址到struct page物理存储的映射已经建立,但struct page结构体内部的内容还是空的。 - 函数遍历所有存在的物理内存段(
-
初始化内存节点和区域 (
free_area_init_nodes):内核根据NUMA信息,将物理内存划分为不同的节点(Node)和区域(Zone,如DMA、Normal)。 -
初始化
struct page(memmap_init_zone):这是填充“库存卡片”内容的一步。在free_area_init的过程中,会调用memmap_init_zone函数。它遍历一个zone内所有有效的PFN,对每个PFN:- 通过
pfn_to_page(其背后就是vmemmap + pfn)找到对应的struct page指针。 - 调用
__init_single_page初始化这个struct page:清空内容,设置其所属的zone、NUMA节点ID,初始化锁和引用计数等。
- 通过
-
移交控制权给Buddy系统 (
memblock_free_all):当所有struct page都初始化完毕后,memblock分配器将其管理的所有可用内存页面,释放给buddy系统。从此,内核页面分配器(buddy)正式接管内存分配工作,struct page中的buddy系统相关字段(如_refcount,_mapcount,flags中的迁移类型等)开始发挥作用。
整个流程可以概括为:先架设好“地址簿”的索引系统和存储架(vmemmap映射),然后印制出所有的空白卡片(分配struct page物理内存),接着根据仓库实际布局填写每张卡片的基本信息(初始化struct page),最后将仓库的货物管理权交给专业的物流系统(buddy)。
4. 实战演练:代码视角下的转换与调试
理论说得再多,不如动手看看。我们通过一些简单的代码和调试方法,来验证和理解上述概念。
场景一:在驱动模块中查看vmemmap和page地址
你可以编写一个简单的内核模块来验证:
#include <linux/module.h>
#include <linux/gfp.h>
#include <linux/mm.h>
static int __init my_test_init(void)
{
struct page *page;
unsigned long pfn, vaddr;
// 打印关键宏的值
pr_info("VMEMMAP_START = 0x%llx\n", (unsigned long long)VMEMMAP_START);
pr_info("vmemmap = 0x%p\n", vmemmap); // vmemmap是一个全局变量指针
pr_info("PAGE_OFFSET = 0x%lx\n", PAGE_OFFSET);
pr_info("PHYS_OFFSET = 0x%llx\n", (unsigned long long)memstart_addr);
// 分配一个页
page = alloc_page(GFP_KERNEL);
if (!page) {
pr_err("Failed to allocate page\n");
return -ENOMEM;
}
// 获取该页的PFN和虚拟地址
pfn = page_to_pfn(page);
vaddr = (unsigned long)page_address(page);
pr_info("Allocated page struct at virtual address: 0x%p\n", page);
pr_info("Page Frame Number (PFN): %lu\n", pfn);
pr_info("Page content virtual address: 0x%lx\n", vaddr);
pr_info("Calculated vmemmap offset: page - vmemmap = %lu (should equal PFN)\n",
(page - vmemmap));
// 验证转换
if ((page - vmemmap) == pfn) {
pr_info("SUCCESS: page_to_pfn() matches direct vmemmap calculation.\n");
} else {
pr_info("MISMATCH! Check the math.\n");
}
free_page(vaddr);
return 0;
}
static void __exit my_test_exit(void)
{
pr_info("Module exited\n");
}
module_init(my_test_init);
module_exit(my_test_exit);
MODULE_LICENSE("GPL");
加载这个模块,你会看到类似这样的输出(地址值因系统而异):
[ 123.456789] VMEMMAP_START = 0xffff7fe000000000
[ 123.456790] vmemmap = 0xffff7fdfffe00000
[ 123.456791] PAGE_OFFSET = 0xffff800000000000
[ 123.456792] PHYS_OFFSET = 0x80000000
[ 123.456793] Allocated page struct at virtual address: 0xffff7fe008b6f100
[ 123.456794] Page Frame Number (PFN): 2317252
[ 123.456795] Page content virtual address: 0xffff8002dbc40000
[ 123.456796] Calculated vmemmap offset: page - vmemmap = 2317252
[ 123.456797] SUCCESS: page_to_pfn() matches direct vmemmap calculation.
关键观察点:
page的地址(0xffff7fe008b6f100)确实落在以VMEMMAP_START(0xffff7fe000000000)开头的区域内。page - vmemmap的结果正好等于pfn,直观验证了__page_to_pfn的公式。- 页内容的虚拟地址
vaddr(0xffff8002dbc40000)落在PAGE_OFFSET开始的线性映射区域,这是通过__va(page_to_phys(page))计算得到的。
场景二:解读内核启动日志
在内核启动参数中加入earlyprintk或查看dmesg,可以找到sparse_init相关的日志,它们清晰地展示了section的初始化过程。例如,在一个有16个NUMA节点、每个节点32GB内存的服务器上,你可能会看到:
[ 0.000000] ===sparse_init nid_begin 0 pnum_begin 2 pnum_end 0
[ 0.000000] ===sparse_init::sparse_init_nid 0 nid_begin 0 pnum_begin 2 pnum_end 1088 map_count 32
[ 0.000000] ===sparse_init::sparse_init_nid 0 nid_begin 1 pnum_begin 1088 pnum_end 1152 map_count 32
...
nid_begin: 当前正在初始化的NUMA节点ID。pnum_begin/pnum_end: 处理的section编号范围。map_count: 该节点中存在的、需要建立映射的section数量。这里每个节点map_count为32,结合每个section管理1GB内存,正好对应32GB节点内存。
随后,在memmap_init_zone的日志中,你会看到每个节点上实际初始化的页面数量:
[ 0.000000] ===memmap_init_zone nid 0 size 32768 zone 0 start_pfn 32768 readlcount 31661
[ 0.000000] ===memmap_init_zone nid 0 size 17498112 zone 1 start_pfn 65536 readlcount 490496
这表示在节点0的ZONE_DMA(zone 0)初始化了31661个页,在ZONE_NORMAL(zone 1)初始化了490496个页。readlcount是实际调用__init_single_page的页面数,可能略小于该zone的总页数(size),因为其中可能存在小的空洞。
通过这些日志,你可以像看“施工报告”一样,亲眼见证内核在启动时如何一步步构建起整个内存管理的基础设施。理解SPARSEMEM和vmemmap,就像是掌握了Linux内核管理海量内存的蓝图。它通过section分级管理应对稀疏性,通过vmemmap线性映射实现PFN与struct page的O(1)转换,在复杂性和性能之间取得了精妙的平衡。下次当你调试内存问题或阅读mm/目录下的代码时,希望这份“地图”能帮你更快地定位和理解背后的逻辑。

4407

被折叠的 条评论
为什么被折叠?



