4. 内存映射:Binder 如何通过 mmap 实现一次拷贝,提升性能?

好,咱们来聊聊 Binder 性能最核心的杀手锏——一次拷贝

你想想看,传统的跨进程通信,比如 Socket 或者管道,数据得先从发送进程的用户空间拷贝到内核空间,然后再从内核空间拷贝到接收进程的用户空间。这一来一回,两次拷贝,性能损耗不小。尤其是在 Android 这种对流畅度要求极高的系统里,这种开销是难以接受的。

Binder 是怎么解决的呢?答案就是 mmap。说白了,就是在内核空间和接收进程的用户空间之间,共享同一块物理内存。这样一来,数据只需要从发送进程拷贝一次到这块共享内存,接收方就能直接看到了。一次拷贝,搞定。

核心结论: Binder 通过 mmap 在内核空间开辟一块缓冲区,并将其映射到接收进程的用户空间。发送方将数据拷贝到内核缓冲区,接收方直接读取,从而避免了第二次拷贝。

4.1 传统 IPC 的两步拷贝,到底慢在哪?

我刚开始接触 Android 系统时,也纳闷过:不就是多拷贝一次吗,能有多大影响?后来在优化一个多媒体播放器的进程间通信时,才真正体会到痛。

传统 IPC 的流程大致是这样的:

  1. 发送进程:调用 write(),数据从用户空间拷贝到内核空间。
  2. 内核:将数据从内核空间拷贝到接收进程的用户空间。
  3. 接收进程:调用 read(),拿到数据。

这里有两个关键瓶颈:

  • 上下文切换:每次 writeread 都是系统调用,会触发用户态和内核态的切换。切换本身就有开销。
  • 内存拷贝:两次拷贝意味着 CPU 要搬运两次数据。如果数据量大,比如传一张图片或一段视频,这个开销会非常可观。

我当时遇到的情况是,播放器在解码视频帧时,需要频繁地把数据从解码器进程传给渲染进程。用传统管道方式,帧率死活上不去。后来换成 Binder,问题迎刃而解。嗯,这就是一次拷贝的威力。

4.2 mmap 是如何实现“一次拷贝”的?

Binder 驱动在初始化时,会为每个使用 Binder 的进程调用 mmap。具体来说,是在内核空间分配一块物理内存页,然后把这页内存同时映射到:

  • 内核空间:Binder 驱动可以直接读写。
  • 接收进程的用户空间:接收进程可以直接读写。

这样一来,当发送进程要传递数据时,流程变成了:

  1. 发送进程通过系统调用(ioctl)将数据传给 Binder 驱动。
  2. Binder 驱动将数据从发送进程的用户空间拷贝到内核空间的这块共享缓冲区
  3. 接收进程直接从自己的用户空间读取这块共享缓冲区中的数据。

看到了吗?只有一次拷贝。接收方不需要再调用 read() 从内核空间拷贝到用户空间,因为那块内存已经映射到它的地址空间里了。

我的个人习惯: 在分析 Binder 性能问题时,我通常会先检查 mmap 的映射大小。如果映射太小,会导致频繁的缓冲区分配和回收,反而影响性能。默认是 1MB,对于大多数场景是够用的。

4.3 一次拷贝的完整流程(附 SVG 图)

为了让你更直观地理解,我画了一张图。这张图展示了从发送进程到接收进程,数据是如何只经过一次拷贝就到达的。

Binder 一次拷贝流程示意图 发送进程 (用户空间) 内核空间 (Binder驱动) 接收进程 (用户空间) 发送数据 共享缓冲区 (mmap) 映射区域 拷贝 (1次) 直接读取 (0次拷贝) 关键点: 1. 内核空间的共享缓冲区通过 mmap 映射到接收进程的用户空间。 2. 发送进程只需将数据拷贝到内核缓冲区,接收方即可直接访问。 3. 整个过程只有 1 次 CPU 拷贝,大幅提升性能。 mmap 映射关系

4.4 代码层面:mmap 是怎么调用的?

在 Binder 驱动中,mmap 的调用发生在 binder_mmap 函数里。我简化了一下核心逻辑,你感受一下:

// 驱动层:binder_mmap 简化版
static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
{
    struct binder_proc *proc = filp->private_data;
    
    // 1. 在内核空间分配物理页
    proc->buffer = (void *)__get_free_pages(GFP_KERNEL, ...);
    
    // 2. 将内核页映射到接收进程的用户空间 (vma)
    //    这样接收进程就能直接访问这块内存了
    ret = remap_pfn_range(vma, vma->vm_start,
                          virt_to_phys(proc->buffer) >> PAGE_SHIFT,
                          size, vma->vm_page_prot);
    
    // 3. 记录映射信息
    proc->user_buffer_offset = vma->vm_start - (unsigned long)proc->buffer;
    
    return 0;
}

这段代码的核心就是 remap_pfn_range。它把内核空间的物理页,重新映射到用户空间的 vma 区域。这样一来,内核和用户空间就指向了同一块物理内存。

我曾经踩过的坑: 在早期 Android 版本中,如果接收进程的 mmap 区域被破坏(比如内存越界),会导致 Binder 通信直接崩溃。后来内核增加了校验机制,但我们在写 native 代码时,还是要小心不要踩到 Binder 的映射区域。

4.5 一次拷贝 vs 两次拷贝:性能对比

为了让你有个更直观的感受,我整理了一个对比表格。假设传输 1MB 数据:

对比项 传统 IPC(两次拷贝) Binder IPC(一次拷贝)
CPU 拷贝次数 2 次 1 次
上下文切换次数 4 次(write + read 各两次) 2 次(ioctl 进入 + 返回)
内存占用 需要两份临时缓冲区 共享同一块物理内存
1MB 数据传输耗时(估算) 约 500μs 约 250μs

你看,Binder 在拷贝次数和上下文切换上都有明显优势。这也是为什么 Android 选择 Binder 作为核心 IPC 机制的原因。

4.6 需要注意的细节

虽然一次拷贝很美好,但有几个细节我得提醒你:

  • 映射大小有限:默认每个 Binder 进程的 mmap 大小是 1MB。如果传输的数据超过这个大小,Binder 会采用“分片传输”或“文件描述符传递”的方式。我建议你在设计大数据传输时,提前评估这个限制。
  • 只适用于单向传输:一次拷贝的优化是针对“发送方 -> 接收方”这个方向的。如果接收方要回复数据,同样需要再走一次拷贝流程。所以 Binder 的 transact 调用,本质上是一次请求 + 一次回复,各一次拷贝。
  • 内存映射的开销:mmap 本身在建立映射时是有开销的(页表建立等)。但 Binder 在进程初始化时只做一次映射,后续通信直接复用,所以这个开销被平摊了。
我的建议: 如果你在开发中遇到 Binder 传输性能瓶颈,先检查是不是数据量超过了 mmap 大小,导致触发了备用的“大块数据”传输路径。那个路径会退化成两次拷贝,性能会骤降。

好了,关于 mmap 和一次拷贝,就聊到这里。记住一句话:Binder 用空间(共享内存)换时间(减少拷贝),这是它高性能的基石


专注资料整理