[iOS]libffi动态调用C函数

前言:在iOS开发中可以使用Runtime动态调用OC方法,但是无法动态调用C函数,那么该如何动态调用C函数呢?值得思考一下。

1. 函数调用

1.1 函数地址

C语言编译后,在可执行文件中会有函数名信息。如果想要动态调用一个C函数,首先需要根据函数名找到这个函数地址,然后根据函数地址进行调用。

动态链接器已经提供一个 API:dlsym(),可以通过函数名字拿到函数地址:

void test() {
  printf("testFunc");
}

int main() {
  void (*funcPointer)() = dlsym(RTLD_DEFAULT, "test");
  funcPointer();
  return 0;
}

从上面代码中可以看出,test方法是没有返回值和参数的。所以funcPointer只能在指向参数和返回值都是空的函数时才能正确调用到。对于有返回值和有参数的C函数,需要指明参数和返回值类型才能使用。

int testFunc(int n, int m) {
  printf("testFunc");
  return 1;
}

int main() {
  // ① 表示正确定义了函数参数/返回值类型的函数指针
  int (*funcPointer)(int, int) = dlsym(RTLD_DEFAULT, "testFunc");
  funcPointer(1, 2);

  // ② 表示没有正确定义参数/返回值类型的函数指针
  void (*funcPointer)() = dlsym(RTLD_DEFAULT, "testFunc");
  funcPointer(1, 2); //error

  return 0;
}

如上代码,② 在执行的时候会crash,因为没有定义正确的参数类型和返回值类型。

如果所有C函数的参数类型和数量,以及返回类型也一样,那么使用dlsym()就能实现动态的调用C函数,但是这根本不现实。

不同的函数都有不同的参数和返回值类型,也就没办法通过一个万能的函数指针去支持所有函数的动态调用,必须要让函数的参数/返回值类型都对应上才能调用。因为函数的调用方和被调用方会遵循一种约定:调用惯例(Calling Convention)

1.2 调用惯例(Calling Convention)

高级编程语言的函数在调用时,需要约定好参数的传递顺序、传递方式,栈维护的方式,名字修饰。这种函数调用者和被调用者对函数如何调用的约定,就叫作调用惯例(Calling Convention)高级语言编译时,会生成遵循调用惯例的汇编代码

  • 参数传递方式
    调用函数时,参数可以选择使用栈或者使用寄存器进行传递
  • 参数传递顺序
    参数压栈的顺序可以从左到右也可以从右到左
  • 栈维护方式
    函数调用后参数从栈弹出可以由调用方完成,也可以由被调用方完成

在日常工作中,比较少接触到这个概念。因为编译器已经帮我们完成了这一工作,我们只需要遵循正确的语法规则即可,编译器会根据不同的架构生成对应的汇编代码,从而确保函数调用约定的正确性。

函数调用者被调用者需要遵循这同一套约定,上述②,就是函数本身遵循了这个约定,而调用者没有遵守,导致调用出错。

以上面例子简单分析下,如果按①那样正确的定义方式定义funcPointer,然后调用它,这里编译成汇编后,在调用处会有相应指令把参数 n,m 的值 1 和 2 入栈(这里是举例),然后跳过去 testFunc()函数实体执行,这个函数执行时,按约定它知道n,m两个参数值已经在栈上,就可以取出来使用了:

image.png

而如果按②那样定义,编译后这里不会把参数 n,m 的值 1 和 2 入栈,因为这里编译器把它当成了没有参数和没有返回值的函数,也就不需要进行参数入栈的操作,然后在 testFunc()函数实体里按约定去栈上取参数时就会发现栈上本来应该存参数 n 和 m 的地方并没有数据,或者是其他错误的数据,导致调用出错:

image.png

所以要在调用前明确告诉编译器函数的参数和返回值类型,编译器才能生成对应的正确的汇编代码,让被调用的函数执行时能正常取到参数。

也就是说如果需要动态调用任意 C 函数,有一种笨方案就是事先准备好任意参数类型/参数个数/返回值类型 排列组合的 C 函数指针,让最终的汇编把所有情况都准备好,最后调用时通过 switch 去找到正确的那个去执行就可以了。

在C语言层面上解决不了这个问题,只能再往底层走,从汇编考虑了。

