在 C 语言中调用不定参数的外部函数

前言

我曾经一直有个困惑,就是像 JavaScriptPython 这样的脚本语言,是如何做到调用一个外部声明的 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),在这期间,函数所需的参数放到寄存器、栈上,然后直接 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 所需的所有环境都创建完毕,代码片段的最后一行,完美实现函数调用。

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);

是不是十分方便呢,当然,这个封装我还没有写呢...

所以,有时候系统底层的东西也十分有意思,这就是为什么搞应用时间长了,老想做点别的,因为你了解的越多,眼界和经验也就越广阔,越丰富,知识需要不断的积累,而这个过程就是我们不断探索未知的过程。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,039评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,426评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,417评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,868评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,892评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,692评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,416评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,326评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,782评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,957评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,102评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,790评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,442评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,996评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,113评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,332评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,044评论 2 355

推荐阅读更多精彩内容