☰
从系统调用到缺页异常:mmap底层机制深度剖析
2026/10/11 20:43:40 网站建设 项目流程

我在调试一个崩溃问题时,第一次真正跟 mmap 正面交锋。某个模拟项目X的进程稳定运行十几分钟后必崩,backtrace 每次都指向同一个地址,源码却怎么都对不上号。最后把崩溃现场/proc/pid/maps拉出来,才发现那块匿名映射已经被另一个模块 munmap 掉了,而崩溃线程还在闷头往里写。从那以后,我遇到内存相关的疑难杂症,第一反应就是查映射表,而不是猜代码。

这个系列写到第11篇,前面把进程、地址空间、文件系统摊开讲过,现在终于轮到把它们粘起来的那块胶水——mmap。它是 Android 系统调用里出镜率最高的底层机制之一:so 文件靠它映射进内存,Binder 的一次拷贝靠它兜底,大块 malloc 背后也有它。我见过不少朋友把“mmap”挂嘴边,但对它为什么快、为什么偶尔返回 EACCES 或 SIGBUS,其实并不清楚。这篇文章就用底层视角把这套机制掰开揉碎讲一遍。

1. 为什么我坚持用一整篇来聊 mmap:它首先是机制,不只是“指令名称”

1.1 标题里的“指令”其实是个误会,但这个误会很值钱

很多人第一眼看到“mmap 指令”,会下意识把它当成一条 CPU 指令。严格来说,mmap 是系统调用,完整讲法是“编号为 222(arm64)的一个内核服务”。真正在这个调用背后执行触发动作的指令,在 ARM64 上是svc #0,在 x86_64 上是syscall。

但这个误会挺值钱。它提醒我们,系统调用并不只是一个名字,而是“用户态程序通过特定指令、特定寄存器传递意图,进入内核执行权限更高的代码”这一整套机制。搞清楚这个机制以后再去看 mmap,就不是背函数原型,而是理解内核到底在替我们做什么。这也是这篇文章从头到尾的视角。

如果只停留在“mmap 能把文件映射到内存”这个结论,你很难解释为什么会 EINVAL、为什么文件被截断会 SIGBUS、为什么映射成功的地址不能直接当作普通堆指针随便写。这些行为差异全部来自内核侧的处理流程,而不是一个简单的“映射”动作。

1.2 为什么说它是虚拟内存、文件系统和进程之间的连接件

如果说文件系统负责“怎么存”,虚拟内存负责“怎么寻址”,进程负责“怎么隔离”,那 mmap 就站在三者的交界处。它把一段虚拟地址空间与某个“后端”绑定起来:可以是一个普通文件、一个设备、一块匿名内存,也可以是 dma-buf 或共享内存。

绑定完成之后,你对那块内存的读写,会被内核解释成对后端的读写。换句话说,mmap 让“内存”变成了一个通用托盘,各种资源都能被放上去。这也是我在 Android 系统调用系列里把这篇放在“前篇”位置的原因:后面很多机制——动态加载器、Binder、内存分配器、图形栈——都会反复回到 mmap 上来。

在 Android 里它尤其特殊,因为 Android 的很多设计都建立在“设备共享、内存受限”的前提下:so 需要按需加载来省内存,Binder 需要减少 IPC 拷贝,图形 buffer 需要跨进程映射。这些需求无一例外都指向 mmap。可以说,不看懂 mmap,后面分析 Android 的系统架构会很吃力。

1.3 这篇适合谁读,读完之后能拿到什么

这篇既适合刚开始看 Android 底层的人,也适合做系统调优、性能分析和崩溃排查的开发者。前者能补上“系统调用怎么走的、mmap 参数怎么组合、缺页异常干了什么”这套底层概念;后者能拿到几个立刻能用的工具:

  • 用syscall()和裸svc两种方式手动触发 mmap 的示例;
  • 诊断进程内存布局时/proc/self/maps的字段含义;
  • 从 EINVAL、EACCES、SIGBUS 反推问题根因的排查思路。

所以我没有把篇幅浪费在“mmap 能做什么”的科普层面,重点放在:整套调用链路怎么走、每个参数到底在约束什么、内核在背后做了什么、以及真机调试时你会踩到哪些边界条件。

2. Android 系统调用旅程:一次 mmap 是怎么从用户态摸到内核的

2.1 为什么要经过 libc:API、封装和 syscall()

Android 的 C 库是 Bionic,它向应用暴露mmap()这个函数,签名和 Linux 标准接口保持一致。你写代码时调用mmap,Bionic 会把参数整理好,再调用一个更底层的syscall()。

为什么中间要多这一层?因为内核不认识你定义的 C 函数,只认识一套硬编码的约定:触发指令是什么、系统调用号放在哪个寄存器、参数按什么顺序排、返回值怎么拿。Bionic 的封装把这些约定全部包掉了,同时还隐藏了一个重要细节:内核返回错误时不会返回 -1,而是返回一个负错误码,封装层负责把它转成errno。

有些人图省事直接在应用层用syscall(__NR_mmap, ...)绕过 libc 的封装,这在技术上可行,但要注意错误处理和 ABI 兼容问题。自己手动封装时,这些隐藏约定就全部暴露出来了,本文第 6 章会实际走一遍这个过程。

2.2 arm64 下的 svc 指令与系统调用约定

在 arm64 上,触发系统调用的指令就一条:svc #0,也就是 Supervisor Call。调用前要把系统调用号放进 x8 寄存器,把参数依次放进 x0~x5。mmap 恰好有 6 个参数,正好用满 x0~x5。

先看一张各架构的对比表,方便建立全局印象:

架构触发指令系统调用号寄存器参数寄存器返回值寄存器mmap 对应调用号
arm64svc #0x8x0–x5x0222
x86_64syscallraxrdi, rsi, rdx, r10, r8, r9rax9
arm32svc #0r7r0–r6r0mmap2 为 192

注意 arm32 上更多用的是mmap2,它的 offset 参数单位是“页”而不是“字节”,这是一个很经典的兼容性坑。好在 Android 新设备基本都是 arm64,本文后面统一用 arm64 来讨论。

2.3 内核如何“对号入座”处理 mmap

进程通过svc #0陷入内核后,内核的异常入口会判断这是一次系统调用,然后从 x8 读取系统调用号,去sys_call_table这个表里查对应的处理函数。arm64 上 mmap 的系统调用号是 222,表里对应的是__arm64_sys_mmap。

这个函数首先会检查用户态传来的地址是否合法(access_ok一类检查),然后把参数整理好,进入do_mmap。do_mmap是更通用的逻辑,它做的事情可以概括成三步:

  1. 在进程地址空间里找一段满足长度要求的空洞区域;
  2. 为这段区域创建一个vm_area_struct,也就是 VMA 节点,记录起始地址、结束地址、权限、flags、关联的文件等;
  3. 把这个 VMA 插入进程地址空间的管理结构,通常是红黑树,然后返回这段区域的起始地址。

真正的物理内存分配并不在do_mmap里完成,这一点非常关键,第 4 章会详细展开。很多人以为 mmap 返回就代表物理内存已经备好,实际并不是。

2.4 返回值的秘密:负数是暗号

内核返回时,如果成功,x0 就是映射区起始地址;如果失败,x0 是一个介于 -4095 到 -1 之间的负错误码,比如 -12 对应 ENOMEM,-13 对应 EACCES。

Bionic 的syscall()封装看到负值,会把它取反设置到errno,然后对外返回 -1。这就是为什么你在代码里明明调的是同一个系统调用,从 libc 函数拿到的结果和从内核直接拿到的结果表现形式完全不同。

这个约定对做底层调试很重要。如果你绕过 libc 自己触发 svc,就要自己处理负错误码。我自己写裸调用的代码时,习惯加一个判断:当返回值落在[-4095, -1]区间时,把负值取正存入 errno,并返回MAP_FAILED。否则下一步很容易把-12当成一个合法地址去访问,然后一脸懵地看到段错误。