1.3 objc_msgSend

OC的方法调用走的都是objc_msgSend函数,这个函数支持任意返回值以及任意参数类型和个数,它的定义仅是下面这样:

void objc_msgSend(void /* id self, SEL op, ... */ )

了解OC底层的都知道,objc_msgSend是用汇编实现的,其结构分为序言准备(Prologue)、函数体(Body)、结束收尾(Epilogue)三部分。

序言准备部分的作用是会保存之前程序执行的状态,还会将输入的参数保存到寄存器和栈上。这样,objc_msgSend就能够先将未知的参数保存到寄存器和栈上,然后在函数体执行自身指令或者跳转其它函数,最后在结束收尾部分恢复寄存器,回到调用函数之前的状态。

得益于序言准备部分可以事先准备好寄存器和栈,objc_msgSend可以做到函数调用无需通过编译生成汇编代码来遵循调用惯例(通过苹果自己写的汇编接管了,不需要编译器参与了),进而使得OC具备了动态调用函数的能力。

但是,不同的 CPU 架构,在编译时会执行不同的 objc_msgSend 函数,而且 objc_msgSend 函数无法直接调用 C 函数,所以想要实现动态地调用 C 函数就需要使用另一个用汇编语言编写的库 libffi

2. libffi

2.1 libffi简介

“FFI” 的全名是 Foreign Function Interface(外部函数接口),通过外部函数接口允许用一种语言编写的代码调用用另一种语言编写的代码。libffi提供了最底层的接口,在不确定参数个数和类型的情况下,根据相应规则,完成所需数据的准备,生成相应汇编指令的代码来完成函数调用。

libffi还提供了可移植的高级语言接口,可以不使用函数签名间接调用C函数。比如,脚本语言Python在运行时会使用libffi高级语言的接口去调用C函数。libffi的作用类似于一个动态的编译器,在运行时就能够完成编译时所做的调用惯例函数调用代码生成

libffi可以认为是实现了C语言的runtimelibffi通过调用 ffi_call(函数调用)来进行函数调用,ffi_call的输入是ffi_cif(模板)函数指针参数地址。其中,ffi_cifffi_type(参数类型)参数个数生成,也可以是ffi_closure(闭包)

2.2 libffi使用

2.2.1 ffi_type (参数类型)

ffi_type的作用是,描述C语言的基本类型,比如uint32void *struct等,定义如下:

typedef struct _ffi_type
{
  size_t size; // 所占大小
  unsigned short alignment; //对齐大小
  unsigned short type; // 标记类型的数字
  struct _ffi_type **elements; // 结构体中的元素
} ffi_type;

其中,size 表述该类型所占的大小,alignment 表示该类型的对齐大小,type 表示标记类型的数字,element 表示结构体的元素。当类型是 uint32 时,size 的值是 4,alignment 也是 4,type 的值是 9,elements 是空。

同时,libffi也提供了许多内置类型,用于描述参数和返回类型:
比如ffi_type_void、ffi_type_uint8、ffi_type_sint8、ffi_type_float、ffi_type_double等等。

2.2.2 ffi_cif (模板)

ffi_cif参数类型(ffi_type)参数个数生成,定义如下:

typedef struct {
  ffi_abi abi; // 不同 CPU 架构下的 ABI,一般设置为 FFI_DEFAULT_ABI
  unsigned nargs; // 参数个数
  ffi_type **arg_types; // 参数类型
  ffi_type *rtype; // 返回值类型
  unsigned bytes; // 参数所占空间大小,16的倍数
  unsigned flags; // 返回类型是结构体时要做的标记
#ifdef FFI_EXTRA_CIF_FIELDS
  FFI_EXTRA_CIF_FIELDS;
#endif
} ffi_cif;

如代码所示,ffi_cif包含了函数调用时需要的一些信息:

  • abi
    表示不同CPU架构下的ABI,一般设置为FFI_DEFAULT_ABI(在移动设备上 CPU 架构是 ARM64 时,FFI_DEFAULT_ABI 就是 FFI_SYSV;使用苹果公司笔记本 CPU 架构是 X86_DARWIN 时,FFI_DEFAULT_ABI 就是 FFI_UNIX64)

  • nargs
    表示输入参数的个数

  • arg_types
    表示参数的类型,比如 ffi_type_uint32

  • rtype
    表示返回类型,如果返回类型是结构体,字段flags需要设置数值作为标记,以便在ffi_prep_cif_machdep函数中处理,如果返回的不是结构体,flags不做标记

  • bytes
    表示输入参数所占空间的大小,是16的倍数

