引入大疆SDK后,Android 高版本 fd ≥ 1024 导致 FORTIFY 崩溃的 PLT Hook 修复实战

0 阅读40分钟

问题现场

背景

我们的 Android 应用引入了大疆(DJI)的 Mobile SDK V5 来与无人机和遥控器通信。但作为一个面向 C 端用户的商业产品,App 远不止"飞控"这一个功能模块——还集成了广告SDK、直播推流 SDK(RTMP/RTSP 推流需要长连接 socket)、以及三方组件。

这些 SDK 各自独立运行,各自管理自己的 socket、pipe、eventfd 等系统资源。在用户正常使用的过程中,进程持有的 fd 数量很容易就超过 1000 个——这在现代 Android 设备上完全正常,操作系统默认允许单进程打开 32768 个 fd。

实际上,这不是我们 App 的个例。用 adb shell ls /proc/<pid>/fd | wc -l 观察一下手机里的各种大型 App(微信、抖音、淘宝等),你会发现它们在冷启动后没多久,fd 数量就轻松突破 1024。这是现代 Android App 的常态——各种 SDK 组件、WebView、多媒体管线、Binder IPC 都在大量消耗 fd。fd 编号超过 1024 不是异常,是标配。

问题就出在这个"完全正常"的状态下。

然后测试开始反馈app经常性崩溃。 native 崩溃报告。崩溃日志只有一行:

FORTIFY: FD_SET: file descriptor 1255 >= FD_SETSIZE 1024
Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 17247 (Thread-6), pid 17071

进程直接被 kill。没有 Java 层异常,没有 ANR,就是一个干脆利落的 SIGABRT

崩溃栈指向 bionic libc 的内部函数 __fortify_fatal,看不出是哪个业务模块触发的。App 里有不少SDK,每个都有自己的 native .so 文件,排查范围非常大。

最简复现

这个崩溃并不需要复杂的操作序列来触发——事实上,在当前绝大部分 Android 手机上都能用官方 Demo 稳定复现。

只需要 clone DJI Mobile SDK V5 官方仓库,在 DJIAircraftApplicationattachBaseContext 中,SDK 初始化之前,先把进程的 fd 编号"推高"到 1024 附近:

private fun createSockets() {
    val sockets = mutableListOf<ServerSocket>()
    val totalFd = 888
    try {
        for (i in 0 until totalFd) {
            val serverSocket = ServerSocket(0)  // 动态分配端口
            sockets.add(serverSocket)
        }
        Log.i(TAG, "成功创建 ${totalFd} 个 Socket,fd 编号已超过 1024")
    } catch (e: Exception) {
        Log.e(TAG, "createSockets error", e)
    }
}
override fun attachBaseContext(base: Context?) {
    super.attachBaseContext(base)
    createSockets()  // 先把 fd 编号推高
    // SDK 初始化...
}

原理很简单:Android 进程启动时,系统已经占用了数十个低编号 fd(stdin/stdout/stderr、Binder、Zygote 遗留的 socket 等)。再创建 888 个 ServerSocket,每个消耗一个 fd,加上系统已有的 fd,总编号轻松超过 1024。之后 SDK 初始化时创建的任何新 socket,fd 编号必然 ≥ 1024,FD_SET 立即触发 FORTIFY abort。

在生产环境中不需要这个"加速器"——当 App 长时间运行后,fd 编号会自然增长到 1024 以上。这个复现方法只是让问题在几秒内必现,方便问题是否已修复。

定位:从怀疑 fd 泄漏到确认 API 缺陷

第一直觉:是不是 fd 泄漏了?

看到 "file descriptor 1255 >= FD_SETSIZE 1024" 这条日志,我的第一反应和网上大多数文章一样——fd 数量是不是太多了?是不是哪里没有关闭资源?

按照常规思路排查了一轮:adb shell ls -l /proc/<pid>/fd | wc -l 看 fd 数量,确实有 1200+;StrictMode.detectLeakedClosableObjects() 检查 Java 层泄漏,修了一些可能泄露的地方。但 fd 数量降了一点点,离 1024 还差得远,崩溃照旧。

这时候我意识到,网上那些文章的隐含假设——"fd ≥ 1024 说明 fd 泄漏"——在集成了大量个 .so 的大型 SDK App 中可能并不成立。光是 .so 文件加载、Binder 通信、图传 socket、遥控器通信就已经合理地消耗了几百个fd。这不是泄漏,是正常业务需求。

一行 fatal log 的困境

可问题更棘手的是:FORTIFY 触发 abort() 时,logcat 只输出一行致命日志:

FORTIFY: FD_SET: file descriptor 1255 >= FD_SETSIZE 1024

然后进程就死了。没有 Java 异常栈,没有 native backtrace。tombstone 文件中虽然有崩溃时的寄存器状态和栈回溯,但指向的是 bionic 的 __fortify_fatalabort,而不是真正发起 FD_SET 调用的业务代码。

我知道是 fd 编号太大导致的崩溃,但不知道是谁在用这么大的 fd 调用 select。大量的.so 中的任何一个都可能是罪魁祸首。

从 AOSP 源码到自定义 bionic

既然 logcat 给不了答案,我决定从源头下手——去 AOSP 的 bionic 源码里看 FD_SET 的实现,搞清楚 FORTIFY 的检测逻辑,然后直接修改 bionic 来注入诊断日志

具体修改涉及以下几个文件(下面的代码在不同的系统版本可能存在位置差异,我是基于Android 12进行修改):

我在 __check_fd_set 函数执行 abort 之前,添加了堆栈打印逻辑,记录触发 FORTIFY 时的完整调用栈和 fd 编号。同时,FD_SETSIZE 从 1024 改为 711——这样 fd 编号只要超过 711 就会触发诊断日志,不用等到 fd 自然增长到 1024,大幅加速了问题复现。

定位到真凶

修改后的 bionic 刷入设备,重新运行 App,终于捕获到了完整的调用链:

10:28:13.258 30792 32732 D bionic_select: FD 858 points to: socket:[502435]

10:28:14.261 30792 32732 D bionic_select: select called by PID=30792, TID=32732, nfds=916

10:28:16.498 30792 32732 D NativeCrash_FormatPcAddress:
  [at libdjisdk_jni.so: dji::core::TcpSocketForCmd::TryConnectServer(
       std::__ndk1::shared_ptr) + 0x484]

  [at libdjisdk_jni.so: dji::core::TcpServicePort::Impl::TryConnectServer()
       + 0x90]

  [at libdjibase.so: Dji::Common::Worker::TimerCallback(int, short, void*)
       + 0x248]

  [at libdjibase.so: event_base_loop + 0x4d8]

调用链清晰地揭示了问题:

  1. libdjibase.so 中的 event_base_loop(libevent 事件循环)触发了一个定时器回调
  2. 回调进入 Dji::Common::Worker::TimerCallback,这是 DJI SDK 的工作线程定时器
  3. 定时器触发了 TcpServicePort::Impl::TryConnectServer——SDK 在尝试建立 TCP 连接
  4. 连接过程中调用了 TcpSocketForCmd::TryConnectServer,这里使用了 select() 来等待 socket 连接就绪
  5. 此时 socket 的 fd 编号为 858(在降低了 FD_SETSIZE=711 的环境下触发,实际生产环境中 fd=1255),selectnfds=916

罪魁祸首找到了:libdjisdk_jni.so 中的 TcpSocketForCmd::TryConnectServer 函数在 TCP 连接过程中使用了 select() 做连接超时检测。当 socket fd 编号 ≥ 1024 时,FD_SET 触发 FORTIFY abort。

不是泄漏,是 API 选择错误

我把完整的调用栈、AOSP 源码分析流程、bionic 修改方案、以及对 select API 限制的详细说明一并提交给了 DJI 技术支持。

然而,与 SDK 提供方的沟通过程远比技术分析本身更加曲折。

第一轮反馈:方向偏差

尽管我提供了完整的 AOSP 源码链接、bionic 修改 diff、以及精确到函数级别的调用栈,DJI 开发组最初的回复仍然偏离了问题本质。他们的反馈大致包含以下几点:

  1. MSDK 的 fd(句柄)不是无限增长的
  2. MSDK 在 DJI 遥控器上,从来没碰到过类似问题
  3. 查看了常见的市面上的手机,例如 OPPO Find X6,系统限制单进程 fd 上限远大于 1024
  4. 1024 的限制是不是有点太小了,需要描述下设备情况或者更换设备

这四条回复反映出一个典型的认知偏差——把 "fd 编号 ≥ 1024" 等同于 "fd 数量太多"

逐条分析他们为什么错了:

  • "fd 不是无限增长的":没人说 fd 在无限增长。1200 个 fd 对于一个集成了广告、直播、无人机通信的大型 App 来说是正常水位,根本不算"多"。RLIMIT_NOFILE 是 32768,才用了不到 4%。

  • "DJI 遥控器上没碰到过":DJI 遥控器是专用设备,只运行 DJI 自己的 App,没有广告 SDK、直播 SDK 等额外组件。fd 数量少,编号自然不会超过 1024。这恰好说明问题不在 SDK 本身的 fd 管理,而在于 SDK 使用的 API 对 fd 编号有硬编码上限。当 SDK 被集成到一个复杂的商业 App 中(这才是 Mobile SDK 的正常使用场景),问题就会暴露。

  • "手机的 fd 上限远大于 1024":对,正是因为手机允许打开远超 1024 个 fd,fd 编号才会增长到 1024 以上。RLIMIT_NOFILE=32768 说明系统认为进程持有这么多 fd 是合法的——是 select()FD_SETSIZE 硬编码跟不上时代了,不是系统的锅。

  • "1024 的限制是不是太小了"FD_SETSIZE=1024 是 Linux 内核和 bionic 双重硬编码的,不是设备配置,不是可以调大的参数。这不是"限制太小"的问题,而是 select 这个 API 在设计上就不支持大 fd 编号

第二轮沟通:重新阐述问题本质

在第一轮反馈后,我重新组织了说明,重点强调:

  1. 问题不是 fd 数量,而是 fd 编号。进程 fd 数量远未到达 RLIMIT_NOFILE 上限,崩溃发生时只有 1200 多个 fd。
  2. select()FD_SETSIZE=1024 是一个不可修改的硬编码限制,存在于 bionic libc 和 Linux 内核两个层面。
  3. TcpSocketForCmd::TryConnectServer 中使用了 select(),这是调用栈中明确可见的。当 socket fd ≥ 1024 时,FD_SET 必然触发 FORTIFY abort。
  4. Linux 手册man select)明确建议在可能出现大 fd 的场景中使用 poll()epoll() 替代。
  5. Google 自己在 AOSP 中也修复过同样的问题——system/netdndc.cpp将 select 替换为 poll

这次,DJI 开发组终于回复:研发目前知道问题了。但因为涉及底层,解决时间目前未定。

现状:问题依然存在

从最初定位问题并提交给 DJI,到他们回复"排期不确定",至今已经过去了一年多。DJI Mobile SDK V5 的最新版本仍然存在这个问题。

这段沟通经历本身也说明了一个普遍现象:"fd ≥ 1024 → fd 泄漏"这个思维定式根深蒂固,不仅是网上的技术博客这么写,连 SDK 提供方的开发团队第一反应也是这个方向。后面的「两个容易混淆的限制」章节会详细拆解这个误区。

问题的根本结论是:

问题的根本原因不是 fd 的数量超过了 1024,因为当前绝大部分设备的 fd 限制是 32768。而在 app 崩溃时,远远没达到这个数。崩溃的原因是,当 fd 的值超过了 1024 时,JNI 层面使用了 linux 的 select 做 IO 多路复用检测就一定会崩溃。

不是资源泄漏,而是 SDK 在 native 层使用了 select() 这个有 1024 编号限制的 API

但问题存在不代表业务可以停滞,等不起 SDK 的修复周期。既然 SDK 的修复遥遥无期,我们只能自己动手——在不修改 SDK 二进制代码的前提下,用应用层的 PLT Hook透明地将 select 替换为 poll。这就是本文剩余部分要讲的方案。


两个容易混淆的限制

很多文章把"fd 数量超过 1024"和"fd 编号超过 1024"混为一谈,但它们是两个完全独立的机制:

限制名称默认值控制什么
RLIMIT\_NOFILE进程 fd 数量上限Android 9+ 通常 32768进程最多能打开多少个 fd
FD\_SETSIZEselect 位图宽度硬编码 1024,不可修改select() 能处理的 fd 编号范围

一个进程如果打开了很多文件/socket,fd 编号会从 0 往上递增分配。当编号到达 1024 时,RLIMIT_NOFILE 不会限制你(现代 Android 的默认值远大于 1024),但 select() 相关的 API 就出问题了。

网上常见的错误"修复"方案

搜索 "FD_SET file descriptor >= FD_SETSIZE" 这个错误信息,会找到大量文章和 StackOverflow 回答,但其中很多方案在 Android Native SDK 场景下是错误的或不完整的。逐个分析:

❌ 方案一:重定义 FD_SETSIZE 宏

// 很多文章推荐的做法
#define FD_SETSIZE 4096
#include <sys/select.h>

为什么不行?

这是服务端 Linux 编程中一个广为流传的"技巧"。在 glibc 的某些版本中,fd_set 的大小确实依赖于编译时的 FD_SETSIZE 宏——如果你在 #include 之前重定义它,sizeof(fd_set) 会变大,位图能容纳更多 fd。

