鏈接器-初探

一. 首先先回顧一下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); 

/ *這是一個函數的定義,但是因爲它被標記爲
 *靜態,所以只能單獨在此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(y_global_uninit + y_local_uninit); 
}

二. C編譯器的作用

C編譯器的工作就是將C文件從人類可以理解的文本轉換成計算機可以理解的文本。編譯器輸出的文件成爲目標文件,這些文件通常以.o爲後綴(windows爲.obj).

對象文件的內容本質上分爲兩種:
1)code:對應C文件裏的函數定義
2)data: 對應C文件裏的全局變量定義(對於已經初始化的全局變量,它的值也會被存儲在目標文件中)

無論代碼是代表一個變量或函數,只有在編譯器以前曾見過該變量或函數的聲明才被允許。該聲明是一個承諾,承諾它的定義存在在整個程序的某個地方。而鏈接器的工作就是兌現這些承諾。

但是在編譯器生成目標文件的時候如何處理這些承諾呢?編譯器會在這些地方留下“空白”。這些空白(引用)具有與之關聯的名稱,但是還不知道名稱對應的值。

在這裏插入圖片描述

解剖對象文件

#nm test.o

fn_a | | U | NOTYPE | | | * UND * 
z_global | | U | NOTYPE | | | * UND * 
fn_b | 00000000 | t | FUNC | 00000009 | | .text 
x_global_init | 00000000 | D | 對象| 00000004 | | .data 
y_global_uninit | 00000000 | b | 對象| 00000004 | | .bss 
x_global_uninit | 00000004 | C | 對象| 00000004 | | * COM * 
y_global_init | 00000004 | d | 對象| 00000004 | | .data 
fn_c | 00000009 | T | FUNC | 00000055 | | .text
  • U代表未定義的引用,即前面的"fn_a"和"z_global"
  • t或T指示代碼段的定義位置,t表示靜態(static) T標識不是靜態
  • d或D表示已初始化的全局變量,局部(d)非局部(D)
  • b(static)/B(non static)/C(non static)邊上未初始化的全局變量

三.鏈接器的作用:第1部分

前面我們提到過,函數或變量的聲明是對C編譯器的承諾,承諾程序中的其他地方有該函數或變量的定義,鏈接程序的工作就是兌現該承諾。

爲了說明這一點,讓我們在添加一個的C文件:

/* 初始化的全局變量 */
int z_global = 11;
/*第二個全局名爲y_global_init,但它們都是靜態的 */
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);
}

在這裏插入圖片描述
通過這兩個圖,我們可以看到所有的點都可以連接在一起(如果不能連接,則鏈接器將發出錯誤消息)。
在這裏插入圖片描述

至於目標文件,我們可以nm用來檢查生成的可執行文件:

Name                  Value   Class        Type         Size     Line  Section
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
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
y_global_uninit     |080495cc|   b  |            OBJECT|00000004|     |.bss
x_global_uninit     |080495d0|   B  |            OBJECT|00000004|     |.bss

這具有來自兩個對象的所有符號,並且所有未定義的引用均已消失。這些符號也都進行了重新排序,以便將相似類型的事物放在一起

並且添加了一些附加功能,以幫助操作系統將整個事物作爲可執行程序處理。(過濾了無關信息)

重複符號

如果鏈接時一個符號有兩個定義怎麼辦?

在C++中情況比較簡單,鏈接時,一個符號必須只有有一個準確的定義。發生上面這種情況是會編譯不通過。

對於C情況沒那麼簡單,雖然任何函數或已初始化的全局變量都必須有一個準確的定義,但未初始化的全局變量的定義可以視爲臨時定義。然後,C允許不同的源文件對同一對象進行暫定定義。

可以使用-fno-common編譯器選項強制其將未初始化的變量放入BSS段中,而不是生成這些公共塊。

操作系統的作用

現在,鏈接器已經生成了一個可執行程序,其中所有對符號的引用都與這些符號的適當定義結合在一起,我們需要暫時停頓一下以瞭解操作系統在運行程序時的作用。

運行程序需要執行機器代碼,因此操作系統顯然必須將機器代碼從硬盤上的可執行文件傳輸到計算機的內存中,CPU可以在其中進行獲取。程序存儲器的這一塊稱爲代碼段或文本段。沒有數據,代碼也沒有意義,因此所有全局變量也需要在計算機的內存中有一定的空間。

但是,已初始化和未初始化的全局變量之間存在差異。初始化變量具有開始時需要使用的特定值,並且這些值存儲在目標文件和可執行文件中。程序啓動時,操作系統會將這些值複製到數據段中的程序內存中。
對於未初始化的變量,操作系統可以假定它們都以初始值0開頭,因此無需複製任何值。初始化爲0的內存塊稱爲bss segment。

這意味着可以將空間保存在磁盤上的可執行文件中。初始化變量的初始值必須存儲在文件中,但是對於未初始化變量,我們只需要計算它們需要多少空間即可。
在這裏插入圖片描述
到目前爲止,關於目標文件和鏈接器的所有討論都只討論了全局變量。沒有提及前面提到的局部變量和動態分配的內存。

