Linux 内存管理深度剖析(六):mmap 文件映射、共享内存与写时复制

本系列前几篇文章分别介绍了虚拟内存布局、物理内存分配器、页表体系与缺页异常处理机制。本篇继续深入,聚焦于三个紧密相关的主题:mmap 文件映射(把文件直接映射到进程地址空间)、共享内存(多进程通过同一块物理页通信)以及写时复制(COW)fork() 后父子进程高效共享内存的核心机制)。

理解这些机制对于系统编程、性能调优和内核开发都至关重要。mmap 是高性能 I/O 和数据库(如 SQLite WAL 模式、RocksDB mmap 读)的底层利器;COW 让 fork() 的成本从”复制整个进程内存”降至”几乎可以忽略不计”;共享内存则是进程间通信(IPC)延迟最低的手段,Redis 的 RDB 持久化、Nginx 的 worker 与 master 进程通信都依赖于此。

所有代码片段均基于 Linux 6.4-rc1(commit ac9a78681b92)。


一、mmap 文件映射:从系统调用到缺页处理

1.1 do_mmap:建立映射的入口

用户态调用 mmap(2) 后,经过 ksys_mmap_pgoff 进入内核核心逻辑 do_mmapmm/mmap.c)。

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
// mm/mmap.c:1222
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, unsigned long pgoff,
unsigned long *populate, struct list_head *uf)
{
struct mm_struct *mm = current->mm;
vm_flags_t vm_flags;
int pkey = 0;

validate_mm(mm);
*populate = 0;

if (!len)
return -EINVAL;

/* PROT_READ 是否隐含 PROT_EXEC(与 personality 相关) */
if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
if (!(file && path_noexec(&file->f_path)))
prot |= PROT_EXEC;
...
len = PAGE_ALIGN(len);

/* 超过进程最大映射数量限制 */
if (mm->map_count > sysctl_max_map_count)
return -ENOMEM;

/* 找一个合适的虚拟地址区间 */
addr = get_unmapped_area(file, addr, len, pgoff, flags);
...
vm_flags = calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags)
| mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
...
addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
...
}

几个关键点:

  • get_unmapped_area:在进程地址空间中找一段满足对齐要求的空闲虚拟地址区间,若设置了 MAP_FIXED 则直接使用指定地址。对于文件映射,该函数会考虑文件系统的对齐要求(如 HugePage 映射需要 2MB 对齐);对于匿名映射,则从 mmap_base 向下增长(地址空间随机化开启时会有随机偏移)。
  • vm_flags:由保护位(PROT_READ/WRITE/EXEC)和映射标志(MAP_SHARED/PRIVATE)共同决定。MAP_SHARED 映射会设置 VM_SHARED | VM_MAYSHARE,意味着对该区域的修改会直接反映到底层文件(或共享页面);MAP_PRIVATE 则不设置 VM_SHARED,写入触发 COW,不影响原始文件。
  • mmap_region:真正负责分配 VMA(struct vm_area_struct)并将其插入进程地址空间(Linux 6.1+ 使用 maple tree 替代红黑树,查找性能更优)。它还会尝试将新 VMA 与相邻的 VMA 合并(can_vma_merge_before/after),减少 VMA 数量,降低内存占用和管理开销。
  • **vm_pgoff**:记录文件映射的起始偏移(页为单位),mmap(fd, offset=4096)vm_pgoff = 1。缺页时通过 vmf->pgoff = vma->vm_pgoff + ((addr - vma->vm_start) >> PAGE_SHIFT) 计算目标页在文件中的位置。

mmap_region 最终调用文件的 f_op->mmap 回调,以 ext4/xfs 为代表的普通文件系统最终都会走到 generic_file_mmap

1.2 generic_file_mmap:设置 VMA 操作集

1
2
3
4
5
6
7
8
9
10
11
// mm/filemap.c:3594
int generic_file_mmap(struct file *file, struct vm_area_struct *vma)
{
struct address_space *mapping = file->f_mapping;

if (!mapping->a_ops->read_folio)
return -ENOEXEC;
file_accessed(file);
vma->vm_ops = &generic_file_vm_ops; /* 关键:设置 vm_ops */
return 0;
}

generic_file_vm_ops 定义了该 VMA 的缺页处理函数集,其中最重要的两个成员是:

1
2
3
4
5
6
// mm/filemap.c:3585-3592
const struct vm_operations_struct generic_file_vm_ops = {
.fault = filemap_fault, /* 单页缺页处理 */
.map_pages = filemap_map_pages, /* 批量预映射优化 */
.page_mkwrite = filemap_page_mkwrite,
};

vm_ops 在此设置完毕,后续进程访问该地址段时触发缺页异常,内核便会调用 filemap_fault 来完成实际的物理页映射。

1.3 filemap_fault:文件 mmap 缺页处理

当进程首次访问 mmap 映射区域时,由于 PTE 为空,硬件触发 #PF,内核调用链最终到达 filemap_faultmm/filemap.c:3243):

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
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
struct file *file = vmf->vma->vm_file;
struct address_space *mapping = file->f_mapping;
pgoff_t index = vmf->pgoff;
struct folio *folio;
vm_fault_t ret = 0;

