可執行文件ELF的理解

ELF(Executable and Linking Format)是一種對象文件的格式,用於定義不同類型的對象文件(Object files)中都放了什麼東西、以及都以什麼樣的格式去放這些東西。它自最早在 System V 系統上出現後,被 xNIX 世界所廣泛接受,作爲缺省的二進制文件格式來使用。可以說,ELF是構成衆多xNIX系統的基礎之一,所以作爲嵌入式Linux系統乃至內核驅動程序開發人員,你最好熟悉並掌握它。

其實,關於ELF這個主題,網絡上已經有相當多的文章存在,但是其介紹的內容比較分散,使得初學者不太容易從中得到一個系統性的認識。爲了幫助大家學習,我這裏打算寫一系列連貫的文章來介紹ELF以及相關的應用。這是這個系列中的第一篇文章,主要是通過不同工具的使用來熟悉ELF文件的內部結構以及相關的基本概念。後面的文章,我們會介紹很多高級的概念和應用,比方動態鏈接和加載,動態庫的開發,C語言Main函數是被誰以及如何被調用的,ELF格式在內核中的支持,Linux內核中對ELF section的擴展使用等等。

好的,開始我們的第一篇文章。在詳細進入正題之前,先給大家介紹一點ELF文件格式的參考資料。在ELF格式出來之後,TISC(Tool Interface Standard Committee)委員會定義了一套ELF標準。你可以從這裏(http://refspecs.freestandards.org/elf/)找到詳細的標準文檔。TISC委員會前後出了兩個版本,v1.1和v1.2。兩個版本內容上差不多,但就可讀性上來講,我還是推薦你讀 v1.2的。因爲在v1.2版本中,TISC重新組織原本在v1.1版本中的內容,將它們分成爲三個部分(books):

a) Book I

介紹了通用的適用於所有32位架構處理器的ELF相關內容

b) Book II

介紹了處理器特定的ELF相關內容,這裏是以Intel x86 架構處理器作爲例子介紹

c) Book III

介紹了操作系統特定的ELF相關內容,這裏是以運行在x86上面的 UNIX System V.4 作爲例子介紹

值得一說的是,雖然TISC是以x86爲例子介紹ELF規範的,但是如果你是想知道非x86下面的ELF實現情況,那也可以在http://refspecs.freestandards.org/elf/中找到特定處理器相關的Supplment文檔。比方ARM相關的,或者MIPS相關的等等。另外,相比較UNIX系統的另外一個分支BSD Unix,Linux系統更靠近 System V 系統。所以關於操作系統特定的ELF內容,你可以直接參考v1.2標準中的內容。

這裏多說些廢話:別忘了 Linus 在實現Linux的第一個版本的時候,就是看了介紹Unix內部細節的書:《The of the Unix Operating System》,得到很多啓發。這本書對應的操作系統是System V 的第二個Release。這本書介紹了操作系統的很多設計觀念,並且行文簡單易懂。所以雖然現在的Linux也吸取了其他很多Unix變種的設計理念,但是如果你想研究學習Linux內核,那還是以看這本書作爲開始爲好。這本書也是我在接觸Linux內核之前所看的第一本介紹操作系統的書,所以我極力向大家推薦。(在學校雖然學過操作系統原理,但學的也是很糟糕最後導致期末考試才四十來分,記憶彷彿還在昨天:))

好了,還是回來開始我們第一篇ELF主題相關的文章吧。這篇文章主要是通過使用不同的工具來分析對象文件,來使你掌握ELF文件的基本格式,以及瞭解相關的基本概念。你在讀這篇文章的時候,希望你在電腦上已經打開了那個 v1.2 版本的ELF規範,並對照着文章內容看規範裏的文字。

首先,你需要知道的是所謂對象文件(Object files)有三個種類:

1) 可重定位的對象文件(Relocatable file)

這是由彙編器彙編生成的 .o 文件。後面的鏈接器(link editor)拿一個或一些 Relocatable object files 作爲輸入,經鏈接處理後,生成一個可執行的對象文件 (Executable file) 或者一個可被共享的對象文件(Shared object file)。我們可以使用 ar 工具將衆多的 .o Relocatable object files 歸檔(archive)成 .a 靜態庫文件。如何產生 Relocatable file,你應該很熟悉了,請參見我們相關的基本概念文章和JulWiki。另外,可以預先告訴大家的是我們的內核可加載模塊 .ko 文件也是 Relocatable object file。

2) 可執行的對象文件(Executable file)

這我們見的多了。文本編輯器vi、調式用的工具gdb、播放mp3歌曲的軟件mplayer等等都是Executable object file。你應該已經知道,在我們的 Linux 系統裏面,存在兩種可執行的東西。除了這裏說的 Executable object file,另外一種就是可執行的腳本(如shell腳本)。注意這些腳本不是 Executable object file,它們只是文本文件,但是執行這些腳本所用的解釋器就是 Executable object file,比如 bash shell 程序。

3) 可被共享的對象文件(Shared object file)

這些就是所謂的動態庫文件,也即 .so 文件。如果拿前面的靜態庫來生成可執行程序,那每個生成的可執行程序中都會有一份庫代碼的拷貝。如果在磁盤中存儲這些可執行程序,那就會佔用額外的磁盤空間;另外如果拿它們放到Linux系統上一起運行,也會浪費掉寶貴的物理內存。如果將靜態庫換成動態庫,那麼這些問題都不會出現。動態庫在發揮作用的過程中,必須經過兩個步驟:

a) 鏈接編輯器(link editor)拿它和其他Relocatable object file以及其他shared object file作爲輸入,經鏈接處理後,生存另外的 shared object file 或者 executable file。

b) 在運行時,動態鏈接器(dynamic linker)拿它和一個Executable file以及另外一些 Shared object file 來一起處理,在Linux系統裏面創建一個進程映像。