因爲這些數據不需要鏈接程序的參與,因爲它們的生存期僅在程序運行時纔會發生。但是出於完整性考慮,我們可以在此快速過一下:
1)局部變量分配在一塊稱爲棧的內存上,隨着調用和完成不同的函數,內存 會不斷增加和縮小
2)動態分配的內存是從稱爲“堆”的區域中獲取的 ,malloc函數跟蹤該區域中所有可用空間的位置。
因爲堆棧朝一個方向增長而堆朝另一個方向增長。這樣,僅當程序在中間相遇時程序纔會用完內存(此時內存空間實際上將已滿)。
在這裏插入圖片描述

四.連接器的作用:第2部分

(本節完全跳過了鏈接器的主要功能:重定位。具體介紹看:)
(如果存在同名的動態庫和靜態庫,優先鏈接動態庫)

靜態庫

在UNIX系統上,用於生成靜態庫的命令通常爲ar,並且它生成的庫文件通常具有.a擴展名。這些庫文件通常也以“lib” 作爲前綴,並通過“-l”選項傳遞給鏈接器,後跟庫的名稱,不帶前綴或擴展名(因此,“-lfred”將選擇“ libfred.a”)

當鏈接器遍歷要連接在一起的對象文件集合時,它會建立一個尚未解析的符號列表。完成所有明確指定的對象後,鏈接器在庫中查找在此未解析列表上保留的符號。如果未解析的符號在某個模板文件中被定義,則將該對象添加進去,就像用戶首先在命令行上給了它一樣,然後鏈接繼續。

請注意從庫中提取的內容的粒度:如果需要某些特定符號的定義,則將包含該符號定義的整個對象包括在內。這意味着該過程可以向前走一步,也可以向後退一步。新添加的對象可以解析一個未定義的引用,但是它可能附帶了自己的一整套新的未定義的引用,供鏈接器解析。

一個例子:
假設我們有以下目標文件,以及一個鏈接行 a.,b.,-lx和 -ly。
在這裏插入圖片描述

連接器處理完a.o和b.o之後,將解析b2和a3的引用,剩下x12,y22沒有定義。此時,鏈接器檢查第一個庫libx.a的符號,發現x1.o滿足x12引用。但是這樣做也把x23和y12添加到了未定義引用列表(所以現在列表有y22,x23和y12)。

此時鏈接器仍然在處理libx.a,所以x23也可通過從libx.a引入x2.o來滿足引用。但是這也把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.0。x31將會被插入到未解析引用符號表中,並且鏈接將會失敗。到此階段,鏈接過程已經完成且找不到x31定義(在x3.o中)。

只有需要的目標文件纔會被鏈接到。

動態庫

對於像C標準庫(通常是C libc)這樣的流行靜態庫有一個明顯的缺點,任何可執行文件都擁有一份相同的代碼拷貝。如果每個可執行文件都擁有一份printf和fopen之類的代碼拷貝,會佔用非常多的非必須的磁盤空間。

另一個缺點就是一旦程序鏈接完成,代碼就被永遠固定。如果有人發現並修復了printf中的錯誤,則每個程序必須重新鏈接才能獲得已修復的代碼。

爲了解決這些問題,引入了共享庫(.so/dll)。對於動態庫,普通的命令行鏈接器並不會鏈接所有的點,而是採用一種“我欠你(IOU)”這樣的標籤,並將借條的兌現延遲到程序實際運行的那一刻。

歸結爲:如果鏈接器發現特定符號的定義在共享庫中,則最終的可執行文件中不包含該符號的定義,鏈接器在可執行文件中記錄符號的名稱和它來自哪個共享庫。

當程序運行時,操作系統安排這些剩餘的鏈接位及時完成,以使程序運行。在 main 函數開始之前,有一個小型的鏈接器——通常名爲 ld.so,將負責檢查貼過標籤的內容,並完成鏈接的最後一個步驟:導入庫裏的代碼,並將所有符號都關聯在一起。

這意味着所有的可執行文件都沒有printf的代碼副本。如果有新版本的printf可用,則可以通過重新編譯libc.so插入它。

與靜態庫相比,共享庫的工作方式還要另一個很大的不同,這體現在鏈接的粒度上。如果從特定的共享庫中提取一個符號(例如libc.so的printf),則整個共享庫都將映射到程序的地址空間中。這與靜態庫的行爲有很大的不同,靜態庫中只有包含未定義符號的特定對象纔會被拉入。

換句話說,共享庫本身是由於運行鏈接程序而產生的(而不是像ar那樣形成大量的對象 ),並且解析了同一庫中對象之間的引用。在上面的例子中,當在靜態庫版本中運行時,它將爲單個目標文件生成結果集。但在動態庫版本中,liby.so只有x31一個未定義未定義符號。而且如果b.o也引用y32,情況也不會有什麼改變,因爲y3.o和x3.o的內容反正都已經被拉入了。

更大的粒度的原因是因爲現代操作系統足夠聰明。除了可以節省靜態庫中的重複磁盤空間,使用同一共享庫的不同正在運行的進程也可以共享代碼段(但不能共享data / bss段)。爲了做到這一點,整個共享庫都映射到一個入口,使內部引用都排隊到同一個地方。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章