鏈接器算法

在這個專欄中,我經常討論一些新技術,至少是還未被廣泛使用的技術。然而,隨着越來越多的開發者加入到Win32® 程序員隊伍中來,有些對於老手來說是老生常談的問題,對於新手來說卻是神祕莫測的。鏈接器方面的主題就屬於這個範疇。Visual Basic® 5.0就使用了一個鏈接器。事實上,它使用的鏈接器與Visual C++® 5.0的一樣。但是Visual Basic 5.0很好地隱藏了這個事實。如果你仔細觀察就會發現,它生成OBJ文件,然後把它們送往Microsoft 鏈接器。
什麼是鏈接器?它是如何工作的呢?本月我就討論一些這方面的內容。作爲這個專欄研究的一部分,我試圖去找一些以前的資料。有趣的是,看起來,這裏我要講的內容不是絕版了,就是不再包含於MSDN光盤中了,即使鏈接器技術幾乎影響到每一個Windows程序員。
爲這個專欄考慮,我把Microsoft 的LINK.EXE作爲標準的鏈接器。(其它的鏈接器,例如Borland的TLINK32,可能與我這裏描述的有少許不同。)在以後的專欄中,我會講一些關於Microsoft 鏈接器方面更深入、更有趣、更有用的內容。首先,我需要給鏈接器一個極其概括的定義,然後再細化。鏈接器的工作是把一個或多個目標模塊(典型地,就是OBJ文件)組合成一個可執行文件(也就是EXE或DLL)。但是這又引發一個問題:什麼是目標模塊呢?
目標模塊是由一個程序產生的,這個程序把人類可讀的文本轉換成CPU可以理解的機器代碼和數據。對於C++來說,C++編譯器讀取C++源文件。對於彙編語言來說,彙編程序(例如MASM)讀取彙編語言(ASM)文件,這種文件包含與CPU使用的代碼和數據等價的指令。在Visual Basic 5.0中,輸入文件就是工程中的FRM、BAS和CLS文件。這個概念對於諸如Fortran之類其它大部分語言都是正確的。
目標模塊中的主要部分是機器代碼和數據。組成代碼和數據的原始的字節被存儲在連續的塊中,這種塊叫做節(section)。例如,Microsoft編譯器把他們的機器代碼放進一個叫做.text的節中,把數據放進一個叫做.data的節中。這些名字除了提示節的用途之外並沒有什麼特別的意義。其它編譯器能夠(並且也是這麼做的)對他們的節使用不同的名字。如果你曾經爲MS-DOS® 或16位 Windows®編寫過程序,你把我前面講的內容中的“節”都換成“段(segment)”,那麼我講的大部分也還是正確的。如果你係統中安裝的有Visual C++,你可以使用DUMPBIN程序來看一看OBJ文件中的節。執行以下的命令行:
  DUMPBIN 目標文件名
目標文件名處是一個OBJ文件的名字。圖1給出了一個常見節的片段。你可以用DUMPBIN對編譯過的C++程序中的OBJ文件試一下,例如Visual C++\LIB目錄中的CHKSTK.OBJ文件:
Dump of file CHKSTK.OBJ
File Type: COFF OBJECT
Summary
0 .data
2F .text
圖 1 常見節

描述
.text
機器代碼指令。
.data
已初始化的數據。
.rdata
只讀數據。OLE的GUID就保存在這裏,還有其它內容。
.rsrc
資源。由資源編譯器生成,被放進RES文件中。鏈接器把它複製到可執行文件中。
.reloc
基址重定位信息,它由鏈接器產生。OBJ文件中並沒有。
.edata
導出函數表。由鏈接器創建,被放進EXP文件中。鏈接器把它複製到可執行中。
.idata
可執行文件中的導入函數表。
.idata$XXX
導入函數表的一部分。生成庫的程序在導入庫中創建這些節。鏈接器在生成可執行文件時把它們組合成最終的.idata節。
.CRT
可執行文件中的初始化表和關閉指針,它們供Visual C++運行時庫使用。
.CRT$XXX
在鏈接器把它們組合進可執行文件之前存在於OBJ文件中的初始化和關閉指針。
.bss
未初始化數據。
.drectve
OBJ文件中包含鏈接器指令的節。它們並不被複制到可執行文件中。
.debug$XXX
OBJ文件中的COFF符號表信息。

