深入理解計算機系統 --- 鏈接

本章目的: 提供了關於鏈接各方面的全面討論,從傳統靜態鏈接到加載時的共享庫的動態鏈接,以及到運行時的共享庫的動態鏈接

鏈接(linking)是將各種代碼和數據片段收集並組合成一個單一文件的過程

這個文件可被加載(複製)到內存被並執行
鏈接可以執行於編譯時,也就是在源代碼被翻譯成機器代碼時
也可以執行於運行時,也就是應用程序執行

在早期,鏈接是手動執行的,在現代系統中,鏈接是由叫做鏈接器(linker)的程序自動執行的
在這裏插入圖片描述
不過,無論是什麼樣的操作系統、ISA或者目標文件格式、基本鏈接概念是通用的
細節可能不盡相同,但是概念是相同的

7 鏈接

7.1 編譯器驅動程序

在這裏插入圖片描述
大多數編譯系統提供 編譯器驅動程序 , 它代表用戶在需要時調用語言預處理器、編譯器、彙編器和連接器

在shell使用命令:
在這裏插入圖片描述
在這裏插入圖片描述
上圖概括了驅動程序在將示例從ASCLL碼源文件翻譯器成 可執行目標文件時的行爲
在這裏插入圖片描述
如果想看詳細步驟,可以用 –v 選項

它將C的源程序main.c 翻譯成一個ASCLL碼的中間文件 main.i
Cpp main.c main.i

然後,驅動程序運行C編譯器(ccl),它將main.i翻譯成一個ASCLL彙編語言文件main.s
Ccl main.i –Og –o main.s

然後,驅動程序運行彙編器(as),它將main.s翻譯成一個可重定位目標文件main.o
As –o main.o main.s

驅動程序經過相同的過程生產sum.o ,最後,它運行連接器ld,將main.o和sum.o以及一些必要的系統目標文件組合起來,創建一個可執行目標文件 prog
Ld –o prog main.o sum.o

運行prog
./prog
Shell調用操作系統中一個叫做加載器的函數,它將可執行文件prog的代碼和數據複製到內存,然後將控制轉移到這個程序的開頭

在這裏插入圖片描述
上圖展示了 GCC 編譯成不同的中間文件

7.2 靜態鏈接
像Linux LD程序這樣的靜態鏈接器 (static linker) 以一組可重定位目標文件和命令行參數作爲輸入,生成一個完全鏈接的、可以加載和運行的可執行目標文件作爲輸出

輸入的可重定位目標文件由各種不同的代碼和數據節(section)組成
每一節都是一個連續的字節序列,指令在一節中,初始化了的全局變量在另一節中,而未初始化的變量由在另外一節中

爲了構造可執行文件,鏈接器必須完成兩個主要任務:
在這裏插入圖片描述

7.3 目標文件

目標文件有三種形式:
在這裏插入圖片描述

編譯器和彙編器生成可重定位目標文件(包括共享目標文件)
鏈接器生成可執行目標文件

在技術上來說,一個目標模板(Object module)就是一個字節序列,而一個目標文件(Object file)就是一個以文件形式存放在磁盤中的目標模板

目標文件就是按照特定的目標文件格式來組織的,各個系統的目標文件格式都不相同
Windows使用可移植可執行(Protable Executable, PE)格式
現代x86-64 Linux和Unix系統使用可執行可鏈接格式(Executable and Linkable Format, ELF)
但不管哪種格式,基本概念是相似的

7.4 可重定位目標文件

在這裏插入圖片描述
ELF頭(ELF header) 以一個16字節的序列開始,這個序列描述了生產該文件的系統的字的大小和字節順序

ELF頭剩下的部分包括幫助連接器語法分析和解釋目標文件的信息
其中包括ELF頭的大小、目標文件的類型、機器類型、節頭部表中條目的大小和數量
不同節的位置和大小是由節頭部表描述的,其中目標文件中每個節都有一個固定大小的條目

包含在EFL頭和節頭部表之間的都是節,一個典型的ELF可重定位目標文件包含下面幾個節:
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

7.5 符號和符號表