以上所提到的 link editor 以及 dynamic linker 是什麼東西,你可以參考我們基本概念中的相關文章。對於什麼是編譯器,彙編器等你應該也已經知道,在這裏只是使用他們而不再對他們進行詳細介紹。爲了下面的敘述方便,你可以下載test.tar.gz包,解壓縮後使用"make"進行編譯。編譯完成後,會在目錄中生成一系列的ELF對象文件,更多描述見裏面的 README 文件。我們下面的論述都基於這些產生的對象文件。

make所產生的文件,包括 sub.o/sum.o/test.o/libsub.so/test 等等都是ELF對象文件。至於要知道它們都屬於上面三類中的哪一種,我們可以使用 file 命令來查看:

[yihect@juliantec test]$ file sum.o sub.o test.o libsub.so test 
sum.o:     ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped 
sub.o:     ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped 
test.o:    ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped 
libsub.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), not stripped 
test:      ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), for GNU/Linux 2.2.5, dynamically linked (uses shared libs), not stripped

結果很清楚的告訴我們他們都屬於哪一個類別。比方 sum.o 是應用在x86架構上的可重定位文件。這個結果也間接的告訴我們,x86是小端模式(LSB)的32位結構。那對於 file 命令來說,它又能如何知道這些信息?答案是在ELF對象文件的最前面有一個ELF文件頭,裏面記載了所適用的處理器、對象文件類型等各種信息。在TISCv1.2的規範中,用下面的圖描述了ELF對象文件的基本組成,其中ELF文件頭赫然在目。

ELF 文件頭

等等,爲什麼會有左右兩個很類似的圖來說明ELF的組成格式?這是因爲ELF格式需要使用在兩種場合:

a) 組成不同的可重定位文件,以參與可執行文件或者可被共享的對象文件的鏈接構建;

b) 組成可執行文件或者可被共享的對象文件,以在運行時內存中進程映像的構建。

所以,基本上,圖中左邊的部分表示的是可重定位對象文件的格式;而右邊部分表示的則是可執行文件以及可被共享的對象文件的格式。正如TISCv1.2規範中所闡述的那樣,ELF文件頭被固定地放在不同類對象文件的最前面。至於它裏面的內容,除了file命令所顯示出來的那些之外,更重要的是包含另外一些數據,用於描述ELF文件中ELF文件頭之外的內容。如果你的系統中安裝有 GNU binutils 包,那我們可以使用其中的 readelf 工具來讀出整個ELF文件頭的內容,比如:

[yihect@juliantec test]$ readelf -h ./sum.o
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          184 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           40 (bytes)
  Number of section headers:         9
  Section header string table index: 6
 

這個輸出結果能反映出很多東西。那如何來看這個結果中的內容,我們還是就着TISCv1.2規範來。在實際寫代碼支持ELF格式對象文件格式的時候,我們都會定義許多C語言的結構來表示ELF格式的各個相關內容,比方這裏的ELF文件頭,你就可以在TISCv1.2規範中找到這樣的結構定義(注意我們研究的是針對x86架構的ELF,所以我們只考慮32位版本,而不考慮其他如64位之類的):

ELF 文件頭結構

這個結構裏面出現了多種數據類型,同樣可以在規範中找到相關說明:

ELF 相關數據類型

在我們以後一系列文章中,我們會着重拿實際的程序代碼來分析,介時你會在頭文件中找到同樣的定義。但是這裏,我們只討論規範中的定義,暫不考慮任何程序代碼。在ELF頭中,字段e_machine和e_type指明瞭這是針對x86架構的可重定位文件,最前面有個長度爲16字節的字段中有一個字節表示了它適用於32bits機器,而不是64位的。除了這些之外,另外ELF頭還告訴了我們其他一些特別重要的信息,分別是:

a) 這個sum.o的進入點是0x0(e_entry),這表面Relocatable objects不會有程序進入點。所謂程序進入點是指當程序真正執行起來的時候,其第一條要運行的指令的運行時地址。因爲Relocatable objects file只是供再鏈接而已,所以它不存在進入點。而可執行文件test和動態庫.so都存在所謂的進入點,你可以用 readelf -h 看看。後面我們的文章中會介紹可執行文件的e_entry指向C庫中的_start,而動態庫.so中的進入點指向 call_gmon_start。這些後面再說,這裏先不深入討論。

b) 這個sum.o文件包含有9個sections,但卻沒有segments(Number of program headers爲0)。

那什麼是所謂 sections 呢?可以說,sections 是在ELF文件裏頭,用以裝載內容數據的最小容器。在ELF文件裏面,每一個 sections 內都裝載了性質屬性都一樣的內容,比方:

1) .text section 裏裝載了可執行代碼;

2) .data section 裏面裝載了被初始化的數據;

3) .bss section 裏面裝載了未被初始化的數據;

4) 以 .rec 打頭的 sections 裏面裝載了重定位條目;

5) .symtab 或者 .dynsym section 裏面裝載了符號信息;

6) .strtab 或者 .dynstr section 裏面裝載了字符串信息;

7) 其他還有爲滿足不同目的所設置的section,比方滿足調試的目的、滿足動態鏈接與加載的目的等等。

一個ELF文件中到底有哪些具體的 sections,由包含在這個ELF文件中的 section head table(SHT)決定。在SHT中,針對每一個section,都設置有一個條目,用來描述對應的這個section,其內容主要包括該 section 的名稱、類型、大小以及在整個ELF文件中的字節偏移位置等等。我們也可以在TISCv1.2規範中找到SHT表中條目的C結構定義:

ELF section header entry

我們可以像下面那樣來使用 readelf 工具來查看可重定位對象文件 sum.o 的SHT表內容:[yihect@juliantec test]$ readelf -S ./sum.o 
There are 9 section headers, starting at offset 0xb8: 
  
