天天看點

編譯過程和符号表重定位問題、靜态和動态連結 靜态和動态連結:轉載至點選打開連結 靜态連結 動态連結 小結

編譯過程和符号表重定位問題:轉載至:點選打開連結

對于代碼的編譯問題千頭萬緒從何說起呢,首先來說一下計算機是如何處理應用程式的,實質上應用程式是通過作業系統來應用機器指令操控硬體設施完成各種任務的,就從編譯的環節開始談起吧,衆所周知,程式開發人員所寫的代碼實際上計算機是沒有辦法去認識的,那麼就必須通過編譯将其轉換為計算機可以認識的機器指令,在有作業系統根據具體指令從硬體上配置設定記憶體處理程式段。以下從預編譯,編譯,彙編,連結,來簡單的說一下程式的編譯過程。

2.1編譯預處理

在這個階段主要是宏定義的展開,以及頭檔案的遞歸處理,即展開所有的以#開頭的編譯指令,删除所有注釋,添加行号和檔案名辨別,保留所有的#program。

2.2編譯階段

将程式代碼段按字元流格式進行切割,處理,主要是詞法分析,文法分析,語義分析以及優化等階段,編譯完成後生成中間代碼。

2.3彙編

将編譯後的中間代碼通過彙編器子產品生成計算機能夠識别的機器指令用以操控硬體設施生成目标代碼(可重定位目标代碼)。

2.4連結

通過連結器子產品将各種目标代碼以及庫檔案(*.lib檔案),資源檔案(*,rec)進行連結處理最終生成可以執行的*.exe檔案。

2.5重定位問題

通過一個例子來看:假如我們有兩個頭檔案和兩個源檔案分别叫做function1.h和function2.h以及function1.cpp和function2.cpp檔案其中function1.h内容如下

Function1.h

#ifndef   _FUNCTION1_H

#define   _FUNCTION1_H

Int g_val;

Int Add(int m, int n);

#endif

Function1.cpp

g_val=10;

Int Add(int m, int n)

{

Return m+n;

}

Function2.cpp其中包含了main函數内容如下

#include “function1.h”

Int main()

{

Int  l_valfri=3;

Int  l_valsec=4;

g_val=14;

Int result=Add(l_valfri,l_valsec);

Return 0;

}

對于這樣的代碼編譯器在編譯function2.cpp時對于外部符号g_val 和外部函數Add該如何決議呢,這裡又會涉及到可重定位檔案中的符号表問題。

其實在可重定位目标檔案之中會存在一個用來放置變量和其入口位址的符号表,當編譯過程中能夠找到該符号的定義時就将該符号入口位址更新到符号表中否則就對該符号的位址不做任何決議一直保留到連結階段處理。通過兩個例子來看符号表的結構。

在編譯過程中function1.cpp檔案的可重定位目标檔案中的符号表如下

變量名 記憶體位址
g_val 0x100
Add 0x200

為什麼可以做到對于符号g_val和Add配置設定記憶體位址呢,因為在編譯階段就能夠在function1.cpp檔案中找到他們的定義,是以能夠進行明确的記憶體位址配置設定。

再來看看function2.cpp所生成的可重定位目标檔案的結構:

變量名 記憶體位址
g_val 0x00
Add 0x00

為什麼會出現這樣的狀況。因為在編譯階段雖然可以看到這些符号變量的聲明,但卻找不到他們的定義是以編譯器陷入了一個決而未決的境地。

将包含檔案展開時,function2.cpp大概會是這個樣子很明顯隻有符号變量的聲明但是沒有定義。

#ifndef   _FUNCTION1_H

#define   _FUNCTION1_H

Int g_val;

Int Add(int m, int n);

#endif

Int main()

{

Int  l_valfri=3;

Int  l_valsec=4;

g_val=14;

Int result=Add(l_valfri,l_valsec);

Return 0;

}

先将他們存放在符号表中但卻不去為他們進行記憶體關聯一直等到連結階段在進行處理。

重定位發生于目标代碼連結階段,在連結階段連結器就會查找符号表,當他發現了function2.cpp的符号表之中任然有沒有決議的記憶體位址時,連結器就會查找所有的目标代碼檔案,一直到他找到了function1.cpp所生成的目标代碼檔案符号表時發現了這些沒有決議的符号變量的真正記憶體位址,這是function2.cpp所生成的目标代碼檔案就會更新它的符号表,将這些尚未決議的符号變量的記憶體位址寫進其符号表中。