/* 先在页缓存(page cache)中查找 */
folio = filemap_get_folio(mapping, index);
if (likely(!IS_ERR(folio))) {
/* 找到了,尝试异步预读 */
if (!(vmf->flags & FAULT_FLAG_TRIED))
fpin = do_async_mmap_readahead(vmf, folio);
...
} else {
/* 页缓存缺失:计为 major fault,触发同步预读 */
count_vm_event(PGMAJFAULT);
ret = VM_FAULT_MAJOR;
fpin = do_sync_mmap_readahead(vmf);
retry_find:
folio = __filemap_get_folio(mapping, index,
FGP_CREAT|FGP_FOR_MMAP,
vmf->gfp_mask);
...
}
/* 锁页、验证 uptodate 状态,最终将页映射到 PTE */
...
}

流程总结:

  1. 页缓存命中(minor fault):直接拿到 folio,映射到 PTE,耗时极短(通常 < 1 μs)。
  2. 页缓存缺失(major fault):触发 do_sync_mmap_readahead,从磁盘读入,走 I/O 路径,耗时较长(取决于存储设备,NVMe 通常 100μs 量级,HDD 可达 10ms)。
  3. 读入完成后锁定 folio,调用 do_set_pte 将物理页帧号写入 PTE,完成映射。

值得注意的是,filemap_fault 中的 do_async_mmap_readaheaddo_sync_mmap_readahead 代表两种预读策略:

  • 同步预读:首次访问某页时,内核会提前读入后续若干页(由 ra.ra_pages 控制,默认 32 页 = 128 KB),以摊销 I/O 开销;
  • 异步预读:当访问到预读窗口末尾时,提前异步触发下一批次预读,隐藏 I/O 延迟。

对于顺序读场景,预读算法能将磁盘吞吐量接近理论上限;对于随机 mmap 访问(如数据库的随机读),可通过 madvise(MADV_RANDOM) 禁用预读,节省不必要的 I/O。

1.4 filemap_map_pages:预映射优化

map_pages 是一项重要性能优化:在处理单个缺页时,内核会顺带将相邻的已在页缓存中的页面一并映射,以减少后续缺页次数(fault-around)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// mm/filemap.c:3483
vm_fault_t filemap_map_pages(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff)
{
struct address_space *mapping = file->f_mapping;
XA_STATE(xas, &mapping->i_pages, start_pgoff);
struct folio *folio;

rcu_read_lock();
folio = first_map_page(mapping, &xas, end_pgoff);
...
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, addr, &vmf->ptl);
do {
page = folio_file_page(folio, xas.xa_index);
...
do_set_pte(vmf, page, addr); /* 批量写入 PTE */
update_mmu_cache(vma, addr, vmf->pte);
...
} while ((folio = next_map_page(mapping, &xas, end_pgoff)) != NULL);
...
}

通过 XArray 遍历页缓存,把连续页面批量 do_set_pte,一次性减少多次缺页开销。

1.5 MAP_PRIVATE 文件映射与 COW 语义

MAP_PRIVATE 文件映射是最典型的只读共享 + 写时复制场景:

  • 读时:多个进程的 PTE 指向同一份页缓存 folio,物理页只有一份。
  • 写时:内核将进程的写保护 PTE 标记为只读,第一次写时触发 do_wp_page,为该进程分配私有页并复制内容(详见第三节)。

这正是 Linux 进程加载动态库 .so 的工作方式:代码段 MAP_PRIVATE|PROT_READ|PROT_EXEC,所有进程共享同一份物理页;数据段对写入使用 COW,各进程独立修改各自的副本。

MAP_PRIVATEMAP_SHARED 的核心区别体现在 do_mmap 的 flag 检查中:

1
2
3
4
5
6
7
8
9
// mm/mmap.c:1336
case MAP_SHARED_VALIDATE:
...
vm_flags |= VM_SHARED | VM_MAYSHARE; /* 共享:写入同步到文件 */
...
case MAP_PRIVATE:
/* 私有:vm_flags 不含 VM_SHARED,写入触发 COW */
pgoff = addr >> PAGE_SHIFT; /* 匿名私有映射时 pgoff 用地址编码 */
break;

对于 MAP_PRIVATE|MAP_FILE(私有文件映射),COW 发生后产生的私有页不再属于页缓存,而是作为匿名页(MM_ANONPAGES)计入进程内存统计,这也是为什么 smaps 中私有写过的文件映射区域会出现 Private_Dirty 字段。


二、匿名共享内存:tmpfs 与 shmem

2.1 匿名共享映射的底层文件

当调用 mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0) 时,内核需要一个”虚拟文件”来管理共享页面。这个文件由 shmem_zero_setupmm/shmem.c)创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// mm/shmem.c:4332
int shmem_zero_setup(struct vm_area_struct *vma)
{
struct file *file;
loff_t size = vma->vm_end - vma->vm_start;

/* 在内核私有 tmpfs 挂载点上创建匿名文件 */
file = shmem_kernel_file_setup("dev/zero", size, vma->vm_flags);
if (IS_ERR(file))
return PTR_ERR(file);

if (vma->vm_file)
fput(vma->vm_file);
vma->vm_file = file;
vma->vm_ops = &shmem_anon_vm_ops; /* 使用 shmem 的 vm_ops */

return 0;
}

