FFI-Badge-FELLOW@4x.png

FFI的原理

FFI(Foreign Function Interface)是用来与其它语言交互的接口,在有些语言里面称为语言绑定(language bindings),v8里称为v8 binding,之所以我们能在浏览器中使用documentdom等api都是靠v8 binding实现。Java 里面一般称为 JNI(Java Native Interface) 或 JNA(Java Native Access)。由于现实中很多程序是由不同编程语言写的,每个语言的数据模型,函数调用方式都不一样,必然会涉及到跨语言调用,比如 A 语言写的函数如果想在 B 语言里面调用,这时一般有两种解决方案:一种是将函数做成一个服务,通过进程间通信(IPC)或网络协议通信(RPC);另一种就是直接通过 FFI 调用。前者需要至少两个独立的进程才能实现,而后者直接将其它语言的接口内嵌到本语言中,所以调用效率比前者高。
CFFI(C Language Foreign Function Interface),是和c语言的交互的接口。通过CFFI我们可以使用其它语言调用调用c语言的函数。例如我们可以在javaScript中通过cffi调用c的函数等。CFFI利用的是C语言的函数调用约定来实现。通过c语言的函数调用约定。可以把参数放在约定的寄存器或者堆栈中。把函数执行结果从寄存器或者堆栈中获取。这样我们就可以实现跨语言的函数调用。

lib-cffi的设计与实现。

image.png
c函数的调用需要几个条件,函数地址,参数传递,返回值,参数可以是任意数量,返回值是0个或者1个。lib-cffi嵌入式应用通过c函数接口传递函数地址,参数列表和参数数量到cffi_call函数。cffi_call函数根据参数数量计算堆栈空间。最后把堆栈空间的大小,函数地址,参数列表传递给cffi_call_platform汇编子程序。
cffi_call_platform汇编子程序根据C语言的调用约定,在指定的寄存器中获取到从cffi_call函数传递进来的参数。根据cffi_call计算的堆栈大小,初始化子程序所需要的堆栈大小,接着把其调用函数地址和参数列表保存到子程序的堆栈中。再根据c语言的调用约定。把调用函数的参数按照约定存放在指令的寄存器中。最后执行call 指令执行调用函数。
lib-cffi 只针对x86-64位的linux有效。参数和放回值只支持指针类型,最大支持6个指针类型参数。基本类型可以通过强制转换成(vodi)指针。放回值统一使用`void`,基本类型返回值可以通过强制类型转换成对应的接过类型。下面我们看一下具体的实现。

  1. #if defined(__cplusplus)
  2. extern "C" {
  3. #endif
  4. #include "../include/cffi.h"
  5. // 汇编子程序
  6. extern void* cffi_call_platform(int params_count,void** params,int stack_size, void* fun);
  7. void* cffi_call(void* fun, int params_count , void** params){
  8. // 最大支持6个参数
  9. if(params_count > 6) {
  10. return 0;
  11. }
  12. // 计算 cffi_call_platform 函数需要分配的堆栈大小
  13. // 在这里全部使用64位寄存器作为参数传递。
  14. // 堆栈大小以16字节对齐。
  15. int stack_size = 0;
  16. // 获取最小公倍数
  17. double result = (params_count + 2.0) * 8 /16;
  18. if (result != (int)result) {
  19. stack_size = (int)(result + 1) * 16;
  20. } else {
  21. stack_size = (int)result * 16;
  22. }
  23. return cffi_call_platform(params_count,params, stack_size, fun);
  24. }
  25. #if defined(__cplusplus)
  26. }
  27. #endif
  1. void* fun:被调用函数的函数指针。
  2. int params_count:参数数量。需要根据参数数量来计算堆栈空间。
  3. void** params:参数数组。

最后的函数调用方式。

#include <iostream>

int add(int arg1, int agr2){
    return arg1 + agr2;
}

int main(int argc, char **argv) {
    // 参数列表
    void* argv [] = { (void*)1, (void*)2 };
    void* result = cffi_call((void*)add, 2, argv);
    std::cout << (unsigned long long)result << std::endl; // 3
}

