編譯鏈接

轉自http://blog.csdn.net/navyhu/article/details/47023317
理解鏈接有很多好處:
有助于構造大型程序
有助于避免一些危險編程錯誤
有助于理解其他重要的系統概念
讓你能夠利用共享庫

  1. 編譯器驅動程序
    編譯命令,假設有main.c和swap.c兩個源文件

[cpp] view plain copy

$ gcc -O2 -g -o p main.c swap.c

實際上編譯過程可以分解為以下步驟

[cpp] view plain copy

  1. 運行C預處理器(cpp),將main.c翻譯成一個中間文件
    $cpp [options] main.c main.i
  2. 運行C編譯器(ccl),將main.i翻譯成匯編語言
    $ccl main.i main.c -O2 [options] -o main.s
    $gcc -S main.c -O2 [options] -o main.s
  3. 運行匯編器(as),將main.s翻譯成可重定位目標文件(relocatable object file) main.o
    $as [options] -o main.o main.s
  4. 重復以上步驟生成swap.o
  5. 運行連接器(ld),將main.o swap.o以及一些必要的系統目標文件組合起來,生成可執行目標文件(executable object file) p
    $ ld -o p [system object files and args] main.o swap.o

編譯好后就可以通過shell運行了,shell調用操作系統中叫‘加載器’的函數,它將可執行文件p中的代碼和數據拷貝到內存,然后叫控制交給程序開始處

[cpp] view plain copy

$ ./p

  1. 靜態鏈接

上面提到的“ld”是一個靜態連接器,它需要完成兩個主要任務來構造可執行文件

[cpp] view plain copy

  1. 符號解析(symbol resolution)
    將符號引用(object reference)和符號定義聯系起來

  2. 重定位(relocation)
    編譯器和匯編器生成從地址0開始的代碼和數據節(section),鏈接器通過把每個符號定義與一個內存地址聯系起來,然后修改所有對這些符號的引用,
    使得他們指向這個內存地址,從而重定位這些sections

  3. 目標文件

一共有3種目標文件類型
可重定位目標文件
可執行目標文件
共享目標文件:可動態加載到存儲器與可執行文件鏈接執行

目標文件格式,這里討論的都是ELF格式
COFF(Common Object File Format):System V Unix早期版本使用
PE(Portable Executable):COFF變種,Windows NT使用
ELF(Executable and Linkable Format):System V Unix后來的版本使用

ELF格式相關知識可以參考下面兩篇博客:
ELF Format
ELF Format:程序加載和動態鏈接

  1. 可重定位目標文件
    更詳細內容參考:ELF Format
    下圖為一個典型的ELF可重定位目標文件格式

    ELF頭以一個16字節的序列開始,其中包含了生成該文件的系統的字大小和字節順序,ELF頭剩下部分包括ELF頭大小、目標文件類型、機器類型、節頭部表偏移(section header table)。
    ELF文件中其他節(section)的位置信息都在節頭部表中可以找到
    ELF頭和節頭部表之間的都是各種各樣的節(section)

[cpp] view plain copy

.text: 已編譯程序的機器代碼
.rodata: 只讀數據
.data: 已初始化的全局變量(ELF文件中不含局部變量,他們保存在棧中)
.bss:(Block Storage Start) 未初始化的全局變量,區分已初始化和未初始化全局變量的目的是為了節省磁盤空間,目標文件中這個節不占用空間,只是一個占位符
.symtab: 符號表,存放程序中定義和引用的函數和全局變量的信息(沒有局部變量的條目)
.rel.text: 一個.text節中位置的列表。當鏈接器將此文件與其他目標文件鏈接時需要修改這些位置,一般任何調用外部函數或引用全局變量的指令都要修改
.rel.data: 引用或定義的任何全局變量的重定位信息,任何已初始化的全局變量,如果它的初值是一個全局變量地址或外部函數地址,就需要修改
.debug: 調試符號表,包含了程序中定義的局部變量和類型定義,定義或引用的全局變量,以及源文件。編譯時使用-g選項才能生成這個section
.line: 源文件中的行號和.text節中機器指令間的映射,編譯時使用-g生成這個表
.strtab: 字符串表,包含.symtab和.debug節中的符號表,以及節頭部中的節名字

  1. 符號和符號表

