Linux 内核中的 Zone

2023-09-28

在 Linux 内核中,Node(一般译为节点)是内存管理时的常用粒度。 但事实上,它不是 Linux 内存管理的最小粒度。 一个很容易被忽视的数据结构——Zone(一般译为区域),承担了内存管理中很大一部分任务。

Zone 的历史

几乎在最早的几个 Linux 内核版本中,Zone 就已经被加入了。 它最初加入的目的很简单——为了区分不同的物理地址。 现如今,在不考虑异构内存(HMM)的情况下,不同位置的物理内存没有什么区别; 但在远古时代,物理地址大小本身是有意义的。 例如,某些古老的 ISA 设备仅拥有 24 位寻址能力,这就要求内核将物理内存的前 16MiB 予以保留,即 ZONE_DMA。 类似的,ZONE_DMA32(仅在 64 位机器上出现)保留了所有地址小于 4GiB 的物理内存,从而保持对 32 位设备(寻址空间仅有 4GiB)的兼容。

在硬件设备飞速发展的今天,这些古早的 Zone 已经基本上失去了它们对应的作用。 然而,为了确保兼容性,Linux 内核直到今天依然保留着这些机制。

ZONE_NORMALZONE_HIGHMEM

在 64 位机器上,除了 ZONE_DMAZONE_DMA32 之外的内存大部分都划给了 ZONE_NORMAL,即“普通的内存”。 而在 32 位机器上,情况就要复杂一些:对于用户而言,在仅有的 4GiB 虚拟地址空间中,3GiB 被用于映射用户空间的内存,而留给内核空间的虚拟地址就只剩 1GiB 了。 但内核必须使用这仅有的 1GiB 空间去访问全部的物理内存,因此就需要动态地将物理地址大于 1GiB 的物理内存映射到小于 1GiB 的虚拟地址上——这部分物理内存就被划分为 ZONE_HIGHMEM,以示与小于 1GiB 的物理地址 ZONE_NORMAL 的区分。

为什么即使在用户的虚拟地址空间中,也要预留内核空间的地址? 因为如果不这么做的话,每次进行一次系统调用,就需要进行一次上下文切换——通常意味着重新设置整个页表——从而带来巨大的开销。

而在 64 位机器上,就基本不存在类似的问题。 64 位机器的寻址空间高达 64TiB,即使对半分给用户空间和内核空间,一次性地映射全部物理内存也绰绰有余——绝大部分机器都不会有 32TiB 的物理内存。 在这种情况下,ZONE_HIGHMEM 就不再有太大的意义了。

ZONE_DEVICE

ZONE_DEVICE 不是真正可用的内存——用户无法通过内存分配的方式占用这部分内存。 它实际上只是一种标识,一种用于指示这块内存区域应当由特定硬件驱动管理的标识。 内核文档用了一段相当晦涩的语言来解释 ZONE_DEVICE

ZONE_DEVICE 的“设备”属性与这样一个事实有关,即这些地址范围的页面对象从未标记为联机,必须针对设备而不仅仅是页面进行引用,以保持内存固定以备使用。

这段话的意思是,ZONE_DEVICE 中的 DEVICE(设备)指的是,这块内存区域指代的不是一组页面,而是一个物理设备。 用户在访问 ZONE_DEVICE 的时候,必须时刻牢记,他们访问的不是页面而是设备。

矛盾:划分逻辑与实现方式

从 Zone 的前生今世可以发现,Zone 的划分仅仅基于内存区域的物理地址。 也就是说,Zone 在本质上是与 Node 平行的一种划分——物理内存既可以按照 NUMA 划分为不同的 Node,也可以按照地址划分为不同的 Zone。 然而在实现上,Zone 毫无疑问是 Node 的下属结构,也就导致了划分逻辑与实现方式上的矛盾。

一个典型的后果是,Zone 会不可避免地被 Node“切割”,即一个 Zone 的一部分处于一个 Node 中,而剩下的部分处于另一个 Node 中。 尽管从逻辑上,这两个 Zone 本质上是一个,但在实现上,它们却被视为两个独立且没有任何关联的结构。