在C语言调用约定中我们知道。在调用 call 指令之前,必须保证堆栈是16字节对齐的。所以cffi_call_platform汇编子程序的堆栈空间必须以16字节对齐。因为参数列表全部采用指针类型,在64位系统下。参数大小都为8字节大小。通过计算实例所需的堆栈内存和16字节对齐的规则计算出 cffi_call_platform汇编子程序所需要的16位字节对齐的堆栈空间并把堆栈大小结果存放在变量int stack_size中。最后调用了汇编子程序 cffi_call_platform。在C语言的函数调用约定中我们知道。在x86-64下c语言调用函数的参数传递的约定为。

  1. 一个函数在调用时,如果参数个数小于等于 6 个时,前 6 个参数是从左至右依次存放于 RDI,RSI,RDX,RCX,R8,R9 寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈;
  2. 如果参数个数大于 6 个时,前 5 个参数是从左至右依次存放于 RDI,RSI,RDX,RCX,RAX 寄存器里面,剩下的参数通过栈传递,从右至左顺序入栈;

因为lib-cffi中只考虑6个参数的情况下。所以在汇编子程序cffi_call_platform可以通过指定的寄存器拿到从C语言传递进来的参数。

  1. int params_count -> rdi: 被调用函数参数数量
  2. void** params -> rsi:被调用函数的参数列表
  3. int stack_size-> rdx:子程序cffi_call_platform所需要的堆栈大小。
  4. void* fun -> rcx:被调函数的函数指针。

    .text
     .globl    cffi_call_platform
     .type    cffi_call_platform, @function
     cffi_call_platform:
         pushq    %rbp
         movq    %rsp, %rbp# 初始化堆栈空间
            subq    %rdx, %rsp#分配局部变量的堆栈
         movq    %rdi, -8(%rbp)#保存参数数量
         movq    %rcx, -16(%rbp)#保存调用的函数指针
         pushq %rbx #需要恢复改寄存器
         movq %rsi, %rbx# 多参数数字的引用
    params_in_stack:
         cmpq $1,-8(%rbp)
         jl fun_call_params #参数小于1
         movq 0(%rbx), %rax
         movq %rax, -24(%rbp)
    
         cmpq $2,-8(%rbp)
         jl fun_call_params #参数小于2
         movq 8(%rbx), %rax
         movq %rax, -32(%rbp)
    
         cmpq $3,-8(%rbp)
         jl fun_call_params #参数小于3
         movq 16(%rbx), %rax
         movq %rax, -40(%rbp)
    
         cmpq $4,-8(%rbp)
         jl fun_call_params #参数小于4
         movq 24(%rbx), %rax
         movq %rax, -48(%rbp)
    
         cmpq $5,-8(%rbp)
         jl fun_call_params #参数小于5
         movq 32(%rbx), %rax
         movq %rax, -56(%rbp)
    
         cmpq $6,-8(%rbp)
         jl fun_call_params #参数小于5
         movq 40(%rbx), %rax
         movq %rax, -64(%rbp)
    # 把参数转配到指定的寄存器
    fun_call_params:
         cmpq $1,-8(%rbp)
         jl fun_call #参数小于1
         movq -24(%rbp), %rax
         movq %rax, %rdi
    
         cmpq $2,-8(%rbp)
         jl fun_call #参数小于2
         movq -32(%rbp), %rax
         movq %rax, %rsi
    
         cmpq $3,-8(%rbp)
         jl fun_call #参数小于3
         movq -40(%rbp), %rax
         movq %rax, %rdx
    
         cmpq $3,-8(%rbp)
         jl fun_call #参数小于4
         movq -48(%rbp), %rax
         movq %rax, %rcx
    
         cmpq $3,-8(%rbp)
         jl fun_call #参数小于5
         movq -56(%rbp), %rax
         movq %rax, %r8
    
         cmpq $3,-8(%rbp)
         jl fun_call #参数小于6
         movq -64(%rbp), %rax
         movq %rax, %r9
    fun_call:
         call *-16(%rbp)
         popq %rbx
         leave
         ret
    

    cffi_call_platform汇编子程序只要做了4个步骤。

  5. 初始化子程序所需要的堆栈空间。subq %rdx, %rsp,并把被调用函数指针和存放在堆栈空间中。

  6. params_in_stack标号只要是被调用函数的的参数列表存放在子程序的堆栈空间中。根据参数的数量在指定的堆栈空间中存放参数的值。
  7. fun_call_params标号只要是给被调用的函数传递参数,根据C语言函数调用规范。如果参数个数小于等于 6 个时,前 6 个参数是从左至右依次存放于 RDI,RSI,RDX,RCX,R8,R9 寄存器里面。所以根据参数个数的判断分别在上一步存放的函数参数移动到约定的寄存器中。
  8. 执行子程序的调用指令 call *-16(%rbp),指令操作数-16(%rbp)是第一步存放在堆栈空间的被调用函数的指针。

