我在这行做了快十年的内核相关开发从 2.4 时代一路折腾到现在的 6.x回过头来看很多后来者问得最多的问题反而集中在老版本内核上。就拿sys_mmap来说它是用户态和内核态之间最典型、也最容易被低估的一个系统调用。很多人会用mmap也知道它能做内存映射、文件映射、共享内存但真问到用户态调一下mmap内核里到底发生了什么能说清楚的并不多。这篇文章就以 Linux 2.6 内核为蓝本把sys_mmap从用户态陷入内核、参数解析、地址空间管理、页表建立到缺页异常处理的完整路径掰开揉碎讲一遍。适合正在读内核源码的初学者、做驱动开发的工程师以及那些想在文件系统层做加密、拦截、性能优化的嵌入式开发者。内容不绕弯子直接对着源码逻辑讲。1. 从用户态到内核态一次mmap调用在内核里究竟走了多远1.1 系统调用不是函数跳转那么简单很多初学者容易把系统调用理解成普通函数调用实际上它比函数调用多了一层用户态陷入内核态的过程。在 x86 平台上用户态程序调用mmap时glibc 封装函数会把参数放到寄存器里然后执行int 0x80或者sysenter指令CPU 切换到内核态内核通过系统调用号在sys_call_table里找到对应的处理函数。对于 2.6 内核来说i386 架构下mmap对应的系统调用号是 90最终落到的处理函数是sys_mmap。早期的 2.4 内核里sys_mmap接收的参数是以指针方式传入的内存地址也就是说用户态要把所有参数打包到一个结构体里通过指针传给内核。到了 2.6i386 上改为直接传参数寄存器同时保留了sys_mmap2来兼容旧的调用方式。这里有个关键点sys_mmap并不是最终实现映射逻辑的函数。它更像一个参数处理器真正干活的函数是do_mmap2和do_mmap。把这个链路理清楚是理解整个 mmap 机制的第一步。1.2 sys_mmap参数里暗藏的约定sys_mmap的原型大致是这样的asmlinkage long sys_mmap(unsigned long addr, unsigned long len, unsigned long prot, unsigned long flags, unsigned long fd, unsigned long offset);从用户态角度看这 6 个参数对应如下含义addr映射区起始地址的提示值内核不保证一定用这个地址除非指定MAP_FIXED。len映射长度单位是字节但内核实际操作时是按页处理的。prot期望的内存保护属性如PROT_READ、PROT_WRITE、PROT_EXEC。flags映射类型如MAP_SHARED、MAP_PRIVATE、MAP_ANONYMOUS、MAP_FIXED。fd文件描述符仅文件映射时需要。offset文件偏移表示从文件的哪个位置开始映射。一个大多数人会犯迷糊的地方是len和offset并没有直接按页对齐而是由内核做对齐处理。do_mmap2内部会调用do_mmap传入的offset会先被右移PAGE_SHIFT位转换成页单位的偏移量。这个细节在内核源码里经常被忽略但它解释了为什么用户态mmap的偏移参数可以不是页大小整数倍虽然实际映射时会被截断对齐。1.3 为什么2.6内核要单独拆出sys_mmap264 位平台出现之后文件偏移量off_t从 32 位扩展到了 64 位。i386 平台上一个寄存器只能传 32 位整数sys_mmap的参数里offset是unsigned long在 32 位下只有 4 字节装不下 64 位偏移。为了支持大文件映射内核引入了sys_mmap2它把offset参数拆成高 32 位和低 32 位两个寄存器分别传入再在内部拼成 64 位完整偏移。这个历史包袱在今天看起来有点多余但它很好地点出了内核设计中的一个核心原则系统调用的参数布局是 ABI 的一部分一旦确定就不能轻易改动否则所有用户态程序都要跟着重编。理解这个约束再看 2.6 里各种带_2后缀的系统调用就不会觉得莫名其妙了。2. 走进do_mmapsys_mmap真正干活的入口在这里2.1 入口处那些容易被忽略的检查sys_mmap把参数整理好之后就转给do_mmap2再进入do_mmap。do_mmap要做的事情远不止分配一段虚拟地址空间这么简单它有一连串的前置检查任何一个不满足都会直接返回错误码。关键检查点包括检查len是否为 0len超过TASK_SIZE则直接拒绝。检查offset与len相加是否会溢出防止整型溢出绕过安全检查。检查prot是否与文件打开方式冲突。例如文件以只读方式打开却要求PROT_WRITE映射内核会拒绝。检查flags是否合法MAP_SHARED与MAP_PRIVATE不能同时出现。检查fd对应的文件是否存在以及文件是否有mmap文件操作。这里特别值得说的是保护属性与文件打开方式冲突这一条。很多驱动开发者踩过这个坑用户态程序以O_RDONLY打开设备文件然后试图用PROT_READ | PROT_WRITE做 mmap内核在do_mmap阶段就会拒绝而不是等到真正访问映射内存时才报错。这个错误发生在映射建立阶段表现是mmap返回EACCES。如果你在写驱动最好在驱动自己的mmap回调里也做一遍类似的校验因为不是所有调用路径都会走同样的检查逻辑。2.2 VMA的诞生过程通过前置检查后do_mmap开始真正的核心工作创建一个新的虚拟内存区域也就是vm_area_struct通常简称 VMA。VMA 描述的是一个连续的虚拟地址范围以及这段范围的属性比如起始地址、结束地址、页保护标志、映射文件、文件偏移、私有数据等。do_mmap做的大致流程是调用get_unmapped_area寻找一块合适的空闲虚拟地址区间。如果flags里有MAP_FIXED则直接使用指定地址覆盖该地址上已有的映射。检查新映射是否可以直接合并到已有的相邻 VMA 中合并条件包括属性一致、映射文件一致、偏移连续等。如果不满足合并条件则调用kmem_cache_alloc分配一个新的 VMA 结构体。初始化 VMA 的各个字段设置vm_ops把 VMA 插入进程的地址空间链表和红黑树。最后通过insert_vm_struct完成插入操作并返回起始虚拟地址。这个过程里最影响性能的是第 3 步的合并判断。如果每次mmap都创建一个全新 VMA 而不做合并那么一个频繁做小块映射的程序会在 VMA 链表上积累大量碎片。2.6 内核用红黑树组织 VMA大大加快了查找速度但合并策略依然是优化重点。2.3 sys_mmap返回的不是地址而是一段契约当do_mmap返回起始虚拟地址后sys_mmap直接把这个地址返回给用户态。很多人以为内存已经分配好了其实这是一个非常大的误解。在这个时间点内核只是建立了虚拟地址空间的元数据——即 VMA。它告诉内核从地址 X 到地址 Xlen 这一段是属于某个映射的具有哪些属性对应哪个文件。真正的物理内存页根本还没有分配。这种延迟分配的设计正是虚拟内存系统高效运行的基石。可以这样理解sys_mmap返回的不是一块具体的物理内存而是一份契约。契约上写着这段虚拟地址空间的归属、权限和用途。当程序真正访问这个地址时内核才会通过缺页异常去兑现这份契约——分配物理页、建立页表项、读取文件内容等等。3. 地址空间管理VMA红黑树与get_unmapped_area的分配逻辑3.1 为什么2.6要用红黑树组织VMA在 2.4 内核里VMA 是通过链表组织的每次查找一个地址属于哪个 VMA都要从头遍历时间复杂度是 O(n)。当进程有上千个 VMA 时每次缺页异常都要遍历一遍链表性能损耗非常明显。2.6 内核引入了红黑树来管理 VMA每次查找可以在 O(log n) 时间内完成。进程的mm_struct里同时维护了链表和红黑树链表用于按地址顺序遍历所有 VMA红黑树用于快速查找特定地址对应的 VMA。两者不是替代关系而是互补关系。这里有个实现细节值得注意红黑树节点和链表节点不是独立的结构体而是直接嵌入在vm_area_struct里。vma结构里既有vm_next/vm_prev指针用于链表也有rb_node用于红黑树。这种设计避免了额外的内存分配但也让 VMA 的插入和删除逻辑变得复杂需要同时维护两种结构的一致性。3.2 get_unmapped_area找一块风水宝地do_mmap在创建 VMA 之前需要知道这段虚拟地址应该放在哪里。这个任务由get_unmapped_area完成它有几个不同的实现版本因为不同的体系结构和不同的映射类型寻找空闲区域的策略并不相同。对于常规的匿名映射和文件映射i386 平台使用的是从低地址往高地址查找的策略。内核会从TASK_UNMAPPED_BASE开始沿着 VMA 链表逐个检查空闲区间是否足够放下len长度的映射。找到合适的区间后还会做一些边界对齐和栈增长方向的考虑。如果指定了MAP_FIXEDget_unmapped_area基本不做什么查找直接检查指定地址是否合法、是否超出TASK_SIZE范围即可。但MAP_FIXED有一个危险特性它会在不检查原有映射的情况下直接覆盖指定地址上的已有 VMA。所以内核在do_mmap中专门处理了MAP_FIXED情形下需要先拆除旧 VMA 的逻辑这也是为什么多个线程同时用MAP_FIXED做映射时容易出问题——一个线程的映射可能悄悄覆盖另一个线程的映射。3.3 mmap_min_addr与安全边界的引入2.6 内核后期引入了mmap_min_addr这个安全机制目的是阻止用户态程序映射地址 0 附近的虚拟内存防止空指针解引用被恶意利用来提权。很多做嵌入式开发的朋友在内核裁剪时会把CONFIG_SECURITY选项去掉结果发现有些老程序mmap低地址失败这就是mmap_min_addr在起作用。这个阈值可以通过/proc/sys/vm/mmap_min_addr调整。在一些没有 MMU 的嵌入式平台或者特殊场景下可能确实需要映射低地址但正常情况下建议保留这个机制。内核社区对这个参数的默认值讨论过很多轮最终落地为 65536即 64KB 以下地址不允许映射。这个值既不影响正常程序的地址随机化又能有效防止基于低地址空指针的攻击。4. mmap之后没有立刻发生的真相页表、缺页异常与物理页分配4.1 为什么mmap返回后内存占用没有立刻上涨这是 mmap 机制里最反直觉的一点。很多初学者在用户态写完这样一段代码char *p mmap(NULL, 1024 * 1024 * 1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);然后去看top或者ps发现进程的RES驻留内存几乎没有变化。有人怀疑mmap失败了但实际上mmap返回了一个 1GB 的地址段只是这 1GB 的物理内存一个字节都没分配。原因在于内核使用的是一种按需分页策略。mmap时内核只创建了 VMA记录了这段地址可以被访问、类型是匿名映射、可读写不建立任何页表项。当程序第一次访问这段地址中的某个页时CPU 触发缺页异常内核才真正分配一页物理内存并建立对应的页表项。这种策略的价值在于如果一个程序 mmap 了大块内存但实际只用了一小块就不会浪费物理内存。这也是虚拟内存系统能够支撑地址空间 物理内存假象的基础。4.2 缺页异常处理链从do_page_fault到do_no_page缺页异常的处理程序是体系结构相关的。以 i386 为例缺页异常会进入do_page_fault它做的工作包括读取 CPU 的CR2寄存器获取触发缺页的线性地址。判断发生缺页时 CPU 所处的是用户态还是内核态。调用find_vma在进程的 VMA 红黑树中查找该地址属于哪一段映射。如果找不到对应的 VMA说明这个地址根本不在进程地址空间内发送SIGSEGV。找到 VMA 后检查访问类型是否与 VMA 的保护属性一致比如向只读页面写入会走写保护处理。调用handle_mm_fault进入更细粒度的缺页处理流程。handle_mm_fault通过pte_alloc确认页表存在然后调用handle_pte_fault检查页表项。缺页分两种情况一是页表项完全为空即这个页从来没有被访问过二是页表项存在但页面被换出或写保护。第一种情况对应do_no_page文件映射或do_anonymous_page匿名映射第二种情况对应do_wp_page或do_swap_page。4.3 文件映射的缺页流程从address_space到readpage对于文件映射缺页处理的核心函数是do_no_page。它的大致流程如下static int do_no_page(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *page_table, pmd_t *pmd) { struct page *page; struct address_space *mapping vma-vm_file-f_mapping; page find_get_page(mapping, offset); if (!page) { page page_cache_alloc(mapping); mapping-a_ops-readpage(vma-vm_file, page); } ... install_page(mm, vma, address, page, vma-vm_page_prot); }流程就是首先在页缓存page cache里查找这个文件偏移位置的页面是否已经被缓存如果没有则分配一个新页调用该文件所属地址空间的readpage回调从磁盘或设备读取数据最后调用install_page把物理页连接到页表建立映射。读取数据的具体方式由文件系统决定。ext3 的readpage会通过块层读磁盘而 tmpfs 的readpage则直接处理内存页。这个文件系统通过address_space操作集参与缺页处理的设计是 Linux 文件映射机制灵活性的核心。4.4 匿名映射的缺页走向zero page与COW匿名映射没有文件作为数据来源它的缺页处理比较简单。但 Linux 在这里做了一个很有意思的优化首次读匿名页时映射的是系统全局唯一的zero page零页。这个页面是全零的而且是只读的所有匿名映射的首次读访问都指向同一个物理页。当程序尝试向这个页面写入时会触发写保护缺页进入do_wp_page。内核这时才真正分配一个新的物理页面把零页的内容复制过来然后更新页表让写入操作落到新页面上。这就是写时复制Copy-On-WriteCOW机制。很多人只知道 COW 用于fork()父子进程共享内存页实际上匿名映射也是 COW 的一个重要用户。这套机制带来的好处是一个程序即使匿名映射了几 GB 内存但只要不实际写入就几乎不消耗物理内存。这对于内存受限的嵌入式系统来说尤其重要。5. 文件映射的落点文件系统与驱动的mmap实现5.1 文件系统如何响应mmapaddress_space_operations的作用文件映射建立之后真正读取文件内容靠的不是文件系统直接提供的read接口而是通过address_space中的操作集。每个文件都有自己的address_space包含readpage、writepage、sync_page等回调函数。mmap文件映射和普通read系统调用在数据路径上的差别是理解文件映射性能优势的关键。read需要把数据从页缓存复制到用户缓冲区涉及一次内核态到用户态的数据拷贝。而 mmap 映射后的数据页直接通过页表映射到用户空间用户态访问映射地址时只要页已经在缓存里就直接访问物理页省去一次拷贝。这解释了为什么大文件读取场景下 mmap 通常比 read 快但也引入了一个问题如果文件被截断或者另一个线程通过 write 修改了文件内容通过 mmap 访问到的旧页缓存可能需要手动同步。2.6 内核里有msync来处理这类同步问题但并不像很多人想象的那样mmap 之后文件就跟内存完全一致了。5.2 驱动里的mmapremap_pfn_range和nopage的能力边界设备驱动实现 mmap 有两种典型方式。第一种是把设备的物理内存直接映射到用户空间使用remap_pfn_range或历史版本里的io_remap_page_range。这种方式适用于硬件寄存器、DMA buffer、帧缓冲等场景。内核在驱动的mmap回调中调用这个函数把一段物理地址区间直接关联到进程的虚拟地址区间。static int mydev_mmap(struct file *file, struct vm_area_struct *vma) { unsigned long pfn virt_to_phys(mydev_buffer) PAGE_SHIFT; if (remap_pfn_range(vma, vma-vm_start, pfn, vma-vm_end - vma-vm_start, vma-vm_page_prot)) return -EAGAIN; return 0; }第二种方式是在 VMA 里设置vm_ops提供nopage或fault回调。这种方式更灵活可以在每次缺页时动态决定返回哪一页。2.6 内核早期的接口叫nopage后来演进成fault。驱动可以在fault回调里分配内存、管理引用计数甚至可以只读映射某些内容。5.3 一个极简驱动的mmap实现案例我举个例子。假设我们有一个 PCIe 设备其 BAR0 空间暴露了一块控制寄存器区域我们希望用户态程序可以直接读写这段寄存器减少系统调用开销。驱动侧的做法是在file_operations里注册.mmap回调把 BAR0 的物理地址映射到用户空间。static int pcie_mmap(struct file *file, struct vm_area_struct *vma) { unsigned long bar0_phys pci_resource_start(dev, 0); unsigned long size vma-vm_end - vma-vm_start; if (size pci_resource_len(dev, 0)) return -EINVAL; vma-vm_page_prot pgprot_noncached(vma-vm_page_prot); if (remap_pfn_range(vma, vma-vm_start, bar0_phys PAGE_SHIFT, size, vma-vm_page_prot)) return -EAGAIN; return 0; }注意这里有一行非常重要的调用pgprot_noncached。它把这段映射设置为非缓存属性。如果不做这一步CPU 可能会通过缓存读写寄存器而缓存并不会自动识别硬件寄存器的变化造成读到的是旧值或写入不及时。这是驱动开发里非常经典的坑。用户态对应访问这段地址时直接对指针读写即可完全没有系统调用开销。性能上会比 read/write 高一个量级因为每次 read 都有一次用户态到内核态的切换而 mmap 后用户态直接在用户空间访问映射内存。6. 内核工程实践sys_mmap在透明加密、read/write拦截与性能陷阱中的应用6.1 想拦截read/write为什么绕不过mmap最近在社区里经常看到有人问怎么在内核里拦截某个进程的 read/write 系统调用或者怎么给文件系统做透明加密。很多人第一反应是钩住系统调用表或者替换file_operations但真正做起来会发现光拦截 read/write 是远远不够的。原因就在于 mmap。如果用户态程序不通过 read/write 访问文件而是通过 mmap 做文件映射那么数据从文件到用户空间的路径完全不经过 read/write 系统调用——数据是在缺页处理时从页缓存映射过去的。你拦截了 read/write挡不住 mmap 那条路。所以在内核里做文件内容拦截或者透明加密必须在两个层面同时下功夫在文件系统的address_space_operations层拦截readpage、writepage。在 VMA 缺页处理路径上做相应的处理。只有两条路径都覆盖才能保证无论用户态用 read 还是 mmap 访问文件数据都经过你的处理逻辑。6.2 基于VMA与缺页回调的透明加密思路透明加密的一个常见实现思路是用 mmap 映射文件时注册自定义的 VMA 操作在fault回调里解密数据后返回给用户空间。平时文件在磁盘上以密文形式存储用户态程序用 mmap 映射文件后访问到的却是解密后的明文。这个方案的关键点是映射文件的 VMA 需要设置VM_DONTDUMP等属性避免在生成 core dump 时把明文写出去。解密后的页不能直接采用普通文件映射的页缓存否则其他进程通过普通 read 也能读到明文绕过了访问控制。fault回调里返回的页需要标记为私有页不与文件的页缓存共享。这里有一个非常容易踩的坑如果fault回调操作不当页缓存里存了明文页内核在内存压力下把页写回磁盘时就会把明文写进磁盘的页缓存里导致密文文件被破坏。所以透明加密的 mmap 路径一定要精细控制页的生命周期和 writeback 行为通常需要设置VM_PFNMAP或锁页或者在writepage里做反向加密处理。6.3 mmap在高性能场景的坑TLB抖动、锁竞争与顺序写做内核和驱动开发久了对 mmap 的性能陷阱就有切身体会。这里说几个常见的第一个是 TLB 抖动。mmap 一大块内存后如果程序频繁访问不同页面TLB 会不断换入换出页表项。相比之下顺序访问的 read 因为每次都走同一条路径TLB 状态更稳定。所以mmap 就一定比 read 快这个结论只在随机访问场景下比较可靠——顺序读一个几百 MB 的文件时read 配合页缓存预读往往能跑得比 mmap 更稳。第二个是锁竞争。mmap 之后每次访问触发缺页异常尽管内核会尽量批量处理但缺页异常本身仍然需要拿mm-mmap_sem读锁还有页表的自旋锁。如果多个线程同时映射并访问不同的地址区间锁竞争可能成为瓶颈。这也是为什么在内核 4.x 之后社区开始引入无锁页表查找等优化2.6 内核里这个瓶颈是真实存在的。第三个是顺序写的陷阱。mmap 映射的文件如果用户态程序以顺序写方式持续往里写内核页缓存会积累大量脏页写回策略可能跟不上用户态的写入速度导致突然的写阻塞。这个问题在 2.6 内核上尤其明显因为它的 writeback 机制远不如现代内核成熟。解决办法是设置MAP_POPULATE在映射时预分配物理页或者在关键写入路径上定期调用msync强制刷盘虽然这会牺牲一部分瞬时性能但能换来稳定性。7. 从内核源码之外的视角看sys_mmap最后聊点我在实际项目里积累的经验。用 strace 观察真实调用序列第一次研究 mmap 时我建议你先写一个简单程序用strace ./your_prog观察它到底调了什么。你会发现 glibc 在 malloc 大内存时不一定用 brk而是直接走 mmap动态链接器加载共享库时也要靠 mmap。sys_mmap 被调用的频率远超你的想象。版本差异是绕不开的坎这篇文章围绕 2.6 内核展开但你在实际工程中读代码时一定要先确认当前内核版本的接口变化。比如nopage回调后来改名成了fault参数也从struct vm_area_struct里取出地址变成由vmf结构体传入。直接拿老代码编译新内核大概率会报错。从实际需求出发不要为了用mmap而用mmap嵌入式场景里如果只是简单读写几个寄存器用 read/write 足矣系统调用开销在大多数场景下可以忽略。真正需要 mmap 的场景是大块数据反复读写、用户态和内核态需要共享数据缓冲、以及像帧缓冲这样需要用户态直接操作硬件内存的地方。选型的时候想清楚自己的数据流比盲目追求零拷贝更实际。我在实际调试中还有一个小技巧如果怀疑 mmap 映射异常可以在用户态先检查/proc/self/maps文件它会把当前进程所有 VMA 的地址区间、权限、文件偏移、设备号和 inode 全部列出来。内核侧 VMA 出了问题这个文件会非常直观地暴露异常。配合内核动态调试打印定位起来比凭空猜快得多。sys_mmap 这套机制从 2.6 到现在核心设计并没有发生颠覆性的变化依然是虚拟地址空间元数据 延迟页表建立 缺页按需填充这套组合拳。把这一条链路彻底吃透你再去看现代内核里 io_uring、用户态页表映射、大页支持这些新特性会发现它们本质上都是在同一个地基上做文章。