内核堆内存管理(一)
前言
一直在学kernel,但是一直没时间学......这段时间总算有空去做这一部分的工作了,还是想着去把这里的内核的堆内存分配先搞明白再去看后面的堆手法的利用
kernel的堆分配好抽象啊
估计会更着arttnba3佬的博客来过一遍,也是通过 Linux 5.11 的源代码来对Linux 内核中的内存管理(memory management)部分进行分析吧,先叠个甲,大部分内容都是偷自arttnba3佬的博客,侵删
这里是原文链接:Linux 内核内存管理I - 页、区、节点
分配函数概览
Linux 内核不像用户态程序那样只有一种 malloc() 分配方式,它提供了 多种 API 来满足不同情境下的内存需求:
kmalloc()/kzalloc():最常用的内存分配方式,适合分配小于一页的内存。kzalloc()会将内存初始化为 0。这两个的分配相当于用户态的
malloc()/calloc()。
vmalloc 系列用于分配 虚拟上连续,但物理不必连续 的大块内存
vmalloc()/vzalloc()等:当需要分配比较大的缓冲区,而物理页不连续时可使用。
直接请求页:如果需要物理连续的页(比如 DMA 或 page-level 使用):
alloc_pages():请求指定数量的页,这种方式更接近页面分配器本身。
专用分配器:还有一些更专用的分配机制,例如:
cma_alloc:用于连续内存区域(用于某些 DMA 场景)。zs_malloc:用于压缩内存分配。
GFP标志
大多数内核内存分配函数都需要传入一个 GFP(Get Free Pages)标志,用来告诉内核:是否允许或禁止睡眠(blocking); 是否可以进行直接回收(reclaim);内存是否必须可被用户空间访问; 是否应使用内存回收机制等。最常用的几个标志对应的含义如下:
Overview
首先我们先明确几个概念:页框(page frame) 是 物理内存,页(page) 是 内核管理的最小单位,区(zone) 是 用途/硬件约束的分组,节点(node) 是 NUMA 架构下的内存归属,其关系可以理解为:
节点 (node)
└── 区 (zone)
└── 页 (page / struct page)
└── 页框 (page frame)具体而言:
页框 = 物理内存中的一个固定大小块,具体大小由 MMU + 硬件架构决定。页框只存在于物理内存!!
x86 / arm64架构下其大小通常是4KB
页 = 内核用来描述和管理“页框”的抽象,也是内核的最小内存管理单位。
页与页框的区别是页是内核抽象的,是有状态、引用计数的,是有用途的(比如存在匿名页/文件页这样的用途),而页框只是物理内存,没有用途概念
在分页存储系统中,每个页都对应一个页框。页框是物理内存中的实际存储单元,而页是逻辑地址空间中的抽象概念。
区 = 按“硬件限制 + 用途”对页进行的分类。举个例子:网卡 DMA 只能访问低 4GB,但是我们的系统有 64GB 内存,这个时候有需要有区的概念来对内存进行区分。
节点(Node) = 一组 CPU + 本地内存,存在于 NUMA(非一致内存访问)系统,对于每一个Node而言:其有自己的内存,自己的区,自己的页分配器
Node 0
├─ CPU 0-15
└─ RAM 0-64GB
Node 1
├─ CPU 16-31
└─ RAM 64-128GB这里举个例子来理解一下上面的这些概念:
当我们使用下面的语句来实现内核内存的分配的时候,当前 CPU 首先回去找本地 node,在 node 中选择合适的 zone,之后从 zone 的空闲链表拿 page,这里page 对应一个 page frame,最后返回内核虚拟地址。
p = kmalloc(128, GFP_KERNEL);下面使用一张图来结束这一部分,自顶向下是
节点(node,对应结构体 pgdata_list)
区 (zone,对应结构体 zone,图上展示了三种类型的 zone)
页 (page,对应结构体 page)

注:我们可以通过cat /proc/buddyinfo与cat /proc/pagetypeinfo查看本机上的页面相关信息:

struct page:页
这里用struct page来表示页是不太准确的,struct page实际上是物理页框在内核中的管理描述符
在 Linux 内核中,物理内存并不是被直接操作的对象,而是通过一个名为 struct page 的数据结构进行统一管理(linux内核内存管理的核心数据结构,所有内存管理设施都是以它为中心展开的,如vma管理、缺页中断、页面分配与回收等)。内核为系统中的每一个物理页框(page frame)都维护了一个唯一对应的 page 结构体,用于记录该物理页框的状态、用途以及生命周期等关键信息。通过这种方式,内核能够在不直接依赖物理地址的情况下,对内存进行精细化的管理与调度。
由于系统中物理页框的数量通常非常庞大(例如在大内存系统中可达数百万级别),struct page 本身的空间开销就显得尤为重要。为了在保证功能完整性的同时尽可能降低内存管理的额外成本,Linux 内核在 page 结构体的设计中大量使用了联合体(union)。这是因为一个物理页在任意时刻只会处于某一种用途状态(例如页缓存、匿名页或 slab 页),这些互斥的状态可以复用同一块内存,从而显著减少结构体的整体大小。
在当前主流的 64 位体系结构下,一个 struct page 的大小通常为 64 字节(64B)。假设系统中每个物理页框的大小为 4KB(4096B),那么用于描述和管理该页框的 page 结构体所占用的空间比例仅为:1.5625%。

