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 的数据传输就变得非常高效。我们来看一个典型的数据发送流程:
- 发送进程调用
ioctl()发起 Binder 事务 - 驱动在接收进程的 mmap 区域分配一块缓冲区
- 驱动把数据从发送进程的用户空间拷贝到内核空间(也就是接收进程的 mmap 区域的内核端)
- 接收进程通过自己的 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 内存映射结构
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 这种资源受限的移动设备上,是非常明智的选择。