每個可重定位目標模塊m都有一個符號表,包含m所定義和引用的符號信息。有3種不同的符號:

[cpp] view plain copy

  1. 由m定義,能被其他模塊引用的全局符號。非static函數和非static全局變量
  2. 其他模塊定義,被m引用的全局符號。 源文件中使用external修飾
  3. 只被m定義和引用的本地符號。帶static的函數和帶static的全局變量和本地變量

注意:本地符號與函數中的本地變量是不同的,.symtab中的符號表不包含函數中的本地變量,這些本地變量(除static 變量外)運行時由棧管理,鏈接器不理會他們

[cpp] view plain copy

利用static隱藏變量和函數名:
一個源文件中聲明的全局變量和函數,其他模塊都可以看到。如果不想其他模塊使用全局變量和函數,可以用static修飾,static全局變量和函數只有聲明它的源文件可用

符號表結構如下:


[cpp] view plain copy

name: symbol名字,指向字符串表中的字節偏移量
value: 符號地址
size: 目標大小
type/binding: 目標類型,binding表示符號是本地還是全局的
reserved: 保留
section: 每個符號都和目標文件中某個節相關聯,這個字段存儲的是到節頭部表的索引。除了具體節,還有3個偽節(pseudo section):
ABS:不該被重定位的符號
UNDEF:未定義符號,表明被這個目標文件引用,但是在其他地方定義
COMMON:表示還未分配位置的未初始化的數據目標

  1. 符號解析

鏈接器解析符號時,將符號引用于輸入的可重定位目標文件的符號表中的個確定的符號定義聯系起來。
本地符號的解析很簡單,就在本目標文件中找到符號定義就行了。但是當鏈接器在本地沒有找到符號定義時,就會嘗試到其他目標文件中查找。如果其他文件也沒找到,就會產生鏈接錯誤!
6.1 解析多重定義的全局符號
編譯器將全局符號分為‘強’和‘弱’符號,函數和已初始化的全局變量是強符號,未初始化的全局變量是弱符號。
鏈接器使用如下規則處理多重符號定義(這是C的規則,C++中不允許出現多重定義,弱符號也不行)

[cpp] view plain copy

規則1:不允許有多個同名強符號
規則2:如果有一個強符號和多個同名弱符號,那么選擇強符號
規則3:如果有多個同名弱符號,那么從中任選一個

6.2 與靜態庫鏈接

系統可以將一組相關的目標模塊打包成一個單獨的文件,稱為靜態庫(static library)。鏈接時可以使用靜態庫里的目標模塊作為輸入,鏈接器只會拷貝被應用程序引用的目標模塊。
使用ar創建靜態庫

[cpp] view plain copy

$ ar rcs libvector.a addvec.o multvec.o

6.3 使用靜態庫進行引用解析
進行符號解析時,鏈接器按照命令行上從左到右順序掃描可重定位目標文件和庫文件,此過程中鏈接器維護3個集合:
可重定位目標文件集合E
未解析的符號集合U
在輸入文件中已定義的符號集合D

初始時3個集合都為空,鏈接器按照如下規則填充3個集合:
對于輸入文件f,判斷它是一個目標文件還是一個庫文件
如果是目標文件,添加到E,并且掃描f里的符號定義和引用來修改集合U和D。繼續下一個文件
如果f是庫文件,嘗試在庫文件中查找U中未定義的符號。如果在庫文件的某個成員m中找到一個符號來解析U中的引用,就將m添加到E,并且掃描m來修改U和D。對庫文件中所有成員目標文件都反復進行此過程,直到U和D都不再變化
當處理完所有文件,如果U是非空,那么就會產生鏈接錯誤。否則就就合并和重定位E中的目標文件,構建可執行文件

