前言
我曾經一直有個困惑,就是像 JavaScript、Python 這樣的腳本語言,是如何做到調用一個外部聲明的 native 函數的呢?
試想有一個動態(tài)鏈接庫,里面有一個兩個參數的函數。如果說我們在 C 語言中調用,無非就是先用 dlopen 打開動態(tài)鏈接庫,然后用 dlsym 拿到函數的地址,然后強制轉換到預先聲明的一個函數簽名,然后就可以直接像調用本地函數一樣調用它了。但是,如果我們在 Python 中調用,我們用 ctypes.CDLL 打開一個動態(tài)鏈接庫,然后直接就可以調用其中的任意參數了,那么 Python 運行時是怎么處理參數列表和返回值的問題的呢?解析函數地址固然簡單,但是顯然 Python 不可能為許多不可預知的函數聲明一堆函數簽名。于是我便開始研究這其中的奧秘。
探究的開始
因為我一直在做 iOS 開發(fā),對于 Objective-C Runtime 也有一定的了解,其實 OC 底層在調用一個類實例的方法時采用了發(fā)送消息的方式。例如:
[aObject someMethodWithArg1:foo arg2:bar];
在編譯時將會自動轉換為純 C 語言調用:
objc_msgSend(aObject, @selector(someMethodWithArg1:arg2:), foo, bar);
然后函數內部會根據 SEL,在 id 所表示的類繼承鏈里尋找相應的 IMP,當然內部還會做一些動態(tài)解析和消息轉發(fā)的工作,與本文無關,這里就不贅述了。但是重點是在找到 IMP 后,怎么去調用它。這里蘋果所采用的方式比較取巧,那就是用 Assembly(匯編) 實現(xiàn),因為函數在被調用之前,會有一個準備工作(稱之為 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 所需的所有環(huán)境都創(chuàng)建完畢,代碼片段的最后一行,完美實現(xiàn)函數調用。
What's the Hell?
說了這么多,libffi 到底是什么?我 Google 了一下,有這樣一篇文章描述地很清晰:

也就是說,只要你知道函數的參數類型和參數個數以及返回值的類型,你就可以不用函數簽名來間接調用這個函數,我想其內部實現(xiàn)應該和 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,我們就不用操心匯編層面的棧幀、寄存器的維護了,直接去做我們業(yè)務邏輯就可以了。當然,我們還可以把這個庫進行簡單的封裝,例如用 Type Encoding 的方式將類型進行統(tǒng)一的編碼,一起放到函數名字符串中,然后用 VA_LIST 來傳遞參數,我們就有望把上面如此繁瑣的步驟變成下面這樣了:
char *result = dylib_call("libexample.dylib", "@$exp_string$di", 3.14, 6);
是不是十分方便呢,當然,這個封裝我還沒有寫呢...
所以,有時候系統(tǒng)底層的東西也十分有意思,這就是為什么搞應用時間長了,老想做點別的,因為你了解的越多,眼界和經驗也就越廣闊,越豐富,知識需要不斷的積累,而這個過程就是我們不斷探索未知的過程。