【FFI】 C FFI 与 libffi——事实标准的工作原理

12 阅读 1382 字 · 约 5 分钟

FFI 的底层逻辑很简单:两个语言之间要互相调用,总得有个共同的"语言"做桥梁。这个桥梁不是 C 语言本身,而是 C 的调用约定——C ABI。

为什么是 C ABI,不是别的

操作系统内核用什么语言写的不重要。Linux 是纯 C,macOS 的 XNU 混了大量 C++,Windows 内核也是 C/C++ 混合。但不管内核怎么写,它对外暴露的系统调用接口(syscall)遵循的都是 C 的调用约定:参数怎么传、返回值放哪、栈谁清理。这不是 C 语言有多好,而是历史形成的——Unix 定义了这套规则,之后所有 OS 都沿用了。

所以 C ABI 成了事实标准。你用 Rust 写个库,编译成 .so,只要函数签名是 C ABI 的(去掉 name mangling、去掉 vtable),任何语言都能调。Rust 调 C、Python 调 Rust、Java 调 Go——中间那层永远是一段 C ABI 的调用栈。

libffi 做什么:把"函数描述"变成"函数调用"

libffi 是这套逻辑的底层实现。它不翻译语言,它翻译的是调用方式。

核心就两个概念:

ffi_cif(Call Interface)——描述一个函数的签名。返回值是什么类型、有几个参数、每个参数是什么类型。这本质上是把"函数长什么样"翻译成一个数据结构。

ffi_call——拿到 cif 和函数地址后,按 C 调用约定构造栈帧,把参数塞到该放的位置(寄存器还是栈上),跳转到函数地址执行,把返回值拿回来。

整个过程不需要编译期知道函数签名。你运行时告诉 libffi"这个函数长这样,地址在那",它帮你调。Python 的 ctypes.CDLL("libc.so").printf 能工作,就是因为 libffi 在底层干了这件事。

静态绑定 vs 动态绑定

FFI 方案按绑定时机分两类:

静态绑定——编译期生成桩代码。JNI 的做法:你写个 Java 类,用 javac -h 生成 .h 头文件,然后手写 C 实现,编译成 .so。调的时候 JVM 通过函数名(如 Java_com_example_Main_compute)找到桩,直接调。快,因为调用路径在编译期就定好了;但烦,改一次函数签名得重新生成、重新编译。

动态绑定——运行时解析。libffi 的做法:你不需要生成任何桩代码,只需要在运行时描述函数签名,libffi 帮你构造调用栈。Python ctypes、Java JNA 都走这条路。灵活,改签名不用重编译;但有额外开销——每次调用都要解析签名、构造栈帧。

取舍很清楚:静态绑定适合固定接口、高性能场景(如 JNI 调底层计算引擎);动态绑定适合探索、胶水脚本、频繁变更的场景(如 Python 调 C 库做原型验证)。

libffi 的边界:只管调用,不管别的

libffi 只解决"函数怎么调"这一件事。它不关心:

  • 数据怎么传——你得自己把 Python 的 PyObject marshal 成 C 的 int,libffi 不管这个。它假设你塞进参数数组的东西已经是 C 能直接读的。
  • 内存谁管——libffi 不追踪指针、不负责 free。一个 malloc 出来的指针从 C 传回 Python,谁释放、什么时候释放,是上层的事。

所以 libffi 是 FFI 方案的底层基石,不是全套方案。Python ctypes 站在 libffi 上,自己处理了数据 marshal 和引用计数;Java JNA 站在 libffi 上,自己处理了结构体映射和内存管理。各语言方案真正的复杂度,都在 libffi 之上那层。