本系列前几篇文章分别介绍了虚拟内存布局、物理内存分配器、页表体系与缺页异常处理机制。本篇继续深入,聚焦于三个紧密相关的主题: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)/mapscat /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