但在 Android 上这个方案有三重失败

  1. bionic 的 FD_SETSIZE 是硬编码的bionic/libc/kernel/uapi/linux/posix_types.h#define __FD_SETSIZE 1024 不受用户宏影响。你的重定义只能改自己编译的代码,改不了 bionic libc 和 SDK 的 .so。

  2. FORTIFY 检查用的是 __builtin_object_size__FD_SET_chk 的第三个参数是编译器在调用方编译时计算的。SDK 的 .so 早就编译好了,里面的 __bos 值已经固化为 128,不管你怎么重定义宏,SDK 调用 FD_SET 时传给 __FD_SET_chk 的 size 永远是 128。

  3. Linux 内核不认你的宏。内核 core_sys_select()__FD_SETSIZE 是内核自己的常量(1024),和用户空间毫无关系。即使用户空间的 fd_set 物理上扩大了,系统调用依然会拒绝 nfds > 1024。

结论:这个方案在自己的代码中管用(如果只针对自己调 select),但对第三方 SDK 的预编译 .so 完全无效,而且也无法突破内核限制。

❌ 方案二:setrlimit 提高 RLIMIT_NOFILE

struct rlimit rl;
rl.rlim_cur = 65536;
rl.rlim_max = 65536;
setrlimit(RLIMIT_NOFILE, &rl);

为什么不行?

这是对问题的根本性误解。RLIMIT_NOFILE 控制的是进程能打开的 fd 总数,不是 select 能处理的 fd 编号范围

实际场景中,用户的设备 RLIMIT_NOFILE 已经是 32768。fd 编号已经涨到了 1255——恰恰说明系统允许打开这么多 fd。问题不是"打开 fd 被拒绝",而是"fd 编号超出了 select 的处理能力"。这两个是完全不同的限制。

提高 RLIMIT_NOFILE 甚至可能让问题更容易出现——更大的 limit 意味着进程能打开更多 fd,fd 编号更容易涨到 1024 以上。

结论:方向完全错误。就像汽车过不了限高 2 米的桥,你把车载重量提高到 10 吨也没用——限制的维度就不一样。

❌ 方案三:关闭不用的 fd 来保持编号较低

// 有人建议定期清理未关闭的 fd
ParcelFileDescriptor.close();
cursor.close();

为什么不行?

首先,fd 编号的分配规则是"最小可用编号"。如果你关闭了 fd=500,下一次 open() 会复用 500 这个编号。所以理论上,只要不同时持有超过 1024 个 fd,编号就不会超过 1024。

但在一个集成了大疆 SDK、包含大量so文件的大型 App 中,这是不现实的:

  • 你不控制 SDK 的 fd 生命周期。SDK 内部会创建多少 socket、pipe、eventfd,何时创建何时关闭,完全是黑盒。
  • Android 系统本身就占用大量 fd。每个加载的 .so 文件占一个 fd,大量SDK .so + 系统 .so 轻松消耗 100+ fd。Binder 通信、GraphicBuffer、ART 虚拟机内部的 fd 更是不计其数。
  • 即使你的代码零泄漏,SDK 或系统的 fd 累积也可能推高编号。在设备长时间运行、多次连接/断开遥控器和无人机后,fd 编号涨到 1255 是完全正常的。

结论:这是个良好的编程习惯,但不是修复手段。对第三方 SDK 的内部资源管理无能为力。

❌ 方案四:排查 fd 泄漏(最常见的误诊)

搜索 "FD_SET file descriptor >= FD_SETSIZE" 这个错误信息,你会发现绝大多数文章得出的结论是:这是 fd 泄漏导致的,需要排查哪里没有关闭资源。

前面「定位」章节中已经提到,这也是我最初的直觉。典型的排查流程是这样的(几乎每篇文章都是这个套路):

# 第一步:查看进程 fd 数量
adb shell ls -l /proc/<pid>/fd | wc -l

# 第二步:查看 fd 指向了什么
adb shell ls -l /proc/<pid>/fd
# 输出一堆 socket:[12345]、pipe:[67890]、/dev/ashmem 之类的

# 第三步:开 StrictMode 检测
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
    .detectLeakedClosableObjects()
    .penaltyLog()
    .build())

# 第四步:定位泄漏代码,逐个修复 close()

掘金上的《Android FD 文件描述符泄露总结简述》、CSDN 上的《Android FD 文件描述符泄露总结》、《记一次 FileDescriptor 泄漏造成的 Crash》、代码先锋网的《Android 句柄泄漏排查》——这些文章无一例外都把 FD_SET >= FD_SETSIZE 等同于"fd 泄漏",然后花大量篇幅讲解如何排查 Cursor 没关、Socket 没关、HandlerThread 没 quit。

这个思路在某些场景下没错,但在我们的场景下是根本性的误诊。

为什么?因为 fd 编号高 ≠ fd 泄漏。让我们算一笔账:

  • Android 系统自身的 .so(libc、libm、libandroid_runtime 等)→ 50+
  • Binder 通信的 fd(/dev/binder/dev/hwbinder)→ 5~10
  • GraphicBuffer、SurfaceFlinger 相关的 fd → 10~20
  • ART 虚拟机的内部 fd(boot.art、boot.oat、dex 文件)→ 20+
  • App 自身的文件和数据库 → 若干
  • SDK 正常运行创建的 socket(遥控器通信、图传、遥测、OTA 检查等)→ 数百

这些 fd 全是正常使用,没有任何一个是"泄漏"。但它们加起来轻松超过 1000 个,fd 编号自然就会分配到 1024 以上。

实际验证:在我们的设备上,/proc/<pid>/fd 里确实有 1200+ 个 fd,而 RLIMIT_NOFILE 是 32768。从操作系统的角度看,这个进程很健康——它使用了 1200/32768 的资源配额,远未到达上限。问题不在于 fd 太多,而在于 select() 这个 API 天生只能处理编号 < 1024 的 fd。

这就是"fd 数量"和"fd 编号"混淆带来的误导。那些文章的潜在假设是:正常的 App 不应该打开超过 1024 个 fd,所以 fd 编号 ≥ 1024 就一定是泄漏。这个假设在简单 App 上可能成立,但在集成了大型 Native SDK 的应用中完全不成立。

如果你按照这些文章的思路去"修复",会发生什么?

你会花几天时间排查 fd 泄漏,可能找到并修复了几个 Cursor 没关的地方,fd 数量从 1250 降到了 1180。但 1180 仍然 > 1024,崩溃照旧。你继续排查,发现剩下的 fd 全是 SDK 内部创建的 socket 和系统 fd——这些你关不掉,也不应该关。最终卡死在这里。

即使你通过某种极端手段把 fd 数量压到 1024 以下,这也只是一个脆弱的平衡——设备多连接几次、多飞几次,fd 编号随时可能再次越过 1024。这不是修复,是在赌运气。