Section Headers: 
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al 
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0 
  [ 1] .text             PROGBITS        00000000 000034 00000b 00  AX  0   0  4 
  [ 2] .data             PROGBITS        00000000 000040 000004 00  WA  0   0  4 
  [ 3] .bss              NOBITS          00000000 000044 000000 00  WA  0   0  4 
  [ 4] .note.GNU-stack   PROGBITS        00000000 000044 000000 00      0   0  1 
  [ 5] .comment          PROGBITS        00000000 000044 00002d 00      0   0  1 
  [ 6] .shstrtab         STRTAB          00000000 000071 000045 00      0   0  1 
  [ 7] .symtab           SYMTAB          00000000 000220 0000a0 10      8   7  4 
  [ 8] .strtab           STRTAB          00000000 0002c0 00001d 00      0   0  1 
Key to Flags: 
  W (write), A (alloc), X (execute), M (merge), S (strings) 
  I (info), L (link order), G (group), x (unknown) 
  O (extra OS processing required) o (OS specific), p (processor specific)

這個結果顯示了 sum.o 中包含的所有9個sections。因爲sum.o僅僅是參與link editor鏈接的可重定位文件,而不參與最後進程映像的構建,所以Addr(sh_addr)爲0。後面你會看到可執行文件以及動態庫文件中大部分sections的這一字段都是有某些取值的。Off(sh_offset)表示了該section離開文件頭部位置的距離。Size(sh_size)表示section的字節大小。ES(sh_entsize)只對某些形式的sections 有意義。比方符號表 .symtab section,其內部包含了一個表格,表格的每一個條目都是特定長度的,那這裏的這個字段就表示條目的長度10。Al(sh_addralign)是地址對齊要求。另外剩下的兩列Lk和Inf,對應着條目結構中的字段sh_link和字段sh_info。它們中記錄的是section head table 中的條目索引,這就意味着,從這兩個字段出發,可以找到對應的另外兩個 section,其具體的含義解釋依據不同種類的 section 而不同,後面會介紹。

注意上面結果中的 Flg ,表示的是對應section的相關標誌。比方.text section 裏面存儲的是代碼,所以就是隻讀的(X);.data和.bss裏面存放的都是可寫的(W)數據(非在堆棧中定義的數據),只不過前者存的是初始化過的數據,比方程序中定義的賦過初值的全局變量等;而後者裏面存儲的是未經過初始化的數據。因爲未經過初始化就意味着不確定這些數據剛開始的時候會有些什麼樣的值,所以針對對象文件來說,它就沒必要爲了存儲這些數據而在文件內多留出一塊空間,因此.bss section的大小總是爲0。後面會看到,當可執行程序被執行的時候,動態連接器會在內存中開闢一定大小的空間來存放這些未初始化的數據,裏面的內存單元都被初始化成0。可執行程序文件中雖然沒有長度非0的 .bss section,但卻記錄有在程序運行時,需要開闢多大的空間來容納這些未初始化的數據。

另外一個標誌A說明對應的 section 是Allocable的。所謂 Allocable 的section,是指在運行時,進程(process)需要使用它們,所以它們被加載器加載到內存中去。

而與此相反,存在一些non-Allocable 的sections,它們只是被鏈接器、調試器或者其他類似工具所使用的,而並非參與進程的運行中去的那些 section。比方後面要介紹的字符串表section .strtab,符號表 .symtab section等等。當運行最後的可執行程序時,加載器會加載那些 Allocable 的部分,而 non-Allocable 的部分則會被繼續留在可執行文件內。所以,實際上,這些 non-Allocable 的section 都可以被我們用 stip 工具從最後的可執行文件中刪除掉,刪除掉這些sections的可執行文件照樣能夠運行,只不過你沒辦法來進行調試之類的事情罷了。

我們仍然可以使用 readelf -x SecNum 來傾印出不同 section 中的內容。但是,無奈其輸出結果都是機器碼,對我們人來說不具備可讀性。所以我們換用 binutils 包中的另外一個工具 objdump 來看看這些 sections 中到底具有哪些內容,先來看看 .text section 的:[yihect@juliantec test]$ objdump -d -j .text ./sum.o 
  
./sum.o:     file format elf32-i386 
  
Disassembly of section .text: 
  
00000000 : 
   0:   55                      push   %ebp 
   1:   89 e5                   mov    %esp,%ebp 
   3:   8b 45 0c                mov    0xc(%ebp),%eax 
   6:   03 45 08                add    0x8(%ebp),%eax 
   9:   c9                      leave  
   a:   c3                      ret

objdump 的選項 -d 表示要對由 -j 選擇項指定的 section 內容進行反彙編,也就是由機器碼出發,推導出相應的彙編指令。上面結果顯示在 sum.o 對象文件的 .text 中只是包含了函數 sum_func 的定義。用同樣的方法,我們來看看 sum.o 中 .data section 有什麼內容:[yihect@juliantec test]$ objdump -d -j .data  ./sum.o 
  
./sum.o:     file format elf32-i386 
  
Disassembly of section .data: 
  
00000000 : 
   0:   17 00 00 00                                         ....

這個結果顯示在 sum.o 的 .data section 中定義了一個四字節的變量 gv_inited,其值被初始化成 0x00000017,也就是十進制值 23。別忘了,x86架構是使用小端模式的。

我們接下來來看看字符串表section .strtab。你可以選擇使用 readelf -x :

[yihect@juliantec test]$ readelf -x 8 ./sum.o 
  
Hex dump of section '.strtab': 
  0x00000000 64657469 6e695f76 6700632e 6d757300 .sum.c.gv_inited 
  0x00000010       00 68630063 6e75665f 6d757300 .sum_func.ch.

