講動態鏈接之前,得先說說符號重定位。
C/C++ 程序的編譯是以文件為單位進行的,因此每個 c/cpp 文件也叫作一個編譯單元(translation unit), 源文件先是被編譯成一個個目標文件, 再由鏈接器把這些目標文件組合成一個可執行文件或庫,鏈接的過程,其核心工作是解決模塊間各種符號(變量,函數)相互引用的問題,對符號的引用本質是對其在內存中具體地址的引用,因此確定符號地址是編譯,鏈接,加載過程中一項不可缺少的工作,這就是所謂的符號重定位。
因為編譯是以源文件為單位進行的,編譯器此時並沒有一個全局的視野,因此對一個編譯單元內的符號它是無力確定其最終地址的,而對於可執行文件來說,在現代操作系統上,程序加載運行的地址是固定或可以預期的,因此在鏈接時,鏈接器可以直接計算分配該文件內各種段的絕對或相對地址,所以對於可執行文件來說,符號重定位是在鏈接時完成的,但對動態鏈接庫來說,因為動態庫的加載是在運行時,且加載的地址不固定,因此沒法事先確定該模塊的起始地址,所以對動態庫的符號重定位,只能推遲。
符號重定位既指在當前目標文件內進行重定位,也包括在不同目標文件,甚至不同模塊間進行重定位,這裡面有什麼不同嗎?如果是同一個目標文件內,或者在同一個模塊內,鏈接後,各個符號的相對地址就已經確定了,看起來似乎不用非得要知道最後的絕對地址才能引用這些符號,這說起來好像也有道理,但事實不是這樣,x86 上 mov 之類訪問程序中數據段的指令,它要求操作數是絕對地址,而對於函數調用,雖然是以相對地址進行調用,但計算相對地址也只限於在當前目標文件內進行,跨目標文件跨模塊間的調用,編譯期也是做不到的,只能等鏈接時或加載時才能進行相對地址的計算,因此重定位這個過程是不能缺少的,事實上目前來說,對於動態鏈接即使是當前目標文件內,如果是全局非靜態函數,那麼它也是需要進行重定位的,當然這裡面有別的原因,比如說使得能實現 LD_PRELOAD 的功能等。
鏈接時符號重定位指的是在鏈接階段對符號進行重定位,一般來說,構建一個可執行文件可以簡單分為兩個步驟:編譯及鏈接,如下例子,我們嘗試使用靜態鏈接的方式構建一個可執行文件:
// file: a.c
int g_share = 1;
int g_func(int a)
{
g_share += a;
return a * 3;
}
// file: main.c
extern int g_share;
extern int g_func(int a);
int main()
{
int a = 42;
a = g_func(a);
return 0;
}
正如前面所說,此時符號的重定位在鏈接時進行,那麼在編譯時,編譯器是怎麼生成代碼來引用那些還沒有重定位的符號呢?讓我們先編譯一下,再來看看目標文件的內容:
// x86_64, linux 2.6.9
-bash-3.00$ gcc -c a.c main.c -g
-bash-3.00$ objdump -S a.o
然後得到如下輸出(對於 main.o 中對 g_func 的引用,實現是一樣的,故略):
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <g_func>:
int g_share = 1;
int g_func(int a)
{
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d fc mov %edi,0xfffffffffffffffc(%rbp)
g_share += a;
7: 8b 45 fc mov 0xfffffffffffffffc(%rbp),%eax
a: 01 05 00 00 00 00 add %eax,0(%rip) # 10 <g_func+0x10>
return a * 2;
10: 8b 45 fc mov 0xfffffffffffffffc(%rbp),%eax
13: 01 c0 add %eax,%eax
}
15: c9 leaveq
16: c3 retq
從中可以看到,目標文件裡的 .txt 段地址從 0 開始,其中地址為7的指令用於把參數 a 放到寄存器 %eax 中,而地址 a 處的指令則把 %eax 中的內容與 g_share 相加,注意這裡 g_share 的地址為:0(%rip). 顯然這個地址是錯的,編譯器當前並不知道 g_share 這個變量最後會被分配到哪個地址上,因此在這兒只是隨便用一個假的來代替,等著到接下來鏈接時,再把該處地址進行修正。那麼,鏈接器怎麼知道目標文件中哪些地方需要修正呢?很簡單,編譯器編譯文件時時,會建立一系列表項,用來記錄哪些地方需要在重定位時進行修正,這些表項叫作“重定位表”(relocatioin table):
-bash-3.00$ objdump -r a.o
a.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000000c R_X86_64_PC32 g_share+0xfffffffffffffffc
如上最後一行,這條記錄記錄了在當前編譯單元中,哪兒對 g_share 進行了引用,其中 offset 用於指明需要修改的位置在該段中的偏移,TYPE 則指明要怎樣去修改,因為 cpu 的尋址方式不是唯一的,尋址方式不同,地址的形式也有所不同,這個 type 用於指明怎麼去修改, value 則是配合 type 來最後計算該符號地址的。
有了如上信息,鏈接器在把目標文件合並成一個可執行文件並分配好各段的加載地址後,就可以重新計算那些需要重定位的符號的具體地址了, 如下我們可以看到在可執行文件中,對 g_share(0x40496處), g_func(0x4047a處)的訪問已經被修改成了具體的地址:
-bash-3.00$ gcc -o am a.o main.o
-bash-3.00$ objdump -S am
// skip some of the ouput
extern int g_func(int a);
int main()
{
400468: 55 push %rbp
400469: 48 89 e5 mov %rsp,%rbp
40046c: 48 83 ec 10 sub $0x10,%rsp
int a = 42;
400470: c7 45 fc 2a 00 00 00 movl $0x2a,0xfffffffffffffffc(%rbp)
a = g_func(a);
400477: 8b 7d fc mov 0xfffffffffffffffc(%rbp),%edi
40047a: e8 0d 00 00 00 callq 40048c <g_func>
40047f: 89 45 fc mov %eax,0xfffffffffffffffc(%rbp)
return 0;
400482: b8 00 00 00 00 mov $0x0,%eax
}
400487: c9 leaveq
400488: c3 retq
400489: 90 nop
40048a: 90 nop
40048b: 90 nop
000000000040048c <g_func>:
int g_share = 1;
int g_func(int a)
{
40048c: 55 push %rbp
40048d: 48 89 e5 mov %rsp,%rbp
400490: 89 7d fc mov %edi,0xfffffffffffffffc(%rbp)
g_share += a;
400493: 8b 45 fc mov 0xfffffffffffffffc(%rbp),%eax
400496: 01 05 dc 03 10 00 add %eax,1049564(%rip) # 500878 <g_share>
return a * 2;
40049c: 8b 45 fc mov 0xfffffffffffffffc(%rbp),%eax
40049f: 01 c0 add %eax,%eax
}
4004a1: c9 leaveq
4004a2: c3 retq
// skip some of the ouput
當然,重定位時修改指令的具體方式還牽涉到比較多的細節很啰嗦,這裡就不細說了。
前面描述了靜態鏈接時,怎麼解決符號重定位的問題,那麼當我們使用動態鏈接來構建程序時,這些符號重定位問題是怎麼解決的呢?目前來說,Linux 下 ELF 主要支持兩種方式:加載時符號重定位及地址無關代碼。地址無關代碼接下來會講,對於加載時重定位,其原理很簡單,它與鏈接時重定位是一致的,只是把重定位的時機放到了動態庫被加載到內存之後,由動態鏈接器來進行。
int g_share = 1;
int g_func(int a)
{
g_share += a;
return a * 2;
}
int g_func2()
{
int a = 2;
int b = g_func(3);
return a + b;
}
// compile on 32bit linux OS
-bash-3.00$ gcc -c a.c main.c
-bash-3.00$ gcc -shared -o liba.so a.o
-bash-3.00$ gcc -o am main.o -L. -la
-bash-3.00$ objdump -S liba.so
// skip some of the output
000004f4 <g_func>:
int g_share = 1;
int g_func(int a)
{
4f4: 55 push %ebp
4f5: 89 e5 mov %esp,%ebp
g_share += a;
4f7: 8b 45 08 mov 0x8(%ebp),%eax
4fa: 01 05 00 00 00 00 add %eax,0x0
return a * 2;
500: 8b 45 08 mov 0x8(%ebp),%eax
503: d1 e0 shl %eax
}
505: c9 leave
506: c3 ret
00000507 <g_func2>:
int g_func2()
{
507: 55 push %ebp
508: 89 e5 mov %esp,%ebp
50a: 83 ec 08 sub $0x8,%esp
int a = 2;
50d: c7 45 fc 02 00 00 00 movl $0x2,0xfffffffc(%ebp)
int b = g_func(3);
514: 6a 03 push $0x3
516: e8 fc ff ff ff call 517 <g_func2+0x10>
51b: 83 c4 04 add $0x4,%esp
51e: 89 45 f8 mov %eax,0xfffffff8(%ebp)
return a + b;
521: 8b 45 f8 mov 0xfffffff8(%ebp),%eax
524: 03 45 fc add 0xfffffffc(%ebp),%eax
}
527: c9 leave
// skip some of the output
注意其中地址 4fa 及 516 處的指令:此兩處分別對 g_share 及 g_func 進行了訪問,顯然此時它們的地址仍然是假地址,這些地址在動態庫加載完成後會被動態鏈接器進行重定位,最終修改為正確的地址,這看起來與靜態鏈接時進行重定位是一樣的過程,但實現上有幾個關鍵的不同之處:
因為不允許對可執行文件的代碼段進行加載時符號重定位,因此如果可執行文件引用了動態庫中的數據符號,則在該可執行文件內對符號的重定位必須在鏈接階段完成,為做到這一點,鏈接器在構建可執行文件的時候,會在當前可執行文件的數據段裡分配出相應的空間來作為該符號真正的內存地址,等到運行時加載動態庫後,再在動態庫中對該符號的引用進行重定位:把對該符號的引用指向可執行文件數據段裡相應的區域。
ELF 文件對動態庫中的函數調用采用了所謂的"延遲綁定”(lazy binding), 動態庫中的函數在其第一次調用發生時才去查找其真正的地址,因此我們不需要在調用動態庫函數的地方直接填上假的地址,而是使用了一些跳轉地址作為替換,這裡先不細說。
至此,我們可以發現加載時重定位實際上是一個重新修改動態庫代碼的過程,但我們知道,不同的進程即使是對同一個動態庫也很可能是加載到不同地址上,因此當以加載時重定位的方式來使用動態庫時,該動態庫就沒法做到被各個進程所共享,而只能在每個進程中 copy 一份:因為符號重定位後,該動態庫與在別的進程中就不同了,可見此時動態庫節省內存的優勢就不復存在了。
更多詳情見請繼續閱讀下一頁的精彩內容: http://www.linuxidc.com/Linux/2015-03/114572p2.htm