每個可重定位目標模板m都有一個符號表,它包含m定義和引用的符號的信息,在連接器的上下文中,有三種不同的符號:
在這裏插入圖片描述
本地鏈接器符號和本地程序變量不同的, .symtab中的符號表不包含對應於本地非靜態程序變量的任何符號,這些符號在運行時在棧中被管理

定義帶有C static屬性的本地過程變量是不在棧中被管理的,編譯器在.data 或 .bss 中爲每個定義分配空間,並在符號表中創建一個唯一名字的本地鏈接符號

符號表是由編譯器構造的,使用編譯器輸出到彙編語言 .s 文件中的符號, .symtab節中包含ELF符號表,這張符號表包含一個條目的數據
在這裏插入圖片描述
在這裏插入圖片描述
每個符號都被分配到目標文件的某個節,由section字段表示,該字段也是到一個節頭部表的索引

有三個特殊的僞節(pseudosection),它們在節頭部表中是沒有條目的:
ABS 代表不被重定位的符號
UNDEF 代表未定義的符號,也就是在本目標模板中引用
COMMON 表示還未被分配位置的未初始化的數據目標
對於COMMON符號,value字段給出對齊要求,而size給出最小的大小

注意,只有可重定位目標文件纔有這些僞節,可執行目標文件中是沒有的

7.6 符號解析

鏈接器解析符號引用的方法是將每個引用與它輸入的可重定位目標文件的符號表中的一個確定的符號定義關聯起來

對於那些和引用定義在相同模板中的局部符號的引用,符號解析式是非常簡單明瞭的
編譯器只允許每個模板中每個局部符號有一個定義,靜態變量也會有本地鏈接器符號
編譯器還要確保他們擁有唯一的名字

對於全局變量的引用解析就棘手得多,當編譯器遇到一個不是在當前模板中定義的符號(變量或函數名)時,會假設該符號是在其他某個模塊中定義的,生成一個鏈接器符號表條目,並把它交給鏈接器處理
如果鏈接器在他任何輸入模塊中找不到這個被引用符號的定義,就輸出一條錯誤信息
在這裏插入圖片描述

7.6.1 鏈接器如何解析多重定義的全局符號

鏈接器的輸入時一組可重定位目標模塊
每個模塊定義一組符號,有些是局部(只對定義該符號的模塊可見)
有些是全局(對其他模塊也可見)

如果多個模塊定義同名的全局符號,下面是Linux編譯系統採用的方法:
在編譯時,編譯器向彙編器輸出每個全局符號,或者是強(strong),或者是弱(weak)
而彙編器把這個信息隱含在可重定位目標文件的符號表裏
函數和已初始化的全局變量是強符號
未初始化的全局變量是弱符號
在這裏插入圖片描述
假設試圖編譯和鏈接下面兩個C模塊:

在這裏插入圖片描述在這裏插入圖片描述
在這裏插入圖片描述

7.6.2 與靜態庫鏈接

迄今爲止,我們都是假設鏈接器讀取一組可重定位目標文件,並把它們鏈接起來,形成一個輸出的可執行文件
實際上,所有的編譯系統都提供一種機制,將所有相關的目標模塊打包成爲一個單獨的文件,稱爲 靜態庫(static library),它可以用做鏈接器的輸入
當鏈接器構造一個輸出的可執行文件時,它只複製靜態庫裏被引用程序引用的模塊

在Linux系統中,靜態庫以一種稱爲**存檔(archive)**的特殊文件格式存放在磁盤中
存檔文件是一組連接起來的可重定位目標文件的集合,有一個頭部用來描述每個成員目標文件的大小和位置,存檔文件由後綴 .a 標識

爲了對庫的討論更加形象,參考下面例程:

在這裏插入圖片描述
要創建這些函數的一個靜態庫,使用ar工具:
在這裏插入圖片描述
爲了使用這個庫,我們可以編寫一個應用程序:
在這裏插入圖片描述
在這裏插入圖片描述
//vector.h 是自己創建的一個頭文件,裏邊包括那兩個函數的聲明
-static 參數告訴編譯器驅動程序,鏈接器應該構建一個完全鏈接的可執行目標文件
它可以加載到內存並運行,在加載時無須更進一步的鏈接
-lvector 參數是 libvector.a 的縮寫, -L. 參數告訴編譯器在當前目錄下查找libvector.a

