本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均来自真实内核文件。

内存是操作系统最核心的资源之一。当物理内存不足时,内核必须决定哪些页面可以释放、哪些必须保留——这个过程叫做内存回收(Memory Reclaim)。本文深入剖析 Linux 内存回收子系统的完整链路:从 LRU 链表组织、kswapd 后台线程、直接回收路径,到 Swap 换出、RMAP 反向映射、OOM Killer,以及内存碎片整理(Compaction)。


一、LRU 页面链表

1.1 五种 LRU 链表

Linux 内核将所有可回收的用户态页面按照访问活跃度类型组织成五条 LRU(Least Recently Used)链表,定义于 include/linux/mmzone.h

1
2
3
4
5
6
7
8
9
10
11
12
13
// include/linux/mmzone.h
#define LRU_BASE 0
#define LRU_ACTIVE 1
#define LRU_FILE 2

enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};

五条链表的语义如下:

链表 含义
LRU_INACTIVE_ANON 不活跃的匿名页(栈、堆等),回收时需要换出到 swap
LRU_ACTIVE_ANON 活跃的匿名页,近期被访问过
LRU_INACTIVE_FILE 不活跃的文件页(page cache),若干净可直接释放
LRU_ACTIVE_FILE 活跃的文件页,近期被访问过
LRU_UNEVICTABLE 不可回收页,如 mlock() 锁定的页

Active/Inactive 双链表设计的关键价值:若所有页面都在同一条链表上,扫描时很容易将那些历史上访问频繁但近期”恰好”没有被访问的页面错误淘汰,产生 thrashing。双链表通过”降级”而非直接淘汰来保护热页面:页面必须先从 active 链表移到 inactive 链表,在 inactive 链表上”熬过”若干次扫描后才会被回收候选。

1.2 per-node LRU 向量:struct lruvec

每个 NUMA 节点(pg_data_t)都维护一个 struct lruvec,其中包含上述五条链表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// include/linux/mmzone.h
struct lruvec {
struct list_head lists[NR_LRU_LISTS];
/* per lruvec lru_lock for memcg */
spinlock_t lru_lock;
/*
* These track the cost of reclaiming one LRU - file or anon -
* over the other. As the observed cost of reclaiming one LRU
* increases, the reclaim scan balance tips toward the other.
*/
unsigned long anon_cost;
unsigned long file_cost;
/* Non-resident age, driven by LRU movement */
atomic_long_t nonresident_age;
/* Refaults at the time of last reclaim cycle */
unsigned long refaults[ANON_AND_FILE];
/* Various lruvec state flags (enum lruvec_flags) */
unsigned long flags;
#ifdef CONFIG_LRU_GEN
/* evictable pages divided into generations */
struct lru_gen_folio lrugen;
/* to concurrently iterate lru_gen_mm_list */
struct lru_gen_mm_state mm_state;
#endif
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
};

lruvec 中的 nonresident_age 字段是工作集检测的核心计数器——每次 LRU 发生移动时递增,用于计算 refault distance(见第四节)。anon_costfile_cost 动态记录回收匿名页和文件页各自的代价,平衡扫描压力。

1.3 folio_lru_list:判断 folio 应当位于哪条链表

include/linux/mm_inline.h 中的 folio_lru_list() 封装了 LRU 链表判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// include/linux/mm_inline.h
static __always_inline enum lru_list folio_lru_list(struct folio *folio)
{
enum lru_list lru;

VM_BUG_ON_FOLIO(folio_test_active(folio) && folio_test_unevictable(folio), folio);

if (folio_test_unevictable(folio))
return LRU_UNEVICTABLE;

lru = folio_is_file_lru(folio) ? LRU_INACTIVE_FILE : LRU_INACTIVE_ANON;
if (folio_test_active(folio))
lru += LRU_ACTIVE;

return lru;
}

判断依据是 folio->flags 中的两个核心标志位:

  • **PG_active**:置位时表示该页面在 active 链表上,是近期访问过的热页面。
  • **PG_referenced**:访问引用标志,用于实现二次机会(second-chance)算法。当 inactive 链表上的页面被扫描时,若 PG_referenced 已置位,则将其提升回 active 链表并清除标志;若未置位,才将其作为回收候选。

二、kswapd 后台回收

2.1 kswapd 的职责

kswapd 是内核中每个 NUMA 节点对应的一个内核线程(kswapd0kswapd1…),它在后台默默维护各 zone 的空闲页面水位,避免内存分配路径上的延迟。它的核心逻辑在 mm/vmscan.ckswapd() 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// mm/vmscan.c
static int kswapd(void *p)
{
unsigned int alloc_order, reclaim_order;
unsigned int highest_zoneidx = MAX_NR_ZONES - 1;
pg_data_t *pgdat = (pg_data_t *)p;
struct task_struct *tsk = current;
const struct cpumask *cpumask = cpumask_of_node(pgdat->node_id);

if (!cpumask_empty(cpumask))
set_cpus_allowed_ptr(tsk, cpumask);

tsk->flags |= PF_MEMALLOC | PF_KSWAPD;
set_freezable();

WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
atomic_set(&pgdat->nr_writeback_throttled, 0);
for ( ; ; ) {
bool ret;

alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);

kswapd_try_sleep:
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
highest_zoneidx);

/* Read the new order and highest_zoneidx */
alloc_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);
WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);

ret = try_to_freeze();
if (kthread_should_stop())
break;

if (ret)
continue;

trace_mm_vmscan_kswapd_wake(pgdat->node_id, highest_zoneidx,
alloc_order);
reclaim_order = balance_pgdat(pgdat, alloc_order,
highest_zoneidx);
if (reclaim_order < alloc_order)
goto kswapd_try_sleep;
}

tsk->flags &= ~(PF_MEMALLOC | PF_KSWAPD);
return 0;
}

kswapd 的主循环逻辑很清晰:

  1. 尝试睡眠(kswapd_try_to_sleep),直到某个 zone 的空闲页低于 WMARK_HIGH,被 wakeup_kswapd() 唤醒。
  2. 调用 balance_pgdat() 对该 NUMA 节点执行回收。
  3. 若回收到的 order 低于请求 order(高阶分配不满足),则再次尝试睡眠等待 kcompactd 完成碎片整理。

PF_MEMALLOC 标志让 kswapd 在分配内存时可绕过部分水位检查,避免回收过程本身因内存不足而卡死。

2.2 balance_pgdat:对单节点执行回收

balance_pgdat() 是 kswapd 的核心回收函数,其签名为:

1
2
// mm/vmscan.c(第 7347 行)
static int balance_pgdat(pg_data_t *pgdat, int order, int highest_zoneidx)

它的工作流程:

  1. 统计各 zone 的 watermark_boost,决定是否需要 boost 回收。
  2. 以优先级(priority)从 DEF_PRIORITY(默认 12)逐步降低,每轮调用 kswapd_shrink_node(),直到水位满足或优先级耗尽。
  3. 优先级越低,每次扫描的 LRU 页面比例越高(扫描范围 = 链表长度 >> priority),回收压力越大。
  4. 回收结束后唤醒所有因水位不足而被阻塞的用户进程。

2.3 kswapd_shrink_node

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// mm/vmscan.c(第 7267 行)
static bool kswapd_shrink_node(pg_data_t *pgdat,
struct scan_control *sc)
{
struct zone *zone;
int z;

/* Reclaim a number of pages proportional to the number of zones */
sc->nr_to_reclaim = 0;
for (z = 0; z <= sc->reclaim_idx; z++) {
zone = pgdat->node_zones + z;
if (!managed_zone(zone))
continue;

sc->nr_to_reclaim += max(high_wmark_pages(zone), SWAP_CLUSTER_MAX);
}

/*
* Historically care was taken to put equal pressure on all zones but
* now pressure is applied based on node LRU order.
*/
shrink_node(pgdat, sc);

if (sc->order && sc->nr_reclaimed >= compact_gap(sc->order))
sc->order = 0;

return sc->nr_scanned >= sc->nr_to_reclaim;
}

kswapd_shrink_node 计算出本轮需要回收的页面数(与各 zone 的高水位之和成正比),然后调用 shrink_node() 实际执行回收,最后返回是否已扫描了足够多的页面。


三、直接回收(Direct Reclaim)

3.1 调用链路

当内存分配器(__alloc_pages())在 slow path 里仍无法满足分配请求时,分配上下文会直接参与内存回收,这就是直接回收。其调用路径为:

1
2
3
4
5
6
7
8
__alloc_pages_slowpath()
└─> __alloc_pages_direct_reclaim()
└─> try_to_free_pages()
└─> do_try_to_free_pages()
└─> shrink_zones()
└─> shrink_node()
└─> shrink_node_memcgs()
└─> shrink_lruvec()

3.2 try_to_free_pages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// mm/vmscan.c(第 6998 行)
unsigned long try_to_free_pages(struct zonelist *zonelist, int order,
gfp_t gfp_mask, nodemask_t *nodemask)
{
unsigned long nr_reclaimed;
struct scan_control sc = {
.nr_to_reclaim = SWAP_CLUSTER_MAX,
.gfp_mask = current_gfp_context(gfp_mask),
.reclaim_idx = gfp_zone(gfp_mask),
.order = order,
.nodemask = nodemask,
.priority = DEF_PRIORITY,
.may_writepage = !laptop_mode,
.may_unmap = 1,
.may_swap = 1,
};
// ...
if (throttle_direct_reclaim(sc.gfp_mask, zonelist, nodemask))
return 1;

set_task_reclaim_state(current, &sc.reclaim_state);
trace_mm_vmscan_direct_reclaim_begin(order, sc.gfp_mask);

nr_reclaimed = do_try_to_free_pages(zonelist, &sc);

trace_mm_vmscan_direct_reclaim_end(nr_reclaimed);
set_task_reclaim_state(current, NULL);

return nr_reclaimed;
}

struct scan_control 是贯穿整个回收子系统的控制结构,记录回收目标数量、GFP 标志、是否允许 writepage、是否允许 unmap、是否允许 swap 等所有策略参数。

3.3 do_try_to_free_pages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// mm/vmscan.c(第 6780 行)
static unsigned long do_try_to_free_pages(struct zonelist *zonelist,
struct scan_control *sc)
{
int initial_priority = sc->priority;
// ...
retry:
do {
if (!sc->proactive)
vmpressure_prio(sc->gfp_mask, sc->target_mem_cgroup,
sc->priority);
sc->nr_scanned = 0;
shrink_zones(zonelist, sc);

if (sc->nr_reclaimed >= sc->nr_to_reclaim)
break;
if (sc->compaction_ready)
break;

if (sc->priority < DEF_PRIORITY - 2)
sc->may_writepage = 1;
} while (--sc->priority >= 0);
// ...
}

do_try_to_free_pagesbalance_pgdat 的策略相同:优先级从 12 逐级降低,每轮调用 shrink_zones() 遍历 zonelist 中的每个 NUMA 节点,调用 shrink_node()。值得注意的是,优先级低于 DEF_PRIORITY - 2 时,即使处于 laptop_mode 也会强制开启 writepage。


四、LRU 页面扫描与回收

4.1 shrink_inactive_list:扫描 inactive 链表

shrink_inactive_list() 是内存回收的核心逻辑,它从 inactive LRU 链表中隔离页面,尝试回收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// mm/vmscan.c(第 2556 行)
static unsigned long shrink_inactive_list(unsigned long nr_to_scan,
struct lruvec *lruvec, struct scan_control *sc,
enum lru_list lru)
{
LIST_HEAD(folio_list);
unsigned long nr_scanned;
unsigned int nr_reclaimed = 0;
unsigned long nr_taken;
struct reclaim_stat stat;
bool file = is_file_lru(lru);
// ...
lru_add_drain();
spin_lock_irq(&lruvec->lru_lock);

nr_taken = isolate_lru_folios(nr_to_scan, lruvec, &folio_list,
&nr_scanned, sc, lru);

__mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, nr_taken);
// 统计 PGSCAN_KSWAPD 或 PGSCAN_DIRECT 事件
spin_unlock_irq(&lruvec->lru_lock);

if (nr_taken == 0)
return 0;

nr_reclaimed = shrink_folio_list(&folio_list, pgdat, sc, &stat, false);

spin_lock_irq(&lruvec->lru_lock);
move_folios_to_lru(lruvec, &folio_list);
__mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, -nr_taken);
// 统计 PGSTEAL_KSWAPD 或 PGSTEAL_DIRECT 事件
spin_unlock_irq(&lruvec->lru_lock);

lru_note_cost(lruvec, file, stat.nr_pageout, nr_scanned - nr_reclaimed);
mem_cgroup_uncharge_list(&folio_list);
free_unref_page_list(&folio_list);
// ...
}

整体流程:

  1. 调用 isolate_lru_folios() 在持锁状态下从 LRU 链表隔离一批页面到临时链表 folio_list
  2. 释放锁后,调用 shrink_folio_list()(6.4 中已替代旧的 shrink_page_list)处理每个页面的实际回收。
  3. 重新持锁,将未能回收的页面放回 LRU(move_folios_to_lru)。
  4. 调用 lru_note_cost() 更新 anon/file 回收代价,动态平衡后续扫描比例。

4.2 shrink_folio_list:逐页面决策

shrink_folio_list() 对每个隔离出的页面做细粒度决策:

  • 匿名页(Anon):先调用 add_to_swap() 分配 swap slot 并加入 swap cache,再通过 try_to_unmap() 解除所有 PTE 映射,最后调用 swap_writepage() 将页面数据写入 swap 分区。
  • 文件页(File):若页面是脏的(folio_test_dirty),则调用 pageout() → writeback;若页面已经干净,则直接从 page cache 中移除并释放物理页帧。

4.3 isolate_lru_folios:从 LRU 隔离页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// mm/vmscan.c(第 2293 行)
static unsigned long isolate_lru_folios(unsigned long nr_to_scan,
struct lruvec *lruvec, struct list_head *dst,
unsigned long *nr_scanned, struct scan_control *sc,
enum lru_list lru)
{
struct list_head *src = &lruvec->lists[lru];
unsigned long nr_taken = 0;
// ...
while (scan < nr_to_scan && !list_empty(src)) {
struct folio *folio;

folio = lru_to_folio(src);
// ...
if (!folio_test_lru(folio))
goto move;
if (!sc->may_unmap && folio_mapped(folio))
goto move;

if (unlikely(!folio_try_get(folio)))
goto move;

if (!folio_test_clear_lru(folio)) {
folio_put(folio);
goto move;
}
nr_taken += nr_pages;
// 移动到 dst 链表
}
// ...
}

isolate_lru_folios 从链表尾部(最老的页面)开始扫描,对每个候选 folio 做一系列检查:是否仍在 LRU 上、是否允许 unmap、是否能成功获取引用、是否能清除 LRU 标志(确保独占处理权)。通过检查后才从链表摘下,加入隔离列表 dst

4.4 refault distance:工作集检测

内核通过 shadow entry(影子条目) 机制来检测被错误淘汰的页面(refault)。当一个页面从 page cache 被逐出时,内核不会立刻删除其在 xarray 中的槽位,而是在原位置存入一个 shadow entry,包含当前 nonresident_age 的快照(即”驱逐时间戳”)。

当该文件区域被再次访问(page fault)时,workingset_refault() 被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// mm/workingset.c(第 396 行)
void workingset_refault(struct folio *folio, void *shadow)
{
// ...
unpack_shadow(shadow, &memcgid, &pgdat, &eviction, &workingset);
eviction <<= bucket_order;
// ...
refault = atomic_long_read(&eviction_lruvec->nonresident_age);
// ...
refault_distance = (refault - eviction) & EVICTION_MASK;
// ...
if (refault_distance > workingset_size)
goto out; // refault 距离太远,不值得激活

folio_set_active(folio); // 直接提升为 active,跳过 inactive 熬炼
// ...
}

refault distance = 从页面被逐出到被重新访问,LRU 总共发生了多少次移动(nonresident_age 增量)。若这个距离小于当前工作集大小(active + inactive 链表长度),说明该页面本应留在内存中,内核将直接把它提升为 active,防止反复 thrashing。


五、Swap 机制

5.1 swap_entry_t:swap 槽位引用

swap_entry_t 是一个 32/64 位的不透明整数值,编码了 swap 类型(设备索引)和偏移量(槽位号):

1
2
3
4
// include/linux/swapops.h
typedef struct {
unsigned long val;
} swp_entry_t;

其二进制布局:高位为 swap 类型 ID(SWP_TYPE_SHIFT 位),低位为 offset(页面在 swap 分区中的页号)。

5.2 add_to_swap_cache

当匿名页即将被换出时,需要先通过 add_to_swap() 分配一个 swap slot(swp_entry_t),再调用 add_to_swap_cache() 将页面插入 swap cache(swapper_space 的 xarray):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// mm/swap_state.c(第 88 行)
int add_to_swap_cache(struct folio *folio, swp_entry_t entry,
gfp_t gfp, void **shadowp)
{
struct address_space *address_space = swap_address_space(entry);
pgoff_t idx = swp_offset(entry);
XA_STATE_ORDER(xas, &address_space->i_pages, idx, folio_order(folio));
unsigned long i, nr = folio_nr_pages(folio);
void *old;

xas_set_update(&xas, workingset_update_node);

VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio);
VM_BUG_ON_FOLIO(folio_test_swapcache(folio), folio);
VM_BUG_ON_FOLIO(!folio_test_swapbacked(folio), folio);

folio_ref_add(folio, nr);
folio_set_swapcache(folio);