編譯器或彙編器的輸出有一個奇怪的名字叫做編譯單元。然而我們大部分人都認爲它們只不過是OBJ文件。鏈接器最重要的工作就是收集所有編譯單元並且從不同的編譯單元中組合所有的節。當然,如果事情真的這麼簡單,那麼鏈接器頂多不過是一個連接數據的奇特程序。事實上,鏈接器工作中的複雜部分是處理修正問題。後面將詳細敘述。
你可能想知道鏈接器是如何決定在最終的可執行文件中排列各個OBJ文件中的代碼和數據節的。很明顯,鏈接器有一整套詳細規則要遵守。事實上,鏈接器的任務太複雜,因此它不得不對輸入文件進行兩遍處理。鏈接器在第一遍時通篇查看要做的工作。在第二遍中,它應用所有規則產生可執行文件。
雖然對鏈接器規則的描述不能面面俱到,但我仍然會涉及它的主要部分。鏈接器的主要規則就是從各個OBJ文件中提取代碼和數據,並把它們放進最後的可執行文件中。如果你給鏈接器三個OBJ文件,這三個文件中的代碼與數據最後按某種方式被組合進可執行文件中。然而鏈接器並不是簡單地把各個文件中的所有原始節一個挨一個地放在一起。相反,鏈接器把所有名字相同的節組合(也就是連接)在一起。例如,如果三個OBJ文件中每個都有.text節,最後的可執行文件中只有一個.text節,鏈接器按它們出現的順序把它們組合在一起。
鏈接器遵守的另一個規則就是,可執行文件中節的順序是由鏈接器處理節時遇到它們的順序決定的。鏈接器嚴格按照命令行上指定的OBJ文件的順序進行處理。但是組合具有相同名字的節這條規則優先。
圖2顯示了三個OBJ文件,A.OBJ、B.OBJ和C.OBJ。每個文件都有三個節,其中.text節和.data節是三者共有的,但是在不同的文件中位置不同。它們都有一個與它們的源文件(也就是a.asm、b.asm和c.asm)有關的節。調用LINK,使用下面的內容作爲參數:
  A.OBJ B.OBJ C.OBJ
節的順序(以及名字相同的節是如何被組合的)如圖2所示。你可以從http://www.microsoft.com/msj下載源文件和OBJ文件。之所以提供OBJ文件主要是因爲,如果你沒有MASM或與其兼容的彙編程序,你可以使用OBJ文件,用“Link B.OBJ A.OBJ C.OBJ”這樣的命令行來測試一下。

