1. 程式人生 > >節的原始資料 1(Sections' raw data)

節的原始資料 1(Sections' raw data)

7.輸入符號(imported symbols)
------------------------------

當編譯器發現一個對別的可執行檔案(大多數是DLL檔案)中的函式呼叫時,在最簡單化的情況下,它會對此情況一無所知,只是簡單地輸出一個對那個符號的正常呼叫指令。連結器不得不修正那個符號的地址,就象它為任何其它的外部符號所做的那樣。
連結器使用一個輸入庫來查詢從哪個DLL檔案輸入了哪個符號,併為所有的輸入符號都建立存根,每個存根包含一個跳轉指令;存根就是實際的呼叫目標。這些跳轉指令實際上將跳往從所謂的輸入地址表中提取的一個地址。在更復雜的應用程式(使用“__declspec(dllimport)”時)中,編譯器會知道函式是輸入的,並直接輸出一個位於輸入地址表中的地址的呼叫,繞過跳轉。

不管怎樣,DLL檔案中的函式地址總是必要的,並將於應用程式載入時,由載入器從輸出DLL檔案的輸出目錄中提供。載入器知道哪個庫中的哪些符號需要被查詢以及哪些地址需要通過搜尋輸入目錄來修正。

我最好給你一個例子。有或無__declspec(dllimport)的呼叫如下所示:

     原始檔:
         int symbol(char *);
         __declspec(dllimport) int symbol2(char*);
         void foo(void)
         {
             int i=symbol("bar");
             int j=symbol2("baz");
         }

     彙編:
         ...
         call _symbol                  ; 沒有declspec(dllimport)的
         ...
         call [__imp__symbol2]         ; 含有declspec(dllimport)的
         ...

在第一種(沒有__declspec(dllimport))情況下,編譯器不知道“_symbol”位於一個DLL檔案中,因此連結器必須要提供“_symbol”函式。因為此函式不存在,它就為輸入符號提供一個存根函式,即一個間接跳轉。所有輸入存根的集合被稱為“轉移區”(有時也叫做“跳板”,因為你跳到那裡的目的是為了跳到別的地方)。

典型地,此轉移區位於程式碼節中(它不是輸入目錄的一部分)。每一個函式存根都是一個跳往DLL檔案中的實際函式的跳轉。轉移區的形式象這樣:

     _symbol:         jmp   [__imp__symbol]
     _other_symbol:   jmp   [__imp__other__symbol]
     ...

這意味著:如果你不指定“__declspec(dllimport)”來使用輸入符號,那麼連結器將會為它們產生一個由間接跳轉所組成的轉移區。如果你真指定了“__declspec(dllimport)”,那麼編譯器就會自己做間接(跳轉),轉移區也就不需要了。(這也意味著:如果你輸入的是變數或其它東西,你就必須指定“__declspec(dllimport)”,因為一個具有jmp指令的存根只合適於函式。)

不管怎樣,符號“x”的地址都被存在“__imp_x”的儲存單元。所有這樣的儲存單元一起形成所謂的“輸入地址表”,此表是由被用到的各DLL檔案中的輸入庫提供給連結器的。輸入地址表就是由下面這種形式的一組地址組成的:

    __imp__symbol:    0xdeadbeef
    __imp__symbol2:   0x40100
    __imp__symbol3:   0x300100
    ...

這個輸入地址表是輸入目錄的一部分,並且被IMAGE_DIRECTORY_ENTRY_IAT(輸入地址表目錄項)目錄指標所指向(儘管有些連結器不設定此目錄項,程式也能執行;很明顯地,這是因為載入器不使用IMAGE_DIRECTORY_ENTRY_IAT(輸入地址表目錄項)目錄也能解決輸入問題)。
這些地址並不被連結器所知;連結器只插入一些偽地址(函式名稱的RVA;參見後面的更多資訊),這些偽地址會在載入時被載入器用輸出DLL檔案中的輸出目錄來修正。輸入地址表,以及它是怎樣被載入器找到的,將會在本章的後面被詳細講述。

注意:這個介紹是針對C語言規範的;有些別的應用程式構建環境是不使用輸入庫的,儘管它們都需要建立一個輸入地址表,用來讓它們的程式訪問輸入物件和函式。C語言編譯器往往使用輸入庫,因為無論如何講,這都有利於它們----它們的連結器使用好庫。別的環境使用的是例如:一個列出需要的DLL檔名稱和函式名稱的描述檔案(比如“模組定義檔案”),或者一個原始檔中的宣告形式的列表等。

這就是程式的程式碼如何使用輸入函式的;現在我們再來看看輸入目錄是如何建立以便載入器使用的。

輸入目錄應該存在於是“已初始化資料”並且“可讀”的節中。
輸入目錄是一個多IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)的陣列,每個被使用的DLL檔案都有一個。(它們的)列表由一個全部用0填充的IMAGE_IMPORT_DESCRIPTOR(輸入地址表目錄項)結構作為結束。
一個IMAGE_IMPORT_DESCRIPTOR(輸入地址表目錄項)是一個擁有下列成員的結構體:

     OriginalFirstThunk(原始第一個換長)(漢譯的說明見註釋 )
         它是一個RVA(32位),指向一個以0結尾的、由IMAGE_THUNK_DATA(換長資料)的RVA構成的陣列,其每個IMAGE_THUNK_DATA(換長資料)元素都描述一個函式。此陣列永不改變。

     TimeDateStamp(時間日期戳)
         它是一個具有好幾個目的的32位的時間戳。讓我們先假設時間戳為0,一些高階的情況將在以後處理。

     ForwarderChain(中轉鏈)
         它是輸入函式列表中第一個中轉的、32位的索引。中轉也是高階的東東。對初學者先將所有位設為-1。
        
     Name(名稱)
         它是一個DLL檔案的名稱(0結尾的ASCII碼字串)的、32位的RVA。
        
     FirstThunk(第一換長)
         它也是一個RVA(32位),指向一個0結尾的、由IMAGE_THUNK_DATA(換長資料)的RVA構成的陣列,其每個IMAGE_THUNK_DATA(換長資料)元素都描述一個函式。此陣列是輸入地址表的一部分,並且可以改變。

因此,陣列中的每個IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)結構體都給出輸出DLL檔案的名稱,並且,除了中轉和時間日期戳,它還給出2個指向IMAGE_THUNK_DATA(換長資料)的陣列的RVA,都是32位。(每個陣列的最後一個成員都全部填充為0位元組,以標誌結尾。)
目前看來,每個IMAGE_THUNK_DATA(換長資料)都是一個RVA,指向一個描述輸入函式的IMAGE_IMPORT_BY_NAME(輸入名字)項。
現在,有趣的是兩個陣列並行執行,也就是說:它們指向同一組IMAGE_IMPORT_BY_NAME(輸入名字)。

沒有必要失望,我將再畫一圖。這裡是IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)的關鍵內容:

       原始第一個換長         第一個換長
             |                     |
             |                     |
             |                     |
             V                     V

             0-->     函式1      <--0
             1-->     函式2      <--1
             2-->     函式3      <--2
             3-->     foo        <--3
             4-->     mumpitz    <--4
             5-->     knuff      <--5
             6-->0             0<--6       /* 最後的RVA是0! */

圖當中的名字就是尚未討論的IMAGE_IMPORT_BY_NAME(輸入名字)。每一個都是一個16位的數字(一個提示)跟著一些數量未定的位元組,它們都是以0結尾的、輸入符號的ASCII碼名字。
提示就是指向輸出DLL檔名字表的索引(參見上面的輸出目錄)。那個索引中的名字將被一一嘗試,如果沒有相符的,再使用二進位制搜尋來尋找名字。
(有些連結器不願意查詢正確的提示,總是隻簡單的將其指定為1,或者其它的隨意數字。這並無大害,只是使解決名字的第一次嘗試總是失敗,並迫使每個名字都使用二進位制搜尋來進行。)

總結一下:如果你想從“knurr”DLL中查詢輸入函式“foo”的資訊,第一步你先找到資料目錄中的IMAGE_DIRECTORY_ENTRY_IMPORT(輸入目錄項)項,得到一個RVA,再在原始節資料中找到那個地址,現在你就得到一個IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)陣列了。通過檢視根據它們的“名稱”被指向的字串,得到和“knurr”DLL有關的這個陣列的成員(即一個輸入描述結構)。在你找到正確的IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)後,順著它的“OriginalFirstThunk”(原始第一個換長)得到被指向的IMAGE_THUNK_DATA(換長資料)陣列;再通過查詢RVA找到“foo”函式。