do {
xas_lock_irq(&xas);
xas_create_range(&xas);
if (xas_error(&xas))
goto unlock;
for (i = 0; i < nr; i++) {
old = xas_load(&xas);
if (xa_is_value(old)) {
if (shadowp)
*shadowp = old;
}
set_page_private(folio_page(folio, i), entry.val + i);
xas_store(&xas, folio);
xas_next(&xas);
}
address_space->nrpages += nr;
__node_stat_mod_folio(folio, NR_FILE_PAGES, nr);
__lruvec_stat_mod_folio(folio, NR_SWAPCACHE, nr);
unlock:
xas_unlock_irq(&xas);
} while (xas_nomem(&xas, gfp));
// ...
}

注意此函数会将 page->private 设为 swp_entry_t 的值,这样当页面从 swap 读回时(lookup_swap_cache),内核可以通过 entry 在 swap cache 中查找已有的内存拷贝,避免重复 I/O。

5.3 匿名页换出全流程

1
2
3
4
shrink_folio_list()
├─> add_to_swap() // 分配 swap slot,加入 swap cache
├─> try_to_unmap() // 解除所有进程的 PTE 映射(RMAP)
└─> swap_writepage() // 异步将页面内容写入 swap 分区

页面换入(swap-in)时,缺页中断触发 do_swap_page(),先查 swap cache(lookup_swap_cache),命中则直接重建 PTE;未命中则从 swap 分区读回,经历一次 major fault。


六、RMAP(反向映射)

6.1 为什么需要 RMAP

页面回收的一个关键步骤是:找到所有映射了该页面的 PTE,将它们全部改为 swap entry 或置为无效。这就需要从物理页面反向找到所有的虚拟地址映射——这就是 RMAP(Reverse Mapping) 的作用。

6.2 struct anon_vma:匿名页反向映射

匿名页的 RMAP 通过 struct anon_vma 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// include/linux/rmap.h(第 31 行)
struct anon_vma {
struct anon_vma *root; /* Root of this anon_vma tree */
struct rw_semaphore rwsem; /* W: modification, R: walking the list */
atomic_t refcount;

unsigned long num_children;
unsigned long num_active_vmas;

struct anon_vma *parent; /* Parent of this anon_vma */

/* Interval tree of private "related" vmas */
struct rb_root_cached rb_root;
};

每个匿名 VMA 关联一个 anon_vma,后者通过区间树(interval tree)组织所有可能映射了该匿名页的 VMA 集合。当进程 fork 时,子进程的 VMA 会链接到父进程的 anon_vma,形成树状结构,确保 COW 后的页面也能被正确追踪。

文件页的反向映射则依赖 struct address_space(即 inode->i_mapping)中的 i_mmap 区间树,记录所有映射了该文件页的 VMA。

6.3 page_add_anon_rmap

当新的匿名页面通过 PTE 映射到某个 VMA 时,调用 page_add_anon_rmap() 注册 RMAP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// mm/rmap.c(第 1214 行)
void page_add_anon_rmap(struct page *page, struct vm_area_struct *vma,
unsigned long address, rmap_t flags)
{
struct folio *folio = page_folio(page);
atomic_t *mapped = &folio->_nr_pages_mapped;
int nr = 0, nr_pmdmapped = 0;
bool compound = flags & RMAP_COMPOUND;
bool first = true;

if (likely(!compound)) {
first = atomic_inc_and_test(&page->_mapcount);
nr = first;
// ...
}
// ...
if (likely(!folio_test_ksm(folio))) {
if (first)
__page_set_anon_rmap(folio, page, vma, address,
!!(flags & RMAP_EXCLUSIVE));
// ...
}
}

_mapcount 记录有多少个 PTE 映射了该页面。__page_set_anon_rmap()page->mapping 指向对应的 anon_vma,这是反向查找的入口。

6.4 try_to_unmap_one:解除 PTE 映射

try_to_unmap() 通过 rmap_walk 遍历所有映射了目标 folio 的 VMA,对每个 VMA 调用 try_to_unmap_one()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// mm/rmap.c(第 1451 行)
static bool try_to_unmap_one(struct folio *folio, struct vm_area_struct *vma,
unsigned long address, void *arg)
{
struct mm_struct *mm = vma->vm_mm;
DEFINE_FOLIO_VMA_WALK(pvmw, folio, vma, address, 0);
pte_t pteval;
bool anon_exclusive, ret = true;
struct mmu_notifier_range range;
enum ttu_flags flags = (enum ttu_flags)(long)arg;
// ...
mmu_notifier_invalidate_range_start(&range);

while (page_vma_mapped_walk(&pvmw)) {
/* Unexpected PMD-mapped THP? */
VM_BUG_ON_FOLIO(!pvmw.pte, folio);
// ...
// 清除 PTE,写入 swap entry 或置零
// 刷新 TLB
// 调用 page_remove_rmap() 减少 _mapcount
}
// ...
}

对于匿名页,PTE 被替换成 swap entry(编码了 swp_entry_t),后续访问触发缺页中断时内核可识别 swap entry 并发起换入。对于文件页,PTE 直接被清零(置为 not-present),访问时触发 minor fault 从 page cache 重建。


七、OOM Killer

7.1 触发条件

当所有回收尝试(kswapd + 直接回收)都告失败,__alloc_pages_slowpath() 最终调用 out_of_memory(),即 OOM Killer 的入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// mm/oom_kill.c(第 1106 行)
bool out_of_memory(struct oom_control *oc)
{
unsigned long freed = 0;

if (oom_killer_disabled)
return false;

if (!is_memcg_oom(oc)) {
blocking_notifier_call_chain(&oom_notify_list, 0, &freed);
if (freed > 0 && !is_sysrq_oom(oc))
/* Got some memory back in the last second. */
return true;
}

if (task_will_free_mem(current)) {
mark_oom_victim(current);
queue_oom_reaper(current);
return true;
}

if (oc->gfp_mask && !(oc->gfp_mask & __GFP_FS) && !is_memcg_oom(oc))
return true;

oc->constraint = constrained_alloc(oc);
if (oc->constraint != CONSTRAINT_MEMORY_POLICY)
oc->nodemask = NULL;
check_panic_on_oom(oc);

if (!is_memcg_oom(oc) && sysctl_oom_kill_allocating_task &&
current->mm && !oom_unkillable_task(current) &&
oom_cpuset_eligible(current, oc) &&
current->signal->oom_score_adj != OOM_SCORE_ADJ_MIN) {
get_task_struct(current);
oc->chosen = current;
oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)");
return true;
}

select_bad_process(oc);
if (!oc->chosen) {
dump_header(oc, NULL);
pr_warn("Out of memory and no killable processes...\n");
if (!is_sysrq_oom(oc) && !is_memcg_oom(oc))
panic("System is deadlocked on memory\n");
}
if (oc->chosen && oc->chosen != (void *)-1UL)
oom_kill_process(oc, !is_memcg_oom(oc) ? "Out of memory" :
"Memory cgroup out of memory");
return !!oc->chosen;
}

out_of_memory 会先检查各类快速出口(OOM killer 被禁用、通知链释放了内存、当前进程即将退出、GFP 标志不允许 FS 操作等),都无法处理时才真正选择受害者进程。

7.2 oom_badness:评分公式

select_bad_process() 遍历所有进程,对每个进程调用 oom_evaluate_task(),后者使用 oom_badness() 计算分数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// mm/oom_kill.c(第 201 行)
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;

if (oom_unkillable_task(p))
return LONG_MIN;

p = find_lock_task_mm(p);
if (!p)
return LONG_MIN;

adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN ||
test_bit(MMF_OOM_SKIP, &p->mm->flags) ||
in_vfork(p)) {
task_unlock(p);
return LONG_MIN;
}

/*
* The baseline for the badness score is the proportion of RAM that each
* task's rss, pagetable and swap space use.
*/
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
task_unlock(p);

/* Normalize to oom_score_adj units */
adj *= totalpages / 1000;
points += adj;

return points;
}

评分公式的核心:

1
2
score = RSS + Swap使用量 + 页表占用页数
score += oom_score_adj × (total_pages / 1000)
  • RSS(Resident Set Size):进程常驻内存大小,RSS 越大,杀掉后释放的内存越多,得分越高。
  • swap 使用量:进程已换出到 swap 的页面数也计入。
  • 页表:页表本身占用的物理内存也计入。
  • **oom_score_adj**:范围 [-1000, 1000],通过 /proc/PID/oom_score_adj 调整。设为 -1000 相当于豁免(返回 LONG_MIN),设为 1000 则大幅增加被杀概率。

得分最高(内存占用最大、oom_score_adj 最高)的进程会被选中为受害者。

