1. 程式人生 > >徹底理解連結器:庫與可執行檔案

徹底理解連結器:庫與可執行檔案

庫與可執行檔案

在連結器可操作的元素這一節中我們提到,連結器可以操作的最小單元為目標檔案,也就是說我們見到的無論是靜態庫、動態庫、可執行檔案,都是基於目標檔案構建出來的。目標檔案就好比樂高積木中最小的零部件。

給定目標檔案以及連結選項,連結器可以生成兩種庫,分別是靜態庫以及動態庫,如圖所示,給定同樣的目標檔案,連結器可以生成兩種不同型別的庫,接下來我們分別介紹。

clipboard.png

靜態庫

假設這樣一個應用場景,基礎設計團隊設計了好多實用並且功能強大的工具函式,業務團隊需要用到裡面的各種函式。每次新新增其中一個函式,業務團隊都要去找相應的實現檔案並修改連結選項。使用靜態庫就可以解決這個問題。靜態庫在Windows下是以.lib為字尾的檔案,Linux下是以.a為字尾的檔案。

為解決上述問題,基礎設計團隊可以提前將工具函式集合打包編譯連結成為靜態庫提供給業務團隊使用,業務團隊在使用時只要連結該靜態庫就可以了,每次新使用一個工具函式的時候,只要該函式在此靜態庫中就無需進行任何修改。

你可以簡單的將靜態庫理解為由一堆目標檔案打包而成, 使用者只需要使用其中的函式而無需關注該函式來自哪個目標檔案(找到函式實現所在的目標檔案是連結器來完成的,從這裡也可以看出,不是所有靜態庫中的目標檔案都會用到,而是用到哪個連結器就連結哪個)。靜態庫極大方便了對其它團隊所寫程式碼的使用。

靜態連線

靜態庫是連結器通過靜態連結將其和其它目標檔案合併生成可執行檔案的,如下圖一所示,而靜態庫只不過是將多個目標檔案進行了打包,在連結時只取靜態庫中所用到的目標檔案,因此,你可以將靜態連結想象成如下圖2所示的過程。​​

clipboard.png

clipboard.png

靜態庫是使用庫的最簡單的方法,如果你想使用別人的程式碼,找到這些程式碼的靜態庫並簡單的和你的程式連結就可以了。靜態連結生成的可執行檔案在執行時不依賴任何其它程式碼,要理解這句話,我們需要知道靜態連結下,可執行檔案是如何生成的。

靜態連結下可執行檔案的生成

在上一節中我們知道,可以將靜態連結簡單的理解為連結器將使用到的目標檔案集合進行拼裝,拼裝之後就生成了可執行檔案,同時我們在目標檔案裡有什麼這一節中知道,目標檔案分成了三段,程式碼段,資料段,符號表,那麼在靜態連結下可執行檔案的生成過程如圖所示:

clipboard.png

從上圖中我們可以看到可執行檔案的特點:

  • 可執行檔案和目標檔案一樣,也是由程式碼段和資料段組成。
  • 每個目標檔案中的資料段都合併到了可執行檔案的資料段,每個目標檔案當中的程式碼段都合併到了可執行檔案的程式碼段。
  • 目標檔案當中的符號表並沒有合併到可執行檔案當中,因為可執行檔案不需要這些欄位。

可執行檔案和目標檔案沒有什麼本質的不同,可執行檔案區別於目標檔案的地方在於,可執行檔案有一個入口函式,這個函式也就是我們在C語言當中定義的main函式,main函式在執行過程中會用到所有可執行檔案當中的程式碼和資料。而這個main函式是被誰呼叫執行的呢,答案就是作業系統(Operating System),這也是後面文章當中要重點介紹的內容。