结论:排查 fd 泄漏是良好的工程实践,但把 FD_SET >= FD_SETSIZE 等价于 fd 泄漏是错误的。问题的本质不是"资源没关",而是"select 这个 30 年前设计的 API 有一个今天不可接受的硬编码限制"。正确的方向是替换 API,而不是削足适履。

✅ 正确的方向:透明替换 select 为 poll

正确的方案需要同时满足两个条件:

  1. fd ≥ 1024 不能被丢弃,必须被正确监听。
  2. IO 多路复用函数不能是 select,因为内核硬编码了 1024 的限制。

唯一的出路是:拦截整条调用链(FD_SET → select → FD_ISSET),将 fd 信息存入 shadow buffer,在 select 调用点将所有 fd 转换为 pollfd 数组交给 poll(),再将结果写回 shadow 供 FD_ISSET 读取。这就是本文方案的核心思路,后续章节会详细展开。

FORTIFY 崩溃的原因

要理解这个崩溃,得先搞清楚 FORTIFY 是什么。

FORTIFY_SOURCE 机制

FORTIFY_SOURCE 是 GCC/Clang 和 glibc/bionic 配合实现的一套编译期+运行期安全加固方案。它的做法是:在编译阶段,对一批容易出现缓冲区溢出的 C 标准库函数(memcpystrcpyFD_SET 等),静默替换成带 _chk 后缀的安全版本。这些安全版本额外接收一个 size 参数(表示目标缓冲区的实际大小),在运行时检查写入是否越界。

FD_SET 来说,它在 C 标准中只是一个宏,展开后直接操作 fd_set 结构体内部的位数组。开启 FORTIFY 后,bionic 会把它替换成一个函数调用:

// 开发者写的代码:
FD_SET(fd, &readfds);

// 编译器实际生成的代码(-D_FORTIFY_SOURCE=2 时):
__FD_SET_chk(fd, &readfds, __bos(&readfds));
//                          ↑ __builtin_object_size,编译期求出 readfds 的内存大小

__bos(&readfds) 由编译器在编译时计算出 readfds 这个变量占多少字节。对于栈上声明的 fd_set readfds;,结果就是 sizeof(fd_set) = 128 字节(1024 / 8)。

__FD_SET_chk 内部逻辑

bionic libc 的 __FD_SET_chk 实现(简化后的核心逻辑):

void __FD_SET_chk(int fd, fd_set *set, size_t set_size) {
    // 检查 1:fd 编号不能超过 FD_SETSIZE(硬编码 1024)
    if (fd < 0 || fd >= FD_SETSIZE) {
        __fortify_fatal("FD_SET: file descriptor %d >= FD_SETSIZE %d",
                        fd, FD_SETSIZE);
        // __fortify_fatal 内部调用 abort() → 产生 SIGABRT → 进程终止
        // 没有异常,没有 try-catch 的机会,Java 层完全无法捕获
    }

    // 检查 2:写入位置不能超过 fd_set 的实际内存大小
    if (((size_t)fd) / 8 >= set_size) {
        __fortify_fatal("FD_SET: buffer overflow detected");
    }

    // 通过检查后,正常设置位图
    ((unsigned long *)set)[fd / NFDBITS] |= (1UL << (fd % NFDBITS));
}

这里有两层保护。第一层检查 fd 是否超过 FD_SETSIZE,这个值在内核头文件中硬编码为 1024,没有任何用户空间的手段可以修改它。第二层检查写入偏移是否超过 fd_set 结构体的实际内存大小,防止堆/栈缓冲区溢出。

崩溃发生的完整链路

回到我们的场景。当进程已经打开了大量的 socket 和文件,fd 编号被内核分配到了 1255。我们推测 SDK 的某个 .so 在做网络通信时,执行了如下调用链:

SDK 内部的 C 代码(我们看不到、改不了):
  fd_set readfds;
  FD_ZERO(&readfds);
  FD_SET(sock_fd, &readfds);          // sock_fd = 1255
  ↓
编译期 FORTIFY 替换:
  __FD_SET_chk(1255, &readfds, 128);  // 128 = sizeof(fd_set)
  ↓
运行时 bionic 检查:
  1255 >= FD_SETSIZE(1024)?  → YES
  ↓
  __fortify_fatal("FD_SET: file descriptor 1255 >= FD_SETSIZE 1024")
  ↓
  abort()  →  SIGABRT  →  进程死亡

这就是 logcat 中看到的那条日志。Fatal signal 6 (SIGABRT) 中的 signal 6 就是 SIGABRT,由 abort() 产生。它不是段错误(SIGSEGV),不是非法指令(SIGILL),而是程序主动调用 abort() 自杀——bionic 认为这是一个"不应该发生"的状况,与其让程序继续执行可能导致内存越界写入,不如直接终止。

即使绕过 FORTIFY 也不够

假设我们用某种方式绕过了 FORTIFY 检查,让 FD_SET(1255, &readfds) 不崩溃。问题解决了吗?没有。

fd_set 只有 128 字节(1024 bit),FD_SET(1255, ...) 实际上是在往第 1255 / 8 = 156 字节的位置写入。但 fd_set 只有 128 字节——这是一次实打实的栈缓冲区溢出,会覆盖栈上相邻的局部变量或返回地址,后果不可预测(可能是数据错乱,可能是跳转到随机地址崩溃,也可能表面上"正常"但数据已经被污染)。

即使侥幸活过了 FD_SET,下一步调用 select(1256, &readfds, ...) 时,Linux 内核的 core_sys_select() 实现中有这么一行检查:

// linux/fs/select.c
int core_sys_select(int n, fd_set __user *inp, ...)
{
    ...
    if (n > __FD_SETSIZE)     // __FD_SETSIZE = 1024
        return -EINVAL;       // 直接拒绝
    ...
}

内核直接返回 -EINVAL(参数无效),select() 调用失败,errno 被设置为 EINVAL。这是内核层面的硬编码,不管用户空间怎么折腾(扩大 fd_set 内存、修改头文件中的 FD_SETSIZE 宏),只要系统调用层的 nfds > 1024,内核都会拒绝。

所以这个问题没有 workaround,只有一条路:不用 select,换一个没有 1024 限制的 IO 多路复用函数

为什么用 poll() 替换

poll() 使用线性数组 struct pollfd[] 而不是固定大小的位图,fd 编号可以是任意非负整数。从内核实现来看,selectpoll 底层都是遍历 fd 列表、逐个调用驱动的 poll 回调,核心逻辑几乎一样,区别仅在于数据结构。

poll() 替换 select() 是语义等价的——传入一组 fd,阻塞等待其中任意一个就绪,返回就绪的数量。只是突破了 1024 的限制。

至于 epoll,虽然性能更好(事件驱动,不需要每次传入完整列表),但它是一个有状态的三步式 API(create → ctl → wait),和 select 的无状态一次性调用模型不兼容,在 PLT Hook 场景下无法做到 1:1 替换。