7.3 oom_kill_process:发送 SIGKILL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// mm/oom_kill.c(第 1013 行)
static void oom_kill_process(struct oom_control *oc, const char *message)
{
struct task_struct *victim = oc->chosen;
struct mem_cgroup *oom_group;
// ...
task_lock(victim);
if (task_will_free_mem(victim)) {
mark_oom_victim(victim);
queue_oom_reaper(victim);
task_unlock(victim);
put_task_struct(victim);
return;
}
task_unlock(victim);

if (__ratelimit(&oom_rs))
dump_header(oc, victim);

oom_group = mem_cgroup_get_oom_group(victim, oc->memcg);

__oom_kill_process(victim, message);

if (oom_group) {
mem_cgroup_scan_tasks(oom_group, oom_kill_memcg_member,
(void *)message);
mem_cgroup_put(oom_group);
}
}

__oom_kill_process() 会:

  1. 调用 count_vm_event(OOM_KILL) 计数。
  2. 先向受害者发送 SIGKILL
  3. 如果该进程的 mm 没有被其他线程共享,将其加入 OOM reaper 队列。OOM reaper 是专门的内核线程(oom_reaper),它在受害者进程的 mm 上调用 exit_mmap() 强制释放内存,而不等待进程自然退出,确保内存被快速回收。

当设置了 memory.oom.group = 1 的 cgroup 中有进程触发 OOM 时,oom_kill_process 会杀死整个 cgroup 中的所有进程。


八、内存压缩(Compaction)

8.1 碎片化问题

Linux buddy allocator 使用的伙伴系统天然会产生外部碎片:即使总空闲页数充足,也可能无法满足高阶(order > 0)的连续内存分配请求,因为可用页面被分散在不连续的物理地址上。内存压缩(Compaction)通过迁移可移动页面来合并空闲块,解决这一问题。

8.2 compact_zone

compact_zone() 是内存压缩的核心函数,它使用两个扫描指针:

  • migrate scanner:从低地址向高地址扫描,寻找可迁移的页面(MIGRATE_MOVABLE 类型)。
  • free scanner:从高地址向低地址扫描,寻找空闲页面块作为迁移目标。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// mm/compaction.c(第 2317 行)
compact_zone(struct compact_control *cc, struct capture_control *capc)
{
enum compact_result ret;
unsigned long start_pfn = cc->zone->zone_start_pfn;
unsigned long end_pfn = zone_end_pfn(cc->zone);
// ...
cc->total_migrate_scanned = 0;
cc->total_free_scanned = 0;
// ...
cc->migrate_pfn = cc->zone->compact_cached_migrate_pfn[sync];
cc->free_pfn = cc->zone->compact_cached_free_pfn;
// ...

while ((ret = compact_finished(cc)) == COMPACT_CONTINUE) {
int err;
unsigned long start_pfn = cc->migrate_pfn;

// 1. 隔离可迁移页面(isolate_migratepages_block)
// 2. 隔离空闲页面(isolate_freepages)
// 3. 执行页面迁移(migrate_pages)
// 4. 释放迁移后的空闲页面回 buddy allocator
}
// ...
}

迁移完成后,低地址区域腾出了连续的空闲块,高阶分配得以满足。

8.3 kcompactd:后台压缩线程

与 kswapd 类似,每个 NUMA 节点有一个 kcompactd 后台线程。当 kswapd 完成回收但高阶分配仍然失败时,kswapd 会调用 wakeup_kcompactd() 唤醒 kcompactd,后者调用 compact_zone() 执行后台压缩,提高高阶分配成功率而不影响前台服务的延迟。


九、诊断方法

9.1 /proc/meminfo 关键字段解读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ cat /proc/meminfo
MemTotal: 16384000 kB # 总物理内存
MemFree: 512000 kB # 完全空闲(buddy 中的页)
MemAvailable: 4096000 kB # 实际可用(含可回收 cache),比 MemFree 更准确
Buffers: 128000 kB # 块设备的 buffer cache
Cached: 6144000 kB # 文件 page cache(不含 Buffers 和 SwapCached)
SwapCached: 64000 kB # 已换出但仍在 swap cache 中的页
Active: 5120000 kB # active LRU 总量(anon + file)
Inactive: 3072000 kB # inactive LRU 总量(anon + file)
Active(anon): 2048000 kB # active 匿名页
Inactive(anon): 1024000 kB # inactive 匿名页(换出候选)
Active(file): 3072000 kB # active 文件页
Inactive(file): 2048000 kB # inactive 文件页(回收候选)
Unevictable: 32000 kB # mlock 等不可回收页
Mlocked: 32000 kB # mlock 锁定的页
Dirty: 16000 kB # 脏页(待写回)
Writeback: 2000 kB # 正在写回的页
AnonPages: 3072000 kB # 匿名页总量
Mapped: 1024000 kB # 已映射到用户空间的文件页
Shmem: 512000 kB # 共享内存(tmpfs)
  • MemAvailable 远比 MemFree 更能反映系统真实可用内存,它综合考虑了 page cache 和 slab 的可回收部分。
  • Dirty 通常意味着 I/O 子系统跟不上写入速度,可能导致回收时的写回等待。
  • Writeback 长时间非零 表明正在发生大量回写,内存压力较高。

9.2 vmstat 实时监控

1
2
3
4
5
6
# -w 宽格式,1 秒刷新一次
$ vmstat -w 1

procs ---memory--- ---swap-- ---io--- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 1 102400 512000 64000 3072000 128 256 1024 2048 3000 5000 30 10 55 5 0

关键字段:

  • si(swap-in):每秒从 swap 换入的页数。非零表示系统正在积极换入。
  • so(swap-out):每秒换出到 swap 的页数。长期非零说明内存严重不足。
  • **bi/bo**:块设备读写(pages/s),结合 si/so 可判断是 swap I/O 还是文件 I/O。

9.3 /proc/vmstat 细粒度统计

1
2
3
4
5
6
$ cat /proc/vmstat | grep -E 'pgsteal|pgscan|pgmajfault'
pgscan_kswapd 1234567 # kswapd 扫描的页数
pgscan_direct 123456 # 直接回收扫描的页数
pgsteal_kswapd 1000000 # kswapd 实际回收的页数
pgsteal_direct 90000 # 直接回收实际回收的页数
pgmajfault 5678 # major page fault 次数(需要磁盘 I/O 的缺页)

pgscan / pgsteal 的比值反映回收效率:比值越接近 1,说明扫描的页面几乎都能成功回收;比值远大于 1 说明大量扫描都是无效的(页面被重新激活或跳过)。

9.4 sar -B:内存回收统计

1
2
3
4
5
$ sar -B 1 5
Linux 6.4.0 ...

14:30:01 pgpgin/s pgpgout/s fault/s majflt/s pgfree/s pgscank/s pgscand/s pgsteal/s %vmeff
14:30:02 256.0 512.0 1024.0 2.0 2048.0 4096.0 128.0 3800.0 90.33

%vmeff = pgsteal / (pgscank + pgscand) × 100,即回收效率百分比。低于 50% 时说明系统内存压力异常高,大量页面反复被扫描却无法回收。

9.5 BPFtrace 追踪 OOM

1
2
3
4
5
6
7
8
# 追踪 OOM kill 事件,打印被杀进程名和 PID
$ bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill: comm=%s pid=%d\n", comm, pid); }'

# 追踪 oom_badness 的评分过程
$ bpftrace -e '
kretprobe:oom_badness {
printf("oom_badness: pid=%d retval=%ld\n", pid, retval);
}'

9.6 dmesg 查看 OOM 日志

1
2
3
4
5
6
7
$ dmesg | grep -i oom
[123456.789] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),
cpuset=/,mems_allowed=0,global_oom,task_memcg=/docker/abc123,
task=mysqld,pid=12345,uid=999
[123456.790] Out of memory: Killed process 12345 (mysqld) total-vm:4194304kB,
anon-rss:3145728kB, file-rss:131072kB, shmem-rss:0kB,
UID:999 pgtables:8192kB oom_score_adj:0

OOM 日志包含:触发约束类型、目标 memcg 路径、被杀进程名/PID、内存分布(total-vm/anon-rss/file-rss)和 oom_score_adj 值,是排查内存泄漏和容量规划的重要依据。


十、总结:内存回收全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
内存分配请求(alloc_pages)

▼ 快速路径失败
__alloc_pages_slowpath

├─── 唤醒 kswapd ──────────────────────────────┐
│ │
├─── 直接回收 (try_to_free_pages) │ kswapd 后台回收
│ │ │ balance_pgdat
│ ▼ │ kswapd_shrink_node
│ shrink_node │ shrink_node
│ │ └───────────────────────
│ shrink_lruvec
│ │
│ ┌────┴────────────┐
│ │ │
│ shrink_active_list shrink_inactive_list
│ (降级到 inactive) │
│ ├── isolate_lru_folios
│ ├── shrink_folio_list
│ │ ├── 匿名页 → add_to_swap → try_to_unmap → swap_writepage
│ │ └── 文件页 → 脏则 writeback,干净则直接释放
│ └── 未回收页放回 LRU

├─── 内存压缩 (kcompactd / direct compact)

