在 Linux 内核中,Node(一般译为节点)是内存管理时的常用粒度。 但事实上,它不是 Linux 内存管理的最小粒度。 一个很容易被忽视的数据结构——Zone(一般译为区域),承担了内存管理中很大一部分任务。
Zone 的历史
几乎在最早的几个 Linux 内核版本中,Zone 就已经被加入了。
它最初加入的目的很简单——为了区分不同的物理地址。
现如今,在不考虑异构内存(HMM)的情况下,不同位置的物理内存没有什么区别;
但在远古时代,物理地址大小本身是有意义的。
例如,某些古老的 ISA 设备仅拥有 24 位寻址能力,这就要求内核将物理内存的前 16MiB 予以保留,即 ZONE_DMA。
类似的,ZONE_DMA32(仅在 64 位机器上出现)保留了所有地址小于 4GiB 的物理内存,从而保持对 32 位设备(寻址空间仅有 4GiB)的兼容。
在硬件设备飞速发展的今天,这些古早的 Zone 已经基本上失去了它们对应的作用。 然而,为了确保兼容性,Linux 内核直到今天依然保留着这些机制。
ZONE_NORMAL 和 ZONE_HIGHMEM
在 64 位机器上,除了 ZONE_DMA 和 ZONE_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_DMA 和 ZONE_DMA32 等传统 Zone 的失效——现在几乎没有只能寻址 24 位的硬件设备了。
在现代机器上,ZONE_DMA、ZONE_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。
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_node 和 zone_absent_pages_in_node。