聊聊libffi的調用流程

背景

花了點時間分析了下libffi的調用流程,做個總結。

什么是libffi

libffi是ffi的主流實現方式,其主要是用C和匯編來實現的。

原理和用法市面上已經很多,下面這兩篇是我覺得講得較為通俗易懂的,這里就不做過多的解釋了。

外部函數接口 FFI —— 虛擬機中重要但不起眼的組件

使用 libffi 實現 AOP

libffi的調用流程

PS:最近換了M1,所以以下的代碼都是ARM64架構下的邏輯,libffi版本3.4.2

1.ffi_call

直接上手,第一種動態調用方式 ffi_call

int fun1 (int a, int b) {
    return a + b;
}

- (void)libffiCallTest{
    ffi_type **types;  // 參數類型
    types = malloc(sizeof(ffi_type *) * 2) ;
    types[0] = &ffi_type_sint;
    types[1] = &ffi_type_sint;
    // 返回類型
    ffi_type *retType = &ffi_type_sint;

    void **args = malloc(sizeof(void *) * 2);
    int x = 1, y = 2;
    args[0] = &x;
    args[1] = &y;

    int ret;

    ffi_cif cif;
    // 生成模板
    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, retType, types);
    // 動態調用fun1
    ffi_call(&cif, fun1,  &ret, args);
    NSLog(@"libffi return func1 value: %d", ret);
}

來看看ffi_call這個核心函數到底是如何幫我進行動態調用的,首先會進入ffi_call_int方法,在該方法中,第一個核心的邏輯

0x01.拉伸SP,開辟棧空間

printf("----");
context = alloca (sizeof(struct call_context) + stack_bytes + 40 + rsize);// 拉伸sp
printf("----");// 
stack = context + 1;
frame = (void*)((uintptr_t)stack + (uintptr_t)stack_bytes); // fp
rvalue = (rsize ? (void*)((uintptr_t)frame + 40) : orig_rvalue); // 返回值地址

allocamalloc的區別在于,前者是在棧上開辟新的空間,后者是在堆上開辟新的空間。

image-20220510000544075.png

通過匯編可以得知,在alloca底層實現中會拉伸sp,context的首地址就是新的sp的地址,開辟的空間就是接下來的匯編調用做準備。這里的硬編碼的40,主要是放了放置lr, 原fp, 返回值rvalue, 返回值類型flags, 原sp。