└─── OOM Killer (out_of_memory)

select_bad_process (oom_badness 评分)

oom_kill_process → SIGKILL → OOM reaper

Linux 内存回收机制是一个精巧的多层次系统:LRU 链表提供了基于访问热度的页面分层;kswapd 在水位告急前就开始预防性回收;直接回收作为最后的”同步”手段兜底;refault distance 防止误杀热页面;RMAP 确保能在常数时间内找到并解除所有 PTE 映射;OOM Killer 在万不得已时牺牲”最不重要”的进程换取系统继续运行。理解这些机制的协同工作方式,是编写内存高效程序和调优 Linux 系统不可或缺的基础。


参考源文件(Linux 6.4-rc1):

  • mm/vmscan.c — 回收主逻辑(6.4-rc1 约 8000 行)
  • mm/oom_kill.c — OOM Killer
  • mm/rmap.c — 反向映射
  • mm/compaction.c — 内存碎片整理
  • mm/swap_state.c — swap cache 管理
  • mm/workingset.c — 工作集检测
  • include/linux/mmzone.h — zone/lruvec 数据结构
  • include/linux/rmap.h — RMAP 数据结构
  • include/linux/mm_inline.h — LRU 内联函数

本文基于 Linux 6.4-rc1 源码(commit ac9a78681b92),系统梳理进程虚拟地址空间的数据结构、多级页表的实现细节、mmap 系统调用的完整路径,以及缺页异常的处理流程。所有代码片段均来自真实内核源文件。

一、进程虚拟地址空间布局

1.1 struct mm_struct:进程的内存管理核心

每个进程拥有独立的虚拟地址空间,其元数据由 struct mm_struct 描述,定义在 include/linux/mm_types.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// include/linux/mm_types.h
struct mm_struct {
struct {
struct maple_tree mm_mt; /* VMA 的 Maple Tree 索引 */
unsigned long mmap_base; /* mmap 区域的基地址 */
unsigned long mmap_legacy_base;/* 自底向上分配时的 mmap 基地址 */
unsigned long task_size; /* 用户态地址空间上限 */
pgd_t *pgd; /* 页全局目录(顶级页表)指针 */

atomic_t mm_users; /* 使用该 mm 的用户数 */
atomic_t mm_count; /* mm_struct 引用计数 */

int map_count; /* VMA 数量 */
spinlock_t page_table_lock; /* 保护页表及部分计数器 */
struct rw_semaphore mmap_lock; /* 保护 VMA 链表/树的读写锁 */

unsigned long start_code, end_code; /* 代码段范围 */
unsigned long start_data, end_data; /* 初始化数据段范围 */
unsigned long start_brk, brk; /* 堆的起始地址和当前顶端 */
unsigned long start_stack; /* 栈的起始地址 */
unsigned long arg_start, arg_end; /* 命令行参数范围 */
unsigned long env_start, env_end; /* 环境变量范围 */
} __randomize_layout;
unsigned long cpu_bitmap[];
};

值得注意的是,Linux 6.1 起用 Maple Treemm_mt)取代了旧的红黑树 + 链表双结构来管理 VMA,查找性能从 O(log n) 提升,同时减少了锁竞争。mmap_lock 是一把读写信号量,读路径(find_vma 等查询)持读锁,写路径(新建/删除 VMA)持写锁。

1.2 典型 64 位进程虚拟地址空间

在 x86-64 四级页表模式下(LA48),用户态地址空间为 0 ~ 0x0000_7fff_ffff_ffff(128 TB),布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
高地址
┌─────────────────────────────┐ 0xFFFF_FFFF_FFFF_FFFF
│ 内核空间 │ (用户态不可访问)
├─────────────────────────────┤ 0xFFFF_8000_0000_0000
│ (非规范地址空洞) │
├─────────────────────────────┤ 0x0000_7FFF_FFFF_FFFF
│ 栈 (向下增长) │ ← start_stack
│ ... │
├─────────────────────────────┤
│ mmap / 动态库映射区 │ ← mmap_base
│ ... │
├─────────────────────────────┤
│ 堆 (向上增长) │ start_brk → brk
├─────────────────────────────┤
│ BSS 段 (.bss) │
│ 数据段 (.data) │ start_data ~ end_data
├─────────────────────────────┤
│ 代码段 (.text) │ start_code ~ end_code
└─────────────────────────────┘ 0x0000_0000_0040_0000(通常)
低地址

五级页表(LA57)将用户态空间扩展至 128 PB,通过 CONFIG_X86_5LEVEL 开启,运行时由 pgtable_l5_enabled() 检测 CPU 的 LA57 特性位决定是否激活。


二、VMA:虚拟内存区域

2.1 struct vm_area_struct

VMA 是虚拟地址空间的最小管理单元,每段具有相同属性(权限、映射文件)的连续地址区间对应一个 struct vm_area_struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// include/linux/mm_types.h
struct vm_area_struct {
union {
struct {
unsigned long vm_start; /* VMA 起始地址(包含) */
unsigned long vm_end; /* VMA 结束地址(不包含) */
};
};

struct mm_struct *vm_mm; /* 所属进程的 mm_struct */
pgprot_t vm_page_prot; /* 页保护属性(由 vm_flags 派生) */
const vm_flags_t vm_flags; /* 访问权限与行为标志 */

/* 文件映射 interval tree 节点 */
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;

struct list_head anon_vma_chain; /* RMAP 匿名映射链 */
struct anon_vma *anon_vma; /* 匿名映射反向映射锚点 */

const struct vm_operations_struct *vm_ops; /* 操作函数表 */

unsigned long vm_pgoff; /* 在文件中的页偏移量 */
struct file *vm_file; /* 映射的文件(匿名映射为 NULL) */
void *vm_private_data; /* 驱动私有数据 */
} __randomize_layout;

vm_flags 常用标志(定义在 include/linux/mm.h):

1
2
3
4
5
6
7
#define VM_READ     0x00000001   /* 可读 */
#define VM_WRITE 0x00000002 /* 可写 */
#define VM_EXEC 0x00000004 /* 可执行 */
#define VM_SHARED 0x00000008 /* 共享映射 */
#define VM_MAYREAD 0x00000010 /* 允许设置 VM_READ */
#define VM_MAYWRITE 0x00000020 /* 允许设置 VM_WRITE */
#define VM_MAYEXEC 0x00000040 /* 允许设置 VM_EXEC */

VM_SHAREDVM_WRITE 的组合决定了写时复制(COW)行为:MAP_PRIVATE | PROT_WRITE 的 VMA 不含 VM_SHARED,写入时会触发 COW;MAP_SHARED | PROT_WRITEVM_SHARED,写入直接反映到文件。

2.2 struct vm_operations_struct

每类映射都通过 vm_ops 提供钩子,定义在 include/linux/mm.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct vm_operations_struct {
void (*open)(struct vm_area_struct *area); /* VMA 被复制(fork)时调用 */
void (*close)(struct vm_area_struct *area); /* VMA 被销毁时调用 */
int (*may_split)(struct vm_area_struct *area, unsigned long addr);
int (*mremap)(struct vm_area_struct *area);
int (*mprotect)(struct vm_area_struct *vma, unsigned long start,
unsigned long end, unsigned long newflags);
vm_fault_t (*fault)(struct vm_fault *vmf); /* 缺页时读入数据 */
vm_fault_t (*huge_fault)(struct vm_fault *vmf,
enum page_entry_size pe_size);
vm_fault_t (*map_pages)(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff); /* 预读多页 */
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf); /* 只读页变为可写前调用 */
vm_fault_t (*pfn_mkwrite)(struct vm_fault *vmf);
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);
const char *(*name)(struct vm_area_struct *vma); /* /proc/maps 中的名称 */
};

例如,ext4 文件映射使用 ext4_file_vm_ops,其 fault 钩子会调用 filemap_fault() 从页缓存读取数据;/dev/zero 使用 zero_vm_ops,其 fault 直接返回零页。

2.3 VMA 查找:find_vma

在 Linux 6.1 之后,find_vma 基于 Maple Tree 实现,源码在 mm/mmap.c

1
2
3
4
5
6
7
8
9
// mm/mmap.c  line 1858
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
unsigned long index = addr;

mmap_assert_locked(mm);
return mt_find(&mm->mm_mt, &index, ULONG_MAX);
}
EXPORT_SYMBOL(find_vma);

mt_find 在 Maple Tree 中找到第一个满足 key >= addr 的区间节点。若返回的 VMA 满足 vma->vm_start <= addr < vma->vm_end,说明 addr 落在该 VMA 内;若 addr < vma->vm_start,则 addr 在一个”空洞”里,返回的是下一个 VMA。调用方在缺页异常处理中需要额外验证前者条件。


三、mmap 系统调用路径

3.1 调用链总览

1
2
3
4
5
6
用户态 mmap(2)
→ sys_mmap_pgoff / SYSCALL_DEFINE6(mmap_pgoff)
→ ksys_mmap_pgoff() [mm/mmap.c]
→ vm_mmap_pgoff()
→ do_mmap() [mm/mmap.c]
→ mmap_region() [mm/mmap.c]

3.2 ksys_mmap_pgoff:文件描述符解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// mm/mmap.c  line 1402
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags,
unsigned long fd, unsigned long pgoff)
{
struct file *file = NULL;
unsigned long retval;

if (!(flags & MAP_ANONYMOUS)) {
audit_mmap_fd(fd, flags);
file = fget(fd); /* 从 fd 获取 struct file */
if (!file)
return -EBADF;
if (is_file_hugepages(file)) {
len = ALIGN(len, huge_page_size(hstate_file(file)));
} else if (unlikely(flags & MAP_HUGETLB)) {
retval = -EINVAL;
goto out_fput;
}
} else if (flags & MAP_HUGETLB) {
/* 匿名大页:通过 hugetlbfs 创建内存文件 */
...
}
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
out_fput:
if (file)
fput(file);
return retval;
}

匿名映射(MAP_ANONYMOUS)时 file 为 NULL,pgoff 对私有匿名映射被设置为 addr >> PAGE_SHIFT(在 do_mmap 中),用于 anon_vma 的索引。

3.3 do_mmap:权限检查与标志计算

do_mmapmm/mmap.c line 1222)是核心逻辑层,负责:

  1. 调用 get_unmapped_area() 找到合适的地址区间(ASLR 随机化在此发生);
  2. 通过 calc_vm_prot_bits / calc_vm_flag_bits 将 POSIX PROT_*MAP_* 转换为内核 VM_* 标志;
  3. 对文件映射验证文件模式(FMODE_READ / FMODE_WRITE);
  4. MAP_SHARED 文件映射:添加 VM_SHARED | VM_MAYSHAREMAP_PRIVATE 文件映射:不添加 VM_SHARED,写入时触发 COW;
  5. 最终调用 mmap_region()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// mm/mmap.c  line 1289(关键片段)
vm_flags = calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
...
/* 文件映射:MAP_SHARED */
case MAP_SHARED:
vm_flags |= VM_SHARED | VM_MAYSHARE;
if (!(file->f_mode & FMODE_WRITE))
vm_flags &= ~(VM_MAYWRITE | VM_SHARED);
fallthrough;
/* 文件映射:MAP_PRIVATE */
case MAP_PRIVATE:
if (!(file->f_mode & FMODE_READ))
return -EACCES;
break;

3.4 mmap_region:创建 VMA

mmap_regionmm/mmap.c line 2547)是真正分配 VMA 的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// mm/mmap.c  line 2547(核心流程)
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma = NULL;
...
/* 尝试合并相邻 VMA(减少碎片) */
if (vma && !vma_expand(&vmi, vma, merge_start, merge_end, vm_pgoff, next)) {
khugepaged_enter_vma(vma, vm_flags);
goto expanded;
}

cannot_expand:
/* 分配新 VMA */
vma = vm_area_alloc(mm);
vma->vm_start = addr;
vma->vm_end = end;
vm_flags_init(vma, vm_flags);
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;

if (file) {
/* 文件映射:调用 file->f_op->mmap() 设置 vm_ops */
vma->vm_file = get_file(file);
error = call_mmap(file, vma); /* file->f_op->mmap(file, vma) */
...
} else if (vm_flags & VM_SHARED) {
/* 匿名共享映射:通过 shmem_zero_setup 创建 tmpfs 文件 */
error = shmem_zero_setup(vma);
} else {
/* 匿名私有映射:vma_set_anonymous 将 vm_ops 设为 NULL */
vma_set_anonymous(vma);
}
...
}

MAP_PRIVATE vs MAP_SHARED 的本质区别

特性 MAP_PRIVATE MAP_SHARED
vm_flags 无 VM_SHARED 含 VM_SHARED
写时行为 COW:产生进程私有副本 直接修改底层页,其他进程可见
文件刷盘 修改不回写文件 修改最终通过 writeback 回写
匿名映射 vm_ops = NULL 通过 shmem 实现共享

四、多级页表结构

4.1 x86-64 五级页表层级

x86-64 采用分级页表将 57 位(五级)或 48 位(四级)虚拟地址翻译为物理地址。各级索引位划分如下(五级,arch/x86/include/asm/pgtable_64_types.h):

1
2
3
4
5
6
7
虚拟地址 [56:0](57 位):
bits[56:48] → PGD 索引(9 bit,512 项)
bits[47:39] → P4D 索引(9 bit,512 项)
bits[38:30] → PUD 索引(9 bit,512 项) PUD_SHIFT = 30
bits[29:21] → PMD 索引(9 bit,512 项) PMD_SHIFT = 21
bits[20:12] → PTE 索引(9 bit,512 项)
bits[11:0] → 页内偏移(12 bit,4 KB)

对应的类型定义(arch/x86/include/asm/pgtable_64_types.h):

1
2
3
4
5
6
7
8
9
10
11
typedef unsigned long  pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long p4dval_t;
typedef unsigned long pgdval_t;

typedef struct { pteval_t pte; } pte_t;
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { p4dval_t p4d; } p4d_t;
typedef struct { pgdval_t pgd; } pgd_t; /* 定义在 pgtable_types.h */

使用强类型结构体而非裸 unsigned long,可以让编译器在混用不同层级页表项时产生类型错误,是内核防御性编程的典型实践。

4.2 页表项位域

x86-64 每个页表项(PTE)为 64 位,关键位定义在 arch/x86/include/asm/pgtable_types.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define _PAGE_BIT_PRESENT   0   /* P:页存在 */
#define _PAGE_BIT_RW 1 /* R/W:可写 */
#define _PAGE_BIT_USER 2 /* U/S:用户态可访问 */
#define _PAGE_BIT_PWT 3 /* PWT:页写直通 */
#define _PAGE_BIT_PCD 4 /* PCD:禁用页缓存 */
#define _PAGE_BIT_ACCESSED 5 /* A:已访问(CPU 置位) */
#define _PAGE_BIT_DIRTY 6 /* D:已写脏(CPU 置位) */
#define _PAGE_BIT_PSE 7 /* PS:大页(PMD=2MB / PUD=1GB) */
#define _PAGE_BIT_GLOBAL 8 /* G:全局页(TLB 切换不刷新) */
#define _PAGE_BIT_NX 63 /* NX/XD:不可执行(需 EFER.NXE=1) */

#define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
#define _PAGE_RW (_AT(pteval_t, 1) << _PAGE_BIT_RW)
#define _PAGE_USER (_AT(pteval_t, 1) << _PAGE_BIT_USER)
#define _PAGE_ACCESSED (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)
#define _PAGE_PSE (_AT(pteval_t, 1) << _PAGE_BIT_PSE)
#define _PAGE_NX (_AT(pteval_t, 1) << _PAGE_BIT_NX)

典型用户页保护配置(arch/x86/include/asm/pgtable_types.h):

1
2
3
4
/* 可读不可写不可执行的用户共享页 */
#define PAGE_SHARED __pg(__PP|__RW|_USR|___A|__NX|0|0|0)
/* 只读可执行的代码页 */
#define PAGE_READONLY_EXEC __pg(__PP|0|_USR|___A|0|0|0|0)

4.3 页表遍历宏

include/linux/pgtable.h 提供了标准的多级页表遍历接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 从 mm->pgd 获取 PGD 项 */
#define pgd_offset(mm, address) pgd_offset_pgd((mm)->pgd, (address))

/* 依次向下索引各级 */
static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address) {
return pud_pgtable(*pud) + pmd_index(address);
}
static inline pud_t *pud_offset(p4d_t *p4d, unsigned long address) {
return p4d_pgtable(*p4d) + pud_index(address);
}

/* 非高端内存场景下 pte_offset_map 等价于 pte_offset_kernel */
#define pte_offset_map(dir, address) pte_offset_kernel((dir), (address))

一次完整的地址翻译调用链(见 include/linux/pgtable.h line 153):

1
2
3
4
5
6
7
8
static inline pmd_t *pmd_off(struct mm_struct *mm, unsigned long va) {
return pmd_offset(
pud_offset(
p4d_offset(
pgd_offset(mm, va), va),
va),
va);
}

五、缺页异常处理

5.1 入口:exc_page_fault

CPU 触发 #PF 异常后,进入 IDT 注册的处理函数(arch/x86/mm/fault.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// arch/x86/mm/fault.c  line 1546
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
unsigned long address = read_cr2(); /* 从 CR2 读取发生缺页的线性地址 */
irqentry_state_t state;

prefetchw(&current->mm->mmap_lock); /* 预取锁,减少缓存 miss */

if (kvm_handle_async_pf(regs, (u32)address))
return;

state = irqentry_enter(regs);
instrumentation_begin();
handle_page_fault(regs, error_code, address);
instrumentation_end();
...
}