在這裏插入圖片描述
上圖概括了鏈接器的行爲
當鏈接器運行時,它判定main2.o引用了addvec.o定義的addvec符號,所以複製addcec.o到可執行文件,因爲程序不引用任何由 multvec.o 定義的符號,所以鏈接器就不會複製這個模塊到可執行文件,鏈接器還會複製liba.a 中的printf.o 模塊,以及許多C運行時系統中的其他模塊

7.6.3 鏈接器如何使用靜態庫來解析引用

Linux 鏈接器使用它們解析外部引用的方式
在符號解析階段,鏈接器從左到右按照它們在編譯器驅動程序命令行上出現的順序來掃描可重定位目標文件和存檔文件
(驅動程序自動將命令行中所有的.c文件翻譯成.o文件)在這次掃描中,鏈接器維護一個可重定位目標文件的集合E(在這個集合中的文件會被合併起來形成可執行文件)
一個未解析的符號(即引用了但是尚未定義的符號)集合U,以及一個在前面輸入文件中已
定義的符號集合D,初始時,E、U、D均爲空
在這裏插入圖片描述
在這裏插入圖片描述

7.7 重定位

一旦鏈接器完成了符號解析這一步,就把代碼中的每個符號引用和正好一個符號定義
(即它的一個輸入目標模塊中的一個符號表條目)關聯起來
此時,鏈接器就知道它的輸入目標模塊中的代碼節和數據節的確切大小
現在就可以開始重定位步驟了,在這個步驟中,將合併輸入模塊,併爲每個符號分配運行時地址,步驟:
在這裏插入圖片描述在這裏插入圖片描述

7.7.1 重定位條目

當彙編器生成一個目標模塊時,它並不知道數據和代碼最終將要放在內存的什麼位置
也不知道這個模塊引用的任何外部定義的函數或者全局變量的位置

所以,無論何時彙編器遇到對最終位置未知的目標引用,它就會生產一個重定位條目,告訴鏈接器在將目標文件合併成可執行文件時如何修改這個引用

代碼重定位條目放在 .rel.text中,已初始化數據的重定位條目放在 .rel.text中

在這裏插入圖片描述
上圖展示了ELF重定位條目的格式

Offset 是需要被修改的引用的節偏移
Symbol 標識被修改引用應該指向的符號
Type 告訴鏈接器如何修改新的引用
Addend 是一個有符號常數,一些類型的重定位要被使用它對被修改引用的值做偏移調整

在這裏插入圖片描述
這兩種重定位類型支持x86-64小型代碼模型(small code model),該模型假設可執行目標文件中的代碼和數據總體大小小於2GB,因此在運行時可以用32位PC相對地址來訪問
GCC默認使用小型代碼模型,大於2GB的程序可以用 –mcmodel = medium(中型代碼模型)
和 –mcmodel = large(大型代碼模型)標誌來編譯

7.7.2 重定位符號引用

在這裏插入圖片描述
上圖展示了鏈接器的重定位算法的僞代碼