0x02.參數入棧

 for (i = 0, nargs = cif->nargs; i < nargs; i++)
    {
      ffi_type *ty = cif->arg_types[i];
      size_t s = ty->size;
      void *a = avalue[i];
      int h, t;

      t = ty->type;
      switch (t)
    {
    ...
    case FFI_TYPE_SINT16:
    case FFI_TYPE_UINT32:
    case FFI_TYPE_SINT32:
    case FFI_TYPE_UINT64:
    case FFI_TYPE_SINT64:
    case FFI_TYPE_POINTER:
    do_pointer:
      {
        ffi_arg ext = extend_integer_type (a, t);
        if (state.ngrn < N_X_ARG_REG)
          context->x[state.ngrn++] = ext; // 參數小于8個,放在context->x中,從棧頂部開始分配
        else
          {
        void *d = allocate_to_stack (&state, stack, ty->alignment, s);// 參數大于8個,從底部stack開始分配
        state.ngrn = N_X_ARG_REG;
        ...
          }
      }
      break;
...

可以看到參數的數量小于/大于寄存器數量(arm是x0-x7作為參數寄存器)還是略有區別,這是為了方便后面再次取出做準備。

0x03.ffi_call_SYSV

ffi_call_SYSV (context, frame, fn, rvalue, flags, closure);

有了函數地址和函數調用該有的環境,接下來進入真正調用的階段,這部分是匯編實現的,

CNAME(ffi_call_SYSV):
    /* Sign the lr with x1 since that is where it will be stored */
  ...
  /* x0 = context, x1 = frame ,x2 = fn ,x3 = rvalue ...*/
    stp x29, x30, [x1] // fp和sp 相應入棧
    mov x9, sp
    str x9, [x1, #32]
    mov x29, x1
    mov sp, x0 // 這里sp又重新賦值,其實在alloc的時候sp已經變了。

  ...

    mov x9, x2          /* x9 = fn */
    mov x8, x3          /* x8 = rvalue */
#ifdef FFI_GO_CLOSURES
    mov x18, x5         /* install static chain */
#endif
    stp x3, x4, [x29, #16]  /* save rvalue and flags */

    /* Load the core argument passing registers, including
       the structure return pointer.  */
    ldp     x0, x1, [sp, #16*N_V_ARG_REG + 0]    /* 把提前準備的參數存入寄存器中 */
    ldp     x2, x3, [sp, #16*N_V_ARG_REG + 16]
    ldp     x4, x5, [sp, #16*N_V_ARG_REG + 32]
    ldp     x6, x7, [sp, #16*N_V_ARG_REG + 48]

    /* 參數已經存入寄存器了,銷毀context */
    add sp, sp, #CALL_CONTEXT_SIZE

  /* 調用真正的函數地址 */
    BRANCH_AND_LINK_TO_REG     x9           /* call fn */

    /* 把放返回值的地址和類型標識地址重新取回 */
    ldp x3, x4, [x29, #16]  /* reload rvalue and flags */

    /*  通過返回值的類型,計算不同邏輯 */
    adr x5, 0f
    and w4, w4, #AARCH64_RET_MASK
    add x5, x5, x4, lsl #3
    br  x5
    
  ...
  /* 把返回值放回x0 */
0:  b 99f               /* VOID */
    nop
1:  str x0, [x3]        /* INT64 */
    b 99f
2:  stp x0, x1, [x3]        /* INT128 */
    b 99f

  ...
  /*  結束  */
    ret
image-20220510140445309.png

黑色以上部分是函數調用環境準備之后的狀態。

2.ffi_closure

第二種動態創建函數進行調用

- (void)libffiBindTest {
    //1.
    ffi_type **argTypes;
    ffi_type *returnTypes;
    
    argTypes = malloc(sizeof(ffi_type *) * 2);
    argTypes[0] = &ffi_type_sint;
    argTypes[1] = &ffi_type_sint;
    
    returnTypes = malloc(sizeof(ffi_type *));
    returnTypes = &ffi_type_pointer;
    
    ffi_cif *cif = malloc(sizeof(ffi_cif));
    ffi_status status = ffi_prep_cif(cif, FFI_DEFAULT_ABI, 2, returnTypes, argTypes);
    if (status != FFI_OK) {
        NSLog(@"ffi_prep_cif return %u", status);
        return;
    }
    //2.
    char* (*funcInvoke)(int, int);
    //3.
    ffi_closure *closure = ffi_closure_alloc(sizeof(ffi_closure), &funcInvoke);
    //4.
    status = ffi_prep_closure_loc(closure, cif, bind_func, (__bridge void *)self, funcInvoke);
    if (status != FFI_OK) {
        NSLog(@"ffi_prep_closure_loc return %u", status);
        return;
    }
    //5.
    char *result = funcInvoke(2, 3);
    NSLog(@"libffi return func value: %@", [NSString stringWithUTF8String:result]);
    ffi_closure_free(closure);
}

// 6.
void bind_func(ffi_cif *cif, char **ret, int **args, void *userdata) {
    //7.
    int value0 = *args[0];
    int value1 = *args[1];
    const char *result = [[NSString stringWithFormat:@"str-%d", (value0 + value1)] UTF8String];
    //8.
    *ret = result;
}

可以看到,申明了一個函數char* (*funcInvoke)(int, int);但開始沒有具體實現,ffi_prep_closure_loc方法將申明的函數和一個通用的bind_func進行了一個綁定,當funcInvoke(2, 3);時,會來到我們的綁定函數bind_func,你可以在這里做函數的真正實現和函數返回。

那么libffi是怎么幫我們做到這一點的呢?

申明的函數都會在庫的內部綁上統一函數實現,可以理解為一個跳板(trampoline),通過這個跳板函數,找到之前申明的函數調用上下文環境(如參數類型、返回值類型等等),和入參組裝之后,再一起跳轉丟給到bind_func,接下來梳理下大致流程。

0x01.創建跳板頁

/* Allocate two pages -- a config page and a placeholder page */
config_page = 0x0;
kt = vm_allocate (mach_task_self (), &config_page, PAGE_MAX_SIZE * 2,
VM_FLAGS_ANYWHERE);
if (kt != KERN_SUCCESS)
return NULL;

/* Remap the trampoline table on top of the placeholder page */
trampoline_page = config_page + PAGE_MAX_SIZE;

vm_allocate這個函數是linux底層分配的內存的函數,他只能以頁為單位來分配連續的內存,分配之后不會立即進行與物理內存的映射,在這里是開辟了兩個頁的虛擬內存,一個作為配置頁,一個作為占位頁。

0x02.vm_remap

/* Remap the trampoline table on top of the placeholder page */
  trampoline_page = config_page + PAGE_MAX_SIZE;
    
#ifdef HAVE_PTRAUTH
  trampoline_page_template = (vm_address_t)(uintptr_t)ptrauth_auth_data((void *)&ffi_closure_trampoline_table_page, ptrauth_key_function_pointer, 0);
#else
  trampoline_page_template = (vm_address_t)&ffi_closure_trampoline_table_page;
#endif

#ifdef __arm__
  /* ffi_closure_trampoline_table_page can be thumb-biased on some ARM archs */
  trampoline_page_template &= ~1UL;
#endif
  kt = vm_remap (mach_task_self (), &trampoline_page, PAGE_MAX_SIZE, 0x0,
         VM_FLAGS_OVERWRITE, mach_task_self (), trampoline_page_template,
         FALSE, &cur_prot, &max_prot, VM_INHERIT_SHARE);
  if (kt != KERN_SUCCESS || !(cur_prot & VM_PROT_EXECUTE))
  {
  vm_deallocate (mach_task_self (), config_page, PAGE_MAX_SIZE * 2);
  return NULL;
  }

vm_remap的作用是內存映射,通過它,我們就能實現一個對象通過多個不同的地址來進行訪問(可以看Thunk程序的實現原理以及在iOS中的應用(二)進行理解),在這里是把上述0x01中的占位頁的首地址映射到了一個函數上(ffi_closure_trampoline_table_page),該函數由匯編實現。

0x03.創建跳板表

/* We have valid trampoline and config pages */
  table = calloc (1, sizeof (ffi_trampoline_table));
  table->free_count = FFI_TRAMPOLINE_COUNT;
  table->config_page = config_page;

  /* Create and initialize the free list */
  table->free_list_pool =
    calloc (FFI_TRAMPOLINE_COUNT, sizeof (ffi_trampoline_table_entry));

  for (i = 0; i < table->free_count; i++)
    {
      ffi_trampoline_table_entry *entry = &table->free_list_pool[i];
      entry->trampoline =
    (void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));
#ifdef HAVE_PTRAUTH
      entry->trampoline = ptrauth_sign_unauthenticated(entry->trampoline, ptrauth_key_function_pointer, 0);
#endif

      if (i < table->free_count - 1)
    entry->next = &table->free_list_pool[i + 1];
    }

  table->free_list = table->free_list_pool;
  
  return table;

跳板表在這里創建。

table->config_page = config_page

表的config_page指向跳板頁的第一頁。

entry->trampoline =
    (void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));

表中的一個個entrytrampoline屬性指向跳板頁的第二頁 + 偏移。

*code = entry->trampoline; // funcInvoke = entry->trampoline
closure->trampoline_table = table; 
closure->trampoline_table_entry = entry;
return closure;

可以看到,一開始申明的funcInvoke的實際地址,其實就是指向了跳板表里entry->trampoline,又trampoline已經remap到了ffi_closure_trampoline_table_page上,來看下ffi_closure_trampoline_table_page的實現

CNAME(ffi_closure_trampoline_table_page):
    .rept PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE
    adr x16, -PAGE_MAX_SIZE
    ldp x17, x16, [x16]
    br x16
    nop     /* each entry in the trampoline config page is 2*sizeof(void*) so the trampoline itself cannot be smaller than 16 bytes */
    .endr

.rept times 代表以下代碼要重復的次數,可以看到,其實這一整頁每16個字節都填充了重復的實現,為什么要這么做呢?后面會講到

image-20220512105954981.png

所以到時候調用funcInvoke()的時候,會跳兩次到ffi_closure_trampoline_table_page上,最終會去做上面說的這個重復的實現。

到此為止一個closure算是創建完畢了,里面具備了基本的調用環境。

0x04.ffi_prep_closure_loc

//...
  start = ffi_closure_SYSV;
//...
  void **config = (void **)((uint8_t *)codeloc - PAGE_MAX_SIZE); // *codeloc = funcInvoke
  config[0] = closure;
  config[1] = start; //ffi_closure_SYSV
//...  
    closure->cif = cif;
  closure->fun = fun;
  closure->user_data = user_data;

  return FFI_OK;

0x03說到,funcInvoke的實際地址是跳板頁的(第二頁 + 偏移),那么codeloc - PAGE_MAX_SIZE就是我們創建的跳版頁第一頁 + 偏移,在第一頁 + 偏移的位置前后八個字節放了兩個東西,一個就是我們之前創建closure,后八個字節放的是一個ffi_closure_SYSV函數,該函數也由匯編實現。最后將方法簽名cif、綁定函數fun等進行保存,一切就準備就緒了。

下圖是這個階段大致的現狀。

未命名文件.jpg

0x05.funcInvoke(2, 3);

當真正發生函數調用時,發生了什么呢?

函數的調用的實際調用entey->trampoline,該屬性又指向trampoline_page中的某片區域,而trampoline_page又因為remap到了ffi_closure_trampoline_table_page,經過一系列的反復橫跳會來到這。又因為調用是帶偏移的,再貼一下

for (i = 0; i < table->free_count; i++)
    {
    // ...
  entry->trampoline =
    (void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));
}

這就是為什么ffi_closure_trampoline_table_page里的都是重復的實現,因為調用都是攜帶偏移的,在工程里會有很多這樣的動態函數,哪個方法調進來事先是什么不知道,所以干脆整頁全部填充重復實現了

adr x16, -PAGE_MAX_SIZE // x16 = pc - PAGE_MAX_SIZE賦值
ldp x17, x16, [x16] /* x17 = closure ,x16 = start / ffi_closure_SYSV */
br x16

adr x16, -PAGE_MAX_SIZE 找到config page其中對應的內容。又因為當前的pc本身就是帶偏移的,所以可以在config page找到entry當時對應埋入的clousurestart函數分別賦給x16x17br x16跳轉到start(ffi_closure_SYSV),在這塊的實現思路跟第一部分的ffi_call_SYSV基本就大同小異了:

CNAME(ffi_closure_SYSV):
    SIGN_LR
    stp     x29, x30, [sp, #-ffi_closure_SYSV_FS]!  // 拉伸sp,x29,x30入棧
    cfi_adjust_cfa_offset (ffi_closure_SYSV_FS)
    cfi_rel_offset (x29, 0)
    cfi_rel_offset (x30, 8)
0:
    mov     x29, sp

    /* Save the argument passing core registers.  */
    stp     x0, x1, [sp, #16 + 16*N_V_ARG_REG + 0]  //funcInvoke參數入棧
    stp     x2, x3, [sp, #16 + 16*N_V_ARG_REG + 16]
    stp     x4, x5, [sp, #16 + 16*N_V_ARG_REG + 32]
    stp     x6, x7, [sp, #16 + 16*N_V_ARG_REG + 48]

    /* 從x17取出closure,讀取調用環境  */
    ldp PTR_REG(0), PTR_REG(1), [x17, #FFI_TRAMPOLINE_CLOSURE_OFFSET]   /* load cif, fn */
    ldr PTR_REG(2), [x17, #FFI_TRAMPOLINE_CLOSURE_OFFSET+PTR_SIZE*2]    /* load user_data */
#ifdef FFI_GO_CLOSURES
.Ldo_closure:
#endif
    add x3, sp, #16             /* load context */
    add x4, sp, #ffi_closure_SYSV_FS        /* load stack */
    add x5, sp, #16+CALL_CONTEXT_SIZE       /* load rvalue */
    mov x6, x8                  /* load struct_rval */
    /* 調用bindfun ,就會跳跳轉我們綁定的bindfun函數 */
    bl      CNAME(ffi_closure_SYSV_inner)

    /* 根據返回值類型,跳轉相關邏輯  */
    adr x1, 0f
    and w0, w0, #AARCH64_RET_MASK
    add x1, x1, x0, lsl #3
    add x3, sp, #16+CALL_CONTEXT_SIZE
    br  x1

    /* Note that each table entry is 2 insns, and thus 8 bytes.  */
    .align  4
0:  b   99f         /* VOID */
    nop
1:  ldr x0, [x3]        /* INT64 */
    b   99f
2:  ldp x0, x1, [x3]        /* INT128 */
    b   99f
...
31:                 /* reserved */
/* 恢復棧幀環境  */
99: ldp     x29, x30, [sp], #ffi_closure_SYSV_FS
    cfi_adjust_cfa_offset (-ffi_closure_SYSV_FS)
    cfi_restore (x29)
    cfi_restore (x30)
    AUTH_LR_AND_RET
    cfi_endproc

3.總結

至此我們了解了libffi是怎么幫助我們實現動態調用的,在開發過程中,我們可以用libffi幫助我們去實現一些常規代碼無法進行的動態調用和動態創建調用,比如iOS中的block hook等。

題外話: 學會黑科技,一招搞定iOS 14.2的 libffi crash 字節的這篇文章中說到,在14.2 libffi會crash,原因是vm_remap導致的code sign error,通過靜態跳板去解決這個問題,所謂的靜態跳板,其實就是不再使用占位頁,從而不需要通過remap映射,函數直接放在call到跳版頁(text段),由于缺少了和config_page的關聯(之前是直接vm_allocate了連續兩頁,由占位頁 - page_size找到config_page),所以算出偏移還不夠,需要通過adrp找到config_page(在.data段通過匯編分配)的基地址相加,找到clouse和start。

不過,我個人認為還是要先搞清楚vm_remap為什么會失敗。當然了,這個問題咱也沒碰到過,所以咱也不敢說。

4.參考

外部函數接口 FFI —— 虛擬機中重要但不起眼的組件

使用 libffi 實現 AOP

學會黑科技,一招搞定iOS 14.2的 libffi crash

Thunk程序的實現原理以及在iOS中的應用(二)

libffi/libffi

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容