好了,為什麼我們有“兩”列指向IMAGE_IMPORT_BY_NAME(輸入名字)的指標呢?這是因為在執行時,應用程式不需要輸入函式的名字,只需要地址。在這裡輸入地址表又出現了。載入器將從相關的DLL檔案的輸出目錄中查詢每一個輸入符號,並用DLL檔案入口點的線性地址替換“FirstThunk”( 第一個換長)列表中的IMAGE_THUNK_DATA(換長資料)元素(到現在之前它還是指向IMAGE_IMPORT_BY_NAME(輸入名字)的)。
請記住帶有象“__imp__symbol”標籤的地址列表;被資料目錄IMAGE_DIRECTORY_ENTRY_IAT(輸入地址表目錄項)所指向的輸入地址表,就是被“FirstThunk”( 第一個換長)所指向的列表。[在從好幾個DLL檔案輸入的情況下,輸入地址表是包含所有DLL檔案的“FirstThunk”( 第一個換長)陣列。目錄項IMAGE_DIRECTORY_ENTRY_IAT(輸入地址表目錄項)可能會丟失,但輸入(函式)仍能工作良好。]
“OriginalFirstThunk”( 原始第一個換長)陣列保持不變,因此你總能通過“OriginalFirstThunk”( 原始第一個換長)列表查詢原始的輸入名字列表。

現在輸入已經被用正確的線性地址修正,如下所示:

        原始第一個換長         第一個換長
             |                     |
             |                     |
             |                     |
             V                     V

             0-->     函式1         0-->   輸出函式1
             1-->     函式2         1-->   輸出函式2
             2-->     函式3         2-->   輸出函式3
             3-->     foo           3-->   輸出函式foo
             4-->     mumpitz       4-->   輸出函式mumpitz
             5-->     knuff         5-->   輸出函式knuff
             6-->0             0<--6

這是簡單情況下的基本結構。現在我們將要學習輸入目錄中的需細講的東西。

第一,當陣列中IMAGE_THUNK_DATA元(換長資料)素的IMAGE_ORDINAL_FLAG(序數標誌)位(也是:MSB,參見注釋 )被置1時,表示列表中沒有符號的名字資訊,符號只以序數輸入。你可通過檢視IMAGE_THUNK_DATA(換長資料)中的低地址word來得到序數。
通過序數輸入是不鼓勵的,通過名字輸入會更安全,因為如果輸出DLL檔案不是預期的版本時輸出序數可能會改變。

第二,有所謂的“繫結輸入”。

請思考一下載入器的工作:當它想執行的一個二進位制檔案需要一個DLL中的函式時,載入器會載入該DLL,找到它的輸出目錄,查詢函式的RVA並計算函式的入口點。然後用這樣找到的地址修正“FirstThunk”( 第一個換長)列表。
假設程式設計師很聰明,給DLL檔案提供的唯一優先載入地址不會發生衝突,那麼我們就能認為函式的入口點將總是相同的。它們在連結時能被算出並被補進“FirstThunk”( 第一個換長)列表中,這就是“繫結輸入”所發生的一切。(“繫結”工具就是幹這個的,它是Win32 SDK的一部分。)            