更新之後的function2.obj檔案符号表

變量名 記憶體位址
g_val 0x100
Add 0x200

當所有的符号變量都能夠找到合法的記憶體位址時,連結階段重定位完成。

靜态和動态連結:轉載至點選打開連結

即使是最簡單的HelloWorld的程式,它也要依賴于别人已經寫好的成熟的軟體庫,這就是引出了一個問題,我們寫的代碼怎麼和别人寫的庫內建在一起,也就是連結所要解決的問題。

首先看HelloWorld這個例子: [cpp]  view plain  copy

  1. // main.c  
  2.   1 #include <stdio.h>  
  3.   2  
  4.   3 int main(int argc, char** argv)  
  5.   4 {  
  6.   5         printf("Hello World! argc=%d\n", argc);  
  7.   6         return 0;  
  8.   7 }  

HelloWorld的main函數中引用了标準庫提供的printf函數。連結所要解決的問題就是要讓我們的程式能正确地找到printf這個函數。 解決這個問題有兩個辦法:一種方式是在生成可執行檔案的時候,把printf函數相關的二進制指令和資料包含在最終的可執行檔案中,這就是靜态連結;另外一種方式是在程式運作的時候,再去加載printf函數相關的二進制指令和資料,這就是動态連結。 每個源檔案都會首先被編譯成目标檔案,每個目标檔案都提供一些别的目标檔案需要的函數或者資料,同時又從别的目标檔案中獲得一些函數或者資料。是以,連結的過程就是目标檔案間互通有無的過程。 本文根據《程式員的自我修養》一書中關于靜态和動态連結總結而成,歡迎指正并推薦閱讀原書。

靜态連結

靜态連結就是在生成可執行檔案的時候,把所有需要的函數的二進制代碼都包含到可執行檔案中去。是以, 連結器需要知道參與連結的目标檔案需要哪些函數,同時也要知道每個目标檔案都能提供什麼函數,這樣連結器才能知道是不是每個目标檔案所需要的函數都能正确地連結。如果某個目标檔案需要的函數在參與連結的目标檔案中都找不到的話,連結器就報錯了。 目标檔案中有兩個重要的接口來提供這些資訊:一個是符号表,另外一個是重定位表。 利用Linux中的readelf工具就可以檢視這些資訊。 首先我們用指令gcc -c -o main.o main.c 來編譯上面main.c檔案來生成目标檔案main.o。然後我們用指令readelf -s main.o來檢視main.o中的符号表: [plain]  view plain  copy

  1. Symbol table '.symtab' contains 11 entries:  
  2.    Num:    Value  Size Type    Bind   Vis      Ndx Name  
  3.      0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND  
  4.      1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c  
  5.      2: 00000000     0 SECTION LOCAL  DEFAULT    1  
  6.      3: 00000000     0 SECTION LOCAL  DEFAULT    3  
  7.      4: 00000000     0 SECTION LOCAL  DEFAULT    4  
  8.      5: 00000000     0 SECTION LOCAL  DEFAULT    5  
  9.      6: 00000000     0 SECTION LOCAL  DEFAULT    7  
  10.      7: 00000000     0 SECTION LOCAL  DEFAULT    8  
  11.      8: 00000000     0 SECTION LOCAL  DEFAULT    6  
  12. <strong>     9: 00000000    36 FUNC    GLOBAL DEFAULT    1 main  
  13.     10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND printf</strong>  

我們重點關注最後兩行,從中可以看到main.o中提供main函數(Type列為FUNC,Ndx為1表示它是在本目标檔案中第1個Section中),同時依賴于printf函數(Ndx列為UND)。

因為在編譯main.c的時候,編譯器還不知道printf函數的位址,是以在編譯階段隻是将一個“臨時位址”放到目标檔案中,在連結階段,這個“臨時位址”将被修正為正确的位址,這個過程叫重定位。是以連結器還要知道該目标檔案中哪些符号需要重定位,這些資訊是放在了重定位表中。很明顯,在main.o這個目标檔案中,printf的位址需要重定位,我們還是用指令readelf -r main.o來驗證一下,這些資訊是儲存在.rel.textSection中: [plain]  view plain  copy

  1. Relocation section '.rel.text' at offset 0x400 contains 2 entries:  
  2.  Offset     Info    Type            Sym.Value  Sym. Name  
  3. 0000000a  00000501 R_386_32          00000000   .rodata  
  4. 00000019  00000a02 R_386_PC32        00000000   printf  

