深入内核:Binder 驱动的内存管理与事务调度

上一篇我们梳理了用户态的调用流程,从 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 时:

  1. Copy from User :内核使用 copy_from_user 将 Client 用户态 Parcel 的数据拷贝到 Client 进程的 binder_proc->buffer 中的一块空闲区域(binder_buffer)。

  2. 地址转换 :内核解析事务数据,找到目标 binder_node(即 Server)。

  3. 映射共享 :内核并不把数据再拷贝一份给 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 命令:

  1. 查找目标 :根据命令中的 handle(句柄),在 proc->refs_by_desc 红黑树中找到对应的 binder_ref,进而找到目标的 binder_node。

  2. 分配事务 :分配一个 binder_transaction 结构体,填充代码、数据指针、发送者信息等。

  3. 入队:

    • 如果是异步(FLAG_ONEWAY):将事务放入目标进程 target_proc->todo 队列。
    • 如果是同步:将事务放入目标线程 target_thread->todo 队列(如果有空闲线程),或者挂起当前线程等待。
  4. 唤醒 :调用 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 后:

  1. 找到发起该事务的原始 Client 线程(通过 transaction_stack)。
  2. 将回复数据准备好。
  3. 唤醒 Client 线程。
  4. 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 之间无缝传递复杂对象。

相关推荐
hunterandroid3 小时前
Android 运行时权限实战:从声明权限到拒绝后的可用降级
android
a5986523 小时前
docker-android 6080 端口访问失败?
android·docker·容器·云手机
天意__4 小时前
Android手机上部署Linux服务器 在Termux里安装Ubuntu
android·linux·服务器
恋猫de小郭4 小时前
Flutter Android 改动:APK 不再允许通过 Intent 动态修改 Engine 参数
android·前端·flutter
修船大队长5 小时前
KL15 高电平请求 FullCom:AUTOSAR 项目调用流程
android·java·服务器
三少爷的鞋5 小时前
毁掉 Android 现代架构,只需要一个自定义的“FlowBus”
android
jack1868917 小时前
安卓手机忘记锁屏密码怎么办? 无需密码也可解锁
android·智能手机
应用市场20 小时前
不用一张图片的像素小游戏:小人是字符串,房间是矩形,平衡靠两千个假玩家
android·kotlin·游戏开发
stevenzqzq20 小时前
android 对渠道的理解
android
QEasycloud21 小时前
BOM接口抛转失败钉钉通知策略实战教程:MySQL异常日志到钉钉消息的端到端配置
android·mysql·钉钉