3. 拆解 mmap 六个参数:每一块拼图都决定行为边界

3.1 addr 与 length:在虚拟地址空间里“圈地”

addr大部分时间传 NULL,让内核自己找一段合适的空闲地址。内核返回的地址不一定等于你传的 addr,addr 只是提示;除非 flags 里带了MAP_FIXED,那内核就必须在指定地址处建立映射,否则报错。

length是要映射的长度。一个容易忽略的细节是:length 不要求页对齐,内核会向上取整到页大小的倍数。比如你传 5000 字节,内核实际上会映射 8192 字节(假设页大小 4096)。但这不代表你可以高枕无忧地访问超出 length 的部分,尤其文件映射场景,越界访问可能触发 SIGBUS,这点后面有专门一节聊。

addr和length还有一个组合规定:如果addr不为空,它应当页对齐;如果同时用了MAP_FIXED,而不满足对齐要求,内核直接返回 EINVAL。实际开发中,我建议地址统一让内核选,length 自己按业务算好,不要依赖内核帮你圆整。

3.2 prot:读、写、执行的自由组合

prot决定这块区域未来能怎么访问,取值是以下几种的组合:

  • PROT_READ:可读;
  • PROT_WRITE:可写;
  • PROT_EXEC:可执行;
  • PROT_NONE:完全不可访问。

这些权限最终会被设置到进程的页表项里。如果后续访问违反了权限,比如只读映射却往里面写,CPU 会产生缺页/权限异常,最终表现为 SIGSEGV 段错误。

在 Android 底层,有一个特别值得注意的点:W^X 策略,也就是“可写区域不可执行,可执行区域不可写”。普通 Linux 上你可能随手创建一个PROT_READ | PROT_WRITE | PROT_EXEC的匿名映射当 JIT 内存用,但在 Android 上会因为加固或安全策略被拒绝或警告。我第一次用 mmap 写 JIT 实验时就撞过这堵墙,后来改成两步:先映射 RW,再mprotect切成 RX。

3.3 flags:共享、私有与匿名映射的分叉路

flags是最能体现 mmap 语义的参数,常用组合有:

  • MAP_SHARED:映射对文件或共享内存的修改会写回后端,且对其他进程可见;
  • MAP_PRIVATE:写时复制,你的修改不会落到文件上,也不会影响其他进程;
  • MAP_ANONYMOUS:匿名映射,不关联文件,fd 传 -1;
  • MAP_FIXED:强制把映射放到 addr 指定的地址;
  • MAP_FIXED_NOREPLACE:在指定地址已有映射则报错,避免破坏原有映射。

MAP_PRIVATE的写时复制(COW)是理解很多事情的关键。比如 fork 之后,父子进程共享同一个只读文件映射,任何一方写数据时,内核会把对应页复制一份,让写操作作用在私有副本上。这正是 mmap 高效共享文件页的基础。

匿名映射则适合纯粹的“借一块内存”场景,分配线程栈、分配大块缓冲区、进程间共享内存,都会用到它。它没有文件后端,不需要 fd,内核会为这块内存准备零填充的物理页。

3.4 fd 与 offset:文件映射绑定关系的契约

当你不使用MAP_ANONYMOUS时,fd 和 offset 会把映射绑定到一个具体文件。offset 表示从文件哪个位置开始映射,它必须是页大小的整数倍。这是内核做页缓存索引的硬性要求,不是“建议”。

fd 至少要可读。如果你还打算用MAP_SHARED | PROT_WRITE写回文件,那么打开文件时就必须带写权限,否则内核会返回 EACCES。这里有个容易误解的组合:MAP_PRIVATE | PROT_WRITE是允许的,即使 fd 是O_RDONLY,因为你的写入只会影响自己的私有副本,不会动文件本身。

一个真实的坑是:open()时没有加O_RDWR,然后想映射成共享可写,结果反复 EACCES。这不是内核乱报,而是权限模型设计得已经很直白:你要能写回文件,文件就得允许你写。

3.5 参数组合的常见坑位排查