現在你應該對可執行檔案有一個比較形象的認知了吧。你可以把可執行檔案生成的過程想象成裝訂一本書,一本書中通常有好多章節,這些章節是你自己寫的,且一本書不可避免的要引用其它著作。靜態連結這個過程就好比不但要裝訂你自己寫的文章,而且也把你引用的其它人的著作也直接裝訂進了你的書裡,這裡不考慮版權問題 :),這些工作完成後,只需要按一下訂書器,一本書就製作完成啦。

在這個比喻中,你寫的各個章節就好比你寫的程式碼,引用的其它人的著作就好比使用其它人的靜態庫,裝訂成一本書就好比可執行檔案的生成。

靜態連結是使用庫的最簡單最直觀的形式, 從靜態連結生成可執行檔案的過程中可以看到,靜態連結會將用到的目標檔案直接合併到可執行檔案當中,想象一下,如果有這樣的一種靜態庫,幾乎所有的程式都要使用到,也就是說,生成的所有可執行檔案當中都有一份一模一樣的程式碼和資料,這將是對硬碟和記憶體的極大浪費,假設一個靜態庫為2M,那麼500個可執行檔案就有1G的資料是重複的。如何解決這個問題呢,答案就是使用動態庫。

動態庫

在前三小節中我們瞭解了靜態庫、靜態連結以及使用靜態連結下可執行檔案是如何生成的。接下里我們講解一下動態庫,那麼什麼是動態庫?

動態庫(Dynamic Library),又叫共享庫(Shared Library),動態連結庫等,在Windows下就是我們常見的大名鼎鼎的DLL檔案了,Windows系統下大量使用了動態庫。在Linux下動態庫是以.so為字尾的檔案,同時以lib為字首,比如進行數字計算的動態庫Math,編譯連結後產生的動態庫就叫做libMath.so。從名字中我們知道動態庫也是庫,本質上動態庫同樣包含我們已經熟悉的程式碼段、資料段、符號表。只不過動態庫的使用方式以及使用時間和靜態庫不太一樣。

在前面幾個小節中我們知道,使用靜態庫時,靜態庫的程式碼段和資料段都會直接打包copy到可執行檔案當中,使用靜態庫無疑會增大可執行檔案的大小,同時如果程式都需要某種型別的靜態庫,比如libc,使用靜態連結的話,每個可執行檔案當中都會有一份同樣的libc程式碼和資料的拷貝,如圖所示,動態庫的出現解決了此類問題。

clipboard.png

動態庫允許使用該庫的可執行檔案僅僅包含對動態庫的引用而無需將該庫拷貝到可執行檔案當中。也就是說,同靜態庫進行整體拷貝的方式不同,對於動態庫的使用僅僅需要可執行檔案當中包含必要的資訊即可,為了方便理解,你可以將可執行檔案當中儲存的必要資訊僅僅理解為需要記錄動態庫的名字就可以了,如圖所示,同靜態庫相比,動態庫的使用減少了可執行檔案的大小。

clipboard.png

從上面這張圖中可以看出,動態庫的使用解決了靜態連結當中可執行檔案過大的問題。我們在前幾節中將靜態連結生成可執行檔案的過程比作了裝訂一本書,靜態連結將引用的其它人的著作也裝訂到了書裡,而動態連結可以想象成作者僅僅在引用的地方寫了一句話,比如引用了《碼農的荒島求生》,那麼作者就在引用的地方寫上“此處參考《碼農的荒島求生》”,那麼讀者在讀到這裡的時候會自己去找到碼農的荒島求生這本書並查詢相應的內容,其實這個過程就是動態連結的基本思想了。

到這裡我們就可以回答之前提到過的問題了,helloworld程式中的printf函式到底是在哪裡定義的,答案就是該函式是在libc.so當中定義的,Linux下編譯連結生成可執行檔案時會預設動態連結libc.so(Windows下也是同樣的道理),使用ldd命令就會發現每個可執行檔案都依賴libc.so。因此雖然你從沒有看到過printf的定義也可以正確的使用這個函式。

接下來我們講解一下動態連結

動態連結

