程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 靜態和動態鏈接

靜態和動態鏈接

編輯:C++入門知識

引言
即使是最簡單的HelloWorld的程序,它也要依賴於別人已經寫好的成熟的軟件庫,這就是引出了一個問題,我們寫的代碼怎麼和別人寫的庫集成在一起,也就是鏈接所要解決的問題。


首先看HelloWorld這個例子:
[cpp]
// main.c  
  1 #include <stdio.h> 
  2 
  3 int main(int argc, char** argv) 
  4 { 
  5         printf("Hello World! argc=%d\n", argc); 
  6         return 0; 
  7 } 

// main.c
  1 #include <stdio.h>
  2
  3 int main(int argc, char** argv)
  4 {
  5         printf("Hello World! argc=%d\n", argc);
  6         return 0;
  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]
Symbol table '.symtab' contains 11 entries: 
   Num:    Value  Size Type    Bind   Vis      Ndx Name 
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c 
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    3 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     0 SECTION LOCAL  DEFAULT    5 
     6: 00000000     0 SECTION LOCAL  DEFAULT    7 
     7: 00000000     0 SECTION LOCAL  DEFAULT    8 
     8: 00000000     0 SECTION LOCAL  DEFAULT    6 
<STRONG>     9: 00000000    36 FUNC    GLOBAL DEFAULT    1 main 
    10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND printf</STRONG> 

Symbol table '.symtab' contains 11 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1
     3: 00000000     0 SECTION LOCAL  DEFAULT    3
     4: 00000000     0 SECTION LOCAL  DEFAULT    4
     5: 00000000     0 SECTION LOCAL  DEFAULT    5
     6: 00000000     0 SECTION LOCAL  DEFAULT    7
     7: 00000000     0 SECTION LOCAL  DEFAULT    8
     8: 00000000     0 SECTION LOCAL  DEFAULT    6
     9: 00000000    36 FUNC    GLOBAL DEFAULT    1 main
    10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
我們重點關注最後兩行,從中可以看到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]
Relocation section '.rel.text' at offset 0x400 contains 2 entries: 
 Offset     Info    Type            Sym.Value  Sym. Name 
0000000a  00000501 R_386_32          00000000   .rodata 
00000019  00000a02 R_386_PC32        00000000   printf 

Relocation section '.rel.text' at offset 0x400 contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0000000a  00000501 R_386_32          00000000   .rodata
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
 1 #include <stdio.h> 
 2 extern int shared; 
 3 extern void bar(); 
 4 void foo(int i) 
 5 { 
 6   printf("Printing from Lib.so %d\n", i); 
 7   printf("Printing from Lib.so, shared %d\n", shared); 
 8 
 9   bar(); 
10   sleep(-1); 
11 } 

  1 #include <stdio.h>
  2 extern int shared;
  3 extern void bar();
  4 void foo(int i)
  5 {
  6   printf("Printing from Lib.so %d\n", i);
  7   printf("Printing from Lib.so, shared %d\n", shared);
  8
  9   bar();
 10   sleep(-1);
 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]
[20] .got              PROGBITS        00001fe4 000fe4 000010 04  WA  0   0  4 
[21] .got.plt          PROGBITS        00001ff4 000ff4 000020 04  WA  0   0  4 

  [20] .got              PROGBITS        00001fe4 000fe4 000010 04  WA  0   0  4
  [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]
[Requesting program interpreter: /lib/ld-linux.so.2] 

      [Requesting program interpreter: /lib/ld-linux.so.2]
當動態鏈接器被加載進來後,它首先做的事情就是先找到該可執行文件依賴的動態庫,這部分信息也是在可執行文件中可以查到的。用命令readelf -d helloworld.dyn,可以看到如下輸出:
[plain]
Dynamic section at offset 0xf28 contains 20 entries: 
  Tag        Type                         Name/Value 
 0x00000001 (NEEDED)                     Shared library: [libc.so.6] 

Dynamic section at offset 0xf28 contains 20 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
或者用命令ldd helloworld.dyn,可以看到如下輸出:
[plain]
linux-gate.so.1 =>  (0x008cd000) 
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0x00a7a000) 
/lib/ld-linux.so.2 (0x0035d000) 

        linux-gate.so.1 =>  (0x008cd000)
        libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0x00a7a000)
        /lib/ld-linux.so.2 (0x0035d000)
都表明該可執行文件依賴於libc.so.6這個動態庫,也就是C語言標准庫的動態鏈接版本。如果某個庫依賴於別的動態庫,它們也會被加載進來直到所有依賴的庫都被加載進來。


當所有的庫都被加載進來以後,類似於靜態鏈接,動態鏈接器從各個動態庫中可以知道每個庫都提供什麼函數(符號表)和哪些函數引用需要重定位(重定位表),然後修正.got和.got.plt中的符號到正確的地址,完成之後就可以將控制權交給可執行文件的入口地址,從而開始執行我們編寫的代碼了。
可見,動態鏈接器在程序運行前需要做大量的工作(修正符號地址),為了提高效率,一般采用的是延遲綁定,也就是只有用到某個函數才去修正.got.plt中地址,具體是如何做到延遲綁定的,推薦看《程序員的自我修養》一書。
小結
鏈接解決我們寫的程序是如何和別的庫組合在一起這個問題。每個參與鏈接的目標文件中都提供了這樣的信息:我有什麼符號(變量或者函數),我需要什麼符號,這樣鏈接器才能確定參與鏈接的目標文件和庫是否能組合在一起。靜態鏈接是在生成可執行文件的時候把需要的所有內容都包含在了可執行文件中,這導致的問題是可執行文件大,浪費磁盤和內存空間以及靜態庫升級的問題。動態鏈接是在程序運行的時候完成鏈接的,首先是動態鏈接器被加載到內存中,然後動態鏈接器再完成類似於靜態鏈接器的所做的事情。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved