
1. 为什么要啃Linux 2.6的sys_mmap1.1 一次段错误引发的源码之旅做Linux开发这十年我见过太多对mmap的浅理解知道它能映射文件、能分配共享内存、用起来比read/write快但真出事的时候——比如段错误、SIGBUS、文件截断后映射区崩溃——很少有人能在内核层面解释清楚。我就是在这种半懂不懂的状态里被一次线上故障触发把Linux 2.6内核里sys_mmap系统调用所在的整个链路从头到尾读了一遍。那次故障的根因其实很简单进程通过mmap映射了一个日志文件另一个进程在滚动日志时用O_TRUNC把文件截断了映射区域里越界部分的页在缺页时找不到对应的文件页内核直接给进程发了SIGBUS。可当时没人说得清为什么地址合法还崩了因为大家默认mmap返回的每一寸地址都是可用的。这个认知误差让我意识到用户态调用mmap的熟练程度和内核态do_mmap_pgoff的执行细节之间隔着一整套地址空间管理的知识。这篇文章就是那份源码阅读笔记的整理版。目标很直接把sys_mmap从用户态发起、经过系统调用分发、再到VMA创建、最后在缺页异常阶段真正落地执行的全过程讲透同时把我在2.6源码里实际踩过的坑一并写出来。适合正在做内核学习、嵌入式系统移植或者需要排查内存映射相关问题的读者参考。1.2 sys_mmap能做什么不能做什么先把边界划清楚。sys_mmap的核心能力是三个第一把文件内容映射到进程虚拟地址空间之后读写内存就是读写文件页缓存第二创建匿名映射作为malloc大块内存、共享内存、栈扩展之类的底层机制第三以MAP_SHARED或MAP_PRIVATE决定多进程之间、进程与文件之间的可见性语义。但它不能做的事同样关键。在2.6内核里一次sys_mmap调用返回时内核只是完成了一次虚拟地址空间记账创建一个或多个vm_area_struct节点把它挂进进程地址空间的红黑树和链表里至多预把部分页表项建好MAP_POPULATE才做。它不会为你的映射预先分配物理页也不会把文件内容预先读入内存。真正的物理页分配和文件读取发生在你第一次访问这些地址、触发缺页异常的时候。这个先记账后付钱的设计是理解后面所有内容的前提也正是2.6内核存储管理里demand paging的核心思想。2. 入口处的两个分身sys_mmap与sys_mmap22.1 6个参数怎么传i386上的历史包袱在x86_64上sys_mmap就是标准6参数系统调用addr、len、prot、flags、fd、offset直接通过寄存器传给内核干净利落。但i386上有历史包袱。早期x86系统调用的参数通过寄存器传递个数有限而mmap恰好有6个参数塞不下。于是老内核选择把6个参数打包成一个结构体用户态把这个结构体的指针放在寄存器里传给内核内核用copy_from_user把它取出来。这就是sys_mmapi386上是__NR_mmap编号90的ioctl式做法struct mmap_arg_struct { unsigned long addr; unsigned long len; unsigned long prot; unsigned long flags; unsigned long fd; unsigned long offset; }; asmlinkage long sys_old_mmap(struct mmap_arg_struct __user *arg) { struct mmap_arg_struct a; if (copy_from_user(a, arg, sizeof(a))) return -EFAULT; /* 老版本offset按字节传但它其实必须按页对齐 */ if (a.offset ~PAGE_MASK) return -EINVAL; return sys_mmap_pgoff(a.addr, a.len, a.prot, a.flags, a.fd, a.offset PAGE_SHIFT); }结构体版本的问题在于offset按字节计算用32位unsigned long表达文件偏移时撑死只能映射2^32字节即4GB以内的文件位置对大文件很不友好。后来内核引入了sys_mmap2__NR_mmap2编号192直接把6个参数拆开通过寄存器传递并且把offset参数改成语义为页号pgoff由调用者预先做offset PAGE_SHIFT。这样一来32位pgoff最多能表达2^32 × 4096字节也就是16TB的文件偏移彻底解决了大文件映射的问题。这也是为什么在32位ARM、MIPS这些平台上glibc内部的mmap实现往往调用的是mmap2而不是mmap。做嵌入式移植时如果发现自己的mmap调用返回EINVAL先看看是不是offset传成了字节数而非页数这是非常典型的坑。2.2 sys_mmap_pgoff两条路径的共同归宿不管是sys_old_mmap还是sys_mmap2真正干活的都是mm/mmap.c里的sys_mmap_pgoff。它的任务可以概括成三步拿到文件对象、拿地址空间写锁、调用do_mmap_pgoff。asmlinkage long sys_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 -EBADF; if (!(flags MAP_ANONYMOUS)) { file fget(fd); if (!file) goto out; } down_write(current-mm-mmap_sem); retval do_mmap_pgoff(file, addr, len, prot, flags, pgoff); up_write(current-mm-mmap_sem); out: if (file) fput(file); return retval; }这段代码里有两件事值得停下来想。第一fget会递增文件对象的引用计数所以函数返回前必须有对应的fput否则文件永远释放不掉这直接影响umount提示设备忙。第二mmap_sem是进程地址空间上最重要的一把锁所有修改地址空间布局的操作——mmap、munmap、mremap、brk——都要拿它的写锁而缺页异常路径只拿读锁从而允许并发缺页、串行改布局。理解了这把锁也就理解了2.6内核同步方法里读写锁区分读者写者思想的典型应用。MAP_ANONYMOUS的情况不需要fd所以fd填-1是约定俗成。但注意即使传了一个有效fd进来只要flags带MAP_ANONYMOUS内核也会忽略这个fd不会做任何文件关联。3. 选址逻辑get_unmapped_area如何找一块空地3.1 mmap区域在进程地址空间里的位置do_mmap_pgoff拿到各项参数之后首先要确定映射的虚拟地址落在哪里。如果用户传了MAP_FIXED那地址就是死命令用也得用、不用也得用否则内核必须在当前进程的地址空间里找一块够大够空的连续区间。2.6内核里一个进程的地址空间大致分成几段代码段、数据段、堆往上长、mmap区、栈区。栈从TASK_SIZE往下长mmap区则从mmap_base开始往下长中间留出安全间隙。mmap_base的取值与进程栈的大小、体系结构、是否开启ASLR都有关系。在32位x86上栈顶约在3GB附近mmap_base大致在栈底再往下留一段距离的位置开启ASLR时每次进程启动mmap_base还会加一个随机偏移。3.2 红黑树上的空隙查找arch_get_unmapped_area做的事看起来简单实际非常讲究效率。它的输入是用户给的addr提示输出是一段满足条件且不与其他VMA重叠的区间unsigned long arch_get_unmapped_area(struct file *filp, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags) { struct mm_struct *mm current-mm; struct vm_area_struct *vma; unsigned long start_addr; if (len TASK_SIZE) return -ENOMEM; if (flags MAP_FIXED) return addr; /* 用户给了addr先看看这个位置合不合适 */ if (addr) { vma find_vma(mm, addr); if (TASK_SIZE - len addr (!vma || addr len vma-vm_start)) return addr; } /* 从mmap_base开始逐段检查空隙 */ for (addr mm-mmap_base; len TASK_SIZE - addr; addr vma-vm_end) { vma find_vma(mm, addr); if (!vma || addr len vma-vm_start) return addr; } return -ENOMEM; }这里find_vma是关键它的作用是在红黑树里查找第一个vm_end大于或等于addr的VMA。因为2.6进程的VMA同时用双向链表和红黑树维护find_vma走的是红黑树路径查找时间复杂度是O(log n)。整个地址空间里VMA数量动辄几百上千个如果每次选址都线性扫描链表性能会非常难看。我补充一个细节文件系统可以覆写选址逻辑。比如hugetlbfs会提供自己的get_unmapped_area用于保证大页映射在地址对齐上的特殊要求。所以get_unmapped_area在内核里的完整形式是先问文件系统文件系统不接管才回到通用的arch版本。3.3 MAP_FIXED是强拆不是协商MAP_FIXED这个flag必须单独拿出来讲因为它和普通选址逻辑完全不同只要指定了MAP_FIXED内核不再做任何空隙检查直接把addr当成最终地址。这意味着addr与addrlen范围内原本存在的映射会被悄悄取消掉。这行为的危险程度取决于地址怎么来的。如果地址是上一个mmap的返回值、是固定地址段的一部分那没问题但如果是随手填的一个数或者依赖某个库的映射布局MAP_FIXED极可能把别人的VMA给拆了轻则进程崩溃重则埋下诡异的内存破坏。所以内核社区后面才在4.17引入了MAP_FIXED_NOREPLACE作为替代但2.6时代没有这个选项开发者只能自己小心。常见的设计模式是先不带MAP_FIXED调用一次mmap拿到一个可用地址如果这个地址符合预期再以MAP_FIXED重新映射一次这样至少能避免误踩已有映射。JVM在预留压缩指针空间、一些动态二进制翻译器在保留代码缓存区时都这么干。4. 签合同do_mmap_pgoff与mmap_region完整流程4.1 多重合法性审查do_mmap_pgoff进入正题后先做一堆检查这些检查决定了用户态最常见的几个报错。我按顺序梳理len为0直接返回-EINVAL。空映射没有意义。addr在MAP_FIXED下未按页对齐返回-EINVAL。flags同时设置MAP_SHARED和MAP_PRIVATE返回-EINVAL。这俩语义互斥。非匿名映射时fd对应的文件必须存在且file-f_op-mmap必须有实现否则返回-ENODEV。这就是为什么有些伪文件系统、驱动设备不支持mmap。权限检查特别关键如果映射是MAP_SHARED且带PROT_WRITE而文件是用O_RDONLY打开的返回-EACCES。但MAP_PRIVATE带PROT_WRITE是允许的因为私有映射的写走COW不会把改动写回文件。内存超卖检查私有映射会通过security_vm_enough_memory检查当前内存commit是否超过overcommit限制超了返回-ENOMEM共享文件映射因为页可以回收通常不额外计数。RLIMIT_AS检查如果进程已映射的总页数加上新映射超过地址空间上限返回-ENOMEM。这些检查的顺序和分支在不同2.6小版本里有细微差别但整体逻辑一致。遇到mmap返回-ENOMEM时先说清楚可能是地址空间不够也可能是overcommit拒绝而不是物理内存真的见底。4.2 VMA的合并与新建检查过后do_mmap_pgoff开始真正创建映射这部分核心在mmap_region。它首先处理MAP_FIXED的遗留问题如果addr到addrlen之间已有VMA先把它们do_munmap掉清场后再干活。接着是一个非常重要的优化VMA合并。进程加载一个动态库时一个库可能包含多个不同权限的段但相邻段如果权限、文件、页偏移连续内核会尝试把它们合并成同一个VMA减少VMA数量。vma_merge就是干这个的它检查新映射能否和前一个或后一个VMA合并合并条件包括映射的文件对象相同或都是匿名映射vm_ops相同vm_flags和页保护位相同vm_pgoff连续prev-vm_pgoff加上prev的页数要等于新映射的vm_pgoffanon_vma兼容不会造成反向映射歧义。一旦能合并内核直接扩展已有VMA的vm_end省掉一次slab分配和红黑树插入。只有无法合并时才会从vm_area_cachep slab缓存里分配新的vm_area_struct。理解这个合并且逻辑对排查为什么/proc/pid/maps里VMA数量比预期少/多很有帮助。新建VMA之后mmap_region会做这些初始化vma-vm_start addr; vma-vm_end addr len; vma-vm_flags vm_flags; vma-vm_page_prot protection_map[vm_flags (VM_READ | VM_WRITE | VM_EXEC | VM_SHARED)]; vma-vm_pgoff pgoff; vma-vm_file file ? get_file(file) : NULL; vma-vm_ops NULL;vm_page_prot就是以后建立页表项时要用的页权限位。注意它来自一张保护位映射表protection_map由运行时的页表属性比如NX位、硬件写权限二次加工而来不是简单把PROT_READ翻译成PTE_READ就完事。4.3 文件系统的最后一棒file-f_op-mmapVMA基本字段填完后mmap_region会调用file-f_op-mmap(file, vma)把如何响应缺页的决定权交给文件系统。普通文件系统大多使用generic_file_mmapint generic_file_mmap(struct file *file, struct vm_area_struct *vma) { struct address_space *mapping file-f_mapping; if (!mapping-a_ops-readpage) return -ENOEXEC; vma-vm_ops generic_file_vm_ops; return 0; }这里的逻辑很微妙文件系统只要没有readpage回调就不允许mmap返回-ENOEXEC。而一旦通过VMA的缺页回调就被设定为generic_file_vm_ops其中最关键的是fault函数2.6.18之后是filemap_fault更早版本是nopage回调。这一步之后VMA才算真正绑定了具体的缺页处理策略后面访问映射区域触发的每一个缺页都会走进filemap_fault这条路径。设备驱动如果希望自己的mmap有些特殊行为——比如映射设备内存、DMA缓冲区就自定义file_operations里的mmap函数给vma填上自己的vm_ops。这也是驱动开发者最常见的切入点。字符设备驱动如果没有实现mmap用户在设备文件上调用mmap就会得到-ENODEV。5. VMA对象模型vm_area_struct与vm_operations_struct的协同5.1 vm_area_struct关键字段速览整个mmap体系的核心数据结构是vm_area_struct每个字段背后都有一摊子逻辑。我把最常用的字段整理成一张表字段作用与备注vm_start / vm_end映射区间的起止虚拟地址左闭右开vm_mm反向指向所属mm_struct用于定位地址空间vm_flagsVM_READ/WRITE/EXEC/SHARED/GROWSDOWN等行为位vm_page_prot建立PTE时使用的页保护属性来源于protection_mapvm_file关联的struct file匿名映射为NULLvm_pgoff文件偏移以页为单位注意不是字节vm_ops缺页、open、close、page_mkwrite等回调集合vm_next / vm_rb链表节点与红黑树节点一鱼两吃anon_vma匿名页反向映射链表头COW和页面回收都要用vm_private_data设备驱动私有数据指针也可以让fs塞自己的元数据对初学者最困惑的往往是vm_pgoff。为什么是页号不是字节数两个原因第一页号比字节数在32位下表达范围更大第二内核内部所有文件与页缓存的索引、以及radix tree的key都是页索引直接以页号为基准省去反复移位。VMA同时挂在mm-mmap链表和mm-mm_rb红黑树上链表用于/proc/pid/maps这种顺序遍历红黑树用于find_vma这种按地址查找。文件映射还会把VMA挂进对应address_space的i_mmap结构里形成文件页到VMA的反向映射这在页面回收时要用来反向遍历哪些VMA映射了某一个物理页。5.2 vm_operations_struct内核态的回调接口vm_operations_struct是VMA与具体后端之间的接口协议。文件映射、匿名映射、设备映射、大页映射各自提供不同的实现但调用方缺页异常路径、munmap路径只认这组回调回调触发时机典型实现openVMA首次被链入某进程时文件映射计数、驱动资源初始化closeVMA被拆除时释放驱动资源、递减计数fault缺页且PTE不存在时filemap_fault、anon_vma的匿名页分配page_mkwrite只读页被改写、需要通知文件系统时文件系统标记块分配、快照、写时复制准备accessgdb/proc访问进程内存时远程内存访问策略populateMAP_POPULATE预填充时提前分配页并建立PTE以fault为例它的角色是缺页时给我一个可以填进PTE的struct page。文件映射从页缓存里找找不到就调用address_space_operations的readpage读进内存匿名映射直接分配一个新页设备映射则可能返回设备内存对应的页描述符。这个回调接口的存在保证了缺页处理主流程不需要关心VMA背后的文件系统、设备或特殊内存类型这种面向接口的设计和file_operations体系是一脉相承的。5.3 从file_operations到vm_ops透明加密为什么难做我特意把这一小节拿出来是因为很多做文件系统过滤、透明加密、安全审计的同学都在这上面栽过跟头。传统思维里拦截文件读写就是替换file_operations的read和write这没有错但只覆盖了read/write系统调用路径。mmap是另一条完全独立的通道。进程映射一个文件后后续的读文件不再走file_operations.read而是走缺页异常里的vma-vm_ops-fault最终落到address_space_operations的readpage/writepage。如果只拦截了file_operations的read/write却不管address_space_operations和vm_ops那么用户通过mmap方式访问到的文件内容还是原始明文和通过read读到的密文完全对不上整个透明加密方案就废了。所以一套完整的文件透明加密实现至少要在两个层面同时挂钩文件系统层处理read/write/splice等常规路由页缓存层处理readpage/writepage/write_begin/write_end2.6.24之后演进成write_begin/write_end或更大的aops必要时还要在vma-vm_ops的fault/page_mkwrite里做映射页的加解密。这也解释了为什么ecryptfs这类堆叠式加密文件系统总是围绕address_space做文章而不是只在file_operations上打补丁。6. 缺页异常mmap真正干活的现场6.1 先记账后付钱demand paging的设计回到本文开头mmap返回时地址空间里只有一张合同——VMA没有物理页也没有对应文件的页缓存。你访问映射区域的那一刻CPU在翻译虚拟地址时查不到PTE触发缺页异常内核才在异常处理路径里翻出那张合同按合同条款把页准备好。为什么这样设计核心是效率。一个1GB的映射可能只用到前几MB如果mmap时就把1GB的页全读进来内存和磁盘IO都被浪费了。延迟到缺页时按需加载既省内存又省IO。这也是malloc调用的大块内存能那么便宜的原因——在真正写第一下之前内核不过是给你画了张空头支票。用户态能明显感知demand paging的地方是首次访问的耗时第一次读映射页往往比后续读慢一个数量级因为要经历磁盘IO和页缓存填充。这就是为什么很多高性能程序会用MAP_POPULATE或madvise来主动预热、批量预读。6.2 handle_mm_fault到filemap_fault的执行路径缺页异常入口是架构相关的x86上是do_page_fault它会获取当前进程的mm_struct、查找address所在的VMA、检查访问权限越权访问直接判SIGSEGV然后进入通用的handle_mm_fault。handle_mm_fault逐级遍历页表目录PGD、PUD、PMD、PTE缺哪一级就分配哪一级直到拿到PTE之后调用handle_pte_fault做最终裁决static int handle_pte_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, int write_access, pte_t *pte, pmd_t *pmd) { pte_t entry *pte; if (!pte_present(entry)) { if (pte_none(entry)) { if (!vma-vm_ops || !vma-vm_ops-nopage) return do_anonymous_page(mm, vma, address, write_access, pte, pmd); return do_no_page(mm, vma, address, write_access, pte, pmd); } /* 处理swap进来的页 */ return do_swap_page(mm, vma, address, pte, pmd, entry); } if (write_access) { if (!pte_write(entry)) return do_wp_page(mm, vma, address, pte, pmd, entry); ... } ... }这里的判断很经典PTE项是全零的pte_none说明这个地址从未建立过映射如果有vma-vm_ops且实现了nopage/fault回调走do_no_page/do_fault去问文件系统要页否则走do_anonymous_page分配匿名零页。PTE存在但只读、又遇到写访问时进入do_wp_page做写时复制。do_no_page在2.6.18之后改名成了do_fault内部通过vmf结构体把缺页信息封装好再调用vma-vm_ops-fault。以filemap_fault为例它做的事情是按vm_pgoff算出页索引在页缓存radix tree里查找是不是已经有这个page没有就调用mapping-a_ops-readpage从磁盘读入拿到页之后设置PTE。整个过程最耗时的IO发生在readpage里这也是为什么mmap和read在性能对比上各有千秋的根源。6.3 私有映射与共享映射的分水岭COW同一个文件被两个进程以不同方式映射行为天差地别。MAP_SHARED映射下缺页建立的PTE带写权限进程写这个页等于直接改页缓存里的page文件内容的可见性立即对所有映射同一页的进程开放。MAP_PRIVATE映射下缺页建立的PTE刻意只读——即使你传了PROT_WRITE也一样——因为内核要等第一次写发生时再做决定。第一次写MAP_PRIVATE的只读PTE会触发do_wp_page。内核检查到PTE映射的page引用计数大于1页缓存里还有文件原本的数据就分配一个新page把旧page内容拷进去再把PTE指向新page并置为可写。从此这个页和文件再无关系改动只在进程自己的匿名页里。这就是COW的全过程也是fork之后父子进程共享物理页内存、写时才分离的底层机制。理解COW对性能诊断很有帮助。比如你用mmap读一个只读文件、又开了MAP_PRIVATE|PROT_WRITE即使你从不写它某些场景下内核也可能因为要维护COW语义而不做共享造成物理内存浪费。反过来如果你明确不会修改映射内容建议配上PROT_READ即可给内核更多自由去复用页缓存。6.4 为什么mmap读文件有时候反而更慢这是个老生常谈但很少有人讲透的话题。mmap读文件本质上是一次缺页读入一个页缺页异常本身就有开销CPU陷入内核、查找VMA、遍历页表、建立PTE、刷新TLB。如果程序是顺序读整个大文件read系统调用配合内核预读算法一次可以连续读入多个页再加上copy_to_user的批处理优势效率反而比反复触发缺页的mmap高。只有当访问模式是随机指针跳跃或数据量大但每次只改一小块共享区域时mmap才真正占优——它免去了每次随机访问都做read系统调用的开销地址空间就是缓存。2.6时代没有后来那些复杂的readahead自适应算法mmap在顺序读场景的劣势更明显。做性能取舍时建议实测对比不要想当然认为mmap一定快。7. 收尾工作munmap、msync与脏页回写7.1 munmap的拆解流程映射不可能只建不拆munmap负责把VMA从进程地址空间里完整移除。sys_munmap最终也是走do_munmap它做的事情和mmap_region正好相反先拿着mmap_sem写锁把覆盖[start, startlen)范围的VMA逐个拆解必要时把首尾的VMA切割成更小的块再分别处理。清理过程中有几个必须完成的动作unmap_page_range清掉范围内所有PTE并释放对应的物理页引用flush_tlb_range让TLB中没有残留的虚拟地址映射然后对每个被移除的VMA调用vma-vm_ops-close回调最后fput(vma-vm_file)释放文件引用并把VMA从链表、红黑树、以及address_space的反向映射结构里摘除归还到slab缓存。如果你是驱动开发者close回调里通常要释放自己的资源比如取消IOMMU映射、递减中断申请计数。漏回调的一个典型症状是程序退出后设备依然报资源忙或者/proc/pid/maps里映射消失但文件系统仍然无法卸载。这时候就该怀疑VMA的close里是不是少了一次资源释放。7.2 msync与文件一致性对MAP_SHARED文件映射写内存只是改了页缓存里的页何时写回磁盘由内核pdflush等机制决定。如果业务要求强一致——比如数据库提交日志、配置文件持久化——就必须调用msync它走sys_msync把范围内处于脏状态的页通过address_space_operations的writepage或专门的清理接口刷回磁盘。msync的flags要分清MS_SYNC要求同步完成msync返回时数据已经落盘或至少已经提交给块层MS_ASYNC只做异步调度函数立即返回MS_INVALIDATE则和上层协议的缓存失效语义相关用的少但读文档时别搞混。对MAP_PRIVATE映射msync没有文件一致性的意义——写入的数据本来就在COW出的匿名页里不可能回写文件。内核实现上会对私有映射的msync直接返回成功但不做任何文件回写。不少开发者误以为私有映射的数据迟早写回文件这个误解导致数据丢失的事故我在项目里不止见过一次。7.3 引用计数决定文件能不能卸载mmap_region里有一行容易被忽略的关键代码vma-vm_file file ? get_file(file) : NULL。get_file把文件的引用计数加一所以一个被mmap的文件即使其文件描述符已经被close只要还有VMA引用它底层的struct file和inode就继续存活。这也是为什么打开文件后mmap再close fd映射仍然有效——所有认真学过POSIX的人都知道这个规则但知道它背后是引用计数在支撑的人不多。这个机制带来的实际坑是文件系统卸载如果某个进程偷偷mmap了挂载点下某个文件umount就会因为设备忙而失败。排查方法简单直接看/proc/pid/maps谁占着这个映射路径谁就是罪魁祸首。实战中我遇到过守护进程把日志文件mmap后忘了解除映射导致运维无法安全卸载磁盘分区最后只能重启进程的案例。8. 实战追踪一次mmap系统调用的完整观测8.1 strace先看用户态发生了什么定位用户态程序问题第一步永远是strace。在2.6内核对应的老系统上strace版本够用就行$ strace -f -e tracemmap,mmap2,munmap,msync ./demo mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 0xb7f6b000 mmap2(NULL, 8192, PROT_READ, MAP_PRIVATE, 3, 0) 0xb7f69000第一行的mmap2(NULL, 4096, ...)是glibc内部匿名映射用来分配一块内存第二行是映射文件描述符3的内容。注意32位平台上这里调用的是mmap2并且文件的pgoff传的是0对应文件头。如果某个版本传了非页对齐的offsetstrace会同样打印出来方便你比对错误返回EINVAL的根源。strace还能抓到mmap的返回地址。如果后续访问这个地址崩了再用这个地址去/proc/pid/maps里反查它属于哪个VMA、什么权限、对应的文件路径连锁定位就不难了。8.2 kprobe与ftrace深入内核函数strace只能看到系统调用的外壳要观察do_mmap_pgoff、get_unmapped_area、filemap_fault这些内核内部函数就得动用内核动态插桩。2.6.28之后的内核自带ftrace可以这样追踪函数调用# 进入tracefs $ echo function /sys/kernel/debug/tracing/current_tracer $ echo do_mmap_pgoff /sys/kernel/debug/tracing/set_ftrace_filter $ echo 1 /sys/kernel/debug/tracing/tracing_on $ ./demo $ cat /sys/kernel/debug/tracing/trace如果是更老的2.6内核ftrace不可用最实在的办法是kprobe。写一个小内核模块挂到do_mmap_pgoff上每次进入时打印参数#include linux/kprobes.h static struct kprobe kp { .symbol_name do_mmap_pgoff, }; static int handler_pre(struct kprobe *p, struct pt_regs *regs) { /* i386下根据调用约定从寄存器取参数 */ printk(KERN_INFO mmap: addr%lx len%lx prot%lx flags%lx\n, regs-bx, regs-cx, regs-dx, regs-si); return 0; } static int __init kprobe_init(void) { kp.pre_handler handler_pre; register_kprobe(kp); return 0; } module_init(kprobe_init);参数从哪个寄存器取取决于体系结构和内核ABI最好先查对应架构的kernel调用约定。kprobe的价值在于不重新编译内核就能动态观察线上函数适合快速验证某个函数有没有被调用、参数是什么这类问题。如果你在QEMU里跑2.6内核做学习验证更直接的办法是gdb连上内核调试符号在do_mmap_pgoff、mmap_region、filemap_fault上下断点单步看VMA是怎么一步步建起来的效果比任何log都好。8.3 返回值与常见错误速查表把日常常见的mmap失败场景整理成表排查时可以快速对照返回值/信号触发场景排查方向EINVALlen为0、addr未对齐、offset未对齐、flags组合非法检查参数与页对齐ENOMEM地址空间不足、overcommit限制、VMA分配失败检查RLIMIT_AS与overcommit配置EAGAIN文件租约冲突、锁导致阻塞查看是否有文件锁、leaseEBADF非匿名映射但fd无效确认fd是否已closeEACCESMAP_SHAREDPROT_WRITE但文件只读打开检查open标志ENODEV文件系统或设备不支持mmap检查f_op-mmap是否存在ENOEXECfs无readpage却要求mmap检查底层文件系统实现SIGSEGV访问越权地址、prot不允许的操作检查映射权限和越界写SIGBUS访问超出文件当前大小的映射区域检查文件是否被截断9. 2.6时代的经典坑与学习方法9.1 MAP_FIXED的强拆风险前面提到MAP_FIXED会无条件清除范围内旧映射这是2.6时代用户态程序一类经典崩溃的源头。我见过一个图形程序加载纹理时用MAP_FIXED把地址0x40000000到0x50000000占住结果那块区域正好覆盖了某个动态库映射进来的Segment程序随后在库函数调用里花式崩溃。解决办法是改成先不带MAP_FIXED探测拿到可用地址后再固定重映射或者干脆不用固定地址。9.2 偏移量对齐与mmap2迁移问题把代码从64位平台往32位平台迁移时mmap的offset参数很容易出问题。64位下glibc调用sys_mmapoffset按字节传支持大文件32位下glibc调用sys_mmap2offset按页传。如果你的代码里直接内联了汇编或用了某些老的libc封装按64位习惯传入字节offset到mmap2轻则EINVAL重则映射到错误位置危害极大。通用的做法是只在应用层使用系统提供的mmap封装不要自己拼系统调用号。9.3 nopage到fault2.6.18的接口分水岭2.6.18是个重要节点内核把vm_operations_struct里的nopage回调替换为fault回调并引入struct vm_fault来传递详细信息。这直接影响内核模块开发者如果你的设备驱动按老版本写了vma-vm_ops-nopage升级到2.6.18之后这个回调不会被调用mmap出来的区域一访问就SIGSEGV。移植时务必检查VMA回调是nopage还是fault新版内核只认后者。这个坑在网络上的老代码里出现频率极高看到老驱动仓库务必多留个心眼。9.4 我个人推荐的阅读路线最后分享一套我实际用过的阅读方法。第一步把内核源码里的mm/mmap.c、mm/filemap.c、mm/memory.c按顺序通读重点看do_mmap_pgoff、mmap_region、handle_mm_fault、filemap_fault这四件事。第二步用QEMU起一个2.6.32的虚拟机把gdb挂到内核上在do_mmap_pgoff和filemap_fault各下断点然后跑一个简单的cat命令观察日志顺序。第三步改一行代码验证理解比如临时在mmap_region里打印vm_end看看合并前后VMA边界怎么变。我在读这部分源码时最大的感受是2.6内核的mmap路径虽然比现代内核少了RCU、少了VMA锁拆分、少了THP那些复杂机制但它是理解整个Linux内存管理层最干净的学习素材。把这个问题啃透之后再去看4.x、6.x内核里那些针对大规模并发的优化就会清楚每个改动都是在解决什么旧问题。这也是为什么我把这篇sys_mmap的笔记保存了这么多年每次翻出来都还能有新的收获。