该文件使用内核私有的 shm_mnt tmpfs 挂载点,对用户不可见(clear_nlink 确保无目录项),但提供了完整的 inode/页缓存语义。

2.2 __shmem_file_setup:创建 tmpfs inode

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
// mm/shmem.c:4251
static struct file *__shmem_file_setup(struct vfsmount *mnt, const char *name,
loff_t size, unsigned long flags,
unsigned int i_flags)
{
struct inode *inode;
struct file *res;

if (size < 0 || size > MAX_LFS_FILESIZE)
return ERR_PTR(-EINVAL);

if (shmem_acct_size(flags, size)) /* 检查内存配额 */
return ERR_PTR(-ENOMEM);

/* 创建 S_IFREG 类型的 tmpfs inode */
inode = shmem_get_inode(&nop_mnt_idmap, mnt->mnt_sb, NULL,
S_IFREG | S_IRWXUGO, 0, flags);
...
inode->i_size = size;
clear_nlink(inode); /* 无目录项,unlinked */

res = alloc_file_pseudo(inode, mnt, name, O_RDWR,
&shmem_file_operations);
...
return res;
}

shmem_file_setup(公开 API)直接封装了上述函数,供 System V 共享内存、memfd_create 等使用。

2.3 shmem_fault:匿名共享内存缺页

shmem 使用自己的 vm_ops,缺页时调用 shmem_faultmm/shmem.c:2095):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static vm_fault_t shmem_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct inode *inode = file_inode(vma->vm_file);
gfp_t gfp = mapping_gfp_mask(inode->i_mapping);
struct folio *folio = NULL;
int err;
vm_fault_t ret = VM_FAULT_LOCKED;

/* 处理正在进行 fallocate hole-punch 的竞争 */
if (unlikely(inode->i_private)) {
...
schedule(); /* 等待 hole-punch 完成 */
...
}

/* 核心:分配或获取 shmem 页 */
err = shmem_get_folio_gfp(inode, vmf->pgoff, &folio, SGP_CACHE,
gfp, vma, vmf, &ret);
...
return ret;
}

shmem_get_folio_gfp 首先查找页缓存,若缺失则分配新物理页并加入页缓存。对于 MAP_SHARED 映射,所有映射同一 inode 同一偏移的 VMA 都会映射到同一物理页,这正是进程间通信的物理基础。

2.4 POSIX 共享内存与 System V 共享内存

**POSIX shm_open**:本质是在 /dev/shm(tmpfs 文件系统)上创建/打开普通文件,通过 mmap 映射。路径:shm_openopen("/dev/shm/name")mmapshmem_fault。POSIX 共享内存有文件系统可见性,可以通过 ls /dev/shm 查看,进程退出后(若未 shm_unlink)文件依然存在。

**System V shmget/shmat**:内核路径略有不同。shmget 最终调用 shmem_kernel_file_setup 创建内核私有 tmpfs 文件并保存在 struct shmid_kernel 中;shmat 调用 do_shmatdo_mmap,将该文件 mmap 进进程地址空间。两者底层都依赖 tmpfs/shmem 的页缓存,原理一致。System V 共享内存通过 ipcs -m 查看,生命周期独立于进程(直到显式 shmctl(IPC_RMID) 或系统重启)。

memfd_create(现代匿名共享内存):Linux 3.17 引入,创建一个无路径的匿名 tmpfs 文件描述符,可通过 /proc/PID/fd 传递给其他进程:

1
2
3
4
int fd = memfd_create("my_shm", MFD_CLOEXEC);
ftruncate(fd, SIZE);
void *ptr = mmap(NULL, SIZE, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
/* 通过 sendmsg/SCM_RIGHTS 将 fd 传给其他进程,实现共享 */

memfd_create 是目前推荐的进程间共享内存方式,结合了 POSIX shm 的易用性和匿名映射的安全性(无文件系统路径,无权限问题)。Android 的 Binder IPC 大量数据传输使用的 ashmem(现已迁移为 memfd_create)即是此机制。


三、fork() 与写时复制(COW)

COW 是 Linux 高效实现 fork() 的关键——fork 时不复制物理页,而是让父子进程共享同一份物理页,仅在写入时才真正复制,大幅减少 fork 开销。

3.1 copy_mm:fork 时处理 mm_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
// kernel/fork.c:1714
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;

tsk->mm = NULL;
tsk->active_mm = NULL;

oldmm = current->mm;
if (!oldmm)
return 0; /* 内核线程,无用户地址空间 */

if (clone_flags & CLONE_VM) {
/* pthread:直接共享父进程 mm,引用计数 +1 */
mmget(oldmm);
mm = oldmm;
} else {
/* fork:完整复制 mm */
mm = dup_mm(tsk, current->mm);
if (!mm)
return -ENOMEM;
}

tsk->mm = mm;
tsk->active_mm = mm;
...
return 0;
}

CLONE_VMpthread_create 使用)不复制 mm,线程与父进程共享整个地址空间;真正的 fork() 则调用 dup_mmdup_mmap