把前面几个参数的错误表现汇总成一张表,排查时可以直接对照:

错误常见原因
EINVALlength 为 0、offset 未页对齐、flags 和 fd 组合非法、addr 不满足MAP_FIXED对齐要求
EACCES文件 fd 的打开权限与 prot/flags 不匹配,比如O_RDONLY+MAP_SHARED + PROT_WRITE
ENOMEM内存不足、VMA 数量超过max_map_count、映射长度过大
EPERM安全策略限制,比如禁止PROT_EXEC组合或映射某些特殊 fd
ENODEV文件系统或设备后端不支持 mmap,常见于某些伪文件系统

我调试的时候习惯把错误码和参数打印出来,跟表里逐项比。80% 的文件映射问题出在 offset 对齐和 fd 权限,剩下的多数是 flags 组合写错。

4. mmap 真正神奇的地方:页表和缺页异常撑起“懒加载”

4.1 映射成功不等于物理页已经分配

这是新手最容易误解的一点。mmap 返回地址,只代表进程的地址空间里多了一个 VMA 节点,这段地址已经被标记为“计划映射”,但物理页并没有立刻准备好。

这样做的好处非常明显:映射一个 100MB 的 so 文件,进程实际可能只访问其中 20MB 的代码页。如果 mmap 一次性把 100MB 全部载入内存,Android 这种多进程系统早就被撑爆了。内核对文件映射采取按需加载,只有真正被访问到的页才会从磁盘读入。

这也是它跟 read 的核心差异之一。read 是主动把数据从内核搬进用户缓冲区;mmap 是先在地址空间里画好区域,等 CPU 踩到未准备好的页时,内核再临时处理。

4.2 缺页异常是 mmap 的隐形引擎

当进程第一次访问映射区域中某一页的地址时,CPU 查页表发现这一页还没有对应的物理页,于是触发缺页异常,进程陷入内核。

内核接下来会做这么几件事:先找到这段虚拟地址对应的 VMA,判断它是匿名映射还是文件映射。如果是匿名映射,就分配一个物理页并清零;如果是文件映射,就从 VMA 关联的文件里读取偏移对应的数据,填进 page cache,再建立虚拟页到物理页的映射。处理完成之后,CPU 返回用户态,程序继续执行,整个过程对用户透明。

缺页异常听上去像错误,实际上是 mmap 高性能的基础。它把“准备数据”这件事推迟到了真正访问的那一刻,省去了大量无用 IO。代价是首次访问会有额外延迟,这也解释了一个常见现象:mmap 之后第一次触碰每一页都会卡一下,后面再访问就很快。

4.3 为什么说 mmap 能省拷贝

传统read()读文件时,内核先把文件页放进 page cache,再用copy_to_user把数据从内核缓冲区复制到用户缓冲区。这里有两次内存操作,一次是从磁盘到 page cache,一次是 page cache 到用户空间。

mmap 方式下,你映射的页就是 page cache 里的页。CPU 的 MMU 直接让用户态地址指向那同一块物理页,没有第二次拷贝。对于“读文件然后随机访问”的场景,这种差异非常明显;对于只从头到尾顺序读一遍的场景,差距会被缺页开销抵消一部分,未必快很多。

所以不要迷信“mmap 一定更快”。正确的取舍是:数据量很大、映射后会反复访问、或者需要随机访问时,mmap 优势明显;小文件、一次性读取、逻辑简单的场景,read 反而更省事。

4.4 从/proc/self/maps观察一个真实映射

Linux 和 Android 都通过/proc/<pid>/maps暴露进程的地址空间布局。每一行的标准格式是:

起始地址-结束地址 权限 偏移 设备 inode 路径

举个例子,某进程加载了 libart.so,maps 里可能出现这样的行:

7a6e00000000-7a6e00100000 r--p 00000000 08:06 12345 /system/lib64/libart.so 7a6e00100000-7a6e00200000 r-xp 00001000 08:06 12345 /system/lib64/libart.so

