19、Binder 的 fd 传递:如何在进程间通过 Binder 传递文件描述符?
文件描述符(fd)的传递,是 Binder 机制里一个非常实用但又容易踩坑的功能。说白了,就是让一个进程把自己的某个打开的文件句柄“借”给另一个进程用。你想想看,两个进程本来是完全隔离的,但通过 Binder,它们能共享同一个内核文件对象。
我当年第一次接触这个功能时,心里直犯嘀咕:“这不就相当于把钥匙直接给别人了吗?安全吗?”后来深入研究了内核源码,才明白这里面的门道。今天我就带你彻底搞懂它。
为什么需要传递 fd?
先说说实际场景。我在做多媒体系统时,经常遇到这样的需求:
- 服务端打开了一个视频文件,想让客户端直接读取
- 服务端创建了一个匿名管道,需要把读端传给子进程
- 服务端打开了一个设备节点,希望客户端能直接操作
如果不用 fd 传递,你就得把文件内容全部拷贝过去。对于大文件,这效率太低了。fd 传递的本质,是让两个进程的 fd 表指向同一个内核文件结构体。
核心要点:Binder 传递的不是 fd 这个数字,而是它背后指向的内核文件对象。接收方拿到的是一个全新的 fd 数字,但指向的是同一个文件。
传递的底层原理
嗯,这里要稍微深入一下内核了。Binder 驱动在处理 fd 传递时,会做这么几件事:
- 序列化阶段:发送方在 Binder 数据中标记一个特殊的 BINDER_TYPE_FD 类型
- 内核处理:Binder 驱动在目标进程的 fd 表中找到一个空闲位置
- 安装阶段:驱动调用
get_file()增加文件引用计数,然后把新 fd 安装到目标进程 - 反序列化:接收方从 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 和接收方的 fd=12 虽然数字不同,但都指向同一个内核文件对象 A。引用计数变成了 2,意味着任何一方关闭自己的 fd,都不会影响另一方的使用。
性能与安全考量
fd 传递虽然方便,但也不是没有代价的。我总结了几点:
- 性能:每次传递都会触发一次
get_file()和fd_install(),开销比普通数据传输大一些 - 安全:接收方拿到 fd 后,可以执行任何文件操作,包括读写和修改文件偏移
- 生命周期:如果发送方在传递后崩溃,接收方的 fd 仍然有效,因为内核文件对象的引用计数还在
我的建议:如果你只是需要传递少量数据,用普通的 Binder 数据传输就够了。fd 传递最适合那些需要直接操作文件描述符的场景,比如管道、socket、设备节点等。
好了,关于 Binder 的 fd 传递,核心内容就是这些。说白了,它就是内核帮你在两个进程的 fd 表之间搭了一座桥,让它们共享同一个文件对象。理解了这个本质,你就能避免很多使用上的坑。