3.2 dup_mmap:复制所有 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
38
39
40
41
// kernel/fork.c:649
static __latent_entropy int dup_mmap(struct mm_struct *mm,
struct mm_struct *oldmm)
{
struct vm_area_struct *mpnt, *tmp;
VMA_ITERATOR(old_vmi, oldmm, 0);
VMA_ITERATOR(vmi, mm, 0);

/* 复制 mm 级别的元数据 */
mm->total_vm = oldmm->total_vm;
mm->data_vm = oldmm->data_vm;
mm->exec_vm = oldmm->exec_vm;
mm->stack_vm = oldmm->stack_vm;

/* 预分配 VMA 节点,避免循环中分配失败 */
retval = vma_iter_bulk_alloc(&vmi, oldmm->map_count);

for_each_vma(old_vmi, mpnt) {
if (mpnt->vm_flags & VM_DONTCOPY)
continue; /* 如 vdso 等不需要复制的区段 */

/* 分配新 VMA 结构体,复制父 VMA 字段 */
tmp = vm_area_dup(mpnt);
tmp->vm_mm = mm;

if (tmp->vm_flags & VM_WIPEONFORK)
tmp->anon_vma = NULL; /* 子进程中清零此区段 */
else if (anon_vma_fork(tmp, mpnt))
goto fail_nomem_anon_vma_fork;

/* 将 VMA 插入子进程 maple tree */
vma_iter_bulk_store(&vmi, tmp);
mm->map_count++;

/* 关键:复制页表并标记 COW */
if (!(tmp->vm_flags & VM_WIPEONFORK))
retval = copy_page_range(tmp, mpnt);
...
}
...
}

3.3 copy_page_range:写保护标记 COW

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/memory.c:1251
copy_page_range(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma)
{
...
bool is_cow;

/* 判断此 VMA 是否需要 COW 语义 */
is_cow = is_cow_mapping(src_vma->vm_flags);

if (is_cow) {
/* 通知 MMU notifier,即将降低页面保护级别 */
mmu_notifier_range_init(&range, MMU_NOTIFY_PROTECTION_PAGE,
0, src_mm, addr, end);
mmu_notifier_invalidate_range_start(&range);
/* 原子序列计数:防止并发写入竞争 */
raw_write_seqcount_begin(&src_mm->write_protect_seq);
}

/* 递归遍历页表各级(pgd -> p4d -> pud -> pmd -> pte),
* 对每个可写 PTE,清除写权限位(wp_page_copy 时会恢复) */
dst_pgd = pgd_offset(dst_mm, addr);
src_pgd = pgd_offset(src_mm, addr);
do {
...
copy_p4d_range(dst_vma, src_vma, dst_pgd, src_pgd, addr, next);
...
} while (dst_pgd++, src_pgd++, addr = next, addr != end);
...
}

is_cow_mapping 判断条件:VMA 不带 VM_SHARED 且带有 VM_MAYWRITE(即 MAP_PRIVATE 可写映射)。对这类 VMA,copy_pte_range(深层函数)会将父子进程双方的 PTE 都改为只读(清除 _PAGE_RW 位),同时设置 PageAnonExclusive 等标记,为后续 COW 做准备。

3.4 do_wp_page:COW 写保护缺页处理

当父进程或子进程首次写入共享物理页时,触发写保护缺页,内核调用 do_wp_pagemm/memory.c:3324):

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
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = NULL;

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

/* 共享映射(MAP_SHARED):直接标记可写,不 COW */
if (vma->vm_flags & (VM_SHARED | VM_MAYSHARE)) {
if (!vmf->page)
return wp_pfn_shared(vmf);
return wp_page_shared(vmf);
}

if (vmf->page)
folio = page_folio(vmf->page);

/* 私有匿名页:判断能否复用(reuse)还是必须复制(copy) */
if (folio && folio_test_anon(folio)) {
/* 该页对此进程独占(PageAnonExclusive)→ 直接复用 */
if (PageAnonExclusive(vmf->page))
goto reuse;

/* 引用计数 > 3 说明有其他进程也映射了此页 → 必须复制 */
if (folio_test_ksm(folio) || folio_ref_count(folio) > 3)
goto copy;

/* 再次精确检查引用计数(含 swap cache) */
if (folio_ref_count(folio) > 1 + folio_test_swapcache(folio))
goto copy;

/* 只有一个引用者,可以直接复用 */
page_move_anon_rmap(vmf->page, vma);
folio_unlock(folio);
reuse:
wp_page_reuse(vmf); /* 直接恢复可写权限,不分配新页 */
return 0;
}
copy:
folio_get(folio);
pte_unmap_unlock(vmf->pte, vmf->ptl);
return wp_page_copy(vmf); /* 分配新页,复制内容 */
}

3.5 wp_page_reuse:单引用者快路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mm/memory.c:3006
static inline void wp_page_reuse(struct vm_fault *vmf)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
pte_t entry;

/* 重置 NUMA 平衡信息 */
if (page)
page_cpupid_xchg_last(page, (1 << LAST_CPUPID_SHIFT) - 1);

flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
entry = pte_mkyoung(vmf->orig_pte);
entry = maybe_mkwrite(pte_mkdirty(entry), vma); /* 重新标记可写+脏 */
if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
update_mmu_cache(vma, vmf->address, vmf->pte);
pte_unmap_unlock(vmf->pte, vmf->ptl);
count_vm_event(PGREUSE);
}