我們知道靜態庫在編譯連結期間就被打包copy到了可執行檔案,也就是說靜態庫其實是在編譯期間(Compile time)連結使用的,那麼動態庫又是在什麼時候才連結使用的呢,動態連結可以在兩種情況下被連結使用,分別是load-time dynamic linking(載入時動態連結) 以及 run-time dynamic linking(執行時動態連結),接下來我們分別講解一下。

1,load-time dynamic linking(載入時動態連結)首先可能有的同學會問,什麼是load-time呢,load_time翻譯過來也就是載入時,那麼什麼又是載入呢?我們大家都玩過遊戲,當我們開啟遊戲的時候經常會跳出來一句話:“載入中,請稍後。。。”和這裡的載入意思差不多。這裡的載入指的是程式的載入,而所謂程式的載入就是把可執行檔案從磁碟搬到記憶體的過程,因為程式最終都是在記憶體中被執行的。至於這個過程的詳解內容我會在接下來的文章《載入器與可執行檔案》一文中給大家詳細講解。在這裡我們只需要簡單的把載入理解為程式從磁碟複製到記憶體的過程,載入時動態連結就出現在這個過程。

當把可執行檔案複製到記憶體後,且在程式開始執行之前,作業系統會查詢可執行檔案依賴的動態庫資訊(主要是動態庫的名字以及存放路徑),找到該動態庫後就將該動態庫從磁碟搬到記憶體,並進行符號決議(關於符號決議,參考符號決議一節),如果這個過程沒有問題,那麼一切準備工作就緒,程式就可以開始執行了,如果找不到相應的動態庫或者符號決議失敗,那麼會有相應的錯誤資訊報告為使用者,程式執行失敗。比如Windows下比較常見的啟動錯誤問題,就是因為沒有找到依賴的動態庫。Linux下同樣會有類似資訊提示使用者程式啟動失敗。

clipboard.png

到這裡,同學們應該對載入時動態連結應該有一個比較清晰的瞭解了。從總體上看,載入時動態連結可以分為兩個階段:階段一,將動態庫資訊寫入可執行檔案;階段二,載入可執行檔案時依據動態庫資訊進行動態連結。

階段一,將動態庫資訊寫入可執行檔案在編譯連結生成可執行檔案時,需要將使用的動態庫加入到連結選項當中,比如在Linux下引用libMath.so,就需要將libMath.so加入到連結選項當中(比如libMath.so放到了/usr/lib下,那麼使用命令 gcc ... -lMath -L/user/lib ... 進行編譯連結),所以使用這種方式生成的可執行檔案中儲存了依賴的動態庫資訊,在Linux可使用一個簡單的命令ldd來檢視。

階段二:載入可執行檔案時依據動態庫資訊進行動態連結由於在階段一生成的可執行檔案中儲存了動態庫資訊,當可執行檔案載入完成後,就可以依據此資訊進行中動態庫的查詢以及符號決議了。

通過這個過程也可以清楚的看到靜態庫和動態庫的區別,使用動態庫的可執行檔案當中僅僅保留相應資訊,動態庫的連結過程被推遲到了程式啟動載入時。

為加深你對載入時動態連結這個過程的理解,我們用一個類比來結束本小節,沿用前幾節讀書的例子,我們正在讀的書中引用了《碼農的荒島求生》以及其它著作,那麼載入時動態連結就好比,讀者開始準備讀這本書的時候(還沒有真正的讀)就把所有該書當中引用的資料著作都找齊放到一旁準備檢視,當我們真正看到引用其它文獻的地方時就可以直接在一旁找到該著作啦。在這個類比當中,開始讀書前的準備工作就好比載入時動態連結。

