本系列前几篇文章分别介绍了虚拟内存布局、物理内存分配器、页表体系与缺页异常处理机制。本篇继续深入,聚焦于三个紧密相关的主题: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_mmap(mm/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
| 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;
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
| 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; return 0; }
|
generic_file_vm_ops 定义了该 VMA 的缺页处理函数集,其中最重要的两个成员是:
1 2 3 4 5 6
| 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_fault(mm/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;
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 { 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); ... } ... }
|
流程总结:
- 页缓存命中(minor fault):直接拿到 folio,映射到 PTE,耗时极短(通常 < 1 μs)。
- 页缓存缺失(major fault):触发
do_sync_mmap_readahead,从磁盘读入,走 I/O 路径,耗时较长(取决于存储设备,NVMe 通常 100μs 量级,HDD 可达 10ms)。
- 读入完成后锁定 folio,调用
do_set_pte 将物理页帧号写入 PTE,完成映射。
值得注意的是,filemap_fault 中的 do_async_mmap_readahead 与 do_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
| 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); 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_PRIVATE 与 MAP_SHARED 的核心区别体现在 do_mmap 的 flag 检查中:
1 2 3 4 5 6 7 8 9
| case MAP_SHARED_VALIDATE: ... vm_flags |= VM_SHARED | VM_MAYSHARE; ... case MAP_PRIVATE: pgoff = addr >> PAGE_SHIFT; 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_setup(mm/shmem.c)创建:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| int shmem_zero_setup(struct vm_area_struct *vma) { struct file *file; loff_t size = vma->vm_end - vma->vm_start;
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;
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
| 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);
inode = shmem_get_inode(&nop_mnt_idmap, mnt->mnt_sb, NULL, S_IFREG | S_IRWXUGO, 0, flags); ... inode->i_size = size; clear_nlink(inode);
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_fault(mm/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;
if (unlikely(inode->i_private)) { ... schedule(); ... }
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_open → open("/dev/shm/name") → mmap → shmem_fault。POSIX 共享内存有文件系统可见性,可以通过 ls /dev/shm 查看,进程退出后(若未 shm_unlink)文件依然存在。
**System V shmget/shmat**:内核路径略有不同。shmget 最终调用 shmem_kernel_file_setup 创建内核私有 tmpfs 文件并保存在 struct shmid_kernel 中;shmat 调用 do_shmat → do_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);
|
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
| 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) { mmget(oldmm); mm = oldmm; } else { mm = dup_mm(tsk, current->mm); if (!mm) return -ENOMEM; }
tsk->mm = mm; tsk->active_mm = mm; ... return 0; }
|
CLONE_VM(pthread_create 使用)不复制 mm,线程与父进程共享整个地址空间;真正的 fork() 则调用 dup_mm → dup_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
| 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->total_vm = oldmm->total_vm; mm->data_vm = oldmm->data_vm; mm->exec_vm = oldmm->exec_vm; mm->stack_vm = oldmm->stack_vm;
retval = vma_iter_bulk_alloc(&vmi, oldmm->map_count);
for_each_vma(old_vmi, mpnt) { if (mpnt->vm_flags & VM_DONTCOPY) continue;
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_iter_bulk_store(&vmi, tmp); mm->map_count++;
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
| copy_page_range(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma) { ... bool is_cow;
is_cow = is_cow_mapping(src_vma->vm_flags);
if (is_cow) { 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); }
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_page(mm/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);
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);
if (folio && folio_test_anon(folio)) { if (PageAnonExclusive(vmf->page)) goto reuse;
if (folio_test_ksm(folio) || folio_ref_count(folio) > 3) goto copy;
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
| static inline void wp_page_reuse(struct vm_fault *vmf) __releases(vmf->ptl) { struct vm_area_struct *vma = vmf->vma; pte_t entry;
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
| 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))) { 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); ... }
mem_cgroup_charge(new_folio, mm, GFP_KERNEL); __folio_mark_uptodate(new_folio);
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); } 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); 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() 的路径:
- 磁盘 → DMA → 内核页缓存(1次 DMA)
- 内核页缓存 → 用户态缓冲区(1次 CPU 拷贝)
- 用户态缓冲区 → 内核 socket 发送缓冲区(1次 CPU 拷贝)
- 内核 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
| 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) { retval = do_splice_direct(in.file, &pos, out.file, &out_pos, count, fl); } else { 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
| ssize_t splice_direct_to_actor(struct file *in, struct splice_desc *sd, splice_direct_actor *actor) { struct pipe_inode_info *pipe;
pipe = current->splice_pipe; if (unlikely(!pipe)) { pipe = alloc_pipe_info(); ... current->splice_pipe = pipe; }
do { size_t read_len; loff_t pos = sd->pos, prev_pos = pos;
ret = do_splice_to(in, &pos, pipe, len, flags); ... 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
| 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; }
if (brk <= mm->brk) { mm->brk = brk; ret = do_vma_munmap(&vmi, brkvma, newbrk, oldbrk, &uf, true); ... goto success; }
if (check_brk_limits(oldbrk, newbrk - oldbrk)) goto out; ... }
|
brk 不会立即分配物理内存,只是扩展 VMA 的虚拟范围;实际的物理页分配推迟到首次访问时的缺页处理(匿名页 COW 路径)。
5.2 mremap 与 MREMAP_MAYMOVE
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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;
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
| 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;
if (mm->map_count >= sysctl_max_map_count - 3) return -ENOMEM;
err = ksm_madvise(vma, old_addr, old_addr + old_len, MADV_UNMERGEABLE, &vm_flags); ... 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); ... 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
| 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;
tlb_gather_mmu(&tlb, current->mm); nstart = start;
for_each_vma_range(vmi, vma, end) { unsigned long newflags;
newflags = calc_vm_prot_bits(prot, new_vma_pkey); newflags |= (vma->vm_flags & ~mask_off_old_flags);
if ((newflags & ~(newflags >> 4)) & VM_ACCESS_FLAGS) { error = -EACCES; break; }
error = mprotect_fixup(&vmi, &tlb, vma, &prev, nstart, tmp, newflags); ... } 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 后的只读区域),缺页处理流程:
- 硬件触发写保护
#PF
do_user_addr_fault → handle_mm_fault → do_wp_page
- 若 VMA 没有写权限(
!(vma->vm_flags & VM_WRITE)),则 bad_area → force_sig_fault(SIGSEGV, ...)
- 进程收到
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
| void *code_buf = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); memcpy(code_buf, machine_code, code_size); mprotect(code_buf, size, PROT_READ | PROT_EXEC);
|
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
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>
|
7.3 pmap -X:详细内存映射
1 2 3
| pmap -X $(pidof python3)
|
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
| sudo bpftrace -e ' kprobe:do_mmap { @sizes = hist(arg2); /* arg2 = len */ } interval:s:10 { print(@sizes); clear(@sizes); }'
sudo bpftrace -e ' kprobe:wp_page_copy { @cow_count[comm] = count(); } interval:s:5 { print(@cow_count); }'
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
|
bpftrace 追踪 mmap 泄漏:
1 2 3 4 5 6 7 8 9 10 11
| 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->fault、filemap_fault、shmem_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.c:do_mmap、mmap_region、SYSCALL_DEFINE1(brk)
mm/filemap.c:generic_file_mmap、filemap_fault、filemap_map_pages
mm/shmem.c:__shmem_file_setup、shmem_zero_setup、shmem_fault
mm/memory.c:copy_page_range、do_wp_page、wp_page_reuse、wp_page_copy
kernel/fork.c:copy_mm、dup_mmap
mm/mremap.c:SYSCALL_DEFINE5(mremap)、move_vma
mm/mprotect.c:do_mprotect_pkey
fs/read_write.c:do_sendfile
fs/splice.c:splice_direct_to_actor