1. 程式人生 > >ELF檔案格式研究

ELF檔案格式研究

如果你是一個對軟體體積腫脹受夠了的程式設計師,那麼可能你能在這裡找到合適的解決 
方法。 
本文討論把額外的位元組榨出簡單程式的方法。(當然,本文更實際的目的是描述ELF 
檔案格式和Linux作業系統的一些內部工作。但是希望你也能在這個過程中學到一些 
如何建立真正很小的ELF可執行檔案的知識。) 
請注意這裡給出的資訊和示例,絕大部分,都是針對運行於Intel-386體系結構下的 
Linux平臺上的ELF可執行檔案的。我猜測這些資訊中很有一部分也適用於其它基於ELF 
的Unix系統,但是我在這方面的經驗太有限了所以不能肯定。 
本文中出現的彙編程式碼是要使用Nasm彙編的。(除了更適合於我們的需要之外,Nasm 

的語法對於那些在學會使用Gas之前學習x86組合語言的人來說比AT&T語法更好。)Nasm 
可以免費獲得並且非常容易移植;參見http://www.web-sites.co.uk/nasm/。 
也請注意如果你對彙編程式碼不是特別熟,你會發現本文的一些部分很難懂。 
—————————————————————————————————————— 
為了開始工作,我們需要一個程式。幾乎任何程式都可以,但是程式越簡單越好,因為 
我們更感興趣的是我們能把可執行檔案做成多麼小而不是能把程式作多麼小。 
讓我們拿一個非常非常簡單的程式,它什麼也不作只是向作業系統返回一個數。為什麼 
不呢?畢竟,Unix已經帶了至少兩個這種程式:true和false。既然0和1已經被用過了, 

我們就使用數42吧。 
所以,這就是我們的第一版: 
/* tiny.c */ 
int main(void) { return 42; } 
我們可以進行如下的編譯和測試: 
$ gcc -Wall tiny.c 
$ ./a.out ; echo $? 
42 
好了。它有多大呢?在我的機器上,有: 
$ wc -c a.out 
3998 a.out 
(在你的機器上可能會稍有不同。)應該承認,按當今的標準來說這已經是非常小了。 
但是它幾乎肯定比它需要的要大。 
很顯然的第一步是strip可執行檔案: 
$ gcc -Wall -s tiny.c 
$ ./a.out ; echo $? 

