函数地址&函数指针
在如何动态调用C函数之前,我们先来看一个demo
int func(int value) {
return value;
}
int main(int argc, char * argv[]) {
int (*funcPtr)(int) = &func;
int value = (*func)(10); // value = 10
return 0;
}
这涉及到两个知识点,函数指针,函数地址。
如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针。
了解到函数指针和函数地址的联系之后,我们也清楚了,若要动态调用C函数,第一步就是需要找到函数地址,也就是这个函数名字符串。通过上述例子,我们其实已经得出C编译后的可执行文件存在原函数名的信息,不过既然是解析,还是有必要去验证一番。
使用otool查看上述demo的汇编,发现函数名字符串的确记录下来了,言验证了上面的结论.
main.o:
(__TEXT,__text) section
_func:
0000000100000f60 pushq %rbp
0000000100000f61 movq %rsp, %rbp
0000000100000f64 movl %edi, -0x4(%rbp)
0000000100000f67 movl -0x4(%rbp), %eax
0000000100000f6a popq %rbp
0000000100000f6b retq
0000000100000f6c nopl (%rax)
_main:
0000000100000f70 pushq %rbp
0000000100000f71 movq %rsp, %rbp
0000000100000f74 subq $0x20, %rsp
0000000100000f78 movl $0xa, %eax
0000000100000f7d leaq _func(%rip), %rcx
0000000100000f84 movl $0x0, -0x4(%rbp)
0000000100000f8b movl %edi, -0x8(%rbp)
0000000100000f8e movq %rsi, -0x10(%rbp)
0000000100000f92 movq %rcx, -0x18(%rbp)
0000000100000f96 movl %eax, %edi
0000000100000f98 callq _func
0000000100000f9d xorl %edi, %edi
0000000100000f9f movl %eax, -0x1c(%rbp)
0000000100000fa2 movl %edi, %eax
0000000100000fa4 addq $0x20, %rsp
0000000100000fa8 popq %rbp
0000000100000fa9 retq
通过函数名得到对应的函数地址之后,这样就可以自由调用所有C函数了吗?答案当然是否定的。还是举一个例子来说明
int func(int n, int m) {
printf("func");
return 1;
}
int main() {
// 1
int (*funcPtr)(int, int) = &func;
funcPtr(1, 2);
// 2
void (*funcPtr)() = &func;
funcPtr(1, 2); //error
return 0;
}
(1表示调用正确定义了函数参数/返回值类型的函数指针,2表示调用没有正确定义参数/返回值类型的函数指针)
PS: 这边想提下dlsym()
,这是动态链接器提供的一个 API,本来是用于动态加载库(DLL),然后通过这个接口拿到函数地址,它也可以应用于当前可执行文件镜像,原理是一样的
// 两者是一致的
int (*funcPtr)(int, int) = &func;
int (*funcPtr)(int, int) = dlsym(RTLD_DEFAULT, "func");
这个例子中虽然我们得到了func
的函数指针,必须像 1
那样指明它的返回类型和参数类型后,才能调用成功,如果像 2
那样定义这个指针,没有正确的参数类型和返回值类型,在调用时就会出现crash。
也就是说我们没法通过定义一个万能的函数指针去支持所有函数的动态调用,这里必须让函数的参数/返回值类型都对应上才能调用,为什么必须要对应上呢?因为函数的调用方和被调用方是会遵循一种叫调用惯例(Calling Convention)的约定的。
调用惯例
一个函数的调用过程中,函数的参数既可以使用栈传递,也可以使用寄存器传递,参数压栈的顺序可以从左到右也可以从右到左,函数调用后参数从栈弹出这个工作可以由函数调用方完成,也可以由被调用方完成。如果函数的调用方和被调用方(函数本身)不遵循统一的约定,有这么多分歧这个函数调用就没法完成。这个双方必须遵守的统一约定就叫做调用惯例(Calling Convention),调用惯例规定了参数的传递的顺序和方式,以及栈的维护方式。
函数调用者和被调用者需要遵循这同一套约定,上述2这样的情况,就是函数本身遵循了这个约定,而调用者没有遵守,导致调用出错。
也就是说如果需要动态调用任意 C 函数,就得先准备好任意 参数类型/参数个数/返回值类型 排列组合的 C 函数指针,让最终的汇编把所有情况都准备好,最后调用时通过 switch 去找到正确的那个去执行就可以了。但显然这是很糟糕的主意。
objc_msgSend
OC 所有方法调用最终都会走到 objc_msgSend去调用,这个神奇的方法支持任意返回值任意参数类型和个数,而它的定义仅是这样:
void objc_msgSend(void /* id self, SEL op, ... */ )
为什么它就可以支持所有函数调用呢,不是说调用者和函数本身要遵循调用惯例吗,这个函数跟我们上述的2有什么区别?
答案是在C语言层面上没区别,但人家在汇编上做了手脚,objc_msgSend是用汇编写的,在调用这个函数之前,会把栈/寄存器等数据都准备好,相当于调用前对参数入栈等处理由这个函数自己写的汇编代码接管了,不需要编译器在调用处去生成这些指令。
libffi
libffi是C的runtime,提供了动态调用任意C函数的动能
先来看看怎样通过libffi动态调用一个C函数
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);
}
}
梳理下主要的流程
- 获取函数指针
- 给每个参数申请内存空间,封装成参数类型数组
- 生成ffi_cfi对象,生成模板
- ffi_call调用
这里每一步都可以在运行时动态去做,也就做到了在运行时动态调用任意C函数
这里最终 libffi 能调用任意 C 函数的原理跟上面说的 objc_msgSend的原理差不多,ffi_call底层是用汇编实现的,它在调用我们传入的函数之前,会根据上面提到的函数原型 cif 和参数数据,把参数都按规则塞到栈/寄存器里,准备好数据和状态,这样调用的函数实体里就可以按规则取到这些参数,正常执行了。调用完再获取返回值,清理这些栈帧/寄存器数据。libffi 针对每个架构不同的 Calling Convention 写了不同的汇编代码去做这个事。
引申
上面讲到了使用libffi来动态调用,那假如支持任意参数类型的C函数呢?
常规我们能想到的:
对所有参数类型和个数进行排列组合,然后静态声明N个函数,在运行时根据参数类型个数分配对应的函数
那有其他方法动态定义对应的函数吗?