wp_page_reuse 只修改 PTE 标志位(从只读恢复到可写),无需分配新物理页,是 COW 场景中最快的路径。

3.6 wp_page_copy:真正的 COW 复制

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
// mm/memory.c:3050
static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct mm_struct *mm = vma->vm_mm;
struct folio *old_folio = NULL;
struct folio *new_folio = NULL;
pte_t entry;

if (is_zero_pfn(pte_pfn(vmf->orig_pte))) {
/* 零页(zero page)直接分配清零页 */
new_folio = vma_alloc_zeroed_movable_folio(vma, vmf->address);
} else {
/* 分配新物理页 */
new_folio = vma_alloc_folio(GFP_HIGHUSER_MOVABLE, 0, vma,
vmf->address, false);
/* 从用户态复制内容到新页 */
ret = __wp_page_copy_user(&new_folio->page, vmf->page, vmf);
...
}

/* cgroup 记账、设置 uptodate */
mem_cgroup_charge(new_folio, mm, GFP_KERNEL);
__folio_mark_uptodate(new_folio);

/* 重新获取 PTE 锁,验证 PTE 未被其他 CPU 修改 */
vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
if (likely(pte_same(*vmf->pte, vmf->orig_pte))) {
/* 更新页面统计(匿名页计数) */
if (!folio_test_anon(old_folio)) {
dec_mm_counter(mm, mm_counter_file(&old_folio->page));
inc_mm_counter(mm, MM_ANONPAGES);
}
/* 将新页写入 PTE,标记为可写+脏 */
entry = mk_pte(&new_folio->page, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
...
set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
/* 更新 rmap,解除对旧页的引用 */
page_remove_rmap(vmf->page, vma, false);
...
}
...
}

COW 完整流程:分配新物理页 → 复制旧页内容 → 更新 PTE 指向新页 → 递减旧页引用计数

3.7 _mapcount 与引用计数

每个 struct page(folio)维护两个关键计数:

字段 含义
page->_refcount 物理页总引用计数,包含 page cache、PTE 映射、内核直接引用等
page->_mapcount PTE 映射计数,即有多少条 PTE 指向此物理页

do_wp_page 中,folio_ref_count(folio) > 3 这个阈值:

  • 1 = page cache 自身持有
  • 1 = 调用方临时持有(folio_get
  • 1 = 当前 PTE 映射

超出这个值说明有其他进程(或内核)也引用了该页,不能复用,必须 COW 复制。

_mapcount_refcount 的关系值得细说:_mapcount == 0 表示只有一条 PTE 映射(_mapcount 初始值为 -1,每新增一个 PTE 映射加 1,所以 0 = 1 个映射)。page_mapcount(page) 返回 _mapcount + 1。COW 发生后,旧页的 _mapcount 减 1(page_remove_rmap),若减到 -1 说明无任何 PTE 映射,页可以被回收或放入 LRU 等待复用。

这两个计数器的协同工作保证了多进程共享页面时的引用安全:只要 _refcount > 0 物理页就不会被释放,只要 _mapcount >= 0 就表明有用户态进程的 PTE 指向它。


四、sendfile 与零拷贝

4.1 传统文件发送的拷贝开销

不使用 sendfile 时,read() + write() 的路径:

  1. 磁盘 → DMA → 内核页缓存(1次 DMA)
  2. 内核页缓存 → 用户态缓冲区(1次 CPU 拷贝)
  3. 用户态缓冲区 → 内核 socket 发送缓冲区(1次 CPU 拷贝)
  4. 内核 socket 缓冲区 → 网卡 DMA(1次 DMA)

2次 CPU 拷贝 + 2次 DMA + 4次上下文切换

4.2 do_sendfile:零拷贝系统调用

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
// fs/read_write.c:1180
static ssize_t do_sendfile(int out_fd, int in_fd, loff_t *ppos,
size_t count, loff_t max)
{
struct fd in, out;
struct pipe_inode_info *opipe;
loff_t pos, out_pos;
ssize_t retval;

in = fdget(in_fd);
...
out = fdget(out_fd);
...

opipe = get_pipe_info(out.file, true);
if (!opipe) {
/* out_fd 不是 pipe:使用 splice_direct */
retval = do_splice_direct(in.file, &pos, out.file, &out_pos,
count, fl);
} else {
/* out_fd 是 pipe:使用 splice_to_pipe */
retval = splice_file_to_pipe(in.file, opipe, &pos, count, fl);
}
...
}

do_splice_direct 最终调用 splice_direct_to_actor

4.3 splice_direct_to_actor:内部管道传输

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
// fs/splice.c:918
ssize_t splice_direct_to_actor(struct file *in, struct splice_desc *sd,
splice_direct_actor *actor)
{
struct pipe_inode_info *pipe;

/* 复用进程私有的 splice_pipe,避免每次分配 */
pipe = current->splice_pipe;
if (unlikely(!pipe)) {
pipe = alloc_pipe_info();
...
current->splice_pipe = pipe;
}

/* 循环:from in → pipe → to out */
do {
size_t read_len;
loff_t pos = sd->pos, prev_pos = pos;

/* 步骤1:将 in 文件的页引用移入内部 pipe(page 级别,不复制内容) */
ret = do_splice_to(in, &pos, pipe, len, flags);
...
/* 步骤2:actor 将 pipe 中的页引用传给 out(如 tcp_sendpage) */
ret = actor(pipe, sd);
...
} while (len);
...
}

关键在于:do_splice_to 操作的是页引用(folio/page 指针),而非内存拷贝;tcp_sendpage(网络发送路径)同样通过 skb_fill_page_desc 将页直接插入 skb,DMA 引擎直接从页缓存发送数据。

零拷贝路径:磁盘 DMA → 页缓存 → 网卡 DMA(0次 CPU 拷贝,2次上下文切换)。

“零拷贝”的准确含义:消除了用户态 ↔ 内核态之间的 CPU 内存拷贝,数据始终驻留在内核页缓存,通过页引用传递,网卡通过 DMA 直接读取。


五、mremap 与 brk:地址空间的动态调整

5.1 sys_brk:堆的扩展与收缩

堆内存(malloc 的底层)通过 brk(2) 系统调用管理:

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
// mm/mmap.c:189
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
struct mm_struct *mm = current->mm;
unsigned long newbrk, oldbrk;

newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);

