Thunk程式的實現原理以及在iOS中的應用

歐陽大哥2013發表於2019-01-31

導讀:閱讀文字你將能夠了解到C標準庫對快速排序的支援、簡單的索引技術、Thunk技術的原理以及應用、C++虛擬函式呼叫以及介面多重繼承實現、動態庫中函式呼叫的實現原理、以及在iOS等各作業系統平臺上Thunk程式的實現方法、記憶體對映檔案技術。

在說Thunk程式之前,我想先通過一個實際中排序的例子來引出本文所要介紹的Thunk技術的方方面面。

C標準庫對排序的支援

C語言的標準庫<stdlib.h>中提供了一個用於快速排序的函式qsort,函式的簽名如下:

   /*
        @note: 實現快速排序功能
        @param: base 要排序的陣列指標
        @param: nmemb 陣列中元素的個數
        @param: size 陣列中每個元素的size
        @param: compar 排序元素比較函式指標, 用於比較兩個元素。返回值分別為-1, 0, 1。
   */
   void qsort(void *base, size_t nmemb, size_t size, int(*compar)(const void *, const void *));
複製程式碼

這個函式要求提供一個排序的陣列指標base, 陣列的元素個數nmemb, 陣列中每個元素的尺寸size,以及一個排序的比較器函式compar四個引數。下面的例子演示了這個函式的使用方法:

#include <stdlib.h>

typedef struct
{
    int age;
    char *name;
}student_t;

//按年齡升序排序的比較器函式
int agecomparfn(const student_t *s1, const student_t *s2)
{
    return s1->age - s2->age;
}

int main(int argc, const char * argv[])
{    
    student_t students[] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
    size_t count = sizeof(students)/sizeof(student_t);
    qsort(students, count, sizeof(student_t), &agecomparfn);
    for (size_t i = 0; i < count; i++)
    {
        printf("student:[age:%d, name:%s]\n", students[i].age, students[i].name);
    }

    return 0;
}
複製程式碼

函式排序後會將students中元素的記憶體儲存順序打亂。如果需求變為在不將students中的元素打亂情況下,仍希望按age的大小進行排序輸出顯示呢?

為了解決這個問題可以為students陣列建立一個索引陣列,然後對索引陣列進行排序即可。因為打亂的是索引陣列中的順序,而訪問元素時又可以通過索引陣列來間接訪問,這樣就可以實現原始資料記憶體儲存順序不改變的情況下進行有序輸出。程式碼實現改為如下:

#include <stdlib.h>

typedef struct
{
    int age;
    char *name;
}student_t;

student_t students[] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
size_t count = sizeof(students)/sizeof(student_t);

//按年齡升序索引排序的比較器函式
int  ageidxcomparfn(const int *idx1ptr, const int *idx2ptr)
{
    return students[*idx1ptr].age - students[*idx2ptr].age;
}

