導讀:閱讀文本你將可以瞭解到C標準庫對快速排序的支持、簡單的索引技術、Thunk技術的原理以及應用、C++虛函數調用以及接口多重繼承實現、動態庫中函數調用的實現原理、以及在iOS等各操做系統平臺上Thunk程序的實現方法、內存映射文件技術。linux
在說Thunk程序以前,我想先經過一個實際中排序的例子來引出本文所要介紹的Thunk技術的方方面面。git
C語言的標準庫<stdlib.h>中提供了一個用於快速排序的函數qsort,函數的簽名以下:github
/*
@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數組創建一個索引數組,而後對索引數組進行排序便可。由於打亂的是索引數組中的順序,而訪問元素時又能夠經過索引數組來間接訪問,這樣就能夠實現原始數據內存存儲順序不改變的狀況下進行有序輸出。代碼實現改成以下:windows
#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的緣由,移動索引中的數據要比移動原始記錄數據要快並且方便不少,並且性能上也會大大的提高。當大量數據存儲在內存中也是如此,數據記錄在內存中由於排序而進行位置的移動要比索引數組元素移動的開銷和成本大不少,並且若是涉及到多線程下要對不一樣的成員進行原始記錄的排序時還須要引入鎖的機制。安全
所以在實踐中對於那些大數據塊進行排序時,改成經過引入索引來進行間接排序將會使你的程序性能獲得質的提升。bash
對比上面兩個排序的實例代碼實現就會發現經過索引進行排序時不得不將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技術的概念在維基百科中被定義以下:
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++中經過將成員函數定義爲虛函數(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++的基於接口編程的一些技術解決方案中(好比早期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
複製代碼
上面介紹的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程序塊)。代碼塊的指令序列以下:
而後再將這些指令對應的二進制機器碼保存到某個已經分配好的內存塊中,最後再將這塊分配好的內存塊首地址(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;
}
複製代碼
前面介紹了動態構建內存指令的技術,以及讓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系統這樣設置的目的仍是爲了防止咱們經過動態指令下載來實現熱修復的技術。可是上述的代碼是能夠在開發者證書以及模擬器上運行經過的,所以切不可將這個技術解決方案用在須要發佈證書籤名校驗的程序中。雖然如此可是咱們仍是能夠用這項技術在開發版本和測試版本中來實現一些主線程檢測、代碼插樁的能力而不影響程序的性能的狀況下來構建一些測試和檢查的能力。
除了實現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地址