連接器和裝入器的基本工做原理

一個程序要想在內存中運行,除了編譯以外還要通過連接和裝入這兩個步驟。從程序員的角度來看,引入這兩個步驟帶來的好處就是能夠直接在程序中使用printf和errno這種有意義的函數名和變量名,而不用明確指明printf和errno在標準C庫中的地址。固然,爲了將程序員從早期直接使用地址編程的夢魘中解救出來,編譯器和彙編器在這當中作出了革命性的貢獻。編譯器和彙編器的出現使得程序員能夠在程序中使用更具意義的符號來爲函數和變量命名,這樣使得程序在正確性和可讀性等方面都獲得了極大的提升。可是隨着C語言這種支持分別編譯的程序設計語言的流行,一個完整的程序每每被分割爲若干個獨立的部分並行開發,而各個模塊間經過函數接口或全局變量進行通信。這就帶來了一個問題,編譯器只能在一個模塊內部完成符號名到地址的轉換工做,不一樣模塊間的符號解析由誰來作呢?好比前面所舉的例子,調用printf的用戶程序和實現了printf的標準C庫顯然就是兩個不一樣的模塊。實際上,這個工做是由連接器來完成的。html

爲了解決不一樣模塊間的連接問題,連接器主要有兩個工做要作――符號解析和重定位:linux

符號解析:當一個模塊使用了在該模塊中沒有定義過的函數或全局變量時,編譯器生成的符號表會標記出全部這樣的函數或全局變量,而連接器的責任就是要到別的模塊中去查找它們的定義,若是沒有找到合適的定義或者找到的合適的定義不惟一,符號解析都沒法正常完成。程序員

重定位:編譯器在編譯生成目標文件時,一般都使用從零開始的相對地址。然而,在連接過程當中,連接器將從一個指定的地址開始,根據輸入的目標文件的順序以段爲單位將它們一個接一個的拼裝起來。除了目標文件的拼裝以外,在重定位的過程當中還完成了兩個任務:一是生成最終的符號表;二是對代碼段中的某些位置進行修改,全部須要修改的位置都由編譯器生成的重定位表指出。編程

舉個簡單的例子,上面的概念對讀者來講就一目瞭然了。假如咱們有一個程序由兩部分構成,m.c中的main函數調用f.c中實現的函數sum:sass

/* m.c */
int i = 1;
int j = 2;
extern int sum();
void main()
{
        int s;
        s = sum(i, j);
/* f.c */
int sum(int i, int j)
{
        return i + j;
}

在Linux用gcc分別將兩段源程序編譯成目標文件:安全

$ gcc -c m.c
$ gcc -c f.c

咱們經過objdump來看看在編譯過程當中生成的符號表和重定位表:框架

$ objdump -x m.o
……
SYMBOL TABLE:
……
00000000 g		O .data  00000004 i
00000004 g		O .data  00000004 j
00000000 g		F .text  00000021 main
00000000         *UND*  00000000 sum
RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE
00000007 R_386_32          j
0000000d R_386_32          i
00000013 R_386_PC32        sum

首先,咱們注意到符號表裏面的sum被標記爲UND(undefined),也就是在m.o中沒有定義,因此未來要經過ld(Linux下的連接器)的符號解析功能到別的模塊中去查找是否存在函數sum的定義。另外,在重定位表中有三條記錄,指出了在重定位過程當中代碼段中三處須要修改的位置,分別位於七、d和13。下面以一種更加直觀的方式來看一下這三個位置:less

$ objdump -dx m.o
Disassembly of section .text:
00000000 <main>:
   0:   55						push   %ebp
   1:   89 e5						mov    %esp,%ebp
   3:   83 ec 04					sub    $0x4,%esp
   6:   a1 00 00 00 00				mov    0x0,%eax
7: R_386_32     j
   b:   50						push   %eax
   c:   a1 00 00 00 00				mov    0x0,%eax
d: R_386_32     i
  11:   50						push   %eax
  12:   e8 fc ff ff ff				call   13 <main+0x13>
13: R_386_PC32  sum
  17:   83 c4 08					add    $0x8,%esp
  1a:   89 c0						mov    %eax,%eax
  1c:   89 45 fc					mov    %eax,0xfffffffc(%ebp)
  1f:   c9						leave
  20:   c3						ret

以sum爲例,對函數sum的調用是經過call指令實現的,使用IP相對尋址方式。能夠看到,在目標文件m.o中,call指令位於從零開始的相對地址12的位置,這裏存放的e8是call的操做碼,而從13開始的4個字節存放着sum相對call的下一條指令add的偏移。顯然,在連接以前這個偏移量是不知道的,因此未來要來修改13這裏的代碼。那如今這裏爲何存放着0xfffffffc(注意Intel的CPU使用little endian的編址方式)呢?這大概是出於安全的考慮,由於0xfffffffc正是-4的補碼錶示(讀者能夠在gdb中使用p /x -4查看),而call指令自己佔用了5個字節,所以不管如何call指令中的偏移量不多是-4。咱們再看看重定位以後call指令中的這個偏移量被修改爲了什麼:編程語言

$ gcc m.o f.o
$ objdump -dj .text a.out | less
Disassembly of section .text:
……
080482c4 <main>:
……
80482d6:       e8 0d 00 00 00		call   80482e8 <sum>
80482db:       83 c4 08				add    $0x8,%esp
……
080482e8 <sum>:
……

能夠看到通過重定位以後,call指令中的偏移量修改爲0x0000000d了,簡單的計算告訴咱們:0x080482e8-0x80482db=0xd。這樣,通過重定位以後最終的可執行程序就生成了。函數

可執行程序生成後,下一步就是將其裝入內存運行。Linux下的編譯器(C語言)是cc1,彙編器是as,連接器是ld,可是並無一個實際的程序對應裝入器這個概念。實際上,將可執行程序裝入內存運行的功能是由execve(2)這一系統調用實現的。簡單來說,程序的裝入主要包含如下幾個步驟:

  • 讀入可執行文件的頭部信息以肯定其文件格式及地址空間的大小;
  • 以段的形式劃分地址空間;
  • 將可執行程序讀入地址空間中的各個段,創建虛實地址間的映射關係;
  • 將bbs段清零;
  • 建立堆棧段;
  • 創建程序參數、環境變量等程序運行過程當中所需的信息;
  • 啓動運行。

回頁首

連接和裝入技術的發展史

一個程序要想裝入內存運行必然要先通過編譯、連接和裝入這三個階段,雖然是這樣一個你們聽起來耳熟能詳的概念,在操做系統發展的過程當中卻已經經歷了屢次重大變革。簡單來說,能夠將其劃分爲如下三個階段:

1. 靜態連接、靜態裝入

這種方法最先被採用,其特色是簡單,不須要操做系統提供任何額外的支持。像C這樣的編程語言從很早開始就已經支持分別編譯了,程序的不一樣模塊能夠並行開發,而後獨立編譯爲相應的目標文件。在獲得了全部的目標文件後,靜態連接、靜態裝入的作法是將全部目標文件連接成一個可執行映象,隨後在建立進程時將該可執行映象一次所有裝入內存。舉個簡單的例子,假設咱們開發了兩個程序Prog1和Prog2,Prog1由main1.c、utilities.c以及errhdl1.c三部分組成,分別對應程序的主框架、一些公用的輔助函數(其做用至關於庫)以及錯誤處理部分,這三部分代碼編譯後分別獲得各自對應的目標文件main1.o、utilities.o以及errhdl1.o。一樣,Prog2由main2.c、utilities.c以及errhdl2.c三部分組成,三部分代碼編譯後分別獲得各自對應的目標文件main2.o、utilities.o以及errhdl2.o。值得注意的是,這裏Prog1和Prog2使用了相同的公用輔助函數utilities.o。當咱們採用靜態連接、靜態裝入的方法,同時運行這兩個程序時內存和硬盤的使用狀況如圖1所示:

能夠看到,首先就硬盤的使用來說,雖然兩個程序共享使用了utilities,但這並無在硬盤保存的可執行程序映象上體現出來。相反,utilities.o被連接進了每個用到它的程序的可執行映象。內存的使用也是如此,操做系統在建立進程時將程序的可執行映象一次所有裝入內存,以後進程才能開始運行。如前所述,採用這種方法使得操做系統的實現變得很是簡單,但其缺點也是顯而易見的。首先,既然兩個程序使用的是相同的utilities.o,那麼咱們只要在硬盤上保存utilities.o的一份拷貝應該就足夠了;另外,假如程序在運行過程當中沒有出現任何錯誤,那麼錯誤處理部分的代碼就不該該被裝入內存。所以靜態連接、靜態裝入的方法不但浪費了硬盤空間,同時也浪費了內存空間。因爲早期系統的內存資源十分寶貴,因此後者對早期的系統來說更加致命。

2. 靜態連接、動態裝入

既然採用靜態連接、靜態裝入的方法弊大於利,咱們來看看人們是如何解決這一問題的。因爲內存緊張的問題在早期的系統中顯得更加突出,所以人們首先想到的是要解決內存使用效率不高這一問題,因而便提出了動態裝入的思想。其想法是很是簡單的,即一個函數只有當它被調用時,其所在的模塊纔會被裝入內存。全部的模塊都以一種可重定位的裝入格式存放在磁盤上。首先,主程序被裝入內存並開始運行。當一個模塊須要調用另外一個模塊中的函數時,首先要檢查含有被調用函數的模塊是否已裝入內存。若是該模塊還沒有被裝入內存,那麼將由負責重定位的連接裝入器將該模塊裝入內存,同時更新此程序的地址表以反應這一變化。以後,控制便轉移到了新裝入的模塊中被調用的函數那裏。

動態裝入的優勢在於永遠不會裝入一個使用不到的模塊。若是程序中存在着大量像出錯處理函數這種用於處理小几率事件的代碼,使用這種方法無疑是卓有成效的。在這種狀況下,即便整個程序可能很大,可是實際用到(所以被裝入到內存中)的部分實際上可能很是小。

仍然以上面提到的兩個程序Prog1和Prog2爲例,假如Prog1運行過程當中出現了錯誤而Prog2在運行過程當中沒有出現任何錯誤。當咱們採用靜態連接、動態裝入的方法,同時運行這兩個程序時內存和硬盤的使用狀況如圖2所示:

圖 2採用靜態連接、動態裝入方法,同時運行Prog1和Prog2時內存和硬盤的使用狀況

能夠看到,當程序中存在着大量像錯誤處理這樣使用機率很小的模塊時,採用靜態連接、動態裝入的方法在內存的使用效率上就體現出了至關大的優點。到此爲止,人們已經向理想的目標邁進了一部,可是問題尚未徹底解決――內存的使用效率提升了,硬盤呢?

3. 動態連接、動態裝入

採用靜態連接、動態裝入的方法後看似只剩下硬盤空間使用效率不高的問題了,實際上內存使用效率不高的問題仍然沒有徹底解決。圖2中,既然兩個程序用到的是相同的utilities.o,那麼理想的狀況是系統中只保存一份utilities.o的拷貝,不管是在內存中仍是在硬盤上,因而人們想到了動態連接。

在使用動態連接時,須要在程序映象中每一個調用庫函數的地方打一個樁(stub)。stub是一小段代碼,用於定位已裝入內存的相應的庫;若是所需的庫還不在內存中,stub將指出如何將該函數所在的庫裝入內存。

當執行到這樣一個stub時,首先檢查所需的函數是否已位於內存中。若是所需函數尚不在內存中,則首先須要將其裝入。不論怎樣,stub最終將被調用函數的地址替換掉。這樣,在下次運行同一個代碼段時,一樣的庫函數就能直接得以運行,從而省掉了動態連接的額外開銷。由此,用到同一個庫的全部進程在運行時使用的都是這個庫的同一份拷貝。

下面咱們就來看看上面提到的兩個程序Prog1和Prog2在採用動態連接、動態裝入的方法,同時運行這兩個程序時內存和硬盤的使用狀況(見圖3)。仍然假設Prog1運行過程當中出現了錯誤而Prog2在運行過程當中沒有出現任何錯誤。

圖 3採用動態連接、動態裝入方法,同時運行Prog1和Prog2時內存和硬盤的使用狀況

圖中,不管是硬盤仍是內存中都只存在一份utilities.o的拷貝。內存中,兩個進程經過將地址映射到相同的utilities.o實現對其的共享。動態連接的這一特性對於庫的升級(好比錯誤的修正)是相當重要的。當一個庫升級到一個新版本時,全部用到這個庫的程序將自動使用新的版本。若是不使用動態連接技術,那麼全部這些程序都須要被從新連接才能得以訪問新版的庫。爲了不程序意外使用到一些不兼容的新版的庫,一般在程序和庫中都包含各自的版本信息。內存中可能會同時存在着一個庫的幾個版本,可是每一個程序能夠經過版本信息來決定它到底應該使用哪個。若是對庫只作了微小的改動,庫的版本號將保持不變;若是改動較大,則相應遞增版本號。所以,若是新版庫中含有與早期不兼容的改動,只有那些使用新版庫進行編譯的程序纔會受到影響,而在新版庫安裝以前進行過連接的程序將繼續使用之前的庫。這樣的系統被稱做共享庫系統。

回頁首

Linux下動態連接的實現

現在咱們在Linux下編程用到的庫(像libc、QT等等)大多都同時提供了動態連接庫和靜態連接庫兩個版本的庫,而gcc在編譯連接時若是不加-static選項則默認使用系統中的動態連接庫。對於動態連接庫的原理大多數的書本上只是進行了泛泛的介紹,在此筆者將經過在實際系統中反彙編出的代碼向讀者展現這一技術在Linux下的實現。

下面是個最簡單的C程序hello.c:

#include <stdio.h>
int main()
{
    printf("Hello, world\n");
    return 0;
}

在Linux下咱們可使用gcc將其編譯成可執行文件a.out:

$ gcc hello.c

程序裏用到了printf,它位於標準C庫中,若是在用gcc編譯時不加-static的話,默認是使用libc.so,也就是動態連接的標準C庫。在gdb中能夠看到編譯後printf對應以下代碼 :

$ gdb -q a.out
(gdb) disassemble printf
Dump of assembler code for function printf:
0x8048310 <printf>:     jmp    *0x80495a4
0x8048316 <printf+6>:   push   $0x18
0x804831b <printf+11>:  jmp    0x80482d0 <_init+48>

這也就是一般在書本上以及前面提到的打樁(stub)過程,顯然這並非真正的printf函數。這段stub代碼的做用在於到libc.so中去查找真正的printf。

(gdb) x /w 0x80495a4
0x80495a4 <_GLOBAL_OFFSET_TABLE_+24>:   0x08048316

能夠看到0x80495a4處存放的0x08048316正是pushl $0x18這條指令的地址,因此第一條jmp指令沒有起到任何做用,其做用就像空操做指令nop同樣。固然這是在咱們第一次調用printf時,其真正的做用是在從此再次調用printf時體現出來的。第二條jmp指令的目的地址是plt,也就是procedure linkage table,其內容能夠經過objdump命令查看,咱們感興趣的就是下面這兩條對程序的控制流有影響的指令:

$ objdump -dx a.out
……
080482d0 >.plt>:
 80482d0:       ff 35 90 95 04 08       pushl  0x8049590
 80482d6:       ff 25 94 95 04 08       jmp    *0x8049594
……

第一條push指令將got(global offset table)中與printf相關的表項地址壓入堆棧,以後jmp到內存單元0x8049594中所存放的地址0x4000a960處。這裏須要注意的一點是,在查看got以前必須先將程序a.out啓動運行,不然經過gdb中的x命令在0x8049594處看到的結果是不正確的。

(gdb) b main
Breakpoint 1 at 0x8048406
(gdb) r
Starting program: a.out
Breakpoint 1, 0x08048406 in main ()
(gdb) x /w 0x8049594
0x8049594 <_GLOBAL_OFFSET_TABLE_+8>:    0x4000a960
(gdb) disassemble 0x4000a960
Dump of assembler code for function _dl_runtime_resolve:
0x4000a960 <_dl_runtime_resolve>:       pushl  %eax
0x4000a961 <_dl_runtime_resolve+1>:     pushl  %ecx
0x4000a962 <_dl_runtime_resolve+2>:     pushl  %edx
0x4000a963 <_dl_runtime_resolve+3>:     movl   0x10(%esp,1),%edx
0x4000a967 <_dl_runtime_resolve+7>:     movl   0xc(%esp,1),%eax
0x4000a96b <_dl_runtime_resolve+11>:    call   0x4000a740 <fixup>
0x4000a970 <_dl_runtime_resolve+16>:    popl   %edx
0x4000a971 <_dl_runtime_resolve+17>:    popl   %ecx
0x4000a972 <_dl_runtime_resolve+18>:    xchgl  %eax,(%esp,1)
0x4000a975 <_dl_runtime_resolve+21>:    ret    $0x8
0x4000a978 <_dl_runtime_resolve+24>:    nop
0x4000a979 <_dl_runtime_resolve+25>:    leal   0x0(%esi,1),%esi
End of assembler dump.

前面三條push指令執行以後堆棧裏面的內容以下:

下面將0x18存入edx,0x8049590存入eax,有了這兩個參數,fixup就能夠找到printf在libc.so中的地址。當fixup返回時,該地址已經保存在了eax中。xchg指令執行完以後堆棧中的內容以下:

最妙的要數接下來的ret指令的用法,這裏ret實際上被當成了call來使用。ret $0x8以後控制便轉移到了真正的printf函數那裏,而且清掉了堆棧上的0x18和0x8049584這兩個已經沒用的參數,這時堆棧便成了下面的樣子:

而這正是咱們所指望的結果。應該說這裏ret的用法與Linux內核啓動後經過iret指令實現由內核態切換到用戶態的作法有着殊途同歸之妙。不少人都據說過中斷指令int能夠實現用戶態到內核態這種優先級由低到高的切換,在接受完系統服務後iret指令負責將優先級從新降至用戶態的優先級。然而系統啓動時首先是處於內核態高優先級的,Intel i386並無單獨提供一條特殊的指令用於在系統啓動完成後下降優先級以運行用戶程序。其實這個問題很簡單,只要反用iret就能夠了,就像這裏將ret看成call使用同樣。另外,fixup函數執行完還有一個反作用,就是在got中與printf相關的表項(也就是地址爲0x80495a4的內存單元)中填上查找到的printf函數在動態連接庫中的地址。這樣當咱們再次調用printf函數時,其地址就能夠直接從got中獲得,從而省去了經過fixup查找的過程。也就是說got在這裏起到了cache的做用。

相關文章
相關標籤/搜索