在 C 語言中調用不定參數的外部函數

前言

我曾經一直有個困惑,就是像 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),在這期間,函數所需的參數放到寄存器、棧上,然后直接 calljmp 到指定的地址即可。因此使用匯編能擁有對棧幀的完全控制,另一方面也能提升性能。

然而 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 所需的所有環(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)底層的東西也十分有意思,這就是為什么搞應用時間長了,老想做點別的,因為你了解的越多,眼界和經驗也就越廣闊,越豐富,知識需要不斷的積累,而這個過程就是我們不斷探索未知的過程。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容