通过C语言的调用约定。通过对约定的寄存器做数据的移动,就能实现C语言函数的动态调用。这就是我们所所得cffi(c语言函数调用接口)

lib-cffi只实现了部分的C语言调用约定。参数传递也仅仅考虑寄存器传递,如果传递的参数数量过大。需要在堆栈空间中传递。这样需要考虑参数的对齐情况。也需要从外部中传递参数的类型作为堆栈空间的计算和内存的布局。

libffi测试用例的编写

一个库的稳定性需要大量的测试用例去验证去测试。项目地址-libffi

无参数,无返回值

int testValue1 = 0;
void funWithoutParamsWithoutRvalue() {
    testValue1++;
}

TEST(test_cffi_call_without_params_without_rValue, test_cffi_call) {
    EXPECT_EQ(testValue1, 0);
    cffi_call( (void*)funWithoutParamsWithoutRvalue, 0, nullptr);
    EXPECT_EQ(testValue1, 1);
}

无参数,有返回值

int funWithoutParams () {
    return 1;
}
TEST(test_cffi_call_without_params, test_cffi_call) {
    void* result = cffi_call( (void*)funWithoutParams, 0, nullptr);
    EXPECT_EQ((unsigned long long)result, 1);
}

有参数有返回值

int funWithTowParams(int arg1, int agr2){
    EXPECT_EQ(arg1,1);
    EXPECT_EQ(agr2,2);
    return arg1 + agr2;
}

TEST(test_cffi_call_with_tow_params, test_cffi_call) {
    void* argv [] = { (void*)1, (void*)2 };
    void* result = cffi_call( (void*)funWithTowParams, 2, argv);
    EXPECT_EQ((unsigned long long)result, 3);
}

最大的参数调用下

int funWithSixParams(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6) {
    EXPECT_EQ(arg1,1);
    EXPECT_EQ(arg2,2);
    EXPECT_EQ(arg3,3);
    EXPECT_EQ(arg4,4);
    EXPECT_EQ(arg5,5);
    EXPECT_EQ(arg6,6);
    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6;
}

TEST(test_cffi_call_with_six_params, test_cffi_call) {
    void* argv [] = { (void*)1, (void*)2 , (void*)3, (void*)4 ,(void*)5, (void*)6 };
    void* result = cffi_call( (void*)funWithSixParams, 6, argv);
    EXPECT_EQ((unsigned long long)result, 21);
}

字符串指针参数

int funWithStringParams (const char * str) {
    return strlen(str);
}

TEST(test_cffi_call_with_string_params, test_cffi_call) {
    void* argv[] = { (void*)"123456" };
    void* result = cffi_call( (void*)funWithStringParams, 1, argv);
    EXPECT_EQ((unsigned long long)result,6);
}

字符串指针作为返回值

const char* funWithStringRvalue() {
    return  "123456";
}

TEST(test_cffi_call_with_string_rValue, test_cffi_call) {
    void* result = cffi_call( (void*)funWithStringRvalue, 0, nullptr);
    const char* str = reinterpret_cast< const char*>(result);
    EXPECT_STREQ(str, "123456");
}

结构体指针

struct test_struct{
    int property1;
    const char* property2;
};

int funWithPointVar (test_struct* params) {
    EXPECT_EQ(params->property1, 1);
    EXPECT_STREQ(params->property2, "123456");
    int len = strlen(params->property2);
    return len + params->property1;
}

TEST(test_cffi_call_with_point_var, test_cffi_call) {
    test_struct testStruct{1, "123456"};
    void* argv[] = { (void*)&testStruct};
    void* result = cffi_call( (void*)funWithPointVar, 1, argv);
    EXPECT_EQ((unsigned long long)result,7);
}

指针应用测试

void funRef (int* params) {
    *params = 1;
}

TEST(test_cffi_call_with_ref_params, test_cffi_call) {
    int var = 0;
    void* argv[] = { (void*)&var};
    cffi_call( (void*)funRef, 1, argv);
    EXPECT_EQ(var, 1);
}

动态链接库,bar.so文件时linux系统下的一个动态链接库。里面有一个导出函数add,参数为两个整型,并放回两个参数相加的整型结果。

TEST(test_cffi_call_so_lib, test_cffi_call) {
    void* handle = dlopen("./bar.so",  RTLD_LAZY);
    EXPECT_TRUE(!!handle);
    void* add = dlsym( handle, "add" );
    void* argv [] = { (void*)1, (void*)2 };
    void* result = cffi_call( add, 2, argv);
    EXPECT_EQ((unsigned long long)result, 3);
    dlclose(handle);
}