給初學者的連結器指南
原文地址:http://www.lurklurk.org/linkers/linkers.html
本文目的在於幫助C與C++程式設計師理解連結器工作的實質。多年來,我已經在若干學院宣講之,因此是時候將它寫下來,使更多人可以看到它(這樣,我就無需再次解釋它了)。【在2009年3月更新,包括了Windows上鍊接特性的更多資訊,加上一次定義規則的一些澄清】。
促成這個解釋的一個典型例子是,在我幫助某人處理如下連結錯誤時:
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檔案裡各個部分的一個快速回顧。如果下面列出的簡單C檔案裡的每樣東西對你都是順理成章的,你可以跳到下一節。
第一個要理解的分類是宣告與定義。定義將一個名字與該名字的實現關聯起來,這可以是資料或程式碼:
- 一個變數的定義使編譯器為該變數保留空間,並可能向該空間填充特定的值。
- 一個函式的定義使編譯器為該函式產生程式碼。
宣告告訴C編譯器某個東西(帶有特定名字)的定義在程式的某處,可能在另一個的
對於變數,定義分為兩類:
- 全域性變數,在在整個程式的生命期(靜態範圍,static extent)裡存在,通常在許多函式中可訪問它
- 區域性變數,它僅存在於將要執行的一個特定函式裡(區域性範圍,local extent),僅在該函式中可訪問
說得更清楚些,“可訪問”我們指“通過變數定義,使用關聯的名字可以援引”。存在幾個不是那麼顯而易見的特殊情形:
- static區域性變數實際上是全域性變數,因為它們存在於程式的生命期,即使它們僅在一個函式內可見
- 類似的,static全域性變數也計為全域性變數,即使它們僅能由定義它們的特定檔案裡的函式訪問
在我們談到關鍵字static主題時,值得指出的是,使得一個函式成為靜態也減少了通過名字能援引該函式的地方(具體地,同一個檔案裡的其他函式)。
對全域性與區域性變數定義,我們還可以區分變數是否被初始化了——也就是說,與該特定名字關聯的空間是否預先填充了一個特定值。
最後,我們可以在使用malloc或new動態分配的記憶體裡儲存資訊。沒有辦法通過名字援引這個空間,因此我們必須使用指標——持有記憶體匿名片段的地址的一個具名變數(指標)。這片記憶體也可以使用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)); |
/* This is the definition of a uninitialized global variable */ int x_global_uninit;
/* This is the definition of a initialized global variable */ int x_global_init = 1;
/* This is the definition of a uninitialized global variable, albeit * one that can only be accessed by name in this C file */ static int y_global_uninit;
/* This is the definition of a initialized global variable, albeit * one that can only be accessed by name in this C file */ static int y_global_init = 2;
/* This is a declaration of a global variable that exists somewhere * else in the program */ extern int z_global;
/* This is a declaration of a function that exists somewhere else in * the program (you can add "extern" beforehand if you like, but it's * not needed) */ int fn_a(int x, int y);
/* This is a definition of a function, but because it is marked as * static, it can only be referred to by name in this C file alone */ static int fn_b(int x) { return x+1; }
/* This is a definition of a function. */ /* The function parameter counts as a local variable */ int fn_c(int x_local) { /* This is the definition of an uninitialized local variable */ int y_local_uninit; /* This is the definition of an initialized local variable */ int y_local_init = 3;
/* Code that refers to local and global variables and other * functions by name */ 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 (y_global_uninit + y_local_uninit); } |
C編譯器做什麼
C編譯器的工作是將一個C檔案從人類(通常)可理解的文字形式,翻譯為計算機可以理解的內容。編譯器把這個輸出為一個目標檔案。在Unix平臺上,這些目標檔案通常有一個.o字尾;在Windows上,它們有一個.obj字尾。目標檔案的內容主要有兩類:
- 程式碼,對應C檔案中的函式的定義
- 資料,對應C檔案中全域性變數的定義(對於初始化的全域性變數,變數的初始值還必須儲存在目標檔案裡)。
這些東西的例項將有關聯的名字——產生它們的變數或函式定義的名字。
目的碼是對應程式設計師編寫的C指令——那些if、while甚至goto的(恰當編碼的)機器指令序列。所有這些指令需要操作某種資訊,而這個資訊需要被儲存在某處——這就是變數的任務。程式碼還可以引用其他程式碼,特別的程式中的其他C函式。
在任何情況下,程式碼援引一個變數或函式,編譯器僅在之前看到該變數或函式的一個宣告時才允許——該宣告是一個定義存在於整個程式某處的承諾。
連結器的任務是實現這些承諾,但在生成目標檔案時,編譯器如何處理這些承諾?
基本上,編譯器留下一個空白。該空白(“引用”)有一個關聯的名字,但對應這個名字的值尚未知。
記住這,我們可以將對應上面給出的程式的目標檔案像這樣展示:
分解目標檔案
目前為止我們把一切都保持在一個高階的層次;看一下這在實踐中如何工作是有用的。對此,關鍵的工具是命令nm,在UNIX平臺上它給出關於目標檔案中符號的資訊。在Windows上,帶有/symbols選項的dumpbin命令大致相同;也有GNU binutils工具的Windows移植版,這包括一個nm.exe。
讓我們看一下從上面C檔案產生的目標檔案nm會給出什麼:
Symbols from 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),但給出的關鍵資訊有每個符號的類別,其大小(在可用時)。類別可以有若干不同的值:
- 類別U表示一個未定義的引用,之前提到的“空白”之一。這種物件有兩類:fn_a與z_global(某些版本的nm可能還會輸出一個節(section),在這個情形裡將是*UND*或UNDEF)
- 類別t或T表示程式碼被定義的地方;不同的類別表示函式是這個檔案區域性的(t),還是區域性的(T)——即,函式是否一開始使用static宣告。同樣,某些系統還可能顯示一個節,有點像.text
- 類別d或D表示一個初始化的全域性變數,同樣特定的類別表示變數是本地(d)還是全域性的(D)。如果存在一個節,它將有點像.data
- 對於一個非初始化的全域性變數,如果它是static/local,我們得到b,不是時B或C。在情形裡節看起來有點像.bss或*COM*。
我們還可能得到某些不是原始輸入C檔案部分的符號;我們將忽略這些,因為它們通常是為了使你的程式得到連結的編譯器的邪惡的內部機制。
連結器做什麼:第一部分
之前我們提到,一個函式或變數的宣告是,對C編譯器,在程式某處有該函式或變數定義的承諾,而連結器的工作是實現這個承諾。面前擺著一個目標檔案的圖,我們也將這描述為“填充空白”。
為了展示這,對之前給出的C檔案我們有一個伴隨C檔案:
/* Initialized global variable */ int z_global = 11; /* Second global named y_global_init, but they are both static */ static int y_global_init = 2; /* Declaration of another global variable */ 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來檢查得到的可執行檔案:
Symbols from sample1.exe:
Name Value Class Type Size Line Section
_Jv_RegisterClasses | | w | NOTYPE| | |*UND* __gmon_start__ | | w | NOTYPE| | |*UND* [email protected]@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,事情稍微模糊些。任何函式或初始化的全域性變數必須恰好有一個定義,但未初始化全域性變數的定義可處理為一個暫時定義(tentative definition)。C允許(或至少不禁止)不同的原始檔對同一個物件有暫時定義。
不過,連結器還必須處理C與C++以外的其他程式語言,對它們一次定義規則不總是適用的。例如,Fortran的普通模式實際上在每個引用全域性變數的檔案中有一個拷貝;連結器被要求通過挑選其中一個拷貝(如果它們有不同的大小,選最大的)來取消重複(這個模式有時稱為連結的“通用模型”,因為Fortran的COMMON關鍵字)。
因此,對UNIX連結器不抱怨符號的重複定義——至少,在重複符號是未初始化的全域性變數時,是相當常見的(這有時稱為連結的“relaxed ref/def模型”)。如果這使你擔憂(很可能),檢視你的編譯器連結器文件——可能存在一個--work-properly選項來收緊這個行為。例如,對於GNU工具鏈,編譯器選項-fno-common強制它將未初始化變數放入BBS段,而不是產生common塊。
作業系統怎麼做
現在連結器已經產生了一個可執行程式,符號的所有引用連線道路這些符號合適的定義處,我們需要暫停一下來理解在執行該程式時,作業系統做什麼。
執行該程式顯然涉及執行機器程式碼,因此作業系統顯然必須將機器程式碼從硬碟上的可執行檔案傳輸到計算機的記憶體,在那裡CPU可以獲取它。程式的記憶體塊稱為程式碼段或文字段。
沒有資料,程式碼什麼也不是,因此在計算機記憶體中,所有的全域性變數還需要有某些空間。不過,在已初始化與未初始化全域性變數間存在區別。已初始化變數有一開始需要使用的特定值,這些值儲存在目標檔案及可執行檔案中。在程式啟動時,OS將這些值拷貝到在資料段的程式記憶體中。
至於未初始化變數,OS可以假設它們都以初始值0開始,因此無需拷貝任何值。這塊初始化為0的記憶體稱為bbs段。
這意味著硬碟上的可執行檔案中可以節省這塊空間;已初始化變數的初始值必須儲存在檔案裡,但未初始化變數,我們只需要它們需要多少空間的一個計數。
你可能注意到目前對目標檔案及連結器的所有討論僅涉及全域性變數;沒有提到區域性變數及之前講到的動態分配記憶體。
這些資料無需涉及連結器,因為它們的生命期僅是程式執行時——遠在連結器完成了它的工作之後。不過,為了完整起見,這裡我們可以很快指出:
- 區域性變數分配在一塊稱為棧的記憶體上,棧隨著函式的呼叫與完成生長、收縮
- 動態分配記憶體取自稱為堆的區域,malloc函式記錄這個區域中所有可用的空間。
我們可以加入這些記憶體塊來補全我們的執行程序記憶體空間的圖景。因為堆與棧隨著程式執行大小會變化,因此棧向一個方向增長,而堆向另一個方向增長是常見的。這樣,當它們在中間相遇時,程式將耗盡記憶體(這時,記憶體空間真的填滿了)。
連結器做什麼:第二部分
既然我們已經看過了連結器操作非常基本的部分,我們可以繼續描述更復雜的細節——大致是這些特性加入連結器的歷史次序。
影響連結器功能的主要觀察是:如果許多不同的程式需要做相同的事情(向螢幕輸出,從硬碟讀檔案等),將這個程式碼集中在一個地方,讓不同的程式使用它,是合理的。
在連結不同的程式時,使用相同的目標檔案完全可行,但如果整組相關的目標檔案被儲存在一個容易訪問的地方:庫,會更容易得多。
(技術之外:本節完全跳過了連結器的一個主要特性:重定位。不同的程式有不同的大小,因此在共享庫對映到不同程式的地址空間時,它將在不同的地址上。這反過來意味著庫裡所有的函式與變數在不同的位置裡。現在,如果所有訪問地址的方法都是相對的(距離這裡值+1020位元組),而不是絕對地址(在0x102218BF處的值),這不是問題,但這不總是可能的。如果不可能,所有這些絕對地址需要加上一個合適的偏移值——這就是重定位。我不準備再提及這個話題,因為它幾乎總是對C/C++程式設計師不可見——因為重定位導致的連結問題很少見)
靜態庫
庫最基本的化身是靜態庫。前一節提到通過重用目標檔案,你可以共享程式碼;靜態庫被證明不比這複雜更多。
在UNIX系統上,生成靜態庫的命令通常是ar,它產生的庫檔案通常有一個.a副檔名。這些庫檔案通常也帶有字首“lib”,通過後跟沒有副檔名或字首的庫名字的“-l”選項傳遞給連結器(因此“-lfred”將選中“libfred.a”)。
(歷史上,一個稱為ranlib的程式過去用於靜態庫,以在庫開頭構建符號索引。今天ar工具傾向於自己來做)。
在Windows上,靜態庫有.LIB副檔名,由LIB工具生成,但這令人混淆,因為相同的副檔名也用於“匯入庫(import library)”,匯入庫僅包含一個DLL中可用物件的列表——參考Windows DLL一節。
隨著連結器闖過其要合併的目標檔案集,它構建了一組尚不能解析的符號列表。在處理所有顯式指定的物件時,現在連結器有另一個地方可以查詢這個未解析列表中的物件——庫。如果未解析符號定義在庫的其中與目標檔案裡,那麼加入這個目標檔案,就像使用者第一時間在命令列上給出它那樣,連結繼續。
注意從庫匯入的粒度:如果需要某個特定符號的定義,包含該符號的整個目標檔案被包含。這意味著這個過程可以是前進的一步,可以是後退的一步——新加入的目標檔案可能解決了一個未定義引用,但它可能帶來自己的一組新未定義引用要連結器解決。
另一個要注意的重要細節是事件的次序;僅在完成普通的連結時,才詢問庫,它們依次處理,從左到右。這意味著如果從庫匯入的一個目標檔案,在連結路線上需要一個更早出現的庫的符號時,連結器將不能自動找到它。
一個例子有助於澄清這個問題;假設我們有以下目標檔案,匯入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與b.o,它將解決對b2與a3的引用,留下x12與y22仍未解析。這時,連結器對第一個庫libx.a檢查這些符號,發現它可以匯入x1.o來滿足對x12的引用;不過,這樣做還引入了未定義引用x23與y12(因此,列表現在是y22,x23與y12)。
連結器仍然在處理libx.a,因此通過從libx.a匯入x2.o,x23的引用很容易解決。不過,這還向未定義列表加入了y11(現在是y22,y12與y11)。這些都不能通過libx.a解決,因此連結器移到liby.a。
這裡,應用相同的過程,連結器將讀入y1.o與y2.o。y1.o首先加入了對y21的引用,但因為y2.o無論如何都被匯入,這個引用容易解決。這個過程的淨效應是所有未定義引用被解析,庫的一些但不是全部目標檔案被包含到最終的可執行檔案裡。
注意到,情形將稍有不同,如果,比如b.o還包含對y32的引用。如果是這樣,libx.a的連結將是相同的,但liby.a的處理還將匯入y3.o。匯入這個目標檔案將加入未解析符號x31,連結將失敗——在這個階段,連結器已經完成libx.a的處理,不能為這個符號找到定義(在x3.o中)。
(順便提一下,這個例子在兩個庫libx.a與liby.a間存在迴圈依賴;這通常是一件壞事,特別在Windows上)
共享庫
對像C標準庫(通常libc)這樣的流行庫,靜態庫顯然是一個劣勢——每個可執行程式都有相同程式碼的一份拷貝。這會佔據大量不必要的硬碟空間,如果每個可執行檔案都有printf,fopen等等的一個拷貝。
一個不那麼明顯的壞處是,一旦程式被靜態連結,它的程式碼就被永久固定了。如果有人找到並修復了printf裡的一個bug,每個程式必須重新連結以獲取修正的程式碼。
為了繞開這些、那些的問題,引入了共享庫(通常由.so副檔名來表示,或者在Windows機器上.dll,在Mac OS X上.dylib)。對這些型別的庫,正常的命令列連結器不一定會把所有的點都連線起來。相反,正常的連結器獲取一類IOU“紙幣”,該“紙幣”的支付推遲到該程式實際執行時。
這可以歸結為:如果連結器發現一個特定符號的定義在一個共享庫中,那麼它不會在最終的可執行檔案裡包含該符號的定義。相反,連結器記錄符號的名字,以及它應該從可執行檔案中獲得的庫。
在程式執行時,作業系統會安排連結的剩餘部分在程式執行時“及時”完成。在main函式執行之前,一個較小的連結器(通常稱為ld.so)仔細檢查這些約定的“支付”,當場執行連結的最後步驟——匯入庫的程式碼,將所有點連線起來。
這意味著沒有可執行檔案擁有printf程式碼的拷貝。如果一個新的、修正的printf版本可用,可以通過改變libc.so偷偷插入——在程式下次執行時,它將被選中。
與靜態庫相比,共享庫的工作有另一個大的差異,體現在連結的粒度上。如果從一個特定共享庫匯入一個特定符號(比如libc.so裡的printf),那麼整個共享庫被對映到程式的地址空間。這與靜態庫非常不同,靜態庫僅匯入持有未定義符號的特定目標檔案。
換而言之,共享庫自己是作為連結器執行的結果產生的(而不是像ar那樣形成一大堆目標檔案),同一個庫裡的目標檔案間的引用得到解析。
再一次的,nm是展示這的有用工具:對上面的例子庫,當執行在該庫的一個靜態版本時,它將對單獨的目標檔案產生若干組結果,但對該庫的共享版本,liby.so僅有一個未定義符號x31。同樣,對於前一節結尾的庫次序例子,這將不是問題:把y32的引用加入b.c沒有區別,因為y3.o與x3.o的全部內容都已經匯入。
此外,另一個有用的工具是ldd;在UNIX平臺上,它顯示一個可執行檔案(或者一個共享庫)依賴的共享庫集合,連同這些庫可能在哪裡找到的提示。對成功執行的程式,載入器需要能夠找到所有這些庫,連同它們所有的依賴。(通常,載入器在環境變數LD_LIBRARY_PATH儲存的目錄列表裡查詢庫)。
/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段——畢竟對兩個不同的程序,它們的strtok可以在不同的地方)。為了這樣做,整個庫必須被一次對映,這樣內部引用都集中到同一個地方——如果一個程序匯入a.o與c.o,另一個匯入b.o與c.o,將不存在OS可資利用的共同之處。
Windows DLL
雖然在Unix平臺與Windows上共享庫的一般性原則大致類似,有幾個細節會使人大意失荊州。
匯出符號
兩者間最主要的差別是Windows庫不會自動匯出符號。在Unix上,來自構成共享庫所有目標檔案的所有符號,都對該庫的使用者可見。在Windows上,程式設計師必須顯式選擇,使得特定的符號可見——即,匯出它們。
有3中方式從一個Windows DLL匯出一個符號(在同一個庫中,所有這3種方式可以混合使用)。
- 在原始碼中,將符號宣告為__declspec(dllexport),因而:
__declspec(dllexport) int my_exported_function(int x, double y);
- 在呼叫連結器時,對LINK.e.xe使用/export:symbol_to_export選項
LINK.exe /dll /export:my_exported_function
- 使連結器匯入一個模組定義(.DEF)檔案(通過使用/DEF:def_file選項),在該檔案中包括一個包含希望匯出符號的EXPORTS節。
EXPORTS my_exported_function my_other_exported_function |
一旦C++加入混戰,第一個選擇是最簡單的,因為編譯器會為你做好名字重整。
.LIB與其他庫相關檔案
這乾淨利落地引出了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:一個允許控制已連結庫各種細節,包括符號匯出的“模組定義”檔案。
- library.EXP:用於將被連結庫的“匯出檔案”,它可以表示用於庫的LIB.EXE之前的執行已經為該庫建立了.LIB檔案。與連結具有迴圈依賴的二進位制程式碼相關。
- library.ILK:增量連結狀態檔案;參考上面。
- library.RES:包含可執行檔案使用的各種GUI小玩意資訊的資原始檔;這些被包含在最終的二進位制檔案裡。
這與Unix形成對比,在這些額外檔案中儲存的大部分資訊,在Unix中(通常)包含在庫本身。
除了要求DLL顯式宣告它們要匯出哪些符號,Windows還允許使用庫程式碼的二進位制程式碼顯式宣告它們要匯入哪些符號。這是可選的,但由於16位Windows的某些歷史性的特性,會給速度優化。
為此,在原始碼中把符號宣告為__declspec(dllimport),因而:
__declspec(dllimport) int function_from_some_dll(int x, double y);
__declspec(dllimport) extern int global_var_from_some_dll;
在C中,任何函式或全域性變數只有一個宣告,儲存在一個頭檔案裡,通常是好的做法。這導致了一點麻煩:持有函式/變數定義的DLL需要匯出符號,但DLL以外的程式碼需要匯入該符號。
繞開這的一個常用方法是在標頭檔案裡使用一個預處理巨集。
#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檔案確保,在它包括這個標頭檔案之前,前處理器變數EXPORTING_XYZ_DLL_SYMS被定義,因此進行符號的匯出。其他使用這個標頭檔案的程式碼不定義這個符號,因此表明這些符號的匯入。
DLL的最後一個複雜之處是,Windows比Unix更嚴格,在於它要求在連結時刻每個符號必須被解析。在Unix上,連結一個帶有連結器看不到的未解析符號的共享庫是可以的;在這種情況下,載入這個共享庫的程式碼必須提供這個符號,否則程式會載入失敗。Windows不允許這種放縱。