27. 数据发送:ioctl() 中的 BINDER_WRITE_READ 命令处理流程

好,我们继续往下挖。前面几章我们把 Binder 驱动层的核心数据结构、线程管理、内存映射都聊了一遍。现在终于到了最关键的环节——数据到底是怎么发出去的?

你想想看,上层调用 transact(),最终都会落到驱动层的 ioctl() 系统调用。而 ioctl() 里最核心的命令就是 BINDER_WRITE_READ。说白了,整个 Binder IPC 的数据传输,就是靠这个命令撑起来的。

我个人习惯把 BINDER_WRITE_READ 的处理流程分成三个阶段:入口解析 → 数据写入 → 数据读取。今天我们就重点讲「写入」这一半,读取的部分下一章再展开。

27.1 入口:binder_ioctl() 的调度逻辑

用户空间通过 ioctl(fd, BINDER_WRITE_READ, &bwr) 进入内核。驱动层的入口函数是 binder_ioctl()。嗯,这里要注意,这个函数不光处理 BINDER_WRITE_READ,还处理其他命令,比如 BINDER_SET_CONTEXT_MGRBINDER_THREAD_EXIT 等等。

它的核心逻辑其实很简单——根据 cmd 参数做 switch-case 分发。我截取关键部分给你看:

static long binder_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct binder_proc *proc = filp->private_data;
    struct binder_thread *thread;
    unsigned int size = _IOC_SIZE(cmd);
    void __user *ubuf = (void __user *)arg;

    // 获取或创建当前线程的 binder_thread
    thread = binder_get_thread(proc);
    if (thread == NULL) {
        return -ENOMEM;
    }

    switch (cmd) {
    case BINDER_WRITE_READ: {
        struct binder_write_read bwr;
        // 从用户空间拷贝 bwr 结构体
        if (copy_from_user(&bwr, ubuf, sizeof(bwr))) {
            ret = -EFAULT;
            goto err;
        }
        // 核心处理
        ret = binder_ioctl_write_read(filp, cmd, arg, proc, thread);
        if (ret)
            goto err;
        // 将结果写回用户空间
        if (copy_to_user(ubuf, &bwr, sizeof(bwr))) {
            ret = -EFAULT;
            goto err;
        }
        break;
    }
    // ... 其他命令
    }
}

这里有个细节我特别想强调:binder_get_thread() 这个函数。它会在当前进程的线程红黑树里查找,如果找不到就新建一个。每个线程在驱动层都有一个 binder_thread 结构体,用来管理这个线程的待处理事务和等待状态。

核心要点:BINDER_WRITE_READ 的处理入口做了三件事——从用户空间拷贝数据、调用核心处理函数、将结果写回用户空间。任何一步出错都会导致整个 ioctl 返回错误码。

27.2 核心:binder_ioctl_write_read() 的读写决策

进入 binder_ioctl_write_read() 后,驱动会根据 bwr 结构体中的 write_sizeread_size 来决定做什么。

binder_write_read 结构体长这样:

struct binder_write_read {
    // 写入部分
    binder_size_t write_size;    // 要写入的数据大小
    binder_size_t write_consumed; // 已消耗的写入数据
    binder_uintptr_t write_buffer; // 用户空间写入缓冲区地址

    // 读取部分
    binder_size_t read_size;     // 要读取的数据大小
    binder_size_t read_consumed; // 已消耗的读取数据
    binder_uintptr_t read_buffer; // 用户空间读取缓冲区地址
};

处理逻辑其实就四个分支:

  • write_size > 0 且 read_size > 0:先写后读。这是最常见的场景,比如发起一次同步调用。
  • write_size > 0 且 read_size == 0:只写不读。比如发送一个异步通知。
  • write_size == 0 且 read_size > 0:只读不写。比如等待对方回复。
  • 两者都为 0:直接返回,什么都不做。

我记得在 Android 4.4 时代,有个 bug 就是某些场景下 write_sizeread_size 同时为 0,导致驱动层空转,白白浪费了一次系统调用。后来 Google 在 5.0 里加了个 early return 优化。