ffi_cif 是由 ffi_prep_cif函数生成的,返回值是ffi_status类型,一个枚举,表明结果如何,代码如下:

ffi_status ffi_prep_cif(ffi_cif *cif,
            ffi_abi abi,
            unsigned int nargs,
            ffi_type *rtype,
            ffi_type **atypes);
2.2.3 ffi_call (函数调用)

准备好函数模板之后,就可以使用ffi_call调用指定函数了,简单看个例子,结合了模板生成和函数调用步骤:

先定义一个C函数:

double addFunc(int a, double b){
    return a + b;
}

使用libffi调用这个C函数:

void libffi_add(){
    ffi_cif cif;
    // 参数值
    int a = 100;
    double b = 0.5;
    void *args[2] = { &a , &b};
    // 参数类型数组
    ffi_type *argTyeps[2] = { &ffi_type_sint, &ffi_type_double };
    //  参数返回值类型
    ffi_type *rettype = &ffi_type_double;

    //根据参数和返回值类型,设置cif模板
    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, sizeof(args) / sizeof(void *), rettype, argTyeps);

    // 返回值
    double result = 0;
    
    //使用cif函数签名信息,调用函数
    ffi_call(&cif, (void *)&addFunc, &result, args);
    
    // assert
    assert(result == 100.5);
}
2.2.4 ffi_prep_closure_loc

如下代码所示,在 testFFIClosure 函数准备好 cif 后,会声明一个新的函数指针,这个新的函数指针会和分配的 ffi_closure 关联,ffi_closure 还会通过ffi_prep_closure_loc 函数关联到cifclosure函数实体 closureCalled,当我们调用addNumA:numB:方法的时候,会调用到那个imp,之后会调用到关联的函数实体closureCalled中:

// 用来hook原有方法的函数
void closureCalled(ffi_cif *cif, void *ret, void **args, void *userdata) {
    int bar = *((int *)args[2]);
    int baz = *((int *)args[3]);
    *((int *)ret) = bar * baz;
}

void testFFIClosure() {
    // 准备模板
    ffi_cif cif;
    ffi_type *argumentTypes[] = {&ffi_type_pointer, &ffi_type_pointer, &ffi_type_sint32, &ffi_type_sint32};
    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 4, &ffi_type_pointer, argumentTypes);
    
    // 新的函数指针
    IMP newIMP;
    
    // 分配一个closure关联新声明的函数指针
    ffi_closure *closure = ffi_closure_alloc(sizeof(ffi_closure), (void *)&newIMP);
    
    // ffi_closure 关联 cif closure 函数实体 closureCalled
    ffi_prep_closure_loc(closure, &cif, closureCalled, NULL, NULL);
    
    // 使用Runtime 接口将 fooWithBar:baz 方法绑定到 closureCalled 函数指针上
    Method method = class_getInstanceMethod([TestFFI class], @selector(addNumA:numB:));
    method_setImplementation(method, newIMP);

    // after hook
    TestFFI *test = [TestFFI new];
    int ret = [test addNumA:123 numB:456];
    NSLog(@"ffi_closure: %d", ret);
}

libffi能调用任意C函数的原理和objc_msgSend的原理类似,底层都是用汇编实现的,ffi_call根据模板cif和参数值,把参数都按规则塞到栈/寄存器里,调用的函数可以按规则取到参数,调用完再获取返回值,清理数据。
通过其他方式调用上文中的impffi_closure可根据栈/寄存器、模板cif拿到所有的参数,接着执行自定义函数testFFIClosure里的代码。

通过libffi可以hook系统的方法实现,在一些支持热修复的库中,也有用到libffi,更多的了解和使用,还是看libffi的github吧。

相关链接

libffi的github地址
sunnyxxx的libffi示例
如何动态调用C函数
利用libffi实现AOP
动态调用和定义C函数

你可能感兴趣的:([iOS]libffi动态调用C函数)