该结构体定义于内核源码include/linux/mm_types.h中,如下:
struct page {
unsigned long flags; /* 原子标志位,其中一些
* 可能会被异步更新 */
/*
* 该联合体中一共可用 5 个 machine word(32 位系统为 20 字节,64 位系统为 40 字节)
* 警告:第一个 word 的第 0 位被 PageTail() 使用
* 这意味着该联合体的其他使用者 绝对不能使用这一位
* 否则会导致冲突或错误地判断为尾页
*/
union {
struct { /* Page cache 和匿名页 */
/**
* @lru: 页面回收链表,例如 active_list,
* 由 lruvec->lru_lock 保护。
* 有时也会被页的拥有者当作通用链表使用。
*/
struct list_head lru;
/* 见 page-flags.h 中的 PAGE_MAPPING_FLAGS*/
struct address_space *mapping;
pgoff_t index; /* 在 mapping 中的偏移 */
/**
* @private: 映射私有的不透明数据。
* 若设置 PagePrivate,通常用于 buffer_head。
* 若是 PageSwapCache,则用于 swp_entry_t。
* 若是 PageBuddy,则表示 buddy 系统中的阶数。
*/
unsigned long private;
};
struct { /* netstack 使用的 page_pool */
/**
* @dma_addr: 即使在 32 位体系结构上
* 也可能需要 64 位的 DMA 地址。
*/
dma_addr_t dma_addr;
};
struct { /* slab、slob 和 slub */
union {
struct list_head slab_list;
struct { /* 部分页 */
struct page *next;
#ifdef CONFIG_64BIT
int pages; /* 剩余页数 */
int pobjects; /* 近似对象数 */
#else
short int pages;
short int pobjects;
#endif
};
};
struct kmem_cache *slab_cache; /* slob 不使用 */
/* 双字对齐边界 */
void *freelist; /* 第一个空闲对象 */
union {
void *s_mem; /* slab:第一个对象 */
unsigned long counters; /* SLUB */
struct { /* SLUB */
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};
struct { /* 复合页(compound page)的尾页 */
unsigned long compound_head; /* 第 0 位被置位 */
/* 仅第一个尾页使用*/
unsigned char compound_dtor;
unsigned char compound_order;
atomic_t compound_mapcount;
unsigned int compound_nr; /* 1 << compound_order */
};
struct { /* 复合页的第二个尾页 */
unsigned long _compound_pad_1; /* compound_head */
atomic_t hpage_pinned_refcount;
/* 用于全局和 memcg */
struct list_head deferred_list;
};
struct { /* 页表页 */
unsigned long _pt_pad_1; /* compound_head */
pgtable_t pmd_huge_pte; /* 由 page->ptl 保护 */
unsigned long _pt_pad_2; /* mapping */
union {
struct mm_struct *pt_mm; /* 仅 x86 的 pgd */
atomic_t pt_frag_refcount; /* powerpc */
};
#if ALLOC_SPLIT_PTLOCKS
spinlock_t *ptl;
#else
spinlock_t ptl;
#endif
};
struct { /* ZONE_DEVICE 页 */
/** @pgmap: 指向宿主设备的页映射 */
struct dev_pagemap *pgmap;
void *zone_device_data;
/*
* ZONE_DEVICE 私有页被视为已映射,
* 接下来的 3 个 word 保存原始匿名页或
* 页缓存页的 mapping、index 和 private,
* 以支持向设备私有内存迁移。
*
* MEMORY_DEVICE_FS_DAX 类型页在
* pmem 支持的 DAX 文件映射中
* 也会使用这些字段。
*/
};
/** @rcu_head: 可用于通过 RCU 机制释放页面 */
struct rcu_head rcu_head;
};
union { /* 该联合体大小为 4 字节 */
/*
* 如果页面可以映射到用户空间,
* 这里记录该页被页表引用的次数。
*/
atomic_t _mapcount;
/*
* 如果页面既不是 PageSlab,
* 也不能映射到用户空间,
* 这里的值可用于标识页类型,
* 具体见 page-flags.h。
*/
unsigned int page_type;
unsigned int active; /* SLAB */
int units; /* SLOB */
};
/* 使用计数。*不要直接使用*,参见 page_ref.h */
atomic_t _refcount;
#ifdef CONFIG_MEMCG
unsigned long memcg_data;
#endif
/*
* On machines where all RAM is mapped into kernel address space,
* we can simply calculate the virtual address. On machines with
* highmem some memory is mapped into kernel virtual memory
* dynamically, so we need a place to store that address.
* Note that this field could be 16 bits on x86 ... ;)
*
* Architectures with slow multiplication can define
* WANT_PAGE_VIRTUAL in asm/page.h
*/
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
int _last_cpupid;
#endif
} _struct_page_alignment;重要字段
flags
struct page中的flags成员是页面的标志位集合,这些标志位是内存管理中非常重要的部分,具体定义在include/linux/page-flags.h文件中,其中重要的标志位如下:
enum pageflags {
PG_locked, /* 表示页面已上锁,不要访问 */
PG_referenced, /* 表示页面最近被访问过,用于页面回收算法判断冷热 */
PG_uptodate, /* 表示页面内容是有效的,读操作完成后设置该标志 */
PG_dirty, /* 表示页面是脏页,内容被修改过,需要回写到后备存储 */
PG_lru, /* 表示该页面已挂入某条 LRU 链表中(page->lru 有效) */
PG_active, /* 表示该页面位于 active LRU 链表中,近期频繁使用 */
PG_workingset, /* 表示该页面属于 working set,用于优化回收决策 */
// 有线程在等待该 page 解锁,必须是 bit 7,和 PG_locked 同一字节,用于快速判断是否需要 wakeup
PG_waiters,
PG_error, /* 表示页面在 IO 过程中发生错误 */
PG_slab, /* 表示该页面属于 slab/slub/slob 分配器管理 */
PG_owner_priv_1, /* Owner use. If pagecache, fs may use*/
PG_arch_1, /* 体系结构相关的页面状态位(由具体架构定义) */
PG_reserved, /* 表示该页面被内核保留,不可被回收或换出 */
PG_private, /* 表示 page->private 字段有效;pagecache 中常用于 fs-private 数据 */
PG_private_2, /* pagecache 的辅助私有数据(fs aux data) */
PG_writeback, /* 表示页面正在回写到后备存储中 */
PG_head, /* 表示该页面是复合页(compound page)的头页 */
PG_mappedtodisk, /* 表示该页面在磁盘上已分配物理块 */
PG_reclaim, /* 表示该页面应尽快被回收(回收算法使用) */
PG_swapbacked, /* 表示页面由 swap 或匿名内存支持(通常为匿名页) */
PG_unevictable, /* 表示页面不可被回收,位于 LRU_UNEVICTABLE 链表中 */
#ifdef CONFIG_MMU
PG_mlocked, /* 表示该页面被 mlock() 锁定,禁止被换出 */
#endif
#ifdef CONFIG_ARCH_USES_PG_UNCACHED
PG_uncached, /* 表示该页面以 uncached 方式映射(架构相关) */
#endif
#ifdef CONFIG_MEMORY_FAILURE
PG_hwpoison, /* 表示该页面已被硬件标记为损坏,不可再访问 */
#endif
#if defined(CONFIG_IDLE_PAGE_TRACKING) && defined(CONFIG_64BIT)
PG_young, /* 表示页面是“年轻页”,用于 idle page tracking */
PG_idle, /* 表示页面处于 idle 状态,近期未被访问 */
#endif
#ifdef CONFIG_64BIT
PG_arch_2, /* 第二个体系结构相关的页面状态位 */
#endif
__NR_PAGEFLAGS, /* page flags 的总数量 */
/* Filesystems */
PG_checked = PG_owner_priv_1,/* 文件系统使用:表示页面已被检查 */
/* SwapBacked */
/* 表示该页面位于 swap cache 中,此时 page->private 保存 swp_entry_t */
PG_swapcache = PG_owner_priv_1,
/* Two page bits are conscripted by FS-Cache to maintain local caching
* state. These bits are set on pages belonging to the netfs's inodes
* when those inodes are being locally cached.
*/
/* FS-Cache 使用:表示该页面由本地缓存支持(如网络文件系统缓存) */
PG_fscache = PG_private_2,
/* XEN */
/* Xen 使用:页面被固定为只读页表页,禁止回收或迁移 */
PG_pinned = PG_owner_priv_1,
/* Xen 使用:页面在虚拟机保存(domain save)过程中被固定 */
PG_savepinned = PG_dirty,
/* Xen 使用:该页面映射了其他虚拟机(foreign domain)的内存 */
PG_foreign = PG_owner_priv_1,
/* Xen 使用:页面被 swiotlb-xen 重新映射 */
PG_xen_remapped = PG_owner_priv_1,
/* SLOB */
PG_slob_free = PG_private,/* SLOB 分配器使用:表示该页面当前处于空闲状态 */
/* 复合页使用:表示该 compound page 被双重映射(如 fork 场景) */
PG_double_map = PG_workingset,
/* 表示页面已从 LRU 链表隔离,用于迁移或回收过程 */
PG_isolated = PG_reclaim,
/* buddy 分配器使用:表示该页面已被上报或统计(仅对 buddy 页有效) */
PG_reported = PG_uptodate,
}; flags内存复用
为了尽量减少 struct page 的内存开销,Linux 内核对 flags 字段进行了复用,复用的格式与内核配置的内存模型有关,不同的内存模型(例如 FLATMEM、SPARSEMEM、SPARSEMEM_VMEMMAP)对物理内存的组织方式不同,从而决定了哪些位必须保留、哪些位可以复用、以及哪些位需要与 struct page 之外的元数据协同使用。内核在 include/linux/page-flags-layout.h 文件中,描述了五种划分形式(其实是三大种):
/*
* page->flags layout:
*
* There are five possibilities for how page->flags get laid out. The first
* pair is for the normal case without sparsemem. The second pair is for
* sparsemem when there is plenty of space for node and section information.
* The last is when there is insufficient space in page->flags and a separate
* lookup is necessary.
*
* No sparsemem or sparsemem vmemmap: | NODE | ZONE | ... | FLAGS |
* " plus space for last_cpupid: | NODE | ZONE | LAST_CPUPID ... | FLAGS |
* classic sparse with space for node:| SECTION | NODE | ZONE | ... | FLAGS |
* " plus space for last_cpupid: | SECTION | NODE | ZONE | LAST_CPUPID ... | FLAGS |
* classic sparse no space for node: | SECTION | ZONE | ... | FLAGS |
*/非 sparsemem(或 sparsemem + vmemmap)
在 非 sparsemem(典型是 FLATMEM)模型中,物理内存被认为比较连续(或者至少用连续数组管理),所有的struct page 存储的方式通常是一块大数组 mem_map[],通过直接计算/索引pfn → **&mem_map[pfn]**就能找到 page
其字段分布如下图所示,低位用作该 page 的 flag,高位分别标识其归属的 zone, node id(非 NUMA 系统中为0),中间剩余的位保留

这种形式中若是开启了last_cpuid则是下面这个样子:

classic sparsemem(有空间)
classic sparsemem(有空间)是指在启用传统 sparsemem 内存模型时,struct page->flags 的位数仍然足够同时编码 SECTION、NODE、ZONE 以及页面状态标志,从而无需额外查表即可从 flags 中直接获取 page 的归属信息。
其字段分布如下图所示,相比起第一种形式多了一个 SECTION 字段标识其归属的mem_section

若是开启了last_cpuid则是下面这个样子

classic sparsemem(空间不够)
classic sparsemem(空间不够)是指在传统 sparsemem 内存模型下,由于 page->flags 的位数不足以同时编码 SECTION、NODE、ZONE 和页面状态标志,内核选择将 NODE 信息移出 flags,通过 section 结构间接获取,从而在不扩大 struct page 大小的前提下支持大规模、稀疏物理内存系统。
这种模式主要是针对非 NUMA 设计的,在这种模式下取消了 Node 结构

lru:LRU链表节点
lru 即Least Recently Used,就是操作系统的LRU置换算法,在 Linux 内核中,page 结构体通过其 lru 字段组织成链表
在 Linux 中,操作系统对 LRU 的实现主要是基于一对双向链表:active 链表和 inactive 链表,这两个链表是 Linux 操作系统进行页面回收所依赖的关键数据结构,每个内存区域都存在一对这样的链表。顾名思义,那些经常被访问的处于活跃状态的页面会被放在 active 链表上,而那些虽然可能关联到一个或者多个进程,但是并不经常使用的页面则会被放到 inactive 链表上。页面会在这两个双向链表中移动,操作系统会根据页面的活跃程度来判断应该把页面放到哪个链表上。页面可能会从 active 链表上被转移到 inactive 链表上,也可能从 inactive 链表上被转移到 active 链表上,但是,这种转移并不是每次页面访问都会发生,页面的这种转移发生的间隔有可能比较长。那些最近最少使用的页面会被逐个放到 inactive 链表的尾部。进行页面回收的时候,Linux 操作系统会从 inactive 链表的尾部开始进行回收。
每个zone有一个,那么下面针对一个zone来分析 LRU 链表:
如下图所示,一个 LRU 链表描述符中总共有5个双向链表头,它们分别描述五中不同类型的链表:
LRU_INACTIVE_ANON:称为非活动匿名页 LRU 链表(swap)
LRU_ACTIVE_ANON:称为活动匿名页 LRU 链表(swap)
LRU_INACTIVE_FILE:称为非活动文件页 LRU 链表(磁盘)
LRU_ACTIVE_FILE:称为活动文件页 LRU 链表(磁盘)
LRU_UNEVICTABLE:此链表中保存的是此zone中所有禁止换出的页的描述符。

lru 成员是一个struct list_head类型,这是内核中通用的双向链表节点结构
slab相关结构体
在 page 结构体中专门有着一个匿名结构体(没有类型名、没有成员名、直接嵌入到外层结构体中的 struct。)用于存放与 slab 相关的成员
struct { /* 供 slab, slob and slub 使用 */
union {
struct list_head slab_list;
struct { /* Partial pages */
struct page *next;
#ifdef CONFIG_64BIT
int pages; /* 剩余的页数量 */
int pobjects; /* 近似计数 */
#else
short int pages;
short int pobjects;
#endif
};
};
struct kmem_cache *slab_cache; /* 不在 slob 中使用 */
/* 两个 word 的范围 */
void *freelist; /* 第一个空闲对象 */
union {
void *s_mem; /* slab: first object */
unsigned long counters; /* SLUB */
struct { /* SLUB */
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};Linux kernel 中的 slab allocator 一共有三种:slab、slob、slub,其中比较常用的是 slub 分配器,关于 slab allocator 将在后续的文章中进行更为详细的叙述,下图是一张 slub 分配器的 overview

_mapcount:映射计数
映射计数(mapcount)指的是:一个物理页当前被“页表(page table)映射到用户虚拟地址空间中的次数”,也就是记录该 struct page 被“用户态页表项(PTE/PMD)”映射的次数。由于每个进程有其独立的页表,但是由于同一进程可以多次映射同一物理页,所以在常见场景下它近似反映了有多少个进程共享该页,其初始值为 -1。
同一块内存,在不同类型的 page 上,通过联合体被赋予不同语义,若是该页既不是 PageSlab 也没有被映射到用户空间,则为 page_type 字段,具体说明定义于/include/linux/page-flags.h中,如下:
/*
* For pages that are never mapped to userspace (and aren't PageSlab),
* page_type may be used. Because it is initialised to -1, we invert the
* sense of the bit, so __SetPageFoo *clears* the bit used for PageFoo, and
* __ClearPageFoo *sets* the bit used for PageFoo. We reserve a few high and
* low bits so that an underflow or overflow of page_mapcount() won't be
* mistaken for a page type value.
*/
#define PAGE_TYPE_BASE 0xf0000000
/* Reserve 0x0000007f to catch underflows of page_mapcount */
#define PAGE_MAPCOUNT_RESERVE -128
#define PG_buddy 0x00000080
#define PG_offline 0x00000100
#define PG_table 0x00000200
#define PG_guard 0x00000400_refcount:引用计数
_refcount 字段用于记录 struct page** 在内核中的引用次数**,它是页面生命周期管理的核心机制之一。该计数器反映了当前有多少内核实体正在使用或持有该页面,从而决定页面是否可以被释放或回收。
在页面处于空闲状态、尚未被任何内核子系统使用时,其 _refcount 的初始值为 0。当页面被分配并开始被使用时(例如被加入页缓存、映射到进程地址空间、参与 I/O 操作或被内核数据结构引用),内核会相应地增加该页面的引用计数,即 _refcount 加 1。
除了直接的页面分配行为外,当一个页面被其他页面或内核对象间接引用时(例如作为页缓存的一部分、被 bio 或 buffer 引用等),同样会导致 _refcount 增加。这保证了只要页面仍然被任何内核路径使用,其生命周期就不会提前结束。
当 _refcount 的值为 0 时,表示当前没有任何内核实体再持有该页面,该页面要么处于空闲状态,要么已经具备被释放和重新分配的条件;而当 _refcount 大于 0 时,则表示该页面仍然正在被使用,内核不会对其进行释放操作。
为了统一和安全地维护页面的引用计数,内核提供了 get_page() 与 put_page() 两个接口函数,分别用于增加和减少页面的引用计数。get_page() 在获取页面使用权时将 _refcount 加 1,而 put_page() 在释放页面引用时将 _refcount 减 1。当 put_page() 发现引用计数从 1 减至 0 时,会进一步调用 __put_single_page(),完成该页面的最终释放或回收操作,使页面重新回到空闲状态。
virtual:虚拟地址
该字段为该物理页框对应的的虚拟地址,那么这里又要放上这张经典的图:每一个 struct page 对应一个物理页框,那么这个 virtual 字段其实就是上图的反向映射

不同内存模式下的struct page存储方式
Linux 提供了三种(实际上是四种)内存模型:CONFIG_FLATMEM,CONFIG_DISCONTIGMEM,CONFIG_SPARSEMEM,CONFIG_SPARSEMEM_VMEMMAP,但是因为 **SPARSEMEM_VMEMMAP 在“概念上仍然是 SPARSEMEM”,只是 SPARSEMEM 的一种实现方式优化,而不是一种新的内存模型,所以一共为3种。**每一种模型对应使用的宏定义于include/asm-generic/memory_model.h中
/*
* supports 3 memory models.
*/
#if defined(CONFIG_FLATMEM)
...
#elif defined(CONFIG_DISCONTIGMEM)
...
#elif defined(CONFIG_SPARSEMEM_VMEMMAP)
...
#elif defined(CONFIG_SPARSEMEM)
...
#endif这三种模型对应的图如下所示(偷的图,侵删):

Flat Memory
Flat Memory(平坦内存模型)是假设物理内存基本连续,并用一个全局、连续的 struct page 数组(mem_map[])来描述所有物理页框的内存模型。它解决的是最简单的一种情况:物理内存连续;没有(或几乎没有)空洞(hole);不需要内存热插拔;NUMA 拓扑简单或不存在
可以这样理解:
物理内存(连续)
PFN: 0 1 2 3 4 5 ...
| | | | | |
mem_map[0][1][2][3][4][5]...
| | | | | |
struct page(一一对应)Discontiguous Memory
DISCONTIGMEM 是一种面向 NUMA 架构的内存模型:物理内存在地址空间中可以不连续,存在空洞,但以内存节点(node)为单位,每个 node 内的内存是连续的,并且每个 node 拥有自己独立的 struct page 数组。
它不再使用全局唯一的 mem_map[],而是以“连续物理内存段(通常对应 NUMA node)”为单位, 为每一段连续内存维护一个 pglist_data 结构体,并在其中保存该段内存对应的 struct page 数组。 所有 pglist_data 通过全局的 node_data[] 数组进行统一管理。
Sparse Memory
离散内存模型(Sparse Memory)把物理地址空间按固定大小的 section 切分;每个 section 用一个 mem_section 描述,若该 section 实际存在物理内存,则 mem_section->section_mem_map 指向一段 struct page[];全局的 mem_section[] 数组按 section 编号索引,未实际存在内存的 section,其指针为 NULL。
这种模型支持内存的热拔插

mem_section 结构体
mem_section 是 Sparse Memory 内存模型中,用来描述“一个 section(内存段)是否存在、以及它对应的 struct page 在哪里”的核心元数据结构。在 Sparse Memory 中,物理地址空间是这样的:
[ SECTION 0 ][ hole ][ SECTION 2 ][ hole ][ SECTION 4 ] ...就此,引出三个问题:
哪些 section 实际存在物理内存?
存在的 section,它的 **
struct page[]在哪?**如何快速判断某个 PFN 是否落在“洞”里?
mem_section 正是为了解决这三个问题而存在的,该结构体定义于/include/linux/mmzone.h中,如下:
struct mem_section {
/*
* 逻辑上这指向一个 pages 结构体数组,
* 然而,他的存储还有一些别的魔力
* (参见 sparse.c::sparse_init_one_section())
*
* 此外,在引导的早期,我们对此处节区的位置的
* 节点的id进行编码,以指引分配。
* (参见 sparse.c::memory_present())
*
* 将之声明为一个 unsigned long,至少可以让人在
* 错误使用之前完成一次(类型)转换
*/
unsigned long section_mem_map;
struct mem_section_usage *usage;
#ifdef CONFIG_PAGE_EXTENSION
/*
* 若是 SPARSEMEM, pgdat 没有 page_ext 指针.
* 我们使用 section. (关于这个,参见 page_ext.h)
*/
struct page_ext *page_ext;
unsigned long pad;
#endif
/*
* 警告: mem_section 的大小必须是2的幂次方, 以便于
* 让计算与使用 SECTION_ROOT_MASK 有意义
*/
};_CONFIG_SPARSEMEM_EXTREME_:动态分配**mem_section数组**
内核编译选项之一,若开启了则连mem_section数组的空间也是动态分配的,在 section 较多的情况下通常会开启这个编译选项
全局 mem_section 数组
该数组中存放着指向所有 mem_section 结构体的指针,定义于/mm/sparse.c中,如下:
#ifdef CONFIG_SPARSEMEM_EXTREME
struct mem_section **mem_section;
#else
struct mem_section mem_section[NR_SECTION_ROOTS][SECTIONS_PER_ROOT]
____cacheline_internodealigned_in_smp;
#endif若未开启CONFIG_SPARSEMEM_EXTREME编译选项则 mem_section 为一个常规的二维数组,否则为一个二级指针,其所指向空间内存动态分配
对于后一种情况,其结构如下图所示:

PFN 与 page 结构体间的转换
kernel 中提供了两个用以在 PFN(Page Frame Numer) 与 page 结构体之间进行转换的宏,定义于/include/asm-generic/memory_model.h中,如下:
#elif defined(CONFIG_SPARSEMEM)
/*
* Note: 节区的 mem_map 被编码以表示其 start_pfn.
* section[i].section_mem_map == mem_map's address - start_pfn;
*/
#define __page_to_pfn(pg) \
({ const struct page *__pg = (pg); \
int __sec = page_to_section(__pg); \
(unsigned long)(__pg - __section_mem_map_addr(__nr_to_section(__sec))); \
})
#define __pfn_to_page(pfn) \
({ unsigned long __pfn = (pfn); \
struct mem_section *__sec = __pfn_to_section(__pfn); \
__section_mem_map_addr(__sec) + __pfn; \
})
#endif /* CONFIG_FLATMEM/DISCONTIGMEM/SPARSEMEM */在这里我们需要注意一点:mem_section 结构体的 section_mem_map 中存储的为 page 数组与 PFN 的差值
(1)page 结构体到 PFN:page 结构体地址减去对应 mem_section->section_mem_map
该宏首先会使用page_to_section()通过 page 结构体的 flags 字段获取该 page 所属的 section 标号,该函数定义于/include/linux/mm.h中,如下:
static inline unsigned long page_to_section(const struct page *page)
{
return (page->flags >> SECTIONS_PGSHIFT) & SECTIONS_MASK;
}之后使用__nr_to_section()来获取对应的 mem_section 结构体的地址,该函数定义于/include/linux/mmzone.h中,如下:
static inline struct mem_section *__nr_to_section(unsigned long nr)
{
#ifdef CONFIG_SPARSEMEM_EXTREME
if (!mem_section)
return NULL;
#endif
if (!mem_section[SECTION_NR_TO_ROOT(nr)])
return NULL;
return &mem_section[SECTION_NR_TO_ROOT(nr)][nr & SECTION_ROOT_MASK];
}这里用到一个宏SEECTION_NR_TO_ROOT,定义如下:
#ifdef CONFIG_SPARSEMEM_EXTREME
#define SECTIONS_PER_ROOT (PAGE_SIZE / sizeof (struct mem_section))
#else
#define SECTIONS_PER_ROOT 1
#endif
#define SECTION_NR_TO_ROOT(sec) ((sec) / SECTIONS_PER_ROOT)我们默认开启CONFIG_SPARSEMEM_EXTREME,此时SECTION_PER_ROOT意为一张页中 mem_section 结构体的数量,即宏SEECTION_NR_TO_ROOT得到的是对应的_页下标_,之后再通过 mem_section 标号与每页中 mem_section 数量 - 1(SECTION_ROOT_MASK)做与运算最终得到该 mem_section 在该页这一 mem_section 数组中的下标
之后通过__section_mem_map_addr()获取到 mem_section 结构体中的 section_mem_map 成员,该函数定义于/include/linux/mmzone.h中,如下:
static inline struct page *__section_mem_map_addr(struct mem_section *section)
{
unsigned long map = section->section_mem_map;
map &= SECTION_MAP_MASK;
return (struct page *)map;
}最后与 page 结构体的地址做差运算便能获得其 PFN,需要注意的是在这里进行的是page 结构体指针间的运算而非简单的地址加减法,计算过程为:

(2)PFN 到 page 结构体:页框号加上对应 mem_section->section_mem_map
该宏首先使用__pfn_section()来获取到 PFN 所属的 mem_section,该函数定义于/include/linux/mmzone.h中,如下:
static inline struct mem_section *__pfn_to_section(unsigned long pfn)
{
return __nr_to_section(pfn_to_section_nr(pfn));
}其中pfn_to_section_nr()定义如下,用以获取对应的 section 的索引:
static inline unsigned long pfn_to_section_nr(unsigned long pfn)
{
return pfn >> PFN_SECTION_SHIFT;
}这里用到一个宏PFN_SECTION_SHIFT,定义如下:
#define PFN_SECTION_SHIFT (SECTION_SIZE_BITS - PAGE_SHIFT)其中的SECTION_SIZE_BIT表示一个 section 的大小(恒定为2的幂次方)所占位数,而PAGE_SHIFT则为一个页的大小(通常为4096)所占位数,前者移位后者所得为_一个 section 中页的数量_
由页框号移位(本质为除法)单个 section 中页的数量便能得到其所属 section 标号
之后使用__nr_to_section()来获取对应的 mem_section 结构体的地址,最后使用__section_mem_map_addr()获取到 mem_section 结构体中的 section_mem_map 成员后再与页框号做指针加法便能获取到对应的 page 结构体数组,计算过程如下:

Sparse Memory virtual memmap
基于Sparse Memory 内存模型上引入了 vmemmap 的概念,是目前 Linux 最常用的内存模型之一

在开启了 vmemmap 之后,所有的 mem_section 中的 page 都抽象到一个虚拟数组 vmemmap 中,这样在进行struct page * 和 pfn 转换时,直接使用 vmemmap 数组即可
struct zone:区
在 Linux 下将一个节点内不同用途的内存区域划分为不同的区(zone),对应结构体struct zone,该结构体定义于/include/linux/mmzone.h中,如下:
struct zone {
/* Read-mostly fields */
/* zone 的“水位线”, 使用宏 *_wmark_pages(zone) 进行访问 */
unsigned long _watermark[NR_WMARK];
unsigned long watermark_boost;
unsigned long nr_reserved_highatomic;
/*
* 我们不知道我们将要分配的内存是否是可释放的 或/且 最终会被释放,
* 因此为了避免将整个的几个 GB 的 RAM浪费掉,
* 我们必须要保留一些 lower zone memory
* (否则我们将有在 lower zones 上耗尽所有内存(OOM)的风险,
* 尽管此时在 higher zones 仍有大量的 RAM).
* 若 sysctl_lowmem_reserve_ratio 系统控制项改变,
* 这个数组有可能在运行时被改变
*/
long lowmem_reserve[MAX_NR_ZONES];
#ifdef CONFIG_NUMA
int node;
#endif
struct pglist_data *zone_pgdat;
struct per_cpu_pageset __percpu *pageset;
/*
* the high and batch values are copied to individual pagesets for
* faster access
*/
int pageset_high;
int pageset_batch;
#ifndef CONFIG_SPARSEMEM
/*
* 单个 pageblock_nr_pages block 的标志位. 参见 pageblock-flags.h.
* 在 SPARSEMEM 中, 该 map 存放于 struct mem_section 中
*/
unsigned long *pageblock_flags;
#endif /* CONFIG_SPARSEMEM */
/* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
unsigned long zone_start_pfn;
/*
* spanned_pages 为该 zone 所包含的 pages 的范围, 包括空洞
* 计算方式如下:
* spanned_pages = zone_end_pfn - zone_start_pfn;
*
* present_pages 为该 zone 中存在的物理页框数
* 计算方式如下:
* present_pages = spanned_pages - absent_pages(pages in holes);
*
* managed_pages 为现有的由 buddy system 管理的页面数量,
* 计算方式如下 (reserved_pages 包括由 bootmem allocator 分配的页面):
* managed_pages = present_pages - reserved_pages;
*
* present_pages 可能会被内存热拔插或内存电源管理逻辑
* 通过检查(present_pages - managed_pages)来算出未被管理的页面.
* managed_pages 应被页面分配器与 vm 扫描器用以计算所有的水位线与阈值
*
* 锁规则:
*
* zone_start_pfn 与 spanned_pages 由 span_seqlock 保护.
* 这是一个顺序锁(seqlock,译者补充:写优先锁)因为他得在 zone->lock 之外被读取,
* 在主分配器路径中完成.
* 但他确实不经常被写入。
*
* span_seq lock 随着 zone->lock 被定义,因为相较于 zone->lock,
* 他经常被读取. 让他们有个机会在同一条缓存线(cacheline)上一件好事
*
* 运行时 present_pages 应当由 mem_hotplug_begin/end() 进行保护.
* 任何无法忍受 present_pages 的应当使用 get_online_mems()来获得固定的值.
*/
atomic_long_t managed_pages;
unsigned long spanned_pages;
unsigned long present_pages;
const char *name;
#ifdef CONFIG_MEMORY_ISOLATION
/*
* 独立的 pageblock 的数量. 用以解决由于对 pagelock
* 的 migratetype 的竞态检索导致的对 freepage 的错误计数.
* 由 zone->lock 保护
*/
unsigned long nr_isolate_pageblock;
#endif
#ifdef CONFIG_MEMORY_HOTPLUG
/* 参见 spanned/present_pages 以获得更多描述 */
seqlock_t span_seqlock;
#endif
int initialized;
/* 供页分配器使用的写敏感字段 */
ZONE_PADDING(_pad1_)
/* 不同 sizes 的闲置区域 */
struct free_area free_area[MAX_ORDER];
/* zone 标志位 */
unsigned long flags;
/* 主要保护 free_area */
spinlock_t lock;
/* 供 compaction and vmstats 使用的写敏感字段. */
ZONE_PADDING(_pad2_)
/*
* 当闲置页在这一点下时, 在读取闲置页数量时会采取额外的步骤
* 以避免 per-cpu 计数器
* 漂移导致水位线被突破
*/
unsigned long percpu_drift_mark;
#if defined CONFIG_COMPACTION || defined CONFIG_CMA
/* pfn where compaction free scanner should start */
unsigned long compact_cached_free_pfn;
/* pfn where compaction migration scanner should start */
unsigned long compact_cached_migrate_pfn[ASYNC_AND_SYNC];
unsigned long compact_init_migrate_pfn;
unsigned long compact_init_free_pfn;
#endif
#ifdef CONFIG_COMPACTION
/*
* On compaction failure, 1<<compact_defer_shift compactions
* are skipped before trying again. The number attempted since
* last failure is tracked with compact_considered.
* compact_order_failed is the minimum compaction failed order.
*/
unsigned int compact_considered;
unsigned int compact_defer_shift;
int compact_order_failed;
#endif
#if defined CONFIG_COMPACTION || defined CONFIG_CMA
/* Set to true when the PG_migrate_skip bits should be cleared */
bool compact_blockskip_flush;
#endif
bool contiguous;
ZONE_PADDING(_pad3_)
/* Zone 的统计数据 */
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
atomic_long_t vm_numa_stat[NR_VM_NUMA_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;页面迁移机制
页面迁移(page migration)主要用于缓解和解决内核空间中的内存碎片问题。
在系统长期运行的过程中,物理内存会不断经历页面分配与释放,尤其是在频繁进行小块内存分配、释放以及不同生命周期对象混合存在的情况下,空闲页面往往会逐渐呈现出高度零散的分布状态。虽然从“空闲页面总量”来看内存可能仍然充足,但这些空闲页面在物理地址空间中并不连续,从而形成了所谓的物理内存碎片。
这种碎片化状态会直接影响内核对大块连续物理内存的分配能力。例如,在需要分配高阶页(order > 0)、建立大页(Huge Page)、为 DMA 设备提供连续缓冲区,或进行内存热插拔和内存热迁移等操作时,内核往往要求物理内存在地址空间上保持连续。此时,即便系统中存在大量空闲页面,也可能因为无法拼凑出足够大的连续内存区间而导致分配失败。
传统的页面回收机制(如 LRU 回收)主要关注的是“释放页面数量”,而不是“重排页面位置”,因此并不能从根本上解决碎片问题。为此,内核引入了页面迁移机制:通过将已经分配但可移动的页面,从原有的物理页框复制并迁移到新的物理位置,再释放旧页框,从而在物理地址空间中重新整理页面布局,逐步聚合空闲页面,形成更大块的连续空闲内存。

但并非所有的页面都是能够随意迁移的,因此我们在 buddy system 当中还需要将页面按照迁移类型进行分类
迁移类型
迁移类型由一个枚举类型定义,定义于/include/linux/mmzone.h中,如下:
enum migratetype {
MIGRATE_UNMOVABLE,
MIGRATE_MOVABLE,
MIGRATE_RECLAIMABLE,
MIGRATE_PCPTYPES, /* the number of types on the pcp lists */
MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES,
#ifdef CONFIG_CMA
/*
* MIGRATE_CMA migration type is designed to mimic the way
* ZONE_MOVABLE works. Only movable pages can be allocated
* from MIGRATE_CMA pageblocks and page allocator never
* implicitly change migration type of MIGRATE_CMA pageblock.
*
* The way to use it is to change migratetype of a range of
* pageblocks to MIGRATE_CMA which can be done by
* __free_pageblock_cma() function. What is important though
* is that a range of pageblocks must be aligned to
* MAX_ORDER_NR_PAGES should biggest page be bigger then
* a single pageblock.
*/
MIGRATE_CMA,
#endif
#ifdef CONFIG_MEMORY_ISOLATION
MIGRATE_ISOLATE, /* can't allocate from here */
#endif
MIGRATE_TYPES
};MIGRATE_UNMOVABLE:这类页面在内存中有着固定的位置,不能被迁移。
典型代表包括内核代码段、内核数据结构、部分 slab 页面等。这些页面一旦移动,将导致内核指针失效或系统不稳定,因此只能驻留在原有物理页框中,是内存碎片整理中最大的“阻碍源”。
MIGRATE_MOVABLE:这类页面可以随意移动,例如用户空间匿名页或可迁移的用户页缓存。
迁移方式通常是:复制页面内容到新的物理页框,然后更新页表映射即可,对用户进程透明,是页面迁移与内存压缩(compaction)的主要对象。
MIGRATE_RECLAIMABLE:这类页面不能直接迁移,但可以通过回收方式释放,例如文件映射页(page cache)。
当需要整理内存时,内核可以选择将其写回磁盘或直接丢弃(若内容可重建),从而释放对应的物理页框,而不是进行页面复制。
MIGRATE_PCPTYPES:表示 per_cpu_pageset(每 CPU 页帧缓存)中支持的迁移类型数量
它本身不是一种实际的迁移类型,而是一个边界值,用于区分哪些 migratetype 可以进入 per-CPU page list。在该值之前的类型(UNMOVABLE / MOVABLE / RECLAIMABLE)才会出现在 PCP 链表中,其分配与回收通常局限在同一 NUMA 节点内。
MIGRATE_HIGHATOMIC:该类型用于 高优先级、原子上下文分配(如中断或紧急路径)。
这类页面块会被保留,用于满足
GFP_ATOMIC等不能失败的分配请求,其存在目的是避免在内存紧张或碎片严重时,关键路径因无法获得内存而导致系统不稳定。MIGRATE_CMA(在启用
CONFIG_CMA时存在):该类型用于 Contiguous Memory Allocator(CMA)其设计目的是为需要大块连续物理内存的设备(如 DMA 设备)预留内存区域。 CMA 区域中的页面在平时表现得像
MIGRATE_MOVABLE,允许被临时占用; 当设备真正需要连续内存时,这些页面会被迁移走,从而腾出一整块连续物理内存。MIGRATE_ISOLATE(在启用
CONFIG_MEMORY_ISOLATION时存在):不能从该迁移类型对应的页面块中进行普通页面分配。该类型主要用于页面隔离,例如内存热插拔、NUMA 内存迁移或内存下线操作。 被标记为
MIGRATE_ISOLATE的页面块会被暂时“冻结”,防止新的分配或干扰迁移过程。MIGRATE_TYPES:表示迁移类型的总数,仅用于数组大小或边界判断。它并不对应任何实际的页面链表或迁移行为。
几个比较重要的字段
_watermark:“水位线”
在 Linux 内核的内存管理中,每一个 zone 都维护了一组被称为“水位线(watermark)”的阈值,用于描述当前内存的紧张程度。这些水位线存放在 zone->_watermark 数组中,主要包括三档:WMARK_MIN、WMARK_LOW 和 WMARK_HIGH。
在系统长期运行的过程中,zone 中的空闲页面数量会随着内存分配与释放不断波动。为了避免在内存即将耗尽时才被动处理,内核通过水位线机制提前对内存状态进行分级。当空闲页面数量高于 WMARK_HIGH 时,内核认为该 zone 处于较为健康的状态,此时既不需要主动回收页面,也不会对普通内存分配施加额外限制;这是内核所期望维持的理想状态。
随着内存持续被分配,空闲页数量逐渐下降,当其低于 WMARK_LOW 时,内核会认为内存开始变得紧张。此时普通的页面分配通常仍然可以成功,但内核会唤醒后台回收线程 kswapd,开始主动回收不活跃页面,以防止内存进一步恶化。可以将 WMARK_LOW 理解为一种“预警线”,它并不意味着系统已经无法分配内存,而是提醒内核应当提前介入,避免进入危险状态。
如果空闲页数量继续减少并接近 WMARK_MIN,情况就会变得更加严峻。WMARK_MIN 是 zone 的最低安全水位线,内核需要尽可能保证该水位不被突破,以确保中断上下文或原子分配等关键路径仍然能够获得内存。一旦空闲页数量跌破这一阈值,普通内存分配往往会直接失败,只剩下少数高优先级、不可失败的分配请求仍被允许执行。
在实际的内存分配过程中,分配器(例如 buddy system)并不会只检查当前是否存在空闲页面,而是会结合分配后剩余的空闲页数量与水位线进行比较,判断这次分配是否会使 zone 进入不可接受的状态。与此同时,kswapd 也以水位线作为工作依据:当空闲页数量低于 WMARK_LOW 时被唤醒回收页面,直到空闲页数量回升至 WMARK_HIGH 附近才停止工作。正是这种上下限分离的设计,使得内存回收和分配之间形成了一个缓冲区,避免系统在回收与分配之间频繁抖动。

lowmem_reserve:zone 自身的保留内存
在内存分配过程中,如果当前 zone 中已经没有足够的空闲内存,分配器并不会立即失败,而是尝试向更低级别的 zone 申请内存。这种跨 zone 分配虽然提高了分配成功率,但也带来了潜在问题:来自 higher zone 的内存请求,可能会大量消耗 lower zone 中的内存,而这些被分配出去的页面并不一定是可回收的,甚至在较长时间内都不会被释放。这样一来,lower zone 可能会提前耗尽,而 higher zone 中却仍然保留着大量可用内存,最终影响系统的稳定性。
为避免出现这种情况,内核在每个 zone 中引入了 lowmem_reserve 机制,用于为该 zone 预留一部分内存。这部分内存被视为“保留资源”,在进行跨 zone 分配时不会被其他 zone 使用,从而防止 higher zone 的分配请求过度侵占 lower zone 的内存,确保关键 zone 在高内存压力下仍然具备基本的分配能力。
node:NUMA 中标识所属 node
这个字段只在 NUMA 系统中被启用,用以标识该 zone 所属的 node
可以参考下面的图:


zone_pgdat:zone 所属的 pglist_data 节点
该字段用以标识该 zone 所属的 pglist_data 节点
pageset:zone 为每个 CPU 划分一个独立的”页面仓库“
随着多 CPU 系统的普及,内存管理中不可避免地会面临条件竞争的问题。当多个 CPU 同时对同一个 zone 进行页面分配或释放操作时,如果所有操作都需要争抢同一把锁,那么频繁的加锁与解锁将带来显著的性能开销,严重时甚至会成为系统的性能瓶颈。尤其是在高并发场景下,单一的全局锁会使 CPU 在等待锁的过程中大量空转,降低整体的执行效率。
为了缓解这一问题,Linux 内核在 zone 中引入了 per_cpu_pageset 机制。其核心思想是:不再让所有 CPU 都直接访问 zone 中的全局空闲页链表,而是为每一个 CPU 准备一个独立的“页面仓库”。这些页面仓库以 percpu 变量的形式存在,每个 CPU 都只操作属于自己的那一份,从而在大多数情况下避免了跨 CPU 的锁竞争。
在系统运行初期或页面回收过程中,buddy system 会将部分空闲页面分发到各个 CPU 对应的 per_cpu_pageset 中。当某个 CPU 需要进行页面分配时,它会优先从自己的页面仓库中获取空闲页,这一过程通常不需要获取 zone 级别的锁,因此开销极低。只有当本地页面仓库耗尽,或者在释放页面需要回填到全局结构时,才会涉及到对 zone 的全局操作。通过这种“本地优先、全局兜底”的设计,内核在保证正确性的同时,大幅降低了多 CPU 场景下的同步成本。
该结构体定义于/include/linux/mmzone.h中,如下:
struct per_cpu_pages {
int count; /* 链表中页的数量 */
int high; /* 高水位线, 清空需要(笔者补:用以进行判断) */
int batch; /* chunk size for buddy add/remove */
/* 页面链表, 在 pcp-lists 上储存的独立的迁移类型 */
struct list_head lists[MIGRATE_PCPTYPES];
};
struct per_cpu_pageset {
struct per_cpu_pages pcp;
#ifdef CONFIG_NUMA
s8 expire;
u16 vm_numa_stat_diff[NR_VM_NUMA_STAT_ITEMS];
#endif
#ifdef CONFIG_SMP
s8 stat_threshold;
s8 vm_stat_diff[NR_VM_ZONE_STAT_ITEMS];
#endif
};该结构体会被存放在每个 CPU 自己独立的.data..percpu段中,以 CPU0 为例,结构如下图所示:

从整体结构上看,最外层的 CPU0's .data..percpu segment 表示 CPU0 对应的 percpu 内存区域。在 Linux 内核中,percpu 变量并不是一份全局共享的数据,而是“每个 CPU 都有自己的一份副本”,这些副本分别存放在各自 CPU 的 percpu 段中。图中画的是 CPU0 的那一份,CPU1、CPU2 等也都会有结构完全相同、但内容彼此独立的副本。
在这个 percpu 段中,per_cpu_pageset 是 zone 为每个 CPU 准备的页面缓存集合。它的核心成员是 per_cpu_pages,而真正存放页面的地方就在 per_cpu_pages 内部。这里可以把 per_cpu_pages 理解为“CPU 私有的小型空闲页池”,用于缓存从 buddy system 中分发下来的空闲页面。
per_cpu_pages 里最重要的部分就是按迁移类型划分的多个 free_list[]。图中分别画出了 free_list[MIGRATE_UNMOVABLE]、free_list[MIGRATE_MOVABLE]、free_list[MIGRATE_ISOLATE] 等链表,每一条链表中挂的都是 struct page。这样设计的目的,是在本地缓存层面就保持页面迁移类型的区分,避免不同 migratetype 的页面在后续回收到 buddy system 时相互污染,从而加剧内存碎片问题。
free_list[MIGRATE_UNMOVABLE]、free_list[MIGRATE_MOVABLE]、free_list[MIGRATE_ISOLATE]等链表是按照页面迁移属性对本地空闲页进行分类管理的结果:free_list[MIGRATE_UNMOVABLE]中保存的是那些来源于不可迁移页面块的空闲页。这类页面一旦被重新分配,通常会用于内核态或其他无法轻易迁移的用途,因此在内存整理和页面迁移时,它们往往会成为碎片的固定锚点。即便这些页面暂时空闲,内核也需要记住它们的迁移属性,以避免在后续的内存回收和压缩过程中打乱整体布局。free_list[MIGRATE_MOVABLE]中存放的则是可迁移页面对应的空闲页。这类页面主要面向用户空间分配或其他可移动对象,一旦将来发生内存压缩或 NUMA 迁移,这些页面可以被自由搬迁。因此,它们是页面迁移机制中最“友好”的一类,也是内核在进行内存整理时最希望优先使用的资源。free_list[MIGRATE_ISOLATE]则比较特殊,它对应的是被隔离出来的页面块。这类页面通常用于内存热插拔、内存下线或显式的页面迁移操作,在正常的分配路径中不应被使用。将它们单独放在一个链表中,可以确保普通分配请求不会意外消耗这些页面,从而干扰正在进行的内存管理操作。
从右侧的 page 链表可以看到,每个 free_list[...] 本质上是一条由 struct page 组成的链表,这些页面已经是空闲的、并且属于当前 CPU 本地缓存。当 CPU 需要分配页面时,分配路径会优先访问当前 CPU 的 per_cpu_pages,并根据所需的 migratetype 从对应的 free_list 中直接取页。这一过程通常不需要获取 zone 的全局锁,因此代价非常低,也是内核在多核系统上提升分配性能的关键手段。
当本地 free_list 中的页面被消耗到一定程度,或者在释放页面时本地缓存已满,内核才会将页面批量地从本地缓存回填到 zone 的 buddy system 中,或者从 buddy system 中重新补充一批页面到本地缓存。通过这种“批量进出、CPU 本地优先”的方式,内核将高频的小额分配操作与低频的全局操作分离开来,大幅降低了锁竞争。
zone_start_pfn:zone 的起始物理PFN
该字段用以标识该 zone 的起始物理页帧编号(page frame number)
spanned_pages: zone 对应的内存区域中的 pages 总数(包括空洞)
该字段用以标识该 zone 对应的内存区域中的 pages 总数,包括空洞
present_pages: zone 中存在的物理页框数
该字段用以标识 zone 中实际存在的物理页框数
managed_pages:zone 中 buddy system 管理的页面数量
该字段用以标识 zone 中 buddy system 管理的页面数量
free_area:buddy system 按照 order 管理的页面
该字段用以存储 buddy system 按照 order 管理的页面,为一个free_area结构体数组,该结构体定义如下:
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};在 free_area 中存放的页面通过自身的相应字段连接成双向链表结构,这里放一张 overview

free_area 中并非只有一个双向链表,而是按照不同的“迁移类型”(migrate type)进行分开存放,这是由于_页面迁移_机制的存在
以free_area[0]作为例子,我们可以得到如下 overview:

vm_stat:统计数据
该数组用来进行数据统计,按照枚举类型zone_stat_item分为多个数组,以统计不同类型的数据(比如说NR_FREE_PAGES表示 zone 中的空闲页面1数量):
enum zone_stat_item {
/* First 128 byte cacheline (assuming 64 bit words) */
NR_FREE_PAGES,
NR_ZONE_LRU_BASE, /* Used only for compaction and reclaim retry */
NR_ZONE_INACTIVE_ANON = NR_ZONE_LRU_BASE,
NR_ZONE_ACTIVE_ANON,
NR_ZONE_INACTIVE_FILE,
NR_ZONE_ACTIVE_FILE,
NR_ZONE_UNEVICTABLE,
NR_ZONE_WRITE_PENDING, /* Count of dirty, writeback and unstable pages */
NR_MLOCK, /* mlock()ed pages found and moved off LRU */
/* Second 128 byte cacheline */
NR_BOUNCE,
#if IS_ENABLED(CONFIG_ZSMALLOC)
NR_ZSPAGES, /* allocated in zsmalloc */
#endif
NR_FREE_CMA_PAGES,
NR_VM_ZONE_STAT_ITEMS };flags:标志位
该 zone 的标志位,用以标识其所处的状态
zone 的分类
在 Linux kernel 当中,我们根据内存区段的不同用途,将其划分为不同的 zone,在/include/linux/mmzone.h中有着相应的枚举定义,如下:
enum zone_type {
/*
* 当存在无法对整个可寻址内存(ZONE_NORMAL)进行 DMA 的外设时,
* 会使用 ZONE_DMA 和 ZONE_DMA32。
*
* 在某些体系结构上,如果该区域覆盖了整个 32 位地址空间,
* 则使用 ZONE_DMA32;而 ZONE_DMA 则保留给 DMA 寻址能力
* 更受限的设备。
*
* 这种区分非常重要,因为在定义 ZONE_DMA32 时,
* 内核会假定 DMA 设备具有 32 位的 DMA mask。
*
* 一些 64 位平台可能同时需要这两个 zone,
* 因为它们可能支持具有不同 DMA 寻址限制的外设。
*/
#ifdef CONFIG_ZONE_DMA
ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
ZONE_DMA32,
#endif
/*
* 普通可寻址内存位于 ZONE_NORMAL 中。
* 如果 DMA 设备支持对所有可寻址内存进行传输,
* 那么位于 ZONE_NORMAL 中的页面也可以用于 DMA 操作。
*/
ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
/*
* 该内存区域只能通过将部分内存映射到内核自身的地址空间
* 来被内核访问。
*
* 例如在 i386 架构上,该机制用于让内核能够访问
* 超过 900MB 的物理内存。
*
* 内核会为其需要访问的每一个页面建立特殊的映射
*(在 i386 上即页表项)。
*/
ZONE_HIGHMEM,
#endif
/*
* ZONE_MOVABLE 与 ZONE_NORMAL 类似,
* 不同之处在于它主要包含可迁移页面,
* 但存在下文描述的一些特殊例外情况。
*
* ZONE_MOVABLE 的主要用途包括:
* 提高内存下线/热拔插操作成功的概率,
* 以及在局部范围内限制不可迁移分配,
* 例如增加 THP / 大页的可用数量。
*
* 需要注意的一些特殊情况包括:
*
* 1. 被固定(pinned)的页面:
* 对可迁移页面进行长期固定,可能会使这些页面
* 实际上变得不可迁移,导致内存下线操作长时间重试。
*
* 2. memblock 分配:
* 在 kernelcore/movablecore 配置下,
* 启动后 ZONE_MOVABLE 中可能会包含不可迁移的分配,
* 从而导致内存下线和分配较早失败。
*
* 3. 内存空洞:
* 在某些极少见的情况下,
* kernelcore/movablecore 配置可能会使
* ZONE_MOVABLE 在启动后包含内存空洞,
* 例如某些 section 仅被部分填充。
*
* 4. PG_hwpoison 页面:
* 在内存下线过程中可以跳过被污染的页面,
* 但这些页面不能被重新分配。
*
* 5. 不可迁移的 PG_offline 页面:
* 在半虚拟化环境中,热插的内存块可能仅有一部分
* 由 buddy system 管理
* (例如 XEN balloon、Hyper-V balloon、virtio-mem)。
* 未被 buddy 管理的部分即为不可迁移的 PG_offline 页面。
*
* 在某些情况下(如 virtio-mem),
* 这些页面在内存下线时可以被跳过,
* 但不能被迁移或分配。
*
* 某些技术可能会使用 alloc_contig_range()
* 将之前暴露给 buddy system 的页面重新隐藏起来,
* 例如在 virtio-mem 中实现某种形式的内存拔除。
*
* 总体而言,不应有会降低内存下线成功率的不可迁移分配
* 出现在 ZONE_MOVABLE 中。
*
* 分配器(如 alloc_contig_range())必须预期:
* 即使 has_unmovable_pages() 表示不存在不可迁移页面,
* ZONE_MOVABLE 中的页面迁移仍然可能失败
*(该检查可能存在误判)。
*/
ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
ZONE_DEVICE,
#endif
__MAX_NR_ZONES
};X86-32下的zone划分
在 x86-32 架构下,虽然物理地址空间理论上可以达到 4GB,但由于内核虚拟地址空间本身受限,内核并不能将全部物理内存永久、线性地映射到自己的地址空间中。Linux 通常只会将从物理地址 0 开始的一段内存直接映射到内核虚拟地址空间,这一部分被称为线性映射区。
基于这一限制,内核将物理内存划分为不同的 zone。最底部的 0–16MB 被划为 ZONE_DMA,用于满足早期 DMA 设备的寻址需求;接下来的 16MB–896MB 属于 ZONE_NORMAL,这部分内存可以被内核直接线性映射和高效访问;而超过 896MB 的物理内存则划入 ZONE_HIGHMEM。高端内存并非不可用,而是无法被永久映射,内核只能通过临时映射的方式按需访问,因此其使用成本更高。
因此,在 x86-32 系统中,我们通常可以将内存简化地理解为两大区域:前 896MB 的线性映射内存,以及之后需要通过特殊机制访问的高端内存。zone 的划分本质上反映的并不是内存“类型”的差异,而是内核对不同物理内存区域的访问方式和映射能力的不同。

X86-64下的zone划分
在 64 位 Linux 内核中,由于虚拟地址空间极其充裕,内核可以将绝大多数物理内存永久、线性地映射到自身地址空间中,因此不再存在 32 位系统中由于地址空间不足而引入的“高端内存(ZONE_HIGHMEM)”概念。
如图所示,内存主要根据 DMA 设备的寻址能力 进行划分:最底部的 ZONE_DMA(0–16MB)用于兼容只能访问低地址内存的老旧设备;ZONE_DMA32(16MB–4GB)用于只能进行 32 位 DMA 寻址的设备;而超过 4GB 的物理内存则统一归入 ZONE_NORMAL。在 64 位系统中,ZONE_NORMAL 中的页面都可以被内核直接访问,无需额外的映射机制。

struct pglist_data:节点
在 Linux 的内存层级结构中,zone 之上是 节点(node)。节点是内核在更高层次上对物理内存进行组织的单位,其划分依据内存控制器(Memory Controller,MC)。内核认为:由同一个内存控制器管理的内存,具有相同的访问代价和拓扑属性,因此应当归属于同一个节点。
在 UMA(Uniform Memory Access)架构下,系统中所有 CPU 访问内存的延迟是统一的,通常只存在一个内存控制器,因此整个系统也只有一个节点;而在 NUMA(Non-Uniform Memory Access)架构中,系统往往包含多个内存控制器,每个控制器分别连接一部分物理内存,并与一组 CPU 形成紧密绑定关系。此时,Linux 会为每一个内存控制器创建一个对应的节点。
对于某个 CPU 而言,与其处在同一内存控制器下的节点被称为本地节点(local node),访问该节点中的内存具有最低的延迟和最高的带宽;而访问其他节点中的内存则需要经过处理器之间的互连总线(如 QPI、UPI、Infinity Fabric 等),访问代价相对更高。正是由于这种访问代价的不均匀性,内核在 NUMA 系统中会尽量将内存分配到靠近执行该任务的 CPU 所属节点上,以提升整体性能。
从结构上看,一个节点内部通常包含多个 zone(如 DMA、DMA32、NORMAL 等),而多个节点之间通过处理器互连形成一个整体系统。换句话说,zone 解决的是“内存用途和访问限制”的问题,而 node 解决的是“内存拓扑和访问距离”的问题。如下图所示,一个内存控制器对应一个节点,节点内部再进一步划分为多个 zone。

一个节点使用pglist_data结构进行描述,该结构定义于/include/linux/mmzone.h中,如下:
/*
* 在 NUMA 机器上, 每个 NUMA 节点都有一个 pg_data_t 用以描述其内存布局。
* 在 UMA 机器上则只有一个单独的 pglist_data 描述整个内存。
*
* 内存统计数据与页置换数据结构由一个 per-zone basis维持
*/
typedef struct pglist_data {
/*
* node_zones 字段包含该节点所拥有的 zones。 并非所有的 zone 都已被填充,但这是一个满的列表。
* 它被该节点的 node_zonelists 以及其他节点的 node_zonelists 所引用.
*
*/
struct zone node_zones[MAX_NR_ZONES];
/*
* node_zonelists 包含有对所有节点中所有区的引用。
* 通常第一个区将会作为该节点的 node_zones 的引用.
*/
struct zonelist node_zonelists[MAX_ZONELISTS];
int nr_zones; /* 该节点中被填充的 zone 的数量 */
#ifdef CONFIG_FLAT_NODE_MEM_MAP /* 即 SPARSEMEM */
struct page *node_mem_map;
#ifdef CONFIG_PAGE_EXTENSION
struct page_ext *node_page_ext;
#endif
#endif
#if defined(CONFIG_MEMORY_HOTPLUG) || defined(CONFIG_DEFERRED_STRUCT_PAGE_INIT)
/*
* 若你期望 node_start_pfn, node_present_pages,
* node_spanned_pages 或 nr_zones 保持不变,
* 必须在任何时刻持有(这个锁)。
* 同时在 deferred page 初始化期间对 pgdat->first_deferred_pfn 进行同步。
*
* (内核)提供了 pgdat_resize_lock() 与 pgdat_resize_unlock()
* 以在没有对 CONFIG_MEMORY_HOTPLUG 或 CONFIG_DEFERRED_STRUCT_PAGE_INIT
* 进行检查的情况下操纵 node_size_lock
*
* 基于 zone->lock 与 zone->span_seqlock
*/
spinlock_t node_size_lock;
#endif
unsigned long node_start_pfn;
unsigned long node_present_pages; /* 所有物理页的数量 */
unsigned long node_spanned_pages; /* 所有物理页的大小,包括空洞 */
int node_id;
wait_queue_head_t kswapd_wait;
wait_queue_head_t pfmemalloc_wait;
struct task_struct *kswapd; /* 由 mem_hotplug_begin/end() 保护 */
int kswapd_order;
enum zone_type kswapd_highest_zoneidx;
int kswapd_failures; /* 进行了 'reclaimed == 0' 判断的次数 */
#ifdef CONFIG_COMPACTION
int kcompactd_max_order;
enum zone_type kcompactd_highest_zoneidx;
wait_queue_head_t kcompactd_wait;
struct task_struct *kcompactd;
#endif
/*
* 这是每个 node 保留的对用户空间分配不可用的页面
*/
unsigned long totalreserve_pages;
#ifdef CONFIG_NUMA
/*
* 若存在更多的未映射页面,则节点回收将会变得活跃
*/
unsigned long min_unmapped_pages;
unsigned long min_slab_pages;
#endif /* CONFIG_NUMA */
/* 页回收使用的写敏感字段 */
ZONE_PADDING(_pad1_)
#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT
/*
* 若在大机器上的内存初始化被推迟了,那么这是
* 第一个需要被初始化的 PFN
*/
unsigned long first_deferred_pfn;
#endif /* CONFIG_DEFERRED_STRUCT_PAGE_INIT */
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
struct deferred_split deferred_split_queue;
#endif
/* 页回收扫描器通常访问的字段 */
/*
* NOTE: 若开启了 MEMCG 则其将不会被使用
*
* 使用 mem_cgroup_lruvec() 以查询 lruvecs.
*/
struct lruvec __lruvec;
unsigned long flags;
ZONE_PADDING(_pad2_)
/* Per-node vmstats */
struct per_cpu_nodestat __percpu *per_cpu_nodestats;
atomic_long_t vm_stat[NR_VM_NODE_STAT_ITEMS];
} pg_data_t;几个比较重要的字段
node_zones:node 的 zone 列表
节点中最重要的字段node_zones作为一个zone 结构体数组记录了本节点上所有的 zone,其中有效的 zone 的个数由节点结构体的nr_zones字段指出
node_zonelists:内存分配时备用 zone 的搜索顺序
该字段用以确定内存分配时对备用的 zone 的搜索顺序,在本节点常规内存分配失败时会沿着这个数组进行搜索,其中包含的 zone可以是非本节点的 zone
这是一个其为一个zonelist类型的结构体数组,该结构体定义如下:
/*
* 单次分配请求在一个 zonelist 上操作. 一个 zonelist 便是一组 zone 的列表,
* 其中第一个 zone 为分配的“目标”,而其他的 zone 为后备的zone,优先级降低。
*
* 为了提高 zonelist 的读取速度, 在 zonerefs 中包含正在被读取的 entry 的 zone index。
* 用来访问所给的 zoneref 结构体信息的帮助函数有:
*
* zonelist_zone() - 返回一个 struct zone 的指针作为 _zonerefs 中的一个 entry
* zonelist_zone_idx() - 返回作为 entry 的 zone 的 index
* zonelist_node_idx() - 返回作为 entry 的 node 的 index
*/
struct zonelist {
struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
};可以看到的是其为一个zoneref类型的结构体数组,该结构体定义如下,包含了一个 zone 的指针以及一个 index:
/*
* 该结构包含了 zonelist 中一个 zone 的信息。
* 其被储存在这里以预防对大结构体的解引用与对表的查询。
*/
struct zoneref {
struct zone *zone; /* 指向实际上的 zone 的指针 */
int zone_idx; /* zone_idx(zoneref->zone) */
};nr_zones:node 中 zone 的数量
该字段存储了该节点中所有可用的 zone 的数量
node_start_pfn:node 的起始页框标号
该字段记录了该节点上的物理内存起始页框标号
node_present_pages:node 中物理页的总数量
该字段记录了节点中可用的物理页的总数量
unsigned long node_spanned_pages: node 中物理页的总大小
该字段记录了节点上包括空洞在内的页帧为单位的该节点内存的总长度
node_id:node 的标号
该字段记录了该节点在系统中的标号,从 0 开始
node 存储方式:全局数组 node_data[]
在/arch/x86/mm/numa.c中定义了一个 pglist_data 数组,如下:
struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;
EXPORT_SYMBOL(node_data);该数组中保存了系统中的所有的节点
由此,我们最终得到这样一张架构图:

我们可以使用numactl工具来查看系统中的节点信息,如下:

node 状态:全局数组 node_states[]
在/mm/page_alloc.c中定义了一个全局数组node_states用以标识对应标号的节点的状态,如下:
/*
* Array of node states.
*/
nodemask_t node_states[NR_NODE_STATES] __read_mostly = {
[N_POSSIBLE] = NODE_MASK_ALL,
[N_ONLINE] = { { [0] = 1UL } },
#ifndef CONFIG_NUMA
[N_NORMAL_MEMORY] = { { [0] = 1UL } },
#ifdef CONFIG_HIGHMEM
[N_HIGH_MEMORY] = { { [0] = 1UL } },
#endif
[N_MEMORY] = { { [0] = 1UL } },
[N_CPU] = { { [0] = 1UL } },
#endif /* NUMA */
};
EXPORT_SYMBOL(node_states);C在这里的nodemask_t类型为一个位图类型,定义于/include/linux/nodemask.h中,如下:
typedef struct { DECLARE_BITMAP(bits, MAX_NUMNODES); } nodemask_t;这个状态由一个枚举类型node_states定义,该枚举类型定义于/include/linux/nodemask.h中,如下:
/*
* 位掩码将为所有节点保存
*/
enum node_states {
N_POSSIBLE, /* 节点在某个时刻是联机的 */
N_ONLINE, /* 节点是联机的 */
N_NORMAL_MEMORY, /* 节点有着普通的内存 */
#ifdef CONFIG_HIGHMEM
N_HIGH_MEMORY, /* 节点有着普通或高端内存 */
#else
N_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
N_MEMORY, /* 节点有着内存(普通,高端,可移动) */
N_CPU, /* 节点有着一个或多个 cpu */
N_GENERIC_INITIATOR, /* 节点有一个或多个 Generic Initiators */
NR_NODE_STATES
};