27.3 写入处理:binder_thread_write() 的指令解析

write_size > 0 时,驱动会调用 binder_thread_write()。这个函数会从用户空间的 write_buffer 中逐个解析 Binder 指令。

Binder 指令是一系列 BC_XXX 宏定义的操作码,每个操作码后面跟着对应的参数。常见的指令有:

指令 含义 参数
BC_TRANSACTION 发起一次事务(调用) struct binder_transaction_data
BC_REPLY 回复一次事务 struct binder_transaction_data
BC_FREE_BUFFER 释放内核缓冲区 binder_uintptr_t
BC_INCREFS / BC_ACQUIRE 增加 Binder 引用计数 __u32 target
BC_RELEASE / BC_DECREFS 减少 Binder 引用计数 __u32 target
BC_ENTER_LOOPER / BC_EXIT_LOOPER 线程循环状态管理

处理流程是一个 while 循环,每次读取一个操作码,然后根据操作码执行对应的处理函数。代码结构大致如下:

static int binder_thread_write(struct binder_proc *proc,
                struct binder_thread *thread,
                binder_uintptr_t binder_buffer, size_t size,
                binder_size_t *consumed)
{
    uint32_t cmd;
    void __user *buffer = (void __user *)(uintptr_t)binder_buffer;
    void __user *ptr = buffer + *consumed;
    void __user *end = buffer + size;

    while (ptr < end && thread->return_error == BR_OK) {
        // 读取操作码
        if (get_user(cmd, (uint32_t __user *)ptr))
            return -EFAULT;
        ptr += sizeof(uint32_t);

        switch (cmd) {
        case BC_TRANSACTION:
        case BC_REPLY: {
            struct binder_transaction_data tr;
            // 拷贝事务数据
            if (copy_from_user(&tr, ptr, sizeof(tr)))
                return -EFAULT;
            ptr += sizeof(tr);
            // 核心:创建并发送事务
            binder_transaction(proc, thread, &tr, cmd == BC_REPLY);
            break;
        }
        case BC_FREE_BUFFER: {
            binder_uintptr_t data_ptr;
            // 读取要释放的缓冲区地址
            if (get_user(data_ptr, (binder_uintptr_t __user *)ptr))
                return -EFAULT;
            ptr += sizeof(binder_uintptr_t);
            // 释放内核缓冲区
            binder_free_buffer(proc, data_ptr);
            break;
        }
        // ... 其他指令处理
        }
        // 更新已消耗的字节数
        *consumed = ptr - buffer;
    }
    return 0;
}

这里有个容易踩坑的地方:用户空间传入的指令序列必须严格对齐。每个指令的参数长度是固定的,如果用户空间传入了非法指令或者参数长度不对,驱动会直接返回 -EINVAL。我曾经在调试一个三方应用时,发现它构造的 BC_TRANSACTION 指令后面跟的参数长度少了 4 个字节,结果 ioctl 一直返回错误,应用反复重试导致卡死。

27.4 事务创建:binder_transaction() 的完整链路

当指令是 BC_TRANSACTIONBC_REPLY 时,驱动会调用 binder_transaction()。这个函数是整个 Binder IPC 最复杂、最核心的部分。我画了一张流程图帮你理清思路:

binder_transaction() 核心流程 binder_transaction() 入口 根据 handle 查找 target_node binder_alloc_new_buf() 分配缓冲区 copy_from_user() 拷贝数据到内核 将事务挂到 target->todo 或 target_proc->todo wake_up_interruptible() 唤醒目标

流程看起来不复杂,但每个步骤背后都有大量细节。我挑几个关键点说说:

27.5 关键细节:缓冲区分配与数据拷贝

缓冲区分配binder_transaction() 里最容易出问题的地方。驱动会调用 binder_alloc_new_buf() 在目标进程的内核映射区分配一块内存。这块内存的大小等于用户空间传入的数据大小加上 Binder 事务头部的开销。