上面命令中的 8 是 .strtab section 在SHT表格中的索引值,從上面所查看的SHT內容中可以找到。儘管這個命令的輸出結果不是那麼具有可讀性,但我們還是得來說一說如何看這個結果,因爲後續文章中將會使用大量的這種命令。上面結果中的十六進制數據部分從右到左看是地址遞增的方向,而字符內容部分從左到右看是地址遞增的方向。所以,在 .strtab section 中,按照地址遞增的方向來看,各字節的內容依次是 0x00、0x73、0x75、0x6d、0x2e ....,也就是字符 、's'、'u'、'm'、'.' ... 等。如果還是看不太明白,你可以使用 hexdump 直接dumping出 .strtab section 開頭(其偏移在文件內0x2c0字節處)的 32 字節數據:

[yihect@juliantec test]$ hexdump -s 0x2c0 -n 32 -c ./sum.o 
00002c0     s   u   m   .   c     g   v   _   i   n   i   t   e   d 
00002d0     s   u   m   _   f   u   n   c     c   h              
00002dd

.strtab section 中存儲着的都是以字符 爲分割符的字符串,這些字符串所表示的內容,通常是程序中定義的函數名稱、所定義過的變量名稱等等。。。當對象文件中其他地方需要和一個這樣的字符串相關聯的時候,往往會在對應的地方存儲 .strtab section 中的索引值。比方下面將要介紹的符號表 .symtab section 中,有一個條目是用來描述符號 gv_inited 的,那麼在該條目中就會有一個字段(st_name)記錄着字符串 gv_inited 在 .strtab section 中的索引 7 。 .shstrtab 也是字符串表,只不過其中存儲的是 section 的名字,而非所函數或者變量的名稱。

字符串表在真正鏈接和生成進程映像過程中是不需要使用的,但是其對我們調試程序來說就特別有幫助,因爲我們人看起來最舒服的還是自然形式的字符串,而非像天書一樣的數字符號。前面使用objdump來反彙編 .text section 的時候,之所以能看到定義了函數 sum_func ,那也是因爲存在這個字符串表的原因。當然起關鍵作用的,還是符號表 .symtab section 在其中作爲中介,下面我們就來看看符號表。

雖然我們同樣可以使用 readelf -x 來查看符號表(.symtab)section的內容,但是其結果可讀性太差,我們換用 readelf -s 或者 objdump -t 來查看(前者輸出結果更容易看懂):

[yihect@juliantec test]$ readelf -s ./sum.o 
  
Symbol table '.symtab' contains 10 entries: 
   Num:    Value  Size Type    Bind   Vis      Ndx Name 
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS sum.c 
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    2 
     4: 00000000     0 SECTION LOCAL  DEFAULT    3 
     5: 00000000     0 SECTION LOCAL  DEFAULT    4 
     6: 00000000     0 SECTION LOCAL  DEFAULT    5 
     7: 00000000     4 OBJECT  GLOBAL DEFAULT    2 gv_inited 
     8: 00000000    11 FUNC    GLOBAL DEFAULT    1 sum_func 
     9: 00000001     1 OBJECT  GLOBAL DEFAULT  COM ch

在符號表內針對每一個符號,都會相應的設置一個條目。在繼續介紹上面的結果之前,我們還是從規範中找出符號表內條目的C結構定義:

ELF 符號表條目

上面結果中 Type 列顯示出符號的種類。Bind 列定義了符號的綁定類型。種類和綁定類型合併在一起,由結構中 st_info 字段來定義。在ELF格式中,符號類型總共可以有這麼幾種:

ELF 符號類型

類型 STT_OBJECT 表示和該符號對應的是一個數據對象,比方程序中定義過的變量、數組等,比方上面的 gv_inited 和 ch;類型 STT_FUNC 表示該符號對應的是函數,比方上面的 sum_func函數。類型 STT_SECTION 表示該符號和一個 section 相關,這種符號用於重定位。關於重定位,我們下文會介紹。

符號的綁定類型表示了這個符號的可見性,是僅本對象文件可見呢,還是全局可見。它的取值主要有三種:STB_LOCA、STB_GLOBAL和STB_WEAK,具體的內容還請參見規範。關於符號,最重要的就是符號的值(st_value)了。依據對象文件的不同類型,符號的值所表示的含義也略有差異:

a) 在可重定位文件中,如果該符號對應的section index(上面的Ndx)爲SHN_COMMON,那麼符號的值表示的是該數據的對齊要求,比方上面的變量 ch 。

b) 在可重定位文件中,除去上面那條a中定義的符號,對於其他的符號來說,其值表示的是對應 section 內的偏移值。比方 gv_inited 變量定義在 .data section 的最前面,所以其值爲0。

c) 在可執行文件或者動態庫中,符號的值表示的是運行時的內存地址。

好,咱們再來介紹重定位。在所產生的對象文件 test.o 中有對函數 sum_func 的引用,這對我們的x386結構來說,其實就是一條call指令。既然 sum_func 是定義在 sum.o 中的,那對 test.o 來說,它就是一個外部引用。所以,彙編器在產生 test.o 的時候,它會產生一個重定位條目。重定位條目中會包含以下幾類東西:

1) 它會包含一個符號表中一個條目的索引,因爲這樣我們才知道它具體是哪個符號需要被重定位的;

2) 它會包含一個 .text section 中的地址單元的偏移值。原本這個偏移值處的地址單元裏面應該存放着 call 指令的操作數。對上面來說,也就是函數 sum_func 的地址,但是目前這個地址彙編器還不知道。

3) 它還會包含一個tag,以指明該重定位屬於何種類型。

