本文将介绍 Linux 内核中物理内存管理结构的建立过程,主要分为获取物理内存布局、早期内存管理器和最终内存管理器三个阶段,本文将以 x86_64 架构为例,内核代码基于 Linux 5.10 版本来介绍。

获取物理内存布局

当 x86_64 系统启动,BIOS 将控制权交给 Bootloader(如 GRUB) 之后,此时 CPU 模式还处于 实模式 下,可以进行 BIOS 中断调用,Bootloader 将会通过反复调用(每次只能获取一段内存区域的信息) int 0x15 eax=0xE820 这个中断来获取计算机的物理内存布局。

0x15 号中断还有 0xE801, 0x88 的功能号,都用于获取物理内存布局,只不过功能更简单。

存储获取到的每段内存区域信息的数据结构如下,包括起始地址、大小、类型。

/* arch/x86/include/uapi/asm/bootparam.h */
struct boot_e820_entry {
	__u64 addr;
	__u64 size;
	__u32 type;
} __attribute__((packed));

Bootloader 会在将控制权交给内核前,将获取到的 e820 条目传给内核,存储在内核的 boot_params.e820_table 数据结构中。

/* arch/x86/include/uapi/asm/bootparam.h */
struct boot_params {
	...
	__u8  e820_entries;  // 条目数量
	...
	struct boot_e820_entry e820_table[E820_MAX_ENTRIES_ZEROPAGE];
	...
} __attribute__((packed));

内核早期函数 e820__memory_setup() 会复制并整理这张表,生成三份:

  • e820_table:主表,供内核使用。
  • e820_table_firmware:原始 BIOS 数据备份。
  • e820_table_kexec:供 kexec 热重启使用。
/* arch/x86/kernel/e820.c */
void __init e820__memory_setup(void)
{
	char *who;

	/* This is a firmware interface ABI - make sure we don't break it: */
	BUILD_BUG_ON(sizeof(struct boot_e820_entry) != 20);

	who = x86_init.resources.memory_setup();

	memcpy(e820_table_kexec, e820_table, sizeof(*e820_table_kexec));
	memcpy(e820_table_firmware, e820_table, sizeof(*e820_table_firmware));

	pr_info("BIOS-provided physical RAM map:\n");
	e820__print_table(who);
}

至此,内核中已经保存有关于机器各物理内存区域的布局信息。最后,调用 e820__print_table 将其打印出来。

