如何动态调用 C 函数

JSPatch 支持了动态调用 C 函数,无需在编译前桥接每个要调用的 C 函数,只需要在 JS 里调用前声明下这个函数,就可以直接调用:

1
2
3
require('JPEngine').addExtensions(['JPCFunction'])
defineCFunction("malloc", "void *, size_t")
malloc(10)

我们一步步来看看怎样可以做到动态调用 C 函数。

函数地址

首先若要动态调用 C 函数,第一步就是需要通过传入一个函数名字符串找到这个函数地址,这里一个必要的前提条件就是 C 编译后的可执行文件里必须有原函数名的信息,才有可能做到通过函数名字符串找到函数地址。我们写个简单的程序来看看它编译后可执行文件的内容有没有这个信息:

1
2
3
4
5
6
7
//main.m
void test() {
}
int main() {
return 0;
}

编译这个文件,并用otool看下它的汇编:

1
2
gcc main.m -o main.o
otool -tV main.o

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
main.o:
(__TEXT,__text) section
_test:
0000000100000f90 pushq %rbp
0000000100000f91 movq %rsp, %rbp
0000000100000f94 popq %rbp
0000000100000f95 retq
0000000100000f96 nopw %cs:(%rax,%rax)
_main:
0000000100000fa0 pushq %rbp
0000000100000fa1 movq %rsp, %rbp
0000000100000fa4 xorl %eax, %eax
0000000100000fa6 movl $0x0, -0x4(%rbp)
0000000100000fad popq %rbp
0000000100000fae retq

可以看到函数名 test 和 main 都清楚地记录在可执行文件里,只不过前面多了个下划线_,所以完全可以在运行时通过函数名字符串查到这个函数地址。

dlsym()

实际上动态链接器已经提供一个 API:dlsym(),本来是用于动态加载库(DLL),然后通过这个接口拿到函数地址,它也可以应用于当前可执行文件镜像,原理是一样的。

1
2
3
4
5
6
7
8
9
void test() {
printf("testFunc");
}
int main() {
void (*funcPointer)() = dlsym(RTLD_DEFAULT, "test");
funcPointer();
return 0;
}

好了现在我们可以通过函数名拿到对应的函数地址了,这样就可以自由动态调用所有 C 函数了吗?还不行,这样只能动态调用返回值和参数都为空的 C 函数,上面 funcPointer 指针只能在指向参数返回值都为空的函数时才能正确调用到。对于有返回值和有参数的 C 函数,这里定义时需要指明参数和返回值类型才能使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

(这里①和②两个调用方式下文会多次提到,①表示调用正确定义了函数参数/返回值类型的函数指针,②表示调用没有正确定义参数/返回值类型的函数指针)

这个例子中 dlsym 返回了 testFunc 的函数指针,必须像 ① 那样指明它的返回类型和参数类型后,才能调用成功,如果像 ② 那样定义这个指针,没有正确的参数类型和返回值类型,在调用时就会出现crash。

也就是说我们没法通过定义一个万能的函数指针去支持所有函数的动态调用,这里必须让函数的参数/返回值类型都对应上才能调用,为什么必须要对应上呢?因为函数的调用方和被调用方是会遵循一种叫调用惯例(Calling Convention)的约定的。

Calling Convention

一个函数的调用过程中,函数的参数既可以使用栈传递,也可以使用寄存器传递,参数压栈的顺序可以从左到右也可以从右到左,函数调用后参数从栈弹出这个工作可以由函数调用方完成,也可以由被调用方完成。如果函数的调用方和被调用方(函数本身)不遵循统一的约定,有这么多分歧这个函数调用就没法完成。这个双方必须遵守的统一约定就叫做调用惯例(Calling Convention),调用惯例规定了参数的传递的顺序和方式,以及栈的维护方式。

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

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

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

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

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

在 C 语言这个层面上是解决不了这个问题的,要解决只能再往底层走,靠汇编。

(P.S. 在不同 CPU 架构上调用惯例不同,例如arm32位所有参数都通过栈传递,arm64位会让部分参数通过寄存器传递,超出寄存器大小的参数才通过栈传递,因为64位机器多出了寄存器,通过寄存器传递比栈快。不过就算所有CPU架构调用惯例相同,也不影响我们碰到的这个问题,你可以忽略这点。)

objc_msgSend

实际上你会发现 OC 上有个函数脱离了上述限制,就是 objc_msgSend。OC 所有方法调用最终都会走到 objc_msgSend 去调用,这个神奇的方法支持任意返回值任意参数类型和个数,而它的定义仅是这样:

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

为什么它就可以支持所有函数调用呢,不是说调用者和函数本身要遵循调用惯例吗,这个函数跟我们上述的②有什么区别?

答案是在C语言层面上没区别,但人家在汇编上做了手脚,objc_msgSend 是用汇编写的,在调用这个函数之前,会把栈/寄存器等数据都准备好,相当于调用前对参数入栈等处理由这个函数自己写的汇编代码接管了,不需要编译器在调用处去生成这些指令。

这里会在调用真正的函数之前,根据 Calling Convention 准备好栈帧/寄存器数据和状态,最后再 jump/call 到函数实体执行就可以了,这时函数实体按约定去取参数是取得到的,可以正常执行。于是 objc 就做到了在编译前只需要定义一个简单的 objc_msgSend,就支持运行时动态调用任意类型的 C 函数(所有 OC 方法的 IMP)。

