Thunk 技術的一個改進 作者:南風 下載原始碼 摘要:介紹了 thunk 技術中如何避免直接寫機器碼。 關鍵字:Thunk 機器碼 this指標 Thunk技術,一般認為是在程式中直接構造出可執行代碼的技術(在正常情況下,這是編譯器的任務)。《深度探索C++物件模型》中對這個詞的來源有過考證(在中文版的162頁),說thunk是knuth的倒拼字。knuth就是大名鼎鼎的電腦經典名著《The Art of Computer Programming》的作者,該書被程式員們稱為“編程聖經”,與牛頓的“自然哲學的數學原理”等一起,被評為“世界曆史上最偉大的十種科學著作”之一(也不知是誰評的,我沒查到,不過反正這本書很牛就是了)。 一般情況下,使用thunk技術都是事先查好指令的機器碼,然後將數組或結構體賦值為這些機器碼的二進位值,最後再跳轉到數組或結構體的首地址。比如在參考文獻[1]中的代碼: void foo(int a){ printf ("In foo, a = %d\n", a); }unsigned char code[9];* ((DWORD *) &code[0]) = 0x042444FF; /* inc dword ptr [esp+4] */ code[4] = 0xe9; /* JMP */* ((DWORD *) &code[5]) = (DWORD) &foo - (DWORD) &code[0] - 9; /* 跳轉位移量 */void (*pf)(int/* a*/) = (void (*)(int)) &code[0];pf (6); 這是一段典型的thunk代碼,其執行結果是“In foo, a = 7”。 可以看到,它定義了一個數組code[9],然後將事先查好的各彙編指令的機器碼直接賦值給數組。然後定義一個函數指標等於數組的首地址,最後通過該函數指標調用thunk代碼。這裡使用了函數指標完成調用,好處是代碼比較清晰易讀。也可以使用彙編代碼jmp或call來完成,這樣就不必額外定義一個函數指標。 網路上的thunk代碼,基本上都是這個思路。如果你實際寫一段這樣的代碼,一定會發現很麻煩。對著教科書尋找每一個彙編指令的機器碼,相信不會是一件愉快的事情。其實我們回過頭來想想,這件事電腦來做不是最合適嗎,編譯器不就是做這個事情的嗎? 以上面的代碼為例,讓我們重新考慮一下整個過程。我們的目的是在調用函數foo之前將參數增加1。一般而言,這樣做肯定是沒有foo函數的原始碼或者不允許修改原始碼,否則直接改foo函數的代碼就好了,何必這麼麻煩。為了調用時候的簡單化,定義一個函數指標是比較合適的,否則每次調用都寫彙編代碼jmp 或call太麻煩。這樣一來,函數指標必須指向一個程式碼片段的地址。但是這個程式碼片段必須用機器碼來構造嗎,直接寫彙編代碼也同樣可以做到。 當然,這裡有一個問題。我們寫彙編指令的時候,必須是一條指令一條指令的寫,不能說指令寫一半,然後讓組譯工具去處理。上面的代碼中,第一條指令inc直接寫彙編語句當然沒問題。但下面的jmp語句,就不能直接寫。因為我們寫彙編語句的時候,jmp跳轉位移量是未知的,必須編譯後才知道。並且我們不能唯寫 jmp而不寫位移量,那是通不過編譯的。 這個問題可以這樣解決,寫jmp語句的時候,我們寫一個佔位的DWORD,其值設為一個特殊的值,比如0xffff(原理是這樣,實際處理還要迂迴一下,後面有說明)。只要在這段thunk代碼中不出現這個值就好。然後執行的時候,在第一次調用之前,在thunk代碼中尋找該值,將其替換為計算出來的動態值。經過這樣的處理,就可以徹底在thunk代碼中消除機器碼的直接操作。 更一般化,為了產生正確的機器碼,我們用兩個函數。一個用於產生機器碼的模板,另一個函數用於在機器碼的模板中填入需要動態計算產生的值。下面是一個例子: void ThunkTemplate(DWORD& addr1,DWORD& addr2)//產生機器碼{int flag = 0;DWORD x1,x2;if(flag){ //注意,這個括弧中的代碼無法直接執行,因為其中可能含有無意義的佔位數。__asm{thunk_begin: ;//這裡寫thunk代碼的彙編語句. ...thunk_end: ;}}__asm{mov x1,offset thunk_begin; //取 Thunk程式碼片段 的位址範圍.mov x2,offset thunk_end;} addr1 = x1; addr2 = x2;} 上面的函數用於產生thunk的機器碼模板,之所以稱為模板,是因為其中包含了無意義的佔位數,必須將這些佔位數替換為有意義的值之後,才可以執行這些代碼。因此,在函數中thunk代碼模板放在一個if(0)語句中,就是避免調用該函數的時候執行thunk代碼。另外,為了能方便的得到thunk代碼模板的地址,這裡採用一個函數傳出thunk代碼的首尾地址。 至於替換佔位數的功能是很簡單的,直接替換就好。 void ReplaceCodeBuf(BYTE *code,int len, DWORD old,DWORD x)//完成動態值的替換.{int i=0;for(i=0;i<len-4;++i){if(*((DWORD *)&code[i])==old){*((DWORD *)&code[i]) = x;return ;}}}這樣使用兩個函數: DWORD addr1,addr2;ThunkTemplate(addr1,addr2);memset(m_thunk,0,100);//m_thunk是一個數組: char m_thunk[100];memcpy(m_thunk,(void*)addr1,addr2-addr1);//將代碼拷貝到m_thunk中。ReplaceCodeBuf(m_thunk,addr2-addr1,-1,(DWORD)((void*)this));//將m_thunk中的-1替換為this指標的值。 原理部分到此為止。下面舉一個完整的,有實際意義的例子。在windows中,回呼函數的使用是很常見的。比如視窗過程,又比如定時器回呼函數。這些函數,你寫好代碼,但是卻從不直接調用。相反,你把函數地址傳遞給系統,當系統檢測到某些事件發生的時候,系統來調用這些函數。這樣當然很好,不過如果你想做一個封裝,將所有相關部分寫成一個類,那問題就來了。 問題是,這些回呼函數的形式事先已經定義好了,你無法讓一個類的成員函數成為一個回呼函數,因為類型不可能匹配。這不能怪微軟,微軟不可能將回呼函數定義為一個類成員函數(該定義為什麼類?),而只能將回呼函數定義為一個全域的函數。並且微軟其實很多時候也提供了補救措施,在回呼函數中增加了一個void *的參數。這個參數一般都用來傳遞類的this指標。這樣一來,可以這樣解決:給系統提供一個全域函數作為回呼函數,在該函數中通過額外的那個void *參數訪問到類的對象,從而直接調用到類成員函數。如此,你的封裝一樣可以完成,不過多了一次函數調用而已。 但是,不是所有的回呼函數都這麼幸運,微軟都給它們提供了一個額外的參數。比如,定時器的回呼函數就沒有。 VOID CALLBACK TimerProc( HWND hwnd, // handle to window UINT uMsg, // WM_TIMER message UINT_PTR idEvent, // timer identifier DWORD dwTime // current system time); 四個參數,個個都有用途。沒有地方可以讓你傳遞那個this指標。當然了,你實在要傳也可以做到,比如將hwnd設定為一個結構體的指標,其中包含原來的 hwnd和一個this指標。在定時器回呼函數中取出hwnd後強制轉化為結構體指標,取出原來的hwnd,取出this指標。現在就可以通過this指標自由的調用類成員函數了。不過這種方法不是我想要的,我要的是一個通用,統一的解決方案。通過在參數裡面加塞夾帶的方法,一般也是沒有問題的,不過如果碰到一個回呼函數沒有參數怎麼辦?另外,本來是封裝為一個類的,結果還是要帶著一個全域函數,你難道不覺得有些不爽嗎? 這正是thunk技術大顯身手的地方了。我們知道,所謂類成員函數,和對應的全域函數,其實就差一個this指標。如果我們在系統調用函數之前正確處理好this指標,那系統就可以正確的調用類成員函數。 具體的思路是這樣的:當系統需要一個回呼函數地址的時候,我們傳遞一個thunk程式碼片段的地址。這個程式碼片段做兩件事: 1、準備好this指標 2、調用成員函數 關鍵的代碼如下(完整的工程在附件中): void ThunkTemplate(DWORD& addr1,DWORD& addr2,int calltype=0){int flag = 0;DWORD x1,x2;if(flag){__asm //__thiscall{thiscall_1: mov ecx,-1; //-1預留位置,運行時將被替換為this指標. mov eax,-2; //-2預留位置,運行時將被替換為CTimer::CallBcak的地址. jmp eax;thiscall_2: ;}__asm //__stdcall{stdcall_1:push dword ptr [esp] ; //儲存(複製)返回地址到當前棧中mov dword ptr [esp+4], -1 ; //將this指標送入棧中,即原來的返回地址處mov eax, -2; jmp eax ; //跳轉至目標訊息處理函數(類成員函數)stdcall_2: ;}}if(calltype==0)//this_call{__asm{mov x1,offset thiscall_1; //取 Thunk程式碼片段 的位址範圍.mov x2,offset thiscall_2 ;}}else{__asm{mov x1,offset stdcall_1; mov x2,offset stdcall_2 ;}}addr1 = x1;addr2 = x2;}上面的函數有幾個地方需要說明: 1、為了能適應兩種不同的成員函數呼叫慣例,這裡寫了兩份代碼。通過參數calltype決定拷貝哪一份代碼到緩衝區。 2、本來一條jmp xxxx;指令這裡分解為兩條指令: mov eax,-2;jmp eax; 這是由組合語言的特點決定的。直接寫jmp -2是通不過的(根據地址的不同,jmp彙編後可能出現好幾種形式。這裡必須出現一個真實的地址以便彙編器決定jmp類型)。 3、如果對this指標的知識不清楚,請參考我在vc知識庫的另外一篇文章《直接調用類成員函數地址》。 設定thunk代碼的完整代碼如下: DWORD FuncAddr;GetMemberFuncAddr_VC6(FuncAddr,&CTimer::CallBcak);DWORD addr1,addr2;ThunkTemplate(addr1,addr2,0);memset(m_thunk,0,100);memcpy(m_thunk,(void*)addr1,addr2-addr1);ReplaceCodeBuf(m_thunk,addr2-addr1,-1,(DWORD)((void*)this)); //將-1替換為this指標. ReplaceCodeBuf(m_thunk,addr2-addr1,-2,FuncAddr); //將-2替換為成員函數的指標. 如果你還想和以前一樣直接在數組中賦值機器碼(畢竟這樣看起來很酷,我完全理解)。那也可以這樣,調用ThunkTemplate產生m_thunk後,列印出該數組的值,而後在程式中直接給m_thunk數組賦值,就象網上大部分thunk代碼那樣,當然在調用前要多一個步驟就是替換掉佔位數。不過無論如何,調用這兩個函數產生機器碼應該比手工尋找方便多了,如果你也這樣認為,那就算我這篇文章沒白寫。 參考文獻:基於 Thunk 實現的類成員訊息處理函數 |