那麼既然main.o依賴于printf函數,你可能會問,printf是在哪個目标檔案裡面?printf函數是标準庫的一部分,在Linux下靜态的标準庫libc.a位于/usr/lib/i386-linux-gnu/中。你可以認為标準庫就是把一些常用的函數的目标檔案打包在一起,用指令ar -t libc.a可以檢視libc.a中的内容,其中你就可以發現printf.o這個目标檔案。在連結的時候,我們需要告訴連結器需要連結的目标檔案和庫檔案(預設gcc會把标準庫作為連結器輸入的一部分)。連結器會根據輸入的目标檔案從庫檔案中提取需要目标檔案。比如,連結器發現main.o會需要printf這個函數,在處理标準庫檔案的時候,連結器就會把printf.o從庫檔案中提取處理。當然printf.o依賴的目标檔案也很被一起提取出來。庫中其他目标檔案就被舍棄掉,進而減小了最終生成的可執行檔案的大小。

知道了這些資訊後,連結器就可以開始工作了,分為兩個步驟:1)合并相似段,把所有需要連結的目标檔案的相似段放在可執行檔案的對應段中。2)重定位符号使得目标檔案能正确調用到其他目标檔案提供的函數。 用指令gcc -static -o helloworld.static main.c來編譯并做靜态連結,生成可執行檔案helloworld.static。因為可執行檔案helloworld.static已經是連結好了的,是以裡面就不會有重定位表了。指令 readelf -S helloworld.static | grep .rel.text将不會有任何輸出(注:-S是列印出ELF檔案中的Sections)。經過靜态連結生成的可執行檔案,隻要裝載到了記憶體中,就可以開始運作了。

動态連結

靜态連結看起來很簡單,但是有些不足。其中之一就對磁盤空間和記憶體空間的浪費。标準庫中那些函數會被放到每個靜态連結的可執行檔案中,在運作的時候,這些重複的内容也會被不同的可執行檔案加載到記憶體中去。同時,如果靜态庫有更新的話,所有可執行檔案都得重新連結才能用上新的靜态庫。動态連結就是為了解決這個問題而出現的。所謂動态連結就是在運作的時候再去連結。 了解動态連結需要從兩個角度來看,一是從動态庫的角度,而是從使用動态庫的可執行檔案的角度。

從動态庫的角度來看,動态庫像普通的可執行檔案一樣,有其代碼段和資料段。為了使得動态庫在記憶體中隻有一份,需要做到不管動态庫裝載到什麼位置,都不需要修改動态庫中代碼段的内容,進而實作動态庫中代碼段的共享。而資料段中的内容需要做到程序間的隔離,是以必須是私有的,也就是每個程序都有一份。是以,動态庫的做法是把代碼段中變化的部分放到資料段中去,這樣代碼段中剩下的就是不變的内容,就可以裝載到虛拟記憶體的任何位置。那代碼段中變化的内容是什麼,主要包括了對外部函數和變量的引用。 我們來看一個簡單的例子吧,假設我們要把下面的代碼做成一個動态庫: [plain]  view plain  copy

  1.  1 #include <stdio.h>  
  2.  2 extern int shared;  
  3.  3 extern void bar();  
  4.  4 void foo(int i)  
  5.  5 {  
  6.  6   printf("Printing from Lib.so %d\n", i);  
  7.  7   printf("Printing from Lib.so, shared %d\n", shared);  
  8.  8  
  9.  9   bar();  
  10. 10   sleep(-1);  
  11. 11 }  