if (oldbrk == newbrk) {
mm->brk = brk;
goto success;
}

/* 收缩堆:释放 [newbrk, oldbrk) 的映射 */
if (brk <= mm->brk) {
mm->brk = brk;
ret = do_vma_munmap(&vmi, brkvma, newbrk, oldbrk, &uf, true);
...
goto success;
}

/* 扩展堆:检查 rlimit 限制,调用 do_brk_flags 分配匿名 VMA */
if (check_brk_limits(oldbrk, newbrk - oldbrk))
goto out;
...
}

brk 不会立即分配物理内存,只是扩展 VMA 的虚拟范围;实际的物理页分配推迟到首次访问时的缺页处理(匿名页 COW 路径)。

5.2 mremapMREMAP_MAYMOVE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// mm/mremap.c:896
SYSCALL_DEFINE5(mremap, unsigned long, addr, unsigned long, old_len,
unsigned long, new_len, unsigned long, flags,
unsigned long, new_addr)
{
...
if (flags & ~(MREMAP_FIXED | MREMAP_MAYMOVE | MREMAP_DONTUNMAP))
return ret;

/* MREMAP_FIXED 必须配合 MREMAP_MAYMOVE */
if (flags & MREMAP_FIXED && !(flags & MREMAP_MAYMOVE))
return ret;
...
}

MREMAP_MAYMOVE 允许内核在新地址空间找不到连续虚拟空间时移动 VMA,对应 move_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
// mm/mremap.c:571
static unsigned long move_vma(struct vm_area_struct *vma,
unsigned long old_addr, unsigned long old_len,
unsigned long new_len, unsigned long new_addr,
bool *locked, unsigned long flags, ...)
{
struct mm_struct *mm = vma->vm_mm;
struct vm_area_struct *new_vma;
unsigned long new_pgoff;

/* 检查 map_count 限制(移动可能导致 VMA 分裂 +3) */
if (mm->map_count >= sysctl_max_map_count - 3)
return -ENOMEM;

/* KSM:移动前拆解 KSM 页,避免新位置出现重复页面 */
err = ksm_madvise(vma, old_addr, old_addr + old_len,
MADV_UNMERGEABLE, &vm_flags);
...
/* 在新地址建立 VMA,批量迁移页表项(move_page_tables) */
new_vma = copy_vma(&vma, new_addr, new_len, new_pgoff, &need_rmap_locks);
moved_len = move_page_tables(vma, old_addr, new_vma, new_addr,
old_len, need_rmap_locks, false);
...
/* 取消旧 VMA 中的映射 */
do_vma_munmap(&vmi, vma, old_addr, old_addr + old_len, ...);
...
}

move_page_tables 是批量 PTE 搬移的核心,它逐级遍历页表,尽量以整块 PTE 页(而非逐条)的方式迁移,避免逐条拷贝的高开销。


六、内存保护与 mprotect

6.1 do_mprotect_pkey:修改 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
38
39
// mm/mprotect.c:731
static int do_mprotect_pkey(unsigned long start, size_t len,
unsigned long prot, int pkey)
{
struct mmu_gather tlb;
struct vma_iterator vmi;

len = PAGE_ALIGN(len);
...
if (mmap_write_lock_killable(current->mm))
return -EINTR;

/* 初始化 mmu_gather,用于批量 TLB flush */
tlb_gather_mmu(&tlb, current->mm);
nstart = start;

for_each_vma_range(vmi, vma, end) {
unsigned long newflags;

/* 计算新的 vm_flags */
newflags = calc_vm_prot_bits(prot, new_vma_pkey);
newflags |= (vma->vm_flags & ~mask_off_old_flags);

/* 安全检查:不能赋予超出 MAY* 的权限 */
if ((newflags & ~(newflags >> 4)) & VM_ACCESS_FLAGS) {
error = -EACCES;
break;
}

/* mprotect_fixup:实际修改 VMA 的 vm_flags,
* 并对已映射的 PTE 修改保护位 */
error = mprotect_fixup(&vmi, &tlb, vma, &prev,
nstart, tmp, newflags);
...
}
/* 统一执行 TLB flush(批量,比逐页 flush 高效) */
tlb_finish_mmu(&tlb);
...
}