圖 2 A.OBJ,B.OBJ,和C.OBJ
記住了這兩個規則,再理解鏈接器在MS-DOS和16位Windows上是如何工作的就容易了。但是,Win32鏈接器又在前面講的內容上加了幾條規則。首先就是包含“$”字符的節名規則。如果一個節名中包含“$”字符(例如.idata$4),“$”字符及其後續字符在可執行文件中都被移除了。然而在鏈接器修改這些名字之前,它是以“$”字符之前的字符作爲節名來進行組合的。“$”字符之後的字符用以對OBJ文件的節進行排序以產生最終的可執行文件。這些節是按“$”字符之後的字符的字母順序來存儲的。例如三個分別叫做foo$c、foo$a和foo$b的節在最終的可執行文件中將被組合成一個叫做foo的節。這個節中最前面的是foo$a中的數據,接着是foo$b中的數據,最後是foo$c中的數據。這種名字中包含“$”字符的節的自動組合有多種用途。後面我討論導入函數時,你會看到一個例子。它也被用於創建C++構造函數和析構函數靜態初始化時所需的數據表。
除了“$”字符這個組合規則外,Win32鏈接器還有一些其它規則。擁有代碼屬性的節有特別的優先權,它們被放在可執行文件的最前面,緊接着代碼的是由在編譯時未指定初始值的全局數據(例如在C++中int i;語句定義的全局變量)組成的未初始化數據節,接下來是已初始化的數據(包含在.data節中),以及鏈接器產生的數據節(例如.reloc節)。
未初始化的數據通常被編譯器放在一個叫做.bss的節中。現在很少能在可執行文件中看到.bss節。Microsoft鏈接器把.bss節合併到了.data節中,而.data節是被編譯器使用的主要的已初始化的數據節。但是請注意,這是針對希望運行於非Posix子系統,並且子系統版本大於3.5的可執行文件來說的。其它未初始化數據的節由鏈接器單獨處理(也就是說,它們並未被合併)。
現在倒着來看可執行文件。如果在OBJ文件中有.debug節,那麼它被放在文件的最後。如果沒有,鏈接器就把.reloc節放在最後,因爲在大多數情況下,Win32加載器不需要讀取重定位信息。減少需要讀取的可執行文件的內容可以減少加載時間。關於重定位的內容將在後面討論。
Win32下另外一個不符合兩個基本規則的是可移除節。這些節存在於OBJ文件中,但是鏈接器並不把它們複製到可執行文件中。這些節通常有LINK_REMOVE和LINK_INFO屬性(見WINNT.H文件),並且被命名爲.drectve。Microsoft編譯器之所以產生它們是爲了向鏈接器傳遞信息。如果你看一下由Visual C++ 編譯後產生的OBJ文件,你會看到在.drectve節中的數據就像下面這個樣子:
  -defaultlib:LIBC -defaultlib:OLDNAMES
如果你懷疑這些數據是傳遞到鏈接器的命令行參數,那麼你是正確的。當你使用C++的__declspec(dllexport)修飾符時,會看到更多這方面的證據。例如:
  void __declspec(dllexport) ExportMe( void ){...}
將導致.drectve節包含:
  -export:_ExportMe
如果你看一下LINK的命令行參數列表,絕對能看到-export也在其中。
修正和重定位
爲什麼編譯器不直接由源文件生成可執行文件,從而省略鏈接器呢?主要原因是,大部分程序並不是僅包含一個源文件。編譯器專注於由單個的源文件產生等價的機器代碼。由於一個源文件可能引用其它源文件中代碼或數據,而編譯器不能精確地產生調用那個函數或訪問那個變量的正確代碼。編譯器惟一的選擇就是在它產生的文件中包含描述外部代碼或數據的額外信息。這個對外部代碼或數據的描述就是修正(Fixup)。說得更明白一點就是,編譯器產生的訪問外部函數或變量的代碼是不正確的,必須在後面修正。
設想一下在C++中調用一個名爲Foo的函數:
//...
Foo();
//...
由32位C++編譯器產生的精確代碼應該是:
E8 00 00 00 00
0xE8是CALL指令的機器碼。接下來的DWORD應該是Foo函數的偏移(相對於CALL指令)。很明顯,Foo函數相對於CALL指令的偏移不是0字節。如果你執行這段代碼,它並不會按你原本期望的方式運行。產生的這段代碼有錯誤,它需要被修正。
在上面的例子中,鏈接器需要把CALL指令的機器碼後面的DWORD替換成Foo函數的正確地址。在可執行文件中,鏈接器將用Foo函數的相對地址改寫這個DWORD。鏈接器怎麼知道它需要被修正呢?是修正記錄(Fixup Record)告訴它的。鏈接器是怎麼知道Foo函數的地址的?鏈接器知道可執行文件中的所有符號,因爲正是它負責排列和組合可執行文件中的各個部分的。
現在來看一下修正記錄。對於基於Intel的OBJ文件來說,通常遇到的修正記錄有三種。第一種是32位相對修正,也就是REL32修正。(它對應於WINNT.H中的IMAGE_REL_I386_REL32這個宏定義。)在上面的例子中,對Foo函數的調用應該有一個REL32類型的修正記錄,並且這個記錄中應該包含一個DWORD類型的偏移,鏈接器需要用合適的值覆蓋這個偏移處的內容。如果你對由上面的代碼產生的OBJ文件運行
  DUMPBIN /RELOCATIONS
