深入理解計算機系統----第七章連結
原文連結 https://www.jianshu.com/p/7f27c0316355
目 錄
連結是將各種不同檔案的程式碼和資料部分收集(符號解析和重定位)起來並組合成一個單一檔案的過程。本章節我們將要學習連結器工作的詳細原理。通過對這一方面知識的學習,將有助於理解一些危險的程式設計錯誤、分離編譯的過程、作用域的實現以及如何利用共享庫等等。我們將靜態連結和動態連結(載入時共享、執行時共享)兩個大的方向講起。廢話不多說,開始飆車了。
1.1 編譯驅動程式如何工作?
在我的raspberrypi上我建立了兩個c程式原始檔:main.c和swap.c
檔案目錄
檔案內容
main.c和swap.c
我們通過gcc驅動程式:
gcc驅動程式
在第一章我們解釋過編譯驅動程式所完成的工作,如下圖:
gcc完成的工作
先是由前處理器(cpp)將main.c翻譯成中間檔案:main.i,接下來是編譯器(cc1)將main.i翻譯成彙編檔案main.s。然後是彙編器(as)將main.s翻譯成一個可重定位的目標檔案main.o。最後由連結器(ld)將main.o和swap.o以及一些系統目標檔案組合起來,建立可執行目標檔案p
靜態連結
在以上的這個過程中ld連結器的主要工作:
① 符號解析。目標檔案定義和引用符號,符號解析的目的是將每個符號引用和一個符號定義聯絡起來;
②重定位:把每個符號定義與一個儲存器位置聯絡起來,然後修改對這些符號的引用,是的他們指向這個儲存器位置,從而實現重定位。
為了理解這一過程,我們需要補充一些基礎知識。
1.2 連結器操作的目標檔案究竟是什麼?
目標檔案一般是由彙編器生成的.o字尾的檔案,大概有三種不同的形式:可重定位目標檔案;可執行目標檔案和共享目標檔案。我們接下來討論的目標檔案是基於Unix系統的ELF格式(Exxcutable and Linkable Format),這同Windows系統上的PE(Portable Executable)檔案格式在基本概念上其實是相似的:
①一個典型的ELF可重定位目標檔案的格式:
一個典型的ELF格式可重定位目標檔案
解釋:
.text:已編譯程式的機器碼;.rodata:只讀資料(read-only-data);
.data:已初始化的全域性C變數;.bss:未初始化的全域性C變數(better save space);
.symtab:一個符號表(定義和引用的函式和全域性變數資訊);
.rel.text:程式碼重定位條目, 一個.text節中位置的列表,需要修改的位置;
.rel.data: 被模組引用或定義的任何全域性變數的重定位資訊;
.debug:一個除錯符號表; .line:原始C源程式中的行號和.text機器指令的對映;
.strtab: 一個字串表
② 符號和符號表(連結器的第一個任務符號解析)
保存於.symtab中的是一個符號表,其是定義和引用函式和全域性變數的資訊。有三種不同型別的符號:全域性符號(不帶static),外部引用(external)和本地符號。如果是帶有static符號的就會在.data和.bss中為每個定義分配空間,並在.symtab中建立一個唯一名字的本地符號。比如:
中有兩個static定義的x變數,其會在.data中分配空間,並在.symtab中建立兩個,x.1表示f函式的定義和x.2表示函式g的定義。(注:使用static可以保護你自己的變數和函式)
.symtab符號表的資料結構:
我們給出main.o符號表中的最後三個條目:(開始的都是使用的本地符號)
我們看到num8處,的全域性變數buf定義條目,位於.data(Ndx=3)開始位元組偏移為0(value為0)處的8個位元組目標(size)。隨後是全域性符號main的定義,其位於.text(Nex=1)處,偏移位元組為0處(value)的17個位元組函式。最後一個是swap的引用,所以是Und。
1.3 連結器開始工作了
① 符號解析(開始連結器的第一個任務)
符號解析任務簡單的說,就是連結器使得所有模組中的每個符號只有一個定義。連結器在這一個階段的主要任務就是把程式碼中的每個符號引用和確定的一個符號定義聯絡起來。對於本地符號,這個任務相對來說是簡單的。複雜的就是全域性符號,編譯器(cc1)遇到不是在當前模組中定義的符號時,會假設該符號的定義在其他模組中,生成一個連結器符號交給連結器處理。如果連結器ld在所有的模組中都找不到定義的話就會丟擲異常。
這裡最容易產生的錯誤就是當多個模組定義同一個符號的時候,我們的連結器到底怎麼做。以C++中的函式過載為例,我們會按照實際的需要過載許多相同名字的函式,連結器(ld)使用一種叫做毀壞的方法(mangling)將相同函式名不同引數的函式,比如Foo將會編碼成3Foo__的形式,實際上還是使得在連結器層面上來看符號是唯一的。
連結器如何解析多重定義的全域性符號:
使用如下規則
規則1:不允許多個強符號;
規則2:如果有一個強符號和多個弱符號,那麼選擇強符號;
規則3:如果有多個弱符號,那麼這些弱符號中任意選擇一個;
舉個例子:連結器試圖編譯和連結下面兩個模組就會引數錯誤:
規則1:不允許多個強符號(兩處定義了main)
第二個例子:如果模組中有x未被初始化,連結器會選擇定義在另外一個模組中的強符號(這會導致許多不易察覺的錯誤)
會輸出x=15212,規則2,函式f將很低調的將x改成15212,對main帶來不易察覺的意外!特別是當重複定義的符號有不同的型別時,需要特別的謹慎。編譯系統不會發出任何警告,而且會在程式執行很久以後才表現出來。使用GCC-fno-common可以告訴連結器,遇到這類情況,輸入一條警告。
如何連結和解析靜態庫
連結靜態庫:
像printf等一些常用的函式,都是在libc.a靜態庫中,靜態庫以一種存檔的特殊檔案(.a)格式,將可以定位的目標檔案集合成一個.a檔案。舉一個實際的例子:
我的raspberry上建立有這樣的檔案:
檔案目錄
其中:
兩個庫:addvec.o和multvec.o
我們使用vector.h宣告這兩個函式:
vector.h
同時使用:main2.c進行函式的呼叫:
main2.c
我們現在使用AR工具建立一個靜態庫:libvector.a檔案:
建立libvector.a檔案
現在我們使用main2.c函式呼叫libvector.a庫
連結靜態庫libvector.a
這樣一個過程可以用下圖說明:
與靜態庫連結
解析靜態庫:解析靜態庫的過程是按照命令列標識的檔案順序從左到右解析,如果輸入檔案是一個目標檔案(.o),那麼將檔案新增到集合E(合併成執行檔案);如果f是一個存檔檔案(.a),那麼就嘗試解析集合U(未解析的符號),能夠解析的話就將其載入到集合E中去;重複這樣的過程直到都解析完畢。
② 重定位
完成了符號解析以後,連結器的第二個任務就是合併輸入模組,併為每個符號分配執行時的地址。重定位節和符號定義:在這一步中,連結器將所有模組中的.data節合併成一個檔案的.data節,執行時儲存器的地址也會賦給新的聚合節。然後就是,重定位節中的符號引用:連結器修改程式碼節和資料節中對每個符號的引用,使得他們指向正確的執行時地址。這一步要用到重定位條目這一資料結構,我們來描述這個過程:
重定位條目:我們在1.2講述ELF檔案格式的時候說過,.rel.text代表程式碼重定位條目;.rel.data是已經初始化資料的重定位條目。資料結構如下圖:
(注:當彙編器生成一個.o檔案模組的時候,它不知道資料和程式碼最終會放到儲存器的什麼位置,它只是生成一個重定位條目,放到.rel.text中告訴大家這個內容會在以後修改)
說明:
offset:是需要修改的引用節的偏移;
symbol:標識被修改引用應該指向的符號;
type:告訴聯結器如何修改新的引用;
ELF有11種不同的重定位型別:我們只關心常用的兩種
R_386_PC32(相對地址引用)和R_386_32(絕對地址引用)
有了重定位的條目,我們也知道了有兩種不同的重定位型別,我們下面來看看如何進行符號引用的重新定位:
重定位符號引用:
我們先來看看一段重定位演算法的虛擬碼:
假設每個節s是一個位元組陣列,每個重定位條目r是一個Elf32_Rel結構,第三行計算的是需要被重新定位的引用陣列s中的地址。然後就根據r的type型別進行不同的型別的重定位。上圖展示的就是相對地址引用和絕對地址引用兩種模式。
例子1:相對地址引用模式(R_386_PC32)
我們回到最開始講述的main.c和swap.c程式,來看看main.c中的反彙編列表的一個片段:
這裡我們看到call指令開始於位元組偏移0x6處的位置,swap函式在main處偏移0x7處的位置。重定位型別使用的是R_386_PC32模式(相對地址引用)。重定位條目的資料結構如下:
Elf32_Rel結構
這個結構告訴我們,修改偏移量為0x7的相對引用,使得它能指向swap程式的位置。假設:兩處的地址為:
ADDR(s) = ADDR(.text)= 0x80483b4;
ADDR(r.symbol) = ADDR(swap) = 0x80483c8;
使用refaddr演算法計算出引用執行時候的地址為:refaddr = addr(s)+0x7 = 0x80483bb。然後計算出*refptr:
我們使用的是原值是(-4)經過計算後將修改*refptr為0x9;下面我們來看看置頂到call指令時候的地址情況:
我們看到當前地址外80483ba,CPU執行call指令的時候PC的值是下一條指令的地址80483bf,由於是相對地址引用模式,我們使用計算出來的0x9(*refptr)來重定位執行swap函式的位置:
這就是我們之前假設的swap地址的地址。(注:*refptr為什麼初始為-4,因為pc總是指向當前指令的下一條指令,不同的機器可能有不同的偏移量)
例子2:絕對地址引用模式(R_386_32)
我們再來示例程式swap.o中int *bufp0 = &buf[0]的情況。反彙編列表如下:
由於bufp0是一個已經被初始化的資料目標,在ELF檔案結構中位於.data欄位位置,反彙編列出的情況表明其位於偏移0x0處且使用R_386_32絕對地址引用模式。現在我們假設地址已經確定是:addr(r.symbol) = addr(buf) = 0x8049454連結器使用我們前面講過的演算法修改引用:
這樣就使得refptr直接指向了buf的地址(08049454)也就是如下圖所示:
總而言之,連結器絕對在執行時變數bufp0將存放於儲存器0x804945c處,並且初始化為0x8049454即buf地址的內容。
1.4 連結器完成工作後生成的目標檔案是個什麼?
通過前面知識的學習,我們瞭解到連結器主要完成了兩個工作,符號解析和重新定義。將資料和程式碼合併成為一個可執行的檔案,接下來我們看看這個可執行檔案的格式是什麼,以及如何載入到儲存器中開始執行的過程。
① 可執行目標檔案格式(一個典型的ELF可執行檔案)
說明:
ELF頭部:描述檔案總體格式,標註出程式入口點;.init:定義了初始化函式;
段頭部表:可執行檔案是一個連續的片,段頭部表中描述了這種對映關係;
我們在開始的時候使用main.c和swap.c生成了可執行檔案p
我們來看看這個執行檔案的反彙編程式碼:
說明:在段頭部表中,我們會看到程式初始化為兩個儲存器欄位,行1和行2是程式碼段,有讀和執行的許可權(flags:r-x),開始於儲存器地址0x08048000處(vaddr/paddr),該欄位大小為0x448(memsz),並且初始化為可執行目標檔案的頭0x448個位元組(filesz);行3和行4是資料段,有讀寫的許可權(flags),開始於儲存器地址:0x08049448處,總大小0x104個位元組(memsz),從檔案偏移0x448(off)處開始的0xe8(filesz)個位元組初始化。
② 如何載入可執行目標檔案
執行時的儲存器映像
載入後執行的每個Unix程式都有一個映象,如上圖所示。程式碼段總是從0x08048000開始,資料段是接下來的4kb對齊地址處,執行時堆在讀寫段之後,使用malloc向上增長;還有一個段為共享庫保留。使用者棧是在最大合法地址處開始並向下增長。再往上就是不對使用者開放的核心虛擬儲存器了。
什麼是載入?說白了就是將程式拷貝到儲存器並執行的過程。這裡是由execev函式來呼叫載入器(駐留在儲存器中)完成的,我們要執行p檔案的時候,就是使用./p來,載入器就把p的資料和程式碼拷貝從磁碟拷貝到了儲存器中,並通過跳轉到ELF頭部中的程式入口點開始程式p的執行。
怎樣載入?當載入器執行時,就先建立一個儲存器映像(上圖所示),在ELF可執行檔案頭部表的指示下,載入器將可執行檔案的程式碼和資料段拷貝到0x0804800處向上的兩個段中,然後跳轉到程式入口點_start(在ctrl.o中定義)開始執行
1.5 動態連結共享庫
① 編譯時載入
靜態庫需要定期的維護和更新,呼叫的程式碼還會拷貝到每個執行的程序中去,這是對儲存器系統資源的極大浪費。為了彌補這樣的缺陷,我們發明了共享庫。共享庫的一個主要目的就是允許多個正在執行的程序共享儲存器中相同的庫程式碼,節約資源。以(.so)結尾的檔案,在執行時被載入到任意儲存器地址,並和儲存器中的程式連結起來,以後的程序要用到這個庫就從這個固定的位置開始訪問。這一過程的管理交由動態連結器程式來執行。
我們實際來建立一個.so檔案:使用如下方式
說明:-shared指示連結器建立一個共享目標檔案;-fPIC生成與位置無關程式碼
然後建立可執行檔案p2:
這個思路很重要:當p2生成的時候沒有任何libvector.so的程式碼和資料被真正拷貝到p2中去,它是在執行的時候與libvector.so連結,p2中只是拷貝了一些重定位和符號表。當載入器載入p2程式開始執行的時候,動態連結器注意到p2中有.interp節,載入器就會載入和執行動態連結器,動態連結器重定位.so的文字和資料到一個儲存器段中,然後將p2中的符號引用重新定位到儲存器段中已經載入的.so文字和資料的位置。動態連結器完成這些工作以後就會把控制權交給p2,由於共享庫(.so)位置固定好了,程式就會開始執行。
② 執行時載入共享庫
微軟的windows程式開發人員提供共享庫來更新軟體,通常要求下載最新的dll庫,然後在程式下一次執行的時候會自動連結和載入更新後的共享庫。我們建立dll.c檔案,執行時載入libvector.so
說明:
1>使用dlopen開啟本地libvector.so共享庫,並解析庫中的符號;
2>使用dlsym訪問其中的addvec函式,如果存在就返回該函式的地址;
3>使用dlclose解除安裝共享庫;
開始編譯執行時共享庫:
1.6 與位置無關的程式碼
我們前面講過,使用-fPIC(Position-Independent Code)生成與位置無關程式碼,使得多個程序可以共享相同的庫程式碼。那麼多個程序究竟是如何共享程式的一個拷貝庫呢?
我們使用的編譯庫程式碼,使得這一部分的庫程式碼直接可以載入到儲存器中執行,這一過程不需要連結器修改庫程式碼的內容。這樣的程式碼就叫做與位置無關的程式碼。對於模組內部的呼叫不需要特殊處理,但是外部定義的函式呼叫和全域性變數的引用就需要連結時重定位。
① PIC資料引用
當儲存器載入一個共享目標模組的時候,資料段總是被分配到緊跟著程式碼段後,因此任何指令和任何變數之間的距離在執行時都是一個常量。這個很好的特性就被運用起來,編譯器在資料段開始地方建立了GOT表(Global Offset Table)(全域性偏移量表)如main2.o中的GOT表:
.dynamic:段的地址,包含動態連結器用來繫結函式地址的資訊(符號表、重定位);
GOT[1] :定義模組的資訊;GOT[2]:延遲繫結程式碼入口;
每個被main2.o引用的全域性資料物件都有一個條目,編譯器還會為每個條目生成一個重定位地址。在載入時動態連結器會重定位到每個正確的地址。我們來看看資料的引用過程:
pop將當前的pc彈出到ebx中,隨後的add指令加上一個常量,使得指向正確的變數位置,此處包含了該變數的絕對地址。後面的兩條mov指令:第一條eax存放了變數的絕對地址,第二條獲取該絕對位置處的值,放入到eax中;
② PIC函式呼叫
使用延遲繫結技術,通過GOT與過程連結表PLT(Procedure Linkage Table),將過程的地址繫結延遲到第一次呼叫該過程時(第一次呼叫的開銷較大)。p2中的PLT表如圖:
第一個PLT[0]是一個特殊條目,它跳轉到動態連結器中,從PLT[1]開始是每個函式的過程連結表;當addvec被第一次呼叫的時候(不會立即繫結,延遲一下)將控制傳遞到PLT[2]中的第一條指令中,jmp *0x8049684(跳轉到GOT[4]內容為:804846a)又回到了pushl指令處將addvec壓入棧中。然後通過jmp 8048444跳轉到PLT[0]動態連結器中。動態連結器通過兩個棧條目來確定addvec的位置,用這個地址覆蓋GOT[4],並把控制權轉到addvec。
下次訪問的方式還是通過傳遞控制權到PLT[2]中,但這次得到的GOT[4]的地址已經被延遲繫結好了。這樣唯一的開銷就是間接跳轉。