Linux 内存管理深度剖析(三):虚拟内存空间与多级页表

本文基于 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 算法)。