上一篇我们梳理了用户态的调用流程,从 BpBinder 到 IPCThreadState,再到 ioctl 系统调用。当代码执行到 ioctl(mProcess, BINDER_WRITE_READ, &bwr) 这一行时,控制权正式从用户态移交给了内核。
对于应用开发者而言,内核往往是一个黑盒。但在 Binder 的语境下,如果不理解内核做了什么,就无法真正明白为什么 Binder 被称为"高效",也无法解释那些偶发的 TransactionTooLargeException 或死锁问题。
本文基于 Android 10 的内核源码(drivers/android/binder.c),尝试还原内核驱动在接收到请求后的真实行为。这里没有魔法,只有严谨的数据结构操作和内存管理。
一、核心数据结构:内核眼中的进程与线程
在用户态,我们习惯用对象(Object)来思考;在内核态,一切回归到结构体(Struct)。Binder 驱动并没有为每个服务创建独立的线程或进程,而是通过几个核心结构体来维护状态。
1. binder_proc:进程的上下文
每当一个进程打开 /dev/binder 设备时,内核就会为该进程创建一个 binder_proc 结构体。它是该进程在 Binder 世界里的"身份证"。
arduino
// drivers/android/binder.c (Android 10 Kernel)
struct binder_proc {
struct hlist_node proc_node; // 链接到全局 binder_procs 链表
struct rb_root threads; // 红黑树:管理该进程下的所有 binder_thread
struct rb_root nodes; // 红黑树:管理该进程发布的所有 binder_node (服务)
struct rb_root refs_by_desc; // 红黑树:通过句柄(handle)查找引用
struct list_head todo; // 待处理事务队列 (核心)
wait_queue_head_t wait; // 等待队列:当 todo 为空时,线程在此睡眠
struct mm_struct *vma; // 进程的虚拟内存区域描述符
void __user *buffer; // mmap 映射的基地址 (用户态可见)
size_t buffer_size; // 映射区总大小 (默认 1MB - 4KB)
// ... 统计信息、死亡通知列表等
};
关键点解析:
todo队列:这是进程级的消息信箱。当其他进程发来的事务到达时,会被放入这个链表。buffer与vma:这就是我们在用户态调用mmap时映射的那块内存。内核通过vma知道这块内存属于哪个进程,从而在后续实现"零拷贝"(实际上是避免二次拷贝)。
2. binder_thread:线程的上下文
Binder 是多线程安全的,但这种安全是由内核保证的。每个参与 Binder 通信的线程(无论是发起调用还是处理请求),在内核中都有一个对应的 binder_thread。
arduino
struct binder_thread {
struct binder_proc *proc; // 所属进程
struct rb_node rb_node; // 挂载在 proc->threads 红黑树上
unsigned pid;
int sync_work; // 同步工作计数
struct binder_transaction *transaction_stack; // 事务栈 (处理嵌套调用)
struct list_head todo; // 线程级的待处理队列
wait_queue_head_t wait; // 线程级等待队列
enum binder_deferred_state deferred_work; // 延迟工作标志
// ...
};
设计细节:
注意 binder_proc 和 binder_thread 都有 todo 队列。
- 如果是异步调用(
FLAG_ONEWAY),事务通常放入进程级 的todo,由进程中的任意空闲线程处理。 - 如果是同步调用,或者特定的返回逻辑,事务可能会关联到具体的线程级
todo,确保由正确的线程唤醒。
3. binder_node:服务的实体
每一个注册到 ServiceManager 或被其他进程引用的服务,在内核中都对应一个 binder_node。
arduino
struct binder_node {
struct binder_proc *proc; // 服务所在的宿主进程
struct rb_node rb_node;
struct hlist_node dead_node;
void __user *ptr; // 用户态 BBinder 对象的地址 (关键!)
void __user *cookie; // 用户态 cookie
// 引用计数
int has_strong_ref;
int has_weak_ref;
// ...
};
核心机制:
ptr 字段是理解 Binder 映射的关键。它存储了服务端进程中 BBinder 对象的虚拟地址。当客户端发起调用时,内核通过 binder_node 找到这个地址,并将其映射到客户端的上下文中(实际上是通过句柄转换),使得内核可以直接将数据投递到服务端的内存区域,而无需在两个用户态缓冲区之间复制。
二、内存管理:一次拷贝的实现原理
我们常听说 Binder 只需要"一次拷贝"。严格来说,是从发送方用户空间拷贝到内核空间,接收方直接访问内核空间映射的内存。这得益于 mmap 和页表技巧。
1. 映射的建立
在 binder_mmap 函数中,内核为进程分配了一块连续的虚拟内存区域(proc->buffer)。
arduino
static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
{
struct binder_proc *proc = filp->private_data;
size_t size = vma->vm_end - vma->vm_start;
// 分配物理页面 (实际上是在内核空间分配)
proc->buffer = kzalloc(size, GFP_KERNEL);
if (!proc->buffer) return -ENOMEM;
// 关键步骤:将内核分配的 buffer 映射到用户空间的 vma
// 这样,用户态的 vma 和内核态的 proc->buffer 指向同一块物理内存
vma->vm_flags |= VM_DONTCOPY | VM_MIXEDMAP;
vma->vm_ops = &binder_vm_ops;
vma->vm_private_data = proc;
// 建立页表映射,让用户态能访问这块内核内存
binder_update_page_range(proc, 1, proc->buffer,
proc->buffer + size, vma);
return 0;
}
这段代码揭示了真相:proc->buffer 是一块内核分配的内存,但通过 binder_update_page_range,它的物理页被映射到了用户进程的虚拟地址空间(vma)。
2. 数据的流动
当 Client 发起 BC_TRANSACTION 时:
-
Copy from User :内核使用
copy_from_user将 Client 用户态Parcel的数据拷贝到 Client 进程的binder_proc->buffer中的一块空闲区域(binder_buffer)。 -
地址转换 :内核解析事务数据,找到目标
binder_node(即 Server)。 -
映射共享 :内核并不把数据再拷贝一份给 Server。相反,它在 Server 进程的
binder_proc->buffer中分配一个新的binder_buffer描述符,但这个描述符指向的物理数据仍然是刚才 Client 写入的那块(或者更准确地说,内核维护了一个事务缓冲区,Server 线程读取时,内核通过指针运算直接让 Server 访问到这块数据所在的物理页)。
这种机制避免了传统 IPC(如 Socket)需要的两次拷贝(Client->Kernel, Kernel->Server)。
三、事务调度:从 ioctl 到 onTransact
回到那个关键的 ioctl 调用。当 Client 线程陷入内核,执行 binder_ioctl 时,发生了什么?
1. 命令分发
binder_ioctl 是一个巨大的交换机,根据命令字(cmd)分发逻辑。对于 BINDER_WRITE_READ,它主要处理写队列中的命令。
arduino
static long binder_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
struct binder_proc *proc = filp->private_data;
struct binder_thread *thread = binder_get_thread(proc); // 获取或创建当前线程上下文
// ... 参数检查
switch (cmd) {
case BINDER_WRITE_READ:
ret = binder_write_read(proc, thread, arg); // 核心处理函数
break;
// ...
}
return ret;
}
2. 处理 BC_TRANSACTION
在 binder_write_read 中,内核遍历用户传来的写缓冲区。如果遇到 BC_TRANSACTION 命令:
-
查找目标 :根据命令中的
handle(句柄),在proc->refs_by_desc红黑树中找到对应的binder_ref,进而找到目标的binder_node。 -
分配事务 :分配一个
binder_transaction结构体,填充代码、数据指针、发送者信息等。 -
入队:
- 如果是异步(
FLAG_ONEWAY):将事务放入目标进程target_proc->todo队列。 - 如果是同步:将事务放入目标线程
target_thread->todo队列(如果有空闲线程),或者挂起当前线程等待。
- 如果是异步(
-
唤醒 :调用
wake_up_interruptible(&target_thread->wait)或&target_proc->wait。这会唤醒正在该等待队列上睡眠的 Server 线程。
此时,Client 线程如果也是同步调用且未收到回复,它会将自己的状态标记为等待,并进入睡眠(schedule()),直到 Server 返回 BC_REPLY。
3. Server 端的响应
被唤醒的 Server 线程从 ioctl 返回(或者在内部的循环中继续),此时它的读缓冲区已经被内核填入了 BR_TRANSACTION 命令。
用户态的 IPCThreadState::executeCommand 读到 BR_TRANSACTION,解析出数据,调用 BBinder::transact,进而执行 onTransact。
业务逻辑执行完毕后,Server 需要返回结果。这会触发另一次 ioctl,携带 BC_REPLY 命令。
内核收到 BC_REPLY 后:
- 找到发起该事务的原始 Client 线程(通过
transaction_stack)。 - 将回复数据准备好。
- 唤醒 Client 线程。
- Client 线程从
schedule()醒来,ioctl返回BR_REPLY,用户态拿到结果,流程结束。
四、关于死锁与线程池的思考
理解了上述流程,很多疑难杂症便有了线索。
死锁(Deadlock)
最常见的 Binder 死锁源于同步调用的嵌套。
假设:
- Thread A (Proc 1) 持有 Lock X,调用 Proc 2 的服务(同步)。
- Proc 2 的唯一空闲线程是 Thread B。
- Thread B 在处理请求时,需要 Lock X,于是调用 Proc 1 的服务(同步)去获取。
- 此时,Thread A 在等 Thread B 返回,Thread B 在等 Thread A 释放 Lock X。
- 如果 Proc 1 没有其他空闲线程来处理 Thread B 的请求,系统就死锁了。
Binder 驱动本身有一定的检测机制(如 transaction_stack 的检查),但无法完全避免应用层的逻辑死锁。这也是为什么 Android 规定 Binder 调用不能耗时过长,且严禁在 Binder 线程中进行可能阻塞的操作(如等待另一个 Binder 同步回调,除非你确定线程池足够深)。
线程池(ThreadPool)
为什么 Server 端通常需要 joinThreadPool?
因为同步调用会占用线程。如果 Server 只有一个线程,且该线程发起了一个同步的 Binder 调用(作为 Client 去请求别人),那么它自己就阻塞了,无法处理新的 incoming 请求。如果此时又有其他人请求它,系统就没有可用线程,导致超时。
保持一定数量的线程池,是为了确保总有线程处于 wait 状态,随时准备处理 todo 队列中的新事务。
结语
从 binder_proc 的红黑树到 mmap 的页表映射,Binder 驱动的代码虽然庞大,但其核心思想非常朴素:利用内核作为可信中介,通过内存映射减少数据拷贝,通过精细的线程调度实现高效的进程间同步。
它不像网络 socket 那样通用,而是专门为 Android 的场景(大量的短消息、复杂的对象传递、严格的权限控制)量身定制的。
当我们下次在 Logcat 中看到 Binder: XXXX_XX 的线程名,或者遇到 TransactionTooLarge 异常时,脑海中或许能浮现出那块 1MB 的共享内存,以及在内核链表中静静等待被唤醒的 binder_thread。这不仅是代码的运行,更是操作系统资源管理的艺术。
下一篇,我们将跳出内核,回到用户态,聊聊 AIDL 编译器是如何生成那些繁琐的 Parcel 读写代码的,以及如何在 C++ 和 Java 之间无缝传递复杂对象。