導(dǎo)讀:閱讀文本你將能夠了解到C標(biāo)準(zhǔn)庫對快速排序的支持纽竣、簡單的索引技術(shù)坠韩、Thunk技術(shù)的原理以及應(yīng)用卑笨、C++虛函數(shù)調(diào)用以及接口多重繼承實現(xiàn)孕暇、動態(tài)庫中函數(shù)調(diào)用的實現(xiàn)原理、以及在iOS等各操作系統(tǒng)平臺上Thunk程序的實現(xiàn)方法赤兴、內(nèi)存映射文件技術(shù)妖滔。
在說Thunk程序之前,我想先通過一個實際中排序的例子來引出本文所要介紹的Thunk技術(shù)的方方面面桶良。
C標(biāo)準(zhǔn)庫對排序的支持
C語言的標(biāo)準(zhǔn)庫<stdlib.h>中提供了一個用于快速排序的函數(shù)qsort座舍,函數(shù)的簽名如下:
/*
@note: 實現(xiàn)快速排序功能
@param: base 要排序的數(shù)組指針
@param: nmemb 數(shù)組中元素的個數(shù)
@param: size 數(shù)組中每個元素的size
@param: compar 排序元素比較函數(shù)指針, 用于比較兩個元素。返回值分別為-1陨帆, 0曲秉, 1采蚀。
*/
void qsort(void *base, size_t nmemb, size_t size, int(*compar)(const void *, const void *));
這個函數(shù)要求提供一個排序的數(shù)組指針base, 數(shù)組的元素個數(shù)nmemb, 數(shù)組中每個元素的尺寸size,以及一個排序的比較器函數(shù)compar四個參數(shù)。下面的例子演示了這個函數(shù)的使用方法:
#include <stdlib.h>
typedef struct
{
int age;
char *name;
}student_t;
//按年齡升序排序的比較器函數(shù)
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;
}
函數(shù)排序后會將students中元素的內(nèi)存存儲順序打亂承二。如果需求變?yōu)樵诓粚tudents中的元素打亂情況下榆鼠,仍希望按age的大小進行排序輸出顯示呢?為了解決這個問題可以為students數(shù)組建立一個索引數(shù)組亥鸠,然后對索引數(shù)組進行排序即可妆够。因為打亂的是索引數(shù)組中的順序,而訪問元素時又可以通過索引數(shù)組來間接訪問负蚊,這樣就可以實現(xiàn)原始數(shù)據(jù)內(nèi)存存儲順序不改變的情況下進行有序輸出神妹。代碼實現(xiàn)改為如下:
#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);
//按年齡升序索引排序的比較器函數(shù)
int ageidxcomparfn(const int *idx1ptr, const int *idx2ptr)
{
return students[*idx1ptr].age - students[*idx2ptr].age;
}
int main(int argc, const char * argv[])
{
//創(chuàng)建一個索引數(shù)組
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數(shù)組進行排序了家妆,而是對索引數(shù)組idxs進行排序了灾螃。同時在訪問students中的元素時也不再直接通過下標(biāo)訪問,而是通過索引數(shù)組的下標(biāo)來進行間接訪問了揩徊。
索引技術(shù)是一種非常實用的技術(shù),尤其是在數(shù)據(jù)庫系統(tǒng)上應(yīng)用最廣泛嵌赠,因為原始記錄存儲成本和文件IO的原因塑荒,移動索引中的數(shù)據(jù)要比移動原始記錄數(shù)據(jù)要快而且方便很多,而且性能上也會大大的提升姜挺。當(dāng)大量數(shù)據(jù)存儲在內(nèi)存中也是如此齿税,數(shù)據(jù)記錄在內(nèi)存中因為排序而進行位置的移動要比索引數(shù)組元素移動的開銷和成本大很多,而且如果涉及到多線程下要對不同的成員進行原始記錄的排序時還需要引入鎖的機制炊豪。 因此在實踐中對于那些大數(shù)據(jù)塊進行排序時凌箕,改為通過引入索引來進行間接排序?qū)鼓愕某绦蛐阅艿玫劫|(zhì)的提高。
對比上面兩個排序的實例代碼實現(xiàn)就會發(fā)現(xiàn)通過索引進行排序時不得不將students數(shù)組從一個局部變量轉(zhuǎn)化為一個全局變量了词渤,原因是由于排序比較器函數(shù)compar的定義限制導(dǎo)致的牵舱。因為排序的對象從students變?yōu)閕dxs了,而排序比較器函數(shù)ageidxcomparfn的兩個入?yún)⒆優(yōu)樗饕档膇nt類型的指針缺虐,如果不將students數(shù)組設(shè)置為全局變量那么比較器函數(shù)內(nèi)部是無法訪問students中的元素的芜壁,所以只能將students定義為一個全局?jǐn)?shù)組。很明顯這種解決方案是非常不友好而且無法進行擴展的高氮,同一個比較器函數(shù)無法實現(xiàn)對不同的students數(shù)組進行排序慧妄。為了支持這種需要帶擴展參數(shù)的間接排序,很多平臺都提供了一個相應(yīng)的非標(biāo)準(zhǔn)庫擴充函數(shù)(比如Windows下的qsort_s, iOS/macOS的qsort_r, qsort_b等)剪芍。下面是采用iOS系統(tǒng)下的qsort_r函數(shù)來解決上述問題的代碼:
#include <stdlib.h>
typedef struct
{
int age;
char *name;
}student_t;
//按年齡升序索引排序的帶擴展參數(shù)的排序比較器函數(shù)
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參數(shù)塞淹,函數(shù)比較器中也增加了一個參數(shù)。
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函數(shù)的簽名中增加了一個thunk參數(shù)罪裹,同時在排序比較器函數(shù)中也相應(yīng)的增加了一個擴展的入?yún)⒈テ眨渲稻褪莙sort_t中的thunk參數(shù)运挫,這樣就不再需要將數(shù)組設(shè)置為全局變量了。一個不幸的事實是這些擴展函數(shù)并不是C標(biāo)準(zhǔn)庫中的函數(shù)费彼,而且在標(biāo)準(zhǔn)庫中還有非常多的類似的函數(shù)比如二分查找函數(shù)bsearch等等滑臊。當(dāng)要編寫的是跨平臺的應(yīng)用程序時就不得不放棄對這些非標(biāo)準(zhǔn)的擴展函數(shù)的使用了。所幸的是我們還可以借助一種稱之為thunk的技術(shù)來解決qsort函數(shù)間接排序的問題箍铲,這也就是我下面要引入的本文的主題了雇卷。
Thunk技術(shù)
thunk技術(shù)的概念在維基百科中被定義如下:
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程序中文翻譯為形實轉(zhuǎn)換程序,簡而言之Thunk程序就是一段代碼塊颠猴,這段代碼塊可以在調(diào)用真正的函數(shù)前后進行一些附加的計算和邏輯處理关划,或者提供將對原函數(shù)的直接調(diào)用轉(zhuǎn)化為間接調(diào)用的能力。Thunk程序在有的地方又被稱為跳板(trampoline)程序翘瓮,Thunk程序不會破壞原始被調(diào)用函數(shù)的棧參數(shù)結(jié)構(gòu)贮折,只是提供了一個原始調(diào)用的hook的能力。Thunk技術(shù)可以在編譯時和運行時兩種場景下被使用资盅。在介紹用Thunk技術(shù)實現(xiàn)運行時用qsort函數(shù)實現(xiàn)索引排序之前调榄,先介紹三種編譯時Thunk技術(shù)的使用場景。如果你不感興趣編譯時的場景則可以直接跳過這些小節(jié)呵扛。
一每庆、程序調(diào)用動態(tài)庫中函數(shù)的實現(xiàn)原理
在早期的實模式系統(tǒng)中可執(zhí)行程序通常只有一個文件組成,對內(nèi)存的訪問也是直接的物理內(nèi)存訪問今穿,程序加載時所存放的內(nèi)存地址區(qū)域也是固定的缤灵。一個可執(zhí)行程序中的所有代碼則是由多個不同的函數(shù)或者類組成的。當(dāng)要使用某個函數(shù)提供的功能時蓝晒,就需要在代碼處調(diào)用對應(yīng)的函數(shù)腮出。每個函數(shù)在程序運行并加載到內(nèi)存中時都有一個唯一的內(nèi)存中地址來標(biāo)識函數(shù)入口的開始位置,而調(diào)用函數(shù)的代碼則會在編譯鏈接后轉(zhuǎn)化為對函數(shù)執(zhí)行調(diào)用的機器指令(比如call或者bl指令)芝薇。假設(shè)有如下的可執(zhí)行程序源代碼:
void main()
{
foo();
}
void foo()
{
}
假如操作系統(tǒng)在實模式下將可執(zhí)行程序的指令代碼固定加載到地址為0x1000處胚嘲,那么當(dāng)將這個程序源碼進行編譯和鏈接產(chǎn)生二進制的可執(zhí)行文件運行時在內(nèi)存中的數(shù)據(jù)為如下:
//本機器指令是x86系統(tǒng)下的機器指令
//main函數(shù)的起始地址是0x1000
0x1000: E8 03 ;這里的E8是call指令的機器碼,03是表示調(diào)用從當(dāng)前指令位置往下相對偏移3個字節(jié)位置的函數(shù)地址洛二,也就是foo函數(shù)的地址慢逾。
0x1002: 22 ;這里的22是ret指令的機器碼
//foo函數(shù)的起始地址是0x1003
0x1003: 22 ; 這里的22是ret指令的機器碼
可以看出源代碼中的函數(shù)調(diào)用的語句在編譯鏈接后都會轉(zhuǎn)化為call指令操作碼后面跟著被調(diào)用函數(shù)與當(dāng)前指令之間的相對偏移值操作數(shù)的機器指令。函數(shù)調(diào)用地址采用相對偏移值而不采用絕對值的好處在于當(dāng)對內(nèi)存中的程序進行重定向或者動態(tài)調(diào)整程序加載到內(nèi)存中的基地址時就不需要改變二進制可執(zhí)行程序的內(nèi)容灭红。
隨著保護模式技術(shù)的實現(xiàn)以及多任務(wù)系統(tǒng)的誕生侣滩,操作系統(tǒng)為每個進程提供了獨立的虛擬內(nèi)存空間。為了對代碼進行復(fù)用变擒,操作系統(tǒng)提供了對動態(tài)鏈接庫的支持能力君珠。這種情況下一個程序就可能由一個可執(zhí)行程序和多個動態(tài)庫組成了。動態(tài)庫也是一段可被執(zhí)行的二進制代碼娇斑,只不過它并沒有定義像main函數(shù)之類的入口函數(shù)且不能被單獨執(zhí)行策添。當(dāng)一個程序被運行時操作系統(tǒng)會將可執(zhí)行程序文件以及顯式鏈接的所有動態(tài)庫文件的映像(image)隨機的加載到進程的虛擬內(nèi)存空間中去材部。而這時候就會產(chǎn)生出一個問題:當(dāng)所有的函數(shù)都定義在一個可執(zhí)行文件內(nèi)時,因為可執(zhí)行文件中的這些函數(shù)在編譯鏈接時的位置都已經(jīng)固定了唯竹,所以轉(zhuǎn)化為函數(shù)調(diào)用的機器指令時乐导,每個函數(shù)的相對偏移位置是很容易被計算出來的。而如果可執(zhí)行程序中調(diào)用的是一個由動態(tài)庫所提供的函數(shù)呢浸颓?因為這個動態(tài)庫和可執(zhí)行程序文件是兩個不同的文件物臂,并且動態(tài)庫的基地址被加載到進程的虛擬內(nèi)存空間的位置是不固定的而且隨機的,可執(zhí)行程序image和動態(tài)庫image所加載到的內(nèi)存區(qū)域并不一定是連續(xù)的內(nèi)存區(qū)域产上,因此可執(zhí)行程序是無法在編譯鏈接時得到動態(tài)庫中的函數(shù)地址在內(nèi)存中的位置和調(diào)用指令的在內(nèi)存中位置之間的相對偏移量的棵磷。解決這個問題的方法就是在編譯一個可執(zhí)行文件時,將可執(zhí)行程序代碼中調(diào)用的外部動態(tài)庫中定義的每一個函數(shù)都在本程序內(nèi)分別建立一個對應(yīng)的被稱為stub的本地函數(shù)代碼塊晋涣,同時在可執(zhí)行程序中的數(shù)據(jù)段中建立一個表格仪媒,這個表格的內(nèi)容保存的就是可執(zhí)行程序調(diào)用的每個外部動態(tài)庫中定義的函數(shù)的真實地址,我們稱這個表格為導(dǎo)入地址表谢鹊。然后對應(yīng)的每個本地stub函數(shù)塊中的實現(xiàn)就是將調(diào)用跳轉(zhuǎn)到導(dǎo)入地址表中對應(yīng)的真實函數(shù)實現(xiàn)的數(shù)組索引中去算吩。在可執(zhí)行程序啟動時這個導(dǎo)入地址表中的值全部都是0,而一旦動態(tài)庫被加載并確定了基地址后佃扼,操作系統(tǒng)就會將動態(tài)庫中定義的被可執(zhí)行程序調(diào)用的函數(shù)的真實的絕對地址偎巢,更新到可執(zhí)行程序數(shù)據(jù)段中的導(dǎo)入地址表中的對應(yīng)位置。這樣每當(dāng)可執(zhí)行程序調(diào)用外部動態(tài)庫中的函數(shù)時松嘶,其實被調(diào)用的是外部函數(shù)對應(yīng)的本地的stub函數(shù),然后stub函數(shù)內(nèi)部再跳轉(zhuǎn)到真實的動態(tài)庫定義的函數(shù)中去挎扰。這樣就解決了調(diào)用外部函數(shù)時call指令中的操作數(shù)仍然還是相對偏移值翠订,只不過這個偏移值并不是相對于動態(tài)庫中定義的函數(shù)的地址,而是相對于可執(zhí)行程序本身內(nèi)部定義的本地stub函數(shù)的函數(shù)地址遵倦。下面的例子說明了可執(zhí)行程序調(diào)用了C標(biāo)準(zhǔn)庫動態(tài)庫中的abs函數(shù)和printf函數(shù)的源代碼:
#include <stdlib.h>
int foo()
{
return 0;
}
int main(int argc, char *argv[])
{
int a = abs(-1);
printf("%d",a); //上面兩個都是動態(tài)庫中定義和提供的函數(shù)
foo(); //這個是本地定義的函數(shù)
return 0;
}
那在代碼被編譯后實際的偽代碼應(yīng)該是如下:
#include <stdlib.h>
//定義導(dǎo)入地址表結(jié)構(gòu)
typdef struct
{
char *fnname;
void *fnptr;
}iat_t;
iat_t _giat[] = {{"abs", 0}, {"printf",0}};
int foo()
{//本地函數(shù)不會在導(dǎo)入地址表中
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, ...);
}
通過上面的代碼可以看出來在將可執(zhí)行程序編譯鏈接時尽超,所有的函數(shù)調(diào)用call指令中的地址值部分都可以指定為相對偏移值。對于程序中調(diào)用到的動態(tài)庫中定義的函數(shù)梧躺,則會在main函數(shù)運行前似谁,動態(tài)庫被加載后更新_giat表中的所有函數(shù)的真實地址,這樣就實現(xiàn)了動態(tài)庫中的函數(shù)調(diào)用了掠哥。
當(dāng)然了上面介紹的動態(tài)庫函數(shù)調(diào)用的原理在每種操作系統(tǒng)下可能會有一些差異巩踏。Facebook所提供的一個開源的iOS庫fishhook的內(nèi)部實現(xiàn)就是通過修改_giat表中的真實函數(shù)地址來實現(xiàn)函數(shù)調(diào)用的替換的。
當(dāng)你了解到了動態(tài)庫中函數(shù)調(diào)用的機制后续搀,其實你也是可以任意修改一個程序中調(diào)用的所有外部動態(tài)庫的函數(shù)的邏輯的塞琼,因為導(dǎo)入地址表存放在數(shù)據(jù)段,其值可以被任意修改禁舷,因此你也可以將某個函數(shù)調(diào)用的真實實現(xiàn)變?yōu)槟阆胍娜我鈱崿F(xiàn)彪杉。很多越獄后的應(yīng)用就是通過修改導(dǎo)入地址表中的函數(shù)地址而實現(xiàn)函數(shù)調(diào)用的重定向的邏輯的毅往。
再來考察一下_stub_xxx函數(shù)的實現(xiàn),如果你切換到程序的匯編指令代碼視圖時派近,你就會發(fā)現(xiàn)幾乎所有的_stub_xxx函數(shù)的代碼都是一樣的攀唯。這里的_stub_xxx函數(shù)塊就是thunk技術(shù)的一種實際應(yīng)用場景。下面是iOS的arm64位系統(tǒng)中關(guān)于動態(tài)庫函數(shù)調(diào)用實現(xiàn):
你會發(fā)現(xiàn)每個_stub函數(shù)只有3條指令:
_stub_obj_msgSend:
nop
ldr x16, 0x1640
br x16
一條是nop空指令渴丸、一條是將導(dǎo)入符號表中真實函數(shù)地址保存到x16寄存器中侯嘀、一條是跳轉(zhuǎn)指令。這里的跳轉(zhuǎn)指令不用blr而用br的原因是如果采用blr則將會再次形成一個調(diào)用棧的生成曙强,這樣在調(diào)試和斷點時看到的將不是真實的函數(shù)調(diào)用残拐,而是_stub_xxx函數(shù)的調(diào)用,而跳轉(zhuǎn)指令只是簡單的將函數(shù)的調(diào)用跳轉(zhuǎn)到真實的函數(shù)入口地址中去碟嘴,并且不需要再次進行函數(shù)調(diào)用進棧和出棧處理溪食,正是這樣的設(shè)置使得對于外面而言就像是直接調(diào)用動態(tài)庫的函數(shù)一樣。因此可以看出thunk技術(shù)其實是一種代碼重定向的技術(shù)娜扇,并且這種重定向并不會影響到函數(shù)參數(shù)的入棧和出棧處理错沃,對于調(diào)用者來說就好像是直接調(diào)用的真實函數(shù)一樣。
iOS系統(tǒng)中一個程序中的所有stub函數(shù)的符號和實現(xiàn)分別存放在代碼段__TEXT的_stubs和_stub_helper兩個section中雀瓢。
二枢析、C++中虛函數(shù)調(diào)用的實現(xiàn)原理。
C++語言是一門面向?qū)ο蟮恼Z言刃麸,面向?qū)ο笏枷胫袑Χ鄳B(tài)的支持是其核心能力醒叁。所謂多態(tài)描述的是對象的行為可以在運行時來決定。對象的行為在語義層面上表現(xiàn)為類中定義的方法函數(shù)泊业。一般情況下對具體函數(shù)的調(diào)用會在編譯時就被確定下來把沼,那如何能將函數(shù)的調(diào)用轉(zhuǎn)化為運行時再進行確定呢? 在C++中通過將成員函數(shù)定義為虛函數(shù)(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定義了一個普通成員函數(shù)foo1和兩個虛函數(shù)foo2, foo3吁伺。CB繼承自CA并覆寫foo1函數(shù)和重載了foo2函數(shù)饮睬。上述代碼運行得到如下的結(jié)果:
CA::foo1
CA::foo2
CA::foo3
CA::foo1
CB::foo2
CA::foo3
可以看出來在func函數(shù)內(nèi)無論你傳遞的對象是基類CA的實例還是派生類CB的實例當(dāng)調(diào)用foo1函數(shù)時總是打印的是基類的foo1函數(shù)中的內(nèi)容,而調(diào)用foo2函數(shù)時就會區(qū)分是基類對象的實現(xiàn)還是派生類對象的實現(xiàn)篮奄。在函數(shù)func中它的參數(shù)指向的總是一個CA對象捆愁,因為編譯器是不知道運行時傳遞的到底是基類還是派生類的對象實例,那么系統(tǒng)又是如何實現(xiàn)這種多態(tài)的特性的呢窟却?
在C++中昼丑,一旦類中有成員函數(shù)被定義為虛函數(shù)(帶有virtual關(guān)鍵字)就會在編譯鏈接時為這個類建立一個全局的虛函數(shù)表(virtual table),這個虛函數(shù)表中每個條目的內(nèi)容保存著被定義為虛函數(shù)的函數(shù)地址指針夸赫。每當(dāng)實例化一個定義有虛函數(shù)的對象時矾克,就會將對象的中的一個隱藏的數(shù)據(jù)成員指針(這個指針稱之為vtbptr)指向為類所定義的虛函數(shù)表的開始地址。整個結(jié)構(gòu)就如下面的圖中展示的一樣:
因此上面的代碼在被編譯后其實就會轉(zhuǎn)化為如下的完整偽代碼:
struct CA
{
void *vtbptr;
};
struct CB
{
void *vtbptr;
};
//因為C++中有函數(shù)命名修飾,實際的名字不應(yīng)該是這樣的胁附,這里是為了讓大家更好的理解函數(shù)的定義和實現(xiàn)
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個類的全局虛擬函數(shù)表
void * _gCAvtb[] = {&CA::foo2, &CA::foo3};
void * _gCBvtb[] = {&CB::foo2, &CA::foo3, &CB::foo4};
void func(CA *p)
{
CA::foo1(p); //這里被編譯為正常函數(shù)的調(diào)用
p->vtbptr[0](p); //這里被編譯為虛函數(shù)調(diào)用的實現(xiàn)代碼酒繁。
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;
}
觀察上面函數(shù)func的實現(xiàn)可以看出來,當(dāng)對程序進行編譯時控妻,如果發(fā)現(xiàn)調(diào)用的函數(shù)是非虛函數(shù)那么就會在代碼中直接調(diào)用類中定義的函數(shù)州袒,如果發(fā)現(xiàn)調(diào)用的是虛函數(shù)時那么在代碼中將會使用間接調(diào)用的方法,也就是通過調(diào)用虛函數(shù)表中記錄的函數(shù)地址弓候,這樣就實現(xiàn)了所謂的多態(tài)和運行時動態(tài)確定行為的效果郎哭。從上面的代碼實現(xiàn)中您也許會發(fā)現(xiàn)這里和前面關(guān)于動態(tài)庫函數(shù)調(diào)用實現(xiàn)有類似的一些機制:都定義了一個表格,表格中存放的是真正要調(diào)用的函數(shù)地址菇存,而在外部調(diào)用這些函數(shù)時夸研,并不是直接調(diào)用定義的函數(shù)的地址,而是采用了間接調(diào)用的方式來實現(xiàn)依鸥,這個間接調(diào)用方式都是用比較統(tǒng)一和相似的代碼塊來實現(xiàn)亥至。查看虛函數(shù)的調(diào)用對應(yīng)的匯編代碼時你可能會看到如下的代碼片段:
//macOS中的x86_64位下的匯編代碼
movq -0x8(%rbp), %rdi ;CA對象的p1保存到%rdi寄存器中。
callq 0x100000e80 ;非虛函數(shù)CA::foo1采用直接調(diào)用的方式
movq (%rdi), %rax ;將p1中的虛函數(shù)表vtbptr指針取出保存到%rax中
callq *(%rax) ;間接調(diào)用虛函數(shù)表中的第一項也就是foo2函數(shù)所保存的位置
callq *0x8(%rax) ;間接調(diào)用虛函數(shù)表中的第二項也就是foo3函數(shù)所保存的位置
可見在C++中對虛擬函數(shù)進行調(diào)用的代碼的實現(xiàn)也是用到了thunk技術(shù)贱迟。除了虛函數(shù)調(diào)用這里使用了thunk技術(shù)外姐扮,C++還在另外一種場景中使用到了thunk技術(shù)。
嚴(yán)格來說其實C++的虛函數(shù)調(diào)用機制的實現(xiàn)不應(yīng)該納入thunk技術(shù)的一種實現(xiàn)衣吠,但是某種意義上虛函數(shù)調(diào)用確實又是高級語言直接調(diào)用而在編譯后又通過安插特定代碼來實現(xiàn)真實的函數(shù)調(diào)用的茶敏。
三、C++中基于接口的多重繼承中對thunk技術(shù)的使用
在C++的基于接口編程的一些技術(shù)解決方案中(比如早期Windows的COM技術(shù))缚俏。往往會設(shè)計一個系統(tǒng)公用的基接口(比如COM的IUnknown接口)惊搏,然后所有的接口都從這個基接口進行派生,而一個實現(xiàn)類往往會實現(xiàn)多個接口忧换。整個設(shè)計結(jié)構(gòu)可用如下代碼表示:
//定義共有抽象基接口
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;
};
//實現(xiàn)類Imp同時實現(xiàn)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;
}
上面的這種繼承關(guān)系圖如下:
根據(jù)C++對虛函數(shù)的支持實現(xiàn)以及多重繼承支持,上面的Imp類的對象實例的內(nèi)存布局以及虛函數(shù)表的布局結(jié)構(gòu)如下:
因此上面的代碼在編譯后真實的偽代碼實現(xiàn)如下:
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個的全局虛函數(shù)表
void * _gImpvtb[] = {&Imp::basefn, &Imp::afn, &Imp::bfn};
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;
}
仔細(xì)觀察第二個虛函數(shù)表中的兩個條目包雀,會發(fā)現(xiàn)B接口類虛函數(shù)表中的函數(shù)地址并不是Imp::basefn和Imp::bfn宿崭,而是兩個特殊的并未公開的函數(shù)亲铡,這兩函數(shù)實現(xià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);
}
兩個函數(shù)內(nèi)部只是簡單的將對象指針轉(zhuǎn)化為了派生類對象的指針并調(diào)用真實的函數(shù)實現(xiàn)才写。那為什么B接口虛函數(shù)表中的函數(shù)地址不是真實的函數(shù)地址而是一個thunk函數(shù)的地址呢?其實從上面的對象的內(nèi)存布局結(jié)構(gòu)就能找出答案奖蔓。因為Imp是從B進行的多重繼承赞草,所以當(dāng)將一個Imp類對象的指針,轉(zhuǎn)化為基類B的指針時吆鹤,其實指針的值是增加了8個字節(jié)(如果是32位就4個字節(jié))厨疙。又因為B和A都是從Base派生的,因此不管是B還是A都可以調(diào)用fnBase函數(shù)疑务,但這樣就會出現(xiàn)入?yún)⒌牡刂凡灰恢碌膯栴}沾凄。舉例來說梗醇,假如實例化一個Imp對象并且為其分配在內(nèi)存中的地址為0x1000,就如如下代碼:
Imp *pImp = new Imp; //假設(shè)這里分配的地址是0x1000, 也就是pImp == 0x1000
A *pA = pImp; //因為A是Imp的第一個基類撒蟀,所以根據(jù)類型轉(zhuǎn)換規(guī)則得到的pA == 0x1000 叙谨,pA和pImp指向同一個地址。
B *pB = pImp; //因為B是Imp的第二個基類保屯,所以根據(jù)類型轉(zhuǎn)換規(guī)則得到pB == 0x1008手负,pB等于pImp的值往下偏移8個字節(jié)。
pImp->basefn(); //轉(zhuǎn)化為pImp->vtbImpptr[0](0x1000);
pA->basefn(); //轉(zhuǎn)化為pA->vtbptr[0](0x1000);
pB->basefn(); //轉(zhuǎn)化為pB->vtbptr[0](0x1008);
可以看出如果基接口B中的虛函數(shù)表的第一個條目保存的也是Imp::basefn的話姑尺,因為最終的實現(xiàn)是Imp類竟终,而且basefn接收的參數(shù)也是Imp指針,但是因為調(diào)用者是pB,對象指針被偏移了8個字節(jié)切蟋,這樣就產(chǎn)生了同一個函數(shù)實現(xiàn)接收兩個不一致的this地址的問題统捶,從而產(chǎn)生錯誤的結(jié)果,因此為了糾正轉(zhuǎn)化為B類指針時調(diào)用會產(chǎn)生的問題敦姻,就必須將B接口的虛函數(shù)表中的所有條目改成為一個個thunk函數(shù)瘾境,這些thunk函數(shù)的作用就是對this指針的地址進行真實的調(diào)整,從而保證函數(shù)調(diào)用的一致性镰惦∶允兀可以看出在這里thunk技術(shù)又再次的被應(yīng)用到實際的問題解決中來了。下面是這個thunk代碼塊的macOS系統(tǒng)下x86_64位的匯編代碼實現(xiàn):
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技術(shù)的方法和實現(xiàn)
上面介紹的3種使用thunk技術(shù)的地方都是在編譯階段通過插入特定的thunk代碼塊來完成的旺入,在編譯高級語言時會自動生成一些thunk代碼塊函數(shù)兑凿,并且會對一些特殊的函數(shù)調(diào)用改為對thunk代碼塊的調(diào)用,這些調(diào)用邏輯一旦確定后就無法再進行改變了茵瘾。因此我們不可能使用編譯時的thunk技術(shù)來解答文本的qsort函數(shù)排序的需求礼华。那除了由編譯器生成thunk代碼塊外,在程序運行時是否可以動態(tài)的來構(gòu)造一個thunk代碼塊呢拗秘?答案是可以的圣絮,要想動態(tài)來構(gòu)造一個thunk代碼塊,首先要了解函數(shù)的調(diào)用實現(xiàn)過程雕旨。
下面舉例中的機器指令以及參數(shù)傳遞主要是iOS的arm64位下面的規(guī)定扮匠,如果沒有做其他說明則默認(rèn)就是指的iOS的arm64位系統(tǒng)。
函數(shù)調(diào)用以及參數(shù)傳遞的機器指令實現(xiàn)
一個函數(shù)簽名中除了有函數(shù)名外凡涩,還可能會定義有參數(shù)棒搜。函數(shù)的調(diào)用者在調(diào)用函數(shù)時除了要指定調(diào)用的函數(shù)名時還需要傳入函數(shù)所需要的參數(shù),函數(shù)參數(shù)從調(diào)用者傳遞給實現(xiàn)者活箕。在編譯代碼時會將對函數(shù)的調(diào)用轉(zhuǎn)化為call/bl指令和對應(yīng)的函數(shù)的地址力麸。那么編譯器又是來解決參數(shù)的傳遞的呢?為了解決這個問題就需要在調(diào)用者和實現(xiàn)者之間形成一個統(tǒng)一的標(biāo)準(zhǔn),雙方可以約定一個特定的位置克蚂,這樣當(dāng)調(diào)用函數(shù)前闺鲸,調(diào)用者先把參數(shù)保存到那個特定的位置,然后再執(zhí)行函數(shù)調(diào)用call/bl指令埃叭,當(dāng)執(zhí)行到函數(shù)內(nèi)部時翠拣,函數(shù)實現(xiàn)者再從那個特定的位置將數(shù)據(jù)讀取出來并處理。參數(shù)存放的最佳位置就是棧內(nèi)存區(qū)域或者CPU中的寄存器中游盲,至于是采用哪種方法則是根據(jù)不同操作系統(tǒng)平臺以及不同CPU體系結(jié)構(gòu)而不同误墓,有些可能規(guī)定為通過棧內(nèi)存?zhèn)鬟f,而有些規(guī)定則是通過寄存器傳遞益缎,有些則采用兩者的混合方式進行傳遞谜慌。就以iOS的64位arm系統(tǒng)來說幾乎所有函數(shù)調(diào)用的參數(shù)傳遞都是通過寄存器來實現(xiàn)的,而當(dāng)函數(shù)的參數(shù)超過8個時才會用到棧內(nèi)存空間來進行參數(shù)傳遞莺奔,并且進一步規(guī)定非浮點數(shù)參數(shù)的保存從左到右依次保存到x0-x8中去欣范,并且函數(shù)的返回值一般都保存在x0寄存器中。因此下面的函數(shù)調(diào)用和實現(xiàn)高級語言的代碼:
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;
}
最終在轉(zhuǎn)化為arm64位匯編偽代碼就變?yōu)榱巳缦轮噶睿?/p>
//真實中并不一定有這些指令令哟,這里這些偽指令主要是為了讓大家容易去理解
int foo(int a, int b, int c)
{
mov a, x0 ;把調(diào)用者存放在x0寄存器中的值保存到a中恼琼。
mov b, x1 ;把調(diào)用者存放在x1寄存器中的值保存到b中。
mov c, x2 ;把調(diào)用者存放在x2寄存器中的值保存到c中屏富。
add x0, a, b, c ;執(zhí)行加法指令并保存到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 ;調(diào)用foo函數(shù)指令
mov ret, x0 ;將foo函數(shù)返回的結(jié)果保存到ret變量中。
mov x0, #0 ;將main函數(shù)的返回結(jié)果0保存到x0寄存器中
ret
}
至此狠半,我們基本了解到了函數(shù)的調(diào)用和參數(shù)傳遞的實現(xiàn)原理噩死,可見無論是函數(shù)調(diào)用還是參數(shù)傳遞都是通過機器指令來實現(xiàn)的。
動態(tài)構(gòu)建內(nèi)存指令塊
一個運行中的程序無論是其指令代碼還是數(shù)據(jù)都是以二進制的形式存放在內(nèi)存中神年,程序代碼段中的指令代碼是在編譯鏈接時就已經(jīng)產(chǎn)生了的固定指令序列已维。當(dāng)然,只要在內(nèi)存中存放的二進制數(shù)據(jù)符合機器指令的格式已日,那么這塊內(nèi)存中存儲的二進制數(shù)據(jù)就可以送到CPU當(dāng)中去執(zhí)行垛耳。換句話說就是機器指令除了可以在編譯鏈接時靜態(tài)生成還可以在程序運行過程中動態(tài)生成。這個結(jié)論的意義在于我們甚至可以將指令數(shù)據(jù)從遠(yuǎn)端下載到本地進程中飘千,并且在程序運行時動態(tài)的改變程序的運行邏輯堂鲜。
參考上面關(guān)于函數(shù)調(diào)用以及參數(shù)傳遞的實現(xiàn)可以得出,qsort函數(shù)接收一個比較器compar函數(shù)指針占婉,函數(shù)指針其實就是一塊可執(zhí)行代碼的內(nèi)存首地址泡嘴。而每次在進行兩個元素的比較時都會先將兩個元素參數(shù)分別保存到x0,x1兩個寄存器中甫恩,然后再通過 bl compar
指令實現(xiàn)對比較器函數(shù)的調(diào)用逆济。為了讓qsort能夠支持對帶擴展參數(shù)的比較器函數(shù)調(diào)用,我們可以動態(tài)的構(gòu)造出一段指令代碼(這段指令代碼就是一個thunk程序塊)。代碼塊的指令序列如下:
- 將寄存器x1的值保存到x2中奖慌。
- 將寄存器x0的值保存到x1中抛虫。
- 將擴展參數(shù)的值保存到x0中。
- 將帶擴展參數(shù)的真實比較器函數(shù)的地址保存到x3中去
- 跳轉(zhuǎn)到x3寄存器所保存的帶擴展參數(shù)的真實比較器函數(shù)中去简僧。
然后再將這些指令對應(yīng)的二進制機器碼保存到某個已經(jīng)分配好的內(nèi)存塊中建椰,最后再將這塊分配好的內(nèi)存塊首地址(thunk比較器函數(shù)地址),作為qsort的compar函數(shù)比較器指針的參數(shù)岛马。這樣當(dāng)qsort內(nèi)部在需要比較時就先把兩個比較的元素分別存放入x0,x1中并調(diào)用這個thunk比較器函數(shù)棉姐。而當(dāng)執(zhí)行進入thunk比較器函數(shù)內(nèi)部時,就會如上面所寫的把原先的x0,x1兩個寄存器中的值移動到x1,x2中去啦逆,并把擴展參數(shù)移動到x0中伞矩,然后再跳轉(zhuǎn)一個真實的帶擴展參數(shù)的比較器函數(shù)中去,等真實的帶擴展參數(shù)的比較器函數(shù)比較完成返回時夏志,thunk比較器函數(shù)就會將結(jié)果返回給qsort函數(shù)來告訴qsort比較的結(jié)果乃坤。這個過程中其實真正進行比較的是一個帶擴展參數(shù)的真實比較器函數(shù),但是我們卻通過thunk技術(shù)欺騙了qsort函數(shù)沟蔑,讓qsort函數(shù)以為執(zhí)行的仍然是一個不帶擴展參數(shù)的比較器函數(shù)湿诊。
可執(zhí)行代碼的執(zhí)行權(quán)限
為了方便管理和安全的需要,操作系統(tǒng)對一個進程中的虛擬內(nèi)存空間進行了權(quán)限的劃分瘦材。某些區(qū)域被設(shè)置為僅可執(zhí)行厅须,比如代碼段所加載的內(nèi)存區(qū)域;而某些區(qū)域則被設(shè)置為可讀寫食棕,比如數(shù)據(jù)段所加載的內(nèi)存區(qū)域九杂;而某些區(qū)域則被設(shè)置為了只讀,比如常量數(shù)據(jù)段所加載的內(nèi)存區(qū)域宣蠕;而某些區(qū)域則被設(shè)置了無讀寫訪問權(quán)限例隆,比如進程的虛擬內(nèi)存的首頁地址區(qū)域(0到4096這塊區(qū)域)。程序中代碼段所加載的內(nèi)存區(qū)域只供可執(zhí)行抢蚀,可執(zhí)行表明這塊區(qū)域的內(nèi)存中的數(shù)據(jù)可以被CPU執(zhí)行以及進行讀取訪問镀层,但是不能進行改寫。不能改寫的原因很簡單皿曲,假如這塊區(qū)域的內(nèi)容可以被改寫的話唱逢,那就可以在運行時動態(tài)變更可執(zhí)行邏輯,這樣整個程序的邏輯就會亂套和結(jié)果未可知屋休。因此幾乎所有操作系統(tǒng)中的進程內(nèi)存中的代碼要想被執(zhí)行則這塊內(nèi)存區(qū)域必須具有可執(zhí)行權(quán)限坞古。有些操作系統(tǒng)甚至更加嚴(yán)格的要求可執(zhí)行的代碼所屬的內(nèi)存區(qū)域必須只能具有可執(zhí)行權(quán)限,而不能具有寫權(quán)限劫樟。
內(nèi)存映射文件技術(shù)實現(xiàn)權(quán)限動態(tài)調(diào)整
上一個小結(jié)中我們說到可以在程序運行時動態(tài)的在內(nèi)存中構(gòu)建出一塊指令代碼來讓CPU執(zhí)行痪枫。如果是這樣的話那就和可執(zhí)行的內(nèi)存區(qū)域只能是可執(zhí)行權(quán)限互相矛盾了织堂。為了解決讓動態(tài)分配的內(nèi)存塊具有可執(zhí)行的權(quán)限,可以借助內(nèi)存映射文件的技術(shù)來達到目的奶陈。內(nèi)存映射文件技術(shù)是用于將一個磁盤中的文件映射到一塊進程中的虛擬內(nèi)存空間中的技術(shù)易阳,這樣我們要對文件進行讀寫時就可以用內(nèi)存地址進行讀寫訪問的方式來進行,而不需要借助文件的IO函數(shù)來執(zhí)行讀寫訪問操作吃粒。內(nèi)存映射文件技術(shù)大大簡化了對文件進行讀寫操作的方式潦俺。而且其實當(dāng)可執(zhí)行程序在運行時,操作系統(tǒng)就是通過內(nèi)存映射文件技術(shù)來將可執(zhí)行程序映射到進程的虛擬內(nèi)存空間中來實現(xiàn)程序的加載的徐勃。內(nèi)存映射文件技術(shù)還可以指定和動態(tài)修改文件映射到內(nèi)存空間中的訪問權(quán)限事示。而且內(nèi)存映射文件技術(shù)還可以在不關(guān)聯(lián)具體的文件的情況下來實現(xiàn)虛擬內(nèi)存的分配以及對分配的內(nèi)存進行權(quán)限的設(shè)置和修改的能力。因此可以借助內(nèi)存映射文件技術(shù)來實現(xiàn)對內(nèi)存區(qū)域的可執(zhí)行保護設(shè)置僻肖。下面的代碼就演示了這種能力:
#include <sys/mman.h>
int main(int argc, char *argv[])
{
//分配一塊長度為128字節(jié)的可讀寫和可執(zhí)行的內(nèi)存區(qū)域
char *bytes = (char *)mmap(0, 128, PROT_EXEC|PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0);
memcpy(bytes, "Hello world!", 13);
//修改內(nèi)存的權(quán)限為只可讀,不可寫很魂。
mprotect(bytes, 128, PROT_READ);
printf(bytes);
memcpy(bytes, "Oops!", 6); //oops! 內(nèi)存不可寫!
return 0;
}
iOS上的thunk代碼實現(xiàn)
前面介紹了動態(tài)構(gòu)建內(nèi)存指令的技術(shù)檐涝,以及讓qsort支持帶擴展參數(shù)的函數(shù)比較器的方法介紹绳锅,以及內(nèi)存映射文件技術(shù)的介紹走芋,這里將用具體的代碼示例來實現(xiàn)一個在iOS的64位arm系統(tǒng)下的thunk代碼實現(xiàn)吱涉。
#include <sys/mman.h>
//因為結(jié)構(gòu)體定義中存在對齊的問題狼犯,但是這里要求要單字節(jié)對齊,所以要加#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;
//按年齡升序排列的函數(shù)
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};
//第一步: 構(gòu)造出機器指令
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
};
//第二步:分配指令內(nèi)存并設(shè)置可執(zhí)行權(quán)限
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);
//第三步:為排序函數(shù)傳遞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系統(tǒng)中每條指令都占用4個字節(jié),因此為了方便實現(xiàn)前面介紹的邏輯可以建立一個如下的結(jié)構(gòu)體:
#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)
上述結(jié)構(gòu)體中第三個和第四個數(shù)據(jù)成員所描述的指令如下:
ldr x0, #0xc0
ldr x3, #0x10
第三條指令的意思是將從當(dāng)前位置偏移0xc0個字節(jié)位置中的內(nèi)存中的數(shù)據(jù)保存到x0寄存器中巷怜,根據(jù)偏移量可以得出剛好arg0的位置和指令當(dāng)前位置偏移0xc0個字節(jié)葛超。同理可以得到第四條指令是將realfn的值保存到x3寄存器中。這里設(shè)計為這樣的原因是為了方便數(shù)據(jù)的讀取延塑,因為動態(tài)構(gòu)造的指令塊對和指令自身連續(xù)存儲的內(nèi)存地址訪問要比訪問其他不連續(xù)的特定內(nèi)存地址訪問要簡單得多绣张,只需要簡單的讀取當(dāng)前指令偏移特定值的地址即可。
再接下來的代碼中可以看出初始化這個結(jié)構(gòu)體的代碼:
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, //第一個參數(shù)保存的就是擴展的參數(shù)students數(shù)組
.realfn = ageidxcomparfn //真實的帶擴展參數(shù)的比較器函數(shù)地址ageidxcomparfn
};
這段代碼可以看到thunk程序塊的匯編指令和對應(yīng)的16進制機器指令关带,因此在構(gòu)造結(jié)構(gòu)體的數(shù)據(jù)成員時侥涵,只需要將特定的16進制值賦值給對應(yīng)的數(shù)據(jù)成員即可,在最后的arg0中保存的是擴展參數(shù)students的指針宋雏,而realfn中保存的就是真實的帶擴展參數(shù)的比較器函數(shù)地址芜飘。
當(dāng)thunkblock_t結(jié)構(gòu)體初始化完成后,結(jié)構(gòu)體tb中的內(nèi)容就是一段可被執(zhí)行的thunk程序塊了磨总,接下來就需要借助內(nèi)存映射文件技術(shù)嗦明,將這塊代碼存放到一個只有可執(zhí)行權(quán)限的內(nèi)存區(qū)域中去,這就是上面實例代碼的第二步所做的事情蚪燕。最后第三步則只需要將內(nèi)存映射生成的可執(zhí)行thunk程序塊的首地址作為qsort函數(shù)的最后一個參數(shù)即可娶牌。
注意1记场!裙戏! 在iOS系統(tǒng)中如果您的應(yīng)用需要提交到appstore進行審核,那么當(dāng)你用Distrubution證書和provison配置文件所打出來的應(yīng)用程序包是不支持將某個內(nèi)存區(qū)域設(shè)置為可執(zhí)行權(quán)限的厕诡!也就是上面的mprotect函數(shù)執(zhí)行時會失效累榜。因為iOS系統(tǒng)內(nèi)核會對從appstore下載的應(yīng)用程序中的可執(zhí)行代碼段進行簽名校驗,而我們動態(tài)分配的可執(zhí)行內(nèi)存區(qū)域是無法通過簽名校驗的灵嫌,所以代碼必定會運行失敗壹罚。iOS系統(tǒng)這樣設(shè)置的目的還是為了防止我們通過動態(tài)指令下載來實現(xiàn)熱修復(fù)的技術(shù)。但是上述的代碼是可以在開發(fā)者證書以及模擬器上運行通過的寿羞,因此切不可將這個技術(shù)解決方案用在需要發(fā)布證書簽名校驗的程序中猖凛。雖然如此但是我們還是可以用這項技術(shù)在開發(fā)版本和測試版本中來實現(xiàn)一些主線程檢測、代碼插樁的能力而不影響程序的性能的情況下來構(gòu)建一些測試和檢查的能力绪穆。
一個多平臺下的完整thunk代碼實現(xiàn)
除了實現(xiàn)iOS64位arm系統(tǒng)的thunk的例子外辨泳,下面是一段完整的thunk代碼,它分別在windows64位操作系統(tǒng)玖院、樹莓派linux系統(tǒng)菠红、macOS系統(tǒng)、以及iOS的x86_64位模擬器难菌、arm试溯、arm64位系統(tǒng)下驗證通過,因為不同的操作系統(tǒng)以及不同CPU下的指令集不一樣郊酒,以及函數(shù)調(diào)用的參數(shù)傳遞規(guī)則不一樣遇绞,所以不同的系統(tǒng)下實現(xiàn)會略有差異,但是總體的原理是大同小異的燎窘。這里就不再詳細(xì)介紹不同系統(tǒng)的差異了摹闽,從注釋中的匯編代碼你就能將邏輯和原理搞清楚。而且這段代碼還可以復(fù)用到所有需要使用擴展參數(shù)但是又不支持?jǐn)U展參數(shù)的那些回調(diào)函數(shù)中去褐健。
#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;
//按年齡升序排列的函數(shù)
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技術(shù)其實是在10多年前的Windows的ATL庫實現(xiàn)中钩骇,ATL庫中通過thunk技術(shù)巧妙的將一個窗口句柄操作轉(zhuǎn)化為了類的操作。當(dāng)時覺得這個解決方案太神奇了铝量,后來依葫蘆畫瓢將thunk技術(shù)應(yīng)用到了一個快速排序的Windows程序中去倘屹,也就是本文例子中的原型,然后在開發(fā)中又發(fā)現(xiàn)了很多的thunk技術(shù)慢叨,所以就想寫這么一篇thunk技術(shù)原理以及應(yīng)用相關(guān)的文章纽匙。thunk技術(shù)還可以在比如函數(shù)調(diào)用的采集、埋點拍谐、主線程檢測等等應(yīng)用場景中使用烛缔。希望這篇文章能夠?qū)Υ蠹以趯嵺`中解決問題有所幫助馏段,如果您閱讀完這篇文章后有所收獲,那就請不要吝嗇在下面給一個贊賞吧??践瓷。