2, 接下來我們講解第二種動態連結,run-time dynamic linking(執行時動態連結) 。run-time dynamic linking(執行時動態連結)上一小節中我們看到如果我們想使用載入時動態連結,那麼在編譯連結生成可執行檔案階段時需要告訴編譯器所依賴的動態庫資訊,而run-time dynamic linking 執行時動態連結則不需要在編譯連結時提供動態庫資訊,也就是說,在可執行檔案被啟動執行之前,可執行檔案對所依賴的動態庫資訊一無所知,只有當程式執行到需要呼叫動態庫所提供的程式碼時才會啟動動態連結過程。

我們在上一節中介紹了load-time,也就是程式載入時,那麼程式載入完成後就開始程式執行了,那麼所謂run-time(執行時)指的就是從程式開始被CPU執行到程式執行完成退出的這段時間。

所以執行時動態連結這種方式對於“動態連結”闡釋的更加淋漓盡致,因為可執行檔案在啟動執行之前都不知道需要依賴哪些動態庫,只在執行時根據程式碼的需要再進行動態連結。同載入時動態連結相比,執行時動態連結將連結這個過程再次推遲往後推遲,推遲到了程式執行時。

由於在編譯連結生成可執行檔案的過程中沒有提供所依賴的動態庫資訊,因此這項任務就留給了程式設計師,在程式碼當中如果需要使用某個動態庫所提供的函式,我們可以使用特定的API來執行時載入動態庫,在Windows下通過LoadLibrary或者LoadLibraryEx,在Linux下通過使用dlopen、dlsym、dlclose這樣一組函式在執行時連結動態庫。當這些API被呼叫後,同樣是首先去找這些動態庫,將其從磁碟copy到記憶體,然後查詢程式依賴的函式是否在動態庫中定義。這些過程完成後動態庫中的程式碼就可以被正常使用了。

相對於載入時動態連結,執行時動態連結更加靈活,同時將動態連結過程推遲到執行時可以加快程式的啟動速度。

為了和載入時動態連結作比對,我們繼續使用上一小節當中讀書的例子,載入時動態連結就好比在開始準備讀一本書之前,將該書中所有引用到的資料文獻找齊全,而執行時動態連結則不需要這個過程,執行時動態連結就好比直接拿起一本書開始看,看到有引用的參考文獻時再去找該資料,找到後檢視該文獻然後繼續讀我們的書。從這個例子當中執行時動態連結更像是我們平時讀書時的樣子。

至此,兩種動態連結的形式我們就都已經清楚了,接下來我們看一下動態連結下生成的可執行檔案。

動態連結下可執行檔案的生成

在靜態連結下,連結器通過將各個目標檔案的程式碼段和資料段合併拷貝到可執行檔案,因此靜態連結下可執行檔案當中包含了所依賴的所有程式碼和資料,而與之對比的動態連結下可執行檔案又是什麼樣的呢?其實我們在動態庫這一節中已經瞭解了動態連結下可執行檔案的生成,即,在動態連結下,連結器並不是將動態庫中的程式碼和資料拷貝到可執行檔案中,而是將動態庫的必要資訊寫入了可執行檔案,這樣當可執行檔案在載入時就可以根據此資訊進行動態連結了。為方便理解,我們將該資訊僅僅認為是動態庫都名字,真實情況當然要更復雜一點,這裡我們以Linux下可執行檔案即ELF檔案為例(這一系列的文章重點關注最本質的原理思想,所以這裡討論的同樣適合Windows下的可執行檔案即exe檔案)。在前幾節中我們將可執行檔案簡單的劃分為了兩段,資料段和程式碼段,在這裡我們繼續豐富可執行檔案中的內容,如圖所示,在動態連結下,可執行檔案當中會新增兩段,即dynamic段以及GOT(Global offset table)段,這兩段內容就是是我們之前所說的必要資訊。

clipboard.png

dynamic段中儲存了可執行檔案依賴哪些動態庫,動態連結符號表的位置以及重定位表的位置等資訊。關於dynamic以及GOT段的作用限於篇幅就不重點闡述了。如果你對GOT段的具體作用很好奇的話,歡迎關注微信公共賬號,碼農的荒島求生。當載入可執行檔案時,作業系統根據dynamic段中的資訊即可找到使用的動態庫,從而完成動態連結。