當我們用鏈接器去鏈接這個對象文件的時候,鏈接器會遍歷所有的重定位條目,碰到像 sum_func 這樣的外部引用,它會找到 sum_func 的確切地址,並且把它寫回到上面 call 指令操作數所佔用的那個地址單元。像這樣的操作,稱之爲重定位操作。link editor 和 dynamic linker 都要完成一些重定位操作,只不過後者的動作更加複雜,因爲它是在運行時動態完成的,我們以後的文章會介紹相關的內容。概括一下,所謂重定位操作就是:“彙編的時候產生一個空坐位,上面用紅紙寫着要坐在這個座位上的人的名字,然後連接器在開會前安排那個人坐上去”。

如前面我們說過的,對象文件中的重定位條目,會構成一個個單獨的 section。這些 section 的名字,常會是這樣的形式:".rel.XXX"。其中XXX表示的是這些重定位條目所作用到的section,如 .text section。重定位條目所構成的section需要和另外兩個section產生關聯:符號表section(表示要重定位的是哪一個符號)以及受影響地址單元所在的section。在使用工具來查看重定位section之前,我們先從規範中找出來表示重定位條目的結構定義(有兩種,依處理器架構來定):

ELF 重定位條目結構定義

結構中 r_offset 對於可重定位文件.o來說,就是地址單元的偏移值(前面的b條);另外對可執行文件或者動態庫來說,就是該地址單元的運行時地址。上面 a條中的符號表內索引和c條中的類型,一起構成了結構中的字段 r_info。

重定位過程在計算最終要放到受影響地址單元中的時候,需要加上一個附加的數 addend。當某一種處理器選用 Elf32_Rela 結構的時候,該 addend 就是結構中的 r_addend 字段;否則該 addend 就是原本存儲在受影響地址單元中的原有值。x86架構選用 Elf32_Rel 結構來表示重定位條目。ARM架構也是用這個。

重定位類型意味着如何去修改受影響的地址單元,也就是按照何種方式去計算需要最後放在受影響單元裏面的值。具體的重定位類型有哪些,取決與特定的處理器架構,你可以參考相關規範。這種計算方式可以非常的簡單,比如在x386上的 R_386_32 類型,它規定只是將附加數加上符號的值作爲所需要的值;該計算方式也可以是非常的複雜,比如老版本ARM平臺上的 R_ARM_PC26。在這篇文章的末尾,我會詳細介紹一種重定位類型:R_386_PC32。至於另外一些重要的重定位類型,如R_386_GOTPC,R_386_PLT32,R_386_GOT32,R_386_GLOB_DAT 以及 R_386_JUMP_SLOT 等。讀者可以先自己研究,也許我們會在後面後面的文章中討論到相關主題時再行介紹。

我們可以使用命令 readelf -r 來查看重定位信息:

[yihect@juliantec test_2]$ readelf -r test.o 
  
Relocation section '.rel.text' at offset 0x464 contains 8 entries: 
Offset     Info    Type            Sym.Value  Sym. Name 
00000042  00000902 R_386_PC32        00000000   sub_func 
00000054  00000a02 R_386_PC32        00000000   sum_func 
0000005d  00000a02 R_386_PC32        00000000   sum_func 
0000007a  00000501 R_386_32          00000000   .rodata 
0000007f  00000b02 R_386_PC32        00000000   printf 
0000008d  00000c02 R_386_PC32        00000000   double_gv_inited 
00000096  00000501 R_386_32          00000000   .rodata 
0000009b  00000b02 R_386_PC32        00000000   printf

至此,ELF對象文件格式中的 linking view ,也就是上面組成圖的左邊部分,我們已經介紹完畢。在這裏最重要的概念是 section。在可重定位文件裏面,section承載了大多數被包含的東西,代碼、數據、符號信息、重定位信息等等。可重定位對象文件裏面的這些sections是作爲輸入,給鏈接器那去做鏈接用的,所以這些 sections 也經常被稱做輸入 section。

鏈接器在鏈接可執行文件或動態庫的過程中,它會把來自不同可重定位對象文件中的相同名稱的 section 合併起來構成同名的 section。接着,它又會把帶有相同屬性(比方都是隻讀並可加載的)的 section 都合併成所謂 segments(段)。segments 作爲鏈接器的輸出,常被稱爲輸出section。我們開發者可以控制哪些不同.o文件的sections來最後合併構成不同名稱的 segments。如何控制呢,就是通過 linker script 來指定。關於鏈接器腳本,我們這裏不予討論。

一個單獨的 segment 通常會包含幾個不同的 sections,比方一個可被加載的、只讀的segment 通常就會包括可執行代碼section .text、只讀的數據section .rodata以及給動態鏈接器使用的符號section .dymsym等等。section 是被鏈接器使用的,但是 segments 是被加載器所使用的。加載器會將所需要的 segment 加載到內存空間中運行。和用 sections header table 來指定一個可重定位文件中到底有哪些 sections 一樣。在一個可執行文件或者動態庫中,也需要有一種信息結構來指出包含有哪些 segments。這種信息結構就是 program header table,如ELF對象文件格式中右邊的 execute view 所示的那樣。

我們可以用 readelf -l 來查看可執行文件的程序頭表,如下所示:

[yihect@juliantec test_2]$ readelf -l ./test 
  
Elf file type is EXEC (Executable file) 
Entry point 0x8048464 
There are 7 program headers, starting at offset 52 
  
Program Headers: 
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align 
  PHDR           0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4 
  INTERP         0x000114 0x08048114 0x08048114 0x00013 0x00013 R   0x1 
      [Requesting program interpreter: /lib/ld-linux.so.2] 
  LOAD           0x000000 0x08048000 0x08048000 0x0073c 0x0073c R E 0x1000 
  LOAD           0x00073c 0x0804973c 0x0804973c 0x00110 0x00118 RW  0x1000 
  DYNAMIC        0x000750 0x08049750 0x08049750 0x000d0 0x000d0 RW  0x4 
  NOTE           0x000128 0x08048128 0x08048128 0x00020 0x00020 R   0x4 
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4 
  
Section to Segment mapping: 
  Segment Sections... 
   00     
   01     .interp 
   02     .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame 
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag 
   06

結果顯示,在可執行文件 ./test 中,總共有7個 segments。同時,該結果也很明白顯示出了哪些 section 映射到哪一個 segment 當中去。比方在索引爲2的那個segment 中,總共有15個 sections 映射進來,其中包括我們前面提到過的 .text section。注意這個segment 有兩個標誌: R 和 E。這個表示該segment是可讀的,也可執行的。如果你看到標誌中有W,那表示該segment是可寫的。

我們還是來解釋一下上面的結果,希望你能對照着TISCv1.2規範裏面的文本來看,我這裏也列出程序頭表條目的C結構:

ELF 程序頭表項

上面類型爲PHDR的segment,用來包含程序頭表本身。類型爲INTERP的segment只包含一個 section,那就是 .interp。在這個section中,包含了動態鏈接過程中所使用的解釋器路徑和名稱。在Linux裏面,這個解釋器實際上就是 /lib/ ,這可以通過下面的 hexdump 看出來:[yihect@juliantec test_2]$ hexdump -s 0x114 -n 32 -C  ./test  
00000114  2f 6c 69 62 2f 6c 64 2d  6c 69 6e 75 78 2e 73 6f  |/lib/ld-linux.so| 
00000124  2e 32 00 00 04 00 00 00  10 00 00 00 01 00 00 00  |.2..............| 
00000134

爲什麼會有這樣的一個 segment?這是因爲我們寫的應用程序通常都需要使用動態鏈接庫.so,就像 test 程序中所使用的 libsub.so 一樣。我們還是先大致說說程序在linux裏面是怎麼樣運行起來的吧。當你在 shell 中敲入一個命令要執行時,內核會幫我們創建一個新的進程,它在往這個新進程的進程空間裏面加載進可執行程序的代碼段和數據段後,也會加載進動態連接器(在Linux裏面通常就是 /lib/ld-linux.so 符號鏈接所指向的那個程序,它本省就是一個動態庫)的代碼段和數據。在這之後,內核將控制傳遞給動態鏈接庫裏面的代碼。動態連接器接下來負責加載該命令應用程序所需要使用的各種動態庫。加載完畢,動態連接器纔將控制傳遞給應用程序的main函數。如此,你的應用程序才得以運行。

這裏說的只是大致的應用程序啓動運行過程,更詳細的,我們會在後續的文章中繼續討論。我們說link editor鏈接的應用程序只是部分鏈接過的應用程序。經常的,在應用程序中,會使用很多定義在動態庫中的函數。最最基礎的比方C函數庫(其本身就是一個動態庫)中定義的函數,每個應用程序總要使用到,就像我們test程序中使用到的 printf 函數。爲了使得應用程序能夠正確使用動態庫,動態連接器在加載動態庫後,它還會做更進一步的鏈接,這就是所謂的動態鏈接。爲了讓動態連接器能成功的完成動態鏈接過程,在前面運行的link editor需要在應用程序可執行文件中生成數個特殊的 sections,比方 .dynamic、.dynsym、.got和.plt等等。這些內容我們會在後面的文章中進行討論。

我們先回到上面所輸出的文件頭表中。在接下來的數個 segments 中,最重要的是三個 segment:代碼段,數據段和堆棧段。代碼段和堆棧段的 VirtAddr 列的值分別爲 0x08048000 和 0x0804973c。這是什麼意思呢?這是說對應的段要加載在進程虛擬地址空間中的起始地址。雖然在可執行文件中規定了 text segment和 data segment 的起始地址,但是最終,在內存中的這些段的真正起始地址,卻可能不是這樣的,因爲在動態鏈接器加載這些段的時候,需要考慮到頁面對齊的因素。爲什麼?因爲像x86這樣的架構,它給內存單元分配讀寫權限的最小單位是頁(page)而不是字節。也就是說,它能規定從某個頁開始、連續多少頁是隻讀的。卻不能規定從某個頁內的哪一個字節開始,連續多少個字節是隻讀的。因爲x86架構中,一個page大小是4k,所以,動態鏈接器在加載 segment 到虛擬內存中的時候,其真實的起始地址的低12位都是零,也即以 0x1000 對齊。

我們先來看看一個真實的進程中的內存空間信息,拿我們的 test 程序作爲例子。在 Linux 系統中,有一個特殊的由內核實現的虛擬文件系統 /proc。內核實現這個文件系統,並將它作爲整個Linux系統面向外部世界的一個接口。我們可以通過 /proc 觀察到一個正在運行着的Linux系統的內核數據信息以及各進程相關的信息。所以我們如果要查看某一個進程的內存空間情況,也可以通過它來進行。使用/proc唯一需要注意的是,由於我們的 test 程序很小,所以當我們運行起來之後,它很快就會結束掉,使得我們沒有時間去查看test的進程信息。我們需要想辦法讓它繼續運行,或者最起碼運行直到讓我們能從 /proc 中獲取得到想要的信息後再結束。

我們有多種選擇。最簡單的是,在 test main 程序中插入一個循環,然後在循環中放入 sleep() 的調用,這樣當程序運行到這個循環的時候,就會進入“運行-睡眠-運行-睡眠”循環中。這樣我們就有機會去看它的虛擬內存空間信息。另外一個方法,是使用調試器,如GDB。我們設置一個斷點,然後在調試過程中讓test進程在這個斷點處暫停,這樣我們也有機會獲得地址空間的信息。我們這裏就使用這種方法。當然,爲了能讓GDB調試我們的 test,我們得在編譯的時候加上"-g"選項。最後我們用下面的命令得到 test 程序對應進程的地址空間信息。

