17、内存映射文件:mmap原理,匿名共享内存,Ashmem驱动

说到Android内存管理,有个东西绕不开——内存映射文件。说白了,就是把磁盘文件或者内存区域,直接映射到进程的虚拟地址空间里。你想想看,这样一来,读写文件就像读写内存一样简单,省去了传统read/write那套数据拷贝的麻烦。

我个人习惯把mmap比作“搭桥”。它直接在用户空间和内核空间之间搭了一座桥,数据不用绕路。嗯,今天我们就来聊聊这座桥是怎么搭的,以及Android里那个特殊的“匿名共享内存”——Ashmem。

17.1 mmap:内存映射的基石

mmap是Linux内核提供的一个系统调用。它的原型长这样:

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

参数看着多,其实核心就几个:

  • addr:建议的映射起始地址,传NULL让内核自己选
  • length:映射多长
  • prot:保护标志,PROT_READ、PROT_WRITE这些
  • flags:映射类型,MAP_SHARED还是MAP_PRIVATE
  • fd:文件描述符,如果是匿名映射就传-1
  • offset:文件偏移量,必须是页大小的整数倍

我在项目中遇到过一个问题:用mmap映射一个大文件后,进程的内存占用看着暴涨。其实那不是真的物理内存,只是虚拟地址空间被占用了。真正物理内存的分配,是等到你访问那一页的时候才发生的——这就是所谓的按需分页

核心要点:mmap减少了数据在内核空间和用户空间之间的拷贝次数。传统read/write需要两次拷贝(磁盘→内核缓冲区→用户缓冲区),而mmap只需要一次(磁盘→用户空间,通过缺页中断)。

17.2 匿名共享内存:没有文件名的共享

普通的mmap需要关联一个文件描述符。但有时候我们只是想分配一块内存,让多个进程共享,并不想真的创建一个文件。这时候就需要匿名共享内存了。

怎么做?很简单,mmap的时候指定MAP_ANONYMOUS标志,fd传-1:

#include <sys/mman.h>
#include <unistd.h>

size_t size = 4096;
void *shared_mem = mmap(NULL, size,
                        PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_ANONYMOUS,
                        -1, 0);
if (shared_mem == MAP_FAILED) {
    perror("mmap failed");
    return -1;
}

这样分配出来的内存,如果进程fork了,子进程也能看到同样的内容。因为MAP_SHARED保证了父子进程共享同一块物理内存。

但这里有个问题:如果两个不相关的进程想共享内存呢?fork不行,它们没有血缘关系。这时候就需要一个“名字”来标识这块共享内存。Linux上有POSIX共享内存(shm_open),但Android选择了自己的一套——Ashmem。

17.3 Ashmem驱动:Android的共享内存方案

Ashmem,全称Anonymous Shared Memory,是Android内核特有的一个驱动。它本质上是一个字符设备驱动,注册在/dev/ashmem节点上。

为什么Android不直接用POSIX共享内存?我个人觉得有几个原因:

  • 内存回收:Ashmem支持“pin/unpin”机制,系统内存紧张时可以unpin掉,把物理页面回收掉
  • 文件描述符传递:通过Binder传递fd,天然适合Android的IPC架构
  • 大小可伸缩:Ashmem分配的内存可以设置一个最大尺寸,但实际使用按需分配

我曾经调试过一个SurfaceFlinger的内存泄漏问题。SurfaceFlinger用Ashmem来传递图形缓冲区,结果有个地方忘了unpin,导致物理内存一直被占用。嗯,从那以后我每次用Ashmem都会检查pin/unpin的配对情况。

17.4 Ashmem的使用流程

Ashmem的使用其实不复杂,核心就几步:

  1. 打开/dev/ashmem设备,得到一个文件描述符
  2. 通过ioctl设置名字和大小
  3. mmap把这个fd映射到进程地址空间
  4. 通过Binder把这个fd传给其他进程
  5. 其他进程同样用mmap映射,就共享了同一块物理内存

代码示例大概是这样:

#include <fcntl.h>
#include <linux/ashmem.h>
#include <sys/ioctl.h>

int create_ashmem(const char *name, size_t size) {
    int fd = open("/dev/ashmem", O_RDWR);
    if (fd < 0) return -1;

    // 设置名字(可选,方便调试)
    ioctl(fd, ASHMEM_SET_NAME, name);

    // 设置大小
    ioctl(fd, ASHMEM_SET_SIZE, size);

    return fd;
}

// 使用示例
int fd = create_ashmem("my_shared_buffer", 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_SHARED, fd, 0);
// 现在可以读写ptr指向的内存了
// 通过Binder把fd传给另一个进程...

小提示:Ashmem的名字不是必须的,但我建议你每次都设置。为什么?因为用dumpsys meminfo或者看/proc/pid/maps的时候,名字能帮你快速定位是哪块共享内存。我曾经在排查问题时,就是靠这个名字找到了一个第三方库偷偷分配的Ashmem。

17.5 Ashmem的pin/unpin机制

这是Ashmem最特别的地方。普通mmap映射的内存,内核没法主动回收。但Ashmem可以:

  • pin:锁定物理页面,保证可用
  • unpin:解锁物理页面,内核可以在内存紧张时回收

当你unpin了一页,内核可能把它回收掉。下次再访问时,会触发缺页中断,重新分配一页零填充的物理内存。数据当然就丢了。所以unpin之前,你得确保数据已经不再需要,或者已经同步到其他地方了。

警告:不要随意unpin别人正在使用的Ashmem页面。我曾经见过一个bug:A进程unpin了共享内存,B进程毫不知情,继续读写,结果读到全是零。排查了两天才找到原因——就是pin/unpin状态没同步好。

17.6 知识体系总览

下面这张图帮你理清mmap、匿名共享内存和Ashmem之间的关系:

内存映射文件知识体系 mmap 系统调用 文件映射 匿名映射 设备映射 MAP_ANONYMOUS Ashmem 驱动 pin/unpin 机制 Binder 传递 fd 按需分配物理页 Android 进程间共享内存的核心基础设施

17.7 实际应用场景

Ashmem在Android里用得非常多。我随便列几个:

场景 说明
SurfaceFlinger 图形缓冲区共享,App和SurfaceFlinger之间传递图像数据
Binder传输大数据 超过1MB的数据用Ashmem传,避免Binder缓冲区限制
ContentProvider 跨进程共享文件内容时,用Ashmem做中间缓冲
Zygote预加载 Zygote进程用Ashmem共享预加载的类和资源

你想想看,如果没有Ashmem,这些场景要么用传统IPC反复拷贝数据,要么用POSIX共享内存但没法控制内存回收。Android选择Ashmem,说白了就是为了在性能和内存管理之间找到平衡。

总结一下:mmap是底层机制,匿名共享内存是它的一个应用模式,而Ashmem是Android对这个模式的增强实现。理解这三者的关系,你就能明白Android为什么能在有限的内存里跑得这么流畅。

好了,这一章就到这里。记住:下次你在Android上做跨进程大数据传输时,第一个想到的应该是Ashmem,而不是自己写个socket或者管道。用对了工具,事半功倍。


公众号:蓝海资料掘金营,微信deep3321