你會看到類似下面的內容:
Offset    Type         Applied To         Symbol Index     Symbol Name        -------- ----------- --------------    -------------    ------------        00000004  REL32              00000000                 7  _Foo
  這個修正記錄表示鏈接器需要計算函數Foo的相對偏移,並把它寫到這個節內的偏移0x00000004處。這個修正記錄僅在鏈接器創建可執行文件之前需要,之後它就被丟棄了,並不會出現在可執行文件中。既然這樣,那爲什麼大部分可執行文件中還一個.reloc節呢?這正是第二種類型的修正記錄發揮作用的地方。設想以下程序:
int i;
int main()
{
  i = 0x12345678;
}
Visual C++將爲上述賦值語句生成以下指令:
MOV DWORD PTR [00406280],12345678

真正有趣的是指令中的[00406280]這一部分。它引用的是內存中的一個固定位置,並且假定包含變量i的那個DWROD在可執行文件的默認加載地址0x400000之上的0x6280字節處。現在,想象一下,如果可執行文件不能被加載在默認加載地址,那會怎麼樣呢?假設Win32加載器把它加載到默認加載地址2M之上的地方(也就是,被加載在地址0x600000處)。如果是這樣,指令中的[00406280]這一部分應該被調整爲[00606280]。
  這正是DIR32(Direct32)大顯身手的時候。它們能夠表示哪裏需要相對於實際地址(直接地址)做一些修改。這也意味着可執行文件的加載地址很重要。當創建可執行文件時,加載器利用OBJ文件中的DIR32類型的修正記錄來創建.reloc節。在OBJ文件上運行
  DUMPBIN /RELOCATIONS
會出現類似下面的內容:
Offset    Type         Applied To         Symbol Index     Symbol Name        -------- ----------- --------------    -------------    ------------        00000005  DIR32              00000000                 4  _i
這個修正記錄表示鏈接器需要計算變量i的32位絕對地址,並且把它寫到節內偏移0x00000005處。
可執行文件中的.reloc節基本上就是可執行文件中的一系列地址,這些地址是默認地址與實際加載地址不同,需要被修正的地方。默認情況下,Win32加載器並不需要.reloc節。然而當Win32加載器需要加載可執行文件到一個不同於其首選地址的地址時,.reloc節允許那些使用代碼與數據的絕對地址的指令獲得更正。
第三種類型在Intel平臺上的OBJ文件中比較常見,它就是DIR32NB(Direct32,No Base),供調試信息使用。鏈接器的次要工作之一就是創建調試信息,這種信息中包含函數和變量名稱以及它們的地址。由於只有鏈接器知道函數和變量到哪裏結束,所以DIR32NB修正記錄被用來指示調試信息中需要函數或變量地址的地方。DIR32和DIR32NB的關鍵區別在於DIR32NB中的修正值不包含可執行文件的默認加載地址。
在一些情況下,把兩個或多個OBJ文件組合成單個文件,然後送往鏈接器更有價值。這方面的經典例子就是C++運行時庫(RTL)。C++ RTL是由許多源文件被編譯之後,產生的所有OBJ文件被組合成的一個庫。對Visual C++ 來說,標準的單線程靜態運行時庫是LIBC.LIB。RTL庫還有其它版本,諸如調試版(例如LIBCD.LIB)和多線程版(LIBCMT.LIB)。
庫文件通常以.LIB爲擴展名。它們由庫文件頭和包含在OBJ文件中的原始數據組成。庫文件頭用於告訴鏈接器哪個符號(函數和變量)可以在它裏面的OBJ文件中找到,同時也指明這個符號存在於哪個OBJ中。你可以通過使用DUMPBIN /LINKERMEMBER來觀察庫文件的內容。不用知道其中緣由,你會發現如果你指定選項:1或:2,DUMPBIN的輸出會更具可讀性。例如用Visual C++ 5.0的PENTER.LIB文件,使用以下命令行
  DUMPBIN /LINKERMEMBER:1 PENTER.LIB