[yihect@juliantec ~]$ cat /proc/`pgrep test`/maps 
00103000-00118000 r-xp 00000000 08:02 544337     /lib/ld-2.3.4.so 
00118000-00119000 r--p 00015000 08:02 544337     /lib/ld-2.3.4.so 
00119000-0011a000 rw-p 00016000 08:02 544337     /lib/ld-2.3.4.so 
0011c000-00240000 r-xp 00000000 08:02 544338     /lib/tls/libc-2.3.4.so 
00240000-00241000 r--p 00124000 08:02 544338     /lib/tls/libc-2.3.4.so 
00241000-00244000 rw-p 00125000 08:02 544338     /lib/tls/libc-2.3.4.so 
00244000-00246000 rw-p 00244000 00:00 0 
00b50000-00b51000 r-xp 00000000 08:02 341824     /usr/lib/libsub.so 
00b51000-00b52000 rw-p 00000000 08:02 341824     /usr/lib/libsub.so 
08048000-08049000 r-xp 00000000 08:05 225162     /home/yihect/test_2/test 
08049000-0804a000 rw-p 00000000 08:05 225162     /home/yihect/test_2/test 
b7feb000-b7fed000 rw-p b7feb000 00:00 0 
b7fff000-b8000000 rw-p b7fff000 00:00 0 
bff4c000-c0000000 rw-p bff4c000 00:00 0 
ffffe000-fffff000 ---p 00000000 00:00 0

