基于 Linux Kernel 源码深度分析(kernel 6.x mainline) 源码根目录:
/Users/zt/src/linux-projects/linux
- 内存管理总体架构
- 物理内存组织:NUMA → Zone → Page 三层模型
- struct page 与 struct folio 详解
- page 标志位系统
- 伙伴系统(Buddy Allocator)深度剖析
- SLUB 分配器
- 虚拟内存:mm_struct 与 VMA
- 页表管理
- 缺页异常处理链
- 内存回收子系统
- MGLRU:多代 LRU
- OOM Killer
- Swap 子系统
- 综合视角:各层协作
Linux 内存管理(Memory Management,MM)是内核中最庞大、最精密的子系统。它的核心任务可以用一句话概括:用有限的物理内存,为无数进程提供相互隔离、看似无限的虚拟地址空间。
理解 Linux MM 设计之前,先回答几个基础问题:
问题 1:为什么需要虚拟内存? 如果所有进程直接使用物理地址,进程 A 的错误指针会破坏进程 B 的数据。虚拟内存通过页表隔离每个进程的地址空间,使得每个进程都认为自己独占从 0 到最大值的地址范围。
问题 2:为什么需要页(Page)而不是字节级管理? 以字节为单位管理内存的元数据开销是灾难性的。4GB 内存若以字节管理,元数据本身就需要数 GB 空间。以 4KB 页为单位,元数据开销可控(每页约 64 字节的 struct page)。
问题 3:为什么 Linux 不用简单的 malloc/free? 内核需要应对多种场景:中断上下文(不能睡眠)、DMA 设备(需连续物理内存)、大量小对象(需 slab 分配器)、大对象(需 buddy 分配器)。一套机制无法满足所有场景。
┌─────────────────────────────────────────────────────────────────┐
│ 用户空间应用程序 │
│ malloc() / mmap() / brk() │
└─────────────────────────┬───────────────────────────────────────┘
│ syscall
┌─────────────────────────▼───────────────────────────────────────┐
│ 虚拟内存管理层 │
│ mm_struct / vm_area_struct / Maple Tree / 缺页异常处理 │
│ mmap() → do_mmap() → vm_area_struct │
└─────┬───────────────────────────────────┬───────────────────────┘
│ │
┌─────▼──────────────┐ ┌─────────────▼─────────────────────┐
│ 页表管理层 │ │ 内存分配层 │
│ PGD→P4D→PUD→ │ │ kmalloc() → SLUB → buddy │
│ PMD→PTE │ │ alloc_pages() → buddy allocator │
│ TLB shootdown │ │ vmalloc() → 非连续物理内存 │
└─────┬──────────────┘ └─────────────┬─────────────────────┘
│ │
┌─────▼───────────────────────────────────▼─────────────────────┐
│ 物理内存组织层 │
│ NUMA Node (pglist_data) │
│ └── Zone (struct zone): DMA / DMA32 / Normal / Movable │
│ └── Page (struct page / folio) │
│ free_area[0..10]: 伙伴系统空闲链表 │
└────────────────────────────────┬───────────────────────────────┘
│
┌────────────────────────────────▼───────────────────────────────┐
│ 内存回收层 │
│ kswapd → 水位监控 → LRU/MGLRU 扫描 → shrink_folio_list() │
│ OOM Killer → out_of_memory() → oom_kill_process() │
│ Swap → swap_writepage() / swap_readpage() │
└────────────────────────────────────────────────────────────────┘
| 数据结构 | 文件 | 职责 |
|---|---|---|
struct pglist_data |
include/linux/mmzone.h |
代表一个 NUMA 节点 |
struct zone |
include/linux/mmzone.h:879 |
一个内存区域 |
struct page |
include/linux/mm_types.h:79 |
一个物理页框 |
struct folio |
include/linux/mm_types.h:401 |
复合页抽象 |
struct mm_struct |
include/linux/mm_types.h:1123 |
进程虚拟地址空间 |
struct vm_area_struct |
include/linux/mm_types.h:913 |
一段虚拟内存区域 |
struct kmem_cache |
mm/slab.h:197 |
SLUB 缓存描述符 |
struct kmem_cache_node |
mm/slub.c:430 |
per-node slab 链表 |
struct free_area |
include/linux/mmzone.h:138 |
伙伴系统每阶空闲区 |
现代服务器普遍采用 NUMA(Non-Uniform Memory Access)架构:每个 CPU socket 拥有本地内存,访问本地内存比跨 socket 访问快 1.5~4 倍。Linux 用 struct pglist_data(别名 pg_data_t)表示一个 NUMA 节点。
NUMA 架构示意(双 socket 服务器):
┌──────────────────────┐ ┌──────────────────────┐
│ Node 0 │ │ Node 1 │
│ CPU 0-15 │ │ CPU 16-31 │
│ 本地内存 64GB │══════│ 本地内存 64GB │
│ pglist_data [0] │ QPI │ pglist_data [1] │
│ zones: DMA/Normal │ │ zones: Normal │
└──────────────────────┘ └──────────────────────┘
↑ 访问延迟 ~80ns ↑ 跨节点访问 ~160ns
pglist_data 的关键字段:
// include/linux/mmzone.h(约 1100+ 行)
struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 该节点所有区域
struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
int nr_zones; // 活跃区域数
unsigned long node_start_pfn; // 节点起始物理页号
unsigned long node_present_pages; // 实际存在的物理页数
unsigned long node_spanned_pages; // 跨度(含空洞)
int node_id; // NUMA 节点 ID
// kswapd 相关
wait_queue_head_t kswapd_wait;
wait_queue_head_t pfmemalloc_wait;
struct task_struct *kswapd; // 该节点的 kswapd 线程
int kswapd_order;
// ...
};为什么 zonelist 要有回退机制?
当本地节点内存不足时,分配器按 zonelist 顺序尝试其他节点的 zone,这叫 NUMA 回退(fallback)。回退牺牲访问局部性,但保证了分配成功率。
每个 NUMA 节点下的物理内存按用途划分为若干 zone。
// include/linux/mmzone.h:784
enum zone_type {
ZONE_DMA, // 低 16MB,兼容旧 ISA DMA 设备
ZONE_DMA32, // 低 4GB,32 位 DMA 设备
ZONE_NORMAL, // 内核可直接映射的内存(主力区)
ZONE_HIGHMEM, // 仅 32 位系统:高端内存,需 kmap 访问
ZONE_MOVABLE, // 可移动页,利于热插拔和大页分配
ZONE_DEVICE, // 设备持久内存(PMEM/DAX)
};为什么要分 DMA/DMA32/Normal? 早期 ISA 总线的 DMA 控制器只有 24 位地址线,只能访问前 16MB 物理内存(ZONE_DMA)。PCI 32 位 DMA 只能访问前 4GB(ZONE_DMA32)。现代 64 位 DMA 设备可访问全部内存(ZONE_NORMAL)。内核通过 zone 分层确保 DMA 缓冲区始终落在正确的物理地址范围内。
struct zone 的关键字段(include/linux/mmzone.h:879):
struct zone {
/* 水位线:控制内存回收触发时机 */
unsigned long _watermark[NR_WMARK]; // line 883
unsigned long watermark_boost;
long lowmem_reserve[MAX_NR_ZONES]; // 低端内存保留
struct per_cpu_pages __percpu *per_cpu_pageset; // per-CPU 页缓存
unsigned long zone_start_pfn; // 区域起始 PFN
atomic_long_t managed_pages; // 伙伴系统管理的页数
unsigned long spanned_pages; // 含空洞的总跨度
unsigned long present_pages; // 实际存在的页数
/* 核心:伙伴系统空闲链表数组 */
struct free_area free_area[NR_PAGE_ORDERS]; // line 999
spinlock_t lock; // 保护 free_area
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS]; // 统计计数器
} ____cacheline_internodealigned_in_smp;缓存行对齐的意义:____cacheline_internodealigned_in_smp 确保 zone 结构跨 CPU 缓存行对齐,避免不同 CPU 修改同一 zone 时产生 false sharing,这在 NUMA 多 socket 系统上至关重要。
水位线是 zone 内存管理的核心控制点(include/linux/mmzone.h:708):
enum zone_watermarks {
WMARK_MIN, // 最低水位:低于此紧急回收,阻塞分配者
WMARK_LOW, // 低水位:唤醒 kswapd 后台回收
WMARK_HIGH, // 高水位:kswapd 回收到此停止
WMARK_PROMO, // 晋升水位:NUMA 页面晋升参考
NR_WMARK
};内存水位线示意图:
free_pages
▲
│████████████████████ 高水位 (WMARK_HIGH)
│ kswapd 停止回收
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 低水位 (WMARK_LOW)
│ 唤醒 kswapd
│░░░░░░░░░░░░░░░░░░░░ 最低水位 (WMARK_MIN)
│ 直接回收,PF_MEMALLOC 特权分配
└────────────────────────────────────► 时间
水位线由 /proc/sys/vm/min_free_kbytes 控制,计算公式大致为:
WMARK_MIN=min_free_kbytes / PAGE_SIZE(各 zone 按比例分配)WMARK_LOW=WMARK_MIN * 5 / 4WMARK_HIGH=WMARK_MIN * 3 / 2
System
├── NUMA Node 0 (pglist_data)
│ ├── ZONE_DMA (struct zone)
│ │ ├── free_area[0]: 1-page 空闲块链表 × MIGRATE_TYPES
│ │ ├── free_area[1]: 2-page 空闲块链表 × MIGRATE_TYPES
│ │ ├── ...
│ │ └── free_area[10]: 1024-page 空闲块链表 × MIGRATE_TYPES
│ ├── ZONE_DMA32 (struct zone)
│ └── ZONE_NORMAL (struct zone)
│ ├── per_cpu_pageset[CPU0]: PCP 缓存
│ ├── per_cpu_pageset[CPU1]: PCP 缓存
│ └── free_area[0..10]: 伙伴系统
│ 每个 free_area 有 MIGRATE_TYPES 条链表:
│ [MIGRATE_UNMOVABLE] → page → page → ...
│ [MIGRATE_MOVABLE] → page → page → ...
│ [MIGRATE_RECLAIMABLE] → page → ...
│
└── NUMA Node 1 (pglist_data)
└── ZONE_NORMAL (struct zone)
└── ...
struct page(include/linux/mm_types.h:79)是内核中最重要的数据结构之一,每个物理页框对应一个。在 64 位系统上,它的大小约为 64 字节。
设计挑战:同一个物理页在不同时刻可能用于完全不同的目的(页缓存、匿名内存、slab 对象、页表、空闲页等),如何在 64 字节内兼容所有用途?答案是大量使用 union。
// include/linux/mm_types.h:79
struct page {
memdesc_flags_t flags; // 原子标志位(zone/node 编码 + 状态标志)
union {
/* 页缓存 / 匿名页 */
struct {
union {
struct list_head lru; // LRU 链表节点
struct list_head buddy_list; // 在伙伴系统中
struct list_head pcp_list; // 在 per-CPU 缓存中
struct llist_node pcp_llist;
};
struct address_space *mapping; // 所属 address_space(文件映射)
// 或 anon_vma(匿名映射,低位为1)
union {
pgoff_t __folio_index; // 在文件中的偏移(页为单位)
unsigned long share; // fsdax 引用计数
};
unsigned long private; // 页私有数据(buffer_head / swap entry / buddy order)
};
/* 网络 page_pool 页 */
struct {
unsigned long pp_magic;
struct page_pool *pp;
unsigned long dma_addr;
atomic_long_t pp_ref_count;
};
/* 复合页的尾页 */
struct {
unsigned long compound_head; // bit 0 = 1 表示这是尾页,其余位指向头页
};
/* RCU 释放 */
struct rcu_head rcu_head;
};
union {
unsigned int page_type; // 类型页(typed folio)标识
atomic_t _mapcount; // 非类型页:被映射到用户页表的次数(初始化为 -1)
};
atomic_t _refcount; // 引用计数(初始化为 1)
#ifdef CONFIG_MEMCG
unsigned long memcg_data; // Memory Cgroup 数据
#endif
} _struct_page_alignment;mapping 字段的双重用途:这是一个经典的内核技巧。当页用于文件映射时,mapping 指向 struct address_space(低位为 0);当页是匿名页时,mapping 指向 struct anon_vma,并将最低位置 1 作为区分标志。调用者用 PageAnon(page) 宏检查。
private 字段的多义性:
- 伙伴系统中的空闲页:存储该块的 order(大小)
- swapcache 页:存储
swp_entry_t(swap 槽位编码) - 文件系统页:存储
buffer_head *(缓冲头链表) - SLUB 页:存储 freelist 指针
struct folio(include/linux/mm_types.h:401)是 2021 年引入的新抽象,旨在统一处理大页(THP、hugetlb)和普通页。
引入 folio 的原因:大页(Transparent Huge Pages, THP)需要跨越多个连续 struct page,但很多代码用 struct page * 传递大页的头页,导致语义混乱——调用者不知道这个指针代表单个页还是整个大页。struct folio 明确表示"整个逻辑页"。
// include/linux/mm_types.h:401
struct folio {
union {
struct {
memdesc_flags_t flags; // 与 struct page.flags 对齐
union {
struct list_head lru; // LRU 链表
struct dev_pagemap *pgmap; // ZONE_DEVICE
};
struct address_space *mapping; // 地址空间
union {
pgoff_t index; // 在文件中的偏移
unsigned long share;
};
union {
void *private; // 私有数据
swp_entry_t swap; // swap 入口
};
atomic_t _mapcount; // 映射计数
atomic_t _refcount; // 引用计数
#ifdef CONFIG_MEMCG
unsigned long memcg_data;
#endif
};
struct page page; // 与 struct page 兼容
};
/* 大 folio 特有字段(第 2 个 struct page 的位置)*/
union {
struct {
unsigned long _flags_1;
unsigned long _head_1;
atomic_t _large_mapcount; // 整体映射计数
atomic_t _nr_pages_mapped; // 已映射的子页数
atomic_t _entire_mapcount; // 整体完整映射计数
atomic_t _pincount; // DMA pin 计数
mm_id_mapcount_t _mm_id_mapcount[2];
union {
mm_id_t _mm_id[2];
unsigned long _mm_ids;
};
};
};
/* 第 3 个 struct page 位置 */
union {
struct {
unsigned long _flags_2;
unsigned long _head_2;
struct list_head _deferred_list; // 延迟分裂列表
};
};
};FOLIO_MATCH 静态断言(include/linux/mm_types.h:508):确保 struct folio 与 struct page 的字段偏移完全对齐,使得 page_folio(page) 和 folio_page(folio, 0) 零开销转换。
page_folio() 宏(include/linux/mm_types.h:306):
#define page_folio(p) (_Generic((p),
const struct page *: (const struct folio *)_compound_head(p),
struct page *: (struct folio *)_compound_head(p)))这是 C11 泛型宏,编译期根据指针类型选择正确的 const 性,避免误用。
struct page 有两个引用计数:
| 计数器 | 访问方式 | 语义 |
|---|---|---|
_refcount |
page_ref_count() |
页框本身的引用数(谁持有这个 page) |
_mapcount |
page_mapcount() |
被用户页表映射的次数,-1 表示未映射 |
_refcount 的典型来源:
- 伙伴系统分配:设为 1
- 在 LRU 链表中:+1
- 每次用户页表映射:通过
_mapcount追踪(不增加 _refcount) get_page()/put_page():显式持有
当 _refcount 归零时,页框被释放回伙伴系统。
page->flags(实际类型 memdesc_flags_t,内含 unsigned long)是一个精心设计的复合字段:
64位 flags 字段布局(从高到低):
| SECTION | NODE | ZONE | LAST_CPUPID | KASAN_TAG | LRU_GEN | LRU_REFS | FLAGS |
↑高位 ↑NR_PAGEFLAGS↑低位
FLAGS(低位)存储页状态标志
高位字段(ZONE/NODE 等)用于快速定位页所属的 zone 和 NUMA 节点
这个设计的精妙之处:给定一个 struct page *,不需要额外内存访问就能得知它属于哪个 NUMA 节点和哪个 zone(通过 page_zonenum()、page_to_nid() 等函数)。
// include/linux/page-flags.h:93
enum pageflags {
PG_locked, // 页被锁定(I/O 进行中)。其他代码必须等待解锁
PG_writeback, // 页正在写回(writeback 进行中)
PG_referenced, // LRU 访问标记:最近被访问过(第二次机会)
PG_uptodate, // 页内容与磁盘一致(读取完成)
PG_dirty, // 页被修改,尚未写回磁盘
PG_lru, // 页在 LRU 链表中
PG_head, // 复合页头页标记(必须在 bit 6)
PG_waiters, // 有线程在等待此页解锁(与 PG_locked 同字节,bit 7)
PG_active, // 页在活跃 LRU 链表中
PG_workingset, // MGLRU:页属于工作集(被重新访问的 refault 页)
PG_owner_priv_1,// 所有者私用(文件系统用作 PG_checked/PG_swapcache)
PG_owner_2, // 所有者私用(用作 PG_anon_exclusive)
PG_arch_1, // 架构特定标志(通常表示 dcache/icache flush 状态)
PG_reserved, // 保留页,不能被页分配器分配
PG_private, // 页有私有数据(buffer_heads 附着于此)
PG_private_2, // 用于 FS-Cache 本地缓存状态
PG_reclaim, // 页即将被回收(同时用作 PG_readahead)
PG_swapbacked, // 页由 RAM/swap 支撑(匿名页或 shmem)
PG_unevictable, // 页不可驱逐(mlock'd 或 ramfs 等)
PG_dropbehind, // I/O 完成后丢弃(顺序读优化)
#ifdef CONFIG_MMU
PG_mlocked, // 页被 mlock() 锁定在内存中
#endif
#ifdef CONFIG_MEMORY_FAILURE
PG_hwpoison, // 硬件内存错误,绝对不能访问
#endif
};PG_head 必须在 bit 6 的原因:compound 页的尾页用 compound_head 字段(以 bit 0 = 1 标记)来指向头页,而头页检测用 test_bit(PG_head, &page->flags)。bit 6 确保与某些架构的 atomics 操作兼容,且与 compound_head 的 bit 0 不冲突。
PG_waiters 必须与 PG_locked 在同一字节(bit 7):wake_up_page_bit() 在解锁时需要同时检查是否有等待者,两者在同一字节可以用单次原子操作同时检查,避免额外的内存屏障。
同一个 bit 可以有多个别名(取决于页的用途):
// include/linux/page-flags.h:132(部分)
PG_readahead = PG_reclaim, // 预读控制
PG_swapcache = PG_owner_priv_1, // 页在 swap 缓存中
PG_anon_exclusive = PG_owner_2, // 匿名页排他所有权(COW 优化)
PG_mappedtodisk = PG_owner_2, // 所有 buffer_heads 已映射
PG_fscache = PG_private_2, // FS-Cache 缓存状态
PG_large_rmappable = PG_workingset, // 大页可反向映射标志这种复用节省了宝贵的 bit,但需要调用者了解上下文。
内核提供了一套统一的宏来访问标志位:
// 测试:PageXxx(page) → bool
PageLocked(page), PageDirty(page), PageAnon(page), ...
// 设置:SetPageXxx(page)
SetPageDirty(page), SetPageLocked(page), ...
// 清除:ClearPageXxx(page)
ClearPageDirty(page), ...
// 测试并设置(原子):TestSetPageXxx(page)
TestSetPageLocked(page), ...
// folio 版本:folio_test_xxx(folio)
folio_test_dirty(folio), folio_test_uptodate(folio), ...伙伴系统(Buddy System)解决的核心问题:如何高效分配和回收不同大小的连续物理内存块,同时减少外部碎片?
核心思想:
- 所有内存块的大小都是 2 的幂次(order 0 = 1 页, order 1 = 2 页, ..., order 10 = 1024 页)
- 每个内存块都有一个"伙伴"(buddy):大小相同、物理地址仅在该 order 的最高位不同
- 分配时从空闲链表取,不够大则分裂;释放时与伙伴合并
伙伴系统内存分裂示意(分配 order-0 时从 order-2 分裂):
order-2 空闲块(4页):
[A |B |C |D ]
分裂为两个 order-1:
[A |B ] [C |D ]
↑ 取走一个 ↑ 这个放入 order-1 链表
第二个 order-1 继续分裂:
[C ] [D ]
↑ 返回给用户 ↑ 这个放入 order-0 链表
合并示意(释放 order-0 的 C 页):
[C ] + [D ] → [C |D ] (order-1)
[C |D ] + [A |B ] → [A |B |C |D ] (order-2)
为什么用 2 的幂次?
- 判断伙伴只需 XOR 一个 bit:
buddy_pfn = pfn ^ (1 << order) - 对齐检查用位运算:
pfn & ((1 << order) - 1) == 0 - 无需额外数据结构来定位伙伴,O(1) 时间找到伙伴
// include/linux/mmzone.h:138
struct free_area {
struct list_head free_list[MIGRATE_TYPES]; // 每种迁移类型一条链表
unsigned long nr_free; // 总空闲块数(所有 migrate type 之和)
};zone 中有 NR_PAGE_ORDERS(= MAX_PAGE_ORDER + 1 = 11)个 free_area:
// include/linux/mmzone.h:999
struct free_area free_area[NR_PAGE_ORDERS];zone.free_area[] 结构:
free_area[0] (order-0, 4KB):
.free_list[UNMOVABLE] → page → page → NULL
.free_list[MOVABLE] → page → page → page → NULL
.free_list[RECLAIMABLE]→ page → NULL
.nr_free = 5
free_area[1] (order-1, 8KB):
.free_list[UNMOVABLE] → page-pair → NULL
.free_list[MOVABLE] → page-pair → page-pair → NULL
.nr_free = 3
...
free_area[10] (order-10, 4MB):
.free_list[MOVABLE] → 4MB-block → NULL
.nr_free = 1
// include/linux/mmzone.h:64
enum migratetype {
MIGRATE_UNMOVABLE, // 不可移动:内核数据、内核栈等
MIGRATE_MOVABLE, // 可移动:用户空间匿名页、文件页
MIGRATE_RECLAIMABLE, // 可回收:page cache、slab 可回收部分
MIGRATE_PCPTYPES, // PCP 列表的类型数量(= 3)
MIGRATE_HIGHATOMIC = MIGRATE_PCPTYPES, // 高优先级原子分配保留
MIGRATE_CMA, // CMA(连续内存分配器)专用
MIGRATE_ISOLATE, // 隔离中(内存热插拔、CMA 迁移)
MIGRATE_TYPES
};为什么要区分迁移类型?
不可移动的内核对象(如 struct inode)一旦分配就固定在那里,随着时间推移会形成"钉子",让大块连续内存无法合并。通过将不可移动页与可移动页分离到不同链表,减少碎片化对大块分配的影响。
回退(fallback)机制:当目标迁移类型耗尽时,按预定顺序回退到其他类型:
// mm/page_alloc.c(回退顺序表,概念示意)
static int fallbacks[MIGRATE_TYPES][4] = {
[MIGRATE_UNMOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_MOVABLE, ... },
[MIGRATE_MOVABLE] = { MIGRATE_RECLAIMABLE, MIGRATE_UNMOVABLE, ... },
[MIGRATE_RECLAIMABLE] = { MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, ... },
};分配调用链(以 alloc_pages(GFP_KERNEL, 0) 为例):
alloc_pages() [用户入口宏]
└── __alloc_pages_noprof() [mm/page_alloc.c:5279]
├── prepare_alloc_pages() [构建 alloc_context]
├── get_page_from_freelist() [快路径:mm/page_alloc.c:3808]
│ └── rmqueue() [从 zone 取页]
│ ├── rmqueue_pcplist() [per-CPU 缓存快路径]
│ └── __rmqueue() [从 free_area 取]
│ ├── __rmqueue_smallest() [按 order 查找]
│ └── __rmqueue_fallback() [迁移类型回退]
│
└── __alloc_pages_slowpath() [慢路径:mm/page_alloc.c:4710]
├── wake_all_kswapds() [唤醒 kswapd]
├── get_page_from_freelist() [再次尝试]
├── __alloc_pages_direct_compact() [内存规整]
├── __alloc_pages_direct_reclaim() [直接回收]
└── __alloc_pages_may_oom() [OOM 路径]
get_page_from_freelist()(mm/page_alloc.c:3808)核心逻辑:
static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
const struct alloc_context *ac)
{
struct zoneref *z;
struct zone *zone;
retry:
// 遍历 zonelist(按优先级:本地 zone → 远程 zone)
for_next_zone_zonelist_nodemask(zone, z, ac->highest_zoneidx, ac->nodemask) {
unsigned long mark;
// 检查水位线
mark = wmark_pages(zone, alloc_flags & ALLOC_WMARK_MASK);
if (!zone_watermark_fast(zone, order, mark, ...))
continue; // 水位不足,尝试下一个 zone
// 水位满足,尝试从该 zone 分配
page = rmqueue(ac->preferred_zoneref->zone, zone, order, ...);
if (page)
goto done;
}
// 所有 zone 都失败
return NULL;
}alloc_context 结构(mm/internal.h:657)封装了一次分配请求的上下文:
struct alloc_context {
struct zonelist *zonelist; // 按优先级排序的 zone 列表
nodemask_t *nodemask; // NUMA 节点掩码约束
struct zoneref *preferred_zoneref; // 首选 zone
int migratetype; // 目标迁移类型
enum zone_type highest_zoneidx; // 允许的最高 zone
bool spread_dirty_pages; // 是否分散脏页到各节点
};每个 CPU 在每个 zone 上维护一个小型页缓存(struct per_cpu_pages,include/linux/mmzone.h:744),避免频繁竞争 zone->lock:
struct per_cpu_pages {
spinlock_t lock; // 保护 lists
int count; // 当前缓存的页数
int high; // 上限:超过时批量归还给 buddy
int batch; // 批量操作大小
struct list_head lists[NR_PCP_LISTS]; // 按 order 和 migratetype 组织
};PCP 工作流程:
分配 order-0 页:
1. 检查 pcp->lists[migratetype] 是否有页
2. 有 → 直接从 pcp 取,无需持有 zone->lock
3. 没有 → 从 buddy 批量补充(batch 个页)到 pcp,再取
释放 order-0 页:
1. 放入 pcp->lists[migratetype]
2. 若 pcp->count > pcp->high → 批量归还给 buddy
释放调用链:
free_page() / put_page()
└── free_frozen_pages() / __free_pages()
└── free_unref_page() [order-0,经由 PCP]
└── free_unref_page_commit()
→ 放入 pcp 或直接归还 buddy
直接归还 buddy:
└── __free_pages_core() / free_one_page()
└── __free_one_page(page, pfn, zone, order, migratetype)
├── 循环尝试合并:
│ buddy_pfn = pfn ^ (1 << order) // 找伙伴
│ buddy = page + (buddy_pfn - pfn) // 伙伴的 struct page
│ if (PageBuddy(buddy) && buddy_order(buddy) == order):
│ 从链表移除伙伴
│ 合并:pfn &= ~(1 << order)
│ order++
│ 若 order < MAX_PAGE_ORDER: 继续合并
└── 将合并后的块加入 free_area[order].free_list[migratetype]
合并条件:
- 伙伴页必须也在 buddy 系统中(
PageBuddy(buddy)为真) - 伙伴的 order 必须相同
- 两者必须在同一个 zone
- 两者的 pfn 是正确的伙伴关系:
pfn ^ (1 << order) == buddy_pfn
当高 order 分配失败但总 free pages 足够时,内核启动内存规整:将可移动页从内存低地址区迁移到高地址区,腾出连续的低地址内存供大块分配使用。这也是 MIGRATE_MOVABLE 存在的重要价值。
规整前: 规整后:
[K][.][U][K][U][.] → [K][K][U][U][.][.]
↑ 合并为连续空闲块
K=内核不可移动 U=用户可移动 .=空闲
伙伴系统的最小分配单位是一个页(4KB)。但内核中大量对象远小于一个页(task_struct 约 7KB,inode 约 600 字节,dentry 约 200 字节)。如果每个对象都独占一个页,内部碎片(internal fragmentation)会极其严重。
Slab 分配器的核心思想:
- 按对象大小创建专用 kmem_cache(缓存池)
- 从伙伴系统批量获取若干页(称为 slab)
- 将 slab 切分为固定大小的对象槽位
- 维护 freelist(空闲对象链表),实现 O(1) 分配和释放
- 对象构造缓存:对象释放后保留初始化状态,下次分配时无需重新初始化
Linux 历史上有三种 slab 实现(SLAB、SLUB、SLOB),当前主流是 SLUB(2007 年引入),以简洁著称。
kmem_cache(全局描述符)
├── cpu_sheaves(per-CPU 快路径): struct slub_percpu_sheaves __percpu *
│ ├── main: struct slab_sheaf *(活跃 slab)
│ ├── spare: struct slab_sheaf *(备用 slab)
│ └── rcu_free: struct slab_sheaf *(kfree_rcu 批量释放)
│
└── node[MAX_NUMNODES]: struct kmem_cache_node *
├── partial: struct list_head(部分满的 slab 链表)
├── nr_partial: unsigned long(partial slab 数量)
└── barn: struct node_barn *(NUMA 优化的 slab 仓库)
kmem_cache 结构(mm/slab.h:197):
struct kmem_cache {
struct slub_percpu_sheaves __percpu *cpu_sheaves; // per-CPU slab
slab_flags_t flags; // SLAB_POISON / SLAB_RED_ZONE 等调试标志
unsigned long min_partial; // node->partial 链表的最小长度
unsigned int size; // 对象大小(含元数据对齐)
unsigned int object_size; // 原始对象大小(不含元数据)
struct reciprocal_value reciprocal_size; // 除法优化:对象索引计算
unsigned int offset; // freelist 指针在对象内的偏移
unsigned int sheaf_capacity; // 每个 sheaf 容纳的对象数
struct kmem_cache_order_objects oo; // 首选 slab 大小(order+objects)
struct kmem_cache_order_objects min; // 最小 slab 大小(OOM 时降级)
gfp_t allocflags; // 分配 slab 页时的 gfp flags
int refcount; // 缓存引用计数(destroy 时检查)
void (*ctor)(void *); // 对象构造函数(初始化一次)
unsigned int inuse; // 对象内 metadata 的偏移
unsigned int align; // 对象对齐要求
const char *name; // 缓存名称(仅用于显示)
struct list_head list; // 全局 slab_caches 链表节点
unsigned long random; // SLAB_FREELIST_HARDENED 随机化密钥
struct kmem_cache_node *node[MAX_NUMNODES]; // per-node 数据
};per-CPU sheaves 结构(mm/slub.c:420):
struct slub_percpu_sheaves {
local_trylock_t lock; // per-CPU 轻量级锁
struct slab_sheaf *main; // 活跃 slab sheaf,永不为 NULL
struct slab_sheaf *spare; // 备用 sheaf(空或满)
struct slab_sheaf *rcu_free; // kfree_rcu 批量释放队列
};per-node 结构(mm/slub.c:430):
struct kmem_cache_node {
spinlock_t list_lock; // 保护 partial 链表
unsigned long nr_partial; // partial slab 数量
struct list_head partial; // 部分使用的 slab 链表
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs; // 总 slab 数
atomic_long_t total_objects; // 总对象数
struct list_head full; // 已满 slab(仅 debug 模式追踪)
#endif
struct node_barn *barn; // NUMA 优化仓库
};kmalloc(size, GFP_KERNEL)
└── kmalloc_caches[type][index] [按大小选择 cache]
└── kmem_cache_alloc()
└── slab_alloc_node()
├── 快路径(per-CPU 无锁):
│ pcs = this_cpu_ptr(s->cpu_sheaves)
│ local_trylock(&pcs->lock)
│ object = slab_sheaf_alloc(pcs->main)
│ local_tryunlock(&pcs->lock)
│ return object ← 最快路径
│
└── 慢路径(需要补充 slab):
__slab_alloc()
├── 尝试 spare sheaf
├── 从 node->partial 链表取 slab
│ (需要 node->list_lock)
├── 若 partial 为空,向 buddy 申请新 slab
│ new_slab() → allocate_slab()
│ → alloc_pages() [向伙伴系统申请]
└── 从新 slab 分配对象
freelist 硬化(SLAB_FREELIST_HARDENED):freelist 指针会用随机密钥(kmem_cache->random)进行 XOR 编码,防止 heap overflow 或 use-after-free 漏洞利用中修改 freelist 指针来控制程序流。
kmalloc() 根据请求大小选择合适的 kmem_cache:
// include/linux/slab.h:584
#define KMALLOC_SHIFT_HIGH (PAGE_SHIFT + 1) // = 13,即 8KB
#define KMALLOC_SHIFT_MAX (MAX_PAGE_ORDER + PAGE_SHIFT) // = 22,即 4MB
#define KMALLOC_SHIFT_LOW 3 // = 8 字节(最小)kmalloc size class(典型配置,x86_64):
请求大小范围 → 实际分配大小 → 对应 kmem_cache
1 ~ 8 字节 → 8 字节 → kmalloc-8
9 ~ 16 字节 → 16 字节 → kmalloc-16
17 ~ 32 字节 → 32 字节 → kmalloc-32
33 ~ 64 字节 → 64 字节 → kmalloc-64
65 ~ 128 字节 → 128 字节 → kmalloc-128
129 ~ 256 字节 → 256 字节 → kmalloc-256
257 ~ 512 字节 → 512 字节 → kmalloc-512
513 ~ 1024字节 → 1024字节 → kmalloc-1k
1025 ~ 2048字节 → 2048字节 → kmalloc-2k
2049 ~ 4096字节 → 4096字节 → kmalloc-4k
4097 ~ 8192字节 → 8192字节 → kmalloc-8k(order-1 slab,直接分配)
> 8192 字节 → 向上取整到 2^n → 直接用 alloc_pages()
为什么在 8KB 以上直接用 alloc_pages?(include/linux/slab.h:584的注释)
SLUB 直接处理最大 order-1 页(PAGE_SIZE*2 = 8KB)的请求。更大的请求传递给页分配器,因为 slab 本身就是从页分配器获取的,没有必要维护更大的 slab cache。
kmalloc_type 区分(KMALLOC_NORMAL / KMALLOC_DMA / KMALLOC_CGROUP 等):不同 GFP 标志会选择不同的 cache 集合,例如 GFP_DMA 分配来自 ZONE_DMA 的对象。
kfree(ptr)
└── slab_free()
├── 快路径(对象属于当前 CPU 的 slab):
│ pcs = this_cpu_ptr(s->cpu_sheaves)
│ local_trylock(&pcs->lock)
│ 若对象属于 pcs->main 的 slab:
│ 直接放回 main->freelist(无锁,超快)
│ local_tryunlock()
│
└── 慢路径:
__slab_free(s, slab, object, ...)
├── 放回 slab 的 freelist
├── 若 slab 全空:
│ discard_slab() → 归还给 buddy(若超出 min_partial)
│ 或加入 node->partial
└── 若 slab 原来全满,现在有空闲:
加入 node->partial
每个进程(除内核线程外)都有一个 struct mm_struct(include/linux/mm_types.h:1123)描述其虚拟地址空间:
struct mm_struct {
struct {
struct {
atomic_t mm_count; // 持有引用数(mmgrab/mmdrop)
} ____cacheline_aligned_in_smp;
struct maple_tree mm_mt; // VMA 的 Maple Tree 索引(替代红黑树)
unsigned long mmap_base; // mmap 区域起始地址
unsigned long task_size; // 用户空间虚拟地址上限
pgd_t *pgd; // 页全局目录(硬件页表根指针)
atomic_t mm_users; // 用户引用数(mmget/mmput)
atomic_long_t pgtables_bytes; // 页表占用的内存(字节)
int map_count; // VMA 数量
spinlock_t page_table_lock; // 保护页表和部分计数器
struct rw_semaphore mmap_lock; // 保护 VMA 链表/树的读写锁
seqcount_t mm_lock_seq; // per-VMA lock 序列号
};
// ... 更多字段(start_code, start_stack, exe_file 等)
};mm_users vs mm_count 的区别:
mm_users:真正使用这个 mm 的用户数(线程数 + get_user_pages 等持有者),归零时释放用户资源mm_count:mm_struct 结构体本身的引用数(mm_users 计为 1),归零时释放 mm_struct 内存
线程(clone() 时共享 mm)共享同一个 mm_struct,mm_users 计数每个线程,而 mm_count 只计一次。
在 6.1 版本之前,VMA 由红黑树(rbtree)和链表双重索引:红黑树按地址范围快速查找,链表按地址顺序遍历。但维护两个数据结构带来了复杂性和锁竞争。
Linux 6.1(2022 年)引入 Maple Tree 替代红黑树,由 Oracle 开发,专为内核范围区间查找优化:
struct mm_struct {
struct maple_tree mm_mt; // 替代原来的 mm_rb + mmap 链表
};Maple Tree 的优势:
- B-tree 变体,节点存储多个区间,缓存行利用率更高(红黑树每节点只存一个值)
- 内置 RCU(Read-Copy-Update)支持,无锁读取 VMA
- 原生支持区间查询("找所有与 [addr, addr+len) 重叠的 VMA"),红黑树需要额外代码
- 支持 lockless 遍历,减少 mmap_lock 竞争
VMA(include/linux/mm_types.h:913)描述进程地址空间中一段连续的虚拟内存区域,是进程内存映射的基本单元:
struct vm_area_struct {
/* 第一缓存行:VMA 树遍历关键信息 */
union {
struct {
unsigned long vm_start; // 区域起始虚拟地址(含)
unsigned long vm_end; // 区域结束虚拟地址(不含)
};
freeptr_t vm_freeptr; // SLAB_TYPESAFE_BY_RCU 时的空闲指针
};
struct mm_struct *vm_mm; // 所属进程的 mm_struct
pgprot_t vm_page_prot; // 页保护属性(读/写/执行位)
const vm_flags_t vm_flags; // VM_READ / VM_WRITE / VM_EXEC / VM_SHARED 等
/* 匿名页反向映射(RMAP)*/
struct list_head anon_vma_chain; // 链入 anon_vma->rb_root
struct anon_vma *anon_vma; // 匿名内存 VMA
/* 文件映射后端 */
const struct vm_operations_struct *vm_ops; // mmap/fault/open/close 操作集
unsigned long vm_pgoff; // 在文件中的页偏移
struct file *vm_file; // 映射的文件(NULL 表示匿名映射)
void *vm_private_data; // 驱动私有数据
/* per-VMA 锁(减少 mmap_lock 竞争)*/
unsigned int vm_lock_seq; // 锁序列号
refcount_t vm_refcnt; // VMA 引用计数
/* 文件映射 rmap:interval tree 节点 */
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; // NUMA 分配策略
struct vma_numab_state *numab_state; // NUMA 负载均衡状态
#endif
} __randomize_layout;vm_flags 常见标志:
| 标志 | 含义 |
|---|---|
VM_READ |
可读 |
VM_WRITE |
可写 |
VM_EXEC |
可执行 |
VM_SHARED |
共享映射(MAP_SHARED) |
VM_MAYWRITE |
可以设置 VM_WRITE(mprotect 使用) |
VM_GROWSDOWN |
向下生长(栈) |
VM_LOCKED |
mlock() 锁定 |
VM_HUGETLB |
使用 Huge Pages |
VM_DONTCOPY |
fork 时不复制 |
VM_IO |
设备 I/O 映射 |
虚拟地址空间布局(x86_64,非 ASLR 简化版):
0xFFFFFFFFFFFFFFFF ┬─────────────────
│ 内核空间(128TB)
0xFFFF800000000000 ┼─────────────────
...(不可用的规范形式漏洞)
0x00007FFFFFFFFFFF ┼─────────────────
│ 用户栈(向下生长)
│ ↓
├── mmap 区域(从高到低:MAP_FIXED、共享库、匿名映射)
│ ↓
├── 堆(brk():向上生长)
│ ↑
├── BSS 段(未初始化全局变量)
├── Data 段(初始化全局变量)
├── Text 段(代码)
0x0000000000400000 ┼─────────────────
│ NULL 保护区(不可访问)
0x0000000000000000 ┴─────────────────
用户调用 mmap(addr, len, prot, flags, fd, offset)
syscall → sys_mmap()
└── ksys_mmap_pgoff()
└── vm_mmap_pgoff()
└── do_mmap()
├── 参数验证(地址对齐、长度、权限)
├── 寻找合适的空闲虚拟地址(若未指定 addr)
│ get_unmapped_area() → arch_get_unmapped_area()
├── 创建 VMA:
│ vm_area_alloc(mm) [从 slab 分配 vma]
│ vma->vm_start = addr
│ vma->vm_end = addr + len
│ vma->vm_flags = calc_vm_prot_bits(prot, flags)
│ vma->vm_file = file
│ vma->vm_pgoff = pgoff
├── 若有文件:call_mmap(file, vma)
│ → file->f_op->mmap(file, vma) [驱动/fs 注册 vm_ops]
├── 插入 Maple Tree:
│ vma_iter_store(&vmi, vma) [插入 mm->mm_mt]
└── 更新 mm->map_count / mm->total_vm
关键设计:mmap() 不立即建立物理内存映射,只创建 VMA 描述符(延迟分配策略)。物理内存在首次访问时通过缺页异常(page fault)分配。这是 demand paging 的核心。
x86_64 Linux 支持四级或五级页表(取决于 CONFIG_X86_5LEVEL):
四级页表(48 位虚拟地址):
虚拟地址:[47:39]=PGD索引 [38:30]=PUD索引 [29:21]=PMD索引 [20:12]=PTE索引 [11:0]=页内偏移
CR3 寄存器
└──► PGD(Page Global Directory,512 项)
└──► PUD(Page Upper Directory,512 项)
└──► PMD(Page Middle Directory,512 项)
└──► PTE(Page Table Entry,512 项)
└──► 物理页(4KB)
五级页表(57 位虚拟地址,加了 P4D 层):
CR3 → PGD → P4D → PUD → PMD → PTE → 物理页
每级页表项(Entry)的结构(以 PTE 为例):
PTE 格式(x86_64):
63 62:52 51:12 11 10 9 8 7 6 5 4 3 2 1 0
┌───┬────┬──────┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│NX │ OS │ PFN │G │PS│D │A │UC│WT│U │W │P │
└───┴────┴──────┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
│ │ │ │ │ │ │ │ │ │ └─ Present
│ │ │ │ │ │ │ │ │ └─── Writable
│ │ │ │ │ │ │ │ └────── User/Supervisor
│ │ │ │ │ │ │ └───────── Write-Through
│ │ │ │ │ │ └──────────── Cache Disable
│ │ │ │ │ └─────────────── Accessed(硬件置位)
│ │ │ │ └────────────────── Dirty(硬件置位)
│ │ │ └───────────────────── Page Size(PMD 时表示大页)
│ │ └──────────────────────── Global(TLB 全局条目)
│ └───────────────────────────────── 物理页帧号(PFN)
└─────────────────────────────────────────── No-Execute
// mm/memory.c:6372(在 __handle_mm_fault 中)
pgd = pgd_offset(mm, address); // PGD:mm->pgd[PGD_INDEX(address)]
p4d = p4d_alloc(mm, pgd, address); // P4D(五级页表用;四级时是 PGD 本身)
vmf.pud = pud_alloc(mm, p4d, address); // PUD
vmf.pmd = pmd_alloc(mm, vmf.pud, address); // PMD
// 最终到 PTE 层由 handle_pte_fault 处理每个 xxx_alloc() 函数:如果对应的页表页不存在,就从 ptdesc 分配新的页表页(来自 kmem_cache),并原子地插入父级页表项。
修改页表后,TLB(Translation Lookaside Buffer)中缓存的旧翻译必须失效。在多处理器系统中,这需要通知所有 CPU,称为 TLB shootdown:
TLB Shootdown 流程(以 munmap 为例):
CPU 0(执行 munmap):
1. 修改页表(清除 PTE)
2. 刷新本地 TLB:flush_tlb_range() / invlpg
3. 发送 IPI(处理器间中断)给其他所有 CPU
→ smp_call_function_many()
4. 等待其他 CPU 完成 TLB 刷新(可选:批量延迟处理)
其他 CPU 收到 IPI:
→ flush_tlb_func() [中断处理]
→ local_flush_tlb_range(info)
→ invlpg [x86 指令,使单个 TLB 条目无效]
mmu_gather 机制:为避免每次 PTE 修改都立即 shootdown(开销极大),Linux 用 struct mmu_gather(tlb.h)批量收集需要 shootdown 的 PTE,在 tlb_finish_mmu() 时统一执行一次 shootdown。这是 Linux 内存管理中典型的批量延迟优化。
当 CPU 访问一个虚拟地址,且该地址对应的 PTE 不存在(Present 位 = 0)或权限不足时,CPU 触发缺页异常(Page Fault),进入内核的缺页处理路径:
CPU 触发 Page Fault
└── arch 层(x86: arch/x86/mm/fault.c)
└── do_page_fault() / exc_page_fault()
└── handle_page_fault()
├── 判断地址是否在有效的 VMA 内
│ find_vma(mm, address)
│ 若不在 VMA → SIGSEGV
│
└── handle_mm_fault(vma, address, flags, regs)
└── __handle_mm_fault(vma, address, flags)
[mm/memory.c:6355]
├── 建立 PGD/P4D/PUD/PMD(若不存在)
├── 若是大页(THP):handle huge pmd/pud
└── handle_pte_fault(&vmf)
[mm/memory.c:6273]
├── PTE 不存在 → do_pte_missing()
│ ├── 匿名页 → do_anonymous_page()
│ └── 文件页 → do_fault() / vm_ops->fault()
├── PTE 存在但不 present(swap)→ do_swap_page()
├── NUMA 提示页 → do_numa_page()
└── 写保护 → do_wp_page()(COW)
当进程首次访问匿名映射区域(heap、stack、MAP_ANONYMOUS)时:
// mm/memory.c:5217
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct folio *folio;
pte_t entry;
// 读访问:映射零页(zero page),所有进程共享一个只读的全零物理页
if (!(vmf->flags & FAULT_FLAG_WRITE) && !mm_forbids_zeropage(vma->vm_mm)) {
entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
vma->vm_page_prot));
// 建立 PTE 指向零页(只读)
// → 写时才会真正分配物理页(COW)
goto setpte;
}
// 写访问:分配真实的物理页
folio = alloc_anon_folio(vmf); // 调用 alloc_pages(GFP_HIGHUSER_MOVABLE)
if (!folio) goto oom;
__folio_mark_uptodate(folio); // 标记内容有效(全零)
entry = folio_mk_pte(folio, vma->vm_page_prot);
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry), vma);
// 设置 PTE,建立虚拟→物理映射
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
// 将 folio 加入 LRU 链表
folio_add_lru_vma(folio, vma);
return 0;
oom:
return VM_FAULT_OOM;
}零页优化:读取新分配内存时,所有进程共享同一个物理零页(ZERO_PAGE()),完全无需分配。首次写入时,COW 机制才分配独立物理页。
COW(Copy-on-Write)是 fork() 高效实现的基础:
COW 触发场景(do_wp_page,mm/memory.c 约 3300 行):
场景:父进程 fork() 后写入共享页
1. fork() 时:子进程继承父进程所有 PTE,但将 PTE 设为只读
parent PTE: PA → [PFN=0x1234, W=0](只读)
child PTE: PA → [PFN=0x1234, W=0](只读)
2. 子进程写入地址 PA:
→ 触发 Page Fault(写保护违例)
→ do_wp_page():
a. 检查 folio 的 _refcount(是否只有我一个用户?)
若唯一持有者(refcount == 1):直接 mkwrite,无需复制
b. 否则:
new_folio = alloc_folio(GFP_HIGHUSER_MOVABLE)
copy_user_highpage(new_folio, old_folio, ...) // 复制内容
set_pte_at(..., new_pfn, writable_pte) // 更新子进程 PTE
folio_put(old_folio) // 释放对旧页的引用
3. 父进程的 PTE 仍指向旧页(不受影响)
PG_anon_exclusive 标志(include/linux/page-flags.h:146):表示此匿名 folio 被某个 VMA 独占,不被任何其他 VMA 共享。do_wp_page() 检查此标志来决定是否真正需要复制,减少不必要的页复制。
访问 mmap() 映射的文件区域:
do_fault() → do_read_fault() → do_cow_fault() → do_shared_fault()
↓
__do_fault(vmf)
↓
vm_ops->fault(vmf) [文件系统实现,如 ext4_filemap_fault]
↓
filemap_fault() [通用文件缓存缺页]
├── 查找 page cache:find_get_page(mapping, index)
├── 若在 cache 中:直接建立 PTE 映射
└── 若不在 cache:
folio_alloc(GFP_HIGHUSER_MOVABLE)
→ add_to_page_cache_lru()
→ submit_bio() [发起磁盘 I/O]
→ wait_on_page_locked() [等待 I/O 完成]
→ 建立 PTE 映射(VM_FAULT_MAJOR)
major fault vs minor fault:
- Minor fault(小缺页):物理页已在内存中(page cache 命中、COW 零页等),只需建立 PTE 映射,不需 I/O
- Major fault(大缺页):物理页不在内存中,需要从磁盘读取,
VM_FAULT_MAJOR置位
handle_mm_fault() 的 VM_FAULT_MAJOR 标志会更新 current->maj_flt 计数(mm/memory.c:6507),可通过 /proc/[pid]/stat 观察。
当空闲内存低于水位线时,内核必须回收内存:
内存压力来源:
alloc_pages() 发现 free < WMARK_LOW
↓
两条回收路径:
┌─────────────────────────────────┐
│ 后台回收:kswapd │
│ 条件:free < WMARK_LOW │
│ 目标:free > WMARK_HIGH │
│ 行为:异步,不阻塞分配者 │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ 直接回收:分配者自己回收 │
│ 条件:kswapd 回收不够快 │
│ 或 free < WMARK_MIN │
│ 行为:同步,阻塞当前进程 │
└─────────────────────────────────┘
kswapd:每个 NUMA 节点一个 kswapd 线程([kswapd0], [kswapd1]...),通过 pgdat->kswapd_wait 等待队列唤醒。
经典 LRU(include/linux/mmzone.h:316)将所有可回收页分为四个链表(加 unevictable):
enum lru_list {
LRU_INACTIVE_ANON = 0, // 不活跃匿名页:候选换出对象
LRU_ACTIVE_ANON = 1, // 活跃匿名页:最近被访问的匿名页
LRU_INACTIVE_FILE = 2, // 不活跃文件页:候选丢弃/写回对象
LRU_ACTIVE_FILE = 3, // 活跃文件页:最近被访问的文件页
LRU_UNEVICTABLE = 4, // 不可驱逐(mlock/ramfs)
NR_LRU_LISTS
};LRU 四链表状态机:
新分配页 ──────────────────────→ ACTIVE_ANON / ACTIVE_FILE
│
时间流逝/扫描
│
▼
INACTIVE_ANON / INACTIVE_FILE
│ │
最近被访问(PG_referenced) │
│ │ 回收扫描
▼ ▼
升回 ACTIVE shrink_folio_list()
│
┌─────────┴──────────┐
│ │
文件页 匿名页
丢弃(clean) 换出 swap
或写回(dirty)
LRU 链表由 struct lruvec 管理(include/linux/mmzone.h:669):
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; // 五条 LRU 链表
spinlock_t lru_lock; // 保护链表
unsigned long anon_cost; // 回收匿名页的历史代价
unsigned long file_cost; // 回收文件页的历史代价
atomic_long_t nonresident_age; // 非驻留年龄(workingset 检测)
unsigned long refaults[ANON_AND_FILE]; // 重新 fault 统计
#ifdef CONFIG_LRU_GEN
struct lru_gen_folio lrugen; // MGLRU 数据
#endif
};shrink_folio_list()(mm/vmscan.c:1083)是内存回收的核心函数,处理一批待回收的 folio:
static unsigned int shrink_folio_list(struct list_head *folio_list,
struct pglist_data *pgdat, struct scan_control *sc,
struct reclaim_stat *stat, bool ignore_references,
struct mem_cgroup *memcg)
{
while (!list_empty(folio_list)) {
folio = lru_to_folio(folio_list); // 取出候选 folio
folio_trylock(folio); // 获取页锁
// 检查是否正在 writeback(写回中的页不立即回收)
if (folio_test_writeback(folio)) {
// 等待写回完成(或放弃)
goto keep_locked;
}
// 是否最近被访问过?(第二次机会算法)
references = folio_check_references(folio, sc);
if (references == FOLIOREF_ACTIVATE) {
// 刚被访问,回到 active 链表
goto activate_locked;
}
// 文件页且脏:需要写回
if (folio_test_dirty(folio) && is_file_lru) {
// pageout():提交写回 I/O
if (pageout(folio, mapping) == PAGE_SUCCESS)
goto keep; // 写回中,暂不回收
}
// 解除所有用户页表的映射
if (folio_mapped(folio))
unmap_folio(folio); // → try_to_unmap() → RMAP 反向映射
// 匿名页:写入 swap
if (folio_test_anon(folio) && !folio_test_swapcache(folio)) {
add_to_swap(folio); // 分配 swap 槽位,写入 swap 设备
}
// 最终释放:从 page cache / swapcache 移除,归还给 buddy
folio_free(folio);
nr_reclaimed++;
}
}RMAP(Reverse Mapping)反向映射:给定一个 folio,找到所有映射了它的页表项,批量取消映射。实现在 mm/rmap.c,使用 anon_vma 链表(匿名页)和 address_space->i_mmap 区间树(文件页)。
struct scan_control {
unsigned long nr_to_scan; // 本次需要扫描的页数
unsigned long nr_reclaimed; // 已回收的页数
int order; // 触发回收的分配 order
unsigned int priority; // 扫描优先级(0 = 最高,DEF_PRIORITY = 12)
gfp_t gfp_mask; // 分配标志(约束可用回收手段)
unsigned int may_writepage:1; // 是否允许写回脏页
unsigned int may_unmap:1; // 是否允许取消映射
unsigned int may_swap:1; // 是否允许换出匿名页
// ...
};priority 的含义:数值越小,扫描力度越大。shrink_inactive_list() 每次扫描 nr_to_scan >> priority 个页,priority 递减时扫描更多页。
传统四链表 LRU 的主要缺陷:
- 粗粒度:active/inactive 只有两代,无法区分"刚刚访问"和"很久前访问"
- 文件/匿名独立:比较文件页和匿名页的热度困难
- 扫描开销:需要获取 LRU 锁扫描链表,高并发下竞争激烈
- 页表感知:不直接感知 CPU 的访问位(Accessed bit),需要 rmap 逆向查找
Multi-Gen LRU(MGLRU,CONFIG_LRU_GEN)由 Google 工程师 Yu Zhao 开发,Linux 6.1 合并主线。
核心思想:用"世代"(Generation)代替"活跃/不活跃"二分。
// include/linux/mmzone.h:398
#define MIN_NR_GENS 2U // 最少 2 个世代(维持第二次机会算法)
#define MAX_NR_GENS 4U // 最多 4 个世代(需要 order_base_2(5) = 3 个 bit)
#define MAX_NR_TIERS 4U // 每个世代内的访问层级世代编号用 folio->flags 中的 LRU_GEN_MASK 字段存储(include/linux/mmzone.h:425)。
// include/linux/mmzone.h:490
struct lru_gen_folio {
unsigned long max_seq; // 最年轻世代编号(单调递增)
unsigned long min_seq[ANON_AND_FILE]; // 最老世代编号(anon/file 分开)
unsigned long timestamps[MAX_NR_GENS]; // 各世代诞生时间(jiffies)
// 多维 LRU 链表:[世代][anon/file][zone]
struct list_head folios[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
// 各维度的页面数量
long nr_pages[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_ZONES];
// 统计(用于回收决策)
unsigned long avg_refaulted[ANON_AND_FILE][MAX_NR_TIERS];
unsigned long avg_total[ANON_AND_FILE][MAX_NR_TIERS];
unsigned long protected[NR_HIST_GENS][ANON_AND_FILE][MAX_NR_TIERS];
atomic_long_t evicted[NR_HIST_GENS][ANON_AND_FILE][MAX_NR_TIERS];
atomic_long_t refaulted[NR_HIST_GENS][ANON_AND_FILE][MAX_NR_TIERS];
bool enabled;
};MGLRU 世代滑动窗口示意(MAX_NR_GENS = 4):
时间轴 ────────────────────────────────────────►
世代 N+3 (最年轻, max_seq)
└── 最近 fault 进来的页(新生代)
世代 N+2
└── 上一轮 aging 后留下的页
世代 N+1
└── 更老的页
世代 N (最老, min_seq)
└── 候选驱逐(eviction 从这里取)
Aging(老化):
max_seq++ → 新建最年轻世代
老世代内的被访问页 → 晋升到年轻世代(folio_update_gen)
Eviction(驱逐):
从 min_seq 世代取页 → 回收
若该世代耗尽 → min_seq++
每个世代内部按 Tier 区分访问频次(include/linux/mmzone.h:401):
Tier 0: 通过页表 fault 进来,未被 fd 读取(PG_referenced 未设置)
Tier 1: 被文件描述符读取 1 次(PG_referenced 设置)
Tier 2: 文件 fd 读取 2 次(LRU_REFS_MASK 第 1 位)
Tier 3: 文件 fd 读取 4 次(PG_workingset 设置,热页保护)
MGLRU 通过 avg_refaulted / avg_total 统计各 Tier 的 refault 率,推断哪些 Tier 的页属于真实工作集,回收时优先保护工作集页。
MGLRU 最关键的创新:直接扫描页表的 Accessed 位,无需 RMAP 逆向查找。
MGLRU MM Walk 流程:
lru_gen_mm_walk(后台 kswapd 触发):
1. 遍历 mm->mm_mt 中的所有 VMA
2. 对每个 VMA,遍历页表:walk_page_range()
3. 对每个已设置 Accessed 位的 PTE:
a. 清除 Accessed 位
b. folio_update_gen():将对应 folio 晋升到年轻世代
4. 使用 Bloom Filter 缓存非叶节点(PUD/PMD)
避免重复扫描无访问的子树(mm/vmscan.c lru_gen_mm_state)
Bloom Filter 的使用(include/linux/mmzone.h:529):记录最近一轮扫描中发现有 Accessed 位的非叶页表节点,下轮扫描优先访问这些节点,跳过无访问的子树,大幅降低 MM Walk 开销。
当内存分配(__alloc_pages_slowpath())经历:
- 唤醒 kswapd,仍然失败
- 直接内存压缩(Compaction),仍然失败
- 直接内存回收(Reclaim),仍然失败
则进入 __alloc_pages_may_oom() → out_of_memory()。
// mm/oom_kill.c:1119
bool out_of_memory(struct oom_control *oc)
{
// 1. 通知链:允许驱动/应用释放内存
blocking_notifier_call_chain(&oom_notify_list, 0, &freed);
if (freed > 0) return true; // 有内存释放,不杀进程
// 2. 当前进程即将退出?让它先分配完内存然后退出
if (task_will_free_mem(current)) {
mark_oom_victim(current);
queue_oom_reaper(current);
return true;
}
// 3. sysctl_oom_kill_allocating_task:直接杀当前进程
if (sysctl_oom_kill_allocating_task && ...)
oom_kill_process(oc, "Out of memory (kill allocating task)");
// 4. 选择"最坏"的进程
select_bad_process(oc); // → oom_evaluate_task() → oom_badness()
// 5. 杀进程
oom_kill_process(oc, "Out of memory");
return !!oc->chosen;
}OOM 选择的依据是 oom_badness() 分数(mm/oom_kill.c 约 219 行):
oom_score = RSS + page_table_pages + swap_entries
+ 调整(oom_score_adj / 1000 * 总内存)
其中 oom_score_adj 范围:-1000 ~ +1000
-1000:从不杀(OOM_SCORE_ADJ_MIN,如 init 进程)
0 :默认,不偏向
+1000:优先杀(适合沙箱进程)
为什么选 RSS(Resident Set Size)而不是虚拟地址空间? 虚拟地址可以很大(延迟分配),但 RSS 反映实际占用的物理内存。杀死 RSS 最大的进程能最快回收最多内存。
// mm/oom_kill.c:1024
static void oom_kill_process(struct oom_control *oc, const char *message)
{
// 打印 OOM 信息(dmesg 中的 "Out of memory: Kill process...")
__oom_kill_process(victim, message);
// 发送 SIGKILL
// 设置 TIF_MEMDIE 标志,允许受害者从 PF_MEMALLOC 池分配内存(以便快速退出)
// 唤醒 oom_reaper 内核线程(异步回收受害进程的内存)
}oom_reaper(mm/oom_kill.c):在受害进程收到 SIGKILL 但可能还未退出时,oom_reaper 线程异步遍历受害者的匿名 VMA 并释放物理页,加速内存回收,避免系统长时间卡死。
Swap entry(include/linux/mm_types.h:285)是一个 unsigned long,编码了 swap 设备和槽位信息:
// include/linux/swapops.h:27
#define SWP_TYPE_SHIFT (BITS_PER_XA_VALUE - MAX_SWAPFILES_SHIFT)
#define SWP_OFFSET_MASK ((1UL << SWP_TYPE_SHIFT) - 1)
// 编码:
// | swp_type (高位) | swp_offset (低位) |
// MAX_SWAPFILES_SHIFT 位 SWP_TYPE_SHIFT 位// include/linux/swapops.h:88
static inline swp_entry_t swp_entry(unsigned int type, pgoff_t offset)
{
swp_entry_t ret;
ret.val = (type << SWP_TYPE_SHIFT) | (offset & SWP_OFFSET_MASK);
return ret;
}
// 解码:
static inline unsigned swp_type(swp_entry_t entry) {
return (entry.val >> SWP_TYPE_SHIFT); // line 98
}
static inline pgoff_t swp_offset(swp_entry_t entry) {
return entry.val & SWP_OFFSET_MASK; // line 107
}当一个匿名页被换出到 swap 时,对应的 PTE 被替换为 swap entry:
PTE 换出前:[PFN=0x1234, Present=1, Dirty=1]
↓ swap_out
PTE 换出后:[swp_type=0, swp_offset=42, Present=0]
↓ 下次访问触发 page fault
do_swap_page() 读取 swap,恢复 PTE
匿名页换出(shrink_folio_list 中):
add_to_swap(folio)
├── get_swap_page() [分配 swap 槽位]
│ └── swap_alloc_cluster() 或 scan_swap_map()
│ 返回 swp_entry_t(type + offset)
├── add_to_swap_cache(folio, entry) [加入 swapcache]
│ → radix tree / xarray 索引
└── folio_mark_dirty(folio) [标记需要写回]
swap_writepage(folio, wbc)
└── swp_entry = folio->swap
swap_info = get_swap_device(swp_entry)
block_device = swap_info->bdev
submit_bio(WRITE, ...) [异步写入 swap 设备]
缺页异常处理 swap page:
handle_pte_fault() → do_swap_page()
├── swp_entry = pte_to_swp_entry(orig_pte)
├── 查找 swapcache:find_get_page(swap_address_space, offset)
│ ├── 命中:直接映射,无需 I/O(minor fault)
│ └── 未命中:
│ folio = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, addr)
│ add_to_swap_cache(folio, swp_entry)
│ swap_readpage(folio) [发起读 I/O]
│ └── submit_bio(READ, ...) → block layer
│ wait_on_page_locked(folio) [等待 I/O 完成]
├── unmap_and_move():处理多个进程共享同一 swap entry
├── set_pte_at():恢复 PTE 指向物理页
└── swap_free(swp_entry):释放 swap 槽位
每个 swap 分区/文件由 struct swap_info_struct 管理,核心字段:
struct swap_info_struct {
unsigned long flags; // SWP_USED / SWP_WRITEOK 等
signed short prio; // 优先级(高优先级先用)
struct block_device *bdev; // swap 设备
struct file *swap_file; // swap 文件(若用文件作 swap)
unsigned int max; // swap 槽位总数
unsigned char *swap_map; // 槽位引用计数数组
struct swap_cluster_info *cluster_info; // 集群管理(优化连续分配)
unsigned long *frontswap_map; // frontswap(内存压缩)位图
};Swap 优先级:swapon -p 10 /dev/sdb 设置优先级 10,内核优先填满高优先级的 swap 设备,再使用低优先级的,实现 swap 分层(SSD + HDD)。
以 malloc(4096) 为例,追踪从用户空间到硬件的完整路径:
用户程序调用 malloc(4096)
glibc malloc:
├── 若 brk 堆有空间:从 heap 切割返回(无系统调用)
└── 若需要扩展堆:brk() 系统调用
brk() → mm/mmap.c → do_brk_flags()
└── vm_area_struct 扩展:vma->vm_end += PAGE_SIZE
(此时无物理内存分配,PTE 尚未建立)
程序写入 ptr[0] = 'X':
CPU:虚拟地址 → 查 TLB → Miss → 查页表 → PTE 不存在
CPU 触发 Page Fault(#PF 中断)
x86 fault handler → handle_mm_fault()
→ __handle_mm_fault() → handle_pte_fault() → do_anonymous_page()
├── alloc_anon_folio(vmf)
│ └── alloc_pages(GFP_HIGHUSER_MOVABLE, 0)
│ └── __alloc_pages_noprof()
│ └── get_page_from_freelist()
│ └── rmqueue() → rmqueue_pcplist()
│ 从 per_cpu_pages 取出一个 order-0 页
│ (若 pcp 为空,从 buddy 补充)
│
├── 设置 PTE:set_pte_at(mm, addr, pte, entry)
│ entry 包含:PFN | Present | Writable | User | Dirty
│
├── 更新 TLB(flush_tlb_page)
└── folio_add_lru(folio) → 加入 LRU_ACTIVE_ANON
Page Fault 返回,用户程序继续执行
ptr[0] = 'X' 写入成功(现在有物理页了)
内存压力场景:系统内存严重不足
┌─────────────────────────────────────────────────────────┐
│ 应用持续分配内存 │
│ free_pages < WMARK_LOW │
└───────────────────────────┬─────────────────────────────┘
│ 唤醒 kswapd
┌───────────────────────────▼─────────────────────────────┐
│ kswapd 后台回收 │
│ kswapd_shrink_node() → shrink_node() │
│ → 扫描 LRU / MGLRU → shrink_folio_list() │
│ ├── 文件页(clean)→ 直接丢弃 │
│ ├── 文件页(dirty)→ 写回,稍后丢弃 │
│ └── 匿名页 → 换出 swap(若 vm.swappiness > 0) │
└───────────────────────────┬─────────────────────────────┘
│ 若回收仍不够
┌───────────────────────────▼─────────────────────────────┐
│ 直接回收(分配者执行) │
│ __alloc_pages_direct_reclaim() │
│ → try_to_free_pages() → shrink_zones() │
│ 阻塞当前进程直到回收足够内存 │
└───────────────────────────┬─────────────────────────────┘
│ 若仍然失败
┌───────────────────────────▼─────────────────────────────┐
│ OOM Killer │
│ out_of_memory() → select_bad_process() │
│ → oom_kill_process() → SIGKILL 受害者 │
│ → oom_reaper 异步回收内存 │
└─────────────────────────────────────────────────────────┘
| 参数 | 路径 | 说明 |
|---|---|---|
vm.min_free_kbytes |
/proc/sys/vm/ |
控制水位线基准值 |
vm.swappiness |
/proc/sys/vm/ |
0=避免 swap,100=积极 swap,默认 60 |
vm.dirty_ratio |
/proc/sys/vm/ |
脏页占内存比例上限(触发同步写回) |
vm.dirty_background_ratio |
/proc/sys/vm/ |
后台写回触发阈值 |
vm.overcommit_memory |
/proc/sys/vm/ |
0=启发式,1=总是允许,2=严格 |
/proc/sys/vm/nr_hugepages |
预分配的 HugePage 数 | |
/sys/kernel/mm/lru_gen/enabled |
MGLRU 开关 | |
/proc/[pid]/oom_score_adj |
进程 OOM 评分调整 |
# 内存整体状态
cat /proc/meminfo
# MemTotal, MemFree, MemAvailable, Buffers, Cached
# SwapTotal, SwapFree
# AnonPages, Mapped, Shmem
# KReclaimable: 可回收内核内存(slab 可回收部分)
# 详细虚拟内存统计
cat /proc/vmstat
# pgfault: 总缺页次数(minor)
# pgmajfault: 大缺页次数(需要 I/O)
# pswpin/pswpout: swap 读写页数
# pgsteal_*: 各 LRU 回收页数
# pginodesteal: inode slab 回收
# SLUB 统计
cat /sys/kernel/slab/<cache_name>/stat
# 内存回收追踪
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_kswapd_wake/enable
cat /sys/kernel/debug/tracing/trace
# per-process 内存
cat /proc/[pid]/smaps_rollup
# Private_Clean, Private_Dirty, Shared_Clean, Shared_Dirty, Swap
# NUMA 统计
numactl --hardware
cat /proc/[pid]/numa_maps
# 伙伴系统状态
cat /proc/buddyinfo
# Node 0, zone Normal: 1 2 3 4 5 6 7 8 9 10 11
# 每列是该 order 的空闲块数| 功能 | 源文件 |
|---|---|
| 物理内存区域定义 | include/linux/mmzone.h |
| page/folio 结构体 | include/linux/mm_types.h |
| 页标志位 | include/linux/page-flags.h |
| 伙伴分配器 | mm/page_alloc.c |
| SLUB 分配器 | mm/slub.c |
| SLUB 头文件 | mm/slab.h |
| 虚拟内存映射 | mm/mmap.c |
| 缺页处理 | mm/memory.c |
| 内存回收 | mm/vmscan.c |
| RMAP 反向映射 | mm/rmap.c |
| OOM Killer | mm/oom_kill.c |
| Swap 管理 | mm/swap_state.c, mm/swapfile.c |
| 页表操作 | mm/pgtable-generic.c, arch/x86/mm/ |
| 内存 compaction | mm/compaction.c |
| 大页 THP | mm/huge_memory.c |
| kmalloc API | include/linux/slab.h |
1. 延迟分配(Lazy Allocation):mmap() 不立即分配物理内存;fork() 不立即复制页面(COW)。代价是需要 page fault 处理,收益是避免浪费(程序可能不访问所有分配的内存)。
2. 分层缓存(Hierarchical Caching):buddy → pcp → SLUB freelist,每层减少下层锁竞争。类似 CPU 缓存层次,用局部性原理减少全局同步开销。
3. 批量操作(Batching):pcp 批量从 buddy 取页;mmu_gather 批量收集 TLB shootdown;kswapd 批量回收页面。减少系统调用和中断次数。
4. 无锁/读无锁(Lock-free / Read-mostly):RCU 保护 VMA 读取;per-CPU 数据无需全局锁;MGLRU 的 MM Walk 尽量无锁扫描页表。Linux 锁策略从粗粒度全局锁演进到精细的 per-CPU / per-VMA / per-folio 锁。
5. 与硬件协作(HW Cooperation):利用 CPU 自动置位的 Accessed/Dirty 位追踪页访问;利用 TLB 减少页表查找;利用 NUMA topology 优化数据局部性。
6. 渐进式降级(Graceful Degradation):水位线三档(MIN/LOW/HIGH)、OOM 逐步升级(释放→回收→杀进程→panic),在资源耗尽时尽可能维持系统可用性。
由 Claude Code 分析生成