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

上一篇我们梳理了用户态的调用流程,从 BpBinderIPCThreadState,再到 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 队列:这是进程级的消息信箱。当其他进程发来的事务到达时,会被放入这个链表。
  • buffervma :这就是我们在用户态调用 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_procbinder_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)。


三、事务调度:从 ioctlonTransact

回到那个关键的 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 之间无缝传递复杂对象。

相关推荐
范特西林2 小时前
代码的生成:AIDL 编译器与 Parcel 的序列化艺术
android
范特西林3 小时前
解剖麻雀:Binder 通信的整体架构全景图
android
范特西林3 小时前
破冰之旅:为什么 Android 选择了 Binder?
android
奔跑中的蜗牛6664 小时前
一次播放器架构升级:Android 直播间 ANR 下降 60%
android
测试工坊6 小时前
Android 视频播放卡顿检测——帧率之外的第二战场
android
Kapaseker8 小时前
一杯美式深入理解 data class
android·kotlin
鹏多多8 小时前
Flutter使用screenshot进行截屏和截长图以及分享保存的全流程指南
android·前端·flutter
Carson带你学Android8 小时前
OpenClaw移动端要来了?Android官宣AI原生支持App Functions
android
黄林晴8 小时前
Android 删了 XML 预览,现在你必须学 Compose 了
android