但是這個過程有一個問題,那就是輸入文件需要以一定的順序出現在命令行上,不然就可能出現鏈接錯誤(如果后面的文件中引用前面文件的符號)。不過現在的鏈接器應該使用了不同的策略(或者有其他步驟保證)。

  1. 重定位
    完成符號解析后,鏈接器就把代碼中的每個符號引用和符號定義聯系起來,此時鏈接器已經知道當前所有輸入目標模塊中的代碼節和數據節的大小,可以進行重定位了。
    重定位由兩部組成:
    重定位節和符號定義:鏈接器將所有相同類型的節合并到同一類型的新的聚合節,并將此節作為可執行文件的對應節。隨后鏈接器將運行時內存地址賦給新的聚合節,賦給輸入模塊定義的每個節,以及每個符號,現在程序中每個指令和全局變量都有唯一的運行時地址了
    重定位節中的符號引用:鏈接器修改代碼和數據節中的符號引用,讓他們指向正確的運行時地址。這一步需要“重定位條目”的支持

7.1 重定位條目
編譯器在編譯目標文件時,它并不知道數據和代碼最終會放在內存的什么位置,也不知道引用的外部函數或全局變量的位置。所以,當編譯器遇到最終內存位置未知的目標引用時,就會生成一個“重定位條目”,鏈接器根據重定位條目修改對應引用。代碼(函數)的重定位條目放在.rel.text中,已初始化數據的重定位條目放在.rel.data中。
ELF重定位條目格式如下:

[cpp] view plain copy

typedef struct {

[cpp] view plain copy

int offset; /* Offset of the reference to relocate */

[cpp] view plain copy

int symbol:24, /* Symbol of the reference should point to */

[cpp] view plain copy

type:8; /* Relocation type */

[cpp] view plain copy

} Elf32_Rel;

ELF有11種重定位類型,以下是其中兩種最基本的:

[cpp] view plain copy

R_386_PC32:重定位一個使用32位PC相對地址的引用

[cpp] view plain copy

R_386_32:重定位一個使用32位絕對地址的引用

7.2 重定位符號引用
下面是鏈接器重定位算法的偽代碼

[cpp] view plain copy

foreach section s {
foreach relocation entry r {
refptr = s + r.offset; /* ptr to reference to be relocated */

    /* Relocate a PC-relative reference */                                                       
    if (r.type == R_386_PC32) {                                                                  
        refaddr = ADDR(s) + r.offset; /* ref's runtime address */                                
        *refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr);                               
    }                                                                                            
                                                                                                 
    /* Relocate an obsolute reference */                                                         
    if (r.type == R_386_32)                                                                      
        *refptr = (unsigned) (ADDR(r.symbol) + *refptr);                                         
}                                                                                                

}

重定位PC相對引用前例中,main.o的.text節中,main函數調用了swap函數(在swap.o中定義),反匯編main.o如下:

[cpp] view plain copy

$ objdump -d main.o

....
6: e8 fc ff ff ff call 7 <main+0x7> swap();
7: R_386_PC32 swap relocation entry
.....

可以看出,call指令偏移地址為0x6,后面是32位引用0xfffffffc(十進制-4),開能看到重定位條目的值如下:

[cpp] view plain copy

r.offset = 0x7
r.symbol = swap
r.type = R_386_PC32

從中鏈接器可以得出需要修改開始于偏移量0x7處的32位PC相對引用,使得在運行時指向swap函數。
重定位之前鏈接器已經指定好了目標模塊中各節和符號的運行時地址,假設當前節和符號地址如下:

[cpp] view plain copy

ADDR(s) = ADDR(.text) = 0x80483b4
ADDR(r.symbol) = ADDR(swap) = 0x80483c8

使用上面的算法,鏈接器首先計算處引用的運行時地址

[cpp] view plain copy

refaddr = ADDR(s) + r.offset
= 0x80483b4 + 0x7
= 0x80483bb

然后重新計算引用的值,使之在運行時指向swap函數

[cpp] view plain copy

*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr)
= (unsigned) (0x80483c8 + (-4) - 0x80483bb)
= (unsigned) (0x9)

因此在生成的可執行文件中,call指令的形式如下

[cpp] view plain copy

80483ba: e8 09 00 00 00 call 80483c8 <swap> swap();

運行時,call指令在地址0x80483ba處,當CPU執行call指令時,PC的值為0x80483bf(指向后一條指令),CPU實際執行如下指令:

[cpp] view plain copy

push PC onto stack
PC <- PC + 0x9 = 0x80483bf + 0x9 = 0x80483c8

