Python 能否定义供 shared library 直接调用的 C function?
结论
Python 可以用 ctypes.CFUNCTYPE 创建采用普通 C calling convention 的 callback,C 代码再通过函数指针调用对应的 Python 函数。
普通 Python 函数无法直接成为名为 bar 的 ELF、Mach-O 或 PE 导出符号。所以下面这段未经修改的 C 代码不能直接找到 Python 中定义的 bar:
int foo(float f) {
return f + bar();
}
如果 mod.so 通过动态链接符号 bar 调用函数,就需要 native adapter,或者通过 Cython、CFFI embedding 等方式生成带有该导出符号的 native library。如果可以修改 mod.so,让它接收函数指针,那么只用 ctypes 即可,C 代码中也不再需要 PyObject_CallObject()。
向 shared library 传入 callback
可以把 bar 作为函数指针传给 foo:
typedef int (*bar_fn)(void);
int foo(float f, bar_fn bar) {
return (int)(f + bar());
}
Python 端通过 CFUNCTYPE 创建 callback:
import ctypes
BAR_CALLBACK = ctypes.CFUNCTYPE(ctypes.c_int)
@BAR_CALLBACK
def bar():
return 1337
dll = ctypes.CDLL("./mod.so")
dll.foo.argtypes = [ctypes.c_float, BAR_CALLBACK]
dll.foo.restype = ctypes.c_int
result = dll.foo(42.0, bar)
assert result == 42 + 1337
CFUNCTYPE 会创建一个 native callback 入口。对 C 代码来说,它就是一个普通函数指针,因此不需要包含 Python.h,也不用调用 PyObject_CallObject()。
callback 执行时仍然依赖 Python runtime 和 ctypes 来处理参数与返回值转换以及 GIL 管理,只是 mod.so 的 C 代码不需要处理这些细节。
不想在每次调用时传入函数指针
可以增加一个只调用一次的注册接口:
typedef int (*bar_fn)(void);
static bar_fn registered_bar = NULL;
void set_bar_callback(bar_fn callback) {
registered_bar = callback;
}
int foo(float f) {
if (registered_bar == NULL) {
return -1;
}
return (int)(f + registered_bar());
}
对应的 Python 代码是:
import ctypes
BAR_CALLBACK = ctypes.CFUNCTYPE(ctypes.c_int)
@BAR_CALLBACK
def bar():
return 1337
dll = ctypes.CDLL("./mod.so")
dll.set_bar_callback.argtypes = [BAR_CALLBACK]
dll.set_bar_callback.restype = None
dll.foo.argtypes = [ctypes.c_float]
dll.foo.restype = ctypes.c_int
dll.set_bar_callback(bar)
assert dll.foo(42.0) == 1379
注册后,调用 foo() 时就不用再传 callback 参数。系统中有多项 API 时,还可以注册一个由函数指针组成的结构体:
struct mod_api {
int (*bar)(void);
void (*log_message)(const char *);
};
void set_mod_api(const struct mod_api *api);
这种设计比依赖 main executable 导出多个全局符号更容易移植,也便于根据 mod_api.json 自动生成绑定代码。
为什么不能直接解析 Python 中的 bar
C 编译器看到下面的声明:
int bar(void);
会生成对 native symbol bar 的引用。加载 mod.so 时,dynamic linker 必须在 executable 或其他已加载的 shared library 中找到这个符号。
而 Python 中的定义:
def bar():
return 1337
创建的是 Python object。它既不是 native object file 中的机器码函数,也不会被登记到 dynamic linker 的符号表中。
CFUNCTYPE 可以创建供 C 调用的入口地址,但这个地址只是函数指针,不会自动注册为名为 bar 的全局动态符号。需要通过参数、注册函数或 API table,把这个地址交给 mod.so。
必须保持 mod.so 完全不变时
假设 mod.so 已经固定为:
extern int bar(void);
int foo(float f) {
return (int)(f + bar());
}
加载它之前,进程中必须有一个真正导出 bar 的 native module,例如:
- 手写或自动生成的
adapter.so; - 由 Cython 生成的扩展;
- 使用 CFFI embedding 生成的 shared library;
- 其他能够生成并导出 native symbol 的编译工具。
这些方式都需要某种 native bridge。纯 Python 和 ctypes 没有通用接口可以向当前进程的动态符号表中追加具名函数符号。修改 GOT、重写 call instruction 或使用特定平台的 loader 技巧有可能达到类似效果,但这些做法缺乏可移植性,也容易受链接方式和安全机制影响。
注意事项
使用 ctypes callback 时需要注意:
- 必须保留 callback object 的强引用。callback 一旦被垃圾回收,C 代码保存的函数指针就会失效。
callbacks = {
"bar": BAR_CALLBACK(bar_impl),
}
CFUNCTYPE适用于常见的 C calling convention。如果 Windows 上的目标函数使用stdcall,应按照 ABI 改用WINFUNCTYPE。argtypes和restype必须与 C 声明完全一致,包括整数宽度、指针类型、结构体布局和 calling convention。- Python exception 不能直接跨越 C ABI 边界。callback 应自行捕获异常,并返回双方约定的错误码:
@BAR_CALLBACK
def bar():
try:
return calculate_value()
except Exception:
return -1
- 如果 C 从其他线程调用 callback,
ctypes会处理进入 Python 所需的线程状态。不过,thread-local 状态、性能和回调频率仍需分别评估。 - callback 依赖存活的 Python interpreter。程序退出或 interpreter finalization 期间,C 代码不能继续调用已经注册的 callback。
如果可以调整 mod.so 的 ABI,直接传入函数指针或注册 API table 最简单。如果 mod.so 必须继续直接调用名为 bar 的符号,那么仍需使用现有的 adapter 方案,并可根据 mod_api.json 自动生成 adapter。
备注:内容仅供参考。
