☰
Linux内核mmap深度解析:从sys_mmap到缺页异常与VMA管理
2026/10/1 9:07:00 网站建设 项目流程

我在这行做了快十年的内核相关开发,从 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.6,i386 上改为直接传参数寄存器,同时保留了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_mmap2

64 位平台出现之后,文件偏移量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_mmap:sys_mmap真正干活的入口在这里

2.1 入口处那些容易被忽略的检查

sys_mmap把参数整理好之后就转给do_mmap2,再进入do_mmap。do_mmap要做的事情远不止"分配一段虚拟地址空间"这么简单,它有一连串的前置检查,任何一个不满足都会直接返回错误码。

关键检查点包括:

  • 检查len是否为 0,len超过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做的大致流程是:

  1. 调用get_unmapped_area寻找一块合适的空闲虚拟地址区间。
  2. 如果flags里有MAP_FIXED,则直接使用指定地址,覆盖该地址上已有的映射。
  3. 检查新映射是否可以直接合并到已有的相邻 VMA 中(合并条件包括属性一致、映射文件一致、偏移连续等)。
  4. 如果不满足合并条件,则调用kmem_cache_alloc分配一个新的 VMA 结构体。
  5. 初始化 VMA 的各个字段,设置vm_ops,把 VMA 插入进程的地址空间链表和红黑树。
  6. 最后通过insert_vm_struct完成插入操作,并返回起始虚拟地址。

这个过程里,最影响性能的是第 3 步的合并判断。如果每次mmap都创建一个全新 VMA 而不做合并,那么一个频繁做小块映射的程序会在 VMA 链表上积累大量碎片。2.6 内核用红黑树组织 VMA,大大加快了查找速度,但合并策略依然是优化重点。

2.3 sys_mmap返回的不是地址,而是一段契约

当do_mmap返回起始虚拟地址后,sys_mmap直接把这个地址返回给用户态。很多人以为"内存已经分配好了",其实这是一个非常大的误解。

在这个时间点,内核只是建立了虚拟地址空间的"元数据"——即 VMA。它告诉内核:从地址 X 到地址 X+len 这一段,是属于某个映射的,具有哪些属性,对应哪个文件。真正的物理内存页根本还没有分配。这种"延迟分配"的设计正是虚拟内存系统高效运行的基石。

可以这样理解: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_FIXED,get_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,它做的工作包括:

  1. 读取 CPU 的CR2寄存器,获取触发缺页的线性地址。
  2. 判断发生缺页时 CPU 所处的是用户态还是内核态。
  3. 调用find_vma在进程的 VMA 红黑树中查找该地址属于哪一段映射。
  4. 如果找不到对应的 VMA,说明这个地址根本不在进程地址空间内,发送SIGSEGV。
  5. 找到 VMA 后,检查访问类型是否与 VMA 的保护属性一致,比如向只读页面写入,会走写保护处理。
  6. 调用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-Write,COW)机制。

很多人只知道 COW 用于fork()父子进程共享内存页,实际上匿名映射也是 COW 的一个重要用户。这套机制带来的好处是:一个程序即使匿名映射了几 GB 内存,但只要不实际写入,就几乎不消耗物理内存。这对于内存受限的嵌入式系统来说尤其重要。

5. 文件映射的落点:文件系统与驱动的mmap实现

5.1 文件系统如何响应mmap:address_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 驱动里的mmap:remap_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、用户态页表映射、大页支持这些新特性,会发现它们本质上都是在同一个地基上做文章。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询