這個地址剛好就是swap函數的第一條指令!

[cpp] view plain copy

注意:
為什么call指令中引用的初始值為-4?
這是因為CPU執行call指令時,PC實際指向了下一條指令,然而引用的開始地址是下一條指令之前的4 bytes處(因為引用占4 bytes)。

重定位絕對引用
前例中,swap.o中全局指針bufp0指向了全局數組buf的第一個元素

[cpp] view plain copy

int *bufp0 = &buf[0];

由于bufp0已初始化,它會被存放在目標文件swap.o的.data節中。而且它指向了一個未定義的全局數組地址,所以需要重定位。下面是swap.o的.data節的反匯編:

[cpp] view plain copy

00000000 <bufp0>:
0: 00 00 00 00 int *bufp0 = &buf[0];
0: R_386_32 buf Relocation entry

這是個32位引用,bufp0的指針值為0x0,這是個絕對引用,開始于偏移位置0處,需要重定位使它指向符號buf。
假設鏈接器以及確定符號地址:

[cpp] view plain copy

ADDR(r.symbol) = ADDR(buf) = 0x8049454

使用重定位算法修改引用:

[cpp] view plain copy

*refptr = (unsigned) (ADDR(r.symbol) + *refptr)
= (unsigned) (0x8049454 + 0)
= (unsigned) (0x8049454)

最終可執行文件中的形式如下

[cpp] view plain copy

0804945c <bufp0>:
804945c: 54 94 04 08 Relocated

鏈接器將變量bufp0重定位到0x08049454,就是buf數組的運行時地址。

  1. 可執行目標文件

    下圖為一個典型的ELF可執行文件格式:

    與可重定位文件類似,也有ELF頭部、節頭標、各種節,運行時系統把需要的一些節(sections)加載到相應的內存地址,怎么知道哪些節加載到什么位置呢?這是由段頭部表(segment header table)決定的。下圖為可執行文件的段頭部:

    從上圖可看出,運行時加載了兩個內存段:code segment和data segment。
    代碼段對齊到一個4KB(2^12)的邊界,有讀/執行權限,開始地址為0x08048000,占用內存大小為0x448字節。其中包括ELF頭部、段頭部表、.init、.text和.rodata節。
    數據段同樣對齊到4KB的邊界,開始于0x8049448處,內存大小為0x104字節,其中的0xe8字節(.data節)使用文件中的內容初始化,剩下的初始化為0(也就是.bss)。

  2. 加載可執行目標文件
    運行可執行文件時,系統使用一個被稱為加載器(loader)的程序,將可執行文件的代碼和數據從磁盤加載到內存中,然后跳轉到程序的第一條指令(或者入口點entry point)開始執行。
    Unix程序運行時在有一個內存映像,表示程序在內存中的結構,如下圖



    代碼段總是從地址0x08048000開始,數據段是在緊接著的一個4KB對齊的地址處,堆在數據段之后,往上增長。中間有一個共享庫保留的內存段。然后是用戶棧,棧從最大的合法用戶地址開始,向下增長。棧之上是系統保留的內存,用戶進程不能訪問(只能通過系統調用陷入內核態訪問)。

  3. 動態鏈接共享庫
    靜態庫可以為編譯鏈接提供方便,但是缺點也很明顯:每次改動使用到靜態庫的程序都有重新鏈接、很多程序使用相同的靜態庫會增加內存負載等
    解決這些問題我們可以使用共享庫(shared library,dll),在運行時使用動態鏈接器(dynamic linker)與程序進行動態鏈接來執行。
    使用gcc可以生產共享庫:

[cpp] view plain copy

$ gcc -shared -fPIC -o libvector.so addvec.c multvec.c

可以將它鏈接到程序中:

[cpp] view plain copy

$ gcc -o p2 main.c libvector.so