6.2 权限降低与 TLB flush

mprotect_fixup 调用 change_protection 遍历 PTE,将可写页改为只读(或降低其他权限)。权限降低(write → read)必须 TLB flush,否则 CPU 可能使用旧缓存的可写 TLB 条目绕过保护。

Linux 使用 mmu_gather(TLB lazy flush 机制)批量收集需要 flush 的地址范围,最后一次性 flush,避免单次 mprotect 对数千页逐一操作 TLB 的性能损耗。

6.3 SEGV 信号的产生路径

当进程尝试写入只读保护页(如写 .text 段,或写 mprotect 后的只读区域),缺页处理流程:

  1. 硬件触发写保护 #PF
  2. do_user_addr_faulthandle_mm_faultdo_wp_page
  3. 若 VMA 没有写权限(!(vma->vm_flags & VM_WRITE)),则 bad_areaforce_sig_fault(SIGSEGV, ...)
  4. 进程收到 SIGSEGV,默认动作:终止(或触发 SIGSEGV handler)

6.4 mprotect 的典型使用场景

JIT 编译器:V8、JVM 等 JIT 引擎的典型做法是:先 mmap(MAP_ANONYMOUS|PROT_READ|PROT_WRITE) 分配内存,写入机器码,再 mprotect(PROT_READ|PROT_EXEC) 切换为可执行。这是为了遵循 W^X(Write XOR Execute)安全策略,防止代码注入攻击。

1
2
3
4
5
6
// JIT 引擎分配可执行内存的典型模式
void *code_buf = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(code_buf, machine_code, code_size); // 写入 JIT 生成的机器码
mprotect(code_buf, size, PROT_READ | PROT_EXEC); // 切换为只读可执行
// 之后 code_buf 可以被调用,但不能再写入

Guard pages(栈溢出检测):在栈底部设置不可访问的保护页(PROT_NONE),当栈溢出访问该页时触发 SIGSEGV,这比不设置保护页时悄悄覆盖数据要安全得多。glibc 的 pthread_create 默认为每个线程栈末尾设置 guard page。

内存安全检测:AddressSanitizer 使用 shadow memory + mprotect 来检测内存越界访问,通过将 redzone 区域设为 PROT_NONE,任何对其的访问都会立即触发 SIGSEGV 并被 ASan 的信号处理器捕获,输出详细的错误报告。


七、诊断方法

7.1 /proc/PID/maps/proc/PID/smaps_rollup

1
2
3
4
5
6
7
8
9
10
11
12
# 查看进程地址空间布局
cat /proc/$(pidof nginx)/maps

# 输出示例:
# 地址范围 权限 偏移 设备 inode 路径
# 55a3b2c00000-55a3b2e00000 r--p 00000000 08:01 131076 /usr/sbin/nginx
# 55a3b2e00000-55a3b3200000 r-xp 00200000 08:01 131076 /usr/sbin/nginx
# 7f3a40000000-7f3a42000000 rw-p 00000000 00:00 0 [heap]
# 7ffd12345000-7ffd12366000 rw-p 00000000 00:00 0 [stack]

# 汇总内存统计(RSS、PSS、USS 等)
cat /proc/$(pidof nginx)/smaps_rollup

关键字段含义:

字段 含义
RSS 常驻物理内存(包含共享页)
PSS Proportional Set Size,共享页按引用者均摊
USS Unique Set Size,进程独占的物理内存
Shared_Clean/Dirty 与其他进程共享的 clean/dirty 页
Private_Clean/Dirty 进程私有的 clean/dirty 页(COW 后产生)

7.2 strace 追踪内存操作

1
2
3
4
5
6
7
8
# 追踪目标进程的内存相关系统调用
strace -e trace=mmap,munmap,mprotect,brk -p <PID>

# 典型输出示例:
# mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3a4000
# mprotect(0x55a3b2e00000, 2097152, PROT_READ|PROT_EXEC) = 0
# brk(0x55a3b4200000) = 0x55a3b4200000
# munmap(0x7f3a4000, 4096) = 0

7.3 pmap -X:详细内存映射

1
2
3
pmap -X $(pidof python3)
# 输出包含:RSS、PSS、Referenced、Anonymous、LazyFree、ShmemPmdMapped 等详细字段
# 可以清晰看到每个 VMA 的物理内存占用情况

7.4 bpftrace 追踪 mmap 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 统计所有 mmap 调用的映射大小分布
sudo bpftrace -e '
kprobe:do_mmap {
@sizes = hist(arg2); /* arg2 = len */
}
interval:s:10 {
print(@sizes);
clear(@sizes);
}'

# 追踪 COW 发生频率(wp_page_copy = 真正的 COW 拷贝)
sudo bpftrace -e '
kprobe:wp_page_copy {
@cow_count[comm] = count();
}
interval:s:5 {
print(@cow_count);
}'

# 追踪缺页类型(major vs minor)
sudo bpftrace -e '
tracepoint:exceptions:page_fault_user {
@[args->error_code & 0x2 ? "write" : "read"] = count();
}'

7.5 内存泄漏排查

Valgrind(工具链)

1
2
valgrind --leak-check=full --track-origins=yes ./your_program
# 检测堆内存泄漏,输出详细的分配调用栈

