前言
我曾经一直有个困惑,就是像 JavaScript、Python 这样的脚本语言,是如何做到调用一个外部声明的 native 函数的呢?
试想有一个动态链接库,里面有一个两个参数的函数。如果说我们在 C 语言中调用,无非就是先用 dlopen
打开动态链接库,然后用 dlsym
拿到函数的地址,然后强制转换到预先声明的一个函数签名,然后就可以直接像调用本地函数一样调用它了。但是,如果我们在 Python 中调用,我们用 ctypes.CDLL
打开一个动态链接库,然后直接就可以调用其中的任意参数了,那么 Python 运行时是怎么处理参数列表和返回值的问题的呢?解析函数地址固然简单,但是显然 Python 不可能为许多不可预知的函数声明一堆函数签名。于是我便开始研究这其中的奥秘。
探究的开始
因为我一直在做 iOS 开发,对于 Objective-C Runtime 也有一定的了解,其实 OC 底层在调用一个类实例的方法时采用了发送消息的方式。例如:
[aObject someMethodWithArg1:foo arg2:bar];
在编译时将会自动转换为纯 C 语言调用:
objc_msgSend(aObject, @selector(someMethodWithArg1:arg2:), foo, bar);
然后函数内部会根据 SEL
,在 id
所表示的类继承链里寻找相应的 IMP
,当然内部还会做一些动态解析和消息转发的工作,与本文无关,这里就不赘述了。但是重点是在找到 IMP
后,怎么去调用它。这里苹果所采用的方式比较取巧,那就是用 Assembly(汇编) 实现,因为函数在被调用之前,会有一个准备工作(称之为 Prologue),在这期间,函数所需的参数放到寄存器、栈上,然后直接 call
或 jmp
到指定的地址即可。因此使用汇编能拥有对栈帧的完全控制,另一方面也能提升性能。
然而 Python 看起来完全不是这么干的,它也没必要这么干,来看看一个外部函数在被调用时经历了怎样的一个过程:
看到高亮的那行了吗?这就是奥秘所在。来看看 Python 源码中这个调用的过程:
PyObject *_ctypes_callproc(PPROC pProc,
PyObject *argtuple,
#ifdef MS_WIN32
IUnknown *pIunk,
GUID *iid,
#endif
int flags,
PyObject *argtypes, /* misleading name: This is a tuple of
methods, not types: the .from_param
class methods of the types */
PyObject *restype,
PyObject *checker)
{
Py_ssize_t i, n, argcount, argtype_count;
void *resbuf;
struct argument *args, *pa;
ffi_type **atypes;
ffi_type *rtype;
void **avalues;
PyObject *retval = NULL;
n = argcount = PyTuple_GET_SIZE(argtuple);
#ifdef MS_WIN32
/* an optional COM object this pointer */
if (pIunk)
++argcount;
#endif
// ...
if (-1 == _call_function_pointer(flags, pProc, avalues, atypes,
rtype, resbuf,
Py_SAFE_DOWNCAST(argcount,
Py_ssize_t,
int)))
goto cleanup;
// ...
}
很明显,Python 在处理外部函数调用时用到了 libffi,在这个函数中最重要的就是 _call_function_pointer
这个函数调用,我们接着往下看:
static int _call_function_pointer(int flags,
PPROC pProc,
void **avalues,
ffi_type **atypes,
ffi_type *restype,
void *resmem,
int argcount)
{
#ifdef WITH_THREAD
PyThreadState *_save = NULL; /* For Py_BLOCK_THREADS and Py_UNBLOCK_THREADS */
#endif
PyObject *error_object = NULL;
int *space;
ffi_cif cif;
int cc;
#ifdef MS_WIN32
int delta;
#ifndef DONT_USE_SEH
DWORD dwExceptionCode = 0;
EXCEPTION_RECORD record;
#endif
#endif
/* XXX check before here */
if (restype == NULL) {
PyErr_SetString(PyExc_RuntimeError,
"No ffi_type for result");
return -1;
}
cc = FFI_DEFAULT_ABI;
#if defined(MS_WIN32) && !defined(MS_WIN64) && !defined(_WIN32_WCE)
if ((flags & FUNCFLAG_CDECL) == 0)
cc = FFI_STDCALL;
#endif
if (FFI_OK != ffi_prep_cif(&cif,
cc,
argcount,
restype,
atypes)) {
PyErr_SetString(PyExc_RuntimeError,
"ffi_prep_cif failed");
return -1;
}
if (flags & (FUNCFLAG_USE_ERRNO | FUNCFLAG_USE_LASTERROR)) {
error_object = _ctypes_get_errobj(&space);
if (error_object == NULL)
return -1;
}
#ifdef WITH_THREAD
if ((flags & FUNCFLAG_PYTHONAPI) == 0)
Py_UNBLOCK_THREADS
#endif
if (flags & FUNCFLAG_USE_ERRNO) {
int temp = space[0];
space[0] = errno;
errno = temp;
}
#ifdef MS_WIN32
if (flags & FUNCFLAG_USE_LASTERROR) {
int temp = space[1];
space[1] = GetLastError();
SetLastError(temp);
}
#ifndef DONT_USE_SEH
__try {
#endif
delta =
#endif
ffi_call(&cif, (void *)pProc, resmem, avalues);
// ...
}
经过从 Python Object 层面到 C 语言层面的一个 Bridge 过程之后,ffi_call
所需的所有环境都创建完毕,代码片段的最后一行,完美实现函数调用。
What's the Hell?
说了这么多,libffi 到底是什么?我 Google 了一下,有这样一篇文章描述地很清晰:
也就是说,只要你知道函数的参数类型和参数个数以及返回值的类型,你就可以不用函数签名来间接调用这个函数,我想其内部实现应该和 OC 底层相似。
谜底揭开
OK,到这我们来尝试一下这个库,用它来调用一个函数,而不使用函数签名。
首先我先声明一个简单的函数,作用就是用两个参数进行幂计算并用结果生成字符串:
char *exp_string(double b, int n) {
double result = 1;
for (int i = 0; i < n; i++) {
result *= b;
}
char *str = (char *) malloc(sizeof(char) * 50);
snprintf(str, 50, "%f", result);
return str;
}
很简单,然后我们用 libffi 调用它:
int main(int argc, char *argv[]) {
ffi_cif cif; // 函数调用所需的上下文
ffi_type *arg_types[2]; // 参数类型指针数组
void *arg_values[2]; // 参数值指针数组
ffi_status status;
// 根据被调用函数的参数类型进行设定.
arg_types[0] = &ffi_type_double;
arg_types[1] = &ffi_type_sint32;
// 这里 ffi_prep_cif 的第三个参数为被调用函数参数数量, 第四个参数为返回值类型的指针.
if ((status = ffi_prep_cif(&cif, FFI_UNIX64, 2, &ffi_type_pointer, arg_types)) != FFI_OK) {
perror("ffi_prep_cif");
abort();
}
// 设置函数参数.
double arg_b = 3.14;
int arg_n = 6;
arg_values[0] = &arg_b;
arg_values[1] = &arg_n;
// 声明返回值存放的变量.
char *retVal;
// 交给 libffi 调用这个函数.
ffi_call(&cif, FFI_FN(exp_string), &retVal, arg_values);
// 输出结果.
printf("Function result: %s\n", retVal);
return 0;
}
其实就是简单设置一下上下文,就可以直接拿去给库调用了,很简单。我们看看调用结果:
结果符合我们的预期,效果和直接调用函数一致。
Wrap Up
有了 libffi,我们就不用操心汇编层面的栈帧、寄存器的维护了,直接去做我们业务逻辑就可以了。当然,我们还可以把这个库进行简单的封装,例如用 Type Encoding 的方式将类型进行统一的编码,一起放到函数名字符串中,然后用 VA_LIST
来传递参数,我们就有望把上面如此繁琐的步骤变成下面这样了:
char *result = dylib_call("libexample.dylib", "@$exp_string$di", 3.14, 6);
是不是十分方便呢,当然,这个封装我还没有写呢...
所以,有时候系统底层的东西也十分有意思,这就是为什么搞应用时间长了,老想做点别的,因为你了解的越多,眼界和经验也就越广阔,越丰富,知识需要不断的积累,而这个过程就是我们不断探索未知的过程。