這裡需要強調一點,在編譯連結過程中,可以同時使用動態庫以及靜態庫。這兩種庫的使用並不衝突,那麼在這種情況下生成的可執行檔案中,可執行檔案中包含了靜態庫的資料和程式碼,以及動態庫的必要資訊。

至此,關於靜態庫,靜態連結,動態庫,動態連結就講述到這,那麼接下來的問題就是靜態庫和動態庫都有什麼樣的優缺點。

動態庫vs靜態庫

在計算機的歷史當中,最開始程式只能靜態連結,但是人們很快發現,靜態連結生成的可執行檔案存在磁碟空間浪費問題,因為對於每個程式都需要依賴的libc庫,在靜態連結下每個可執行檔案當中都有一份libc程式碼和資料的拷貝,為解決該問題才提出動態庫。

在前幾節我們知道,動態連結下可執行檔案當中僅僅保留動態庫的必要資訊,因此解決了靜態連結下磁碟浪費問題。動態庫的強大之處不僅僅於此,我們知道對於現代計算機系統,比如PC,通常會執行成百上千個程式(程序),且程式只有被載入到記憶體中才可以使用,如果使用靜態連結那麼在記憶體中就會有成百上千份同樣的libc程式碼,這對於寶貴的記憶體資源同樣是極大的浪費,而使用動態連結,記憶體中只需要有一份libc程式碼,所有的程式(程序)共享這一份程式碼,因此極大的節省了記憶體資源,這也是為什麼動態庫又叫共享庫。

動態庫還有另外一個強大之處,那就是如果我們修改了動態庫的程式碼,我們只需要重新編譯動態庫就可以了而無需重新新編譯我們自己的程式,因為可執行檔案當中僅僅保留了動態庫的必要資訊,重新編譯動態庫後這些必要都資訊是不會改變的(只要不修改動態庫的名字和動態庫匯出的供可執行檔案使用的函式),編譯好新的動態庫後只需要簡單的替換原有動態庫,下一次執行程式時就可以使用新的動態庫了,因此動態庫的這種特性極大的方便了程序升級和bug修復。我們平時使用都客戶端程式,比如我們常用QQ,輸入法,播放器,都利用了動態庫的這一優點,原因就在於方便升級以bug修復,只需要更新相應的動態庫就可以了。

動態庫的優點不止於此,我們知道動態連結可以出現在執行時(run-time dynamic link),動態連結的這種特性可以用於擴充套件程式能力,那麼如何擴充套件呢?你肯定聽說過一樣神器,沒錯,就是外掛。你有沒有想過外掛是怎麼實現的?實現外掛時,我們只需要實現幾個規定好的幾個函式,我們的外掛就可以運行了,可這是怎麼做到的呢,答案就在於執行時動態連結,可以將外掛以動態的都方式實現。我們知道使用執行時動態連結無需在編譯連結期間告訴連結器所使用的動態庫資訊,可執行檔案對此一無所知,只有當執行時才知道使用什麼動態庫,以及使用了動態庫中哪些函式,但是在編譯連結可執行檔案時又怎麼知道外掛中定義了哪些函式呢,因此所有的外掛實現函式必須都有一個統一的格式,程式在執行時需要載入所有外掛(動態庫),然後呼叫所有外掛的入口函式(統一的格式),這樣我們寫的外掛就可以被執行起來了。

動態庫都強大優勢還體現在多語言程式設計上。我們知道使用Python可以快速進行開發,但Python的效能無法同C/C++相比(因為Python是解釋型語言,至於什麼是解釋型語言我會在後面碼農的荒島求生系列文章當中給大家詳細講解),有沒有辦法可以兼具Python的快速開發能力以及C/C++的高效能呢,答案是可以的,我們可以將C/C++程式碼編譯連結成動態庫,這樣python就可以直接呼叫動態庫中的函數了。不但Python,Perl以及Java等都可以通過動態庫的形式呼叫C/C++程式碼。動態庫的使用使得同一個專案不同語言混合程式設計成為可能,而且動態庫的使用更大限度的實現了程式碼複用。

