幫 C/C++ 程序員徹底瞭解鏈接器
本文旨在幫助 C/C++ 程序員們瞭解鏈接器到底完成了些什麼工作。多年來,我給許多同事解釋過這一原理,因此我覺得是時候把它寫下來了,這樣不僅可以供更多人學習,也省去我一遍遍講解。
[2009年3月更新,內容包括:增加了 Windows 系統中鏈接過程可能遇到的特殊問題,以及對某條定義規則的澄清。]
促使我寫下這篇文章的起因是某次我幫人解決了一個鏈接錯誤,具體是這樣的:
1
2
3
4
|
g++ -o test1 test1a.o test1b.o
test1a.o(.text+0x18): In function `main':
: undefined reference to `findmax(int, int)'
collect2: ld returned 1 exit status
|
如果你認爲這是“幾乎可以肯定是因爲漏寫了 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));) |
以下是一個示例程序,也許是一種更簡便的記憶方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
/* 這是一個未初始化的全局變量的定義 */
int x_global_uninit;
/* 這是一個初始化的全局變量的定義 */
int x_global_init = 1;
/* 這是一個未初始化的全局變量的定義,儘管該變量只能在當前 C文件中訪問 */
static int y_global_uninit;
/* 這是一個初始化的全局變量的定義,儘管該變量只能在當前 C文件中訪問 */
static int y_global_init = 2;
/* 這是一個存在於程序別處的某個全局變量的聲明 */
extern int z_global;
/* 這是一個存在於程序別處的某個函數的聲明(如果你願意,你可以在語句前加上 "extern"關鍵字,但沒有這個必要) */
int fn_a( int x, int y);
/* 這是一個函數的定義,但由於這個函數前加了 static限定,因此它只能在當前 C文件內使用 */
static int fn_b(int x)
{
return x +1;
}
/* 這是一個函數的定義,函數參數可以認爲是局部變量 */
int fn_c( int x_local)
{
/* 這是一個未初始化的局部變量的定義 */
int y_local_uninit ;
/* 這是一個初始化的局部變量的定義 */
int y_local_init = 3 ;
/* 以下代碼通過局部變量、全局變量和函數的名字來使用它們 */
x_global_uninit = fn_a (x_local, x_global_init);
y_local_uninit = fn_a (x_local, y_local_init);
y_local_uninit += fn_b (z_global);
return (x_global_uninit + y_local_uninit);
}
|
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 代碼所產生的目標文件是什麼結構:
1
2
3
4
5
6
7
8
9
10
11
12
|
c_parts.o 中的符號如下:
Name Value Class Type Size Line Section
fn_a | | U | NOTYPE| | |*UND*
z_global | | U | NOTYPE| | |*UND*
fn_b |00000000| t | FUNC|00000009| |.text
x_global_init |00000000| D | OBJECT|00000004| |.data
y_global_uninit |00000000| b | OBJECT|00000004| |.bss
x_global_uninit |00000004| C | OBJECT|00000004| |*COM*
y_global_init |00000004| d | OBJECT|00000004| |.data
fn_c |00000009| T | FUNC|00000055| |.text
|
不同平臺的輸出內容可能會有些許不同(你可以用 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 文件添個“伴兒”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
/* 初始化的全局變量 */
int z_global = 11;
/* 另一個命名爲y_global_init的全局變量 , 但它們都是static的 */
static int y_global_init = 2;
/* 聲明另一個全局變量 */
extern int x_global_init;
int fn_a(int x, int y)
{
return(x+y);
}
int main(int argc, char *argv[])
{
const char *message = "Hello, world";
return fn_a(11,12);
}
|
有了這兩張圖,我們現在可以將這圖中所有的節點都互相連通了(如果不能連通,那麼鏈接器在鏈接過程中就會拋出錯誤信息)。一切各就各位,如下圖所示,鏈接器可以將空白都填補上了(在Unix系統中,鏈接器通常由 ld 調用)。
至於目標文件,我們可以使用 nm 命令來檢查生成的可執行文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
samples1.exe中的符號列表:
Name Value Class Type Size Line Section
_Jv_RegisterClasses | | w | NOTYPE| | |*UND*
__gmon_start__ | | w | NOTYPE| | |*UND*
__libc_start_main@@GLIBC_2.0| | U | FUNC|000001ad| |*UND*
_init |08048254| T | FUNC| | |.init
_start |080482c0| T | FUNC| | |.text
__do_global_dtors_aux|080482f0| t | FUNC| | |.text
frame_dummy |08048320| t | FUNC| | |.text
fn_b |08048348| t | FUNC|00000009| |.text
fn_c |08048351| T | FUNC|00000055| |.text
fn_a |080483a8| T | FUNC|0000000b| |.text
main |080483b3| T | FUNC|0000002c| |.text
__libc_csu_fini |080483e0| T | FUNC|00000005| |.text
__libc_csu_init |080483f0| T | FUNC|00000055| |.text
__do_global_ctors_aux|08048450| t | FUNC| | |.text
_fini |08048478| T | FUNC| | |.fini
_fp_hw |08048494| R | OBJECT|00000004| |.rodata
_IO_stdin_used |08048498| R | OBJECT|00000004| |.rodata
__FRAME_END__ |080484ac| r | OBJECT| | |.eh_frame
__CTOR_LIST__ |080494b0| d | OBJECT| | |.ctors
__init_array_end |080494b0| d | NOTYPE| | |.ctors
__init_array_start |080494b0| d | NOTYPE| | |.ctors
__CTOR_END__ |080494b4| d | OBJECT| | |.ctors
__DTOR_LIST__ |080494b8| d | OBJECT| | |.dtors
__DTOR_END__ |080494bc| d | OBJECT| | |.dtors
__JCR_END__ |080494c0| d | OBJECT| | |.jcr
__JCR_LIST__ |080494c0| d | OBJECT| | |.jcr
_DYNAMIC |080494c4| d | OBJECT| | |.dynamic
_GLOBAL_OFFSET_TABLE_|08049598| d | OBJECT| | |.got.plt
__data_start |080495ac| D | NOTYPE| | |.data
data_start |080495ac| W | NOTYPE| | |.data
__dso_handle |080495b0| D | OBJECT| | |.data
p.5826 |080495b4| d | OBJECT| | |.data
x_global_init |080495b8| D | OBJECT|00000004| |.data
y_global_init |080495bc| d | OBJECT|00000004| |.data
z_global |080495c0| D | OBJECT|00000004| |.data
y_global_init |080495c4| d | OBJECT|00000004| |.data
__bss_start |080495c8| A | NOTYPE| | |*ABS*
_edata |080495c8| A | NOTYPE| | |*ABS*
completed.5828 |080495c8| b | OBJECT|00000001| |.bss
y_global_uninit |080495cc| b | OBJECT|00000004| |.bss
x_global_uninit |080495d0| B | OBJECT|00000004| |.bss
_end |080495d4| A | NOTYPE| | |*ABS*
|
這個表格包含了兩個目標文件中的所有符號,顯然,之前所有“未定義的引用”都已消失。同時,所有符號都按類型重新排了序,還加入了一些額外的信息以便於操作系統更好地對可執行程序實行統一處理。
輸出內容中還有相當多複雜的細節,看上去很混亂,但你只要把以下劃線開頭的內容都過濾掉,整個結構看上去就簡單多了。
重複的符號
上文提到,當鏈接器試圖爲某個符號產生連接引用時卻找不到這個符號的定義,鏈接器將拋出錯誤信息。那麼,在鏈接階段,如果同一個符號定義了兩次又該如何處理呢?
在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 這個環境變量指定的目錄列表中去搜尋所需要的庫)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/usr/bin:ldd xeyes
linux-gate.so.1 => (0xb7efa000)
libXext.so.6 => /usr/lib/libXext.so.6 (0xb7edb000)
libXmu.so.6 => /usr/lib/libXmu.so.6 (0xb7ec6000)
libXt.so.6 => /usr/lib/libXt.so.6 (0xb7e77000)
libX11.so.6 => /usr/lib/libX11.so.6 (0xb7d93000)
libSM.so.6 => /usr/lib/libSM.so.6 (0xb7d8b000)
libICE.so.6 => /usr/lib/libICE.so.6 (0xb7d74000)
libm.so.6 => /lib/libm.so.6 (0xb7d4e000)
libc.so.6 => /lib/libc.so.6 (0xb7c05000)
libXau.so.6 => /usr/lib/libXau.so.6 (0xb7c01000)
libxcb-xlib.so.0 => /usr/lib/libxcb-xlib.so.0 (0xb7bff000)
libxcb.so.1 => /usr/lib/libxcb.so.1 (0xb7be8000)
libdl.so.2 => /lib/libdl.so.2 (0xb7be4000)
/lib/ld-linux.so.2 (0xb7efb000)
libXdmcp.so.6 => /usr/lib/libXdmcp.so.6 (0xb7bdf000)
|
共享庫之所以使用更大的鏈接粒度是因爲現代操作系統已經相當聰明瞭,當你想用靜態庫的時候,他爲了節省一些硬盤空間,就採用小粒度的鏈接方式,但對於共享庫來說,不同的程序運行時共用同一個代碼段(但並不共同數據段和 bss 段,因爲畢竟不同的程序使用不同的內存空間)。爲了做到這一點,必須對整個庫的內容進行一次性映射,這樣才能保證庫內部的符號集中保存在一片連續的空間裏——否則,如果某個進程導入了 a.o 和 c.o, 另一個進程導入的是 b.o 和 c.o,那麼就沒什麼共同點可以供操作系統利用了。
Windows DLLs
雖然 Unix 和 Windows 平臺的共享庫原理大體上一致,但有一些細節如果不注意的話,還是很容易犯錯的。
導出符號
兩個平臺之間最大的區別在於 Windows 的共享庫不會自動導出程序中的符號。在 Unix 上,每一個目標文件中所有與共享庫關聯的符號,對用戶而言都是可見的,但在 Windows 上,爲了使這些符號可見,程序員必須做一些額外的操作,例如,將其導出。
從 Windows DLL 中導出符號信息的方法一共有三種(這三種方法可以同時用於同一個庫中)
-
-
- 在源代碼中爲符號聲明關鍵字declspec(dllexport),例如:
__declspec(dllexport) int my_exported_function(int x, double y);1__declspec(dllexport) int my_exported_function(int x, double y);
- 使用鏈接器 LINK.EXE 提供的選項: /export:_symbol_to_export
LINK.EXE /dll /export:my_exported_function1LINK.EXE /dll /export:my_exported_function
- 使用鏈接器的 /DEF:_def_file_ 這一選項,它可用於導入模塊定義文件(module definition (.DEF) file)。該文件中有一部分名爲 EXPORTS,它包含了你想導出的符號信息。
- 在源代碼中爲符號聲明關鍵字declspec(dllexport),例如:
-
1
2
3
|
EXPORTS
my_exported_function
my_other_exported_function
|
對於以上三種方法而言,第一種方法最爲簡便,因爲編譯器會自行爲你考慮命名改寫(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__declspec(dllimport) int function_from_some_dll(int x, double y); __declspec(dllimport) extern int global_var_from_some_dll;
12 __declspec(dllimport) int function_from_some_dll(int x, double y);__declspec(dllimport) extern int global_var_from_some_dll;
這一方法看似稀鬆平常,但由於 C 語言裏所有函數以及全局變量都在且僅在頭文件中聲明一次,這會讓我們陷入一個兩難的境地:DLL 中包含了函數和變量的定義的代碼需要進行符號導出,但 DLL 以外的代碼需進行符號導入。
一般採取的迴避方式是在頭文件中加上一個預處理宏(preprocessor macro):
1
2
3
4
5
6
7
8
|
#ifdef EXPORTING_XYZ_DLL_SYMS
#define XYZ_LINKAGE __declspec(dllexport)
#else
#define XYZ_LINKAGE __declspec(dllimport)
#endif
XYZ_LINKAGE int xyz_exported_function(int x);
XYZ_LINKAGE extern int xyz_exported_variable;
|
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++ 的第一個改變是允許函數重載,即程序中允許存在多個不同版本的同名函數,當然它們的類型不同(即函數簽名不同)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
int max(int x, int y)
{
if (x>y) return x;
else return y;
}
float max(float x, float y)
{
if (x>y) return x;
else return y;
}
double max(double x, double y)
{
if (x>y) return x;
else return y;
}
|
這一做法顯然給鏈接器出了一個難題:當其它代碼調用 max 函數時,它到底是想調用哪一個呢?
鏈接器採用一種稱爲“命名改寫(name mangling)”的方法來解決這一問題,之所以使用“mangling”是因爲這個詞有損壞、弄糟之意,與函數簽名相關的信息都被“損壞”了,變成一種文本形式,成爲鏈接器眼中符號的實際名稱。不同的函數簽名將被“損壞”成不同的名稱,這樣就解決了函數名重複的問題。
我不打算深入講解“命名改寫”的具體規則,因爲不同編譯平臺有不同的改編規則,但我們通過查看事例代碼所對應的目標文件結構,可以對“命名改寫”規則有一個直觀的認識(記詮住, nm 命令絕對是您不可或缺的好夥伴!):
1
2
3
4
5
6
7
8
|
fn_overload.o中的符號:
Name Value Class Type Size Line Section
__gxx_personality_v0| | U | NOTYPE| | |*UND*
_Z3maxii |00000000| T | FUNC|00000021| |.text
_Z3maxff |00000022| T | FUNC|00000029| |.text
_Z3maxdd |0000004c| T | FUNC|00000041| |.text
|
從上圖中,我們可以看出,三個名爲 max 的函數,在目標文件中的名稱並不相同。聰明的你應該能夠猜得出來 max 的後兩個字母來自各自的參數類型:i表示int, f表示float,d表示double(如果把類、命名空間、模板,以及操作符重載都加入命名改編,情況將更爲複雜)。
需要注意的是,如果你希望能夠在鏈接器可識別的名稱(the mangled names)和用戶可識別的名稱(the demangled names)之間相互轉化,則需要另外單獨使用別的程序(如 c++filt)或者加入命令行選項(對於 GNU 的 nm 命令,可以加 –demangle 選項),這樣你就可以得到如下信息:
1
2
3
4
5
6
7
8
|
fn_overload.o中的符號:
Name Value Class Type Size Line Section
__gxx_personality_v0| | U | NOTYPE| | |*UND*
max(int, int) |00000000| T | FUNC|00000021| |.text
max(float, float) |00000022| T | FUNC|00000029| |.text
max(double, double) |0000004c| T | FUNC|00000041| |.text
|
命名改寫機制最常見的“坑”就是當 C 和 C++ 代碼混在一起寫的時候,C++ 編譯器生成的符號名稱都經過了改編處理,而 C 編譯器生成的符號名稱就是它在源文件中的名稱。爲了避免這一問題,C++ 採用 extern “C” 來聲明和定義 C 語言函數,其目的在於告訴 C++ 編譯器這個函數名不能被改變,既可能因爲相關的 C 代碼需要調用 C++ 函數的定義,也可能因爲相關的 C++ 代碼需要調用 C 函數。
回到本文最初的例子,現在我們很容易能看出這很可能是因爲某人將 C 和 C++ 鏈接到一起卻忘了加 extern “C” 聲明。
1
2
3
4
|
g++ -o test1 test1a.o test1b.o
test1a.o(.text+0x18): In function `main':
: undefined reference to `findmax(int, int)'
collect2: ld returned 1 exit status
|
這條錯誤信息中最明顯的提示點是那個函數簽名——它不僅僅是在抱怨你沒定義 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++ 代碼爲例:
Cclass Fred { private: int x; int y; public: Fred() : x(1), y(2) {} Fred(int z) : x(z), y(3) {} }; Fred theFred; Fred theOtherFred(55);
1234567891011 class Fred {private:int x;int y;public:Fred() : x(1), y(2) {}Fred(int z) : x(z), y(3) {}};Fred theFred;Fred theOtherFred(55);這段代碼的 nm 輸出如下(已經進行了反命名改編處理):
Cglobal_obj.o中的符號: Name Value Class Type Size Line Section __gxx_personality_v0| | U | NOTYPE| | |*UND* __static_initialization_and_destruction_0(int, int)|00000000| t | FUNC|00000039| |.text Fred::Fred(int) |00000000| W | FUNC|00000017| |.text._ZN4FredC1Ei Fred::Fred() |00000000| W | FUNC|00000018| |.text._ZN4FredC1Ev theFred |00000000| B | OBJECT|00000008| |.bss theOtherFred |00000008| B | OBJECT|00000008| |.bss global constructors keyed to theFred |0000003a| t | FUNC|0000001a| |.text
1234567891011 global_obj.o中的符號:Name Value Class Type Size Line Section__gxx_personality_v0| | U | NOTYPE| | |*UND*__static_initialization_and_destruction_0(int, int)|00000000| t | FUNC|00000039| |.textFred::Fred(int) |00000000| W | FUNC|00000017| |.text._ZN4FredC1EiFred::Fred() |00000000| W | FUNC|00000018| |.text._ZN4FredC1EvtheFred |00000000| B | OBJECT|00000008| |.bsstheOtherFred |00000008| B | OBJECT|00000008| |.bssglobal constructors keyed to theFred |0000003a| t | FUNC|0000001a| |.text
這段輸出內容給了很多信息,但我們感興趣的是 Class 列爲 W 的那兩項(W 在這裏表示弱符號 [譯者注6]),它們的 Section 列形如”.gnu.linkonce.t.stuff”,這些都是全局對象構造函數的特徵,我們可以從 “Name” 這一列看出些端倪——在不同情況下使用兩個構造函數中的一個。
模板
上文中,我們給了三個不同 max 函數的例子,在這個例子中,每個 max 函數帶有不同的參數,但函數體的代碼實際上完全相同,作爲程序員,我們得爲這種“複製粘貼”完全相同的代碼感到可恥。
於是 C++ 引入了模板(templates)這一概念來避免這種情況——只需一份代碼來完全所有工作。我們先創建一個只含有一個 max 函數代碼的頭文件 max_template.h :
Ctemplate <class T> T max(T x, T y) { if (x>y) return x; else return y; }
123456 template <class T>T max(T x, T y){if (x>y) return x;else return y;}然後將該頭文件應用到 C++ 代碼中,並使用這個模板函數:
C#include "max_template.h" int main() { int a=1; int b=2; int c; c = max(a,b); // 編譯能自動識別出當前需要調用的是 max<int>(int,int) double x = 1.1; float y = 2.2; double z; z = max<double>(x,y); // 編譯器無法識別,強制調用 max<double>(double,double) return 0; }
1234567891011121314 #include "max_template.h"int main(){int a=1;int b=2;int c;c = max(a,b); // 編譯能自動識別出當前需要調用的是 max<int>(int,int)double x = 1.1;float y = 2.2;double z;z = max<double>(x,y); // 編譯器無法識別,強制調用 max<double>(double,double)return 0;}
這個例子中的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++ 文件爲例,目標文件內容如下:
Cmax_template.o的符號: Name Value Class Type Size Line Section __gxx_personality_v0 | | U | NOTYPE| | |*UND* double max<double>(double, double) |00000000| W | FUNC|00000041| |.text._Z3maxIdET_S0_S0_ int max<int>(int, int) |00000000| W | FUNC|00000021| |.text._Z3maxIiET_S0_S0_ main |00000000| T | FUNC|00000073| |.text
12345678 max_template.o的符號:Name Value Class Type Size Line Section__gxx_personality_v0 | | U | NOTYPE| | |*UND*double max<double>(double, double) |00000000| W | FUNC|00000041| |.text._Z3maxIdET_S0_S0_int max<int>(int, int) |00000000| W | FUNC|00000021| |.text._Z3maxIiET_S0_S0_main |00000000| T | FUNC|00000073| |.text
我們可以從中看出目標文件中即包含了 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%。
如果你想進行更多的深入瞭解,可以參考下列文章:
- John Levine,鏈接器和加載器:本書對鏈接器和加載器的工作原理給出了非常非常詳細的介紹,我所略過的所有細節都包含在本書中。本文有一個在線版本供大家翻看(或者說是出版本前的草稿)
- 在Max操作系統 OS X 上,關於Mach-O([譯者注7])格式的二進制文件有一篇超好的文章 [27-Mar-06 更新]
- Peter Van Der Linden, C 專家編程:本書詳細介紹瞭如何將 C 語言代碼轉換成可執行程序,關於這方面內容的書,我再沒看過寫得比本書更牛的了。
- Scott Meyers, More Effective C++: 本書一共有34條條款,覆蓋了用 C 和 C++ 共同寫出的程序中的存在的各種陷阱(無論是否與鏈接器有關)。
- Bjarne Stroustrup, C++ 語言的設計和演化: 本書的第11.3節討論了 C++ 的鏈接以及鏈接產生的緣由。
- Margaret A. Ellis & Bjarne Stroustrup, 帶註釋的C++參考手冊: 本書的7.2c一節中,介紹了一種命名改編機制。
- ELF格式的相關參考文獻[PDF版]
- 特別推薦兩篇很有趣有文章,分別是:creating tiny Linux executables和minimal Hello World
- “How to Write Shared Libraries” [PDF版]: 由 Ulrich Drepper 所寫,本文對ELF和重定位給出了更爲詳細的介紹。
非常感謝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