用指令gcc -shared -fPIC -o Lib.so Lib.c将生成一個動态庫Lib.so(-shared是生成共享對象,-fPIC是生成位址無關的代碼)。該動态庫提供(導出)一個函數foo,依賴(導入)一個函數bar,和一個變量shared。 這裡我們需要解決的問題是如何讓foo這個函數能正确地引用到外部的函數bar和shared變量?程式裝載有個特性,代碼段和資料段的相對位置是固定的,是以我們把這些外部函數和外部變量的位址放到資料段的某個位置,這樣代碼就能根據其目前的位址從資料段中找到對應外部函數的位址(前提是誰能幫忙在資料段中填上這個外部函數的正确位址,下面會講)。動态庫中外部變量的位址是放在.got(global offset table)中,外部函數的位址是放在了.got.plt段中。 如果你用指令readelf -S Lib.so | grep got将會看到Lib.so中有這樣兩個Section。他們就是分别存放外部變量和函數位址的地方。

[plain]  view plain  copy

  1. [20] .got              PROGBITS        00001fe4 000fe4 000010 04  WA  0   0  4  
  2. [21] .got.plt          PROGBITS        00001ff4 000ff4 000020 04  WA  0   0  4  

到此為止,我們知道了動态庫是把位址相關的内容放到了資料段中來實作位址無關的代碼,進而使得動态庫能被多個程序共享。那麼接着的問題就誰來幫助動态庫來修正.got和.got.plt中的位址。

那麼我們就從動态連結器的角度來看看吧!

靜态連結的可執行檔案在裝載進入記憶體後就可以開始運作了,因為所有的外部函數都已經包含在可執行檔案中。而動态連結 的可執行檔案中對外部函數的引用位址在生成可執行檔案的時候是未知的,是以在這些位址被修正前是動态連結生成的可執行檔案是不能運作的。是以,動态連結生成的可執行檔案運作前,系統會首先将動态連結庫加載到記憶體中,動态連結器所在的路徑在可執行檔案可以查到的。

還是以前面的helloworld為例,用指令gcc -o helloworld.dyn main.c來以動态連結的方式生成可執行檔案。然後用指令readelf -l helloworld.dyn | grep interpreter可以看到動态連結器在系統中的路徑。 [plain]  view plain  copy

  1. [Requesting program interpreter: /lib/ld-linux.so.2]  

當動态連結器被加載進來後,它首先做的事情就是先找到該可執行檔案依賴的動态庫,這部分資訊也是在可執行檔案中可以查到的。用指令readelf -d helloworld.dyn,可以看到如下輸出: [plain]  view plain  copy

  1. Dynamic section at offset 0xf28 contains 20 entries:  
  2.   Tag        Type                         Name/Value  
  3.  0x00000001 (NEEDED)                     Shared library: [libc.so.6]  

或者用指令ldd  helloworld.dyn ,可以看到如下輸出: [plain]  view plain  copy

  1. linux-gate.so.1 =>  (0x008cd000)  
  2. libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0x00a7a000)  
  3. /lib/ld-linux.so.2 (0x0035d000)  

都表明該可執行檔案依賴于libc.so.6這個動态庫,也就是C語言标準庫的動态連結版本。 如果某個庫依賴于别的動态庫,它們也會被加載進來直到所有依賴的庫都被加載進來。

當所有的庫都被加載進來以後,類似于靜态連結,動态連結器從各個動态庫中可以知道每個庫都提供什麼函數(符号表)和哪些函數引用需要重定位(重定位表),然後修正.got和.got.plt中的符号到正确的位址,完成之後就可以将控制權交給可執行檔案的入口位址,進而開始執行我們編寫的代碼了。 可見,動态連結器在程式運作前需要做大量的工作(修正符号位址),為了提高效率,一般采用的是延遲綁定,也就是隻有用到某個函數才去修正.got.plt中位址,具體是如何做到延遲綁定的,推薦看《程式員的自我修養》一書。

小結

連結解決我們寫的程式是如何和别的庫組合在一起這個問題。每個參與連結的目标檔案中都提供了這樣的資訊:我有什麼符号(變量或者函數),我需要什麼符号,這樣連結器才能确定參與連結的目标檔案和庫是否能組合在一起。靜态連結是在生成可執行檔案的時候把需要的所有内容都包含在了可執行檔案中,這導緻的問題是可執行檔案大,浪費磁盤和記憶體空間以及靜态庫更新的問題。動态連結是在程式運作的時候完成連結的,首先是動态連結器被加載到記憶體中,然後動态連結器再完成類似于靜态連結器的所做的事情。