elf格式原理介紹

基礎

機器執行的是機器指令,而機器指令就是一堆二進制的數字。高級語言編寫的程序之所以可以在不同的機器上移植就因爲有爲不同機器設計的編譯器的存在。高級語言的編譯器就是把高級語言寫的程序轉換成某個機器能直接執行的二進制代碼。以上的知識在我們學習CS(Computer Science)的初期,老師都會這麼對我們講。但是我就產生疑問了:既然機器都是執行的二進制代碼,那麼是不是說只要硬件相互兼容,不同操作系統下的可執行文件可以互相運行呢?答案肯定是不行。這就要談到可執行文件的格式問題。

每個操作系統都會有自己的可執行文件的格式,比如以前的Unix®是用a.out格式的,現代的Unix®類系統使用elf格式, WindowsNT®是使用基於COFF格式的可執行文件。那麼最簡單的格式應該是DOS的可執行格式,嚴格來說DOS的可執行文件沒有什麼格式可言,就是把二進制代碼安順序放在文件裏,運行時DOS操作系統就把所有控制計算機的權力都給了這個程序。這種方式的不足之處是顯而易見的,所以現代的操作系統都有一種更好的方式來定義可執行文件的格式。一種常見的方法就是爲可執行文件分段,一般來說把程序指令的內容放在.text段中,把程序中的數據內容放在. data段中,把程序中未初始化的數據放在.bss段中。這種做法的好處有很多,可以讓操作系統內核來檢查程序防止有嚴重錯誤的程序破壞整個運行環境。比如:某個程序想要修改.text段中的內容,那麼操作系統就會認爲這段程序有誤而立即終止它的運行,因爲系統會把.text段的內存標記爲只讀。在. bss段中的數據還沒有初始化,就沒有必要在可執行文件中浪費儲存空間。在.bss中只是表明某個變量要使用多少的內存空間,等到程序加載的時候在由內核把這段未初始化的內存空間初始化爲0。這些就是分段儲存可執行文件的內容的好處。

ELF格式

下面談一下Unix系統裏的兩種重要的格式:a.out和elf(Executable and Linking Format)。這兩種格式中都有符號表(symbol table),其中包括所有的符號(程序的入口點還有變量的地址等等)。在elf格式中符號表的內容會比a.out格式的豐富的多。但是這些符號表可以用 strip工具去除,這樣的話這個文件就無法讓debug程序跟蹤了,但是會生成比較小的可執行文件。a.out文件中的符號表可以被完全去除,但是 elf中的在加載運行是起着重要的作用,所以用strip永遠不可能完全去除elf格式文件中的符號表。但是用strip命令不是完全安全的,比如對未連接的目標文件來說如果用strip去掉符號表的話,會導致連接器無法連接。例如:
代碼:

$:gcc -c hello.c
$:ls hello.c hello.o

用gcc把hello.c編譯成目標文件hello.o
代碼:

$:strip hello.o

用strip去掉hello.o中的符號信息。
代碼:

$:gcc hello.o /usr/lib/gcc/i686-pc-linux-gnu/3.4.5/…/…/…/crt1.o: In function _start': init.c: (.text+0x18) : undefined reference tomain’ collect2: ld returned 1 exit status

再用gcc連接時,連接器ld報錯。說明在目標文件中的符號起着很重要的作用,如果要發佈二進制的程序的話,在debug後爲了減小可執行文件的大小,可以用strip來除去符號信息但是在程序的調試階段還是不要用strip爲好。

在接下去討論以前,我們還要來講講relocations的概念:首先有個簡單的程序hello.c
代碼:

$:cat hello.c

main( )
{
  printf("Hello World\n");
}

當我們把hello.c編譯爲目標文件時,我們並沒有在源文件中定義printf這個函數,所以彙編器也不知道printf這個函數的具體的地址,所以在目標文件中就會留下printf這個符號。以下的工作就交給連接器了,連接器會找到這個函數的入口地址然後傳遞給這個文件最終形成可執行文件。這個過程就叫做relocations。a.out格式的可執行文件是沒有這種relocation的功能的,內核不會執行其中還有未知函數的入口地址的可執行文件的。在目標文件中當然可以relocation,只不過連接器需要把未知函數的入口地址完全找到,生成可執行文件纔行。這樣就有一個很尷尬的問題,在 a.out格式中極其難以實現動態連接技術。要知道爲什麼現在的Unix幾乎都是用的elf格式的可執行文件就要了解a.out格式的短處。

a.out的符號是極其有限的,在/usr/include/linux/asm/a.out.h中定義了一個結構exec就是:
代碼:

struct exec { unsigned long a_info; /Use macros N_MAGIC, etc for access / unsigned a_text; / length of text, in bytes / unsigned a_data; / length of data, in bytes / unsigned a_bss; / length of uninitialized data area for file, in bytes/ unsigned a_syms; /* length of symbol table data in file, in bytes / unsigned a_entry; / start address */ unsigned a_trsize; /*length of relocation info for text, in bytes */ unsigned a_drsize; /*length of relocation info for data, in bytes */ };

在這個結構中更本沒有指示每個段在文件中的開始位置,內核加載器具有一些非正式的方法來加載可執行文件的。明顯的,a.out 是不支持動態連接的。(在內部不支持動態連接,用某些技術也是可以實現a.out的動態連接)

要了解elf可執行文件的運行方式,我們有必要討論一下動態連接技術。很多人對動態連接技術十分熟悉,但是很少有人真正瞭解動態連接的內部工作方式。回想沒有動態連接的日子,程序員寫程序時不用什麼都從頭開始,他們可以調用定義的很好的函數,然後再用連接器與函數庫連接。這樣的話使得程序員更加有效率,但是一個十分重要的問題出現了:這樣產生的可執行文件就會很大。因爲連接器把程序需要用的所有函數的代碼都複製到了可執行文件中去了。這種連接方式就是所謂的靜態連接,與之相對的就是動態連接。連接器在可執行文件中標記出程序調用外部函數的位置,並不把代碼複製進去,只是標出函數在動態連接庫中的位置。用這樣的方式生成的特殊可執行文件就是動態連接的。在運行這種動態程序時,系統在運行時把該程序調用的外部函數地址映射到程序地址,這就是所謂的動態連接,系統就有一個程序叫做動態連接器,在動態連接的程序執行前都要先把地址映射好。很顯然的,必須有一種機制保證動態連接的程序中的函數地址正確地指向了動態連接庫的某個函數地址。這就需要討論一下elf可執行文件格式處理動態連接的機制了。

elf的動態連接庫是內存位置無關的,就是說你可以把這個庫加載到內存的任何位置都沒有影響。這就叫做position independent。而a.out的動態連接庫是內存位置有關的,它一定要被加載到規定的內存地址才能工作。在編譯內存位置無關的動態連接庫時,要給編譯器加上 -fpic選項,讓編譯器產生的目標文件是內存位置無關的還會盡量減少對變量引用時使用絕對地址。把庫編譯成內存位置無關會帶來一些花費,編譯器會保留一個寄存器來指向全局偏移量表(global offset table (or GOT for short)),這就會導致編譯器在優化代碼時少了一個寄存器可以使用,但是在最壞的情況下這種性能的減少只有3%,在其他情況下是大大小於3%的。

Elf的另一個特點是它的動態連接庫是在運行時處理符號的,這是通過用符號表和再佈置(relocation)表來實現的。在載入文件時並不能立即執行,要在處理完符號表把所有的地址都relocation完後纔可以執行。這個聽起來有點複雜而且可能導致文件運行慢,不過對elf做了很大的優化後,這種減慢已經是微不足道的了。理論上說不是用-fpic選項編譯出來的目標文件也可以用作動態連接庫,但是在運行時會需要做數目極大的 relocation,這是對運行速度有極大影響的。這樣的程序性能是很差的,幾乎沒有可用性。

當從動態連接庫中讀一個全局變量時與從非-fpic編譯的目標文件讀是不同的。讀動態連接的庫中的變量是通過GOT來尋找到目標變量的,GOT已經由某一個寄存器指向了。GOT本生就是一個指針列表,找到GOT中的某一個指針就可以讀到所要的全局變量了,有了GOT我們要讀出一個變量只要做一次 relocation。

下面我們來看看elf文件中到底有些什麼信息:
代碼:

$:cat hello.c

main()
{
  printf("Hello World\n");
}

$:gcc-elf -c hello.c

還是這個簡單的程序,用gcc把它編譯成目標文件hello.o。然後用readelf工具來探測一下elf文件的內容。(readelf是在 binutils軟件包裏的一個工具,大多數Linux發行版都包含它)
代碼:

$:readelf -h hello.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: 256 (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: 11
Section header string table index: 8

-h選項是列出elf文件的頭信息。Magic:字段是一個標識符,只要Magic字段是7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00的文件都是elf文件。Class:字段是表示elf的版本,這是一個32位的elf。Machine:字段是指出目標文件的平臺信息,這裏是 I386兼容平臺。其他的字段可以從其字面上看出它的意義,這裏就不一一解釋了。

下面用-S選項列出段的頭信息:
代碼:

$:readelf -S hello.o
There are 11 section headers, starting at offset 0x100:
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 00002a 00 AX 0 0 4
[ 2] .rel.text REL 00000000 000370 000010 08 9 1 4
[ 3] .data PROGBITS 00000000 000060 000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000060 000000 00 WA 0 0 4
[ 5] .rodata PROGBITS 00000000 000060 00000e 00 A 0 0 1
[ 6] .note.GNU-stack PROGBITS 00000000 00006e 000000 00 0 0 1 [ 7] .comment PROGBITS 00000000 00006e 00003e 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 0000ac 000051 00 0 0 1
[ 9] .symtab SYMTAB 00000000 0002b8 0000a0 10 10 8 4
[10] .strtab STRTAB 00000000 000358 000015 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)

Name字段顯示的是各個段的名字,Type顯示段的屬性,Addr是每個段載入虛擬內存的位置,Off是每個段在目標文件中的偏移位置,Size是每個段的大小,後面的一些字段是表示段的可寫,可讀,或者可執行。

用-r可以列出elf文件中的relocation:
代碼:

$:readelf -r hello.o
Relocation section ‘.rel.text’ at offset 0x370 contains 2 entries: Offset Info Type Sym.Value Sym. Name 0000001f 00000501 R_386_32 00000000 .rodata 00000024 00000902 R_386_PC32 00000000 printf

在.text段中有兩個relocation,其中之一就是printf函數的relcation。Offset指出當relocation時要把 printf函數的入口地址貼到離.text段開頭00000024處。

下面我們可以看一下連接過後的可執行文件中的內容:
代碼:

$:gcc hello.o
$:readelf -S a.out
There are 32 section headers, starting at offset 0xbc4:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 08048134 000134 000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048148 000148 000020 00 A 0 0 4
[ 3] .hash HASH 08048168 000168 00002c 04 A 4 0 4
[ 4] .dynsym DYNSYM 08048194 000194 000060 10 A 5 1 4
[ 5] .dynstr STRTAB 080481f4 0001f4 000060 00 A 0 0 1
[ 6] .gnu.version VERSYM 08048254 000254 00000c 02 A 4 0 2 [ 7] .gnu.version_r VERNEED 08048260 000260 000020 00 A 5 1 4 [ 8] .rel.dyn REL 08048280 000280 000008 08 A 4 0 4
[ 9] .rel.plt REL 08048288 000288 000010 08 A 4 11 4
[10] .init PROGBITS 08048298 000298 000017 00 AX 0 0 4
[11] .plt PROGBITS 080482b0 0002b0 000030 04 AX 0 0 4
[12] .text PROGBITS 080482e0 0002e0 0001b4 00 AX 0 0 16
[13] .fini PROGBITS 08048494 000494 00001a 00 AX 0 0 4
[14] .rodata PROGBITS 080484b0 0004b0 000016 00 A 0 0 4
[15] .eh_frame PROGBITS 080484c8 0004c8 000004 00 A 0 0 4
[16] .ctors PROGBITS 080494cc 0004cc 000008 00 WA 0 0 4
[17] .dtors PROGBITS 080494d4 0004d4 000008 00 WA 0 0 4
[18] .jcr PROGBITS 080494dc 0004dc 000004 00 WA 0 0 4
[19] .dynamic DYNAMIC 080494e0 0004e0 0000c8 08 WA 5 0 4
[20] .got PROGBITS 080495a8 0005a8 000004 04 WA 0 0 4
[21] .got.plt PROGBITS 080495ac 0005ac 000014 04 WA 0 0 4
[22] .data PROGBITS 080495c0 0005c0 00000c 00 WA 0 0 4
[23] .bss NOBITS 080495cc 0005cc 000004 00 WA 0 0 4
[24] .comment PROGBITS 00000000 0005cc 0001b2 00 0 0 1 [25] .debug_aranges PROGBITS 00000000 000780 000058 00 0 0 8 [26] .debug_info PROGBITS 00000000 0007d8 000164 00 0 0 1 [27] .debug_abbrev PROGBITS 00000000 00093c 000020 00 0 0 1 [28] .debug_line PROGBITS 00000000 00095c 00015a 00 0 0 1
[29] .shstrtab STRTAB 00000000 000ab6 00010c 00 0 0 1
[30] .symtab SYMTAB 00000000 0010c4 000510 10 31 56 4
[31] .strtab STRTAB 00000000 0015d4 000322 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)