技术方案总览

问题的两层屏障

从前面的分析可以看出,要让 fd ≥ 1024 的 IO 多路复用正常工作,需要同时突破两层屏障:

屏障 1:FORTIFY 的 abort。SDK 调用 FD_SET(1255, &readfds) 时,bionic 的 __FD_SET_chk 检测到 fd ≥ 1024 就直接 abort。程序还没走到 select 就已经死了。

屏障 2:select 的内核限制。即使绕过了 FORTIFY,select(1256, ...) 在内核层面也会被拒绝。

两层屏障需要两套对策:

  1. 拦截 FORTIFY 函数__FD_SET_chk / __FD_CLR_chk / __FD_ISSET_chk):当 fd ≥ 1024 时,不触发 abort,而是将 fd 存入一个线程本地的 shadow buffer。这解决了屏障 1——程序不会再崩。

  2. 拦截 select / pselect:检测到 shadow 中有大 fd 时,将原始 fd_set 位图和 shadow 合并,转换为 pollfd[] 数组,调用 poll() 替代。这解决了屏障 2——IO 多路复用不再受 1024 限制。

为什么需要 5 个 Hook 点

完整的 Hook 点有 5 个,每个都不可省略:

Hook 目标类型作用不 Hook 的后果
__FD_SET_chkFORTIFY 函数fd ≥ 1024 时存入 shadow,避免 abortSIGABRT 崩溃(当前的问题)
__FD_CLR_chkFORTIFY 函数fd ≥ 1024 时从 shadow 清除SDK 无法取消对某个大 fd 的监听
__FD_ISSET_chkFORTIFY 函数fd ≥ 1024 时从 shadow output 读取结果SDK 调用 FD\_ISSET 检查结果时崩溃
select系统调用包装合并 fd\_set + shadow → poll()内核返回 EINVAL,select 失败
pselect系统调用包装同上,额外处理信号掩码使用 pselect 的代码路径未覆盖

pselectselect 的功能几乎一样,区别是 pselect 的超时参数精度更高(纳秒 vs 微秒),并且多了一个原子性的信号掩码操作。我们不清楚 SDK 的 so 中哪些用了 select,哪些用了 pselect,所以两个都要 Hook。成本几乎为零。

调用链路全景

以 SDK 监听 fd=1255 可读事件为例,Hook 安装后的完整调用链路:

SDK 线程:

  ① FD_ZERO(&readfds)
     → 正常清零 128 字节的位图,不涉及 Hook

  ② FD_SET(3, &readfds)
     → my_FD_SET_chk_proxy(3, &readfds, 128)
       3 < 1024 → 快速路径 → CALL_PREV → 原始 __FD_SET_chk
       → 正常设置位图第 3 位

  ③ FD_SET(1255, &readfds)
     → my_FD_SET_chk_proxy(1255, &readfds, 128)
       1255 >= 1024 → 不调原始函数(避免 abort)
       → shadow_find_or_create(&readfds)
       → shadow.input[1255] = 1, max_fd = 1255
       → 返回(程序继续运行,不崩溃)

  ④ select(1256, &readfds, NULL, NULL, &timeout)
     → my_select_proxy(1256, &readfds, ...)
       shadow_find(&readfds) → 找到,max_fd=1255
       has_large = true → 走 poll 路径
       → do_poll_with_shadow(1256, &readfds, ...)
         循环 1:扫描原始位图 [0, 1024),提取 fd=3
         循环 2:扫描 shadow [1024, 1256),提取 fd=1255
         构建 pollfd[] = [{fd=3, POLLIN}, {fd=1255, POLLIN}]
         调用 poll(pollfd, 2, timeout_ms) ← 无 fd 编号限制
         假设 fd=1255 就绪:
           原始位图清零,shadow.output 清零
           fd=3 未就绪 → 不写
           fd=1255 就绪 → shadow.output[1255] = 1
         清空 shadow.input
         返回 ready_count = 1FD_ISSET(1255, &readfds)
     → my_FD_ISSET_chk_proxy(1255, &readfds, 128)
       1255 >= 1024 → 不调原始函数
       → shadow_find(&readfds) → 找到
       → shadow.output[1255] = 1 → 返回 1(可读)

  ⑥ SDK 认为 fd=1255 可读,正常执行 recv()/read()
     → 业务正常运转

整个过程中,SDK 的代码完全不知道底层发生了替换。从 SDK 的角度看,FD_SET 正常返回了,select 正常返回了,FD_ISSET 也给出了正确的结果。这就是透明 Hook 的价值。

Shadow Buffer 的设计

Shadow buffer 是整个方案的核心数据结构。它存在的原因很简单:原始的 fd_set 结构体只有 128 字节(1024 bit),物理上不可能存储 fd ≥ 1024 的信息。我们不能修改 SDK 传入的 fd_set(它是栈上分配的,大小固定),所以需要在"旁边"维护一个扩展存储。

数据结构

typedef struct {
    const void     *owner;                   // 关联的 fd_set 指针地址
    unsigned long   input[SHADOW_NLONG];     // FD_SET 阶段写入(select 之前)
    unsigned long   output[SHADOW_NLONG];    // select 结果(给 FD_ISSET 读)
    int             max_fd;                  // input 中最大的 fd 编号
} shadow_entry_t;

typedef struct {
    shadow_entry_t entries[MAX_SHADOW_ENTRIES];  // 最多 8 个 shadow 条目
    int            count;                        // 当前使用的条目数
} thread_shadow_t;

static __thread thread_shadow_t *tls_shadow = NULL;

这里有几个需要解释的设计决策。

为什么用线程本地存储

static __thread 是 C 语言的线程本地存储(TLS)声明。每个线程有自己独立的 tls_shadow 指针,互不干扰。

为什么不用全局变量 + 锁?因为 select 的使用模式决定了不需要。一个 fd_set 变量从 FD_ZERO → FD_SET → select → FD_ISSET 的整个生命周期,一定是在同一个线程内顺序执行的。不会出现线程 A 调 FD_SET、线程 B 调 select 的情况——那样本身就是并发 bug。所以线程本地存储是天然匹配这个场景的方案:零锁开销,天然线程安全。

TLS 变量第一次访问时为 NULL,get_thread_shadow() 会在首次使用时 calloc 分配:

static thread_shadow_t *get_thread_shadow(void) {
    if (__builtin_expect(tls_shadow != NULL, 1)) return tls_shadow;
    tls_shadow = (thread_shadow_t *)calloc(1, sizeof(thread_shadow_t));
    return tls_shadow;
}

__builtin_expect(ptr != NULL, 1) 提示编译器"大多数情况 tls_shadow 已经分配过了",优化分支预测。

为什么用 fd_set 指针地址做 key