[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
[    0.000000] BIOS-e820: [mem 0x000000000009fc00-0x000000000009ffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000000f0000-0x00000000000fffff] reserved
[    0.000000] BIOS-e820: [mem 0x0000000000100000-0x000000001ffdbfff] usable
[    0.000000] BIOS-e820: [mem 0x000000001ffdc000-0x000000001fffffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000b0000000-0x00000000bfffffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000fed1c000-0x00000000fed1ffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000fffc0000-0x00000000ffffffff] reserved
...

早期内存管理器

内核中有了物理内存的布局信息,便可以开始建立和初始化物理内存管理的数据结构了。但是事实上,这个内存管理器的建立其实也要分两个阶段来进行——从早期内存管理器过渡到最终内存管理器。之所以要经历这样貌似“多余”的步骤,原因其实很简单,Linux 的最终物理内存管理器的数据结构 足够复杂 ,需要用到动态内存分配机制,但是本身动态内存分配机制就要有内存管理器的支持,因此引入一个临时的、简单的、仅依赖静态内存分配的内存管理器就很顺理成章了。

有关 Linux 内核中早期内存管理器的发展历史,可以参考这篇文章:A quick history of early-boot memory allocators

现在 Linux 所采用的早期内存管理器为 memblock,其数据结构定义如下所示:

/* include/linux/memblock.h */
struct memblock_region {
	phys_addr_t base;           // 内存区域基址
	phys_addr_t size;           // 内存区域大小
	enum memblock_flags flags;  // 内存区域特性
#ifdef CONFIG_NEED_MULTIPLE_NODES
	int nid;                    // NUMA结点号
#endif
};

struct memblock_type {
	unsigned long cnt;                // 内存区域数量
	unsigned long max;                // 内存区域数组的大小
	phys_addr_t total_size;           // 内存区域的总大小
	struct memblock_region *regions;  // 内存区域数组
	char *name;                       // 内存区域名
};

struct memblock {
	bool bottom_up;                 // 是否是自底向上的方向?
	phys_addr_t current_limit;      // 地址分配的界限
	struct memblock_type memory;    // 可用的内存区域
	struct memblock_type reserved;  // 保留的内存区域
};

值得一提的一个细节是,memblock_type 的内存区域数组字段 regions 为指针类型,可能会下意识地认为这是一个需要用到动态内存分配的动态数组,与前面提到的”仅依赖静态内存分配“的要求相悖,但事实并非如此。

下面是内核中 memblock 全局变量的初始化,可以看到,memoryreservedregions 字段其实都指向的分别为一个长度为 INIT_MEMBLOCK_REGIONSINIT_MEMBLOCK_RESERVED_REGIONS 的定长数组。

/* mm/memblock.c */
static struct memblock_region memblock_memory_init_regions[INIT_MEMBLOCK_REGIONS] __initdata_memblock;
static struct memblock_region memblock_reserved_init_regions[INIT_MEMBLOCK_RESERVED_REGIONS] __initdata_memblock;

struct memblock memblock __initdata_memblock = {
	.memory.regions		= memblock_memory_init_regions,
	.memory.cnt		= 1,	/* empty dummy entry */
	.memory.max		= INIT_MEMBLOCK_REGIONS,
	.memory.name		= "memory",

	.reserved.regions	= memblock_reserved_init_regions,
	.reserved.cnt		= 1,	/* empty dummy entry */
	.reserved.max		= INIT_MEMBLOCK_RESERVED_REGIONS,
	.reserved.name		= "reserved",

	.bottom_up		= false,
	.current_limit		= MEMBLOCK_ALLOC_ANYWHERE,
};

之所以要用指针类型而不是直接在 memblock_type 中内嵌数组,个人认为可能是为了后续可能的动态扩展的需求。当然这样的动态扩展也是要基于内存管理器来完成的,这部分内容不在本文的讨论范围内。

下列代码就是根据 E820 表对 memblock 数据结构进行填充的过程:

/* arch/x86/kernel/e820.c */
void __init e820__memblock_setup(void)
{
	...
	for (i = 0; i < e820_table->nr_entries; i++) {
		struct e820_entry *entry = &e820_table->entries[i];

		end = entry->addr + entry->size;
		if (end != (resource_size_t)end)
			continue;

		if (entry->type == E820_TYPE_SOFT_RESERVED)
			memblock_reserve(entry->addr, entry->size);

		if (entry->type != E820_TYPE_RAM && entry->type != E820_TYPE_RESERVED_KERN)
			continue;

		memblock_add(entry->addr, entry->size);
	}
	...
}

最终内存管理器

最终,在有了完整的 memblock 结构之后,将开始向最终内存管理器的过渡过程。这部分的内容比较复杂,我也没有仔细研究,等有需要可以考虑单开一篇文章来介绍,下面只是简单的总结。

  1. 初始化页表与内核直接映射init_mem_mapping 会建立完整的内核页表,最关键的是建立对全部物理内存的直接映射(Direct Map)。这意味着所有的物理内存都被线性地映射到一个固定的内核虚拟地址空间(例如,从 0xffff800000000000 开始),使得内核可以方便地访问任何物理地址。
  2. 伙伴系统mem_init 函数中会释放所有由 memblock 管理的可用内存给伙伴系统(Buddy System)。伙伴系统是内核管理物理页帧(通常为 4KB)的核心分配器,负责处理 高阶连续物理内存的分配和释放 。此时,memblock 分配器的任务基本完成,内核后续的物理内存分配将由伙伴系统接管。
  3. 精细化管理:在伙伴系统之上,内核还会初始化 kmem_cache 等机制,用于高效分配内核中常用的小对象(如 task_struct),以减轻伙伴系统的负担并减少内存碎片。