幫 C/C++ 程式設計師徹底瞭解連結器

小胖妞妞發表於2015-12-18

本文旨在幫助 C/C++ 程式設計師們瞭解連結器到底完成了些什麼工作。多年來,我給許多同事解釋過這一原理,因此我覺得是時候把它寫下來了,這樣不僅可以供更多人學習,也省去我一遍遍講解。

[2009年3月更新,內容包括:增加了 Windows 系統中連結過程可能遇到的特殊問題,以及對某條定義規則的澄清。]

促使我寫下這篇文章的起因是某次我幫人解決了一個連結錯誤,具體是這樣的:

如果你認為這是“幾乎可以肯定是因為漏寫了 extern “C””,那你很可能已經掌握了本文的全部內容。

目錄

各部分的命名:看看 C 檔案中都包含了哪些內容

本章,我們將快速回憶一下 C 檔案中包含的幾大部分。如果你認為自己已經完全明白下文示例程式中的內容,那麼你可以跳過本章,直接閱讀下一章

我們首先要弄清的是宣告和定義的區別。定義(definition)是指建立某個名字與該名字的實現之間的關聯,這裡的“實現”可以是資料,也可以是程式碼:

  • 變數的定義,使得編譯器為這個變數分配一塊記憶體空間,並且還可能為這塊記憶體空間填上特定的值
  • 函式的定義,使得編譯器為這個函式產生一段程式碼

宣告(declaration)是告訴 C 編譯器,我們在程式的別處——很可能在別的 C 檔案中——以某個名字定義了某些內容(注意:有些時候,定義也被認為是宣告,即在定義的同時,也在此處進行了宣告)。

對於變數而言,定義可以分為兩種:

  • 全域性變數(global variables):其生命週期存在於整個程式中(即靜態範圍(static extent)),可以被不同的模組訪問
  • 區域性變數(local variables):生命週期只存在於函式的執行過程中(即區域性範圍(local extent)),只能在函式內部訪問

澄清一點,我們這裡所說的“可訪問(accessible)”,是指“可以使用該變數在定義時所起的名字”。

以下是幾個不太直觀的特殊情況:

  • 用 static 修飾的區域性變數實際上是全域性變數,因為雖然它們僅在某個函式中可見,但其生命週期存在於整個程式中
  • 同樣,用 static 修飾的全域性變數也被認為是全域性的,儘管它們只能由它們所在的檔案內的函式訪問

當我們談及 “static” 關鍵字時,值得一提的是,如果某個函式(function)用 static 修飾,則該函式可被呼叫的範圍就變窄了(尤其是在同一個檔案中)。

無論定義全域性變數還是區域性變數,我們可以分辨出一個變數是已初始化的還是未初始化的,分辨方法就是這個變數所佔據的記憶體空間是否預先填上了某個特殊值。

最後要提的一點是:我們可以將資料存於用 malloc 或 new 動態分配的記憶體中。這部分記憶體空間沒法通過變數名來訪問,因此我們使用指標(pointer)來代替——指標也是一種有名字的變數,它用來儲存無名動態記憶體空間的地址。這部分記憶體空間最終可以通過使用 free 和 delete 來回收,這也是為什麼將這部分空間稱為“動態區域”(dynamic extent)。

讓我們來總結一下吧:

程式碼 資料
全域性 區域性 動態
已初始化 未初始化 已初始化 未初始化
宣告 int fn(int x); extern int x; extern int x; N/A N/A N/A
定義 int fn(int x) { … } int x = 1; (作用域:檔案) int x; (作用域:檔案) int x = 1; (作用域:函式) int x; (作用域:函式) (int* p = malloc(sizeof(int));)

以下是一個示例程式,也許是一種更簡便的記憶方法:

C 編譯器都做了些什麼

