深入 C/S 模型:Binder 驱动到底干了什么
上一章我们聊了 Binder 的整体定位,很多同学觉得“进程间通信”这个概念很抽象。说白了,就是两个不同进程的房子之间要递纸条,但操作系统不允许他们直接串门。Binder 驱动就是那个帮忙传纸条的邮递员,而且它还是个智能邮递员——不仅能传,还能帮你把收件地址翻译成真正的房号。
我当年刚开始看 Binder 源码时,最困惑的就是:客户端调用一个接口,数据怎么就到服务端了?中间到底经历了什么?今天我们就把它掰开揉碎,讲清楚 Binder 驱动的核心工作。
1. C/S 模型的结构骨架
Binder 的 C/S 模型里主要有四个角色:Client、Server、Service Manager(简称 SM) 和 Binder Driver。你可以想象成——
- Client:需要服务的进程,比如一个 App 想要读取联系人。
- Server:提供服务的进程,比如联系人服务进程。
- Service Manager:通讯录黄页,Server 在上面注册名字,Client 通过名字查到 Server 的“门牌号”。
- Binder Driver:内核中的调度员,负责所有跨进程数据的实际搬运。
我习惯把 Binder 驱动比喻成一座桥,桥两边是不同进程的地址空间。数据不能直接飞过去,必须通过这座桥的“渡口”——ioctl 系统调用。
2. Binder 驱动实现数据传递的三大关键
说实话,Binder 驱动实现跨进程数据传递,核心就三件事:
- 内存映射(mmap):在驱动里开辟一块物理内存,同时映射到 Server 进程的用户空间和内核空间,实现“一次拷贝”。
- Transaction 事务:Client 把要发送的数据打包成一个
binder_transaction结构体,通过 ioctl 扔给驱动。 - Binder 节点与引用:驱动维护一棵树,记录每个 Binder 实体(BBinder)在哪,以及每个 Binder 引用(BpBinder)指向谁。
嗯,这里要注意:传统 IPC 需要拷贝两次(用户→内核→用户),Binder 因为用了 mmap,只拷贝一次。这也是它性能好的原因之一。
2.1 mmap 的幕后操作
每个 Binder 服务端进程在初始化时,会调用 mmap 向驱动申请一块内存。驱动在内核中分配物理页,然后把这组页同时映射到内核空间和 Server 的用户空间。你想想看,这样当 Client 写数据到内核缓冲区时,Server 的进程里直接就能看到——根本不需要第二次拷贝。
我曾经在项目中遇到过一个诡异的问题:Server 进程的 mmap 总是返回 -ENOMEM。查了半天,发现是 proc 目录下的 /proc/sys/vm/max_map_count 太小了,导致进程映射数超限。所以大家如果遇到类似问题,记得先看一眼这个值。
2.2 Transaction 的流转过程
一次 Binder 通信的过程可以用下面的流程图概括。我建议你边看边对照代码:
这张图里绿色箭头表示正常注册/查询流程:Server 向 SM 注册名字,Client 去 SM 查询到 Server 的 handle,然后通过 handle 向驱动发送事务。红色实线是数据进入驱动,黄色虚线是驱动把数据映射到 Server 用户空间的“一次拷贝”路径。
2.3 binder_transaction 数据结构
驱动里最关键的结构体就是 binder_transaction,我贴一段简化的内核代码(4.14 版本):
struct binder_transaction {
struct binder_work work;
struct binder_thread *from; // 发送方线程
struct binder_proc *to_proc; // 目标进程
struct binder_thread *to_thread;
struct binder_buffer *buffer; // 数据缓冲区
unsigned int code; // 事务码,如 TRANSACTION/REPLY
unsigned int flags;
struct binder_priority priority;
struct binder_transaction *from_parent; // 嵌套事务链
};
每个事务被驱动接收后,会被挂到目标进程的待处理队列里,然后唤醒目标进程的 Binder 工作线程来处理。这里有个细节:驱动不会主动把数据推给用户空间,它只做两件事——把数据拷贝进内核缓冲区,然后通知目标线程。真正读出数据的工作,由目标线程的 ioctl(BINDER_WRITE_READ) 完成。
关键点:驱动返回之后,数据还在内核缓冲区。目标进程读取时,才通过 mmap 映射直接访问,这其实是“零拷贝写入”+“一次读访问”。
3. Binder 节点与引用的管理
驱动内部维护了两个红黑树:proc->nodes 和 proc->refs_by_node、proc->refs_by_desc。每个 Binder 实体(BBinder)在驱动里对应一个 binder_node,每个 Binder 引用(BpBinder)对应一个 binder_ref。
举个具体的例子:当 Client 通过 IServiceManager::getService("media") 拿到一个 Binder 引用时,驱动内部做了这样几件事:
- 找到 SM 中注册的 "media" 对应的 Binder 节点。
- 在 Client 进程的 refs_by_desc 树里创建一个新的
binder_ref,指向那个节点。 - 给 Client 返回一个整数 handle(描述符),Client 的 BpBinder 就持有这个 handle。
哎,我记得有次在调试一个诡异的内存泄漏时,发现某个 Binder 引用计数始终降不下来。查到最后,是 Service 端忘记在 onDestroy 时调用 unlinkToDeath,导致驱动里的节点一直有人引用。这个问题排查起来贼麻烦,建议大家写 Service 时一定要处理好死亡通知。
4. 驱动的核心处理流程(整理成表格)
| 步骤 | 驱动行为 | 涉及数据结构 |
|---|---|---|
| 1. Client 发起 ioctl | 解析 BINDER_WRITE_READ,取出 binder_transaction_data | binder_write_read |
| 2. 驱动创建 transaction | 分配 binder_transaction 和 binder_buffer | binder_transaction, binder_buffer |
| 3. 拷贝数据到内核 | 从 Client 用户空间 copy_from_user 到 binder_buffer | binder_buffer |
| 4. 查找目标节点/引用 | 根据 handle 在 proc->refs_by_desc 中查找 ref | binder_ref, binder_node |
| 5. 唤醒目标线程 | 将 transaction 挂到目标进程的待处理链表,唤醒等待队列 | binder_thread, waitqueue |
| 6. 目标线程读取 | 调用 ioctl 取出数据,通过 mmap 映射直接读取 | 用户空间 buffer |
小提示:如果你自己在写 Native 层 Binder 服务,可以用 BpBinder::transact() 来发事务。但切记,事务大小不要超过 1MB(Binder 默认缓冲区大小),否则驱动会返回 -EINVAL。我就在一次传递大图时踩过这个坑,后来改用共享内存+文件描述符传的。
5. 避坑:驱动层面的常见异常
- 事务大小超限:Binder 内核缓冲区默认 1MB(可调
/sys/kernel/debug/binder/proc/<pid>/buffer_size),超出会返回-EINVAL。 - 死锁:如果你在 Binder 回调里又同步调用了同一个 Binder 服务,可能造成线程池耗尽。驱动不会阻止这种循环,但线程数有限(默认 16 个),用完了就卡住。
- Binder 节点泄漏:客户端没有正确调用
unlinkToDeath,或服务端没有在销毁时清理引用,导致驱动里的binder_node无法释放。曾经我在做系统稳定性测试时,发现/sys/kernel/debug/binder/proc/里某些 pid 对应的节点数一直上涨,就是这个问题。
总结一下,Binder 驱动的工作本质就是:一个 ioctl + 一次拷贝 + 红黑树管理。理解了这三块,你就能把“跨进程调用的数据怎么过去的”这件事彻底想明白。下一部分我们再深入看看数据在 Java 层和 Native 层之间的转换——Binder 的 Parcel 序列化机制。