一次 select() 调用最多传入 3 个不同的 fd_set:readfds、writefds、exceptfds。同一个 fd 可能同时出现在多个集合中。shadow 需要区分"fd=1255 的 POLLIN 事件是给 readfds 的"和"fd=1255 的 POLLOUT 事件是给 writefds 的"。

最直接的关联方式就是用 fd_set 变量的内存地址作为 key。当 SDK 调用 FD_SET(1255, &readfds) 时,我们拦截到的参数包括 fd_set 的指针 &readfds。用这个指针在 shadow 表中查找或创建对应的条目:

static shadow_entry_t *shadow_find(const void *set) {
    if (!tls_shadow || !set) return NULL;
    for (int i = 0; i < tls_shadow->count; i++)
        if (tls_shadow->entries[i].owner == set)
            return &tls_shadow->entries[i];
    return NULL;
}

线性查找看起来不高效,但 MAX_SHADOW_ENTRIES = 8,最多只有 3 个条目(readfds、writefds、exceptfds 各一个),实际就是 1~3 次指针比较,比哈希表的计算开销还小。

input 和 output 为什么分开

这和 select() 的语义有关。select() 是一个"输入-输出复用"的函数——调用前 fd_set 表示"我关心哪些 fd"(输入),调用后同一个 fd_set 被修改为"哪些 fd 已就绪"(输出)。调用前后的含义完全不同。

shadow 用 input 存储 FD_SET 阶段的"关注列表",用 output 存储 select 返回后的"就绪结果"。流程是:

FD_SET(1255, &readfds) → shadow.input[1255] = 1      // 输入:关注此 fd
select(...)            → 读 input,调 poll,写 output  // 转换
                          shadow.input 清零            // 消费完毕
FD_ISSET(1255, &readfds) → 读 shadow.output[1255]     // 输出:检查结果

如果不分开,FD_SET 和 FD_ISSET 读写的是同一个数组。在 select 完成后清零 input 时会把 output 也清掉,FD_ISSET 就读不到结果了。

生命周期管理

shadow 的 input 数据在每次 do_poll_with_shadow 返回前被清零(shd_zero(rs); shd_zero(ws); shd_zero(es);)。这是必须的,因为 SDK 的典型调用模式是循环:

while (running) {
    FD_ZERO(&fds);            // 清空位图
    FD_SET(fd_a, &fds);       // 重新设置关注的 fd(每轮可能不同)
    FD_SET(fd_b, &fds);
    select(max+1, &fds, ...); // 等待
    // 检查结果...
}

每轮循环 SDK 会重新 FD_ZERO + FD_SET,但 FD_ZERO 只清空原始的 128 字节位图,不知道 shadow 的存在。如果我们不在 select 返回后清空 shadow.input,上一轮设置的大 fd 会"幽灵般"残留到下一轮,导致 poll 监听了不该监听的 fd,产生错误的结果。

FORTIFY 函数的 Hook 实现

__FD_SET_chk 为例:

static void my_FD_SET_chk_proxy(int fd, fd_set *set, size_t set_size) {
    if (__builtin_expect(fd >= 0 && fd < FD_SETSIZE, 1)) {
        // 快速路径:fd < 1024,交给原始函数处理
        BYTEHOOK_CALL_PREV(my_FD_SET_chk_proxy, FD_SET_chk_fn_t,
                           fd, set, set_size);
        BYTEHOOK_POP_STACK();
        return;
    }
    if (fd < 0 || fd >= SHADOW_MAX_FD) {
        // 超出 shadow 容量,打日志,不崩溃
        LOGW("FD_SET: fd=%d out of range [0,%d)", fd, SHADOW_MAX_FD);
        BYTEHOOK_POP_STACK();
        return;
    }
    // fd >= 1024 且在合法范围内:存入 shadow
    shadow_entry_t *e = shadow_find_or_create(set);
    if (e) {
        shd_set(e->input, fd);
        if (fd > e->max_fd) e->max_fd = fd;
    }
    BYTEHOOK_POP_STACK();
}

__builtin_expect(condition, 1) 是 GCC 的分支预测提示,告诉编译器"绝大多数情况下 fd < 1024",优化生成代码的分支布局。实际上 App 中 99%+ 的 FD_SET 调用都是小 fd,这个快速路径直接 CALL_PREV 回到原始函数,开销可以忽略。

__FD_ISSET_chk 的处理是对称的,区别在于读的是 output

static int my_FD_ISSET_chk_proxy(int fd, const fd_set *set, size_t set_size) {
    if (__builtin_expect(fd >= 0 && fd < FD_SETSIZE, 1)) {
        int ret = BYTEHOOK_CALL_PREV(my_FD_ISSET_chk_proxy,
                                     FD_ISSET_chk_fn_t, fd, set, set_size);
        BYTEHOOK_POP_STACK();
        return ret;
    }
    if (fd < 0 || fd >= SHADOW_MAX_FD) {
        BYTEHOOK_POP_STACK();
        return 0;
    }
    shadow_entry_t *e = shadow_find(set);
    int ret = (e != NULL) ? shd_get(e->output, fd) : 0;
    BYTEHOOK_POP_STACK();
    return ret;
}

select → poll 的核心转换逻辑

my_select_proxy 是 select 的 Hook 入口,它做的第一件事是判断走快速路径还是 poll 路径:

static int my_select_proxy(int nfds, fd_set *readfds, fd_set *writefds,
                           fd_set *exceptfds, struct timeval *timeout) {
    int ret;

    // 检查 shadow 中是否有 fd >= 1024
    shadow_entry_t *rs = readfds   ? shadow_find(readfds)   : NULL;
    shadow_entry_t *ws = writefds  ? shadow_find(writefds)  : NULL;
    shadow_entry_t *es = exceptfds ? shadow_find(exceptfds) : NULL;
    int has_large = (rs && rs->max_fd >= FD_SETSIZE)
                 || (ws && ws->max_fd >= FD_SETSIZE)
                 || (es && es->max_fd >= FD_SETSIZE);

    if (!has_large && nfds >= 0 && nfds <= FD_SETSIZE) {
        // 快速路径:所有 fd 都 < 1024,直接调原始 select
        ret = BYTEHOOK_CALL_PREV(my_select_proxy, select_fn_t,
                                 nfds, readfds, writefds, exceptfds, timeout);
    } else {
        // 慢路径:存在大 fd,走 poll
        ret = do_poll_with_shadow(nfds, readfds, writefds, exceptfds,
                                  timeval_to_ms(timeout));
    }
    BYTEHOOK_POP_STACK();
    return ret;
}

为什么需要快速路径?因为 PLT Hook 是全局生效的(通过 bytehook_hook_all),进程中所有 .so 的 select 调用都会经过这里。绝大多数调用的 fd 都在正常范围内,直接 CALL_PREV 回到原始 select,整个 Hook 的额外开销就是几个 shadow_find(对空的 tls_shadow 是一次 NULL 检查)和一个条件分支。