當然,你得慎重:使用者的DLL可能是不同的版本,或者DLL必須重定位,這些都會使先前修正的“FirstThunk”( 第一個換長)列表不再有效;此時,載入器仍能查尋“OriginalFirstThunk”( 原始第一個換長)列表,找出輸入符號並重新補正“FirstThunk”( 第一個換長)列表。載入器知道這是必須的,當:1)輸出DLL檔案的版本不符,或2)輸出DLL檔案需要重定位時。

確定有沒有重定位表對載入器來說不是問題,但該怎樣找出版本的不同呢?這時IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)的“時間戳”就派上用場了。如果它是0,表明輸入列表沒有被繫結,載入器總是要修復入口點。否則的話,輸入被繫結,“時間戳”必須要和“檔案頭”中的輸出DLL檔案的“時間戳”相符;如果不符的話,載入器就認為該二進位制檔案被綁到一個“錯誤”的DLL檔案上並重新補正輸入列表。

這裡有另外一個有關輸入列表中的“中轉”的怪事。一個DLL檔案能輸出一個不定義在本DLL檔案中卻需從另一個DLL檔案中輸入的符號;這樣的符號據說就是被中轉的(參見上面的輸出目錄描述)。
現在,很明顯的,你不能通過檢視那個實際上並不包含入口點資訊的DLL檔案的時間戳來確定一個符號的入口點是否有效。因此,出於安全的原因,中轉符號的入口點必須總是被修正。在二進位制檔案的輸入列表中,中轉符號的輸入必須被找出,以便載入器能補正它們。

這一點可通過“ForwarderChain”(中轉鏈)來做到。它是一個指向換長列表中的索引值;被索引位置的輸入就是一箇中轉輸出,並且此位置的“FirstThunk”( 第一個換長)列表中的內容就是“下一個”中轉輸入的索引值,以此類推,直到索引值為-1,就表明已沒有其他的中轉了。如果根本就沒有中轉,那麼“ForwarderChain”(中轉鏈)的值本身就為-1。

這就是所謂的“老式”繫結。

至此,我們應該總結一下我們目前已掌握的情況 :-)

OK,我將認為你已找到了IMAGE_DIRECTORY_ENTRY_IMPORT(輸入目錄項)並且已根據它找到了它的輸入目錄,位於某個節中。現在你已處於IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)陣列的開頭了,此類陣列的最後一個將以全0位元組填充。
要讀懂一個IMAGE_IMPORT_DESCRIPTOR(輸入描述結構),你得先檢視它的“名字”項,根據它的RVA,你就能找到輸出DLL檔案的名字。下一步你得確定輸入是否是繫結的;如果輸入是繫結的,“時間戳”就會是非“0”的。如果它們是繫結的,現在就是你通過比較“時間戳”來檢查DLL檔案的版本是否相符的好機會了。
現在你根據“OriginalFirstThunk”( 原始第一個換長)的RVA來到了IMAGE_THUNK_DATA(換長資料)陣列;過完這些陣列(它是0結尾的),它的每個成員都將是一個IMAGE_IMPORT_BY_NAME(輸入名字)的RVA(除非它的高位被置1,此時你找不到名字只有序數)。根據那個RVA,並跳過2位元組(即‘提示’),現在你就得到一個以0結尾的字串,這就是輸入函式的名字。
在繫結輸入時要找到提供的入口點,先根據“FirstThunk”( 第一個換長)平行的來到“OriginalFirstThunk”( 原始第一個換長)陣列;陣列成員就是入口點的線性地址(暫時不考慮中轉的話題)。