這裏的段比目標文件hello.o的段要多的多,這是因爲這個程序需要elf的一個動態連接庫libc.so.1。在這裏需要簡單的介紹一下內核加載 elf可執行文件。內核先是把整個文件加載到用戶的虛擬內存空間,如果程序是與動態連接庫連接的,則程序中就會包含動態連接器的名稱,可能是 /lib/elf/ld-linux.so.1。(動態連接器本身也是一個動態連接庫)

在文件的尾部的一些段的Addr值是00000000,因爲這些都是符號表,動態連接器並不把這些段的內容加載到內存中。. interp段中只是儲存這一個ASCII的字符串,它就是動態連接器的名字(路徑)。.hash, .dynsym, .dynstr這三個段是用於動態連接器執行relocation時的符號表。.hash是一個哈希表,可以讓我們很快的從.dynsym中找到所需的符號。

.plt段中儲存着我們調用動態連接庫中的函數入口地址,在默認狀態下,程序初始化時,.plt中的指針並不是指向正確的函數入口地址的而是指向動態連接器本身,當你在程序中調用某個動態連接庫中的函數時,連接器會找到那個函數在動態連接庫中的位置,再把這個位置連接到.plt段中。這樣做的好處是如果在程序中調用了很多動態連接庫中的函數,會花費掉連接器很長時間把每個函數的地址連接到.plt段中。所以就可以採用連接器只是把要用的函數地址連接進去,以後要用的再連接。但是也可以設置環境變量LD_BIND_NOW=1讓連接器在程序執行前把所有的函數地址都連接好,這主要是方便調試程序。

readelf工具還有很多選項,具體內容可以查看man手冊。在文章的開頭就說elf文件格式很方便運用動態連接技術,下面我就寫一個就簡單的動態連接庫的例子:
代碼:

$:cat Dyn_hello.c

int main(void)
{
  hi();
}

$:cat hi.c
#include <stdio.h>
hi()
{
  printf("Hello world\n");
}

兩個簡單的文件,在mian函數中調用hi()函數,下面並不是把兩個文件一起編譯,而是把hi.c編譯成動態連接庫。(注意Dyn_hello.c中並沒有包含任何頭文件。)
代碼:

$:gcc -fPIC -c hi.c
$:gcc -shared -o libhi.so hi.o

現在在當前目錄下有一個名字爲libhi.so的文件,這就就是僅含有一個函數的動態連接庫。
代碼:

$:gcc -c Dyn_hello.c
$:gcc -o Dyn_hello Dyn_hello.o -L. -lhi

在當前目錄下有了一個Dyn_hello可執行文件,現在就可以執行它了。
代碼:

$:./Dyn_hello
./Dyn_hello: error while loading shared libraries: libhi.so: cannot open shared object file: No such file or directory

執行不成功,這就表明了這是一個動態連接的程序,連接器找不到libhi.so這個動態連接庫。在命令行加上 LD_LIBRARY_PATH=…就行了。像這樣運行:
代碼:

$:LD_LIBRARY_PATH=. ./Dyn_hello Hello world

指出當前目錄是連接器的搜索目錄,就可以了。

Elf可執行文件還有一個a.out很難實現的特點,就是對dlopen()函數的支持,這個函數可以在程序中控制動態的加載動態連接庫,看下面的一個小程序:
代碼:

$:cat Dl_hello.c

#include <dlfcn.h>
int main (int argc, char *argv[])
{ 
   void (*hi) ();
   void *m; if (argc > 2) exit (0);
   m = dlopen (argv[1], RTLD_LAZY);
   if (!m)
      exit (0);
   hi = dlsym (m, "hi");
   if (hi)
   {
      (*hi) ();
    }
    dlclose (m);
}

用一下命令編譯:
代碼:

$:gcc -c Dl_hello.c
$:gcc -o Dl_hello Dl_hello.o -ldl

運行Dl_hello程序加上動態連接庫。
代碼:

$:./Dl_hello ./libhi.so Hello world

命令行成功的打印出了Hello world說明我們的動態連接庫運用成功了。

在這篇文章中只是討論了elf可執行文件的執行原理,還有很多方面沒有涉及到,要深入瞭解elf你也許需要對動態連接器hack一下,也要hack一下內核加載程序的loader。但是我想對大多數人來說,這篇文章對elf的介紹已經足夠讓你可以自己對elf在進行比較深入的研究了。

公衆號

在這裏插入圖片描述

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