do_poll_with_shadow 是真正做转换的地方。它需要从两个数据源收集所有待监听的 fd:

static int do_poll_with_shadow(int nfds,
                               fd_set *readfds, fd_set *writefds,
                               fd_set *exceptfds, int timeout_ms) {
    shadow_entry_t *rs = readfds   ? shadow_find(readfds)   : NULL;
    shadow_entry_t *ws = writefds  ? shadow_find(writefds)  : NULL;
    shadow_entry_t *es = exceptfds ? shadow_find(exceptfds) : NULL;

    // 计算 fd 编号的上界
    int hi = (nfds > 0) ? nfds : 0;
    if (rs && rs->max_fd >= hi) hi = rs->max_fd + 1;
    if (ws && ws->max_fd >= hi) hi = ws->max_fd + 1;
    if (es && es->max_fd >= hi) hi = es->max_fd + 1;

hi 取 nfds 和所有 shadow max_fd 中的最大值。比如 SDK 调 select(1256, ...) 且 shadow 里最大 fd 是 1255,那 hi = 1256

接下来是两个循环——它们分别从不同的数据源提取 fd,合并到同一个 pollfd[] 数组中:

    int n = 0;
    // orig_hi = min(nfds, 1024):原始 fd_set 位图的安全扫描上界
    int orig_hi = (nfds < FD_SETSIZE) ? nfds : FD_SETSIZE;

    // 循环 1:从原始 fd_set 位图提取 fd [0, 1024)
    for (int fd = 0; fd < orig_hi; fd++) {
        short ev = 0;
        if (readfds   && raw_fd_isset(fd, readfds))   ev |= POLLIN;
        if (writefds  && raw_fd_isset(fd, writefds))  ev |= POLLOUT;
        if (exceptfds && raw_fd_isset(fd, exceptfds)) ev |= POLLPRI;
        if (ev) { pf[n].fd = fd; pf[n].events = ev; pf[n].revents = 0; n++; }
    }

    // 循环 2:从 shadow 提取 fd [1024, hi)
    for (int fd = FD_SETSIZE; fd < hi; fd++) {
        short ev = 0;
        if (rs && shd_get(rs->input, fd)) ev |= POLLIN;
        if (ws && shd_get(ws->input, fd)) ev |= POLLOUT;
        if (es && shd_get(es->input, fd)) ev |= POLLPRI;
        if (ev) { pf[n].fd = fd; pf[n].events = ev; pf[n].revents = 0; n++; }
    }

为什么需要两个循环?因为同一次 select 调用中可能同时监听小 fd 和大 fd:

FD_SET(3, &readfds);      // fd=3,在原始 fd_set 位图中
FD_SET(7, &readfds);      // fd=7,在原始 fd_set 位图中
FD_SET(1255, &readfds);   // fd=1255,被 Hook 拦截存入 shadow
select(1256, &readfds, NULL, NULL, &timeout);

走到 do_poll_with_shadow 时,这次调用不能用原始 select(nfds=1256 > 1024 会被内核拒绝),必须全部走 poll。所以要把 fd=3、fd=7(在原始位图里)和 fd=1255(在 shadow 里)都提取出来,构建成 pollfd[] = [{fd=3, POLLIN}, {fd=7, POLLIN}, {fd=1255, POLLIN}],一起交给 poll()

orig_hi 被限制为 min(nfds, 1024) 是因为 fd_set 结构体固定只有 128 字节(1024 bit),即使 nfds=1256,位图里物理上也只能存 0~1023 的 fd。读超过这个范围就是内存越界。

循环中的 POLLINPOLLOUTPOLLPRI 分别对应 select 的 readfds、writefds、exceptfds。同一个 fd 可以同时被多个集合关注,通过位或运算合并到一个 events 字段里。

poll 返回后,需要把结果写回两个地方:

    if (ret > 0) {
        // 清空原有结果
        if (readfds)   raw_fd_zero(readfds);
        if (writefds)  raw_fd_zero(writefds);
        if (exceptfds) raw_fd_zero(exceptfds);
        shd_zero_out(rs); shd_zero_out(ws); shd_zero_out(es);

        int cnt = 0;
        for (int i = 0; i < n; i++) {
            short rev = pf[i].revents;
            int   fd  = pf[i].fd;

            if ((pf[i].events & POLLIN) && (rev & (POLLIN | POLLHUP | POLLERR))) {
                if (fd < FD_SETSIZE) { if (readfds)  raw_fd_set(fd, readfds); }
                else if (rs)         { shd_set(rs->output, fd); }
                cnt++;
            }
            if ((pf[i].events & POLLOUT) && (rev & (POLLOUT | POLLHUP | POLLERR))) {
                if (fd < FD_SETSIZE) { if (writefds) raw_fd_set(fd, writefds); }
                else if (ws)         { shd_set(ws->output, fd); }
                cnt++;
            }
            if ((pf[i].events & POLLPRI) && (rev & POLLPRI)) {
                if (fd < FD_SETSIZE) { if (exceptfds) raw_fd_set(fd, exceptfds); }
                else if (es)         { shd_set(es->output, fd); }
                cnt++;
            }
        }

小 fd(< 1024)的结果写回原始 fd_set 位图,SDK 后续通过正常的 FD_ISSET 读取。大 fd 的结果写入 shadow 的 output 数组,SDK 后续的 FD_ISSET(1255, ...) 会被我们的 my_FD_ISSET_chk_proxy 拦截,从 output 中读取结果。

POLLHUP(对端关闭连接)和 POLLERR(socket 错误)被映射到 readfds 和 writefds 是遵循 POSIX 语义——连接断开时 read() 会返回 0,write() 会返回 -1,调用方通过检查这些集合来感知连接状态变化。如果漏掉这个映射,SDK 的断线重连逻辑可能失效。

PLT Hook 的原理

ELF 动态链接的间接寻址

要理解 PLT Hook,需要先理解 Android .so 文件的函数调用机制。

当 libsdk.so 中的代码调用 select() 时,编译器不会生成一个直接跳转到 libc.so 中 select 函数地址的指令。为什么?因为在编译 libsdk.so 的时候,编译器根本不知道 libc.so 会被加载到内存的哪个位置——地址空间布局随机化(ASLR)让每次加载的基地址都不同。

ELF 格式用 PLT(Procedure Linkage Table)和 GOT(Global Offset Table)解决这个问题:

libsdk.so 的内存布局:

┌─────────────────────────────────────┐
│ .text 段 (代码,只读+可执行)           │
│                                     │
│   ... 某个函数中:                     │
│   BL select@PLT    ──────────┐      │  ① 代码调用跳到 PLT 跳板
│                              │      │
├──────────────────────────────│──────┤
│ .plt 段 (跳板,只读+可执行)     │      │
│                              ▼      │
│   select@PLT:                       │
│     ADRP X16, GOT_PAGE              │  ② PLT 跳板定位 GOT 表项
│     LDR  X17, [X16, GOT_OFFSET]     │  ③ 从 GOT 读取真实地址
│     BR   X17           ──────┐      │  ④ 跳转到真实地址
│                              │      │
├──────────────────────────────│──────┤
│ .got 段 (数据,可读写)         │      │
│                              ▼      │
│   [GOT_OFFSET]:                     │
│     0x7A8E001234   ─────────────────│──→ libc.so::select 的运行时地址
│                                     │
└─────────────────────────────────────┘

关键点:

  • .text 段是只读的。代码一旦加载就不能修改(W^X 保护)。
  • .got 段是可读写的。动态链接器 linker64 在加载时会将外部函数的真实地址填入 GOT 表。
  • 间接寻址。代码永远通过 PLT→GOT 两跳来调用外部函数,不直接跳到目标。

PLT Hook 的本质:替换 GOT 表项

PLT Hook 所做的事情极其简单——把 GOT 表中存储的 libc::select 地址替换my_select_proxy 的地址:

Hook 之前:
  GOT[select]:  0x7A8E001234  → libc::select

Hook 之后:
  GOT[select]:  0x7B12005678  → libselect_poll_hook.so::my_select_proxy

SDK 的 .text 段一个字节都没有被修改。SDK 的 PLT 跳板还是原样执行 LDR X17, [GOT]; BR X17,只是从 GOT 里读到的地址变了,于是跳到了我们的代理函数。

从操作系统层面看,这只是把一个用户态地址替换成了另一个用户态地址,不涉及内核、不需要 root、不修改任何只读段。这就是 PLT Hook 比 inline hook 安全的原因——inline hook 需要修改 .text 段的指令,而 .text 段在有 W^X 保护的系统上是不可写的。

ByteHook 的角色

手动做 PLT Hook 需要:解析 ELF 头 → 找到 .rel.plt / .rela.plt 重定位表 → 根据符号名查找对应的 GOT 表项 → 修改页面权限 → 原子写入新地址 → 恢复页面权限。而且每个 .so 都要单独处理。

ByteHook(字节跳动开源)封装了这些细节,API 非常简洁:

bytehook_hook_all(
    NULL,                     // caller_path_name, NULL = 所有 .so
    "__FD_SET_chk",           // 要 Hook 的符号名
    my_FD_SET_chk_proxy,      // 替换的函数指针
    NULL, NULL                // 回调参数
);

bytehook_hook_all 的含义是:遍历进程中所有已加载的 .so 和后续动态加载的 .so,找到每个 .so 的 GOT 表中 __FD_SET_chk 对应的表项,替换为 my_FD_SET_chk_proxy

我们使用 hook_all 而不是 hook_single 的原因是:DJI SDK 包含不少 so 文件,我们不知道是哪个在调用 select。手动排查所有 so 不现实,而且后续版本可能变。全量 Hook 一劳永逸。

BYTEHOOK_CALL_PREV 和调用链

Hook 安装后,我们需要在 proxy 函数中调用原始函数(走快速路径时)。ByteHook 提供了 BYTEHOOK_CALL_PREV 宏:

int my_select_proxy(int nfds, ...) {
    // 快速路径:没有大 fd,直接调原始 select
    int ret = BYTEHOOK_CALL_PREV(select, fn_select_t, nfds, ...);
    BYTEHOOK_POP_STACK();
    return ret;
}

BYTEHOOK_CALL_PREV 内部保存了原始函数的地址(Hook 安装时记录的),调用它就相当于调用原始的 libc::selectBYTEHOOK_POP_STACK() 必须在 proxy 函数的每个 return 之前调用,用于维护 ByteHook 内部的递归检测栈——如果不调用,ByteHook 会认为当前调用还没结束,可能影响后续的 Hook 行为。

对进程的影响范围

PLT Hook 的影响是进程级的。一旦安装,该进程中所有通过 PLT 调用目标函数的代码都会被拦截——不仅仅是 SDK 的 .so,也包括 app 自己的 native 代码和其他第三方库。

这是设计上的权衡。对于我们的场景:

  • 好处:不需要精确定位是哪个 .so 触发了问题,一网打尽。
  • 风险:正常的 select(fd<1024) 调用也会经过 proxy。这就是为什么快速路径的性能至关重要——99%+ 的调用都会走快速路径,只多一次 shadow_find(1~3 次指针比较)+ 一个条件分支,约 10ns 的额外开销。

工程集成

初始化时机

Hook 必须在 SDK 初始化之前安装。在 Application 的 attachBaseContext 中调用:

class DJIAircraftApplication : DJIApplication() {
    override fun attachBaseContext(base: Context?) {
        super.attachBaseContext(base)
        SelectPollHook.install(this)
        // SDK 初始化在 onCreate 中进行
    }
}

attachBaseContext 是 Application 生命周期中最早的回调点,在 onCreate 之前执行。在这里安装 Hook 可以确保后续所有 FD_SET / select 调用都被拦截。

内存与性能开销

项目开销
shadow\_entry\_t每个约 1KB(两个 4096-bit 位数组)
每线程最多8 个 entry = 8KB
快速路径额外耗时\~10ns(两次 shadow\_find + 条件分支)
poll 路径额外耗时取决于 fd 数量,通常 \< 1μs 的用户态开销
pollfd 栈分配≤256 个 fd 时用栈(2KB),超过才 malloc

对于一个需要处理无人机实时数据流的 App 来说,这个开销完全可以接受。

边界情况

fd 编号超过 SHADOW_MAX_FD(4096):不会崩溃,打一条 LOGW 警告,该 fd 被忽略。如果确实有这种场景,调大常量即可,每增加 1024 个 fd 范围只多消耗 256 字节。

nfds 参数为 0:合法的纯定时器用法,poll(NULL, 0, timeout_ms) 正确处理。

信号中断(EINTR)poll()select() 一样会被信号中断返回 EINTR,调用方已有的重试逻辑不需要修改。

timeout 精度损失:select 的 timeval 是微秒级,poll 的 timeout 是毫秒级,最多损失不到 1ms。在网络 IO 场景下可以接受。

pselect 的信号掩码:pselect 相比 select 多了一个原子性的信号掩码操作。我们的 Hook 在调用 poll 前后手动 pthread_sigmask 来模拟,保存并恢复 errno,保证语义一致。


使用这个方案后,那个 FORTIFY: FD_SET: file descriptor 1255 >= FD_SETSIZE 1024 的崩溃彻底消失。logcat 中可以看到 Hook 正常工作的日志:

SelectPollHook: ✓ __FD_SET_chk
SelectPollHook:select
SelectPollHook: fd limit: soft=32768 hard=32768

整个修复不修改任何 SDK 代码,不需要等大疆发版,对正常路径(fd < 1024)零开销,只在触发问题的路径上透明地用 poll 替代 select。