当然,也可以从另一种角度看待这种现象:内核中的 Zone 本质上就是物理内存划分的最小单位。 这很大程度上是源于 ZONE_DMAZONE_DMA32 等传统 Zone 的失效——现在几乎没有只能寻址 24 位的硬件设备了。 在现代机器上,ZONE_DMAZONE_DMA32 乃至 ZONE_NORMAL 都只是不同的标识符而已,它们本身没有什么不同。

但是 ZONE_DEVICE 不是,内核对它是有特殊处理的。

如何实现一个自定义的 Zone

enum zone_type {
#ifdef CONFIG_ZONE_DMA
	ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
	ZONE_DMA32,
#endif
	ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
	ZONE_HIGHMEM,
#endif
	ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
	ZONE_DEVICE,
#endif
	__MAX_NR_ZONES
};

只需要在 __MAX_NR_ZONES 之前添加自定义的 Zone 标识,__MAX_NR_ZONES 会自动增加,从而改变全局宏定义 MAX_NR_ZONES,自动配置 Zone 的总数量。 当然,总数量有一些限制

#if MAX_NR_ZONES < 2
    #define ZONES_SHIFT 0
#elif MAX_NR_ZONES <= 2
    #define ZONES_SHIFT 1
#elif MAX_NR_ZONES <= 4
    #define ZONES_SHIFT 2
#elif MAX_NR_ZONES <= 8
    #define ZONES_SHIFT 3
#else
    #error ZONES_SHIFT "Too many zones configured"
#endif

ZONES_SHIFT 表示指代一个 Zone 所需要的位宽是多少,用于 page.flags 中。 从代码中可以看出,原生 Linux 最多允许 8 个 Zone。

当然,添加了 Zone 标识符之后并非万事大吉,还需要对这个新的 Zone 进行一些配置,例如保留页面的数量区域的名字

int sysctl_lowmem_reserve_ratio[MAX_NR_ZONES] = {
#ifdef CONFIG_ZONE_DMA
	[ZONE_DMA] = 256,
#endif
#ifdef CONFIG_ZONE_DMA32
	[ZONE_DMA32] = 256,
#endif
	[ZONE_NORMAL] = 32,
#ifdef CONFIG_HIGHMEM
	[ZONE_HIGHMEM] = 0,
#endif
	[ZONE_MOVABLE] = 0,
};

static char * const zone_names[MAX_NR_ZONES] = {
#ifdef CONFIG_ZONE_DMA
	 "DMA",
#endif
#ifdef CONFIG_ZONE_DMA32
	 "DMA32",
#endif
	 "Normal",
#ifdef CONFIG_HIGHMEM
	 "HighMem",
#endif
	 "Movable",
#ifdef CONFIG_ZONE_DEVICE
	 "Device",
#endif
};

接下来,为了允许内核给新添加的 Zone 分配内存,需要修改它最多允许的 PFN 数量(即这个 Zone 的最大大小)。 这个数值默认为 0,因此不做修改的化,新的 Zone 实际上永远是空的。 以 x86 架构为例:

void __init zone_sizes_init(void)
{
	unsigned long max_zone_pfns[MAX_NR_ZONES];

	memset(max_zone_pfns, 0, sizeof(max_zone_pfns));

#ifdef CONFIG_ZONE_DMA
	max_zone_pfns[ZONE_DMA]		= min(MAX_DMA_PFN, max_low_pfn);
#endif
#ifdef CONFIG_ZONE_DMA32
	max_zone_pfns[ZONE_DMA32]	= min(MAX_DMA32_PFN, max_low_pfn);
#endif
	max_zone_pfns[ZONE_NORMAL]	= max_low_pfn;
#ifdef CONFIG_HIGHMEM
	max_zone_pfns[ZONE_HIGHMEM]	= max_pfn;
#endif

	free_area_init(max_zone_pfns);
}
由于 ZONE_NORMAL 所允许的 PFN 数量特别多,如果新添加的 Zone 在 ZONE_NORMAL 后面,它可能会被“挤出”物理地址空间。 类似的现象也发生在 32 位机器下的 ZONE_HIGHMEM 中。

当然,也可以直接在决定每个 Zone 起始和终止 PFN 的地方修改。 注意有两个地方:zone_spanned_pages_in_nodezone_absent_pages_in_node