int main(int argc, const char * argv[])
 {    
    //建立一個索引陣列
    int idxs[] = {0,1,2,3,4};
    qsort(idxs, count, sizeof(int), &ageidxcomparfn);
    for (size_t i = 0; i < count; i++)
    {
        //通過索引間接引用
        printf("student[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
    }

    return 0;
 }
複製程式碼

從上面的程式碼中可以看出,排序時不再是對students陣列進行排序了,而是對索引陣列idxs進行排序了。同時在訪問students中的元素時也不再直接通過下標訪問,而是通過索引陣列的下標來進行間接訪問了。

索引技術是一種非常實用的技術,尤其是在資料庫系統上應用最廣泛,因為原始記錄儲存成本和檔案IO的原因,移動索引中的資料要比移動原始記錄資料要快而且方便很多,而且效能上也會大大的提升。當大量資料儲存在記憶體中也是如此,資料記錄在記憶體中因為排序而進行位置的移動要比索引陣列元素移動的開銷和成本大很多,而且如果涉及到多執行緒下要對不同的成員進行原始記錄的排序時還需要引入鎖的機制。

因此在實踐中對於那些大資料塊進行排序時,改為通過引入索引來進行間接排序將會使你的程式效能得到質的提高。

對比上面兩個排序的例項程式碼實現就會發現通過索引進行排序時不得不將students陣列從一個區域性變數轉化為一個全域性變數了,原因是由於排序比較器函式compar的定義限制導致的。

因為排序的物件從students變為idxs了,而排序比較器函式ageidxcomparfn的兩個入參變為索引值的int型別的指標,如果不將students陣列設定為全域性變數那麼比較器函式內部是無法訪問students中的元素的,所以只能將students定義為一個全域性陣列。

很明顯這種解決方案是非常不友好而且無法進行擴充套件的,同一個比較器函式無法實現對不同的students陣列進行排序。為了支援這種需要帶擴充套件引數的間接排序,很多平臺都提供了一個相應的非標準庫擴充函式(比如Windows下的qsort_s, iOS/macOS的qsort_r, qsort_b等)。

下面是採用iOS系統下的qsort_r函式來解決上述問題的程式碼:

#include <stdlib.h>

typedef struct
{
    int age;
    char *name;
}student_t;


//按年齡升序索引排序的帶擴充套件引數的排序比較器函式
int  ageidxcomparfn(student_t students[], const int *idx1ptr, const int *idx2ptr)
{
    return students[*idx1ptr].age - parray[*idx2ptr].age;
}

int main(int argc, const char * argv[])
{
    student_t students[] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
    int idxs[] = {0,1,2,3,4};
    size_t count = sizeof(students)/sizeof(student_t);
    //qsort_r增加一個thunk引數,函式比較器中也增加了一個引數。
    qsort_r(idxs, count, sizeof(int), students, &ageidxcomparfn);
    for (size_t i = 0; i < count; i++)
    {
        printf("student[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
    }
    
    return 0
}

複製程式碼

qsort_r函式的簽名中增加了一個thunk引數,同時在排序比較器函式中也相應的增加了一個擴充套件的入參,其值就是qsort_t中的thunk引數,這樣就不再需要將陣列設定為全域性變數了。

一個不幸的事實是這些擴充套件函式並不是C標準庫中的函式,而且在標準庫中還有非常多的類似的函式比如二分查詢函式bsearch等等。當要編寫的是跨平臺的應用程式時就不得不放棄對這些非標準的擴充套件函式的使用了。所幸的是我們還可以藉助一種稱之為thunk的技術來解決qsort函式間接排序的問題,這也就是我下面要引入的本文的主題了。

Thunk技術

thunk技術的概念在維基百科中被定義如下:

In computer programming, a thunk is a subroutine that is created, often automatically, to assist a call to another subroutine. Thunks are primarily used to represent an additional calculation that a subroutine needs to execute, or to call a routine that does not support the usual calling mechanism. They have a variety of other applications to compiler code generation and modular programming.

Thunk程式中文翻譯為形實轉換程式,簡而言之Thunk程式就是一段程式碼塊,這段程式碼塊可以在呼叫真正的函式前後進行一些附加的計算和邏輯處理,或者提供將對原函式的直接呼叫轉化為間接呼叫的能力。

Thunk程式在有的地方又被稱為跳板(trampoline)程式,Thunk程式不會破壞原始被呼叫函式的棧引數結構,只是提供了一個原始呼叫的hook的能力。Thunk技術可以在編譯時和執行時兩種場景下被使用。

在介紹用Thunk技術實現執行時用qsort函式實現索引排序之前,先介紹三種編譯時Thunk技術的使用場景。

如果你不感興趣編譯時的場景則可以直接跳過這些小節。

一、程式呼叫動態庫中函式的實現原理

在早期的真實模式系統中可執行程式通常只有一個檔案組成,對記憶體的訪問也是直接的實體記憶體訪問,程式載入時所存放的記憶體地址區域也是固定的。一個可執行程式中的所有程式碼則是由多個不同的函式或者類組成的。

當要使用某個函式提供的功能時,就需要在程式碼處呼叫對應的函式。每個函式在程式執行並載入到記憶體中時都有一個唯一的記憶體中地址來標識函式入口的開始位置,而呼叫函式的程式碼則會在編譯連結後轉化為對函式執行呼叫的機器指令(比如call或者bl指令)。

假設有如下的可執行程式原始碼:

void main()
{
     foo();
}

void foo()
{
}
複製程式碼

假如作業系統在真實模式下將可執行程式的指令程式碼固定載入到地址為0x1000處,那麼當將這個程式原始碼進行編譯和連結產生二進位制的可執行檔案執行時在記憶體中的資料為如下:

//本機器指令是x86系統下的機器指令
//main函式的起始地址是0x1000
0x1000: E8 03      ;這裡的E8是call指令的機器碼,03是表示呼叫從當前指令位置往下相對偏移3個位元組位置的函式地址,也就是foo函式的地址。
0x1002: 22         ;這裡的22是ret指令的機器碼
 //foo函式的起始地址是0x1003
0x1003: 22         ; 這裡的22是ret指令的機器碼       
複製程式碼

可以看出原始碼中的函式呼叫的語句在編譯連結後都會轉化為call指令操作碼後面跟著被呼叫函式與當前指令之間的相對偏移值運算元的機器指令。函式呼叫地址採用相對偏移值而不採用絕對值的好處在於當對記憶體中的程式進行重定向或者動態調整程式載入到記憶體中的基地址時就不需要改變二進位制可執行程式的內容。

隨著保護模式技術的實現以及多工系統的誕生,作業系統為每個程式提供了獨立的虛擬記憶體空間。為了對程式碼進行復用,作業系統提供了對動態連結庫的支援能力。這種情況下一個程式就可能由一個可執行程式和多個動態庫組成了。

動態庫也是一段可被執行的二進位制程式碼,只不過它並沒有定義像main函式之類的入口函式且不能被單獨執行。當一個程式被執行時作業系統會將可執行程式檔案以及顯式連結的所有動態庫檔案的映像(image)隨機的載入到程式的虛擬記憶體空間中去。而這時候就會產生出一個問題:

當所有的函式都定義在一個可執行檔案內時,因為可執行檔案中的這些函式在編譯連結時的位置都已經固定了,所以轉化為函式呼叫的機器指令時,每個函式的相對偏移位置是很容易被計算出來的。而如果可執行程式中呼叫的是一個由動態庫所提供的函式呢?因為這個動態庫和可執行程式檔案是兩個不同的檔案,並且動態庫的基地址被載入到程式的虛擬記憶體空間的位置是不固定的而且隨機的,可執行程式image和動態庫image所載入到的記憶體區域並不一定是連續的記憶體區域,因此可執行程式是無法在編譯連結時得到動態庫中的函式地址在記憶體中的位置和呼叫指令的在記憶體中位置之間的相對偏移量的。

解決這個問題的方法就是在編譯一個可執行檔案時,將可執行程式程式碼中呼叫的外部動態庫中定義的每一個函式都在本程式內分別建立一個對應的被稱為stub的本地函式程式碼塊,同時在可執行程式中的資料段中建立一個表格,這個表格的內容儲存的就是可執行程式呼叫的每個外部動態庫中定義的函式的真實地址,我們稱這個表格為匯入地址表。然後對應的每個本地stub函式塊中的實現就是將呼叫跳轉到匯入地址表中對應的真實函式實現的陣列索引中去。在可執行程式啟動時這個匯入地址表中的值全部都是0,而一旦動態庫被載入並確定了基地址後,作業系統就會將動態庫中定義的被可執行程式呼叫的函式的真實的絕對地址,更新到可執行程式資料段中的匯入地址表中的對應位置。

這樣每當可執行程式呼叫外部動態庫中的函式時,其實被呼叫的是外部函式對應的本地的stub函式,然後stub函式內部再跳轉到真實的動態庫定義的函式中去。這樣就解決了呼叫外部函式時call指令中的運算元仍然還是相對偏移值,只不過這個偏移值並不是相對於動態庫中定義的函式的地址,而是相對於可執行程式本身內部定義的本地stub函式的函式地址。

下面的例子說明了可執行程式呼叫了C標準庫動態庫中的abs函式和printf函式的原始碼:

#include <stdlib.h>

int  foo()
{
    return 0;
}

int  main(int argc, char *argv[])
{
       int a = abs(-1);
       printf("%d",a);   //上面兩個都是動態庫中定義和提供的函式
       foo();    //這個是本地定義的函式
       return 0;
}

複製程式碼

那在程式碼被編譯後實際的虛擬碼應該是如下:

#include <stdlib.h>

//定義匯入地址表結構
typdef struct
{
    char *fnname;
    void *fnptr;
}iat_t;

iat_t  _giat[] = {{"abs", 0}, {"printf",0}};

int foo()
{//本地函式不會在匯入地址表中
    return 0;
}
 
int  main(int argc, char *argv[])
{
    int a = _stub_abs(-1);
    _stub_printf("%d", a);
    foo();
}

int _stub_abs(int v)
{
     return _giat[0].fnptr(v);
}

void _stub_printf(char *fmt, ...)
{
     _giat[1].fnptr(fmt, ...);
}
複製程式碼

通過上面的程式碼可以看出來在將可執行程式編譯連結時,所有的函式呼叫call指令中的地址值部分都可以指定為相對偏移值。對於程式中呼叫到的動態庫中定義的函式,則會在main函式執行前,動態庫被載入後更新_giat表中的所有函式的真實地址,這樣就實現了動態庫中的函式呼叫了。

當然了上面介紹的動態庫函式呼叫的原理在每種作業系統下可能會有一些差異。Facebook所提供的一個開源的iOS庫fishhook的內部實現就是通過修改_giat表中的真實函式地址來實現函式呼叫的替換的。

當你瞭解到了動態庫中函式呼叫的機制後,其實你也是可以任意修改一個程式中呼叫的所有外部動態庫的函式的邏輯的,因為匯入地址表存放在資料段,其值可以被任意修改,因此你也可以將某個函式呼叫的真實實現變為你想要的任意實現。很多越獄後的應用就是通過修改匯入地址表中的函式地址而實現函式呼叫的重定向的邏輯的。

再來考察一下_stub_xxx函式的實現,如果你切換到程式的彙編指令程式碼檢視時,你就會發現幾乎所有的_stub_xxx函式的程式碼都是一樣的。這裡的_stub_xxx函式塊就是thunk技術的一種實際應用場景。下面是iOS的arm64位系統中關於動態庫函式呼叫實現:

動態庫函式呼叫實現

你會發現每個_stub函式只有3條指令:

_stub_obj_msgSend:
   nop
   ldr x16, 0x1640
   br x16
複製程式碼

一條是nop空指令、一條是將匯入符號表中真實函式地址儲存到x16暫存器中、一條是跳轉指令。這裡的跳轉指令不用blr而用br的原因是如果採用blr則將會再次形成一個呼叫棧的生成,這樣在除錯和斷點時看到的將不是真實的函式呼叫,而是_stub_xxx函式的呼叫,而跳轉指令只是簡單的將函式的呼叫跳轉到真實的函式入口地址中去,並且不需要再次進行函式呼叫進棧和出棧處理,正是這樣的設定使得對於外面而言就像是直接呼叫動態庫的函式一樣。因此可以看出thunk技術其實是一種程式碼重定向的技術,並且這種重定向並不會影響到函式引數的入棧和出棧處理,對於呼叫者來說就好像是直接呼叫的真實函式一樣。

iOS系統中一個程式中的所有stub函式的符號和實現分別存放在程式碼段__TEXT的_stubs和_stub_helper兩個section中。

二、C++中虛擬函式呼叫的實現原理。

C++語言是一門物件導向的語言,物件導向思想中對多型的支援是其核心能力。所謂多型描述的是物件的行為可以在執行時來決定。物件的行為在語義層面上表現為類中定義的方法函式。一般情況下對具體函式的呼叫會在編譯時就被確定下來,那如何能將函式的呼叫轉化為執行時再進行確定呢? 在C++中通過將成員函式定義為虛擬函式(virtual function)就能達到這個效果。來看一下如下程式碼:

class CA
{
public:
   void foo1() 
   {
         printf("CA::foo1\n");
   }
   virtual void foo2()
   {
         printf("CA::foo2\n");
   }
   virtual void foo3()
   {
       printf("CA::foo3\n");
   }
};

class CB: public CA
{
public:
    void foo1()
    {
          printf("CB::foo1\n");
    }
    
    virtual void foo2()
    {
         printf("CB::foo2\n");
    }

    virtual void foo4()
   {
       printf("CB::foo4\n");
   }
};


void func(CA *p)
{
     p->foo1();
     p->foo2();
     p->foo3();
}

int  main(int argc, char *argv[])
{

     CA *p1 = new CA;
     CB *p2 = new CB;

     func(p1);
     func(p2);
       
     delete p1;
     delete p2;
     return 0;
}

複製程式碼

示例程式碼中CA定義了一個普通成員函式foo1和兩個虛擬函式foo2, foo3。CB繼承自CA並覆寫foo1函式和過載了foo2函式。上述程式碼執行得到如下的結果:

     CA::foo1
     CA::foo2
     CA::foo3
     CA::foo1
     CB::foo2
     CA::foo3
複製程式碼

可以看出來在func函式內無論你傳遞的物件是基類CA的例項還是派生類CB的例項當呼叫foo1函式時總是列印的是基類的foo1函式中的內容,而呼叫foo2函式時就會區分是基類物件的實現還是派生類物件的實現。在函式func中它的引數指向的總是一個CA物件,因為編譯器是不知道執行時傳遞的到底是基類還是派生類的物件例項,那麼系統又是如何實現這種多型的特性的呢?

在C++中,一旦類中有成員函式被定義為虛擬函式(帶有virtual關鍵字)就會在編譯連結時為這個類建立一個全域性的虛擬函式表(virtual table),這個虛擬函式表中每個條目的內容儲存著被定義為虛擬函式的函式地址指標。每當例項化一個定義有虛擬函式的物件時,就會將物件的中的一個隱藏的資料成員指標(這個指標稱之為vtbptr)指向為類所定義的虛擬函式表的開始地址。整個結構就如下面的圖中展示的一樣:

虛表和記憶體佈局

因此上面的程式碼在被編譯後其實就會轉化為如下的完整虛擬碼:


struct CA
{
     void *vtbptr;
};

struct CB
{
    void *vtbptr;
};

//因為C++中有函式命名修飾,實際的名字不應該是這樣的,這裡是為了讓大家更好的理解函式的定義和實現
void CA::foo1(CA * const this)
{
     printf("CA::foo1\n");
}

void CA::foo2(CA *const this)
{
     printf("CA::foo2\n");
}

void CA::foo3(CA *const this)
{
    printf("CA::foo3\n");
}

void CB::foo1(CB *const this)
{
    printf("CB::foo1\n");
}

void CB::foo2(CB *const this)
{
   printf("CB::foo2\n");
}

//定義2個類的全域性虛擬函式表
void * _gCAvtb[] = {&CA::foo2, &CA::foo3};
void * _gCBvtb[] = {&CB::foo2, &CA::foo3, &CB::foo4};

void func(CA *p)
{
      CA::foo1(p);   //這裡被編譯為正常函式的呼叫
      p->vtbptr[0](p);  //這裡被編譯為虛擬函式呼叫的實現程式碼。
      p->vtbptr[1](p);
}

int main(int argc, char *argv[])
{
      CA *p1 = (CA*)malloc(sizeof(CA));
      p1->vtbptr = _gCAvtbl;
       
      CB *p2 = (CB*)malloc(sizeof(CB));
      p2->vtbptr = _gCBvtbl;

      func(p1);
      func(p2);

      free(p1);
      free(p2);
      return 0;
}
複製程式碼

觀察上面函式func的實現可以看出來,當對程式進行編譯時,如果發現呼叫的函式是非虛擬函式那麼就會在程式碼中直接呼叫類中定義的函式,如果發現呼叫的是虛擬函式時那麼在程式碼中將會使用間接呼叫的方法,也就是通過呼叫虛擬函式表中記錄的函式地址,這樣就實現了所謂的多型和執行時動態確定行為的效果。從上面的程式碼實現中您也許會發現這裡和前面關於動態庫函式呼叫實現有類似的一些機制:都定義了一個表格,表格中存放的是真正要呼叫的函式地址,而在外部呼叫這些函式時,並不是直接呼叫定義的函式的地址,而是採用了間接呼叫的方式來實現,這個間接呼叫方式都是用比較統一和相似的程式碼塊來實現。檢視虛擬函式的呼叫對應的彙編程式碼時你可能會看到如下的程式碼片段:

//macOS中的x86_64位下的彙編程式碼
   movq   -0x8(%rbp), %rdi      ;CA物件的p1儲存到%rdi暫存器中。
   callq  0x100000e80           ;非虛擬函式CA::foo1採用直接呼叫的方式
   movq   (%rdi), %rax          ;將p1中的虛擬函式表vtbptr指標取出儲存到%rax中
   callq  *(%rax)               ;間接呼叫虛擬函式表中的第一項也就是foo2函式所儲存的位置
   callq  *0x8(%rax)            ;間接呼叫虛擬函式表中的第二項也就是foo3函式所儲存的位置
複製程式碼

可見在C++中對虛擬函式進行呼叫的程式碼的實現也是用到了thunk技術。除了虛擬函式呼叫這裡使用了thunk技術外,C++還在另外一種場景中使用到了thunk技術。

嚴格來說其實C++的虛擬函式呼叫機制的實現不應該納入thunk技術的一種實現,但是某種意義上虛擬函式呼叫確實又是高階語言直接呼叫而在編譯後又通過安插特定程式碼來實現真實的函式呼叫的。

三、C++中基於介面的多重繼承中對thunk技術的使用

在C++的基於介面程式設計的一些技術解決方案中(比如早期Windows的COM技術)。往往會設計一個系統公用的基介面(比如COM的IUnknown介面),然後所有的介面都從這個基介面進行派生,而一個實現類往往會實現多個介面。整個設計結構可用如下程式碼表示:

//定義共有抽象基介面
class Base
{
  public:
      virtual void basefn() = 0;
};

//定義派生介面
class A : public Base
{
   public:
     virtual void  afn() = 0;
};

//定義派生介面
class B : public Base
{
   public:
     virtual void bfn() = 0;
};

//實現類Imp同時實現A和B介面。
class Imp: public A, public B
{
   public:
        virtual void basefn() { printf("basefn\n");}
        virtual void afn() { printf("afn\n");}
        virtual void bfn() { printf("bfn\n");}

        int m_;
};

int main(int argc, char *argv[])
{
   Imp *pImp = new Imp;
   A *pA = pImp;
   B *pB = pImp;

   pImp->basefn();
   pA->basefn();
   pB->basefn();

   delete pImp;
   return 0;
}

複製程式碼

上面的這種繼承關係圖如下:

菱形繼承關係

根據C++對虛擬函式的支援實現以及多重繼承支援,上面的Imp類的物件例項的記憶體佈局以及虛擬函式表的佈局結構如下:

基於介面的多重整合物件佈局圖

因此上面的程式碼在編譯後真實的虛擬碼實現如下:

struct Base
{
     void *vtbptr;
};

struct A
{
   void *vtbptr;
};

struct B
{
   void *vtbptr;
};

struct Imp
{
    void *vtbImpptr;
    void *vtbBptr;
    int m_;
};

void Imp::basefn(Imp * const this)
{
 printf("basefn\n"); 
}

void Imp::afn(Imp *const this)
{
   printf("afn\n");
}

void Imp::bfn(Imp *const this)
{
  printf("bfn\n");
}

void Imp::thunk_basefn(B * const this)
{
    Imp *pThis = this - 1;
    Imp::basefn(pThis);
}

void Imp::thunk_bfn(B *const this)
{
    Imp *pThis = this - 1;
    Imp::bfn(pThis);
}

//定義2個的全域性虛擬函式表
void * _gImpvtb[] = {&Imp::basefn, &Imp::afn};
void * _gImpthunkBvtb[] = {&Imp::thunk_basefn, &Imp::thunk_bfn};

int main(int argc, char *argv[])
{
     Imp *pImp = (Imp*)malloc(sizeof(Imp));
     pImp->vtbImpptr = _gImpvtb;
     pImp->vtbBptr = _gImpthunkBvtb;

    A *pA = pImp;
    B *pB = pImp;
    
    pImp->vtbImpptr[0](pImp);
    pA->vtbImpptr[0](pA);
    pB->vtbBptr[0](pB);

     free(pImp);
     return 0;
}

複製程式碼

仔細觀察第二個虛擬函式表中的兩個條目,會發現B介面類虛擬函式表中的函式地址並不是Imp::basefn和Imp::bfn,而是兩個特殊的並未公開的函式,這兩函式實現如下:

 void Imp::thunk_basefn(B * const this)
 {
       Imp *pThis = this - 1;
       Imp::basefn(pThis);
 }

 void Imp::thunk_bfn(B * const this)
 {
       Imp *pThis = this - 1;
       Imp::bfn(pThis);
 }

複製程式碼

兩個函式內部只是簡單的將物件指標轉化為了派生類物件的指標並呼叫真實的函式實現。那為什麼B介面虛擬函式表中的函式地址不是真實的函式地址而是一個thunk函式的地址呢?其實從上面的物件的記憶體佈局結構就能找出答案。因為Imp是從B進行的多重繼承,所以當將一個Imp類物件的指標,轉化為基類B的指標時,其實指標的值是增加了8個位元組(如果是32位就4個位元組)。又因為B和A都是從Base派生的,因此不管是B還是A都可以呼叫fnBase函式,但這樣就會出現入參的地址不一致的問題。舉例來說,假如例項化一個Imp物件並且為其分配在記憶體中的地址為0x1000,就如如下程式碼:

Imp *pImp = new Imp;    //假設這裡分配的地址是0x1000, 也就是pImp == 0x1000
A *pA = pImp;   //因為A是Imp的第一個基類,所以根據型別轉換規則得到的pA == 0x1000 ,pA和pImp指向同一個地址。          
B *pB = pImp;  //因為B是Imp的第二個基類,所以根據型別轉換規則得到pB == 0x1008,pB等於pImp的值往下偏移8個位元組。

pImp->basefn();   //轉化為pImp->vtbImpptr[0](0x1000);  
pA->basefn();       //轉化為pA->vtbptr[0](0x1000);
pB->basefn();      //轉化為pB->vtbptr[0](0x1008);
複製程式碼

可以看出如果基介面B中的虛擬函式表的第一個條目儲存的也是Imp::basefn的話,因為最終的實現是Imp類,而且basefn接收的引數也是Imp指標,但是因為呼叫者是pB,物件指標被偏移了8個位元組,這樣就產生了同一個函式實現接收兩個不一致的this地址的問題,從而產生錯誤的結果,因此為了糾正轉化為B類指標時呼叫會產生的問題,就必須將B介面的虛擬函式表中的所有條目改成為一個個thunk函式,這些thunk函式的作用就是對this指標的地址進行真實的調整,從而保證函式呼叫的一致性。可以看出在這裡thunk技術又再次的被應用到實際的問題解決中來了。下面是這個thunk程式碼塊的macOS系統下x86_64位的彙編程式碼實現:

xxxx`non-virtual thunk to Imp::bfn():
    0x100000f30 <+0>:  pushq  %rbp
    0x100000f31 <+1>:  movq   %rsp, %rbp
    0x100000f34 <+4>:  subq   $0x10, %rsp
    0x100000f38 <+8>:  movq   %rdi, -0x8(%rbp)
    0x100000f3c <+12>: movq   -0x8(%rbp), %rdi
    0x100000f40 <+16>: addq   $-0x8, %rdi   //指標位置修正
    0x100000f44 <+20>: callq  0x100000ee0               ; Imp::bfn at main.cpp:43
    0x100000f49 <+25>: addq   $0x10, %rsp
    0x100000f4d <+29>: popq   %rbp
    0x100000f4e <+30>: retq 
複製程式碼

執行時使用thunk技術的方法和實現

上面介紹的3種使用thunk技術的地方都是在編譯階段通過插入特定的thunk程式碼塊來完成的,在編譯高階語言時會自動生成一些thunk程式碼塊函式,並且會對一些特殊的函式呼叫改為對thunk程式碼塊的呼叫,這些呼叫邏輯一旦確定後就無法再進行改變了。因此我們不可能使用編譯時的thunk技術來解答文字的qsort函式排序的需求。那除了由編譯器生成thunk程式碼塊外,在程式執行時是否可以動態的來構造一個thunk程式碼塊呢?答案是可以的,要想動態來構造一個thunk程式碼塊,首先要了解函式的呼叫實現過程。

下面舉例中的機器指令以及引數傳遞主要是iOS的arm64位下面的規定,如果沒有做其他說明則預設就是指的iOS的arm64位系統。

函式呼叫以及引數傳遞的機器指令實現

一個函式簽名中除了有函式名外,還可能會定義有引數。函式的呼叫者在呼叫函式時除了要指定呼叫的函式名時還需要傳入函式所需要的引數,函式引數從呼叫者傳遞給實現者。在編譯程式碼時會將對函式的呼叫轉化為call/bl指令和對應的函式的地址。那麼編譯器又是來解決引數的傳遞的呢?為了解決這個問題就需要在呼叫者和實現者之間形成一個統一的標準,雙方可以約定一個特定的位置,這樣當呼叫函式前,呼叫者先把引數儲存到那個特定的位置,然後再執行函式呼叫call/bl指令,當執行到函式內部時,函式實現者再從那個特定的位置將資料讀取出來並處理。引數存放的最佳位置就是棧記憶體區域或者CPU中的暫存器中,至於是採用哪種方法則是根據不同作業系統平臺以及不同CPU體系結構而不同,有些可能規定為通過棧記憶體傳遞,而有些規定則是通過暫存器傳遞,有些則採用兩者的混合方式進行傳遞。就以iOS的64位arm系統來說幾乎所有函式呼叫的引數傳遞都是通過暫存器來實現的,而當函式的引數超過8個時才會用到棧記憶體空間來進行引數傳遞,並且進一步規定非浮點數引數的儲存從左到右依次儲存到x0-x8中去,並且函式的返回值一般都儲存在x0暫存器中。因此下面的函式呼叫和實現高階語言的程式碼:

int foo(int a, int b, int c)
{
     return a + b + c;
}

int main(int argc, char *argv[])
{
    int ret = foo(10, 20, 30);
   return 0;
} 

複製程式碼

最終在轉化為arm64位彙編虛擬碼就變為了如下指令:

//真實中並不一定有這些指令,這裡這些偽指令主要是為了讓大家容易去理解
int foo(int a, int b, int c)
{
     mov a, x0     ;把呼叫者存放在x0暫存器中的值儲存到a中。
     mov b, x1     ;把呼叫者存放在x1暫存器中的值儲存到b中。
     mov c, x2     ;把呼叫者存放在x2暫存器中的值儲存到c中。
     add x0,  a, b, c ;執行加法指令並儲存到x0暫存器中供返回。
     ret
}

int main(int argc, char *argv[])
{
      mov x0, #10   ;將10儲存到x0暫存器中
      mov x1, #20   ;將20儲存到x1暫存器中
      mov x2, #30   ;將30儲存到x2暫存器中
      bl foo        ;呼叫foo函式指令
      mov ret, x0   ;將foo函式返回的結果儲存到ret變數中。
      mov x0, #0    ;將main函式的返回結果0儲存到x0暫存器中
      ret
}
複製程式碼

至此,我們基本瞭解到了函式的呼叫和引數傳遞的實現原理,可見無論是函式呼叫還是引數傳遞都是通過機器指令來實現的。

動態構建記憶體指令塊

一個執行中的程式無論是其指令程式碼還是資料都是以二進位制的形式存放在記憶體中,程式程式碼段中的指令程式碼是在編譯連結時就已經產生了的固定指令序列。當然,只要在記憶體中存放的二進位制資料符合機器指令的格式,那麼這塊記憶體中儲存的二進位制資料就可以送到CPU當中去執行。換句話說就是機器指令除了可以在編譯連結時靜態生成還可以在程式執行過程中動態生成。這個結論的意義在於我們甚至可以將指令資料從遠端下載到本地程式中,並且在程式執行時動態的改變程式的執行邏輯。

參考上面關於函式呼叫以及引數傳遞的實現可以得出,qsort函式接收一個比較器compar函式指標,函式指標其實就是一塊可執行程式碼的記憶體首地址。而每次在進行兩個元素的比較時都會先將兩個元素引數分別儲存到x0,x1兩個暫存器中,然後再通過 bl compar指令實現對比較器函式的呼叫。為了讓qsort能夠支援對帶擴充套件引數的比較器函式呼叫,我們可以動態的構造出一段指令程式碼(這段指令程式碼就是一個thunk程式塊)。程式碼塊的指令序列如下:

  1. 將暫存器x1的值儲存到x2中。
  2. 將暫存器x0的值儲存到x1中。
  3. 將擴充套件引數的值儲存到x0中。
  4. 將帶擴充套件引數的真實比較器函式的地址儲存到x3中去
  5. 跳轉到x3暫存器所儲存的帶擴充套件引數的真實比較器函式中去。

然後再將這些指令對應的二進位制機器碼儲存到某個已經分配好的記憶體塊中,最後再將這塊分配好的記憶體塊首地址(thunk比較器函式地址),作為qsort的compar函式比較器指標的引數。這樣當qsort內部在需要比較時就先把兩個比較的元素分別存放入x0,x1中並呼叫這個thunk比較器函式。而當執行進入thunk比較器函式內部時,就會如上面所寫的把原先的x0,x1兩個暫存器中的值移動到x1,x2中去,並把擴充套件引數移動到x0中,然後再跳轉一個真實的帶擴充套件引數的比較器函式中去,等真實的帶擴充套件引數的比較器函式比較完成返回時,thunk比較器函式就會將結果返回給qsort函式來告訴qsort比較的結果。這個過程中其實真正進行比較的是一個帶擴充套件引數的真實比較器函式,但是我們卻通過thunk技術欺騙了qsort函式,讓qsort函式以為執行的仍然是一個不帶擴充套件引數的比較器函式。

可執行程式碼的執行許可權

為了方便管理和安全的需要,作業系統對一個程式中的虛擬記憶體空間進行了許可權的劃分。某些區域被設定為僅可執行,比如程式碼段所載入的記憶體區域;而某些區域則被設定為可讀寫,比如資料段所載入的記憶體區域;而某些區域則被設定為了只讀,比如常量資料段所載入的記憶體區域;而某些區域則被設定了無讀寫訪問許可權,比如程式的虛擬記憶體的首頁地址區域(0到4096這塊區域)。程式中程式碼段所載入的記憶體區域只供可執行,可執行表明這塊區域的記憶體中的資料可以被CPU執行以及進行讀取訪問,但是不能進行改寫。不能改寫的原因很簡單,假如這塊區域的內容可以被改寫的話,那就可以在執行時動態變更可執行邏輯,這樣整個程式的邏輯就會亂套和結果未可知。因此幾乎所有作業系統中的程式記憶體中的程式碼要想被執行則這塊記憶體區域必須具有可執行許可權。有些作業系統甚至更加嚴格的要求可執行的程式碼所屬的記憶體區域必須只能具有可執行許可權,而不能具有寫許可權。

記憶體對映檔案技術實現許可權動態調整

上一個小結中我們說到可以在程式執行時動態的在記憶體中構建出一塊指令程式碼來讓CPU執行。如果是這樣的話那就和可執行的記憶體區域只能是可執行許可權互相矛盾了。為了解決讓動態分配的記憶體塊具有可執行的許可權,可以藉助記憶體對映檔案的技術來達到目的。記憶體對映檔案技術是用於將一個磁碟中的檔案對映到一塊程式中的虛擬記憶體空間中的技術,這樣我們要對檔案進行讀寫時就可以用記憶體地址進行讀寫訪問的方式來進行,而不需要藉助檔案的IO函式來執行讀寫訪問操作。記憶體對映檔案技術大大簡化了對檔案進行讀寫操作的方式。而且其實當可執行程式在執行時,作業系統就是通過記憶體對映檔案技術來將可執行程式對映到程式的虛擬記憶體空間中來實現程式的載入的。記憶體對映檔案技術還可以指定和動態修改檔案對映到記憶體空間中的訪問許可權。而且記憶體對映檔案技術還可以在不關聯具體的檔案的情況下來實現虛擬記憶體的分配以及對分配的記憶體進行許可權的設定和修改的能力。因此可以藉助記憶體對映檔案技術來實現對記憶體區域的可執行保護設定。下面的程式碼就演示了這種能力:

#include <sys/mman.h>

int main(int argc, char *argv[])
{
    //分配一塊長度為128位元組的可讀寫和可執行的記憶體區域
    char  *bytes = (char *)mmap(0, 128, PROT_EXEC|PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0);
    memcpy(bytes, "Hello world!", 13);
    //修改記憶體的許可權為只可讀,不可寫。
    mprotect(bytes, 128, PROT_READ);
    printf(bytes);
    memcpy(bytes, "Oops!", 6);  //oops! 記憶體不可寫!
    return 0;
}
複製程式碼
iOS上的thunk程式碼實現

前面介紹了動態構建記憶體指令的技術,以及讓qsort支援帶擴充套件引數的函式比較器的方法介紹,以及記憶體對映檔案技術的介紹,這裡將用具體的程式碼示例來實現一個在iOS的64位arm系統下的thunk程式碼實現。

#include <sys/mman.h>

//因為結構體定義中存在對齊的問題,但是這裡要求要單位元組對齊,所以要加#pragma pack(push,1)這個編譯指令。
#pragma  pack (push,1)
    typedef struct
    {
        unsigned int mov_x2_x1;
        unsigned int mov_x1_x0;
        unsigned int ldr_x0_0x0c;
        unsigned int ldr_x3_0x10;
        unsigned int br_x3;
        void *arg0;
        void *realfn;
    }thunkblock_t;
#pragma pack(pop)

typedef struct
{
    int age;
    char *name;
}student_t;

//按年齡升序排列的函式
int  ageidxcomparfn(student_t students[], const int *idx1ptr, const int *idx2ptr)
{
    return students[*idx1ptr].age - students[*idx2ptr].age;
}

int main(int argc, const char *argv[])
{
    student_t students[5] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
    int idxs[5] = {0,1,2,3,4};
   
   //第一步: 構造出機器指令
    thunkblock_t tb = {
        /* 彙編程式碼
         mov x2, x1
         mov x1, x0
         ldr x0, #0x0c
         ldr x3, #0x10
         br x3
         arg0:
         .quad 0
         realfn:
         .quad 0
         */
        //機器指令: E2 03 01 AA E1 03 00 AA 60 00 00 58 83 00 00 58 60 00 1F D6
        .mov_x2_x1 = 0xAA0103E2,
        .mov_x1_x0 = 0xAA0003E1,
        .ldr_x0_0x0c = 0x58000060,
        .ldr_x3_0x10 = 0x58000083,
        .br_x3 = 0xD61F0060,
        .arg0 = students,
        .realfn = ageidxcomparfn
     };

    //第二步:分配指令記憶體並設定可執行許可權
     void  *thunkfn = mmap(0, 128, PROT_EXEC|PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0);
     memcpy(thunkfn, &tb, sizeof(thunkblock_t));
     mprotect(thunkfn, sizeof(thunkblock_t), PROT_EXEC);
 
   //第三步:為排序函式傳遞thunk程式碼塊。
     qsort(idxs, 5, sizeof(int), (int (*)(const void*, const void*))thunkfn);
     for (int i = 0; i < 5; i++)
     {
        printf("student:[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
     }

     munmap(thunkfn, 128);

     return 0;
}
複製程式碼

因為arm64系統中每條指令都佔用4個位元組,因此為了方便實現前面介紹的邏輯可以建立一個如下的結構體:

#pragma pack (push, 1)
typedef struct
{
        unsigned int mov_x2_x1;      //儲存 mov x2, x1 的機器指令
        unsigned int mov_x1_x0;     //儲存  mov x1, x0 的機器指令
        unsigned int ldr_x0_0x0c;   //將arg0中的值儲存到x0中的機器指令 
        unsigned int ldr_x3_0x10;   //將realfn中的值儲存到x3中的機器指令
        unsigned int br_x3;             // 儲存 br x3 的機器指令
        void *arg0;
        void *realfn;
}thunkblock_t;

#pragma pack (pop)

複製程式碼

上述結構體中第三個和第四個資料成員所描述的指令如下:

  ldr  x0, #0xc0
  ldr  x3, #0x10
複製程式碼

第三條指令的意思是將從當前位置偏移0xc0個位元組位置中的記憶體中的資料儲存到x0暫存器中,根據偏移量可以得出剛好arg0的位置和指令當前位置偏移0xc0個位元組。同理可以得到第四條指令是將realfn的值儲存到x3暫存器中。這裡設計為這樣的原因是為了方便資料的讀取,因為動態構造的指令塊對和指令自身連續儲存的記憶體地址訪問要比訪問其他不連續的特定記憶體地址訪問要簡單得多,只需要簡單的讀取當前指令偏移特定值的地址即可。

再接下來的程式碼中可以看出初始化這個結構體的程式碼:

  thunkblock_t tb = {
     
     /* 彙編程式碼
         mov x2, x1
         mov x1, x0
         ldr x0, #0x0c
         ldr x3, #0x10
         br x3
         arg0:
         .quad 0
         realfn:
         .quad 0
         */
        //機器指令: E2 03 01 AA E1 03 00 AA 60 00 00 58 83 00 00 58 60 00 1F D6
        .mov_x2_x1 = 0xAA0103E2,
        .mov_x1_x0 = 0xAA0003E1,
        .ldr_x0_0x0c = 0x58000060,
        .ldr_x3_0x10 = 0x58000083,
        .br_x3 = 0xD61F0060,
        .arg0 = students,                 //第一個引數儲存的就是擴充套件的引數students陣列
        .realfn = ageidxcomparfn    //真實的帶擴充套件引數的比較器函式地址ageidxcomparfn
};
複製程式碼

這段程式碼可以看到thunk程式塊的彙編指令和對應的16進位制機器指令,因此在構造結構體的資料成員時,只需要將特定的16進位制值賦值給對應的資料成員即可,在最後的arg0中儲存的是擴充套件引數students的指標,而realfn中儲存的就是真實的帶擴充套件引數的比較器函式地址。 當thunkblock_t結構體初始化完成後,結構體tb中的內容就是一段可被執行的thunk程式塊了,接下來就需要藉助記憶體對映檔案技術,將這塊程式碼存放到一個只有可執行許可權的記憶體區域中去,這就是上面例項程式碼的第二步所做的事情。最後第三步則只需要將記憶體對映生成的可執行thunk程式塊的首地址作為qsort函式的最後一個引數即可。

注意!!! 在iOS系統中如果您的應用需要提交到appstore進行稽核,那麼當你用Distrubution證書和provison配置檔案所打出來的應用程式包是不支援將某個記憶體區域設定為可執行許可權的!也就是上面的mprotect函式執行時會失效。因為iOS系統核心會對從appstore下載的應用程式中的可執行程式碼段進行簽名校驗,而我們動態分配的可執行記憶體區域是無法通過簽名校驗的,所以程式碼必定會執行失敗。iOS系統這樣設定的目的還是為了防止我們通過動態指令下載來實現熱修復的技術。但是上述的程式碼是可以在開發者證書以及模擬器上執行通過的,因此切不可將這個技術解決方案用在需要釋出證書籤名校驗的程式中。雖然如此但是我們還是可以用這項技術在開發版本和測試版本中來實現一些主執行緒檢測、程式碼插樁的能力而不影響程式的效能的情況下來構建一些測試和檢查的能力。

一個多平臺下的完整thunk程式碼實現

除了實現iOS64位arm系統的thunk的例子外,下面是一段完整的thunk程式碼,它分別在windows64位作業系統、樹莓派linux系統、macOS系統、以及iOS的x86_64位模擬器、arm、arm64位系統下驗證通過,因為不同的作業系統以及不同CPU下的指令集不一樣,以及函式呼叫的引數傳遞規則不一樣,所以不同的系統下實現會略有差異,但是總體的原理是大同小異的。這裡就不再詳細介紹不同系統的差異了,從註釋中的彙編程式碼你就能將邏輯和原理搞清楚。而且這段程式碼還可以複用到所有需要使用擴充套件引數但是又不支援擴充套件引數的那些回撥函式中去。


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(_MSC_VER)
#include <windows.h>
#else
#include <sys/mman.h>
#endif

void * createthunkfn(void *arg0, void *realfn)
{
#pragma  pack (push,1)
    typedef struct
    {
#ifdef __arm__
        unsigned int mov_r2_r1;
        unsigned int mov_r1_r0;
        unsigned int ldr_r0_pc_0x04;
        unsigned int ldr_r3_pc_0x04;
        unsigned int bx_r3;
#elif __arm64__
        unsigned int mov_x2_x1;
        unsigned int mov_x1_x0;
        unsigned int ldr_x0_0x0c;
        unsigned int ldr_x3_0x10;
        unsigned int br_x3;
#elif __x86_64__
        unsigned char ins[22];
#elif _MSC_VER && _WIN64
        //windows
        unsigned char ins[19];
#else
#warning "not support!"
#endif
        void *arg0;
        void *realfn;
	}thunkblock_t;
    
#pragma pack(pop)
    
    
    thunkblock_t tb = {
#if !defined(_MSC_VER)
#ifdef __arm__
        /* 彙編程式碼
         mov r2, r1
         mov r1, r0
         ldr r0, [pc, #0x04]
         ldr r3, [pc, #0x04]
         bx  r3
         arg0:
         .long 0
         realfn:
         .long 0
         */
        //機器指令: 01 20 A0 E1 00 10 A0 E1 04 00 9F E5 04 30 9F E5 13 FF 2F E1
        .mov_r2_r1 = 0xE1A02001,
        .mov_r1_r0 = 0xE1A01000,
        .ldr_r0_pc_0x04 = 0xE59F0004,
        .ldr_r3_pc_0x04 = 0xE59F3004,
        .bx_r3 = 0xE12FFF13,
#elif __arm64__
        /* 彙編程式碼
         mov x2, x1
         mov x1, x0
         ldr x0, #0x0c
         ldr x3, #0x10
         br x3
         arg0:
         .quad 0
         realfn:
         .quad 0
         */
        //機器指令: E2 03 01 AA E1 03 00 AA 60 00 00 58 83 00 00 58 60 00 1F D6
        .mov_x2_x1 = 0xAA0103E2,
        .mov_x1_x0 = 0xAA0003E1,
        .ldr_x0_0x0c = 0x58000060,
        .ldr_x3_0x10 = 0x58000083,
        .br_x3 = 0xD61F0060,
#elif __x86_64__
        /* 彙編程式碼
         movq %rsi, %rdx
         movq %rdi, %rsi
         movq 0x09(%rip), %rdi
         movq 0x0a(%rip), %rax
         jmpq *%rax
         arg0:
         .quad 0
         realfn:
         .quad 0
         */
        //機器指令: 48 89 F2 48 89 FE 48 8B 3D 09 00 00 00 48 8B 05 0A 00 00 00 FF E0
        .ins = {0x48,0x89,0xF2,0x48,0x89,0xFE,0x48,0x8B,0x3D,0x09,0x00,0x00,0x00,0x48,0x8B,0x05,0x0A,0x00,0x00,0x00,0xFF,0xE0},
#endif
        .arg0 = arg0,
        .realfn = realfn
#elif _WIN64
		/* 彙編程式碼
		mov r8,rdx
		mov rdx,rcx
		mov rcx,qword ptr [arg0]
		jmp qword ptr [realfn]
		arg0 qword 0
		realfn qword 0
		*/
		//機器指令:4c 8b c2 48 8b d1 48 8b 0d 06 00 00 00 ff 25 08 00 00 00
		{0x4c,0x8b,0xc2,0x48,0x8b,0xd1,0x48,0x8b,0x0d,0x06,0x00,0x00,0x00,0xff,0x25,0x08,0x00,0x00,0x00},arg0,realfn
#endif
    };
    
#if defined(_MSC_VER)
	void *thunkfn = VirtualAlloc(NULL, 128, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
#else
    void  *thunkfn = mmap(0, 128, PROT_EXEC|PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0);
#endif
    if (thunkfn != NULL)
    {
        memcpy(thunkfn, &tb, sizeof(thunkblock_t));
#if !defined(_MSC_VER)
        mprotect(thunkfn, sizeof(thunkblock_t), PROT_EXEC);
#endif
    }
    
    return thunkfn;
}

void releasethunkfn(void *thunkfn)
{
    if (thunkfn != NULL)
    {
#if defined(_MSC_VER)
		VirtualFree(thunkfn,128, MEM_RELEASE);
#else
        munmap(thunkfn, 128);
#endif
    }
}


typedef struct
{
    int age;
    char *name;
}student_t;

//按年齡升序排列的函式
int  ageidxcomparfn(student_t students[], const int *idx1ptr, const int *idx2ptr)
{
    return students[*idx1ptr].age - students[*idx2ptr].age;
}

int main(int argc, const char *argv[])
{
    student_t students[5] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
    int idxs[5] = {0,1,2,3,4};
    
    void *thunkfn = createthunkfn(students, ageidxcomparfn);
    if (thunkfn != NULL)
        qsort(idxs, 5, sizeof(int), (int (*)(const void*, const void*))thunkfn);
    
    for (int i = 0; i < 5; i++)
    {
        printf("student:[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
    }
    
    releasethunkfn(thunkfn);
    
    return 0;
}
複製程式碼

後記

最早接觸thunk技術其實是在10多年前的Windows的ATL庫實現中,ATL庫中通過thunk技術巧妙的將一個視窗控制程式碼操作轉化為了類的操作。當時覺得這個解決方案太神奇了,後來依葫蘆畫瓢將thunk技術應用到了一個快速排序的Windows程式中去,也就是本文例子中的原型,然後在開發中又發現了很多的thunk技術,所以就想寫這麼一篇thunk技術原理以及應用相關的文章。thunk技術還可以在比如函式呼叫的採集、埋點、主執行緒檢測等等應用場景中使用。


歡迎大家訪問歐陽大哥2013的github地址

相關文章