這樣運行可執行文件時就可以和libvector.so進行鏈接。動態鏈接的基本思路是創建可執行文件時,靜態進行一些鏈接,程序加載過程中再動態完成鏈接過程。
在與共享庫進行靜態鏈接的過程中,并沒有拷貝共享庫中的任何代碼和數據,而只是拷貝了一些重定位和符號表信息,動態鏈接時使用這些信息解析共享庫中的代碼和數據。
當加載器加載和運行可執行文件時,先加載只進行了部分鏈接的可執行文件,它會發現其中有一個.interp節,里面包含了動態鏈接器的路徑名,這時加載器會加載這個動態鏈接器,執行如下鏈接任務:
重定位libc.so的文本和數據到某個內存段
重定位libvector.so的文本和數據到另一個內存段
重定位可執行文件中所有對libc.so libvector.so中符號的引用

鏈接完成后,動態鏈接器將控制交給程序執行。

  1. 從程序加載和連接共享庫
    除了在運行時由系統加載共享庫,我們也可以在代碼中直接加載指定的共享庫,在編譯時要加上編譯選項-rdynamic

[cpp] view plain copy

$ gcc -rdynamic -O2 -o p3 dll.c -ldl

代碼中加載共享庫的函數如下:

[cpp] view plain copy

include <dlfcn.h>

void* dlopen(const char* filename, int flag); // 成功時返回指針為指向句柄的指針,否則返回NULL

flag:
RTLD_GLOBAL: 解析庫‘filename’中的外部符號
RTLD_NOW: 讓鏈接器現在就解析符號引用
RTLD_LAZY: 使用到該符號引用時才解析

然后使用函數dlsym獲取符號地址, 其中handle為 dlopen 返回的指向共享庫句柄的指針,symbol為符號名

[cpp] view plain copy

include <dlfcn.h>

void dlsym(void *handle, char *symbol); // 成功則返回指向符號的指針,否則返回NULL

使用完共享庫調用dlclose關閉,如果沒有其他進程正在使用此共享庫,dlclose函數就卸載該庫

[cpp] view plain copy

include <dlfcn.h>

int dlclose(void* handle); // 成功返回0, 否則返回-1

可用dlerror函數驗證之前的幾個函數是否調用成功

[cpp] view plain copy

include <dlfcn.h>

const char* dlerror(void); //如果dlopen、dlsym、dlclose調用失敗,則返回錯誤信息,成功則返回NULL

Java中的JNI(Java Native Interface,Java本地接口)就是利用共享庫來實現的,它允許Java程序調用“本地的”C和C++函數。JNI的思想是將本地C函數,如foo,編譯到共享庫foo.so中,當Java程序試圖調用函數foo時,Java解釋程序(位于JVM中)利用dlopen動態鏈接和加載foo.so,然后再調用。

  1. 位置無關代碼(PIC)
    PIC:position-independent code
    共享庫可以讓多個進程共享同一段內存中的代碼,以節省寶貴的內存資源,那么它是怎么實現的呢?
    一種方法是給每個庫預留一個專用的地址空間,每次都加載到同一個地址空間。但是隨著共享庫的增加,這會帶來嚴重的內存碎片和管理的問題。
    更好的方法是將庫代碼編譯成不需要鏈接器修改就可以在任何地址加載和執行的代碼,這就叫位置無關代碼(Position-Independent Code, PIC)。gcc使用選項-fPIC來生成PIC代碼。
    同一個目標模塊中的過程調用不需要特殊處理,因為引用的都是本地符號,他們的偏移量是已知的,所以已經是PIC代碼了。但是對于外部定義的過程調用和全局變量的引用通常都不是PIC,都需要連接是進行重定位。
    12.1. PIC數據引用
    生成全局變量的PIC引用有一個前提:加載目標模塊(包括共享目標模塊)時,數據段總是被分配成緊隨代碼段后面。這樣代碼段中的任何指令和數據段中的任何變量之間的距離都是一個運行時常量,與代碼段和數據段的絕對內存位置無關。
    基于此,編譯器在數據段開始的地方創建了一個“全局偏移量表(Global Offset Table,GOT)”。GOT中,每個被改目標模塊引用的全局數據對象都有一個條目,條目中存有重定位記錄。加載時,動態鏈接器會重定位GOT中的每個條目,使之包含正確的地址。每個引用全局數據的目標模塊都有自己的GOT。
    運行時,使用形如下面的代碼,通過GOT間接引用全局變量:

[cpp] view plain copy

      call L1  