handle_page_fault 判断缺页地址属于内核空间还是用户空间,用户空间路径调用 do_user_addr_fault,最终到达通用的 handle_mm_fault

5.2 __handle_mm_fault:分配页表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// mm/memory.c  line 4997
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
struct vm_fault vmf = {
.vma = vma,
.address = address & PAGE_MASK,
.flags = flags,
.pgoff = linear_page_index(vma, address),
.gfp_mask = __get_fault_gfp_mask(vma),
};
struct mm_struct *mm = vma->vm_mm;
pgd_t *pgd;
p4d_t *p4d;

/* 自顶向下分配缺失的页表页 */
pgd = pgd_offset(mm, address);
p4d = p4d_alloc(mm, pgd, address);
if (!p4d)
return VM_FAULT_OOM;

vmf.pud = pud_alloc(mm, p4d, address);
if (!vmf.pud)
return VM_FAULT_OOM;

/* 检查是否可用 1 GB 大页 */
if (pud_none(*vmf.pud) &&
hugepage_vma_check(vma, vm_flags, false, true, true)) {
ret = create_huge_pud(&vmf);
if (!(ret & VM_FAULT_FALLBACK))
return ret;
}

vmf.pmd = pmd_alloc(mm, vmf.pud, address);
if (!vmf.pmd)
return VM_FAULT_OOM;

/* 检查是否可用 2 MB 透明大页 */
if (pmd_none(*vmf.pmd) &&
hugepage_vma_check(vma, vm_flags, false, true, true)) {
ret = create_huge_pmd(&vmf);
if (!(ret & VM_FAULT_FALLBACK))
return ret;
}

return handle_pte_fault(&vmf);
}

5.3 handle_pte_fault:分发具体缺页类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// mm/memory.c  line 4893
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
pte_t entry;

if (unlikely(pmd_none(*vmf->pmd))) {
/* PMD 为空,PTE 尚未分配,延迟到具体 fault handler */
vmf->pte = NULL;
} else {
vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
vmf->orig_pte = *vmf->pte;
barrier();
if (pte_none(vmf->orig_pte)) {
pte_unmap(vmf->pte);
vmf->pte = NULL;
}
}

if (!vmf->pte)
return do_pte_missing(vmf); /* PTE 缺失:匿名页或文件页 */

if (!pte_present(vmf->orig_pte))
return do_swap_page(vmf); /* 页在 swap,需换入 */

if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf); /* NUMA 迁移提示 */

/* PTE 存在但写保护 → COW */
if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
if (!pte_write(entry))
return do_wp_page(vmf);
}
...
}

do_pte_missing 会进一步区分:

1
2
3
4
// mm/memory.c  line 3640(do_pte_missing 内部)
if (vma->vm_ops)
return do_fault(vmf); /* 文件映射缺页 */
return do_anonymous_page(vmf); /* 匿名映射缺页 */

5.4 匿名缺页:do_anonymous_page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// mm/memory.c  line 4031
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 后返回 */
goto setpte;
}

/* 写缺页:分配并清零新物理页 */
if (unlikely(anon_vma_prepare(vma)))
goto oom;
folio = vma_alloc_zeroed_movable_folio(vma, vmf->address);
if (!folio)
goto oom;

entry = mk_pte(&folio->page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));

vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
vmf->address, &vmf->ptl);
/* 安装 PTE */
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
...
}

零页优化:进程第一次读未初始化匿名内存时,内核将虚拟地址映射到一个全局共享的只读零页(my_zero_pfn),物理内存实际零分配。当写入发生时,才触发 COW 分配真实物理页。

5.5 文件缺页:__do_fault

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// mm/memory.c  line 4150
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret;

/* 预分配 PTE 页表页,避免在持锁情况下分配内存导致死锁 */
if (pmd_none(*vmf->pmd) && !vmf->prealloc_pte) {
vmf->prealloc_pte = pte_alloc_one(vma->vm_mm);
if (!vmf->prealloc_pte)
return VM_FAULT_OOM;
}

/* 调用 VMA 的 fault 钩子(如 filemap_fault)从页缓存读取数据 */
ret = vma->vm_ops->fault(vmf);
...
return ret;
}

对于 ext4/xfs 等文件系统,vm_ops->fault 最终调用 filemap_fault,先在 page cache 中查找,命中则直接返回(minor fault);否则提交 I/O 并等待(major fault)。

5.6 写时复制:do_wp_page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// mm/memory.c  line 3324
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = NULL;

vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);

/* 共享映射:标记页可写,通知文件系统 */
if (vma->vm_flags & (VM_SHARED | VM_MAYSHARE)) {
if (!vmf->page)
return wp_pfn_shared(vmf);
return wp_page_shared(vmf);
}

/* 私有映射:若页面为进程独占,可直接复用 */
if (folio && folio_test_anon(folio)) {
if (PageAnonExclusive(vmf->page))
goto reuse; /* 跳过复制,直接设置写权限 */
}

/* 通用路径:分配新页,复制数据,安装新 PTE */
return wp_page_copy(vmf);
}

wp_page_copy 分配一个新的物理页,调用 __wp_page_copy_user 复制内容,然后将原来的只读 PTE 替换为指向新页的可写 PTE,并 flush TLB 使旧映射失效。


六、TLB 管理

6.1 TLB 的作用

TLB(Translation Lookaside Buffer)是 CPU 内部对最近页表查找结果的缓存,避免每次内存访问都遍历多级页表(每级一次内存读取)。x86-64 通常有独立的 L1 iTLB / dTLB 以及共享的 L2 TLB。

当内核修改页表(unmap、mprotect、mremap 等)后,必须使相关 TLB 条目失效(TLB shootdown),否则其他 CPU 可能使用过时的映射。

6.2 TLB 刷新接口

1
2
3
4
5
6
7
8
/* 刷新整个进程的 TLB(进程退出、exec 等) */
flush_tlb_mm(mm);

/* 刷新指定地址范围的 TLB(munmap、mprotect) */
flush_tlb_range(vma, start, end);

/* 刷新单个页面 */
flush_tlb_page(vma, address);

x86 的 SMP TLB shootdown 通过 IPI(Inter-Processor Interrupt)通知其他 CPU 执行 INVLPG 指令(针对单页)或 MOV CR3(针对整个地址空间)。

6.3 PCID 优化

传统 MOV CR3 切换进程时会完全刷新 TLB,代价高昂。Intel Haswell 以后支持 PCID(Process Context Identifier),允许 TLB 条目携带 12 位的进程标识,切换时通过设置 CR3 的第 63 位为 0 来保留其他进程的 TLB 条目:

  • 内核在 switch_mm_irqs_off 中维护 PCID → mm 的映射;
  • 每个 CPU 可缓存最多 6 个活跃 PCID(内核实现中);
  • Meltdown 修复引入了内核/用户 PCID 对(Kaiser/PTI),切换额外开销约 100 ns。

6.4 透明大页(THP)

THP(Transparent Huge Pages)允许匿名映射自动使用 2 MB PMD 大页,由 __handle_mm_fault 中的 create_huge_pmd 触发,实际调用 do_huge_pmd_anonymous_page

  1. 分配 512 个连续物理页(order-9 compound page);
  2. 设置 PMD 项的 _PAGE_PSE 位,指向 2 MB 物理基地址;
  3. TLB 条目覆盖 2 MB,减少 TLB miss 次数;
  4. 写时发生 COW 时通过 __split_huge_pmd 降级为 4 KB 页。

七、诊断方法

7.1 /proc/PID/maps

/proc/PID/maps 列出进程所有 VMA,每行对应一个 vm_area_struct

1
2
3
4
5
6
7
地址范围               权限   偏移量   设备  inode   文件路径
7f3a4c000000-7f3a4c021000 r--p 00000000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c021000-7f3a4c176000 r-xp 00021000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c176000-7f3a4c1c4000 r--p 00176000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c1c4000-7f3a4c1c5000 r--p 001c3000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7f3a4c1c5000-7f3a4c1c8000 rw-p 001c4000 fd:01 1234567 /lib/x86_64-linux-gnu/libc.so.6
7fff8e200000-7fff8e221000 rw-p 00000000 00:00 0 [stack]

权限字段含义:r(VM_READ)、w(VM_WRITE)、x(VM_EXEC)、p(MAP_PRIVATE)/ s(MAP_SHARED)。

7.2 /proc/PID/smaps

smapsmaps 基础上提供每个 VMA 的内存统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
7f3a4c021000-7f3a4c176000 r-xp 00021000 fd:01 1234567  /lib/.../libc.so.6
Size: 1364 kB # VMA 虚拟大小
KernelPageSize: 4 kB # 内核页大小
MMUPageSize: 4 kB # MMU 页大小
Rss: 892 kB # 常驻内存(已映射物理页)
Pss: 124 kB # 按共享比例分摊的常驻内存
Shared_Clean: 892 kB # 共享且未脏页
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 892 kB # 近期被访问的页
Anonymous: 0 kB # 匿名页数量
LazyFree: 0 kB # madvise(MADV_FREE) 标记待回收
AnonHugePages: 0 kB # 透明大页匿名部分
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Swap: 0 kB # 换出到 swap 的页