瞭解了動態庫的這麼多優點,那麼動態庫就沒有缺點嗎,當然是有的。

首先由於動態庫是程式載入時或執行是才進行連結的,因此同靜態連結相比,使用動態連結的程式在效能上要稍弱於靜態連結,這時因為對於載入時動態連結,這無疑會減慢程式都啟動速度,而對於執行時連結,當首次呼叫到動態庫的函式時,程式會被暫停,當連結過程結束後才可以繼續進行。且動態庫中的程式碼是地址無關程式碼(Position-Idependent Code,PIC),之所以動態庫中的程式碼是地址無關程式碼是因為動態庫又被成為共享庫,所有的程式都可以呼叫動態庫中的程式碼,因此在使用動態庫中的程式碼時程式要多做一些工作,這裡我們不再具體展開講解到底程式多做了哪些工作,對此感興趣當同學可以參考CSAPP(深入理解計算機系統)。這裡我們說動態連結的程式效能相比靜態連結稍弱,但是這裡的效能損失是微乎其微的,同動態庫可以帶來的好處相比,我們可以完全忽略這裡的效能損失,同學們可以放心的使用動態庫。

動態庫的一個優點其實也是它的缺點,即動態連結下的可執行檔案不可以被獨立執行(這裡討論的是載入時動態連結,load-time dynamic link),換句話說就是,如果沒有提供所依賴的動態庫或者所提供的動態庫版本和可執行檔案所依賴的不相容,程式是無法啟動的。動態庫的依賴問題會給程式的安裝部署帶來麻煩,在Linux環境下尤其嚴重,以筆者曾參與開發維護的一個虛擬桌面系統為例,我們在開發過程中依賴的一些比較有名的第三方庫預設不會隨著安裝包釋出,這就會導致使用者在較低版本Linux中安裝時經常會出現程式無法啟動的問題,原因就在於我們編譯連結使用都動態庫和使用者Linux系統中都動態庫不相容。解決這個問題的方法通常有兩種,一個是使用者升級系統中都動態庫,另一個是我們講需要都第三方庫隨安裝包一起釋出,當然這是在取得許可的情況下。

在瞭解了動態庫的優缺點後,接下來我們來看一下靜態庫。

靜態連結是最古老也是最簡單的連結技術。靜態連結都最大優點就是使用簡單,編譯好的可執行檔案是完備的,即靜態連結下的可執行檔案不需要依賴任何其它的庫,因為靜態連結下,連結器將所有依賴的程式碼和資料都寫入到了最終的可執行檔案當中,這就消除了動態連結下的庫依賴問題,沒有了庫都依賴問題就意味著程式都安裝部署都得到了極大都簡化。請大家不要小看這一點,這對當今那些擁有海量使用者的後端系統來說至關重要,比如類似微信這種量級的系統,其後端會部署在成千上萬臺機器上,這麼多的機器其系統的安裝部署以及升級會給運維帶來極大挑戰,而靜態連結下的可執行檔案由於不依賴任何庫,因為部署非常方便,僅僅用一個新的可執行檔案進行覆蓋就可以了,因此極大的簡化了系統部署以及升級。筆者之前所在的某電商廣告後端系統就完全使用靜態連結來簡化部署升級。

而靜態庫的缺點相信大家都已經清楚了,那就是靜態連結會導致可執行檔案過大,且多個程式靜態連結同一個靜態庫的話會導致磁碟浪費的問題。

到這裡關於靜態庫和動態庫的討論就告一段落了,相信大家對於這兩種連結型別都有了清晰都認知。接下來讓我們稍作休息,開始連結器的下一個重要功能,重定位。