L1: popl %ebx ebx contains the current PC
addl $VAROFF, %ebx ebx points to the GOT entry for the var
movl (%ebx), %eax reference indirect through the GOT
movl (%eax), %eax got the real content of the reference

為什么popl %ebx會得到PC的值?
這是因為call L1會將當前PC的值壓棧后再跳轉到L1處開始執行,所以popl指令取的其實就是call壓入的PC值。

取PC值的目的是什么呢?
當然是為了找到GOT中當前引用對應的條目,因為引用實際上存的是它在GOT中對應條目相對于下一條指令地址(PC值)的偏移量,
所以(%PC)加上這個偏移量就是此引用在GOT中對應的條目。

可以看出PIC代碼有性能方面的問題,每個全局變量引用都需要五條指令,而且還需要額外空間存儲GOT表。

12.2 PIC函數調用
PIC代碼的外部函數調用也可以用同樣的方式:

[cpp] view plain copy

call L1
popl %ebx ebx contains the current PC
addl $PROCOFF, %ebx ebx points to the GOT entry for proc
call *(%ebx) call indirect through the GOT

但是這種方法同樣有性能問題,ELF編譯系統使用延遲綁定(lazy binding)技術將過程地址的綁定延遲到第一次調用它時。
延遲綁定通過兩個數據結構的交互來實現:GOT和PLT(Procedure Linkage Table,過程鏈接表)。
任何調用了共享庫中定義的函數的目標模塊,都包含了自己的GOT和PLT。GOT位于.data節,PLT位于.text節。
下圖為一個例子的GOT格式:



前3個條目是特殊的:
GOT[0]包含.dynamic段的地址,存有動態鏈接器用來綁定過程(函數)地址的信息,如符號表位置和重定位信息
GOT[1]包含定義這個模塊的信息
GOT[2]包含動態鏈接器的延遲綁定代碼的入口點
其他的對應于目標模塊中的外部過程調用,可以看出調用了printf(在libc.so中)和addvec(libvector.so中)函數
下圖為該例的PLT:



PLT是一個數組,其中每個條目大小為16字節。第一個條目PLT[0]是特殊條目,用于跳轉到動態鏈接器中。從PLT[1]開始的條目對應于目標模塊中的外部過程調用。
PLT[1]對應于printf
PLT[2]對應于addvec
程序剛被加載運行時,調用printf和addvec的地方分別綁定到相應PLT條目的第一條指令上,如調用addvec指令如下:

[cpp] view plain copy

08485bb: e8 a4 fe ff ff call 8048464 <addvec>
call指令使用相對尋址方式,實際地址為當前PC地址+0xfffffea4 = 0x8048464, 剛好就是PLT[2]開始的地址

當第一次運行到調用addvec時,跳轉到PLT[2]的第一條指令,該指令通過GOT[4]執行一個間接跳轉。初始時,對應GOT條目的內容為PLT條目中pushl指令的地址,此時,GOT[4]就是指向了 0x804846a (pushl $0x8),這時的PLT跳轉指令只是轉移回到PLT[2]的下一條指令:pushl $0x8。然后執行PLT[2]的最后一條指令,跳轉到PLT[0],這里第一條指令將GOT[1]的地址壓入棧,然后通過GOT[2]間接跳轉到動態鏈接器中。動態鏈接器用剛壓入的兩個棧條目來確定addvec的位置,并用這個位置替換GOT[4]的內容,把控制交給addvec執行。
當下一次再調用addvec時,PLT[2]的第一條指令通過GOT[4]直接跳轉到addvec開始執行。

  1. 處理目標文件的工具
    下面的工具可以幫助理解目標文件:

[cpp] view plain copy

AR:創建靜態庫,插入、刪除、列出和提取成員
STRINGS:列出一個目標文件中所有可打印的字符串
STRIP:從目標文件中刪除符號表信息
NM:列出一個目標文件的符號表中定義的符號
SIZE:列出目標文件中節的名字和大小
READELF:顯示一個目標文件的完整結構,包括ELF頭中編碼的所有信息。包含SIZE和NM的功能
OBJDUMP:所有二進制工具之母。。能夠顯示一個目標文件的所有信息。最大作用就是反匯編.text中的二進制指令
LDD:列出可執行文件在運行時需要的共享庫

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容