JSPatch 支持了動態調用 C 函數,無需在編譯前橋接每一個要調用的 C 函數,只須要在 JS 裏調用前聲明下這個函數,就能夠直接調用:git
require('JPEngine').addExtensions(['JPCFunction']) defineCFunction("malloc", "void *, size_t") malloc(10)
咱們一步步來看看怎樣能夠作到動態調用 C 函數。github
首先若要動態調用 C 函數,第一步就是須要經過傳入一個函數名字符串找到這個函數地址,這裏一個必要的前提條件就是 C 編譯後的可執行文件裏必須有原函數名的信息,纔有可能作到經過函數名字符串找到函數地址。咱們寫個簡單的程序來看看它編譯後可執行文件的內容有沒有這個信息:數組
//main.m void test() { } int main() { return 0; }
編譯這個文件,並用otool看下它的彙編:架構
gcc main.m -o main.o otool -tV main.o
輸出:函數
main.o: (__TEXT,__text) section _test: 0000000100000f90 pushq %rbp 0000000100000f91 movq %rsp, %rbp 0000000100000f94 popq %rbp 0000000100000f95 retq 0000000100000f96 nopw %cs:(%rax,%rax) _main: 0000000100000fa0 pushq %rbp 0000000100000fa1 movq %rsp, %rbp 0000000100000fa4 xorl %eax, %eax 0000000100000fa6 movl $0x0, -0x4(%rbp) 0000000100000fad popq %rbp 0000000100000fae retq
能夠看到函數名 test 和 main 都清楚地記錄在可執行文件裏,只不過前面多了個下劃線_,因此徹底能夠在運行時經過函數名字符串查到這個函數地址。ui
實際上動態連接器已經提供一個 API:dlsym(),原本是用於動態加載庫(DLL),而後經過這個接口拿到函數地址,它也能夠應用於當前可執行文件鏡像,原理是同樣的。spa
void test() { printf("testFunc"); } int main() { void (*funcPointer)() = dlsym(RTLD_DEFAULT, "test"); funcPointer(); return 0; }
好了如今咱們能夠經過函數名拿到對應的函數地址了,這樣就能夠自由動態調用全部 C 函數了嗎?還不行,這樣只能動態調用返回值和參數都爲空的 C 函數,上面 funcPointer指針只能在指向參數返回值都爲空的函數時才能正確調用到。對於有返回值和有參數的 C 函數,這裏定義時須要指明參數和返回值類型才能使用:.net
int testFunc(int n, int m) { printf("testFunc"); return 1; } int main() { // ① int (*funcPointer)(int, int) = dlsym(RTLD_DEFAULT, "testFunc"); funcPointer(1, 2); // ② void (*funcPointer)() = dlsym(RTLD_DEFAULT, "testFunc"); funcPointer(1, 2); //error return 0; }
(這裏①和②兩個調用方式下文會屢次提到,①表示調用正肯定義了函數參數/返回值類型的函數指針,②表示調用沒有正肯定義參數/返回值類型的函數指針)3d
這個例子中 dlsym 返回了 testFunc 的函數指針,必須像 ① 那樣指明它的返回類型和參數類型後,才能調用成功,若是像 ② 那樣定義這個指針,沒有正確的參數類型和返回值類型,在調用時就會出現crash。指針
也就是說咱們無法經過定義一個萬能的函數指針去支持全部函數的動態調用,這裏必須讓函數的參數/返回值類型都對應上才能調用,爲何必需要對應上呢?由於函數的調用方和被調用方是會遵循一種叫調用慣例(Calling Convention)的約定的。
一個函數的調用過程當中,函數的參數既可使用棧傳遞,也可使用寄存器傳遞,參數壓棧的順序能夠從左到右也能夠從右到左,函數調用後參數從棧彈出這個工做能夠由函數調用方完成,也能夠由被調用方完成。若是函數的調用方和被調用方(函數自己)不遵循統一的約定,有這麼多分歧這個函數調用就無法完成。這個雙方必須遵照的統一約定就叫作調用慣例(Calling Convention),調用慣例規定了參數的傳遞的順序和方式,以及棧的維護方式。
函數調用者和被調用者須要遵循這同一套約定,上述②這樣的狀況,就是函數自己遵循了這個約定,而調用者沒有遵照,致使調用出錯。
再簡單分析下,若是按①那樣正確的定義方式定義funcPointer,而後調用它,這裏編譯成彙編後,在調用處會有相應指令把參數 n,m 的值 1 和 2 入棧,而後跳過去 testFunc()函數實體執行,這個函數執行時,按約定它知道n,m兩個參數值已經在棧上,就能夠取出來使用了。
而若是按②那樣定義,編譯後這裏不會把參數 n,m 的值 1 和 2 入棧,由於這裏編譯器把它當成了沒有參數和沒有返回值的函數,也就不須要進行參數入棧的操做,而後在 testFunc()函數實體裏按約定去棧上取參數時就會發現棧上原本應該存參數 n 和 m 的地方並無數據,或者是其餘錯誤的數據,致使調用出錯。
因此你須要在調用前明確告訴編譯器這個函數的參數和返回值類型是什麼,編譯器才能生成對應的正確的彙編代碼,讓被調用的函數執行時能正常取到參數。
也就是說若是須要動態調用任意 C 函數,就得先準備好任意 參數類型/參數個數/返回值類型 排列組合的 C 函數指針,讓最終的彙編把全部狀況都準備好,最後調用時經過 switch 去找到正確的那個去執行就能夠了。但顯然這是很糟糕的主意。
在 C 語言這個層面上是解決不了這個問題的,要解決只能再往底層走,靠彙編。
(P.S. 在不一樣 CPU 架構上調用慣例不一樣,例如arm32位全部參數都經過棧傳遞,arm64位會讓部分參數經過寄存器傳遞,超出寄存器大小的參數才經過棧傳遞,由於64位機器多出了寄存器,經過寄存器傳遞比棧快。不過就算全部CPU架構調用慣例相同,也不影響咱們碰到的這個問題,你能夠忽略這點。)
實際上你會發現 OC 上有個函數脫離了上述限制,就是 objc_msgSend。OC 全部方法調用最終都會走到 objc_msgSend去調用,這個神奇的方法支持任意返回值任意參數類型和個數,而它的定義僅是這樣:
void objc_msgSend(void /* id self, SEL op, ... */ )
爲何它就能夠支持全部函數調用呢,不是說調用者和函數自己要遵循調用慣例嗎,這個函數跟咱們上述的②有什麼區別?
答案是在C語言層面上沒區別,但人家在彙編上作了手腳,objc_msgSend是用匯編寫的,在調用這個函數以前,會把棧/寄存器等數據都準備好,至關於調用前對參數入棧等處理由這個函數本身寫的彙編代碼接管了,不須要編譯器在調用處去生成這些指令。
這裏會在調用真正的函數以前,根據 Calling Convention 準備好棧幀/寄存器數據和狀態,最後再 jump/call 到函數實體執行就能夠了,這時函數實體按約定去取參數是取獲得的,能夠正常執行。因而 objc 就作到了在編譯前只須要定義一個簡單的 objc_msgSend,就支持運行時動態調用任意類型的 C 函數(全部 OC 方法的 IMP)。
因此咱們要仿照 objc_msgSend作一遍這個事情嗎?難度好高:(。不用怕, libffi 這個神器已經幫你作了。
對 libffi 的介紹能夠看 [這裏],簡單來講它就是提供了動態調用任意 C 函數的功能。
先來看看怎樣經過 libffi 動態調用一個 C 函數:
int testFunc(int m, int n) { printf("params: %d %d \n", n, m); return n+m; } int main() { //拿函數指針 void* functionPtr = dlsym(RTLD_DEFAULT, "testFunc"); int argCount = 2; //按ffi要求組裝好參數類型數組 ffi_type **ffiArgTypes = alloca(sizeof(ffi_type *) *argCount); ffiArgTypes[0] = &ffi_type_sint; ffiArgTypes[1] = &ffi_type_sint; //按ffi要求組裝好參數數據數組 void **ffiArgs = alloca(sizeof(void *) *argCount); void *ffiArgPtr = alloca(ffiArgTypes[0]->size); int *argPtr = ffiArgPtr; *argPtr = 1; ffiArgs[0] = ffiArgPtr; void *ffiArgPtr2 = alloca(ffiArgTypes[1]->size); int *argPtr2 = ffiArgPtr2; *argPtr2 = 2; ffiArgs[1] = ffiArgPtr2; //生成 ffi_cfi 對象,保存函數參數個數/類型等信息,至關於一個函數原型 ffi_cif cif; ffi_type *returnFfiType = &ffi_type_sint; ffi_status ffiPrepStatus = ffi_prep_cif_var(&cif, FFI_DEFAULT_ABI, (unsigned int)0, (unsigned int)argCount, returnFfiType, ffiArgTypes); if (ffiPrepStatus == FFI_OK) { //生成用於保存返回值的內存 void *returnPtr = NULL; if (returnFfiType->size) { returnPtr = alloca(returnFfiType->size); } //根據cif函數原型,函數指針,返回值內存指針,函數參數數據調用這個函數 ffi_call(&cif, functionPtr, returnPtr, ffiArgs); //拿到返回值 int returnValue = *(int *)returnPtr; printf("ret: %d \n", returnValue); } }
看起來挺複雜的,梳理一下就這幾步:
這裏每一步都是能夠在運行時動態去作的,也就能夠作到在運行時動態調用任意 C 函數了。
這裏最終 libffi 能調用任意 C 函數的原理按我理解跟上面說的 objc_msgSend的原理差很少,ffi_call底層是用匯編實現的,它在調用咱們傳入的函數以前,會根據上面提到的函數原型 cif 和參數數據,把參數都按規則塞到棧/寄存器裏,準備好數據和狀態,這樣調用的函數實體裏就能夠按規則取到這些參數,正常執行了。調用完再獲取返回值,清理這些棧幀/寄存器數據。libffi 針對每一個架構不一樣的 Calling Convention 寫了不一樣的彙編代碼去作這個事。能夠參見 libffi 裏的 sysv_arm64.Ssysv_arm.S等彙編源碼。[這篇文章] 有一些細節解析,能夠看看。
到這裏已經完成了動態調用 C 函數,接下來的工做就只是在 JS 和 libffi 之間加一層轉換,就可讓 JSPatch 支持動態調用 C 函數了,JPCFunction就是作這層轉換的。
目前 JPCFunction比較簡單,直接看代碼就能夠了,簡單說下流程:
這裏第二步的處理中對於 struct 類型會比較麻煩,目前還未支持參數/返回值類型爲 struct 的 C 函數,後續會補上。
回顧下動態調用 C 函數的探索過程,先是經過 dlsym()拿到函數指針,而後須要告訴編譯器這個函數的參數/返回值類型,編譯器纔會根據 Calling Convention 約定生成對應的彙編代碼,在調用函數時對參數進行正確的入棧/存入寄存器等操做,讓函數成功調用,這一步在運行時在 C 語言層面上沒法作到,因此 objc_msgSend()和 libffi 都用匯編模擬了這一過程,達到動態調用 C 函數的目的。
http://blog.cnbang.net/tech/3219/