分配策略是这样的:

  • 优先从目标进程的 空闲缓冲区列表 中查找大小合适的块
  • 如果找不到,就从 预分配的内核映射区 中切一块出来
  • 如果映射区也不够,就返回 -ENOSPC

我记得在 Android 8.0 之前,Binder 的缓冲区大小上限是 1MB。那时候有些应用会一次性传输大图片,经常触发 -ENOSPC 错误。后来 Google 把上限调整到了 4MB,但说实话,传输超过 1MB 的数据还是建议用共享内存或者文件描述符传递,别硬塞 Binder。

数据拷贝 用的是 copy_from_user()。这里有个性能优化的点:Binder 会把 binder_transaction_data 中的 data.ptr.bufferdata.ptr.offsets 指向的 扁平数据偏移数组 分别拷贝到内核缓冲区。扁平数据里可能包含 Binder 对象(flat_binder_object),驱动在拷贝完成后会遍历偏移数组,逐个处理这些 Binder 对象——该增加引用的增加引用,该转换句柄的转换句柄。

个人经验:如果你在调试 Binder 传输大数据的性能问题,可以关注 copy_from_user() 的耗时。我曾经在某个项目里发现,传输 512KB 数据时,光拷贝就占了整个 ioctl 耗时的一半以上。后来我们改用 ashmem 共享内存来传递大块数据,性能提升了 3 倍。

27.6 事务挂载与唤醒

数据拷贝完成后,驱动会创建一个 binder_transaction 结构体,里面包含了事务的元信息(发起者、目标、cookie 等)。然后把这个事务挂到目标线程或目标进程的 todo 链表上。

挂载策略是这样的:

  • 如果目标线程正在等待事务(处于 BINDER_LOOPER_STATE_WAITING 状态),就直接挂到该线程的 todo
  • 如果目标线程不在等待,就挂到目标进程的 todo 上,由进程内的其他线程去取
  • 如果是 BC_REPLY(回复),则挂到发起线程的 todo

挂载完成后,调用 wake_up_interruptible() 唤醒目标线程。嗯,这里要注意,唤醒操作是在 不持有目标线程锁 的情况下进行的,这是为了避免死锁。

避坑指南:我曾经遇到过一个诡异的问题——某个服务端线程被唤醒后,读取事务时发现 todo 链表是空的。排查了半天才发现,是因为唤醒操作和事务挂载之间存在竞态条件。虽然驱动层已经用 spinlock 保护了 todo 链表,但唤醒操作在释放锁之后才执行,导致线程被唤醒后可能还没看到新挂载的事务。解决方案是在挂载事务后、释放锁之前调用 wake_up,确保唤醒时事务已经可见。

27.7 写入完成后的读取

binder_thread_write() 处理完所有指令后,控制权回到 binder_ioctl_write_read()。如果 read_size > 0,驱动会接着调用 binder_thread_read() 来读取回复或通知。

这里有个设计上的巧妙之处:同步调用时,写入和读取是在同一个 ioctl 调用中完成的。发起方先写入 BC_TRANSACTION,然后立即进入读取循环,等待目标线程的回复。整个过程不会发生两次系统调用,效率很高。

读取部分的细节我们下一章再细聊。今天先总结一下写入流程的核心要点:

  • BINDER_WRITE_READ 是 Binder IPC 的数据传输入口
  • 写入流程由 binder_thread_write() 处理,逐个解析 BC_XXX 指令
  • BC_TRANSACTION/BC_REPLY 会触发 binder_transaction(),完成缓冲区分配、数据拷贝、事务挂载和唤醒
  • 同步调用时,写入和读取在同一个 ioctl 中完成

好了,数据发送的流程就讲到这里。下一章我们聊聊数据读取——也就是目标线程怎么把事务从 todo 链表里取出来,然后通过 BR_XXX 指令返回给用户空间。


公众号:蓝海资料掘金营,微信deep3321