1,2行在每個節s以及與每個節關聯起來的重定位條目r上迭代執行
假設每個節s是一個字節數組,每個重定位條目r是一個類型爲ELF64_Rela的結構(圖7-9)
還假設當算法運行時,鏈接器已經爲每個節 (ADDR(S)表示) 和每個符號都選擇了運行時地址 (ADDR(r.symbol表示)

3行計算的是需要被重定位的4字節引用的數組s中的地址
如果這個引用是PC相對尋址,那就5-9行來重定位
如果該引用使用的是絕對尋址,那就通過11-13行來重定位

以本章最開始的實例程序看下鏈接器如何用這個算法重定位程序的引用
用objdump –d –x main.o 產生main.o 的反彙編代碼
在這裏插入圖片描述
Main引用了兩個全局符號: array和sum,爲每個引用,彙編器產生一個重定位條目
顯示在引用的後面一行

這些重定位條目告訴鏈接器對sum的引用要使用32位PC相對地址進行重定位,而對array的引用要使用32位絕對地址進行重定位

1.重定位PC相對引用
上圖第6行中,函數main調用sum函數,sum函數是在模塊sum.o中定義的
CALL指令開始於 節偏移0xe 的地方,包括1字節的操作碼 0xe8
後面跟着是對目標sum的32位PC相對引用的佔位符
相應的重定位條目r由4個字段組成:
r.offset = 0xf
r.symbol = sum
r.type = R_x86-64_PC32
r.addend = -4
這些字段告訴鏈接器修改開始於偏移量 0xf 處的32位PC相對引用,這樣在運行時它會指向sum例程

假設鏈接器已經確定
ADDR(s) = ADDR(.text) = 0x4004e8

ADDR(r.symbol) = ADDR(sum) = 0x4004e8
使用圖7-10中的算法,連接器首先計算出引用的運行時地址(7行):
Refaddr = ADDR(s) + r.offset = 0x4004d0 + 0xf = 0x4004df
然後更新該引用,使得它在運行時只想sum程序(8行):
*refptr = (unsigned)(ADDR(r.symbol) + r.addend – refaddr)
= (unsigned)(0x4004e8 + (-4) – 0x4004df)
= (unsigned)(0x5)
在得到的可執行目標文件中,call指令有如下重定位的形式:
4004de: e8 05 00 00 00 callq 4004e8
在這裏插入圖片描述

2.重定位絕對引用
相對簡單,如圖7-11的第4行,mov指令將array的地址(一個32位立即數值)複製到寄存器%edi中,mov指令開始於節偏移量0x9的位置,包括1字節操作碼0xbf,後面緊跟着對array的32位絕對地址引用的佔位符
在這裏插入圖片描述
這些字段告訴鏈接器要修改從偏移量0xa開始的絕對引用,這樣在運行時它將會指向array的第一個字節,假設鏈接器已經確定
ADDR(r.symbol) = ADDR(array) = 0x601018
在這裏插入圖片描述
在這裏插入圖片描述

7.8 可執行目標文件

我們的示例C程序,開始時是一組ASCLL文本文件,現在已經被轉化爲一個二進制文件,且這個二進制文件包含加載程序到內存並運行它所需的所有信息
在這裏插入圖片描述
上圖概括了一個典型的ELF可執行文件中的各類信息
可執行目標文件格式類似於可重定位目標文件格式,ELF頭描述文件的總體格式
它還包括程序的入口點(entry point),也就是當程序運行時要執行的第一條指令的地址

.text .rodata .data 節與重定位目標文件中的節是相似的,除了這些節已經被重定位到它們最終的運行內存地址以外

.init 節定義了一個小函數,叫做 _init ,程序的初始化代碼會調用它
因爲可執行文件是完全鏈接的(已被重定位), 所以它不再需要 .rel 節

ELF可執行文件被設計得很容易加載到內存,可執行文件的連續的片(chunk)被映射到連續的內存段
程序頭部表(program header table)描述了這種映射關係
在這裏插入圖片描述
上圖爲可執行文件prog的程序頭部表,本章開始的示例程序 (OBJDUMP顯示)

從程序頭部,我們會看到根據可執行目標文件的內容初始化兩個內存段,第1,2行
第2行告訴我們第一個段(代碼段)有 讀/執行訪問權限,開始於內存0x400000處,總共內存大小是0x69c字節,並且被初始化爲可執行目標文件的頭0x69c個字節,其中包括ELF頭、程序頭部表以及 .init .text .rodata節

在這裏插入圖片描述

7.9 加載可執行目標文件
要運行可執行目標文件prog,可以在Linux Shell 的命令行輸入:
./prog

因爲prog不是內置Linux命令,所以會被認爲是一個可執行文件
通過調用某個駐留在存儲器中稱爲加載器(loader)的操作系統代碼來運行它
任何Linux程序都可以通過調用 Execve 函數來調用加載器
加載器將可執行目標文件中的代碼和數據從磁盤複製到內存中
然後通過跳轉到程序的第一條指令或入口點來運行該程序,將程序複製到內存並運行的過程叫做加載
在這裏插入圖片描述
每個Linux程序都有一個運行時內存映像,類似上圖
在Linux x86-64系統中,代碼段總是從地址0x400000處開始,後面是數據段

運行時堆在數據段之後,通過調用malloc庫往上增長
堆後面的區域是爲共享模塊保留的
用戶棧總是從最大的合法用戶地址開始,向較小內存地址增長
棧上的區域,從地址2^48開始,是爲內核中代碼和數據保留的,所謂內核就是操作系統駐留在內存的部分

當加載器運行時,它創建類似於上圖所示的內存映像,在程序頭部表的引導下,加載器將可執行文件的片(chunk)複製到代碼段和數據段
接下來跳轉到程序的入口點,也就是 _start 函數的地址,這個函數是在系統目標文件 ctrl.o中定義的,對所有C程序都是一樣的
_start函數調用系統啓動函數 __libc_start_main,由該函數定義在libc.so中
它初始化執行環境,調用用戶層的main函數,處理main函數的返回值,並且在需要的時候把控制返回給內核

在這裏插入圖片描述

7.10 動態鏈接共享庫

靜態庫和所有的軟件都一樣,需要定期維護和更新
如果應用程序員想要使用一個庫的最新版本,必須以某種方式瞭解到該庫的更新情況
然後顯式地將他們的程序與更新了的庫重新鏈接

幾乎每個C程序都使用標準I/O函數,比如printf 和 scanf
在運行時,這些函數的代碼會被複制到每個運行進程的文本段中
在一個運行上百個進程的典型系統上,這將是對稀缺的內存系統資源極大浪費

共享庫( shared library )是致力於解決靜態庫缺陷的一個現代創新產物
共享庫是一個目標模板,在運行或加載時,可以加載到任意的內存地址,並和一個在內存中的程序鏈接起來,這個過程稱爲動態鏈接( dynamic linking ),是由一個叫做動態鏈接器的程序來執行的

共享庫也稱爲共享目標( shared object ),在Linux系統中常用 .so 後綴來表示
Windows中,他們稱爲DLL(動態鏈接庫)

共享庫是以兩種不同的方式來“共享”的
首先,在任何給定的文件系統中,對於一個庫只有一個 .so 文件
所有引用該庫的可執行目標文件共享這個 .so 文件中的代碼和數據
而不是像靜態庫的內容那樣被複制和嵌入到引用他們的可執行文件中

其次,在內存中,一個共享庫的.text節的一個副本可以被不同的正在運行的進程共享
在這裏插入圖片描述
示例:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
-fpic 選項指示編譯器生成與位置無關的代碼
-shared 選項指示鏈接器創建一個共享的目標文件

一旦創建了這個庫,隨後就要將它鏈接到示例程序中,於上圖shell程序中第二行

這樣就創建了一個可執行目標文件 prog ,而此文件形式使得它在運行時可以和libdemo.so鏈接
基本思路就是當創建可執行文件時,靜態執行一些鏈接,然後再程序加載時,動態完成鏈接過程
此時,沒有任何libdemo.so的代碼和數據節被複制到可執行文件prog中
反之,鏈接器複製了一些重定位和符號表信息,它們使得運行可執行文件prog時,可以解析對libdemo.so中代碼和數據的引用

當加載器加載和運行可執行文件prog時,加載部分鏈接的可執行文件prog
接着,它注意到prog包含一個 .interp節,這一節包含動態鏈接器的路徑名
動態鏈接器本身就是一個共享目標,加載器不會像它通常所做地那樣將控制傳遞給應用
而是加載和運行這個動態鏈接器,然後動態鏈接器通過執行下面這些重定位完成鏈接任務:
在這裏插入圖片描述

7.11 從應用程序中加載和鏈接共享庫

應用程序還可能在它運行時要求動態鏈接器加載和鏈接某個共享庫
而無需在編譯時將那些庫鏈接到應用中
在這裏插入圖片描述
Linux系統爲動態鏈接器提供了一個簡單的接口,允許應用程序在運行時加載和鏈接共享庫:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
示例:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

7.12 位置無關代碼

在這裏插入圖片描述
1.PIC數據引用
編譯器通過運用以下這個有趣的事實來生成對全局變量的PIC引用:
無論我們在內存中的何處加載一個目標模板,數據段與代碼段的距離總是保持不變
因此,數據段中任何指令和數據段中任何變量之間的距離都是一個運行時常量,與代碼段和數據段的絕對內存位置是無關的

想要生成對全局變量PIC引用的編譯器利用了這個事實,它在數據段開始的地方創建了一個表,叫做 全局偏移表(Global Offset Tab, GOT)
在GOT中,每個被這個目標模塊引用的全局數據目標都有一個8字節條目
編譯器還爲每個條目生成一個重定位記錄,在加載時動態鏈接器會重定位GOT中dem
在加載時,動態鏈接器會重定位GOT中的每個條目,使它包含目標的正確的絕對地址
在這裏插入圖片描述
上圖展示的是 編譯於 7.6.2小節的代碼
這裏的關鍵思想是對GOT[3]的PC相對引用中的偏移量是一個運行時變量

因爲addcnt是由libvector.so模塊定義的,編譯器可以利用代碼段和數據段之間不變的距離,產生對addcnt的直接PC相對引用,並增加一個重定位,讓鏈接器在構造這個共享模塊時解析它

2.PIC函數調用
在這裏插入圖片描述
使用延遲綁定的動機是對於一個像libc.so這樣的共享庫輸出的成百上千函數中
一個典型的引用程序只會使用其中很少的一部分
把函數地址的解析推遲到它實際被調用的地方,能避免動態鏈接器在加載時進行成百上千個其實並不需要的重定位,第一次調用過程的開銷很大,之後的每次調用都只會花費一條指令和一個間接的內存引用

延遲綁定是通過兩個數據結構之間簡潔但又有些複雜的交互實現的
GOT和過程鏈接表(PLT)
如果有一個目標模塊調用定義在共享庫中的任何函數,那麼它就有自己的GOT和PLT
GOT是數據段的一部分,PLT是代碼段的一部分
在這裏插入圖片描述
上圖展示了PLT和GOT如何協作在運行時解析函數的地址
在這裏插入圖片描述

7.13 庫打樁機制

庫打樁,它允許你截獲對共享庫函數的調用,取而代之執行自己的代碼
在這裏插入圖片描述
打樁可以發生在編譯時、鏈接時、當前程序被加載和執行的運行時

7.13.1 編譯時打樁

在這裏插入圖片描述
上圖展示瞭如何使用C預處理器在編譯時打樁

使用下面這樣編譯和鏈接這個程序:
在這裏插入圖片描述
由於使用 -I 參數,所以會進行打樁,他告訴C預處理器在搜索通常的系統目錄之前,先在當前目錄中查找malloc.h,注意mymalloc.c中的包裝函數是使用標準 malloc.h頭文件編譯的

運行結果:
在這裏插入圖片描述

7.13.2 鏈接時打樁

在這裏插入圖片描述
Linux靜態鏈接器支持用 –wrap f標誌進行鏈接時打樁
這個標誌告訴編譯器 把對符號f的引用解析成 __wrap_f
還要把符號 __real_f的引用解析成 f
在這裏插入圖片描述
在這裏插入圖片描述
-Wl, option標誌把 option 傳遞給鏈接器
Option中的每個逗號都要替換爲一個空格
–wrap,malloc 就把 –wrap malloc 傳遞給鏈接器,以類似的方式傳遞
-Wl,–wrap,free

結果:
在這裏插入圖片描述

7.13.3 運行時打樁

運行時打樁,它只需要能夠訪問可執行目標文件
這個很厲害的機制基於動態鏈接器的LD_PRELOAD環境變量
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

7.14 處理目標文件工具

在這裏插入圖片描述

小結

在這裏插入圖片描述
在這裏插入圖片描述

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