第一段的r--p是只读私有,包含 ELF 文件头;第二段的r-xp是可读可执行,就是代码段。权限字段最后一个字符是 p 或 s,p 对应 private,s 对应 shared。

我拿到一个陌生进程,第一步永远是先看 maps,而不是急着打开反汇编工具。它能把每个模块在地址空间里的位置、权限、文件来源列得清清楚楚。前面那个崩溃问题,就是先从 maps 里发现一段不该消失的映射被 munmap 掉了,才顺着找到了真凶。

5. Android 世界离不开 mmap 的五个现场

5.1 Binder:一次拷贝机制的地基

在 Android 里,只要进程想用 Binder 和服务端通信,就必须对打开后的 binder 设备做一次 mmap。驱动会为这个进程分配一块接收事务的缓冲区,并把这块缓冲区映射进进程地址空间。

这个设计的目的就是减少拷贝。传统 IPC 往往要把数据先拷到内核,再从内核拷到目标进程,算两次;Binder 把目标进程在内核里的缓冲区直接映射给目标进程,发送方通过copy_from_user把数据拷进这块缓冲区,接收方在自己的地址空间里直接访问,省掉了第二次拷贝。

所以每当你看到某个进程的 maps 里有一段匿名的、权限通常不可执行的较大映射,很可能就是它在 Binder 驱动里创建的接收区域。理解这一点以后,做 Binder 性能分析时,你就知道瓶颈未必在“传输”,有时在缓冲区管理和映射区域的维护上。

5.2 linker 加载 so:ELF 不是“读进来”的

很多人以为加载共享库就是把文件打开然后一片片读进内存。实际上 Android 的 linker 解析 ELF 之后,是按程序头表里的PT_LOAD段逐个调用 mmap 的。

代码段映射为r-xp,数据段映射为rw-p,重定位完成后再用mprotect把部分段改成只读,比如 RELRO 段。这样有几个好处:代码页可以按需加载,多个进程共享同一个 so 时能共用物理页,省内存也省启动时间。

这也是为什么你在 maps 里能看到同一个 so 对应多行不同权限的映射。如果你做 native hook 或者想修改某个 so 的内存,先去看对应段的映射权限,比直接mprotect整段要安全得多。改一个r-xp的代码段之前,你得想明白自己在挑战 W^X 策略。

5.3 大块内存分配:malloc 背后的匿名映射

malloc分配超大块时,并不都是从堆里切。Android 的分配器(新版本默认 scudo)面对超过阈值的大块请求,会直接 mmap 一块匿名内存,释放时再 munmap 整体归还内核。

这样设计的好处是减少堆碎片,避免超大块长期霸占堆空间。同理,线程栈也常常是 mmap 出来的,主线程的栈是内核启动时分配的,普通新线程的栈很多时候由 pthread 库用 mmap 分配。

所以你在 maps 里看到许多小段的rw-p匿名映射时,先不要怀疑内存泄漏,可能是线程栈,也可能是某种临时大缓冲区。判断方法很简单:看这段映射是否反复出现又消失,数量是否持续增长,再结合线程数推断。

5.4 图形缓冲区与 dma-buf 映射

图形栈里的 buffer 经常以 dma-buf 或共享内存形式存在。SurfaceFlinger 把 buffer 对应的 fd 传给 App,App 通过 mmap 映射后才能做 CPU 渲染、上传纹理之类的操作。这里的后端不是普通文件系统,而是 GPU/显示驱动提供的物理内存。

这类映射如果忘记 munmap,既占虚拟地址空间,又占真实的物理内存。做图形栈内存优化时,我习惯用 maps 过滤这类 dma-buf 映射,观察数量是否异常。遇到掉帧或内存暴涨,先把所有dmabuf相关映射统计一遍,往往比盲目算引用计数更快。

5.5 memfd 与 ashmem:开发者可控的共享内存

Android 早期常用 ashmem 驱动创建匿名共享内存,新版系统逐渐转向memfd_create。两者本质都是创建一个没有文件名的内存后端,然后通过 mmap 把同一块物理页共享给多个进程。

