可執(zhí)行文件格式綜述
相對于其它文件類型,可執(zhí)行文件可能是一個操作系統(tǒng)中最重要的文件類型,因為它們是完成操作的真正執(zhí)行者。可執(zhí)行文件的大小、運行速度、資源占用情況以及可擴展性、可移植性等與文件格式的定義和文件加載過程緊密相關(guān)。
研究可執(zhí)行文件的格式對編寫高性能程序和一些黑客技術(shù)的運用都是非常有意義的。
不管何種可執(zhí)行文件格式,一些基本的要素是必須的,顯而易見的,文件中應(yīng)包含代碼和數(shù)據(jù)。因為文件可能引用外部文件定義的符號(變量和函數(shù)),因此重定位信息和符號信息也是需要的。一些輔助信息是可選的,如調(diào)試信息、硬件信息等。基本上任意一種可執(zhí)行文件格式都是按區(qū)間保存上述信息,稱為段(Segment)或節(jié)(Section)。不同的文件格式中段和節(jié)的含義可能有細(xì)微區(qū)別,但根據(jù)上下文關(guān)系可以很清楚的理解,這不是關(guān)鍵問題。最后,可執(zhí)行文件通常都有一個文件頭部以描述本文件的總體結(jié)構(gòu)。
相對可執(zhí)行文件有三個重要的概念:編譯(compile)、連接(link,也可稱為鏈接、聯(lián)接)、加載(load)。源程序文件被編譯成目標(biāo)文件,多個目標(biāo)文件被連接成一個最終的可執(zhí)行文件,可執(zhí)行文件被加載到內(nèi)存中運行。因為本文重點是討論可執(zhí)行文件格式,因此加載過程也相對重點討論。下面是LINUX平臺下ELF文件加載過程的一個簡單描述。
1:內(nèi)核首先讀ELF文件的頭部,然后根據(jù)頭部的數(shù)據(jù)指示分別讀入各種數(shù)據(jù)結(jié)構(gòu),找到標(biāo)記為可加載(loadable)的段,并調(diào)用函數(shù) mmap()把段內(nèi)容加載到內(nèi)存中。在加載之前,內(nèi)核把段的標(biāo)記直接傳遞給 mmap(),段的標(biāo)記指示該段在內(nèi)存中是否可讀、可寫,可執(zhí)行。顯然,文本段是只讀可執(zhí)行,而數(shù)據(jù)段是可讀可寫。這種方式是利用了現(xiàn)代操作系統(tǒng)和處理器對內(nèi)存的保護功能。著名的Shellcode(參考資料 17)的編寫技巧則是突破此保護功能的一個實際例子。
2:內(nèi)核分析出ELF文件標(biāo)記為 PT_INTERP 的段中所對應(yīng)的動態(tài)連接器名稱,并加載動態(tài)連接器。現(xiàn)代 LINUX 系統(tǒng)的動態(tài)連接器通常是 /lib/ld-linux.so.2,相關(guān)細(xì)節(jié)在后面有詳細(xì)描述。
3:內(nèi)核在新進程的堆棧中設(shè)置一些標(biāo)記-值對,以指示動態(tài)連接器的相關(guān)操作。
4:內(nèi)核把控制傳遞給動態(tài)連接器。
5:動態(tài)連接器檢查程序?qū)ν獠课募ü蚕韼欤┑囊蕾囆裕⒃谛枰獣r對其進行加載。
6:動態(tài)連接器對程序的外部引用進行重定位,通俗的講,就是告訴程序其引用的外部變量/函數(shù)的地址,此地址位于共享庫被加載在內(nèi)存的區(qū)間內(nèi)。動態(tài)連接還有一個延遲(Lazy)定位的特性,即只在"真正"需要引用符號時才重定位,這對提高程序運行效率有極大幫助。
7:動態(tài)連接器執(zhí)行在ELF文件中標(biāo)記為 .init 的節(jié)的代碼,進行程序運行的初始化。在早期系統(tǒng)中,初始化代碼對應(yīng)函數(shù) _init(void)(函數(shù)名強制固定),在現(xiàn)代系統(tǒng)中,則對應(yīng)形式為
void |
其中函數(shù)名為任意。
8:動態(tài)連接器把控制傳遞給程序,從 ELF 文件頭部中定義的程序進入點開始執(zhí)行。在 a.out 格式和ELF格式中,程序進入點的值是顯式存在的,在 COFF 格式中則是由規(guī)范隱含定義。
從上面的描述可以看出,加載文件最重要的是完成兩件事情:加載程序段和數(shù)據(jù)段到內(nèi)存;進行外部定義符號的重定位。重定位是程序連接中一個重要概念。我們知道,一個可執(zhí)行程序通常是由一個含有 main() 的主程序文件、若干目標(biāo)文件、若干共享庫(Shared Libraries)組成。(注:采用一些特別的技巧,也可編寫沒有 main 函數(shù)的程序,請參閱參考資料 2)一個 C 程序可能引用共享庫定義的變量或函數(shù),換句話說就是程序運行時必須知道這些變量/函數(shù)的地址。在靜態(tài)連接中,程序所有需要使用的外部定義都完全包含在可執(zhí)行程序中,而動態(tài)連接則只在可執(zhí)行文件中設(shè)置相關(guān)外部定義的一些引用信息,真正的重定位是在程序運行之時。靜態(tài)連接方式有兩個大問題:如果庫中變量或函數(shù)有任何變化都必須重新編譯連接程序;如果多個程序引用同樣的變量/函數(shù),則此變量/函數(shù)會在文件/內(nèi)存中出現(xiàn)多次,浪費硬盤/內(nèi)存空間。比較兩種連接方式生成的可執(zhí)行文件的大小,可以看出有明顯的區(qū)別。
a.out 文件格式分析
a.out 格式在不同的機器平臺和不同的 UNIX 操作系統(tǒng)上有輕微的不同,例如在 MC680x0 平臺上有 6 個 section。下面我們討論的是最"標(biāo)準(zhǔn)"的格式。
a.out 文件包含7 個section,格式如下:
exec header(執(zhí)行頭部,也可理解為文件頭部) |
執(zhí)行頭部的數(shù)據(jù)結(jié)構(gòu):
struct exec { |
文件頭部主要描述了各個 section 的長度,比較重要的字段是 a_entry(程序進入點),代表了系統(tǒng)在加載程序并初試化各種環(huán)境后開始執(zhí)行程序代碼的入口。這個字段在后面討論的 ELF 文件頭部中也有出現(xiàn)。由 a.out 格式和頭部數(shù)據(jù)結(jié)構(gòu)我們可以看出,a.out 的格式非常緊湊,只包含了程序運行所必須的信息(文本、數(shù)據(jù)、BSS),而且每個 section 的順序是固定的。這種結(jié)構(gòu)缺乏擴展性,如不能包含"現(xiàn)代"可執(zhí)行文件中常見的調(diào)試信息,最初的 UNIX 黑客對 a.out 文件調(diào)試使用的工具是 adb,而 adb 是一種機器語言調(diào)試器!
a.out 文件中包含符號表和兩個重定位表,這三個表的內(nèi)容在連接目標(biāo)文件以生成可執(zhí)行文件時起作用。在最終可執(zhí)行的 a.out 文件中,這三個表的長度都為 0。a.out 文件在連接時就把所有外部定義包含在可執(zhí)行程序中,如果從程序設(shè)計的角度來看,這是一種硬編碼方式,或者可稱為模塊之間是強藕和的。在后面的討論中,我們將會具體看到ELF格式和動態(tài)連接機制是如何對此進行改進的。
a.out 是早期UNIX系統(tǒng)使用的可執(zhí)行文件格式,由 AT&T 設(shè)計,現(xiàn)在基本上已被 ELF 文件格式代替。a.out 的設(shè)計比較簡單,但其設(shè)計思想明顯的被后續(xù)的可執(zhí)行文件格式所繼承和發(fā)揚。可以參閱參考資料 16 和閱讀參考資料 15 源代碼加深對 a.out 格式的理解。參考資料 12 討論了如何在"現(xiàn)代"的紅帽LINUX運行 a.out 格式文件。