透過彙編另眼看世界之DLL導出函數調用

前言:我一直對DLL技術充滿好奇,一方面是因爲我對DLL的導入/導出機制還不是特別的瞭解,另一面是因爲我發現:DLL技術在Windows平臺下佔有重要的地位,幾乎所有的Win32 API都是以導出函數的形式存放於不同的DLL文件中,在DLL方面的學習是任何一個想深入研究Windows內部機制的Windows程序員都不可能迴避的事實。我在查閱了大量的文章後,對DLL技術有了一定的瞭解,所以我寫了這篇文章來總結和整理我的思路,也爲以後深入的學習提供寶貴的資料。

對於如何製作DLL,網上有很多資料,我這裏就不多羅嗦了。現在假設我們已經成功生成了一個Win32 DLL,這個DLL的名字是DllInDepth.dll,它只導出了一個簡單的C++函數:

__declspec(dllexport) void foobar(int iValue) {
    printf(
"The value is %d.",iValue);
}

接下來我建立了一個Win32 Console Application。我想把這個應用程序(TestApp)作爲客戶程序,使用DLL導出的函數。首先我嘗試在代碼中直接使用這個函數:
int _tmain(int argc, _TCHAR* argv[]) {
    foobar(
5);
    
return 0;
}

編譯器不高興了,給出了這樣的錯誤提示:
error C2065: “foobar” : 未聲明的標識符

哦,編譯器不知道符號"foobar"是什麼東東,當然會報錯了。這個好辦,我就在TestApp程序中引用聲明瞭foobar函數的頭文件:
#include "DllInDepth.h"
int _tmain(int argc, _TCHAR* argv[]) {
    foobar(
5);
    
return 0;
}

這下編譯器是滿意了,但是連接器又不高興了,連接器報錯:
error LNK2019: 無法解析的外部符號 "__declspec(dllimport) void __cdecl foobar(int)" (__imp_?foobar@@YAXH@Z)

仔細想想,連接器不高興是有道理的。連接器的主要任務就是將處於不同編譯單元(Compilation Unit)的彙編代碼"組合/加工"成一個可執行文件,找不到目標文件,連接器拿什麼來"加工"呀。這個也好辦,在編譯DLL的時候,不是生成了一個導出庫麼?就讓連接器連接這個庫就可以了。這下問題都解決了,TestApp也成功生成了,懷着激動的心情迫不及待的運行TestApp,一個對話框和"嘣"的一聲給了我當頭一棒:無法找到需要的DLL

哦,應用程序在裝載所需要的DLL時,會按照一定的順序搜尋指定的目錄來尋找需要的DLL。如果搜尋過程結束了也沒有找到需要的DLL,應用程序就會停止運行。這個也好辦,將DLLDllInDepth.dll複製到TestApp所在的目錄不就可以了。

到了現在,TestApp總算能夠正確的運行起來了。從以上的"挫折"可以看出,一個DLL的正確運行,需要三個"職能部門"的通力合作:
1.編譯器。只有正確引用了相應的頭文件,編譯器才能正確編譯。
2.連接器。只有正確連接了相應的導出庫,連接器才能正確連接。
3.裝載器。只有正確設置了DLL的路徑,當需要它的時候它才能正確被裝載到內存中。

接下來我們將深入DLL的編譯和連接,瞭解更多實隱藏在編譯器和連接器背後的東西。這次我們使用的"利器"仍然是彙編代碼,希望能從彙編代碼中找到一些"蛛絲馬跡"。下面是TestApp中關鍵的代碼和相應的彙編代碼:
; foobar(5);
mov    esi, esp
push    
5
call    DWORD PTR __imp_
?foobar@@YAXH@Z
add    esp, 
4
cmp    esi, esp
call    __RTC_CheckEsp


除了看到奇怪的符號"__imp_?foobar@@YAXH@Z",我們並沒有看到太多"新鮮"的東西,不過這裏有兩點內容引起了我的思考:
1."__imp_?foobar@@YAXH@Z"應該是foobar函數的外部引用,如果將這個符號拆成兩個部分:"__imp_"和"?foobar@@YAXH@Z",對於"?foobar@@YAXH@Z"我們並不陌生,它是被C++編譯器修飾後的函數名引用。這個符號經過"反修飾"後的結果是:

void __cdecl foobar(int);

如果拿這個和foobar函數的聲明進行比較:
__declspec(dllimport) void foobar(int iValue);

這樣就不難想像,"__imp_"應該是編譯器看到"__declspec(dllimport)"後特殊處理的結果。

2.我們在前面提到,如果要將連接器正確的連接,我們需要給連接器提供導入庫。連接器連接導入庫或者靜態庫最主要的目的就是解析(resolution)外部引用。而導入庫和靜態庫先天上的差別,又決定了連接器在處理他們的過程中需要區別對待。靜態庫可以看作是集中存放許多目標文件(.obj)的倉庫,它裏面存放的都是"貨真價實"的經過編譯器”處理“後的彙編代碼。當連接器連接靜態庫的時候,連接器會將相應的代碼拷貝到應用程序的代碼段中,任何使用到這部分代碼的引用被具體的代碼所替換。而對於導入庫,它並沒有存放"貨真價實"的代碼,具體的代碼存放在DLL中,所以連接器並不能將相應的代碼拷貝到應用程序的代碼段中,而只能提供一種"間接使用"方式,這種"間接使用"的方式首先應該將連接器滿足,最重要的是要讓應用程序在運行時能夠獲得他們需要的代碼。分析到這裏,我越來越糊塗了,這後面到底隱藏了什麼祕密?

對於這一部分的內容,我並沒有完整的資料,強烈的好奇心驅使我在博客,雜誌,論壇,MSDN中尋找各種各樣的"蛛絲馬跡",當我試圖將這些"蛛絲馬跡"整理並將他們串聯起來的時候,一個相對模糊的輪廓浮現在我的腦海裏。我已略微能看到勝利的曙光了。

以上的代碼是經過編譯器處理後的彙編代碼,當連接器連接導入庫的時候,所以的外部連接將會被解析。那經過連接後的彙編代碼是什麼樣子的呢?這時候,我們就需要使用VS2002提供的"反彙編"功能。我將TestApp在調試狀態下運行起來,當進行到"foobar(5);"這一句的時候,我選擇"轉到反彙編"進行運行時的彙編分析。這時我又獲得了這樣的彙編代碼:
    foobar(5);
00411A1E  mov         esi,esp
00411A20  push        
5   
00411A22  call        dword ptr [__imp_foobar (42A1BCh)]
00411A28  add         esp,
4
00411A2B  cmp         esi,esp
00411A2D  call        @ILT
+920(__RTC_CheckEsp) (41139Dh) 

看到這個反彙編,再比較一下前面的編譯器生成的彙編代碼,我一下懵了。請注意這兩次函數調用:
call    DWORD PTR __imp_?foobar@@YAXH@Z

00411A22  call        dword ptr [__imp_foobar (42A1BCh)]

在前一此函數調用中,__imp_?foobar@@YAXH@Z扮演的是函數名的角色,表示的是函數的"絕對地址"。而在後一次中,__imp_foobar卻扮演着函數指針的角色:先找到__imp_foobar(它位於0x42A1BCH的位置),然後取它的值,作爲函數的地址,然後轉而調用那個函數。雖然這段代碼使用陷入了更深的迷糊,但是我也從中找到一些細小的線索。我們可以看到,__imp_foobar在內存中位於0x0042A1BCH的位置,而我們再觀察"call dword ptr [__imp_foobar (42A1BCh)]"這段代碼所處的位置,發現它位於0x00411A22H的位置,我們就會意識到他們具有相同的內存段,再聯想到模塊的"基地址",我們就可以大膽猜測,__imp_foobar應該是TestApp的PE文件格式中的導入表的一項,而它的值應該是被導入的函數的地址。喔!"柳暗花明又一村"呀。

