前言
我曾经一直有个困惑,就是像 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 看起来完全不是这么干的,它也没必要这么干,来看看一个外部函数在被调用时经历了怎样的一个过程:
NSLog 在 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);
是不是十分方便呢,当然,这个封装我还没有写呢...
所以,有时候系统底层的东西也十分有意思,这就是为什么搞应用时间长了,老想做点别的,因为你了解的越多,眼界和经验也就越广阔,越丰富,知识需要不断的积累,而这个过程就是我们不断探索未知的过程。
作者:Cyandev
链接:https://www.jianshu.com/p/eb4fe09903fb