26. 内存映射:mmap() 系统调用在 Binder 驱动中的实现细节

说到 Binder 的 mmap,很多人的第一反应就是「哦,就是那个用来共享内存的」。嗯,这话没错,但只说对了一半。Binder 的 mmap 跟普通的进程间共享内存还真不太一样。我当年第一次看 Binder 驱动源码时,也被这块绕了好一阵子。

说白了,Binder 驱动里的 mmap 干了一件很巧妙的事:它只在内核空间分配物理内存页,然后把这块物理内存分别映射到用户空间和内核空间。这样一来,数据只需要拷贝一次,就能从发送进程的用户空间传到接收进程的用户空间。

mmap 在 Binder 中的角色

Binder 驱动为什么要用 mmap?你想想看,传统的 IPC 方式,比如 Socket 或管道,数据要从 A 进程的用户空间拷贝到内核空间,再从内核空间拷贝到 B 进程的用户空间。两次拷贝,效率低。

Binder 的做法是:在驱动初始化时,每个使用 Binder 的进程都会调用一次 mmap。这个 mmap 分配的内存区域,就是后续所有 Binder 事务的数据缓冲区。

核心要点:Binder 的 mmap 不是为了「共享内存」而共享内存,而是为了减少数据拷贝次数。从两次拷贝降为一次拷贝,这就是 Binder 性能优于传统 IPC 的关键所在。

驱动中的 mmap 实现流程

我们来看看 Binder 驱动中 mmap 的具体实现。代码路径在 drivers/android/binder.c 中,对应的函数是 binder_mmap()

static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
{
    struct binder_proc *proc = filp->private_data;
    int ret;

    // 1. 限制映射大小,最大 4MB
    if ((vma->vm_end - vma->vm_start) > SZ_4M)
        vma->vm_end = vma->vm_start + SZ_4M;

    // 2. 禁止可执行权限
    if (vma->vm_flags & VM_EXEC) {
        vma->vm_flags &= ~VM_EXEC;
    }
    vma->vm_flags |= VM_DONTCOPY | VM_MIXEDMAP;
    vma->vm_flags &= ~VM_MAYWRITE;

    // 3. 分配内核缓冲区
    proc->buffer = (void *)__get_free_pages(GFP_KERNEL | __GFP_ZERO,
                        get_order(proc->buffer_size));
    proc->user_buffer_offset = vma->vm_start - (unsigned long)proc->buffer;

    // 4. 建立页表映射
    ret = remap_pfn_range(vma, vma->vm_start,
                 virt_to_phys(proc->buffer) >> PAGE_SHIFT,
                 proc->buffer_size,
                 vma->vm_page_prot);
    return ret;
}

这段代码虽然精简过,但核心逻辑都在了。我来拆解一下每一步在干什么。

关键步骤详解

1. 大小限制与权限控制

Binder 驱动对 mmap 的大小做了硬性限制,默认最大 4MB。为什么是 4MB?我个人理解,这是 Google 在内存占用和性能之间做的权衡。太小了不够用,太大了浪费内存。毕竟每个 Binder 进程都要分配这么一块。

另外注意,驱动强制去掉了 VM_EXEC 标志。这意味着你没法在 Binder 映射区域执行代码。这是安全考虑——Binder 缓冲区只用来传数据,不需要执行代码。

2. 内核缓冲区的分配

__get_free_pages() 分配的是连续的物理内存页。这里有个细节:Binder 驱动在内核空间分配物理内存,而不是在用户空间。这意味着这块内存一开始只有内核能访问。

那用户空间怎么访问?答案就在 user_buffer_offset 这个字段里。

个人经验:我曾在调试一个内存泄漏问题时,发现某个进程的 Binder 缓冲区一直没释放。后来定位到是驱动中 binder_free_buf() 的引用计数逻辑有 bug。所以如果你遇到 Binder 相关的内存问题,不妨先检查一下 mmap 区域的释放逻辑。

3. 用户空间与内核空间的映射关系

这里是最巧妙的地方。驱动通过 remap_pfn_range() 把刚才分配的物理内存页,重新映射到用户空间的 vma->vm_start 地址。这样一来,同一块物理内存,既在内核空间有虚拟地址,也在用户空间有虚拟地址

它们之间的关系是:

用户空间地址 = 内核空间地址 + user_buffer_offset

这个 user_buffer_offset 在驱动初始化时就计算好了,后续所有 Binder 事务都依赖这个偏移量来转换地址。

数据拷贝的零拷贝本质

有了这个映射关系,Binder 的数据传输就变得非常高效。我们来看一个典型的数据发送流程:

  1. 发送进程调用 ioctl() 发起 Binder 事务
  2. 驱动在接收进程的 mmap 区域分配一块缓冲区
  3. 驱动把数据从发送进程的用户空间拷贝到内核空间(也就是接收进程的 mmap 区域的内核端)
  4. 接收进程通过自己的 mmap 用户空间地址,直接读取数据

看到了吗?数据只拷贝了一次:从发送进程的用户空间到内核空间。接收进程不需要再拷贝,因为它通过 mmap 直接映射到了同一块物理内存。

注意:这里说的「零拷贝」其实是一次拷贝,不是真正的零拷贝。真正的零拷贝连这一次都不需要,比如通过共享内存直接读写。但 Binder 的设计目标不是追求极致的零拷贝,而是在安全、稳定、易用之间取得平衡。

mmap 区域的内存管理

Binder 驱动对 mmap 区域的内存管理也很讲究。它使用了一个叫做 binder_buffer 的结构体来管理每个缓冲区:

struct binder_buffer {
    struct list_head entry;    // 链表节点
    struct rb_node rb_node;   // 红黑树节点
    unsigned free:1;          // 是否空闲
    unsigned allow_user_free:1;
    unsigned async_transaction:1;
    struct binder_transaction *transaction;
    struct binder_node *target_node;
    size_t data_size;
    size_t offsets_size;
    size_t extra_buffers_size;
    void *user_data;          // 用户空间地址
};

每个 binder_buffer 对应 mmap 区域中的一块子缓冲区。驱动用红黑树来管理这些缓冲区,支持快速查找和分配。分配策略是 最佳适配(Best-fit),尽量找到大小最合适的空闲块,减少内存碎片。

我曾经在项目中遇到过一个问题:某个服务频繁创建和销毁 Binder 事务,导致 mmap 区域出现大量碎片,最终分配失败。解决方案是调整了事务的复用策略,减少了频繁的分配释放操作。

SVG 图解:Binder mmap 内存映射结构

Binder mmap 内存映射结构 进程A 用户空间 Binder 事务数据 (待发送的数据) 内核空间(Binder 驱动) 物理内存页(内核虚拟地址) 进程B 用户空间 mmap 映射区域(用户虚拟地址) (直接读取数据) 一次拷贝 mmap 映射(无需拷贝) 关键点 同一块物理内存,同时映射到内核空间和进程B的用户空间 数据只需从进程A拷贝到内核,进程B通过mmap直接读取

mmap 的释放与回收

当进程退出或关闭 Binder 设备时,驱动会调用 binder_vma_close() 来释放 mmap 区域。这个过程会遍历所有已分配的 binder_buffer,释放对应的物理内存页。

这里有个容易踩的坑:如果进程异常退出,mmap 区域可能无法正常释放。我记得有一次线上问题,某个 Native 进程频繁 crash,导致内核中 Binder 的 mmap 内存泄漏。最后在驱动中加了异常处理逻辑,在进程退出时强制回收所有 Binder 缓冲区。

避坑指南:如果你在开发 Binder 相关的 Native 服务,建议在进程退出前显式调用 close(binder_fd),确保驱动能正确回收 mmap 资源。不要依赖进程退出时的自动清理,那不一定可靠。

性能考量与优化建议

因素 影响 优化建议
mmap 大小 默认 4MB,太小会导致分配失败 根据实际数据量调整,但不要超过 4MB
内存碎片 频繁分配释放会产生碎片 复用 Binder 事务,减少分配次数
数据拷贝 一次拷贝,性能优于传统 IPC 大数据量时考虑使用共享内存替代
映射开销 mmap 本身有页表建立开销 进程启动时一次性 mmap,后续复用

最后说一句,Binder 的 mmap 设计是典型的「空间换时间」思路。它用 4MB 的内核内存,换来了高效的一次拷贝 IPC。这个设计在 Android 这种资源受限的移动设备上,是非常明智的选择。


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