這裏真正使我迷惑的是:連接器如果將函數的直接調用變成函數的間接調用? 我們知道連接器是不能修改編譯器生成的結果,連接器只能解析外部引用符號,難道是連接器在解析外部引用符號的時候做了些"手腳"? 幸運的是,我找到了一篇文章,其中正好涉及到這方面的內容:
  "The compiler would generate a normal call instruction, leaving the linker to resolve the external. The linker then sees that the external is really an imported function, and, uh-oh, the direct call needs to be converted to an indirect call. But the linker can't rewrite the code generated by the compiler. What's a linker to do?
  The solution is to insert another level of indirection. (Warning: The information below is not literally true, but it's "true enough". We'll dig into the finer details later in this series.)
  For each exported function in an import library, two external symbols are generated. The first is for the entry in the imported functions table, which takes the name __imp__FunctionName. Of course, the naive compiler doesn't know about this fancy __imp__ prefix. It merely generates the code for the instruction call FunctionName and expects the linker to produce a resolution.
  That's what the second symbol is for. The second symbol is the longed-for FunctionName, a one-line function that consists merely of a jmp [__imp__FunctionName] instruction. This tiny stub of a function satisfies the external reference and in turn generates an external reference to __imp__FunctionName, which is resolved by the same import library to an entry in the imported function table.
  When the module is loaded, then, the import is resolved to a function pointer and stored in __imp__FunctionName, and when the compiler-generated code calls the FunctionName function, it calls the stub which trampolines (via the indirect call) to the real function entry point in the destination DLL.
  Note that with a naive compiler, if your code tries to take the address of an imported function, it gets the address of the FunctionName stub, since a naive compiler simply asks for the address of the FunctionName symbol, unaware that it's really coming from an import library."

這篇文章的作者是一名微軟的資深工程師,應該具有很高的可靠性。老實說,這篇文章我看的不是太明白,不過從字面上來看,具體的過程應該是這樣的:
1.當編譯器看到"foobar(5)"的時候,生成這樣的代碼:
    call ?foobar@@YAXH@Z

2.在連接階段,外部引用?foobar@@YAXH@Z被解析成一個簡單的存根函數:
    jmp [__imp_?foobar@@YAXH@Z]

3.在DLL被裝載的時候,DLL導出函數的地址將被填充到應用程序的導入表(Import Table)中。

當我用這個說法和本文的彙編代碼進行比對的時候,發現本文中編譯器看到"foobar(5)"的時候,卻生成這樣的代碼:
    call __imp_?foobar@@YAXH@Z

仔細想想,是不是"__declspec(dllimport)"在"搗鬼"?如果我把它從導出函數聲明處刪掉,會是什麼結果呢?當我這樣做了以後,生成的結果就完全和文章中的分析完全吻合。我在網上找到的一篇文章正好印證了我的猜想:
  "Let's examine what the call to an imported API looks like. There are two cases to consider: the efficient way and inefficient way. In the best case, a call to an imported API looks like this:

  If you're not familiar with x86 assembly language, this is a call through a function pointer. Whatever DWORD-sized value is at 0x405030 is where the CALL instruction will send control. In the previous example, address 0x405030 lies within the IAT.
The less efficient call to an imported API looks like this:

   In this situation, the CALL transfers control to a small stub. The stub is a JMP to the address whose value is at 0x405030. Again, remember that 0x405030 is an entry within the IAT. In a nutshell, the less efficient imported API call uses five bytes of additional code, and takes longer to execute because of the extra JMP.
You're probably wondering why the less efficient method would ever be used. There's a good explanation. Left to its own devices, the compiler can't distinguish between imported API calls and ordinary functions within the same module. As such, the compiler emits a CALL instruction of the form

where XXXXXXXX is an actual code address that will be filled in by the linker later. Note that this last CALL instruction isn't through a function pointer. Rather, it's an actual code address. To keep the cosmic karma in balance, the linker needs to have a chunk of code to substitute for XXXXXXXX. The simplest way to do this is to make the call point to a JMP stub, like you just saw.
Where does the JMP stub come from? Surprisingly, it comes from the import library for the imported function. If you were to examine an import library, and examine the code associated with the imported API name, you'd see that it's a JMP stub like the one just shown. What this means is that by default, in the absence of any intervention, imported API calls will use the less efficient form.
Logically, the next question to ask is how to get the optimized form. The answer comes in the form of a hint you give to the compiler. The __declspec(dllimport) function modifier tells the compiler that the function resides in another DLL and that the compiler should generate this instruction

rather than this one:

In addition, the compiler emits information telling the linker to resolve the function pointer portion of the instruction to a symbol named __imp_functionname. For instance, if you were calling MyFunction, the symbol name would be __imp_MyFunction. Looking in an import library, you'll see that in addition to the regular symbol name, there's also a symbol with the __imp__ prefix on it. This __imp__ symbol resolves directly to the IAT entry, rather than to the JMP stub.
So what does this mean in your everyday life? If you're writing exported functions and providing a .H file for them, remember to use the __declspec(dllimport) modifier with the function:

   If you look at the Windows system header files, you'll find that they use __declspec(dllimport) for the Windows APIs. It's not easy to see this, but if you search for the DECLSPEC_IMPORT macro defined in WINNT.H, and which is used in files such as WinBase.H, you'll see how __declspec(dllimport) is prepended to the system API declarations."

這篇文章的內容正好解釋了我發現的事實和我的疑惑:
1。如果導出函數的聲明沒有用
__declspec(dllimport) 修飾的話,編譯器並不知道這個函數是由DLL導出的,所以編譯器就把這個函數當作普通的外部引用來對待,產生一個外部引用的符號等着連接器解析。當連接器工作的時候,就會將導入庫中的存根函數拷貝到應用程序的代碼段中,並將外部引用解析成那個存根函數。
2。如果導出函數的聲明用__declspec(dllimport) 修飾的話,編譯器就知道這個函數是DLL導出函數,就將這個函數調用直接編譯成對IAT中對應項的調用,而在連接階段,連接器對IAT中對應項的符號解析成一個函數,這種情況下就沒有使用存根函數的必要了。

到了現在,可以說所有的謎底已經"大白於天下"了,可是我突發其想,想看看__imp_foobar到底存的是不是函數地址,還是在調試狀態下,我打開觀察內存的窗口,在裏面輸入"0x0042A1BCH",發現位於此內存下的值是:”7b 17 01 10 00“,由於一個指針佔有4個字節,而且Window是小頭排列(Little Endian),所以正確的值應該是:0x1001177BH。難道這就是函數地址?從這個內存值來看,它應該不位於TestApp所在的內存段,它應該是DLL模塊中的某個部分。當我繼續查看這個內存是什麼內容的時候,我卻得到了這樣的結果:
@ILT+1910(?foobar@@YAXH@Z):
1001177B  jmp         foobar (10011F80h) 

這裏只是一個簡單的跳轉指令,最終的祕密應該在0x10011F80H被揭開。我繼續查看0x10011F80H的內容的時候,我很吃了一驚:
DLLINDEPTH_API void foobar(int iValue) {
10011F80  push        ebp 
10011F81  mov         ebp,esp
10011F83  sub         esp,0C0h
10011F89  push        ebx 
.....


看我發現了什麼!我終於找到了最終的函數調用的位置。直到現在,我纔看到了導出函數的"廬山真面目"。應用程序的IAT中填充的並不是DLL中導出函數的真實地址,而是在處於DLL中的又一個"存根函數"的地址,而這個"存根函數"僅僅是一個簡單的jmp指令跳轉到DLL導出函數的入口處。爲什麼要這樣處理卻不得而知了。

參考文獻:
1。強烈建議大家看看這位老兄的blog: http://blogs.msdn.com/oldnewthing/
這位老兄就是本文提到的那位微軟的資深工程師,他的blog幾乎每天都有更新,其中的內容包含很多Windows內部實現的內容,極具參考價值。其中他寫了一系列的關於DLL導入/導出內容的文章,給了我很大的啓發:
http://blogs.msdn.com/oldnewthing/archive/2006/07/27/680250.aspx

2。Matt Pietrek發表於MSDN專欄中的"Under The Hood"系列

也揭示了許多Windows內部的祕密,具有極高的參考價值:http://www.wheaty.net/Columns.htm
他的"Under The Hood"系列專欄中的一篇關於Linker的工作原理的文章給我很大的啓發:
http://www.microsoft.com/msj/0498/hood0498.aspx

3。MSDN Magazine中的兩篇好文章,內容涉及PE文件格式,作者依然是Matt Pietrek
http://msdn.microsoft.com/msdnmag/issues/02/02/PE/default.aspx
http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/

歷史:
11/19/2006 
v1.0
原文的第一個正式版

11/21/2006  v1.1
1。原文中對__declspec(dllimport)這一塊的理解還不是太清楚,本次修改添加了一篇文章中對這部分的解釋,使所有的疑惑找到了答案。
2。添加了
Matt Pietrek的兩篇關於PE文件格式的文章的引用。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章