42 
$ wc -c a.out 
2632 a.out 
這的確有改善。下一步,優化會怎麼樣? 
$ gcc -Wall -s -O3 tiny.c 
$ wc -c a.out 
2616 a.out 
這也有所幫助,但是不多。這是合理的:程式中幾乎沒有什麼可以優化的。 
看起來好像我們再也不能縮減一個只有一條語句的C程式了。我們將拋棄C,轉而使用匯 
編。希望這將把C程式自動帶來的額外開銷砍掉。 
現在,向我們的第二版進軍。我們需要做的就是從main()中返回42。在組合語言中,這 
意味著該函式應該把累加器,eax,設定為42,然後返回: 
; tiny.asm 
BITS 32 
GLOBAL main 
SECTION .text 
main: 
mov eax, 42 
ret 
然後我們可以build並測試如下: 
$ nasm -f elf tiny.asm 
$ gcc -Wall -s tiny.o 
$ ./a.out ; echo $? 
42 
(嘿,誰說彙編程式碼很難呀?)現在有多大? 
$ wc -c a.out 
2604 a.out 
看起來我們只去掉了區區的12位元組。難道C只自動引進這麼多的額外開銷嗎? 
問題在於,通過使用main()介面我們仍然引進了很大開銷。連結器仍然為我們新增一個 
到OS的介面,真正呼叫main()的是那個介面。那如果我們不需要它的話該怎麼做? 
連結器真正使用的入口點預設是名字為_start的符號。當我們使用gcc連結時,它自動 
包含一個_start例程,該例程將設定argc和argv,以及其他事情,然後呼叫main()。 
那麼,看看我們能否繞過這一點。定義我們自己的_start例程: 
; tiny.asm 
BITS 32 
GLOBAL _start 
SECTION .text 
_start: 
mov eax, 42 
ret 
gcc會按我們想要的去做嗎? 
$ nasm -f elf tiny.asm 
$ gcc -Wall -s tiny.o 
tiny.o(.text+0x0): multiple definition of `_start' 
/usr/lib/crt1.o(.text+0x0): first defined here 
/usr/lib/crt1.o(.text+0x36): undefined reference to `main' 
不會。嗯,實際上,它會的,但是首先我們得知道怎樣才能得到我們想要的東西。 
原來gcc能識別一個選項-nostartfiles。從gcc的info頁上可以看到: 
-nostartfiles 
Do not use the standard system startup files when linking. The 
standard libraries are used normally. 
耶!現在看看我們能做些什麼: 
$ nasm -f elf tiny.asm 
$ gcc -Wall -s -nostartfiles tiny.o 
$ ./a.out ; echo $? 
Segmentation fault 
139 
gcc不抱怨了,但是程式不能工作。錯在哪裡? 
錯誤在於我們把_start當作它好像是一個C函式,並且試圖從它返回。實際上,它根本 
不是一個函式。它只是目標檔案中連結器用來定位程式入口點的一個符號。當我們的程 
序被啟用時,它被直接啟用。如果我們去檢視一下,將會發現棧頂上是數1,這顯然不 
像是一個地址。事實上,棧頂上是我們程式的argc值。在這之後是argv陣列的元素,包 
括結束時的NULL元素,接著是envp的元素。這就是全部。在棧頂上沒有返回地址。 
那,_start是如何退出的?它呼叫了exit()函式!畢竟,這就是它出現的作用。 
實際上,我說謊了。它真正做的是呼叫_exit()函式。[譯註:原文如此。標準啟動中 
_start還是呼叫exit()。](注意前面的下劃線。)exit()要為程序進行某些任務的結 
束處理,但是這些任務將不會被啟動,因為我們繞過了庫的啟動程式碼。所以我們也需要 
繞過庫的結束程式碼,直接到達作業系統的結束處理。 
好,讓我們再試一下。我們將要呼叫_exit(),這是一個需要一個整數引數的函式。所 
以我們需要做的就是把那個數壓到棧上並呼叫該函式。(我們還需要宣告_exit()為外 
部)下面是我們的彙編: 
; tiny.asm 
BITS 32 
EXTERN _exit 
GLOBAL _start 
SECTION .text 
_start: 
push dword 42 
call _exit 
然後我們像前面那樣build和測試: 
$ nasm -f elf tiny.asm 
$ gcc -Wall -s -nostartfiles tiny.o 
$ ./a.out ; echo $? 
42 
終於成功了!現在看它有多大: 
$ wc -c a.out 
1340 a.out 
幾乎只有一半的大小!不錯。真得不錯。Hmmm...那gcc還有什麼別的有意思的選項嗎? 
這一個,在文件中緊接著-nostartfiles的,很是顯眼: 
-nostdlib 
Don't use the standard system libraries and startup files when 
linking. Only the files you specify will be passed to the linker. 
這值得研究一下: 
$ gcc -Wall -s -nostdlib tiny.o 
tiny.o(.text+0x6): undefined reference to `_exit' 
Oops。是的..._exit()畢竟是一個庫函式。它必須要被填充。 
好吧。但是肯定,我們並不需要libc的幫助來結束一個程式,不是嗎? 
是的,我們不需要。如果我們願意拋棄所有可移植性要求,我們可以退出程式而不需要 
和任何其他東西連結。然而,首先我們需要了解如何在Linux下進行系統呼叫。 
—————————————————————————————————————— 
Linux,像大多數作業系統一樣,通過系統呼叫對它支援的程式提供基本的必需功能。這 
包括開啟檔案,讀寫檔案控制代碼——當然,也包括結束一個程序。 
Linux系統呼叫介面是一條指令:int 0x80。所有的系統呼叫都通過這個中斷進行。要進 
行一個系統呼叫,eax應當包含一個數來指明那個系統呼叫被呼叫,並且其他暫存器用於 
傳遞引數,如果有的話。如果系統呼叫需要一個引數,它將在ebx裡;兩個引數的系統調 
將使用ebx和ecx。類似的,edx,esi,和edi將分別被使用,如果需要第三、第四、第五 
個引數的話。當從一個系統呼叫返回後,eax將包含返回值。如果發生錯誤,eax將包含 
一個負值,其絕對值指出錯誤。 
不同系統呼叫的號碼在/usr/include/asm/unistd.h中列出。檢視一下就知道exit系統調 
用被分配的號碼是1。類似於C函式,它需要一個引數,即返回給父程序的值,所以這將 
通過ebx傳遞。 
現在我們知道了如何建立我們程式的下一個版本,這個版本不需要任何外部函式的輔助 
就可以工作: 
; tiny.asm 
BITS 32 
GLOBAL _start 
SECTION .text 
_start: 
mov eax, 1 
mov ebx, 42 
int 0x80 
接下來: 
$ nasm -f elf tiny.asm 
$ gcc -Wall -s -nostdlib tiny.o 
$ ./a.out ; echo $? 
42 
哈哈!大小是? 
$ wc -c a.out 
372 a.out 
現在已經是非常小了。幾乎是上一個版本大小的四分之一。 
那...我們還能做些什麼把它變得更小嗎? 
使用更短的指令怎麼樣? 
如果我們為彙編程式碼生成一個list檔案,就會發現如下: 
00000000 B801000000 mov eax, 1 
00000005 BB2A000000 mov ebx, 42 
0000000A CD80 int 0x80 
嗯,我們不需要初始化ebx的全部,因為作業系統只使用最低位元組。只設置bl就足夠了, 
這將佔用兩個位元組而不是五個。 
我們還可以通過把eax xor成0然後使用一個位元組的增量指令來把eax設成1。這將又節省 
兩個位元組。 
00000000 31C0 xor eax, eax 
00000002 40 inc eax 
00000003 B32A mov bl, 42 
00000005 CD80 int 0x80 
我想現在說我們再也不能把這個程式變得更小已經很安全了。 
另外,我們可以不再用gcc來連結我們的可執行檔案,因為我們沒有使用它的任何附加 
功能,我們可以自己呼叫連結器,ld: 
$ nasm -f elf tiny.asm 
$ ld -s tiny.o 
$ ./a.out ; echo $? 
42 
$ wc -c a.out 
368 a.out 
又小了4個位元組。(嘿!我們不是砍掉了5個位元組嗎?是的,我們是砍了5個位元組,但是 
ELF檔案的對齊考慮導致它需要一個額外位元組的填充。) 
那麼...我們到頭了麼?這就是我們所能達到的最小麼? 
姆。我們的程式現在是7個位元組長。ELF檔案真的需要361位元組的開銷?檔案裡面到底是 
什麼? 
我們可以使用objdump察看檔案的內容: 
$ objdump -x a.out | less 
輸出可能看起來有點混亂,但現在讓我們集中看一下節(section)列表: 
Sections: 
Idx Name Size VMA LMA File off Algn 
0 .text 00000007 08048080 08048080 00000080 2**4 
CONTENTS, ALLOC, LOAD, READONLY, CODE 
1 .comment 0000001c 00000000 00000000 00000087 2**0 
CONTENTS, READONLY 
完整的.text節在列表中是7位元組長,正如我們所指出的。所以好像可以下結論說我們現 
在可以完全控制程式的機器語言內容了。 
但是還有另一個名為“.comment”的節。為什麼會有它?並且它有28位元組長,竟然!我 
們不能確定這個.comment節是什麼,但是好像它並不是必需的... 
.comment節在列表中顯示位於檔案偏移00000087(十六進位制)。如果我們使用一個 
hexdump程式來檢視一下檔案該區域的內容,會發現: 
00000080: 31C0 40B3 2ACD 8000 5468 6520 4E65 7477 [email protected]*...The Netw 
00000090: 6964 6520 4173 7365 6D62 6C65 7220 302E ide Assembler 0. 
000000A0: 3938 0000 2E73 796D 7461 6200 2E73 7472 98...symtab..str 
噢,噢,噢。誰會想到Nasm會這樣破壞我們所追求的東西呢?或許我們需要轉而使用gas 
,儘管要用AT&T語法... 
哎,如果我們這樣做: 
; tiny.s 
.globl _start 
.text 
_start: 
xorl %eax, %eax 
incl %eax 
movb $42, %bl 
int $0x80 
...我們發現: 
$ gcc -s -nostdlib tiny.s 
$ ./a.out ; echo $? 
42 
$ wc -c a.out 
368 a.out 
...沒有區別! 
實際上,是有一點區別的。再次使用objdump,我們會發現: 
Sections: 
Idx Name Size VMA LMA File off Algn 
0 .text 00000007 08048074 08048074 00000074 2**2 
CONTENTS, ALLOC, LOAD, READONLY, CODE 
1 .data 00000000 0804907c 0804907c 0000007c 2**2 
CONTENTS, ALLOC, LOAD, DATA 
2 .bss 00000000 0804907c 0804907c 0000007c 2**2 
ALLOC 
沒有了comment節,但是現在有兩個沒有用處的節來儲存並不存在的資料。儘管這些節 
是0位元組長,它們確實有開銷,毫無道理的使我們的檔案體積變大。 
那麼,這些開銷是什麼呢?我們怎樣去掉它? 
為了回答這些問題,我們必須深入一些。我們需要理解ELF格式。 
—————————————————————————————————————— 
描述Intel-386體系結構ELF格式的規範文件可以在ftp://tsx.mit.edu/pub/linux/ 
packages/GCC/ELF.doc.tar.gz找到。如果你不喜歡Postscript文件,你可以在http:// 
www.muppetlabs.com/~breadbox/software/ELF.txt找到一個純文字版本的。該規範覆蓋 
了很多領域,所以如果你不想讀完整個文件,我可以理解。基本上,下面的東西是我們 
需要知道的: 
每個ELF檔案都以一個稱為ELF頭的結構開始。這個結構是52位元組長,包含一些描述檔案 
內容的資訊。例如,最開始的16個位元組包含一個“標識”,包括檔案的幻數(magic- 
number)簽名(7F 45 4C 46),及一些一位元組標誌,用來指示檔案內容是32位還是64 
位,little-endian還是big-endian,等等。ELF頭中的其他域包括:目標體系結構; 
ELF檔案是一個可執行檔案,一個目標檔案,還是一個共享庫(shared-object library 
);程式的起始地址;以及程式頭表(program header table)和節頭表(section 
header table)在檔案中的位置。 
這兩個表可以位於檔案中任何地方,但是通常前者緊接著ELF頭,後者位於或接近於檔案 
尾。這兩個表的目的類似,他們標識檔案的組成部分。但是,節頭表更側重於標識程式 
的各部分位於檔案中什麼地方,而程式頭表描述這些部分如何以及被裝載的記憶體中的何 
處。簡單的說,節頭表是給編譯器和連結器用的,而程式頭表是給程式裝載器用的。程 
序頭表對目標檔案是可選的,並且在實際中目標檔案從來沒有它。類似,節頭表對可執 
行檔案是可選的——但是可執行檔案幾乎都有它。 
好了,這就是我們第一個問題的答案。我們程式中的相當一部分開銷是完全不必要的節