深入 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 驱动实现跨进程数据传递,核心就三件事:

  1. 内存映射(mmap):在驱动里开辟一块物理内存,同时映射到 Server 进程的用户空间和内核空间,实现“一次拷贝”。
  2. Transaction 事务:Client 把要发送的数据打包成一个 binder_transaction 结构体,通过 ioctl 扔给驱动。
  3. 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 通信的过程可以用下面的流程图概括。我建议你边看边对照代码:

Binder C/S 通信数据流 用户空间 (User Space) 内核空间 (Kernel Space) Client (调用方进程) Server (服务提供方) Service Manager (注册/查询) Binder Driver (内核调度 / 数据拷贝) ioctl (BC_TRANSACTION) 唤醒等待线程 数据拷贝(一次) 🔍 红色虚线框内是内核空间,Client 和 Server 不能直接访问对方内存,必须通过驱动转发。

这张图里绿色箭头表示正常注册/查询流程: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 引用时,驱动内部做了这样几件事:

  1. 找到 SM 中注册的 "media" 对应的 Binder 节点。
  2. 在 Client 进程的 refs_by_desc 树里创建一个新的 binder_ref,指向那个节点。
  3. 给 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 序列化机制。