7.3 /proc/PID/pagemap

pagemap 是虚拟地址到物理帧(PFN)的映射接口,每个虚拟页对应一个 8 字节条目:

  • bit 63:页存在(Present)
  • bit 62:页换出到 swap
  • bit 61:文件映射或共享匿名页
  • bits 54:0:若存在,为 PFN;若换出,为 swap 偏移

示例读取脚本:

1
2
3
4
5
6
7
8
9
10
11
12
# 查看进程虚拟地址 0x400000 对应的物理帧号
python3 -c "
import struct, os, sys
pid = int(sys.argv[1])
va = 0x400000
with open(f'/proc/{pid}/pagemap', 'rb') as f:
f.seek((va >> 12) * 8)
entry = struct.unpack('Q', f.read(8))[0]
present = (entry >> 63) & 1
pfn = entry & ((1 << 55) - 1)
print(f'present={present}, pfn=0x{pfn:x}, phys=0x{pfn << 12:x}')
" <PID>

7.4 /proc/PID/status 内存字段

1
2
3
4
5
6
7
VmPeak:   102400 kB   # 历史峰值虚拟内存
VmSize: 98304 kB # 当前虚拟内存大小
VmRSS: 12288 kB # 常驻物理内存(= RssAnon + RssFile + RssShmem)
RssAnon: 8192 kB # 匿名页 RSS
RssFile: 4096 kB # 文件映射 RSS
RssShmem: 0 kB # 共享内存 RSS
VmSwap: 1024 kB # 换出到 swap 的大小

7.5 pmap -x

1
pmap -x <PID>

输出中 RSS 列为常驻集大小,Dirty 列为脏页大小(写入但未回写),Mapping 列为文件名或 [ anon ]/ [ stack ]

7.6 bpftrace 追踪缺页

1
2
3
4
5
6
7
8
9
10
11
12
# 追踪某进程的缺页异常,打印触发地址和故障类型
bpftrace -e '
kprobe:handle_mm_fault /pid == $1/ {
printf("fault addr=0x%lx flags=0x%x comm=%s\n",
arg1, arg2, comm);
}' <PID>

# 追踪 do_anonymous_page(匿名缺页)调用频率
bpftrace -e 'kprobe:do_anonymous_page { @[comm] = count(); }'

# 追踪 do_wp_page(写时复制)
bpftrace -e 'kprobe:do_wp_page { @[comm] = count(); }'

7.7 perf 统计缺页频率

1
2
3
4
5
6
# 统计目标进程的缺页次数(运行 5 秒)
perf stat -e page-faults,major-faults,minor-faults -p <PID> -- sleep 5

# 采样 page-faults 事件,生成火焰图
perf record -e page-faults -g -p <PID> -- sleep 10
perf script | stackcollapse-perf.pl | flamegraph.pl > pagefault.svg

minor-faults(次缺页):页表项缺失但数据已在内存(如零页映射、文件已在 page cache),无需 I/O;major-faults(主缺页):需要从磁盘读取数据,延迟高达毫秒级。


八、源码阅读路线

理解本文涉及的机制,建议按以下顺序阅读源码:

文件 关键内容
include/linux/mm_types.h mm_structvm_area_structvm_fault 等核心数据结构
include/linux/mm.h vm_operations_struct、VM_* 标志、find_vma/find_vma_intersection 等内联函数
include/linux/pgtable.h pgd_offsetpmd_offsetpte_offset_map 宏与页表遍历函数
arch/x86/include/asm/pgtable_types.h x86 页表项位定义、pgd_t/pte_t 类型
arch/x86/include/asm/pgtable_64_types.h x86-64 各级页表移位常量(PGDIR_SHIFTPMD_SHIFT 等)
mm/mmap.c do_mmapmmap_regionfind_vmavma_merge 实现
mm/memory.c handle_mm_faulthandle_pte_faultdo_anonymous_pagedo_faultdo_wp_page
arch/x86/mm/fault.c x86 缺页异常入口 exc_page_faulthandle_page_fault

总结

虚拟内存是现代操作系统最重要的抽象之一。Linux 通过 mm_struct 和 VMA 体系将进程的虚拟地址空间划分为具有语义的区段,通过多级页表(四级/五级)在硬件层面实现地址翻译,通过缺页异常机制实现按需分页(demand paging)、写时复制(COW)和内存映射文件(mmap)。TLB 则是整个体系的性能关键,PCID 和 Huge Page 是两种重要的 TLB 优化手段。

理解这套机制不仅有助于写出更高效的用户态程序(合理使用 mmap、mlock、madvise),也是分析内存泄漏、性能瓶颈、OOM 问题的基础。后续文章将深入讨论物理内存分配器(Buddy System 与 SLAB)以及内存回收(Kswapd 与 LRU 算法)。

在上一篇文章中,我们梳理了 Linux Buddy 系统如何以页为单位管理物理内存。然而内核中大量数据结构(task_structinodedentry……)都远小于一页(4 KiB),若每次都向 Buddy 系统申请整页,会造成严重的内部碎片。为此 Linux 在 Buddy 之上引入了 Slab 分配器层,专为固定大小的内核对象服务。

本文基于 Linux 6.4-rc1commit ac9a78681b92)源码,深入剖析三种 Slab 实现的设计哲学、SLUB 的核心数据结构与分配/释放路径,并延伸到 vmalloc 虚拟连续内存分配。

Read more »

物理内存是操作系统最基础的资源之一,而 Linux 内核的内存管理子系统正是围绕着如何高效、可靠地组织与分配这些物理页帧展开的。本文基于 Linux 6.4-rc1 源码,系统性地剖析物理内存的组织模型、zone 水位机制、伙伴分配器的核心算法,以及 Per-CPU 页帧缓存、GFP 标志体系和 OOM Killer 的工作原理,并给出实用的诊断方法。

Read more »

BPF CPU Usage High issue

Objective

Problem Statement

https://docs.google.com/document/d/1HlvGBoT8gL3LToCIB8KH88EG4toh7YiiQnNApNyosOo/edit

https://docs.google.com/document/d/1ibjJWueVClKet0by0fWbFHz2vEfj3viL5ss8byJuuA4/edit#heading=h.ubx3xrz6e41r

We met the bpf cpu high issue many times after the cilium conntrack table was full. It impacts the L4/L7 traffic reliability and also impacts the reliability confidence about cilium and the cilium rollout on tlb/gateway nodes.

We have taken some measures to try to avoid the conntrack table from getting full. However, if the load further increases or if the garbage collection is delayed for some reason, the conntrack table can still become full, which will trigger this issue. Therefore, we aim to address this problem fundamentally.

Read more »

网络问题是线上故障中最难定位的一类。症状千变万化——P99 延迟突然抖动、某个服务间歇性超时、容器之间偶发丢包——而根因可能藏在协议栈的任何一层:网卡驱动的 ring buffer 溢出、内核 backlog 队列打满、TCP 重传引发的滑动窗口收缩、iptables 规则误命中,乃至 NUMA 拓扑导致的中断不均衡。本文是本系列第十篇,聚焦于工具链与实战:从 ss 的每个输出字段到 bpftrace 脚本,从 /proc/net 的原始数字到三个完整的排查案例,构建一套系统化的网络诊断方法论。

Read more »

在前几篇的基础上,本文聚焦于 Linux 网络栈中性能极限的挖掘:从硬件卸载、多队列扩展,到内核旁路(Kernel Bypass)技术,再到发送路径的零拷贝优化,最后落地到实际的诊断工具链。所有代码片段均取自 Linux 6.4-rc1 源码树,并附有源文件路径与行号,供读者对照阅读。

Read more »

容器网络是现代云原生基础设施的底座。Docker、Kubernetes 依赖 Linux 内核提供的四大网络虚拟化原语——Network Namespace、veth pair、Linux Bridge、VXLAN——把跑在同一台物理机或跨越多台主机的容器连接成一张逻辑网络。本文基于 Linux 6.4-rc1 源码,逐层拆解这四个模块的内核实现,并在最后串联出一条完整的容器间数据包路径。

Read more »

在前六篇中,我们依次拆解了 sk_buff 生命周期、网卡驱动收发路径、TCP/IP 协议栈、路由子系统、套接字层以及 NAPI/GRO 机制。本篇聚焦数据包过滤与处理的核心框架:Netfilter hook 体系、nftables 规则执行引擎、conntrack 连接追踪,以及近年来高速演进的 eBPF 网络程序(XDP、tc BPF、Socket Filter)。所有代码片段均源自 Linux 6.4-rc1。

Read more »
0%