19、Binder 的 fd 传递:如何在进程间通过 Binder 传递文件描述符?

文件描述符(fd)的传递,是 Binder 机制里一个非常实用但又容易踩坑的功能。说白了,就是让一个进程把自己的某个打开的文件句柄“借”给另一个进程用。你想想看,两个进程本来是完全隔离的,但通过 Binder,它们能共享同一个内核文件对象。

我当年第一次接触这个功能时,心里直犯嘀咕:“这不就相当于把钥匙直接给别人了吗?安全吗?”后来深入研究了内核源码,才明白这里面的门道。今天我就带你彻底搞懂它。

为什么需要传递 fd?

先说说实际场景。我在做多媒体系统时,经常遇到这样的需求:

  • 服务端打开了一个视频文件,想让客户端直接读取
  • 服务端创建了一个匿名管道,需要把读端传给子进程
  • 服务端打开了一个设备节点,希望客户端能直接操作

如果不用 fd 传递,你就得把文件内容全部拷贝过去。对于大文件,这效率太低了。fd 传递的本质,是让两个进程的 fd 表指向同一个内核文件结构体。

核心要点:Binder 传递的不是 fd 这个数字,而是它背后指向的内核文件对象。接收方拿到的是一个全新的 fd 数字,但指向的是同一个文件。

传递的底层原理

嗯,这里要稍微深入一下内核了。Binder 驱动在处理 fd 传递时,会做这么几件事:

  1. 序列化阶段:发送方在 Binder 数据中标记一个特殊的 BINDER_TYPE_FD 类型
  2. 内核处理:Binder 驱动在目标进程的 fd 表中找到一个空闲位置
  3. 安装阶段:驱动调用 get_file() 增加文件引用计数,然后把新 fd 安装到目标进程
  4. 反序列化:接收方从 Binder 数据中读出这个新 fd 数字

我曾经在调试一个 bug 时,发现接收方拿到的 fd 总是比发送方的大 1。后来才意识到,这是因为内核分配 fd 时默认取最小值,而发送方的 fd 刚好被占用了。这个细节让我排查了整整两天。

代码实战:如何传递 fd

在 Android 的 Java 层,传递 fd 主要通过 Parcel 类完成。来看一个完整的例子:

// 服务端:发送文件描述符
public class FdService extends IFileService.Stub {
    @Override
    public ParcelFileDescriptor openFile(String path) {
        try {
            File file = new File(path);
            FileInputStream fis = new FileInputStream(file);
            // 关键:通过 ParcelFileDescriptor 包装
            ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(fis.getFD());
            return pfd;
        } catch (Exception e) {
            return null;
        }
    }
}

// 客户端:接收文件描述符
public class FdClient {
    private IFileService service;
    
    public void readFile() {
        ParcelFileDescriptor pfd = service.openFile("/sdcard/test.txt");
        // 注意:这里拿到的 fd 和服务器端的不是同一个数字
        FileInputStream fis = new FileInputStream(pfd.getFileDescriptor());
        // 直接读取文件内容...
        // 使用完毕后必须关闭
        pfd.close();
    }
}

个人习惯:我建议你在传递 fd 时,始终使用 ParcelFileDescriptor 而不是直接操作 int 类型的 fd。因为 ParcelFileDescriptor 会自动处理 dup 和 close,避免资源泄漏。

传递过程中的关键数据结构

在 Native 层,Binder 驱动用 flat_binder_object 来描述传递的数据。当 type 为 BINDER_TYPE_FD 时,结构如下:

字段 含义
type BINDER_TYPE_FD (0x14)
flags 传递标志,通常为 0
binder 发送方的 fd 值
cookie 保留字段,未使用

驱动在处理时,会读取 binder 字段中的 fd 值,然后通过 task_fd_install() 安装到目标进程。这里有个关键点:驱动会检查发送方是否有权限访问这个 fd。如果发送方传了一个已经关闭的 fd,驱动会直接返回 -EBADF。

避坑指南:我踩过的那些坑

我曾经在做一个跨进程日志系统时,遇到了一个非常隐蔽的问题。服务端每次传递 fd 后都直接关闭了原始 fd,结果客户端读取时总是读到不完整的数据。排查了很久才发现原因:

  • 坑一:传递后不要关闭原始 fd,除非你确定接收方已经完成了操作
  • 坑二:接收方拿到的 fd 是独立的,关闭它不会影响发送方的原始 fd
  • 坑三:不要传递 stdin/stdout/stderr(0/1/2),这会导致奇怪的行为

特别注意:Binder 传递 fd 时,驱动会调用 get_file() 增加引用计数。这意味着即使发送方关闭了原始 fd,内核文件对象也不会被释放,直到接收方也关闭了它。但如果你在传递后立即关闭原始 fd,接收方可能还没来得及完成安装,就会拿到一个无效的 fd。

传递流程的完整示意图

下面这张图展示了 fd 从发送方到接收方的完整旅程。我特意把内核态和用户态的分界画了出来,方便你理解:

用户态 内核态 发送方进程 fd = 5 文件对象 A 接收方进程 fd = 12 文件对象 A Binder 驱动 1. 解析 BINDER_TYPE_FD 2. 分配新 fd 号 3. 安装到目标进程 BINDER_TYPE_FD 新 fd = 12 文件对象 A 引用计数 = 2

从图中你可以看到,发送方的 fd=5 和接收方的 fd=12 虽然数字不同,但都指向同一个内核文件对象 A。引用计数变成了 2,意味着任何一方关闭自己的 fd,都不会影响另一方的使用。

性能与安全考量

fd 传递虽然方便,但也不是没有代价的。我总结了几点:

  • 性能:每次传递都会触发一次 get_file()fd_install(),开销比普通数据传输大一些
  • 安全:接收方拿到 fd 后,可以执行任何文件操作,包括读写和修改文件偏移
  • 生命周期:如果发送方在传递后崩溃,接收方的 fd 仍然有效,因为内核文件对象的引用计数还在

我的建议:如果你只是需要传递少量数据,用普通的 Binder 数据传输就够了。fd 传递最适合那些需要直接操作文件描述符的场景,比如管道、socket、设备节点等。

好了,关于 Binder 的 fd 传递,核心内容就是这些。说白了,它就是内核帮你在两个进程的 fd 表之间搭了一座桥,让它们共享同一个文件对象。理解了这个本质,你就能避免很多使用上的坑。

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