將你的可執行文件“減肥”
2008-02-23
這篇blog從減少可執行文件大小的角度分析了ELF文件,期間通過經典的"Hello World"實例逐步演示如何通過各種常用工具來分析ELF文件,并逐步精簡代碼。
為了能夠盡量減少可執行文件的大小,我們必須了解可執行文件的格式,以及鏈接生成可執行文件時的后臺細節(即最終到底有哪些內容被鏈接到了目標代碼中)。 通過選擇合適的可執行文件格式并剔除對可執行文件的最終運行沒有影響的內容,就可以實現目標代碼的裁減。因此,通過探索減少可執行文件大小的方法,就相當 于實踐性地去探索了可執行文件的格式以及鏈接過程的細節。
當然,算法的優化和編程語言的選擇可能對目標文件的大小有很大的影響,在這篇blog的后面我們會跟參考資料[1]的作者那樣去探求一個打印“Hello World”的可執行文件能夠小到什么樣的地步。
1、可執行文件格式的選取
可執行文件格式的選擇要滿足的一個基本條件是:目標系統支持該可執行文件格式,資料[2]分析和比較了UNIX平臺下的三種可執行文件格式,這三種格式實際上代表著可執行文件的一個發展過程。
a.out文件格式非常緊湊,只包含了程序運行所必須的信息(文本、數據、BSS),而且每個 section的順序是固定的。
coff文件格式雖然引入了一個節區表以支持更多節區信息,從而提高了可擴展性,但是這種文件格式的重定位在鏈接時就已經完成,因此不支持動態鏈接(不過擴展的coff支持)。
elf文件格式不僅動態鏈接,而且有很好的擴展性。它可以描述可重定位文件、可執行文件和可共享文件(動態鏈接庫)三類文件。
下面來看看ELF文件的結構圖。
文件頭部(ELF Header)
程序頭部表(Program Header Table)
節區1(Section1)
節區2(Section2)
節區3(Section3)
...
節區頭部(Section Header Table)
無論是文件頭部、程序頭部表、節區頭部表還是各個節區,都是通過特定的結構體(struct)描述的,這些結構在elf.h文件中定義。文件頭部用于描述 整個文件的類型、大小、運行平臺、程序入口、程序頭部表和節區頭部表等信息。例如,我們可以通過文件頭部查看該ELF文件的類型。
Quote: |
$ cat hello.c #典型的hello, world程序 |
那節區頭部表(將簡稱節區表)和程序頭部表有什么用呢?實際上前者只對可重定向文件有用,而后者只對可執行文件和可共享文件有用。
節區表是用來描述各節區的,包括各節區的名字、大小、類型、虛擬內存中的位置、相對文件頭的位置等,這樣所有節區都通過節區表給描述了,這樣連接器就可以 根據文件頭部表和節區表的描述信息對各種輸入的可重定位文件進行合適的鏈接,包括節區的合并與重組、符號的重定位(確認符號在虛擬內存中的地址)等,把各 個可重定向輸入文件鏈接成一個可執行文件(或者是可共享文件)。如果可執行文件中使用了動態連接庫,那么將包含一些用于動態符號鏈接的節區。我們可以通過 readelf -S(或objdump -h)查看節區表信息。
Quote: |
$ readelf -S hello #可執行文件、可共享庫、可重定位文件默認都生成有節區表 |
三種類型文件的節區(各個常見節區的作用請參考資料[11])可能不一樣,但是有幾個節區,例如.text, .data, .bss是必須的,特別是.text,因為這個節區包含了代碼。如果一個程序使用了動態鏈接庫(引用了動態連接庫中的某個函數),那么需要.interp 節區以便告知系統使用什么動態連接器程序來進行動態符號鏈接,進行某些符號地址的重定位。通常,.rel.text節區只有可重定向文件有,用于鏈接時對 代碼區進行重定向,而.hash,.plt,.got等節區則只有可執行文件(或可共享庫)有,這些節區對程序的運行特別重要。還有一些節區,可能僅僅是 用于注釋,比如.comment,這些對程序的運行似乎沒有影響,是可有可無的,不過有些節區雖然對程序的運行沒有用處,但是卻可以用來輔助對程序進行調 試或者對程序運行效率有影響。
雖然三類文件都必須包含某些節區,但是節區表對可重定位文件來說才是必須的,而程序的執行卻不需要節區表,只需要程序頭部表以便知道如何加載和執行文件。 不過如果需要對可執行文件或者動態連接庫進行調試,那么節區表卻是必要的,否則調試器將不知道如何工作。下面來介紹程序頭部表,它可通過readelf -l(或objdump -p)查看。
Quote: |
$ readelf -l hello.o #對于可重定向文件,gcc沒有產生程序頭部,因為它對可重定向文件沒用 |
從上面可看出程序頭部表描述了一些段(Segment),這些段對應著一個或者多個節區,上面的readelf -l很好地顯示了各個段與節區的映射。這些段描述了段的名字、類型、大小、第一個字節在文件中的位置、將占用的虛擬內存大小、在虛擬內存中的位置等。這樣 系統程序解釋器將知道如何把可執行文件加載到內存中以及進行動態鏈接等動作。
該可執行文件包含7個段,PHDR指程序頭部,INTERP正好對應.interp節區,兩個LOAD段包含程序的代碼和數據部分,分別包含有.text 和.data,.bss節區,DYNAMIC段包含.daynamic,這個節區可能包含動態連接庫的搜索路徑、可重定位表的地址等信息,它們用于動態連 接器。NOTE和GNU_STACK段貌似作用不大,只是保存了一些輔助信息。因此,對于一個不使用動態連接庫的程序來說,可能只包含LOAD段,如果一 個程序沒有數據,那么只有一個LOAD段就可以了。
總結一下,Linux雖然支持很多種可執行文件格式,但是目前ELF較通用,所以選擇ELF作為我們的討論對象。通過上面對ELF文件分析發現一個可執行 的文件可能包含一些對它的運行沒用的信息,比如節區表、一些用于調試、注釋的節區。如果能夠刪除這些信息就可以減少可執行文件的大小,而且不會影響可執行 文件的正常運行。
2、鏈接優化
從上面的討論中已經接觸了動態連接庫。ELF中引入動態連接庫后極大地方便了公共函數的共享,節約了磁盤和內存空間,因為不再需要把那些公共函數的代碼鏈接到可執行文件,這將減少了可執行文件的大小。
與此同時,靜態鏈接可能會引入一些對代碼的運行可能并非必須的內容。你可以從《GCC編譯的背后(第二部分:匯編和鏈接)》 了 解到GCC鏈接的細節。從那篇Blog中似乎可以得出這樣的結論:僅僅從是否影響一個C語言程序運行的角度上說,GCC默認鏈接到可執行文件的幾個可重定 位文件(crt1.o,rti.o,crtbegin.o,crtend.o,crtn.o)并不是必須的,不過值得注意的是,如果沒有鏈接那些文件但在 程序末尾使用了return語句,main函數將無法返回,因此需要替換為_exit調用;另外,既然程序在進入main之前有一個入口,那么main入 口就不是必須的。因此,如果不采用默認鏈接也可以減少可執行文件的大小。
3、可執行文件“減肥”實例
這里主要是根據上面兩點來介紹如何減少一個可執行文件的大小。以"Hello World"為例。
首先來看看默認編譯產生的Hello World的可執行文件大小。
代碼同上,下面是一組演示,
Quote: |
$ uname -r #先查看內核版本和gcc版本,以便和你的結果比較 |
可以考慮編輯時就把return 0替換成_exit(0)并包含定義該函數的unistd.h頭文件。下面是從GCC編譯的背后(第二部分:匯編和鏈接)》總結出的Makefile文件。
Code:
[Ctrl+A Select All]
把上面的代碼復制到一個Makefile文件中,并利用它來編譯hello.c。
Quote: |
$ make #編譯 |
對于一個比較小的程序,能夠減少將近70%“沒用的”代碼。至于一個大一點的程序(這個代碼是[1]的作者寫的一個小工具,我們后面會使用它)再看看效果。
Quote: |
$ gcc -o sstrip sstrip.c #默認編譯的情況 |
通過這兩個簡單的實驗,我們發現,能夠減少掉4000個字節左右,相當于4k左右。
使用上述Makefile來編譯程序,不鏈接那些對程序運行沒有多大影響的文件,實際上也相當于刪除了一些“沒用”的節區,可以通過下列演示看出這個實質。
Quote: |
$ sed -i -e "s/sstrip/hello/g" Makefile #先看看用Makefile編譯的結果,替換回hello |
通過比較發現使用自定義的Makefile文件,少了這么多節區:.bss .ctors .data .dtors .eh_frame .fini .gnu.hash .got .init .jcr .note.ABI-tag .rel.dyn。
再看看還有哪些節區可以刪除呢?通過之前的分析發現有些節區是必須的,那.hash?.gnu.version?呢,通過strip -R(或objcop -R)刪除這些節區試試。
Quote: |
$ wc -c hello #查看大小,以便比較 |
通過刪除各個節區可以查看哪些節區對程序來說是必須的,不過有些節區雖然并不影響程序的運行卻可能會影響程序的執行效率,這個可以上面的運行時間看出個大概。
通過刪除兩個“沒用”的節區,我們又減少了52+612,即664字節。
用普通的工具沒有辦法刪除節區表,但是參考資料[1]的作者已經寫了這樣一個工具。你可以從這里http://www.muppetlabs.com/~breadbox/software/elfkickers.html下載到那個工具,即我們上面作為一個演示例子的sstrip,它是該作者寫的一序列工具ELFkickers中的一個。下載以后,編譯,并復制到/usr/bin下,下面用它來刪除節區表。
Quote: |
$ sstrip hello #刪除ELF可執行文件的節區表 |
通過刪除節區表又把可執行文件減少了688字節。現在回頭看看相對于gcc默認產生的可執行文件,通過刪除一些節區和節區表到底減少了多少字節?減幅達到了多少?
Quote: |
$ echo "6442-708" | bc # |
減少了5734多字節,減幅將近90%,這說明:對于一個簡短的hello.c程序而言,gcc引入了將近90%的對程序運行沒有影響的數據。雖然通過刪 除節區和節區表,使得最終的文件只有708字節,但是打印一個"Hello World"真的需要這么多字節么?
事實上未必,因為:
1、打印一段Hello World字符串,我們無須調用printf,也就無須包含動態連接庫,因此.interp,.dynamic等節區又可以去掉。為什么?我們可以直接使用系統調用(sys_write)來打印字符串。
2、另外,我們無須把Hello World字符串存放到可執行文件中?而是讓用戶把它當作參數輸入。
下面,繼續進行可執行文件的“減肥”。
4、用匯編語言來重寫"Hello World"
先來看看gcc默認產生的匯編代碼情況。通過gcc的-S選項可得到匯編代碼。
Quote: |
$ cat hello.c #這個是使用_exit和printf函數的版本 |
現在對匯編代碼(hello.s)進行簡單的處理得到,
Code:
[Ctrl+A Select All]
再編譯看看,
Quote: |
$ gcc -o hello.o hello.s |
如果不采用默認編譯呢并且刪除掉對程序運行沒有影響的節區和節區表呢?
Quote: |
$ sed -i -e "s/main/_start/g" hello.s #因為沒有初始化,所以得直接進入代碼,替換main為_start |
容易發現這32字節可能跟節區.rodata有關系,因為剛才在鏈接完以后查看節區信息時,并沒有.rodata節區。
前面提到,實際上還可以不用動態連接庫中的printf函數,也不用直接調用_exit,而是在匯編里頭使用系統調用,這樣就可以去掉和動態連接庫關聯的內容。(如果想了解如何在匯編中使用系統調用,請參考資料[9])。使用系統調用重寫以后得到如下代碼,
Code:
[Ctrl+A Select All]
現在編譯就不再需要動態鏈接器ld-linux.so了,也不再需要鏈接任何庫。
Quote: |
$ as -o hello.o hello.s |
可以看到效果很明顯,只剩下一個LOAD段,它對應.text節區。
不過是否還有辦法呢?把Hello World作為參數輸入,而不是硬編碼在文件中。所以如果處理參數的代碼少于Hello World字符串的長度,那么就可以達到減少目標文件大小的目的。
先來看一個能夠打印程序參數的匯編語言程序,它來自參考資料[9]。
Code:
[Ctrl+A Select All]
編譯看看效果,
Quote: |
$ as -o args.o args.s |
可以看到,這個程序可以接收用戶輸入的參數并打印出來,不過得到的可執行文件為130字節,比之前的123個字節還多了7個字節,看看還有改進么?分析上面的代碼后,發現,原來的代碼有些地方可能進行優化,優化后得到如下代碼。
Code:
[Ctrl+A Select All]
再測試(記得先重新匯編、鏈接并刪除沒用的節區和節區表)。
Quote: |
$ wc -c hello |
現在只有124個字節,不過還是比123個字節多一個,還有什么優化的辦法么?
先來看看目前hello的功能,感覺不太符合要求,因為只需要打印Hello World,所以不必處理所有的參數,僅僅需要接收并打印一個參數就可以。這樣的話,把jmp vnext(2字節)這個循環去掉,然后在第一個pop %ecx語句之前加一個pop %ecx(1字節)語句就可以。
Quote: |
.global _start |
現在剛好123字節,和原來那個代碼大小一樣,不過仔細分析,還是有減少代碼的余地:因為在這個代碼中,用了一段額外的代碼計算字符串的長度,實際上如果 僅僅需要打印Hello World,那么字符串的長度是固定的,即12。所以這段代碼可去掉,與此同時測試字符串是否為空也就沒有必要(不過可能影響代碼健壯性!),當然,為了 能夠在打印字符串后就換行,在串的末尾需要加一個回車($10)并且設置字符串的長度為12+1,即13,
Code:
[Ctrl+A Select All]
再看看效果,
Quote: |
$ wc -c hello |
現在只剩下111字節,比剛才少了12字節。貌似到了極限?還有措施么?
還有,仔細分析發現:系統調用sys_exit和sys_write都用到了eax和ebx寄存器,它們之間剛好有那么一點巧合:
1、sys_exit調用時,eax需要設置為1,ebx需要設置為0。
2、sys_write調用時,ebx剛好是1。
因此,如果在sys_exit調用之前,先把ebx復制到eax中,再對ebx減一,則可減少兩個字節。
不過,因為標準輸入、標準輸出和標注錯誤都指向終端,如果往標準輸入寫入一些東西,它還是會輸出到標準輸出上,所以在上述代碼中如果在sys_write 之前ebx設置為0,那么也可正常往屏幕上打印Hell World,這樣的話,sys_exit調用前就沒必要修改ebx,而僅需把eax設置為1,這樣就可減少3個字節。
Code:
[Ctrl+A Select All]
看看效果,
Quote: |
$ wc -c hello |
現在看一下純粹的指令還有多少?
Quote: |
$ readelf -h hello | grep Size |
純粹的指令只有24個字節了,還有辦法再減少目標文件的大小么?如果看了參考資料[1],看樣子你又要蠢蠢欲動了:這24個字節是否可以插入到文件頭部或程序頭部?如果可以那是否意味著還可減少可執行文件的大小呢?現在來比較一下這三部分的十六進制內容。
Quote: |
$ hexdump -C hello -n 52 #文件頭(52bytes) |
從上面結果發現ELF文件頭部和程序頭部還有好些空洞(0),是否可以通過引入跳轉指令把24個字節分散放入到那些空洞里或者是直接覆蓋掉那些系統并不關 心的內容?抑或是把代碼壓縮以后放入可執行文件中,并在其中實現一個解壓縮算法?還可以是通過一些代碼覆蓋率測試工具(gcov,prof)對你的代碼進 行優化?這個作為我們共同的練習吧!
由于時間關系,這里不再進一步討論,如果想進一步研究,請閱讀參考資料[1],它更深層次地討論了ELF文件,特別是Linux系統對ELF文件頭部和程序頭部的解析。
到這里,關于可執行文件的討論暫且結束,最后來一段小小的總結,那就是我們設法去減少可執行文件大小的意義?
實際上,通過這樣一個討論深入到了很多技術的細節,包括可執行文件的格式、目標代碼鏈接的過程、Linux下匯編語言開發等。與此同時,可執行文件大小的 減少本身對嵌入式系統非常有用,如果刪除那些對程序運行沒有影響的節區和節區表將減少目標系統的大小,適應嵌入式系統資源受限的需求。除此之外,動態連接 庫中的很多函數可能不會被使用到,因此也可以通過某種方式剔除[8][10]。
或許,你還會發現更多有趣的意義,歡迎給我發送郵件,一起討論。
參考資料:
[1] A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux
http://www.muppetlabs.com/~breadbox/software/tiny/teensy.html
[2] UNIX/LINUX 平臺可執行文件格式分析
http://blog.chinaunix.net/u/19881/showart_215242.html
[3] C/C++程序編譯步驟詳解
http://www.xxlinux.com/linux/article/development/soft/20070424/8267.html
[4] The Linux GCC HOW TO
http://www.faqs.org/docs/Linux-HOWTO/GCC-HOWTO.html
[5] ELF: From The Programmer's Perspective
http://linux.jinr.ru/usoft/WWW/www_debian.org/Documentation/elf/elf.html
[6] Understanding ELF using readelf and objdump
http://www.linuxforums.org/misc/understanding_elf_using_readelf_and_objdump.html
[7] Dissecting shared libraries
http://www.ibm.com/developerworks/linux/library/l-shlibs.html
[8] 嵌入式Linux小型化技術
http://www.gexin.com.cn/UploadFile/document2008119102415.pdf
[9] Linux匯編語言開發指南
http://www.ibm.com/developerworks/cn/linux/l-assembly/index.html
[10] Library Optimizer
http://sourceforge.net/projects/libraryopt
[11] ELF file format and ABI
http://www.x86.org/ftp/manuals/tools/elf.pdf
http://www.muppetlabs.com/~breadbox/software/ELF.txt
(北大OS實驗室)http://162.105.203.48/web/gaikuang/submission/TN05.ELF.Format.Summary.pdf
(alert7 大牛翻譯)http://www.xfocus.net/articles/200105/174.html