AddressSanitizer(编译时插桩)

1
2
3
4
gcc -fsanitize=address -g -O1 your_program.c -o your_program
./your_program
# ASAN 可检测:堆溢出、use-after-free、double-free、stack 溢出等
# 运行时开销约 2x,适合测试环境

bpftrace 追踪 mmap 泄漏

1
2
3
4
5
6
7
8
9
10
11
# 追踪未被 munmap 的 mmap(简化示例)
sudo bpftrace -e '
kprobe:do_mmap / retval > 0 / {
@[pid, comm, retval] = nsecs;
}
kprobe:__do_munmap {
delete(@[pid, comm, arg1]); /* arg1 = addr */
}
END {
print(@); /* 剩余的即为潜在泄漏 */
}'

八、关键数据结构总结

8.1 VMA 与 mm_struct 的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
task_struct
└── mm_struct
├── mm_mt (maple tree) ← 存储所有 VMA(6.1+ 替代红黑树)
├── brk ← 堆顶位置
├── start_brk ← 堆起始位置
├── mmap_base ← mmap 区域基址
└── write_protect_seq ← fork COW 序列计数

vm_area_struct (VMA)
├── vm_start, vm_end ← 虚拟地址范围
├── vm_flags ← VM_READ/WRITE/EXEC/SHARED/...
├── vm_file ← 文件映射时指向 struct file
├── vm_pgoff ← 文件偏移(页单位)
├── vm_ops ← fault/map_pages/mprotect 等回调
└── anon_vma ← 匿名页反向映射

8.2 三类内存映射的对比

类型 标志 物理页来源 写入行为 典型用途
文件私有映射 MAP_PRIVATE|MAP_FILE 页缓存 COW,产生匿名页 加载 ELF、动态库
文件共享映射 MAP_SHARED|MAP_FILE 页缓存 直接修改页缓存 数据库 mmap I/O
匿名私有映射 MAP_PRIVATE|MAP_ANONYMOUS 零页/新页 COW(fork 后) 堆、栈、JIT 代码
匿名共享映射 MAP_SHARED|MAP_ANONYMOUS tmpfs shmem 页 直接共享修改 父子进程 IPC

8.3 COW 完整状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fork() 后
父/子 VMA: VM_WRITE 清除(只读)
父/子 PTE: _PAGE_RW 清除(写保护)

进程写入 → #PF (写保护)

do_wp_page
├── VM_SHARED? → wp_page_shared(直接可写,不 COW)
├── PageAnonExclusive? → wp_page_reuse(恢复可写,不复制)
├── folio_ref_count == 1? → wp_page_reuse
└── else → wp_page_copy
├── vma_alloc_folio(分配新物理页)
├── __wp_page_copy_user(复制内容)
├── set_pte_at(更新 PTE)
└── page_remove_rmap(解除旧页映射)

8.4 mmap 性能最佳实践

选择合适的映射类型

  • 需要多次顺序读的大文件:优先考虑 mmap + madvise(MADV_SEQUENTIAL),利用预读减少 read() 系统调用开销。
  • 随机访问的数据库文件:mmap + madvise(MADV_RANDOM) 禁用预读,减少不必要的 I/O。
  • 频繁写入的场景:MAP_SHARED 配合 msync 控制刷盘时机,比 write() 减少一次内核态拷贝。

大页映射:对于大段连续内存(如机器学习推理的模型权重),可以使用 MAP_HUGETLB 请求 2MB 大页,减少 TLB 压力,显著提升大内存访问性能。Linux 也支持 Transparent HugePage(THP),通过 madvise(MADV_HUGEPAGE) 向内核暗示某段内存适合 THP 优化。

预热(mlock):对于延迟敏感的应用(如实时交易系统),可以在启动时 mlock 关键内存区域,防止其被换出(swap),避免运行时出现 major fault。


小结

本篇从 do_mmap 出发,沿着 vm_ops->faultfilemap_faultshmem_fault 三条路径深入解析了文件映射与共享内存的工作原理。在 COW 部分,通过 copy_page_range(写保护标记)→ do_wp_page(分支判断)→ wp_page_copy(真正复制)完整还原了 fork() 后的 COW 全链路。sendfile 的零拷贝通过页引用传递(而非内存拷贝)实现了文件到网卡的高效数据路径。mprotect 的 TLB flush 机制保证了权限降低后的内存安全性。

这些机制共同构成了 Linux 进程内存隔离与高效共享的核心基础。下一篇将介绍 NUMA 内存策略、大页(THP/HugePage)与内存压缩(zswap/zram)


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

  • mm/mmap.cdo_mmapmmap_regionSYSCALL_DEFINE1(brk)
  • mm/filemap.cgeneric_file_mmapfilemap_faultfilemap_map_pages
  • mm/shmem.c__shmem_file_setupshmem_zero_setupshmem_fault
  • mm/memory.ccopy_page_rangedo_wp_pagewp_page_reusewp_page_copy
  • kernel/fork.ccopy_mmdup_mmap
  • mm/mremap.cSYSCALL_DEFINE5(mremap)move_vma
  • mm/mprotect.cdo_mprotect_pkey
  • fs/read_write.cdo_sendfile
  • fs/splice.csplice_direct_to_actor