注意,上面命令中的pgre test 是用`括起來的,它不是單引號,而是鍵盤上 Esc 字符下面的那個字符。從這個結果上可以看出,所有的段,其起始地址和結束地址(前面兩列)都是0x1000對齊的。結果中也列出了對應的段是從哪裏引過來的,比方動態鏈接器/lib/ld-2.3.4.so、C函數庫和test程序本身。注意看test程序引入的代碼段起始地址是 0x08048000,這和我們 ELF 文件中指定的相同,但是結束地址卻是0x08049000,和文件中指定的不一致(0x08048000+0x0073c=0x0804873c)。這裏,其實加載器也把數據segment中開頭一部分也映射進了 text segment 中去;同樣的,進程虛擬內存空間中的 data segment 從 08049000 開始,而可執行文件中指定的是從 0x0804973c 開始。所以加載器也把代碼segment中末尾一部分也映射進了 data segment 中去了。

從程序頭表中我們可以看到一個類型爲 GNU_STACK 的segment,這是 stack segment。程序頭表中的這一項,除了 Flg/Align 兩列不爲空外, 其他列都爲0。這是因爲堆棧段在虛擬內存空間中,從哪裏開始、佔多少字節是由內核說了算的,而不決定於可執行程序。實際上,內核決定把堆棧段放在整個進程地址空間的用戶空間的最上面,所以堆棧段的末尾地址就是 0xc0000000。別忘記在 x86 中,堆棧是從高向低生長的。

好,爲了方便你對後續文章的理解,我們在這裏討論一種比較簡單的重定位類型 R_386_PC32。前面我們說過重定義的含義,也即在連接階段,根據某種計算方式計算出一個新的值(通常是地址),然後將這個值重新改寫到對象文件或者內存映像中某個section中的某個地址單元中去的這樣一個過程。那所謂重定位類型,就規定了使用何種方式,去計算這個值。既然是計算,那就肯定需要涉及到所要納入計算的變量。實際上,具體有哪些變量參與計算如同如何進行計算一樣也是不固定的,各種重定位類型有自己的規定。

根據規範裏面的規定,重定位類型 R_386_PC32 的計算需要有三個變量參與:S,A和P。其計算方式是 S+A-P。根據規範,當R_386_PC32類型的重定位發生在 link editor 鏈接若干個 .o 對象文件從而形成可執行文件的過程中的時候,變量S指代的是被重定位的符號的實際運行時地址,而變量P是重定位所影響到的地址單元的實際運行時地址。在運行於x86架構上的Linux系統中,這兩個地址都是虛擬地址。變量A最簡單,就是重定位所需要的附加數,它是一個常數。別忘了x86架構所使用的重定位條目結構體類型是 Elf32_Rela,所以附加數就存在於受重定位影響的地址單元中。重定位最後將計算得到的值patch到這個地址單元中。

或許,咱們舉一個實際例子來闡述可能對你更有用。在我們的 test 程序中,test.c 的 main 函數中需要調用定義在 sum.o 中的 sum_func 函數,所以link editor 在將 test.o/sum.o 聯結成可執行文件 test 的時候,必須處理一個重定位,這個重定位就是 R_386_PC32 類型的。我們先用 objdump 來查看 test.o 中的 .text section 內容(我只選取了前面一部分):[yihect@juliantec test_2]$ objdump -d -j .text ./test.o 
  
./test.o:     file format elf32-i386 
  
Disassembly of section .text: 
  
00000000 <main />: 
   0:   55                      push   %ebp 
   1:   89 e5                   mov    %esp,%ebp 
   3:   83 ec 18                sub    $0x18,%esp 
   6:   83 e4 f0                and    $0xfffffff0,%esp 
   9:   b8 00 00 00 00          mov    $0x0,%eax 
   e:   83 c0 0f                add    $0xf,%eax 
  11:   83 c0 0f                add    $0xf,%eax 
  14:   c1 e8 04                shr    $0x4,%eax 
  17:   c1 e0 04                shl    $0x4,%eax 
  1a:   29 c4                   sub    %eax,%esp 
  1c:   c7 45 fc 0a 00 00 00    movl   $0xa,0xfffffffc(%ebp) 
  23:   c7 45 f8 2d 00 00 00    movl   $0x2d,0xfffffff8(%ebp) 
  2a:   c7 45 f4 03 00 00 00    movl   $0x3,0xfffffff4(%ebp) 
  31:   c7 45 f0 48 00 00 00    movl   $0x48,0xfffffff0(%ebp) 
  38:   83 ec 08                sub    $0x8,%esp 
  3b:   ff 75 f0                pushl  0xfffffff0(%ebp) 
  3e:   ff 75 f4                pushl  0xfffffff4(%ebp) 
  41:   e8 fc ff ff ff          call   42 
  46:   83 c4 08                add    $0x8,%esp 
  49:   50                      push   %eax 
  4a:   83 ec 0c                sub    $0xc,%esp 
  4d:   ff 75 f8                pushl  0xfffffff8(%ebp) 
  50:   ff 75 fc                pushl  0xfffffffc(%ebp) 
  53:   e8 fc ff ff ff          call   54 
  58:   83 c4 14                add    $0x14,%esp 
  ......

如結果所示,在離開 .text section 開始 0x53 字節的地方,有一條call指令。這條指令是對 sum_func 函數的調用,objdump 將其反彙編成 call 54,這是因爲偏移 0x54 字節的地方原本應該放着 sum_func 函數的地址,但現在因爲 sum_func 定義在 sum.o 中,所以這個地方就是重定位需要做 patch 的地址單元所在處。我們注意到,這個地址單元的值爲 0xfffffffc,也就是十進制的 -4(計算機中數是用補碼錶示的)。所以,參與重定位運算的變量A就確定了,即是 -4。

我們在 test.o 中找出影響該地址單元的重定位記錄如下:

[yihect@juliantec test_2]$ readelf -r ./test.o |  grep 54 
00000054  00000a02 R_386_PC32        00000000   sum_func

果然,如你所見,該條重定位記錄是 R_386_PC32 類型的。前面變量A確定了,那麼另外兩個變量S和變量P呢?從正向去計算這兩個變量的值比較麻煩。儘管我們知道,在Linux裏面,鏈接可執行程序時所使用的默認的鏈接器腳本將最後可執行程序的 .text segment 起始地址設置在 0x08048000的位置。但是,從這個地址出發,去尋找符號(函數)sub_func 和 上面受重定位影響的地址單元的運行時地址的話,需要經過很多人工計算,所以比較麻煩。

相反的,我們使用objdump工具像下面這樣分析最終鏈接生成的可執行程序 ./test 的 .text segment 段,看看函數 sum_func 和 那個受影響單元的運行時地址到底是多少,這是反向的查看鏈接器的鏈接結果。鏈接器在鏈接的過程中是正向的將正確的地址分配給它們的。

[yihect@juliantec test_2]$ objdump -d -j .text ./test 
  
./test:     file format elf32-i386 
  
Disassembly of section .text: 
  
08048498 : 
8048498:       31 ed                   xor    %ebp,%ebp 
...... 
08048540 <main />: 
...... 
804858a:       83 ec 0c                sub    $0xc,%esp 
804858d:       ff 75 f8                pushl  0xfffffff8(%ebp) 
8048590:       ff 75 fc                pushl  0xfffffffc(%ebp) 
8048593:       e8 74 00 00 00          call   804860c 
8048598:       83 c4 14                add    $0x14,%esp 
804859b:       50                      push   %eax 
...... 

0804860c : 
804860c:       55                      push   %ebp 
804860d:       89 e5                   mov    %esp,%ebp 
804860f:       8b 45 0c                mov    0xc(%ebp),%eax 
8048612:       03 45 08                add    0x8(%ebp),% 
8048615:       c9                      leave  
8048616:       c3                      ret    
8048617:       90                      nop 
  
......

從中很容易的就可以看出,鏈接器給函數 sum_func 分配的運行時地址是 0x0804860c,所以變量S的值就是 0x0804860c。那麼變量P呢?它表示的是重定位所影響地址單元的運行地址。如果要計算這個地址,我們可以先看看 main 函數的運行時地址,再加上0x54字節的偏移來得到。從上面看出 main 函數的運行時地址爲 0x08048540,所以重定位所影響地址單元的運行時地址爲 0x08048540+0x54 = 0x08048594。所以重定位計算的最終結果爲:

S+A-P = 0x0804860c+(-4)-0x08048594 = 0x00000074

從上面可以看出,鏈接器在鏈接過程中,確實也把這個計算得到的結果存儲到了上面 call 指令操作數所在的地址單元中去了。那麼,程序在運行時,是如何憑藉這樣一條帶有如此操作數的 call 指令來調用到(或者跳轉到)函數 sum_func 中去的呢?

你看,調用者 main 和被調用者 sum_func 處在同一個text segment中。根據x86架構或者IBM兼容機的彙編習慣,段內轉移或者段內跳轉時使用的尋址方式是PC相對尋址。也就是若要讓程序從一個段內的A處,跳轉到同一段內的B處,那麼PC相對尋址會取程序在A處執行時的PC值,再加上某一個偏移值(offset),得到要跳轉的目標地址(B處地址)。那麼,對於x86架構來說,由於有規定,PC總是指向下一條要執行的指令,那麼當程序執行在call指令的時候,PC指向的是下一條add指令,其值也就是 0x8048598。最後,尋址的時候再加上call指令的操作數0x74作爲偏移,計算最終的 sum_func 函數目標地址爲 0x8048598+0x74 = 0x804860c。

有點意思吧:),如果能繞出來,那說明我們是真的明白了,其實,繞的過程本身就充滿着趣味性,就看你自己的心態了。說到這裏,本文行將結束。本文所介紹的很多內容,可能在某些同學眼中會過於簡單,但是爲了體現知識的完整性、同時也爲了讓大家先有個基礎以便更容易的看後續的文章,我們還是在這裏介紹一下ELF格式的基礎知識。下面一篇關於ELF主題的文章,將詳細介紹動態連接的內在實現。屆時,你將看到大量的實際代碼挖掘

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章