对开发者来说,mmap + memfd是一个很实用的跨进程共享方案。你可以创建一个 memfd,把它 mmap 到自己的地址空间,再用 Binder 把 fd 传给另一个进程,那个进程同样 mmap 这块 fd,两边看到的物理页是同一块。做共享数据、图形 buffer、零拷贝管道,都可以按这个思路搭建。

6. 动手实践:从 syscall() 到裸 svc,亲手触发一次 mmap

6.1 用 syscall() 封装跑通最简匿名映射

先写一个最简单、最容易跑通的例子,用 Bionic 提供的mmap()函数,映射一段匿名内存,写数据进去再读出来:

#include <stdio.h> #include <string.h> #include <sys/mman.h> #include <unistd.h> #include <errno.h> #define MAP_SIZE (16 * 1024) int main(void) { void *p = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("mmap failed"); return 1; } strcpy(p, "hello, mmap!"); printf("mapped=%p data=%s\n", p, (char *)p); getchar(); if (munmap(p, MAP_SIZE) != 0) { perror("munmap failed"); return 1; } return 0; }

这里放一个getchar(),是为了让程序驻留,方便你在另一个终端查它的/proc/pid/maps。正常情况下,程序会打印出映射地址和写入的数据,表现和其他内存块没什么区别,但此时这段内存的“后端”是一块匿名物理页,而不是栈或堆。

6.2 用内联汇编直接发出 svc

下面这步会更有意思:完全绕过 libc 的封装,在 arm64 设备上用内联汇编直接触发svc #0。前面说过,调用号 222 放进 x8,6 个参数占 x0 到 x5。

#include <stdio.h> #include <stdint.h> #include <errno.h> #include <sys/mman.h> #include <unistd.h> /* arm64 上 __NR_mmap = 222 */ #define MY_NR_MMAP 222L static void *raw_mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset) { register long x8 __asm__("x8") = MY_NR_MMAP; register long x0 __asm__("x0") = (long)addr; register long x1 __asm__("x1") = (long)length; register long x2 __asm__("x2") = (long)prot; register long x3 __asm__("x3") = (long)flags; register long x4 __asm__("x4") = (long)fd; register long x5 __asm__("x5") = (long)offset; __asm__ volatile( "svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4), "r"(x5) : "memory"); if ((unsigned long)x0 >= (unsigned long)-4095) { errno = (int)-x0; return MAP_FAILED; } return (void *)x0; } int main(void) { void *p = raw_mmap(NULL, 8192, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("raw_mmap failed"); return 1; } printf("raw mmap returned %p\n", p); return 0; }

这段代码的核心就是svc #0这一条指令。编译时建议用 Android NDK 的 clang 交叉编译,或者直接放到 arm64 的 Linux 环境里编译。跑通以后你应该能看到一个正常的映射地址,这意味着你手动完成了 Bionic 封装层层处理的一切。

6.3 用 maps 验证映射结果

程序运行期间,另开一个终端,找到进程 PID 后执行:

cat /proc/<PID>/maps | grep -E "rw-p|anon"

如果你用了getchar()驻留程序,就能在 maps 里观察到一行和打印地址一致的新映射:起始地址对齐、权限rw-p、偏移 00000000、路径为空。这就是匿名映射的经典长相。

用 maps 验证有一个副作用:你会开始觉得每个进程的地址空间都像一张会呼吸的地图。地址在增长、权限在变化、文件映射在加载和释放。这些东西你多盯几次,下次遇到越界、悬空、重复映射问题,直觉会准很多。

6.4 实测对比:mmap 与 read 读大文件的差异

我做过一个小实验:准备一个约 300MB 的匿名临时文件,分别用两种方式把整个文件数据读一遍:

  • read 方式:循环调用read()到用户缓冲区;
  • mmap 方式:mmap映射整个文件,然后顺序遍历每个字节。

在顺序遍历场景下,read 和 mmap 的差距并不夸张;mmap 偶尔还会因为首次缺页卡顿,慢上一点。真正拉开差距的是“映射后反复随机访问”的场景,比如按索引多次跳读文件内容,mmap 的命中速度明显更稳。