C 編譯器的任務是把我們人類通常能夠讀懂的文字形式的 C 語言檔案轉化成計算機能明白的內容。我們將編譯器輸出的檔案稱為目標檔案(object file)。在UNIX平臺上,這些目標檔案的字尾名通常為.o,在Windows平臺上的字尾名為.obj。目標檔案本質上包含了以下兩項內容:

  • 程式碼:對應著 C 檔案中函式的定義(definitions
  • 資料:對應著 C 檔案中全域性變數的定義(definitions)(對於一個已初始化的全域性變數,它的初值也存於目標檔案中)。

以上兩項內容的例項都有相應的名字與之相關聯——即定義時,為變數或函式所起的名字。

目的碼(object code)是指將程式設計師寫成的 C 程式碼——所有的那些if, while, 甚至goto都包括在內——經過適當編碼生成對應的機器碼序列。所有的這些指令都用於處理某些資訊,而這些資訊都得有地方存放才行——這就是變數的作用。另外,我們可以在程式碼中引用另一段程式碼——說得具體些,就是去呼叫程式中其它的 C 函式。

無論一段程式碼在何處使用某個變數或者呼叫某個函式,編譯器都只允許使用已經宣告(declaration)過的變數和函式——這樣看來,宣告其實就是程式設計師對編譯器的承諾:向它確保這個變數或函式已經在程式中的別處定義過了。

連結器(linker)的作用則是兌現這一承諾,但反過來考慮,編譯器又如何在產生目標檔案的過程中兌現這些承諾呢?

大致說來,編譯器會留個空白(blank),這個“空白”(我們也稱之為“引用”(reference))擁有與之相關聯的一個名字,但該名字對應的值還尚未可知。

在熟悉了以上知識後,我們大致可以勾畫出上一節示例程式碼所對應目標檔案的樣子了:

物件檔案原理圖

剖析目標檔案

目前為止,我們僅僅只從巨集觀的角度進行討論,因此,接下來我們很有必要研究一下之前介紹的理論在實際中都是怎麼工作的。這裡我們需要用到一個很關鍵的工具,即命令:nm,這是一條UNIX平臺上使用的命令,它可以提供目標檔案的符號(symbols)資訊。在Windows平臺上,與其大致等價的是帶 /symbols 選項的 dumpbin 命令;當然,你也可以選擇安裝 Windows 版GNU binutils 工具包,其中包含了 nm.exe。

我們來看看執行nm命令後,上文的 C 程式碼所產生的目標檔案是什麼結構:

不同平臺的輸出內容可能會有些許不同(你可以用 man 命令來檢視幫助頁面,從中獲取某個特定版本更多的相關資訊),但它們都會提供這兩個關鍵資訊:每個符號的型別,以及該符號的大小(如果該符號是有效的)。符號的型別包括以下幾種(譯者注[1]):

  • U: 該型別表示未定義的引用(undefined reference),即我們前文所提及的“空白”(blanks)。對於示例中的目標檔案,共有兩個未定義型別:“fn_a” 和 “z_global”。(有些 nm 的版本還可能包括 section(譯註:即巨集彙編中的區,後文直接使用section而不另作中文翻譯)的名字,section的內容通常為 *UND* 或 UNDEF)
  • t/T: 該型別指明瞭程式碼定義的位置。t 和 T 用於區分該函式是定義在檔案內部(t)還是定義在檔案外部(T)——例如,用於表明某函式是否宣告為 static。同樣的,有些系統包括 section ,內容形如.text
  • d/D: 該型別表明當前變數是一個已初始化的變數,d 指明這是一個區域性變數,D 則表示全域性變數。如果存在 section ,則內容形如 .data
  • b/B: 對於非初始化的變數,我們用 b 來表示該變數是靜態(static)或是區域性的(local),否則,用 B 或 C 來表示。這時 section 的內容可能為.bss 或者 *COM*

我們也很可能會看到一些不屬於原始 C 檔案的符號,我們可以忽略它們,因為這一般是由編譯器“邪惡”的內部機制導致的,這是為了讓你的程式連結在一起而額外產生的內容。

連結器都做了些什麼(1)

我們在上文提到過,一個函式或變數的宣告,實際上就是在向 C 編譯器承諾:這個函式或變已在程式中的別處定義了,而連結器的工作就是兌現這一承諾。根據上文提供的目標檔案結構圖,現在,我們可以開始著手“填充圖中的空白”了。

為了更好地進行說明,我們給之前的 C 檔案添個“伴兒”:

物件檔案原理圖

有了這兩張圖,我們現在可以將這圖中所有的節點都互相連通了(如果不能連通,那麼連結器在連結過程中就會丟擲錯誤資訊)。一切各就各位,如下圖所示,連結器可以將空白都填補上了(在Unix系統中,連結器通常由 ld 呼叫)。
物件檔案原理圖

至於目標檔案,我們可以使用 nm 命令來檢查生成的可執行檔案:

這個表格包含了兩個目標檔案中的所有符號,顯然,之前所有“未定義的引用”都已消失。同時,所有符號都按型別重新排了序,還加入了一些額外的資訊以便於作業系統更好地對可執行程式實行統一處理。

輸出內容中還有相當多複雜的細節,看上去很混亂,但你只要把以下劃線開頭的內容都過濾掉,整個結構看上去就簡單多了。

重複的符號

上文提到,當連結器試圖為某個符號產生連線引用時卻找不到這個符號的定義,連結器將丟擲錯誤資訊。那麼,在連結階段,如果同一個符號定義了兩次又該如何處理呢?

在C++中這種情況很容易處理,因為語言本身定義了一種稱為一次定義法則(one definition rule)的約束,即連結階段,一個符號有且只能定義一次(參見 C++ 標準第3.2章節,這一章節還提及了後文中我們將講解的一些異常資訊)。

對於 C 語言而言,事情就稍稍複雜一些了。C語言明確說明了,對於任何的函式或者已經初始化的全域性變數,都有且只能有一次定義,但未初始化的全域性變數的定義可以看成是一種臨時性定義(a tentative definition)。C 語言允許(至少不禁止)同一個符號在不同的原始檔中進行臨時性定義。

然而,連結器還得對付除 C/C++ 以外的其它語言,對於那些語言來說,“一次定義法則”並非總是適用。例如,以 Fortran 語言的正態模式(normal model)為例,實際應用中,每個全域性變數在其被引用的任何檔案中都存在一個複本。此時,連結器需要從多個複本中選擇一個(如果大小不同,就選最大的那個),並將剩餘複本丟棄。(這種模式有時又稱為連結時的“通用模式(common model)”,前頭需要加上Fortran關鍵字: COMMON )

因此,UNIX 系統上的連結器不會為符號的重複定義——或者說不會為未初始化全域性變數的重複符號——丟擲任何資訊,這種情況相當正常(有時,我們將這種情況稱為連結時的“鬆引用/定義模式(relaxed ref/def mode)”模式)。如果你為此感到苦惱(你也完全有理由苦惱),那麼你可以檢視你所使用的編譯器和連結器的相關文件,裡面通常會提供一個 –work-properly 選項,用於“收緊”連結器的檢測規則。例如,GNU 工具包裡提供了 -fno-common 選項,可以讓編譯器強行將未初始化變數存放於 BSS 段,而不是存於 common 段。

作業系統做了些什麼

目前為止,連結器產生了可執行檔案,檔案中所有符號都與其合適的定義相關聯。接下來,我們要休息一會兒,插播一則小知識:當我們執行這個程式時,作業系統都做了些什麼?

程式的執行顯然需要執行機器程式碼,因此作業系統無疑需要把硬碟上的可執行檔案轉換成機器碼,並載入記憶體,這樣CPU才能從中讀取資訊。程式所佔用的這塊記憶體,我們稱之為程式碼段(code segment),或者文字段(text segment).

沒有資料,再好的程式碼也出不來——因此,所有全域性變數也得一併載入記憶體。不過已初始化變數和未初始化變數有些不同。初始化變數已經提前賦予了某個特定的初值,這些值同時儲存於目標檔案和可執行檔案中。當程式開始執行時,作業系統將這些值拷貝至記憶體中一塊名為資料段(data segment)的區域。

對未初始化變數,作業系統假設其初值均為0, 因此沒有必要對這些值進行拷貝,作業系統保留一部分全為0記憶體空間,我們稱其為 bss 段(bss segment)。

這就意味著可執行檔案可以節省這部分儲存空間:初始化變數的初始值必須儲存於檔案中,但對於未初始化變數我們只需要計算出它們佔用的空間大小即可。

作業系統如何將可執行檔案對映到記憶體

你可能已經注意到目前我們關於目標檔案和連結器的所有討論都只圍繞著全域性變數,完全沒有作何關於上文提及的區域性變數和動態分配記憶體的介紹。

事實上,這類資料的處理完全無需連結器介入,因為它們的生命週期只存在於程式執行之時——這與連結器進行連結操作還離了十萬八千里呢。不過,從文章完整性的角度來考慮,我們還是快速過一下這部分知識點吧:

  • 區域性變數被存於記憶體的“棧”區(stack),棧區的大小隨著不同函式的呼叫和返回而動態地增長或減小。
  • 動態分配的記憶體而處於另一塊空間,我們稱之為“堆”(heap),malloc 函式負責跟蹤這塊空間裡還有哪些部分是可用的。

我們將這部分記憶體空間也新增上,這樣,我們就得到了一張完整的程式執行時的記憶體空間示意圖。由於堆和棧在程式執行過程中都會動態地改變大小,通常的處理方式是讓棧從一個方向向另一個方向增長,而堆則從另一端增長。也就是說,當二者相遇之時就是程式記憶體耗盡之日了(到那時,記憶體空間就被佔用得滿滿當當啦!)。

作業系統如何將可執行檔案對映到記憶體

連結器都做了些什麼(2)

現在我們已經對連結器的基礎知識有了一定的瞭解,接下來我們將開始刨根糾底,挖出它更為複雜的細節——大體上,我們會按照連結器每個特性加入的時間順序來一一介紹。

影響連結器特性的最主要的一個現象是:如果有很多不同的程式都需要做一些相同的操作(例如將輸出列印到螢幕上,從硬碟讀取檔案等),那麼顯然,一種合理的做法是將這些功能編寫成通用的程式碼,供所有不同的程式使用。

在每個程式的連結階段去連結相同的目標檔案這種方法顯然完全可行,但是,想象這麼一種方法:把所有相關的目標檔案集合都統一存放在一個方便訪問的地方——這樣我們在使用的時候會覺得生活更加簡單美好了~我們將其稱為“庫”(library)。

(未談及的技術問題:本節不涉及連結器“重定位(relocation)”這一重要特性的介紹。不同的程式大小也不同,因此,當動態庫在不同程式中使用時,將被對映成不同的地址空間,也就是說庫中所有的函式和變數在不同的程式中有不同的地址。如果所有訪問該地址之處,都使用相對地址(如“向後偏移1020位元組”)而不是絕對地址(固定的某個地址值,如 0x102218BF),那這也不是個事兒,可現在我們要考慮的問題在於,現實並不總這麼盡如人意,當這種情況出現時,所有絕對地址都必須加上一個合適的偏移量——這就是重定位的概念。由於這一概念對C/C++程式設計師來說幾乎是完全透明的,並且連結中報的錯誤也幾乎不可能由重定位問題導致,因此下文將不會對此贅述。)

靜態庫

靜態庫(static library)是“庫”最典型的使用方式。前文中提到使用重用目標檔案的方法來共享程式碼,事實上,靜態庫本質上並不比這複雜多少。

在UNIX系統中,一般使用 ar 命令生成靜態庫,並以 .a 作為副檔名,”lib” 作為檔名字首,連結時,使用”-l”選項,其後跟著庫的名稱,用於告訴連結器連結時所需要的庫,這時無需加字首和副檔名(例如,對於名為”libfred.a”的靜態庫,傳遞給連結器引數為”-lfred”)。

(過去,為了生成靜態庫檔案,我們還需要使用另一個名為 ranlib 的工具,該工具的作用是在庫的起始處建立符號索引資訊。如今這一功能已經被整合到 ar 命令中了。)

在Windows平臺上,靜態庫的副檔名為 .LIB,可用 .LIB 工具生成,但由於“匯入庫”(它只包含了DLL中所需要的基本資訊列表,具體介紹可見下文 Windows DLLs也同樣使用 .LIB 作為副檔名,因此二者容易產生混淆。

連結器在將所有目標檔案集連結到一起的過程中,會為所有當前未解決的符號構建一張“未解決符號表”。當所有顯示指定的目標檔案都處理完畢時,連結器將到“庫”中去尋找“未解決符號表”中剩餘的符號。如果未解決的符號在庫裡其中一個目標檔案中定義,那麼這個檔案將加入連結過程,這跟使用者通過命令列顯示指定所需目標檔案的效果是一樣一樣的,然後連結器繼續工作。

我們需要注意從庫中匯入檔案的粒度問題:如果某個特定符號的定義是必須的,那麼包含該符號定義的整個目標檔案都要被匯入。這就意味著“未解決符號表”會出現長短往復的變化:在新匯入的目標檔案解決了某個未定義引用的同時,該目標檔案自身也包含著其他未定義的引用,這就要求連結器將其加入“符號表”中繼續解決。

另一個需要注意的重要細節是庫的處理順序。連結器按命令列從左到右的順序進行處理,只有前一個庫處理結束了,才會繼續處理下一個庫。換句話說,如果後一個庫中匯入的目標檔案依賴於前一個庫中的某個符號,那麼連結器將無法進行自動關聯。

下面這個例子應該可以幫助大家更好的理解本節內容。我們假設有下列幾個目標檔案,並且通過命令列向連結器傳入:a.o, b.o, -lx, -ly.

檔案 a.o b.o libx.a liby.a
目標檔案 a.o b.o x1.o x2.o x3.o y1.o y2.o y3.o
定義的變數 a1, a2, a3 b1, b2 x11, x12, x13 x21, x22, x23 x31, x32 y11, y12 y21, y22 y31, y32
未定義的引用 b2, x12 a3, y22 x23, y12 y11 y21 x31

當連結器開始連結過程時,可以解決 a.o 目標檔案中的未定義引用 b2,以及 b.o 中的 a3,但 x12 和 y22 仍然處於未定義狀態。此時,連結器在第一個庫 libx.a 中查詢這兩個符號,並發現只要將 x1.o 匯入,就可以解決 x12 這一未定義引用,但匯入 x1.o 同時也不得不引入新的未定義引用:x23 和 y12,因此,此時未定義引用的列表裡包含了三個符號:y22, x23, y12。

因為此時連結器還在處理 libx.a,所以就優先處理 x23 了,即從 libx.a 中匯入 x2.o,然而這又引入了新的未定義引用——如今列表變成了y22, y12, y11,這幾個引用都不在在 libx.a 中,因此連結器開始繼續處理下一個庫:liby.a。

接下來,同樣的處理過程也發生在 liby.a 中,連結器匯入 y1.o 和 y2.o:連結器在匯入 y1.o 後首先將 y21 加入未定義引用列表中,不過由於 y22 的存在,y2.o 無論如何都必須匯入,因此問題就此輕鬆搞定了。整個複雜的處理過程,目的在於解決所有未定義引用,但只需要將庫中部分目標檔案加入到最終的可執行檔案中,避免匯入庫中所有目標檔案。

需要注意的一點是,如果我們假設 b.o 中也使用了 y32 ,那麼情況就有些許不同了。這種情況下,對 libx.a 的連結處理不變,但處理 liby.a 時,y3.o 也將被匯入,這將帶來一個新問題:又加入了一個新的未定義引用 x31 ,連結失敗了——原因在於,連結器已經處理完了 libx.a, 但由於 x3.o 未匯入,連結器無法查詢到 x31 的定義。

(補充說明:這個例子展示了 libx.a 和 liby.a 這兩個庫之間出現迴圈依賴的問題,這是個典型的錯誤,尤其當它出現Windows系統上時)

共享庫

對於像 C 標準庫(libc)這類常用庫而言,如果用靜態庫來實現存在一個明顯的缺點,即所有可執行程式對同一段程式碼都有一份拷貝。如果每個可執行檔案中都存有一份如 printf, fopen 這類常用函式的拷貝,那將佔用相當大的一部分硬碟空間,這完全沒有必要。

另一個不那麼明顯的缺點則是,一旦程式完成靜態連結後,程式碼就永久保持不變了,如果萬一有人發現並修復了 printf 中的某個bug,那麼所有使用了printf的程式都不得不重新連結才能應用上這個修復。

為了避開所有這些問題,我們引入了共享庫(shared libraries),其副檔名在 Unix 系統中為 .so,在 Windows 系統中為 .dll,在Mac OS X系統中為 .dylib。對於這類庫而言,通常,連結器沒有必要將所有的符號都關聯起來,而是貼上一個“我欠你(IOU)”這樣的標籤,直到程式真正執行時才對貼有這樣標籤的內容進行處理。

這可以歸結為:當連結器發現某個符號的定義在共享庫中,那麼它不會把這個符號的定義加入到最終生成的可執行檔案中,而是將該符號與其對應的庫名稱記錄下來(儲存在可執行檔案中)。

當程式開始執行時,作業系統會及時地將剩餘的連結工作做完以保證程式的正常執行。在 main 函式開始之前,有一個小型的連結器(通常名為 ld.so,譯者注[2])將負責檢查貼過標籤的內容,並完成連結的最後一個步驟:匯入庫裡的程式碼,並將所有符號都關聯在一起。

也就是說,任何一個可執行檔案都不包含 printf 函式的程式碼拷貝,如果 printf 修復了某些 bug,釋出了新版本,那麼只需要將 libc.so 替換成新版本即可,程式下次執行時,自然會載入更新後的程式碼。

另外,共享庫與靜態庫還存在一個巨大的差異,即連結的粒度(the granularity of the link)。如果程式中只引用了共享庫裡的某個符號(比如,只使用了 libc.so 庫中的 printf),那麼整個共享庫都將對映到程式地址空間中,這與靜態庫的行為完全不同,靜態庫中只會匯入與該符號相關的那個目標檔案。

換句話說,共享庫在連結器連結結束後,可以自行解決同一個庫內不同物件(objects)間符號的相互引用的問題(ar 命令與此不同,對於一個庫它會產生多個目標檔案)。這裡我們可以再一次使用 nm 命令來弄清靜態庫和共享庫的區別:對於前文給出的目標檔案和庫的例子,對於同一個庫,nm 命令只能分別顯示每個目標檔案的符號清單,但如果將 liby.so 變成共享庫,我們只會看到一個未定義符號 x31。同樣,上一節提到的由靜態庫處理順序引起的問題,將不會共享庫中出現:即使 b.o (譯者注[3])中使用了 y32,也不會有任何問題,因為 y3.o 和 x3.o 都已全部匯入了。

順便推薦另一個超好用的命令: ldd,該命令是Unix平臺上用於顯示一個可執行程式(或一個共享庫)依賴的共享庫,同時還可以顯示這些被依賴的共享庫是否找得到——為了使程式正常執行,庫載入工具需要確保能夠找到所有庫以及所有的依賴項(一般情況下,庫載入工具會在 LD_LIBRARY_PATH 這個環境變數指定的目錄列表中去搜尋所需要的庫)。

共享庫之所以使用更大的連結粒度是因為現代作業系統已經相當聰明瞭,當你想用靜態庫的時候,他為了節省一些硬碟空間,就採用小粒度的連結方式,但對於共享庫來說,不同的程式執行時共用同一個程式碼段(但並不共同資料段和 bss 段,因為畢竟不同的程式使用不同的記憶體空間)。為了做到這一點,必須對整個庫的內容進行一次性對映,這樣才能保證庫內部的符號集中儲存在一片連續的空間裡——否則,如果某個程式匯入了 a.o 和 c.o, 另一個程式匯入的是 b.o 和 c.o,那麼就沒什麼共同點可以供作業系統利用了。

Windows DLLs

雖然 Unix 和 Windows 平臺的共享庫原理大體上一致,但有一些細節如果不注意的話,還是很容易犯錯的。

匯出符號

兩個平臺之間最大的區別在於 Windows 的共享庫不會自動匯出程式中的符號。在 Unix 上,每一個目標檔案中所有與共享庫關聯的符號,對使用者而言都是可見的,但在 Windows 上,為了使這些符號可見,程式設計師必須做一些額外的操作,例如,將其匯出。

從 Windows DLL 中匯出符號資訊的方法一共有三種(這三種方法可以同時用於同一個庫中)

對於以上三種方法而言,第一種方法最為簡便,因為編譯器會自行為你考慮命名改寫(name mangling)的問題。

.LIB 以及其它與庫相關的檔案

Windows 的這一特性(符號不可見)導致了 Windows 庫的第二重複雜性:連結器在將各符號連結到一起時所需要的匯出符號資訊,並不包含在 DLL 檔案中,而是包含在與之相對應的 .LIB 檔案中。

與某個 DLL 庫關聯的 .LIB 檔案列出了該 DLL 庫中(匯出的)符號以及符號地址。所有使用這個 DLL 庫的程式都必須同時訪問它的 .LIB 檔案才能保證所有符號正常連結。

有件經常把人弄糊塗的事:靜態庫的副檔名也是 .LIB!

事實上,與 Windows 庫有關的檔案型別簡直千姿百態,除了上檔案提及的 .LIB 檔案和(可選的).DEF 檔案外,以下列出了你可能遇到的所有與 Windows 庫有關的檔案。

      • 連結輸出檔案
        • library.DLL: 庫的實現程式碼,它可實時匯入每個使用該庫的可執行程式。
        • library.LIB: “匯入庫”檔案,給定了 DLL 檔案中的符號及地址列表。只有當 DLL 匯出某些符號時才會產生這個檔案,如果沒有符號匯出,.LIB 檔案也就沒有存在的必要了。所有使用該庫的程式在連結階段都必需用到該檔案。
        • library.EXP: 這是動態庫處在連結期時的一個“匯出檔案”,當連結中二進位制檔案出現迴圈依賴時,該檔案就派上用場了。
        • library.ILK: 如果連結時指定了 /INCREMENTAL 選項這就意味著開啟了增量連結功能,該檔案儲存著增量連結時的相關狀態,以供該動態庫下次增量連結時使用。
        • library.PDB: 如果連結時指定了 /DEBUG 選項,將生成程式資料庫,包含了整個庫的所有除錯資訊。
        • library.MAP: 如果連結時指定了 /MAP 選項,將生成描述整個庫內部佈局資訊的檔案。
      • 連結輸入檔案
        • library.LIB: “匯入庫”檔案,給定了連結時所需的 DLL 檔案中的符號及地址列表。
        • library.LIB: 這是一個靜態庫檔案,包含了連結時所需的系統目標檔案集。請注意:使用 .LIB 檔案時,需要區分是靜態庫還是“匯入庫”。
        • library.DEF: 這是一個“模組定義”檔案,該檔案對連結庫的各種細節都給予了控制權,其中包括符號匯出([譯者注4])。
        • library.EXP: 這是動態庫處於連結期時的一個“匯出檔案”,它提前執行一個與庫檔案對應的 LIB.EXE 工具([譯者注5]),並提前生成對應的 .LIB 檔案。當連結中的二進位制檔案出現迴圈依賴時,該檔案就派上用場了。
        • library.ILK: 增量連結狀態檔案,詳見上文。
        • library.RES: 資原始檔,包含了執行過程中所需的各種GUI部件資訊,這些資訊都將包含在最終的二進位制檔案中。

這與Unix正好相反,Unix中這些外部庫所需的大部分資訊一般情況下全都包含在庫檔案裡了。

匯入符號

正如上文所提,Windows 要求 DLL 顯示地宣告需要匯出的符號,同樣,使用動態庫檔案的程式必須顯示地宣告它們想匯入的符號。這是一個可選功能,但對於16位 Windows 裡的一些古老功能來說,這個選項可以實現執行速度的優化。

我們所要做的是在原始碼里加上這麼一句話:declare the symbol as __declspec(dllimport) ,看上去就像這樣:

這一方法看似稀鬆平常,但由於 C 語言裡所有函式以及全域性變數都在且僅在標頭檔案中宣告一次,這會讓我們陷入一個兩難的境地:DLL 中包含了函式和變數的定義的程式碼需要進行符號匯出,但 DLL 以外的程式碼需進行符號匯入。

一般採取的迴避方式是在標頭檔案中加上一個預處理巨集(preprocessor macro):

DLL 中的包含函式和變數定義的 C 檔案可以確保它在引用這個標頭檔案之前就已經定義(#defined)了預處理巨集EXPORTING_XYZ_DLL_SYMS,對於符號的匯出也是如此。任何引用了該檔案的其他程式碼,都無需定義這一符號也無需指示符號的匯入。

迴圈依賴

動態連結庫的終級難題在於 Windows 比 Unix 嚴厲,它要求每個符號在連結期都必須是“已解決符號”。在 Unix 中,連結一個包含連結器不認識的“未解決符號”的動態庫是可行的。在 Windows 中,任何使用引用了共享庫的程式碼都必須提供庫中的符號,否則程式將載入失敗,Windows 不允許任何形式的鬆懈。

在大部分系統中,這不算個事兒,可執行程式依賴於高階庫,高階庫依賴於低階庫,所有的一切都通過層層反向連結關聯到一起:從低階庫開始,再到高階庫,最終到依賴它們的可執行檔案。

然而,一旦兩個二進位制檔案存在著相互依賴關係,事情就變得詭異起來。如果 X.DLL 使用了 Y.DLL 中的符號,而 Y.DLL 又反過來需要 X.DLL 中的符號,於是就出現了“先有雞還有先有蛋”的問題:無論先連結哪個庫,都無法找到另一個庫的符號。

Windows提供了一種繞過這一問題的方法,大致過程如下:

      • 首先,生成一個庫 X 的假連結。執行 LIB.EXE(不是 LINK.EXE)來生成 X.LIB 檔案,這跟用 LIB.EXE 生成的一模一樣。這時不會生成 X.DLL 檔案,取而代之的是 X.EXP 檔案。
      • 以正常的方式進行庫 Y 的連結:使用上一步中生成的X.LIB,匯出 Y.DLL 和 Y.LIB。
      • 最後以合適的方式連結庫 X,這跟正常的連結方式幾乎沒什麼差別,唯一不同的是額外需要第一步生成的 X.EXP 檔案。之後採用正常的方式,匯入上一步生成的 Y.LIB,並生成 X.DLL。與正常方式不同之處在於,連結時將不再生成 X.LIB 檔案,因為第一步已經生成過了(這在 .EXP 檔案中有標記指示)

當然,更好的解決方法是去重構這些庫來消除這種迴圈依賴……。

將 C++ 加入示意圖

C++ 在 C 的基礎上提供了更多額外的功能,這些功能中有很大一部分需要與連結器的操作進行互動。這並不符合最初的設計——最初 C++ 實現的目的是作為 C 編譯器的前端,因此作為後端的連結器並不需要任何改變——但隨著 C++ 功能日趨複雜,連結器也不得不加入對這些功能的支援。

函式過載和命名改編

C++ 的第一個改變是允許函式過載,即程式中允許存在多個不同版本的同名函式,當然它們的型別不同(即函式簽名不同)。

這一做法顯然給連結器出了一個難題:當其它程式碼呼叫 max 函式時,它到底是想呼叫哪一個呢?

連結器採用一種稱為“命名改寫(name mangling)”的方法來解決這一問題,之所以使用“mangling”是因為這個詞有損壞、弄糟之意,與函式簽名相關的資訊都被“損壞”了,變成一種文字形式,成為連結器眼中符號的實際名稱。不同的函式簽名將被“損壞”成不同的名稱,這樣就解決了函式名重複的問題。

我不打算深入講解“命名改寫”的具體規則,因為不同編譯平臺有不同的改編規則,但我們通過檢視事例程式碼所對應的目標檔案結構,可以對“命名改寫”規則有一個直觀的認識(記詮住, nm 命令絕對是您不可或缺的好夥伴!):

從上圖中,我們可以看出,三個名為 max 的函式,在目標檔案中的名稱並不相同。聰明的你應該能夠猜得出來 max 的後兩個字母來自各自的引數型別:i表示int, f表示float,d表示double(如果把類、名稱空間、模板,以及操作符過載都加入命名改編,情況將更為複雜)。

需要注意的是,如果你希望能夠在連結器可識別的名稱(the mangled names)和使用者可識別的名稱(the demangled names)之間相互轉化,則需要另外單獨使用別的程式(如 c++filt)或者加入命令列選項(對於 GNU 的 nm 命令,可以加 –demangle 選項),這樣你就可以得到如下資訊:

命名改寫機制最常見的“坑”就是當 C 和 C++ 程式碼混在一起寫的時候,C++ 編譯器生成的符號名稱都經過了改編處理,而 C 編譯器生成的符號名稱就是它在原始檔中的名稱。為了避免這一問題,C++ 採用 extern “C” 來宣告和定義 C 語言函式,其目的在於告訴 C++ 編譯器這個函式名不能被改變,既可能因為相關的 C 程式碼需要呼叫 C++ 函式的定義,也可能因為相關的 C++ 程式碼需要呼叫 C 函式。

回到本文最初的例子,現在我們很容易能看出這很可能是因為某人將 C 和 C++ 連結到一起卻忘了加 extern “C” 宣告。

這條錯誤資訊中最明顯的提示點是那個函式簽名——它不僅僅是在抱怨你沒定義 findmax ,換句話說,C++ 程式碼實際上想找的是形如 “_Z7findmaxii” 的符號,可只找到 “findmax”,因此連結失敗了。

順便提一句,注意 extern “C” 的連結宣告對成員函式無效(見 C++ 標準文件的7.5.4章節)

靜態初始化

C++ 比C 多出的另一個大到足以影響連結器行為的功能是物件的建構函式(constructors)。建構函式是用於初始化物件內容的一段程式碼。就其本身而言,它在概念上等同於一個變數的初始值,但關鍵的區別在於,它初始化的不是一個變數,而是一整塊程式碼。

讓我們回想一下前文所學內容:一個全域性變數可以給定一個特殊的初值。在 C 語言中,為全域性變數設定一個初始是件輕而易舉的事:在程式即將執行之時,將可執行檔案中資料段所存的值拷貝至記憶體對應的地址即可。

在 C++ 中,構造過程所需完成的操作遠比“拷貝定值”複雜得多:在程式開始正常執行之前,類層次體系中各種建構函式裡的程式碼都必須提前執行。

為了處理好這一切,編譯器在每一個C++檔案的目標檔案中都儲存了一些額外資訊,例如,儲存了某個檔案所需的建構函式列表。在連結階段,連結器把所有列表合成一張大表,通過一次次掃描該表來呼叫每個全域性物件對應的建構函式。

請注意,所有這些全域性物件的建構函式的呼叫順序並未定義——因此,這完全取決於連結器的實現。(更多細節可以參看 Scott Meyers 的 Effective C++ 一書,第二版的條款47和href=”http://www.amazon.com/gp/product/0321334876″>第三版的條款4有相應的介紹)

我們同樣可以使用 nm 命令來檢視這些列表資訊。以下面這段 C++ 程式碼為例:

這段程式碼的 nm 輸出如下(已經進行了反命名改編處理):

這段輸出內容給了很多資訊,但我們感興趣的是 Class 列為 W 的那兩項(W 在這裡表示弱符號 [譯者注6]),它們的 Section 列形如”.gnu.linkonce.t.stuff”,這些都是全域性物件建構函式的特徵,我們可以從 “Name” 這一列看出些端倪——在不同情況下使用兩個建構函式中的一個。

模板

上文中,我們給了三個不同 max 函式的例子,在這個例子中,每個 max 函式帶有不同的引數,但函式體的程式碼實際上完全相同,作為程式設計師,我們得為這種“複製貼上”完全相同的程式碼感到可恥。

於是 C++ 引入了模板(templates)這一概念來避免這種情況——只需一份程式碼來完全所有工作。我們先建立一個只含有一個 max 函式程式碼的標頭檔案 max_template.h :

然後將該標頭檔案應用到 C++ 程式碼中,並使用這個模板函式:

這個例子中的C++檔案呼叫了兩種型別的 max(int,int) 和 max(double,double),而對於另一個 C++ 檔案,可能會呼叫該模板的其他例項化函式:比如max(float,float),甚至還有可能是更復雜的 max(MyFloatingPointClass,MyFloatingPointClass)。

模板的每一個例項化函式執行時使用的都是不同的機器碼,因此在程式的連結階段,編譯器和連結器需要確保程式呼叫的每個模板例項函式都擴充套件出相應型別的程式程式碼(但對於未被呼叫的其他模板例項函式而言,不會有任何多餘的程式碼生成,這樣可以避免程式程式碼過度膨脹)。

那麼編譯器和連結器是如何做到這一切換呢?一般來說,有兩種實現方案:一種是將每個例項函式程式碼展開,另一種是將例項化操作延遲到連結階段(我喜歡將這兩種方法分別稱作“普通方法”(the sane way)和 “Sun方法”(the sane way)(譯註:之所以取這個名字,是因為Solaris系統下的編譯器採用這樣的方法,而Solaris是當年Sun公司旗下最著名的作業系統。))。

對於第一種方法,即將每個例項函式程式碼展開,每個目標檔案中都會包含它所呼叫的所有模板函式的程式碼,以上文的 C++ 檔案為例,目標檔案內容如下:

我們可以從中看出目標檔案中即包含了 max(int,int) 也包含了 max(double,double)。

目標函式將這兩個函式的定義標記成“弱符號”(weak symbos),這表示當連結器最終生成可執行程式時,將只留下所有重複定義的其中之一,剩餘的定義都將棄之不用(如果設計者願意,那麼可以將連結器設計成檢查所有的重複定義,它們含有幾乎完全相同的程式碼)。這種方法最顯著的缺點是每個目標檔案都將佔用更多的磁碟空間。

另一種方法通常是 Solaris 系統中的 C++ 編譯器所使用的方法,它不會在目標檔案中包含任何跟模板相關的程式碼,只將這些符號標記成“未定義”。等到了連結階段,連結器將所有模板例項化函式對應的未定義符號收集在一起,然後為它們生成相應的機器碼。

這種方法可以節省每個目標檔案所佔的空間大小,但其缺點在於連結器必須跟蹤標頭檔案所包含的原始碼,還必須在連結階段呼叫C++編譯器,這會減慢連結速度。

動態載入庫

接下來我們將討論本文最後一個 C++ 特性:共享庫的動態載入。前文介紹瞭如何使用共享庫,這意味著最終的連結操作可以延遲到程式真正執行的時刻。在現代作業系統中,甚至還可以再往後延遲。

這需要通過一對系統呼叫來實現,分別是:dlopen 和 dlsym (Windows裡大致對應的呼叫分別是LoadLibrary 和 GetProcAddress)。前者獲取共享庫的名稱,並將其載入執行程式的地址空間。當然,載入的這個共享庫本身也可能存在未定義符號,因此,呼叫 dlopen 很可能同時觸發多個其他共享庫的載入。

dlopen 為使用者提供了兩種選擇,一種是一次性解決匯入庫的所有未定義符號(RTLD_NOW),另一種是按遇到的順序一個個解決未定義符號(RTLD_LAZY)。第一種方法意味著呼叫一次dlopen需要等待相當長的時間,而第二種方法則可能需要冒一定的風險,即在程式執行過程中,突然發現某個未定義符號無法解決,將導致程式崩潰終止。

如果你想從動態庫中找出符號對應的名字顯然不可能。但正如以往的程式設計問題一樣,這很容易通過新增額外的間接定址方式解決,即使用指標而不是用引用來指向該符號。dlsym 呼叫時,需要傳入一個 string 型別的引數,表示要查詢的符號的名稱,返回該符號所在地址的指標(如果沒找到就返回 NULL)。

動態載入與C++特性的互動

這種動態載入的功能讓人覺得眼前一亮,但它是如何與影響連結器行為的各種 C++ 特性進行互動的呢?

首當其衝的棘手問題是修改(mangled)後的變數名。當呼叫 dlsym 時,它接收一個包含符號名的字串,這裡的符號名必須是連結器可識別的名字,換句話說,即修改後的變數名。

由於命名改編機制隨著平臺和編譯器的變化而變化,這意味著你想進行跨平臺動態定位 C++ 符號幾乎完全不可能。即使你樂意花大把的時間在某個特定的編譯器上,並鑽研其內部機制,仍然還有更多的問題在前方等著你——這些問題超出了普通類 C 函式的範圍,你還必須要把虛表(vtables)這種型別的問題納入到你考慮的範疇。

總而言之,一般來說最好的辦法是隻使用唯一一個常用的入口點 extern “C”,它可以已經呼叫過dlsym了。這個入口點可以是一個工廠函式,返回一個指向 C++ 物件的指標,它允許訪問所有的 C++ 精華。

在一個已經呼叫過 dlopen 的庫中,編譯器可以為全域性目標選出建構函式,因為庫中可以定義各種特殊符號,這樣連結器無論在載入還是執行時,只要庫需要動態地載入或者取消,都可以呼叫這些符號,因此所有需要用到的建構函式和解構函式都可以放到裡面。在 Unix 系統中,將這兩種函式稱為 _init 和 _fini,而對於使用 GNU 工具鏈的各種現代作業系統中,則是所有標記為__attribute__((constructor)) 和 __attribute__((destructor)) 的函式。在 Windows 中,相應的函式是帶有 reason 或者 DLL_PROCESS_ATTACH,再或者 DLL_PROCESS_DETACH 引數的 DllMain 函式。

最後,動態載入可以很好地例用 “摺疊重複”(fold duplicated)的方法來進行模板例項化,但對於“連結時編譯模板” (compile templates at link time)這一方法則要棘手得多——因為在這種情況下,“連結期”(link time)發生在程式執行之後(而且很可能不是在當初寫原始碼的機器上執行)。你需要檢視編譯器和連結器的手冊來避免這一問題。

參考資料

本文有意跳過了許多連結器內部實現機制的細節,因為我認為針對程式設計師們日常工作時所遇到與連結器有關的問題,本文所介紹的內容已經覆蓋了其中的95%。

如果你想進行更多的深入瞭解,可以參考下列文章:

非常感謝Mike Capp和Ed Wilson為本文提出的寶貴建議。


譯者注:

[1]

  • .bss: BSS全稱為Block Started by Symbol(或者block storage segment)。在採用段式記憶體管理的架構中,BSS 段(bss segment)通常是指用來存放程式中未初始化的全域性變數的一塊記憶體區域。BSS段屬於靜態記憶體分配。
  • .data: 表示資料段(data segment),通常用來存放程式中已初始化的全域性變數的一塊記憶體區,也屬於靜態記憶體分配
  • .text: 表示程式碼段(text segment),通常用來存放程式執行程式碼的一塊記憶體區,這部分割槽域的大小在程式執行前就已經確定,並且記憶體區屬於只讀,程式碼段中也可能包含少量的只讀常數變數,例如字串常量等。
  • COM: 全稱common段。在《程式設計師的自我修養》一書中,指出,如果全域性變數初始化的值不為0,則儲存在data段,值為0,則儲存在bss段,如果沒有初始化,則儲存在common段。當變數為static,且未初始化時放在bss段,否則放在com段
  • 以上內容參考自: 《.bss .data .text 區別》和 《通過未初始化全域性變數,研究BSS段和COMMON段的不同 》

[2] ld.so 是 Unit 系統上的動態連結器,常見的變體有兩個:ld.so 針對 a.out 格式的二進位制可執行檔案,ld-linux.so 針對 ELF 格式的二進位制可執行檔案。當應用程式需要使用動態連結庫裡的函式時,由 ld.so 負責載入。搜尋動態連結庫的順序依此是:環境變數LD——LD_BRARY_PATH(a.out格式),LD_LIBRARY_PATH(ELF格式);在Linux中,LD_PRELOAD 指定的目錄具有最高優先權。 快取檔案 /etc/ld.so.cache。此為上述環境變數指定目錄的二進位制索引檔案。更新快取的命令是 ldconfig。 預設目錄,先在 /lib 中尋找,再到 /usr/lib 中尋找。(以上來自wiki百科)

[3] b.o: 這裡原文是b.c,想來是作者的筆誤

[4] def檔案(module definition file模組定義檔案)是用來建立dll和對應的匯出庫的。來自:http://www.fx114.net/qa-71-109424.aspx
def模組定義檔案,用來建立dll和對應的lib def檔案中,可以指定dll將會匯出哪些符號給使用者使用,連結器會根據def檔案的說明來生成dll和lib。 在def檔案中使用exports語句,可以讓dll內部符號可見(預設不可見)

[5] exp:匯出檔案。當生成了兩個dll:a.dll, b.dll,二者需要互相呼叫對方中的函式(迴圈依賴),這裡存在的問題是:生成a.dll時需要b.lib,生成b.dll需要a.lib,這就變成死鎖了,微軟的解決辦塵埃 是使用exp檔案,在兩個dll生成之前,使用lib.exe(library manager tool庫管理工具)來建立一個DLL對應的.lib和.exp 即先生成a.lib, a.exp,然後利用a.lib去生成b.dll和b.lib,這時再用b.lib來生成a.dll。a.exp檔案中快取了a.dll的匯出資訊,linker載入a.exp中的資訊。

[6] 對於C語言來說,編譯器預設函式和初始化了的全域性變數為強符號,未初始化的全域性變數為弱符號(C++並沒有將未初始化的全域性符號視為弱符號)。我們也可以通過GCC的”__attribute((weak))”來定義任何一個強符號為弱符號。注意,強符號和弱符號都是針對定義來說的,不是針對符號的引用。 來自:http://blog.csdn.net/astrotycoon/article/details/8008629

[7] Mach不是Mac,Mac是蘋果電腦Macintosh的簡稱,而Mach則是一種作業系統核心。Mach核心被NeXT公司的NeXTSTEP作業系統使用。在Mach上,一種可執行的檔案格是就是Mach-O(Mach Object file format)。1996年,賈伯斯將NeXTSTEP帶回蘋果,成為了OS X的核心基礎。所以雖然Mac OS X是Unix的“後代”,但所主要支援的可執行檔案格式是Mach-O。來自:http://www.molotang.com/articles/1935.html 和 http://www.amazon.com/gp/product/0321334876

相關文章