FW: 幫 C/C++ 程式設計師徹底瞭解連結器
幫 C/C++ 程式設計師徹底瞭解連結器
2015/12/18 · C/C++, 開發 · 3 評論 · C語言, 連結器
本文由 伯樂線上 - 小胖妞妞 翻譯, 黃小非 校稿。未經許可,禁止轉載!英文出處: David Drysdale。歡迎加入 翻譯組。
本文旨在幫助 C/C++ 程式設計師們瞭解連結器到底完成了些什麼工作。多年來,我給許多同事解釋過這一原理,因此我覺得是時候把它寫下來了,這樣不僅可以供更多人學習,也省去我一遍遍講解。
[2009年3月更新,內容包括:增加了 Windows 系統中連結過程可能遇到的特殊問題,以及對某條定義規則的澄清。]
促使我寫下這篇文章的起因是某次我幫人解決了一個連結錯誤,具體是這樣的:
C 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 status1 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));) |
以下是一個示例程式,也許是一種更簡便的記憶方法:
C /* 這是一個未初始化的全域性變數的定義 */ 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); }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 程式碼所產生的目標檔案是什麼結構:
C 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| |.text1 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 檔案添個“伴兒”:
C /* 初始化的全域性變數 */ 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); }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 命令來檢查生成的可執行檔案:
C 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*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 這個環境變數指定的目錄列表中去搜尋所需要的庫)。
C /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