還有一件我到現在都沒有提及的事情:明顯地有些連結器在構建輸入目錄時會產生bug(我就發現一個還在被一個Borland C連結器使用的bug)。這些連結器把IMAGE_IMPORT_DESCRIPTOR(輸入描述結構)中的“OriginalFirstThunk”( 原始第一個換長)設為0,並只建立“FirstThunk”( 第一個換長)。很明顯的,這樣的輸入目錄不能被繫結(否則重修輸入的必須資訊就會丟失----你根本找不到函式名字)。在這種情況下,你得根據“FirstThunk”( 第一個換長)陣列來取得輸入符號名字,你將永遠得不到預先補正的入口地址。我已發現一個TIS檔案(參考書目[6]),講述一個在某種程度上和此bug相容的輸入目錄,因此那個檔案可能就是該bug的起源。

TIS檔案規定:
     IMPORT FLAGS(輸入標誌)
     TIME/DATE STAMP(時間/日期戳)
     MAJOR VERSION - MINOR VERSION(主版本號 - 小版本號)
     NAME RVA(名字的RVA)
     IMPORT LOOKUP TABLE RVA(輸入查詢表的RVA)
     IMPORT ADDRESS TABLE RVA(輸入地址表的RVA)

而別處使用的對應結構是:
     OriginalFirstThunk( 原始第一個換長)
     TimeDateStamp(時間日期戳)
     ForwarderChain(中轉鏈)
     Name(名字)
     FirstThunk(第一個換長)

最後一個關於輸入目錄的需要細講的就是所謂的“新式”繫結(在參考書目[3]中講述),它也可以由“繫結”工具來處理。當使用這種方式時,“時間日期戳”的所有位被置為1,並且沒有中轉鏈;此時所有輸入符號的地址都將被補正,而不管它們是不是中轉的。儘管如此,你還是需要知道DLL的版本,並且你還是需要將序數符號從中轉符號中區分開來。為了達到這個目的,IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT(繫結輸入目錄項)目錄被建立了。就我所見,它將不被放在節中,而是被放在頭中,處於節頭之後第一節之前。(咳,這不是我的發明,我只是講述它而已!)
這個目錄告訴你,每一個已使用的DLL檔案的中轉輸出是從哪些別的DLL檔案中來的。
結構是IMAGE_BOUND_IMPORT_DESCRIPTOR(繫結輸入描述結構)形式的,包括(按這個順序):
一個32位數字,“時間戳”。
一個16位數字,“OffsetModuleName(模組名字偏移量)”,是從目錄開頭到以0結尾的DLL檔名的偏移量;
一個16位數字,“NumberOfModuleForwarderRefs(模組中轉參考的數字)”,給出這個DLL檔案為它的中轉使用的DLL檔案數。

緊隨這個結構之後你會發現“NumberOfModuleForwarderRefs(模組中轉參考的數字)”結構,告訴你這個DLL檔案的中轉所來自的DLL檔案的名稱和版本。這些結構就是“IMAGE_BOUND_FORWARDER_REF(繫結中轉參考)”結構的:
一個32位的數字“時間日期戳”(TimeDateStamp);
一個16位的數字“模組名稱偏移量”(OffsetModuleName),它就是從目錄開頭到中轉來自的那個DLL檔案的0結尾的名字處的偏移量;
一個16位的未使用單元。

跟在“IMAGE_BOUND_FORWARDER_REF(繫結中轉參考)”後的是下一個“IMAGE_BOUND_IMPORT_DESCRIPTOR(繫結輸入描述結構)”,以此類推;列表最終以一個全部為0位的IMAGE_BOUND_IMPORT_DESCRIPTOR(繫結輸入描述結構)結束。


我對由此(描述)造成的不便表示歉意,但這就是它看起來的樣子:-)


現在,如果你有一個新的繫結輸入目錄,你得載入所有的DLL檔案,並使用目錄指標IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT(繫結輸入目錄項)找到IMAGE_BOUND_IMPORT_DESCRIPTOR(繫結輸入描述結構),掃描整個IMAGE_BOUND_IMPORT_DESCRIPTOR(繫結輸入描述結構),並檢查被載入的DLL檔案的“時間日期戳”和這個目錄中提供的是否相符。如果不符,就將輸入目錄中“第一換長”(FirstThunk)中的錯誤全部修改過來。