这个实验想说明的结论很简单:mmap 不是包治百病的工具,它强在按需加载、减少拷贝、随机访问友好,弱在首次访问和写回开销。选型时先看清楚访问模式,别上来就无脑全部 mmap。

7. 踩坑记录:五个让 mmap 挂掉的边界问题

7.1 页对齐:offset 和 length 最容易翻车

offset 必须是页大小的整数倍,这是硬性约束。我见过很多新手直接传offset = 1或者offset = 4095,然后对着 EINVAL 发呆。内核要按页去索引文件缓存,offset 不是页对齐,它都不知道该从哪个页块开始映射。

length 虽然会自动向上圆整到页边界,但代码不应该依赖这个行为。正确做法是运行时通过getpagesize()或sysconf(_SC_PAGE_SIZE)拿页大小,自己做对齐计算。尤其是 Android 新设备已经进入 16KB page size 时代,硬编码 4096 的代码会直接踩雷。

另外,mmap 的 length 是你业务上的“有效长度”。有人习惯把sizeof(struct xxx)直接传进去,如果这个结构体大小不是页倍数,内核会多映射一截,访问到那一截的文件末端之外时,很容易引出下一个问题。

7.2 文件被截断后的 SIGBUS

这个问题很少见,但一旦遇上,定位成本很高。假设你 mmap 了一个文件的前 1MB,这时另一个进程把文件truncate成了 512KB。你再访问映射区间末尾附近的页,内核想从文件补数据却补不出来,于是触发 SIGBUS,常见表现就是进程突然崩溃。

为什么不是 SIGSEGV?因为这次错误不是“访问了没有映射的地址”,而是“映射还挂在着,但底层文件已经没有内容支撑这块地址了”。两者在信号层面分得很清楚。

应对方式:做文件映射时,先取文件真实大小并记录下来;对文件内容可能被其他进程改变的场景,要么加锁,要么不要映射超出当前稳定长度的范围。多进程共享同一个文件映射时,截断方和读取方都得遵守约定,否则就是定时炸弹。

7.3 MAP_FIXED 的破坏力

MAP_FIXED的含义是“必须把新映射放在 addr 地址”。如果那个地址已经有映射,内核会先把它拆掉,再建立新映射。拆掉的可能是别人的so映射、堆区、甚至当前正在执行的代码页。

我们曾经定位过一个诡异崩溃:某个模块为了确认自己映射的地址,用了MAP_FIXED去固定位置,结果把另一个模块刚加载的 so 数据段给顶掉了,后续访问全是 0 或错误数据。这类问题最难排查,因为报错的地方往往和踩踏者完全不同,但只要查 maps 对比前后差异,马上能看出谁覆盖了谁。

现代内核提供了MAP_FIXED_NOREPLACE,目标地址已有映射时不会覆盖,而是返回 EEXIST。除非你明确知道那个地址是空的,否则不要用裸的MAP_FIXED。

7.4 权限组合引起的 EACCES 与 EPERM

文件映射时,fd的打开权限、prot、flags三者必须互相匹配。文件以O_RDONLY打开,却想用MAP_SHARED | PROT_WRITE,内核直接回 EACCES,因为一旦允许,你就能绕过打开权限修改文件内容。

PROT_EXEC则是另一类敏感选择。Android 上 SELinux 策略和其他加固机制可能对可执行映射有额外限制。表现就是同样一个PROT_READ | PROT_EXEC映射,在开发板 Linux 上能过,在 Android 用户态被 EPERM 拒了。

排查这类问题时别只盯着内核版本。先把prot、flags、fd权限打印出来,再看 SELinux 的 deny 日志,通常比猜快很多。

7.5 16KB page size 与 max_map_count 的新问题

Android 15 之后,16KB page size 已经是大势所趋。老代码里写死#define PAGE_SIZE 4096,在 16KB 设备上映射一页就是 16384 字节,maps 里的地址和文

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

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

立即咨询