它的部分輸出結果如下:
6 public symbols
180 _DumpCAP@0
180 _StartCAP@0
180 _StopCAP@0
180 _VERSION
180 __mcount
180 __penter
每個符號前面的180表示那個符號(例如_DumpCAP@0)可以在從庫文件開頭算起的0x180字節處的OBJ文件中找到。如你所見,PENTER.LIB裏面只有一個OBJ文件。更復雜的LIB文件有多個OBJ文件,因此符號前面的偏移會有所不同。
不像在命令行上傳遞OBJ文件那樣,鏈接器並非必須把一個庫中的所有OBJ文件都鏈接到最終的可執行文件中。事實上,正好相反,鏈接器不包含庫中OBJ文件中的任何代碼或數據,除非從那個OBJ文件中至少引用了一個符號。換句話說,鏈接器命令行中明確指定的OBJ文件總是被鏈接到最終的可執行文件中,而LIB文件中的OBJ文件只有在被引用時才被鏈接進去。
庫中的符號可以以三種方式被引用。首先,直接訪問明確指定的OBJ文件中的符號。例如,如果在我的一個源文件中調用C++的printf函數,在我的OBJ文件中將會產生一個引用(和修正)。當創建可執行文件時,鏈接器會搜索它的LIB文件以查找包含printf代碼的OBJ文件,並且鏈接相應的OBJ文件。
第二,可能存在一個間接引用。“間接”意味着通過第一種方法包含的OBJ引用了庫中另外一個OBJ文件中的符號。而這第二個OBJ文件可能又引用了庫中第三個OBJ文件中的符號。鏈接器的艱苦工作之一就是,即使符號通過49級間接引用,它也必須跟蹤並且包含引用的每一個OBJ文件。
當查找符號時,鏈接器按它的命令行上遇到的LIB文件的順序進行搜索。然而,一旦在某個庫中找到一個符號,那麼這個庫就變成了首選庫,首先在它裏面搜索所有其它符號。一旦某個符號在這個庫中找不到,這個庫就失去了它的首選地位。此時鏈接器搜索它的列表中的下一個庫。(要獲得更詳細的技術信息,請參考Microsoft知識庫文章Q31998,網址爲http://support.microsoft.com/kb/31998。)
現在,我們把目光轉向導入庫。在結構上,導入庫與普通庫並無區別。在解析符號時,鏈接器並不知道導入庫與普通庫的區別。它們的關鍵區別在於,導入庫中的OBJ文件並沒有相應的編譯單元(例如並沒有相應的源文件)。實際上,是鏈接器自己基於正在創建的可執行文件所導出的符號產生導入庫的。換句話說,鏈接器在創建可執行文件的導出表的同時也創建了相應的導入庫來引用這些符號。這引出了下一個主題——導入表。
創建導入表
Win32最基礎的特性之一就是能夠從其它可執行文件中導入函數。關於導入的DLL和函數的所有信息都存儲在可執行文件中的一個被稱爲導入表(Import Table)的表中。當它單獨成節時,這個節的名字叫.idata
導入對Win32可執行文件來說是至關重要的,因此,如果說鏈接器並不知道導入表方面的專業知識,那真是令人難以置信。但事實的確如此。換句話說,鏈接器並不知道、也不關心你調用的函數是在另外的DLL中,還是在這個文件中。鏈接器在這一點表現得特別聰明。它僅僅簡單地依照上面描述的節組合規則和符號解析規則就創建了導入表,看起來好像它並沒有意識到這個表的重要性。
讓我們看一下一些導入庫的片段,看一看鏈接器是怎樣出色地完成這個任務的。圖2是對USER32.LIB導入庫運行DUMPBIN時的部分輸出結果。假設你調用了ActivateKeyboardLayout這個API。在你的OBJ文件中可以找到一個_ActivateKeyboardLayout@8的修正記錄。從USER32.LIB的頭部,鏈接器知道這個函數可以在文件偏移0xEA14處的OBJ中找到。因此,鏈接器忠實地在最終的可執行文件中包含了這個OBJ文件中指定的內容(見圖3)。
圖 3 導入表
1121 public symbols
EA14 _ActivateKeyboardLayout@8
...
Archive member name at EA14: USER32.dll/
...
SECTION HEADER #2
.text name
RAW DATA #2
00000000 FF 25 00 00 00 00 .%....
...
SECTION HEADER #4
.idata$5 name
RAW DATA #4
00000000 00 00 00 00 ....
...
SECTION HEADER #5
.idata$4 name
RAW DATA #5
00000000 00 00 00 00 ....
...
SECTION HEADER #6
.idata$6 name
RAW DATA #6
00000000 00 00 41 63 74 69 76 61 | 74 65 4B 65 79 62 6F 61 ..Activa|teKeyboa
00000010 72 64 4C 61 79 6F 75 74 | 00 00 rdLayout|..
...
COFF SYMBOL TABLE
...
003 00000000 SECT2 notype () External | _ActivateKeyboardLayout@8
從圖3可以看出,牽涉到了OBJ文件中的許多節,包括.text.idata$5.idata$4.idata$6。在.text節中是一個JMP指令(機器碼0xFF 0x25)。從圖3最後的COFF符號表可以看出,_ActivateKeyboardLayout@8被解析到了.text節的這個JMP指令上。因此鏈接器把你對ActivateKeyboardLayout的調用轉換成了對導入庫的OBJ中的.text節的JMP指令的調用。
在可執行文件中,鏈接器把所有的.idata$XXX節組合成單個的.idata節。現在回憶一下鏈接器組合節名中帶有“$”字符的節時要遵守的規則。如果從USER32.LIB導入的還有其它函數,它們的.idata$4.idata$5.idata$6這些節也要放入其中。結果就形成了所有的.idata$4節組成了一個數組,所有的.idata$5節組成了另一個數組。如果你熟悉“導入地址表(Import Address Table,IAT)”的話,這實際上就是它的創建過程。
最後,注意節.idata$6的原始數據中包含了字符串“ActivateKeyboardLayout”。這就是導入地址表中有被導入函數的名字的原因。重要的一點是,對鏈接器來說,創建導入表並非難事。它只是依照我前面描述的規則,做它自己的工作而已。
創建導出表
除了爲可執行文件創建導入表外,鏈接器還負責創建導出表。這項工作難易參半。在第一遍中,鏈接器的任務是收集關於所有導出符號的信息並創建導出函數表。在此期間,鏈接器創建導出表,並且把它寫入到OBJ文件中一個叫.edata的節中。這個OBJ文件除了擴展名是.EXP而不是.OBJ外,其它地方都符合標準。你使用DUMPBIN檢查一下這些EXP文件的內容就知道了。
在第二遍中,鏈接器的工作就很輕鬆了。它只是把EXP文件當作普通的OBJ文件來對待。這也意味着OBJ文件中的節.edata應該被包含到可執行文件中。如果你在可執行文件看到.edata節,它就是導出表。但是近來很少能找到.edata節。看起來好像是如果可執行文件使用Win32控制檯或GUI子系統,鏈接器就自動合併.edata節和.rdata節,如果其中一個存在的話。
總結
  很明顯鏈接器做的工作要比我這裏描述的多得多。例如生成某種類型的調試信息(例如CodeView信息)是鏈接器全部工作中的重要部分。但是生成調試信息並不是必須的,因此我沒有花什麼時間來描述它。同樣,鏈接器應該能夠創建MAP文件,這種文件包含可執行文件中公共符號的列表,但是它同樣不是必須的功能。
雖然我提供了許多複雜的背景知識,但是鏈接器的核心是簡單地把多個編譯單元組合成可執行文件。第一個基本功能是組合節;第二個功能是解析節之間的相互引用(修正)。聯繫一下諸如導出表之類系統特定的數據結構方面的知識,你就基本上掌握了這個功能強大且重要的工具。

 

發佈了21 篇原創文章 · 獲贊 20 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章