所以我们要仿照 objc_msgSend 做一遍这个事情吗?难度好高:(。不用怕, libffi 这个神器已经帮你做了。

libffi

对 libffi 的介绍可以看 [这里],简单来说它就是提供了动态调用任意 C 函数的功能。

先来看看怎样通过 libffi 动态调用一个 C 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
int testFunc(int m, int n) {
printf("params: %d %d \n", n, m);
return n+m;
}
int main() {
//拿函数指针
void* functionPtr = dlsym(RTLD_DEFAULT, "testFunc");
int argCount = 2;
//按ffi要求组装好参数类型数组
ffi_type **ffiArgTypes = alloca(sizeof(ffi_type *) *argCount);
ffiArgTypes[0] = &ffi_type_sint;
ffiArgTypes[1] = &ffi_type_sint;
//按ffi要求组装好参数数据数组
void **ffiArgs = alloca(sizeof(void *) *argCount);
void *ffiArgPtr = alloca(ffiArgTypes[0]->size);
int *argPtr = ffiArgPtr;
*argPtr = 1;
ffiArgs[0] = ffiArgPtr;
void *ffiArgPtr2 = alloca(ffiArgTypes[1]->size);
int *argPtr2 = ffiArgPtr2;
*argPtr2 = 2;
ffiArgs[1] = ffiArgPtr2;
//生成 ffi_cfi 对象,保存函数参数个数/类型等信息,相当于一个函数原型
ffi_cif cif;
ffi_type *returnFfiType = &ffi_type_sint;
ffi_status ffiPrepStatus = ffi_prep_cif_var(&cif, FFI_DEFAULT_ABI, (unsigned int)0, (unsigned int)argCount, returnFfiType, ffiArgTypes);
if (ffiPrepStatus == FFI_OK) {
//生成用于保存返回值的内存
void *returnPtr = NULL;
if (returnFfiType->size) {
returnPtr = alloca(returnFfiType->size);
}
//根据cif函数原型,函数指针,返回值内存指针,函数参数数据调用这个函数
ffi_call(&cif, functionPtr, returnPtr, ffiArgs);
//拿到返回值
int returnValue = *(int *)returnPtr;
printf("ret: %d \n", returnValue);
}
}

看起来挺复杂的,梳理一下就这几步:

  1. 通过 dlsym 拿到函数指针。
  2. 给每个参数申请内存空间,按 ffi 要求把参数数据组装成数组。(用alloca()申请空间,不需要free()去释放)
  3. 用函数参数个数/参数类型/返回值类型组装成 cif 对象,表示这个函数原型。(有点像OC的methodSignature)
  4. 申请内存空间用于保存函数返回值。
  5. 把 cif 函数原型,函数指针,返回值内存指针,参数数据 传入 ffi_call 调用这个函数。

这里每一步都是可以在运行时动态去做的,也就可以做到在运行时动态调用任意 C 函数了。

这里最终 libffi 能调用任意 C 函数的原理按我理解跟上面说的 objc_msgSend 的原理差不多,ffi_call 底层是用汇编实现的,它在调用我们传入的函数之前,会根据上面提到的函数原型 cif 和参数数据,把参数都按规则塞到栈/寄存器里,准备好数据和状态,这样调用的函数实体里就可以按规则取到这些参数,正常执行了。调用完再获取返回值,清理这些栈帧/寄存器数据。libffi 针对每个架构不同的 Calling Convention 写了不同的汇编代码去做这个事。可以参见 libffi 里的 sysv_arm64.S sysv_arm.S 等汇编源码。[这篇文章] 有一些细节解析,可以看看。

到这里已经完成了动态调用 C 函数,接下来的工作就只是在 JS 和 libffi 之间加一层转换,就可以让 JSPatch 支持动态调用 C 函数了,JPCFunction 就是做这层转换的。

JPCFunction

目前 JPCFunction 比较简单,直接看代码就可以了,简单说下流程:

  1. 调用 C 函数之前需要通过 defineCFunction 定义这个函数的各参数类型和返回值类型,defineCFunction 里解析了类型字符串,转换成一个 JPCFunctionSignature 对象,每个函数对应一个 JPCFunctionSignature对象,这里模拟了 OC 方法的NSMethodSignature
  2. 调用函数时根据函数名拿到 JPCFunctionSignature 对象,集齐了 参数个数/各参数类型/返回值类型/各参数数据 这些信息,组装成 ffi 需要的格式进行调用。

这里第二步的处理中对于 struct 类型会比较麻烦,目前还未支持参数/返回值类型为 struct 的 C 函数,后续会补上。

总结

回顾下动态调用 C 函数的探索过程,先是通过 dlsym() 拿到函数指针,然后需要告诉编译器这个函数的参数/返回值类型,编译器才会根据 Calling Convention 约定生成对应的汇编代码,在调用函数时对参数进行正确的入栈/存入寄存器等操作,让函数成功调用,这一步在运行时在 C 语言层面上无法做到,所以 objc_msgSend 和 libffi 都用汇编模拟了这一过程,达到动态调用 C 函数的目的。