轉載:用我們的32位吸脂工具為你的應用程式減肥[翻譯:松鼠、老狐狸] (22千字)

看雪資料發表於2001-11-30

松鼠:http://squirrel.163.net/
老狐狸:http://kingfox.163.net/

用我們的32位吸脂工具為你的應用程式減肥

Matt Pietrek是《Windows
95系統程式設計奧秘》(1996,電子工業出版社,IDG)一書的作者。他在NuMega工作,EMAIL:71774.362@compuserve.com 。

你以為你已經做了一個很恰當、很緊湊的應用軟體。你清楚程式中的每一行程式碼,而且自信沒有多餘的東西浪費空間、延緩速度。天啊,就像現在的即食食品,你的程式碼中可能隱藏了脂肪成份。也許你所寫的程式碼不用負這個責任,而是你的程式設計工具和技巧為你的軟體中新增多餘的東西,並延緩執行速度。讓我們複習一下可以用來為你的程式減肥的方法。我這裡只談到C和C++程式,但我所說的也部分的使用適用於其他編譯語言,如Delphi等。

《為你肥碩的執行程式吸脂、減肥》(微軟系統月刊,1993,7)是我早期的一篇文章,提出了許多EXE和DLL發胖的原因。那篇文章還提供了一個測試可執行檔案並給出它們相對健康程度的程式:EXESIZE.EXE。那時主要是16位的WINDOWS程式。既然現在的系統是WINDOWS
NT 和WINDOWS 95,我已經收到很多請求,要求得到能為 WIN32的PE檔案工作的新版本的EXESIZE。

因為32位的PE檔案與16位的NET檔案格式不同,我不能僅僅對EXESIZE作很小的改動就完事。現在我覺得有必要回頭看看為什麼提出上一篇文章。16位NE檔案需要的一些單元在WIN32中沒有應用,但16位與32位的可執行程式同樣易於在同樣的地方冗餘,甚至,WIN32還增加了一些增大你EXE、DLL長度與裝入時間的新方法。在這篇文章中,當我提到“可執行檔案”,我的意思是所有的WIN32
PE檔案,不管是EXE 檔案,或DLL檔案,或其他。

首先,讓我們回顧我為16位程式提出的建議。當我在寫這篇文章前我也這樣做了,很高興地想到相較16位WINDOWS的黑暗時代,WIN32的程式設計是多麼的簡單。另一點來說,一些陳年的問題依然存在,所以,我們用1993年的文章來看看現在的WIN32的程式設計世界。

在16位NE檔案中正確設定對齊(Alignment)。每一段(segment)和資源都從檔案的一個偏移開始。大的16位程式經常有大量的段。有幾十個甚至上百個資源也很普遍。很多的廢物就因為預設連線佇列長度為512位元組而引進了。透過配置你的聯結器(linker)(在Microsoft
linker中用"/ALIGN:XXX")你可以設定一個更合理的值(典型的是16位元組),很顯著的縮小你的檔案。

在WIN32的PE檔案中,段的等價物是片段(section)。片段依然需要界定(通常是512位元組)。主要的不同是龐大的PE程式一般也不會超過10片段。而且,一個PE檔案中所有的資源都組合到單個片段中,因此,資源的佇列不是個問題。

在16位NE檔案中不要生成無用的程式碼。當你引進一個函式,編譯器(或彙編器)就在函式的開頭和結尾生成一些特別的程式碼。這些程式碼設定資料段選擇器,為外部函式的程式碼選擇適當的段。這裡的問題是許多程式所用的編譯開關是;為每個遠端函式都生成這些特別的程式碼,而不是僅為外部函式。很高興,在WIN32中外部函式不再需要這些特別的程式碼。
現在是10點鐘了,你知不知道你的除錯資訊在哪兒?這是很重要的,帶著除錯資訊在可執行檔案裡的程式表明這是個很蠢的程式設計師或程式設計小組。後面我還將提到這個問題。

實時模式(Real Mode)已經不再使用,你為什麼還支援它呢?在MICROSOFT
WINDOWS的早期,程式的段可能要在記憶體中移動,這在保護模式不是一個問題,因為INTEL的CPU能夠透過它邏輯-實體地址轉換能力來向你遮蔽;在實時模式,CPU無法做到這點。為了在實時模式下執行時避免出錯,程式中經常包括了一些程式碼來隱藏段已經移動的事實。
這些程式碼只有在實時模式下才用的著。很多程式在WINDOWS
3.0下啟動時都指定只能執行在保護模式下,然而他們還包含有從來不用的“實時廢物”。幸運的是,在PE檔案中,這個問題已經不復存在。

將多個段打包。前面我提到NE檔案經常有很多段。因為一些原因(我不會提到這些),讓連線程式把儘可能多的段合併到單個段中回很有好處。在PE檔案中,不再有段,但它們的替代品(片段)也可以用一些連線程式來合併。合併片段的好處不同於16位程式中合併段,後面再詳細解說。

將你的重定位資訊連線起來。16位NE程式文件允許多個重定位表在可執行程式中象單個重定位表那樣存在,而不是存在一大堆分離的重定位表。這個技術叫做鏈(chainning)。一些連線程式利用了這點,但在我原來那篇文章寫作時Borland的TLINK沒有。後來,TLINK已經更新了。

32位PE檔案的重定位表完全不同於NE檔案。當你不能鏈PE風格的重定位表時,你就不僅僅縮小可執行檔案的長度。後面再說吧。

明智地使用執行庫(Runtime Library).
如果你是用C++之類的編譯語言,你應該連結一些外部的常規庫。這些常規庫一般叫做執行庫(RTL)。如果你選擇靜態連結這些常規庫(而不是使用DLL檔案),你的代價是執行檔案的長度。一般的,你可能為你所使用的每一個
增加了RTL的幾個位元組到幾K位元組的多餘的程式碼和資料。有些簡單的函式如strcpy 非常小,然而有些象printf 那樣複雜的就會大得多。

在我原來那篇吸脂機的文章裡,我要求人們使用通用的RTL函式的Windows版本,而不要連結靜態版本。在Win32這一點同樣適用。實際上,Win32的API包含了Windows
3.1中沒有的C/C++類函式的一個擴充套件集,這使得減少你的可執行檔案長度,用系統DLL裡的程式碼來工作成為可能。

例如,當你要在程式碼中用sprintf時,你可以選用wsprintf代替。它能從你的程式中減少幾千位元組的RTL程式碼。同樣,像strcpy那樣的函式可以用lstrcpy代替,在程式中用malloc和free會增加幾千位元組程式碼和資料,應該考慮用HeapXXX(如HeapAlloc,HeapFree等)代替。可以參照我的關於這個問題的《在鉤子下》(
Under the Hood)專欄。

用BSS段。在16位編譯程式中,未初始化的資料(定義卻沒給初始值的變數)都放在稱為BSS段的地方。(假設你們知道什麼是BSS。)既然BSS段的資料不包含任何特別的初始值,16位連結程式一般把BSS段的資料連線入主資料段,這樣不需要任何磁碟空間。16位連線程式可以設定段記錄中磁碟空間的長度小於記憶體的長度。在Win32對未初始化的資料也可以同樣運用,雖然是片斷而不再是段。後面我還將說到這一點。

隨便說一句,如果你還覺得糊塗,BSS的意思是塊儲存空間(block storage space)。

縮小你的常駐名錶(按順序輸出)。在編16位Windows程式時,當你呼叫或輸出DLL的函式時,連線程式一般按順序查詢函式。“按順序”也就是“按數字順序”的另一個說法。也就是說,每個所呼叫的函式用一個WORD值來識別。除了按數字順序輸出外還可以按名字輸出,函式的實際名字出現在呼叫的可執行檔案和被呼叫的檔案中。顯然,按順序的工作效率比按函式名工作要好。函式名字一般比較長,所以它們要用比按順序更多的空間和工序。

為了按名字輸入(很少在16位Windows使用),目標DLL必須按名字輸出函式。輸出的名字將是這兩種表之一:常駐名錶和非常駐名錶。常駐名錶的缺點是它佔記憶體,而非常駐名錶卻不。不幸的是,很多16位連線程式在某些情況下預設地把輸出名字放到常駐名錶裡。
基於Win32的程式已經沒有常駐名錶和非常駐名錶的概念,只有一個放置輸出函式名的表。但輸出函式名佔據空間的問題依然存在。輸出函式名和輸入函式名都在記憶體中,除非你盡力地按順序輸出你的函式。你能夠按順序輸入和輸出你自己的可執行程式,但按順序來輸入系統DLL函式並不是一個好注意,因為交叉平臺的函式順序不一定相同。



回到現在。
16位、32位的比較就到這裡。在回顧中,我重點提出了我的32位版本EXESIZE程式可以運用的領域。但在可以大踏步前進的地方我們為什麼還要小心翼翼的踩水呢?有一個可以提高的地方是實時效能。記住這一點:我已經把我的新的吸脂工具的技巧分為兩方面,空間、效能。

後面我將奉送一個32位的工具,讓你看看如何縮小你的程式,並提高效能。但在品嚐甜點之前,讓我們先進餐。讓我們考察一些空間和效能的要點,這樣你能更好的理解吸脂工具的好處。

第一套技巧用來為你的程式節省空間,這通常是值得的。每一個規律總有例外,但我想你要找出這些技巧的例外可不容易。

在發行前關閉遞增連結功能!
Visual C++中我很喜歡的一個特點就是遞增連結(Incremental Linking)。減少連結程式的工作,能使Visual
C++在幾秒中就完成連結。遞增連結是除錯時的預設設定。

遞增連結只重寫從上次連結之後改動過的部分,因此獲得這個令人眩目的效能。為了達到這個目的,Microsoft Link複製了一大堆INT
3指令到可執行檔案的不同部分之間。因此當你在原始碼加了幾句後,多出來的程式碼就覆蓋了INT 3所在的空間。檔案的其他部分沒有變動。

正如你想象的,遞增連結的代價也很大,就是可執行檔案的長度。一個使用遞增連結的檔案可以(平均的)用1/3的空間來存放INT
3。在大的執行檔案中,這很可能給你增加幾百KB甚至幾M的INT 3!

怎樣解決呢?在你編譯發行版本時確認關掉遞增連結功能。問題時,我已經見過很多不區分不同版本的程式設計師。他們把測試時的檔案(也就是除錯版本)釋出出去了。我的32位吸脂工具可以在這些檔案中挑出可執行部分和浪費的部分。如果你在一個可執行檔案裡看見了一堆INT
3,很可能這檔案就是遞增連結的。

如果所有的INT
3不夠用,遞增連結的檔案還會有更多的浪費空間。在這種檔案裡,每個檔案裡的函式還多了一個JMP指令。當你呼叫遞增連結檔案裡的函式時,CALL指令找到相應的JMP,方執行所需要的函式。這些JMP的好處是使連結程式可以在記憶體中任意移動函式而不用更新所有的使用這些函式的CALL指令。

總的說來,遞增連結在開發時非常得心應手。只要你確認不要把遞增連結的檔案發行出去。一般,當你在工程裡把除錯開關轉到發行版本時遞增連結功能會自動關閉。如果你自己寫MAKE檔案的話,連結程式控制遞增連結的方法是/INCREMENTAL:XX,XX可以是YES或NO。

去除除錯資訊
在程式裡留下除錯資訊究竟會帶給你多大的浪費空間,以及產生了什麼樣的除錯資訊,依不同編譯器而定。讓我們先從非Microsoft的編譯器開始,因為他們比較容易描述。在非Microsoft的編譯器中,除錯資訊一般都是可執行檔案的一部份。在一個長度可觀的工程裡,除錯資訊可以達到長度的百分之五十,甚至更多。

Microsoft的編譯器與除錯資訊的故事更為複雜。一般,生成除錯資訊的同時也用遞增連結。如果使用了遞增連結,Microsoft的連結器會把除錯資訊放在一個單獨的以PDB為字尾名的檔案裡
(PDB的意思是程式資料庫program database)
。這樣做是為了遞增連結時對可執行檔案做最小的改動。PDB檔案裡是一些零碎的CodeView風格的資訊。你還記得CodeView吧?

用PDB儲存資訊的可執行檔案用一小塊地方來儲存相應PDB檔案的名字。當使用遞增連結和PDB檔案時,可執行檔案裡因除錯資訊而浪費的空間是很小的,僅僅是PDB檔案的全路徑的長度。技術上,這一段會象CodeView資訊那樣列出來,但它確實只是CodeView資訊的指標。

Microsoft除錯資訊常見的另一種型別是在可執行檔案中實實在在的CodeView符號。你可以用/PDB:NONE來強迫連結器生成CodeView資訊,但這樣做就關閉了遞增連結的功能。計算、讀寫CodeView符號與遞增連結相沖突。如果你用CodeView除錯資訊,那麼EXE檔案的浪費空間也象前面我所說的非Microsoft編譯器一樣能達到50%。

用Microsoft編譯器你還可以生成COFF風格的符號。COFF符號是早期Windows
NT製作隊伍做它的程式設計工具時的格式,並流行了下來。連結器的開關可以是/DEBUGTYPE:COFF或/DEBUGTYPE:BOTH。COFF符號只有相對少的工具,而且大多數都在Win32
SDK。COFF除錯資訊象CodeView符號一樣會佔據可執行檔案的很大空間,所以在發行前你應該去除。
Microsoft編譯器還能產生另一種除錯資訊,叫做FPO(Frame Pointer
Omission)。FPO用來連線CodeView或PDB符號。它在編譯器沒有用EBP暫存器生成標準堆疊楨(a standard stack frame)
的地方幫助偵錯程式查詢函式的引數和本地變數。FPO資訊也很大,同樣要在發行前去除。

最後,在Microsoft編譯器生成的可執行檔案裡你可以看見混雜的除錯資訊。這個區域通常是0x110位元組,儲存著連結器生成的可執行檔案的名字。如果你改了可執行檔案的名字,偵錯程式依然能用混雜除錯資訊來判定檔案的原始名字,然後算出對應的PDB檔案的名字。在發行前非除錯連結可執行檔案就可以去除這混雜除錯資訊。

除了除錯資訊浪費的空間外,還有兩個原因要求你關心你發行的檔案裡有沒有除錯資訊。第一,存在除錯資訊就表明編譯時編譯器的最佳化開關沒開啟。最佳化開關可以很大程度地減小檔案長度,下面我還會說到它。第二,除錯資訊讓你無法防範別人反求你的程式。記住,除錯資訊表明了你的程式碼、資料、型別(例如類的定義)。有了除錯資訊,一箇中等能力的程式設計師可以象開啟核桃一樣crack你程式的內部機制。

使用最佳化開關
雖然編譯器的最佳化開關被抨擊產生BUG程式碼,我認為沒有比放棄它更糟的事了。依我經驗,高度最佳化時產生的問題都是由於最佳化程式碼速度而不是程式碼長度引起的。說實在話,在我讓編譯器為程式碼長度最佳化時我從來沒有見過最佳化開關產生的BUG。

最佳化開關的好處不是它產生好的程式碼,一個優秀的組合語言程式設計師能做到甚至比最佳化開關做得更好。最佳化開關能把你從編譯器可能產生的效率不高的程式碼中ZHENJIU出來。下面我用Visual
C++來做個例子,你能看見不同的編譯器產生的類似的結果。

int foo( int i )
{
    return i * 2;
}

int main()
{
    if ( foo(7) )
        return 1;
    else
        return 0;
}


預設情況下,非最佳化的Visual C++ 4.1用CL FOO.C語句會產生表1的指令,這兩個函式共有0x48位元組。
Figure 1 Assembler from Nonoptimized Code

foo proc
401000: PUSH    EBP
401001: MOV    EBP,ESP
401003: PUSH    EBX
401004: PUSH    ESI
401005: PUSH    EDI
401006: MOV    EAX,DWORD PTR [EBP+08]
401009: ADD    EAX,EAX
40100B: JMP    00401010

401010: POP    EDI
401011: POP    ESI
401012: POP    EBX
401013: LEAVE
401014: RET
foo endp

main proc
401015: PUSH    EBP
401016: MOV    EBP,ESP
401018: PUSH    EBX
401019: PUSH    ESI
40101A: PUSH    EDI
40101B: PUSH    07
40101D: CALL    00401000
401022: ADD    ESP,04
401025: TEST    EAX,EAX
401027: JE      0040103C

40102D: MOV    EAX,00000001
401032: JMP    00401043

401037: JMP    00401043

40103C: XOR    EAX,EAX
40103E: JMP    00401043

401043: POP    EDI
401044: POP    ESI
401045: POP    EBX
401046: LEAVE
401047: RET
main endp
現在,我們開啟長度的最佳化開關(CL /O1 FOO.C):

foo proc
401000: MOV    EAX,DWORD PTR [ESP+04]
401004: ADD    EAX,EAX
401006: RET
foo endp

main proc
401007: PUSH    07
401009: CALL    00401000
40100E: ADD    ESP,04
401011: CMP    EAX,01
401014: SBB    EAX,EAX
401016: INC    EAX
401017: RET
main endp

噢!開啟長度最佳化開關使生成的程式碼少了0x18位元組,是非最佳化的33%。如果你比較一下這兩段程式碼,你能看見最佳化開關減少不必要程式碼的幾個途徑。

第一,兩個函式(FOO和MAIN)都不要堆疊(PUSH EBP,MOVE
EBP,ESP和LEAVE指令)。第二,註冊的變數暫存器(EBX,ESI,EDI)都沒有用上,所以最佳化版本不用PUSH和POP儲存它們。第三,在非最佳化版本里,兩個函式都經常用JMP來跳到下一條指令上。這樣做不僅無用、佔5位元組,還打斷了CPU的流水線(pipeline),這是應該避免的。第四,main函式里的if語句在最佳化版本里很聰明地翻譯成CMP,SBB,用EAX來返回值;然而,你簡直找不到比非最佳化版本更差的辦法了。

在這兒,FCC規則要求我告訴你,你不能時刻期待最佳化器能有如此成功的效果,上面這個小例子無疑是造作的結果。同時,記住我開啟的長度開關是長度的開關,而不是速度。這裡的要點是最佳化開關給了你聰明的程式碼。

當你比較長度最佳化和速度最佳化時,你會發現這兩者幾乎是同一回事。主要的差別在於,最佳化速度時編譯器會開啟內聯的函式,例如strcpy等,這樣生成的程式碼就不用呼叫外部函式。這時速度會加快,但行內函數會增大程式碼。在最壞情況下,它們會加入一個4KB的頁,從而可能引起附加頁面錯。一個頁面錯的嚴重性應該說比你從行內函數得到的好處要大,所以你必須小心權衡你的最佳化的決定。這個可以作為參考:Microsoft的作業系統編寫隊伍從長度最佳化而不從速度最佳化。



看看你的佇列!
除了長度最佳化給你的更好的程式碼之外,還有一個很好的理由讓你使用Visual
C++的長度最佳化開關。從不同的OBJ和LIB檔案裡整合片斷(section)時,編譯器把每個OBJ檔案裡的程式碼和資料從一個偏移量開始放置。對於COFF的OBJ檔案(由Visual
C++生成),偏移量是WINNT.H中預定義的IMAGE_SCN_ALIGN_XBYTES,可以是1, 2, 4, 8, 16, 32, 或64位元組
Visual C++ 4.1的預設偏移值是16位元組,也就是說,OBJ檔案裡的每個片斷都從第16位元組開始放置,在上一個OBJ與下一個OBJ中的空間填滿INT
3。最糟情況下,連結後的檔案裡每個段落之間你會得到15位元組INT 3。如果Visual
C++能讓你選擇偏移的量會好一些,但現在並沒有提供這個功能。現在,在OBJ之間你必然有16位元組的偏移。

如果說這些OBJ之間的填充不足以稱為“空間殺手”,一個看起來無害的Visual C++編譯選項如果你使用不恰當會。想一下Visual
C++檔案是怎麼介紹/Gy開關的:“本選項按照COMDAT格式生成函式包以實現函式級連結”。在英語裡,這意味著連結器將從一個OBJ裡取出所需要的函式,而不是把整個OBJ連結進來。

/Gy 開關不正確使用時的問題是使得連結器為每個函式都分配一個偏移。我已經見到很多有幾千個函式而且使用/Gy
的可執行檔案。既然他們使用預設的16位元組偏移量,裡面就到處分佈有共8KB的INT
3。等一下,還不止這些!你不會想到即使你不開啟/Gy開關,如果你用/O1(最佳化長度)或/O2(最佳化速度)開關,它也會隱含地開啟。

嗨,這看起來有點混亂。我一邊既叫你最佳化長度,另一邊又告訴你最佳化長度會開啟這個浪費空間的/Gy。想想長度,又想想速度:你該怎麼辦呢?

如果你不想強迫編譯器使用一個特別的偏移量,至少你還可以用一個開關來解決:/Os。這個開關的意思是“長度重於速度”,它將迫使編譯器使用1位元組的偏移量,而不是預設的16位元組。猜一猜會發生什麼事:當你用最佳化長度的/O1時,/Os開關自動開啟,於是雖然/O1也開啟了/Gy,/Os會設定偏移為1,於是去除量/Gy通常會帶來的多餘的長度。相反,如果你用最佳化速度的/O2,/Os不開啟,於是你就有了垃圾。所以:用/O1而不用/O2,至少在微軟提供更好的辦法之前如此。

另一個問題是在可執行檔案裡面的片段的對準。預設情況下,Borland和Microsoft的連結器在可執行檔案中的每一個片段都是以512位元組作為邊界的。如果你的程式中有好幾個小的片段(就是說小於0x200位元組的片段),理論上設定一個小一些的邊界值――比如16位元組――對你會更有用。Microsoft的Linker有一個/ALIGN開關可以實現這個功能。如果你是Borland
C++使用者,可以用/Afnnnn開關設定檔案邊界。

不幸的是,對於Microsoft使用者來說,如果你使用/Align選項,它會把記憶體裡的片段和磁碟檔案上的片段設定成同樣的數值(比如16位元組)。在Windows
NT中,按照16位元組對準的可執行程式可以執行,但是處於某些低階技術原因,你可能不會想這麼做。唉,Windows
95不會執行片段邊界不是0x1000位元組的倍數的可執行程式。

Borland的TLINK32可以明確地使得你單獨設定檔案的邊界。不過,在Borland C++
5.x中,把檔案的片段邊界設定為任何小於512位元組的數值會導致生成無效的可執行程式碼。結果是:有時候,使用更小的邊界尺寸也許是壓縮PE可執行檔案大小的一種方法。不過,除非連結器能夠提供必要的靈活性並且工作正常,否則,此路不通。

典型情況下,當你的程式包含未初始化資料的時候,編譯器會把有關資訊放到一個稱為.bss的OBJ片段裡面。在可執行檔案裡面,.bss不會佔用任何空間。如果可執行檔案裡面包含.bss片段,作業系統就必須為其提供實體記憶體。即使你僅僅使用了4位元組的未初始化的DWORD變數,作業系統也必須為其建立4KB的實體記憶體,因為是以4KB頁面為單位分配的。

比使用一個單獨的.bss片段更好的方式是把它合併到一個初始化資料片段中。在這種方式下,如果你的程式的未初始化資料不太多,則這些資料會被合併到已經被初始化資料使用的4KB記憶體中。幸運的是,大多數現今的連結器(包括Visual
C++ 4.x和Borland C++
5.x)可以自動把未初始化資料合併到初始化資料片段中。如果你在一個可執行檔案裡看到.bss片段,這個檔案很可能使用老版本的連結器生成的。如果這是你自己編寫的程式,你有理由將它升級。

至今為止我給出的所有提示更是用於那些非固有的問題(All of the tips I've given you so far are pretty much
no-brainers with nothing inherently questionable about
them.)還有幾條其他的路――需要動一點腦筋――可以消減你的程式的空間消耗。換言之,我後面的建議可能是你想或者不想做的。你必須確定它們是否適用於你的特殊程式。

刪除重定位資訊
Win32
PE可執行檔案透過對映各個片斷(比如程式碼片段和資料片段)到記憶體中的制定地址來完成載入。對於每個被載入的可執行模組(EXE和DLL),Win32載入器都會從模組中提取基地址。然後模組中的所有片段被載入到相對於基地址的一個偏移地址中。順便提一句,這個基地址與可執行檔案的模組控制程式碼(HMODULE)完全相同。

當連結器在生成PE檔案的時候,會給出一個首選的載入地址。換句話說,連結器最佳化了檔案,這樣,如果Win32載入器能夠載入文見到首選地址的話,只需要做很少一點工作就可以了。另一方面,如果Win32載入器無法載入模組到首選地址,那麼載入器就必須自己做很多工作。載入器必須重新連結模組,這樣,所有對程式碼和資料項的內部引用都被正確地指向新的載入地址。

Win32載入器用來在記憶體中重定位模組的資訊被認為是基本重定位。這些資料存放於可執行檔案的一個通常稱為.reloc的片段中。簡單情況下,基本重定位資訊是一系列記憶體模組中的偏移,載入器必須在此處新增實際載入地址和首選載入地址之間的位移。無疑,你可以想象,基本重定位資訊越多,載入器在記憶體中重定位模組時要做的工作就越多。後面我會對此做更多的探討。

理想情況下,模組被載入到首選地址,Win32載入器無需關心.reloc片段的基本重定位資訊。如果你想要冒險使得模組總是被載入到其首選地址的話,你可以去掉重定位資訊。我無法確保你這種冒險的穩定性。如果你刪除了重定位資訊,而載入器又無法載入模組到首選地址的話,載入器就會拒絕載入該模組。Game
over!另一方面,打的程式可以由幾百KB的重定位資訊,所以,這才是需要考慮的目標。

如何決定是否該刪除重定位資訊?下面是一些通用的指導方針。記住要用你最佳的判斷歷來從頭到為地思考形式。由於每個程式只有一個EXE檔案,你通常可以忽略EXE檔案中的重定位資訊。習慣上,EXE檔案被加在到程式地址空間的線性地址0x400000(4MB)處,並且首先獲得選擇載入地址的機會(在DLL之前)。由於很少會出現無法加在EXE到首選地址的情況,所以通常忽略或者刪除重定位資訊是安全的。

與EXE相反的是,每個程式通常有多個DLL成用程式的地址空間。幸好它們都被載入到不同的地址,而且無需重定位。儘管如此,保留DLL重的重定位資訊還是一個廉價的保險措施,這就使得DLL可以被載入到任何需要的地方。這在你無法控制所有程式所需的DLL情況下就尤其重要。

儘管嘗試和歸納上述論點並且認為“重定位資訊應該從EXE檔案中剔除,但是保留在DLL中”的想法挺誘人的,可是還是有反例的。比如,Program1想從另一個EXE檔案(Program2)中讀取資源,如果Program2的重定位資訊丟失了的話,Program1就不能把Program2對映到記憶體中來訪問其資源。以你的程式為例,你的程式有一個主視窗的圖示。為了顯示你的程式的圖示,Explorer或者Progman需要載入你的程式以訪問圖示資源。

在什麼場合下會對從DLL中刪除重定位資訊敏感呢?也許你的應用程式只需要很少的磁碟空間和記憶體就可以執行,你對應用程式和作業系統擁有完全的控制權,那麼假設你的DLL無論如何也不會被載入到你制定的位置之外的地方是相當安全的。你可以精確地決定正在載入的程式的特性,並且可以得知只要可執行檔案和作業系統不改變,這些特性就不會改變。

如果你決定刪除重定位資訊,那麼可以有三種方法。最簡單的是在連結器命令列指定/FIXED開關。還有一種方法是,你可以對你的執行程式執行加-f引數的REBASE程式。Win32
SDK附帶了REBASE。第三種刪除重定位資訊的方法是新的NT 4.0
IMAGEHLP.DLL內部的RemoveRelocations函式。下面的例子程式碼顯示瞭如何使用RemoveRelocations。



合併片段
在可執行檔案裡,由程式的程式碼、資料、資源、匯入資訊、匯出資訊等等拼湊起來的原始資料被存放與不同的片段中。通常,程式中有一個程式碼片段(對於Microsoft的編譯器叫做.text,對於Borland
C++編譯器叫做CODE),一個可寫的資料片段,一個資源片段(.rsrc),一個匯入片段(.idata),一個匯出片段(.edata)。對於公用片段做一個完全的羅列是沒意思的。

除了這些“標準”片段之外,你還可以用編譯器程式或者彙編器的SEGMENT指令建立其他的片段。比如,你可能有一些能夠被所有使用你的DLL的程式共享的資料。為了實現資料共享,你最好建立一個新片段,並且告訴連結器連結檔案的時候給予這個片段SHARED屬性。

在某些情況下,“片段”是段的Win32的等效。在我的16-bit吸脂文章裡,我描述了每個段是如何使用系統資源(比如,LDT選擇符)的,這對保持你的程式碼的段的數量儘可能少是個好辦法。這個思路對Win32同樣有效。PE檔案裡的每個“片段”從內部作業系統表中使用記憶體。每個附加片段預設情況下會給可執行檔案增加512位元組。

更重要的是,每個在記憶體中被訪問的可執行檔案片段都會使用至少4KB的實體記憶體。因而,即使你在片段裡只使用了2位元組的資料,你還是必須為此開銷4KB的實體記憶體。如果你有三個片段,每個實際只使用了10位元組記憶體,實際開銷仍舊是12KB物理RAM。

只要可能,你就應該把屬性相同或相容的片段合併起來。我在這裡有意模糊了一下,因為我沒有找到一個硬性的、快速的法則可以給你。如果你沒有顯式地建立你自己的片段,而你又在使用本文所提及的工具,你可以放心地忽略這一點。這些工具可以非常恰當地合併邏輯上可以合併的片斷。比如,Visual
C++ 4.1可以把好幾個不同的OBJ檔案裡的.CRT、.bss和.data段合併成可執行檔案的單一段。

如果你真的想最佳化你的應用程式,你會發現還是有一些段是連結器無法自動合併的。但是你可以強迫連結器合併它們。比如,如果你在Windows NT
4或者更高版本下測試可執行檔案,你會發現大多數情況下.idata和.edata段已經被合併到.rdata(只讀資料)段。

那麼,你該如何合併片段呢?微軟的連結器提供了一個開關稱為/MERGE,精確的語法是:
/MERGE:<source_section>=<destination_section>

連結器會把源片段的內容新增到目的片段,從而使得它們看上去就像是磁碟上的一個單個的段。

對於有些段你可以把它放在一邊,儘管看上去把它們合併起來更好些。一個是.rsrc段,Win32的UpdateResource函式假設資源總是在自己的獨立的段裡面。另一個要避免被合併的是.tls段,這是執行緒的區域性變數呆的地方。執行緒區域性變數是那些你用__declspec(thread)宣告的變數。最後,你可能已經分割了你的程式碼,因此可能會有一個只是在啟動的時候才用到的段。你不會想把這個段合併到其他段中,因為當來自附近其他邏輯段的程式碼和資料被訪問的時候,那個段的程式碼會最有可能儲存在記憶體頁面中。(原文:You
wouldn't want to merge that with other sections, as that section's code would
most likely remain paged into memory when nearby code and data from other
logical sections are accessed.)

使用系統執行庫程式碼:

如果你使用Visual C++,你也許能從你的可執行檔案中除掉所有的執行庫程式碼。到此為止,每個Win32平臺都與至少一個Microsoft執行庫(RTL)
for C/C++的複製想關聯。理想情況下,你可以僅僅以來基本的C/C++ RTL DLL作為基本作業系統的起始部分。比如CRTDLL.DLL從Windows
NT 3.1開始就伴隨著每個Win32平臺。唉,微軟再也不提供CRTDLL.DLL的引入庫了。

自從CRTDLL.DLL不再成為一個選擇,如果有標準的Visual C++ RTL DLL存在就好了。不幸的是,Windows
95使用了MSVCRT20.DLL,而Windows NT 3.51卻不是這樣。在Windows NT
4.0,存在MSVCRT40.DLL和MSVCRT.DLL,可就是沒有MSVCRT20.DLL。

綜上所述,在目前,我無法找到一個合適的方法來生成一個使用系統提供的C/C++ RTL
DLL並且可以使用所有當前Win32平臺的DLL的程式。看樣子,MSVCRT.DLL是朝著這個方向邁出了一步,可是,卻沒有相關的引入庫。

說到這兒,如果你有一個用於特定Win32平臺的程式,你可以使用系統附帶的相關的MSVCRTxx.DLL。當然,如果你不在意額外的關聯一個執行庫DLL的工作,那麼你當然應該考慮使用執行庫DLL,而不是靜態連結的RTL。如果你的產品有許多可執行檔案組成的話,這就特別值得了。


避免地址空間衝突

到目前為止,我已經討論了減小可執行檔案大小的途徑。現在讓我們來看看一些通用的加快程式載入速度的途徑。就想我前面提到的壓縮尺寸那樣,我的升級的吸脂程式可以識別某些你可以用來改進效能的東西。

早些時候,當我敘述除掉重定位資訊的時候,我提到Win32載入器可能無法載入一個可執行模組到它的首選載入地址。載這種情況下,載入器不得不移動記憶體中別處的模組。重定位資訊是一種允許載入器修改記憶體中模組以使之執行在與其首選載入地址不同的地址空間的資訊。你可以想象使用重定位資訊來在記憶體中移動模組是一件花時間的事情。重定位資訊越多,時間開銷越大。

那麼,為什麼載入器無法載入模組到它的首選地址空間呢?首要的原因是已經有某些東西佔用了部分或所有的目的地址範圍。“某些東西”是指什麼?它可能是一個企圖載入到已經用於執行緒棧的區域的模組,或者可能希望的載入地址與某個程式的堆區域衝突。最可能的情況是,模組企圖載入到已經被其他模組佔用的記憶體空間。不管這些衝突會造成什麼後果,載入器都不得不找到一個不同的、未用的線性記憶體區域,並且處理所有的重定位資訊來把模組移到那裡。

載入衝突的典型例子跟DLL有關。大多數連結器使用0x10000000(或256MB)作為預設的首選載入地址。如果你的Project由一個EXE和五個DLL組成,而你沒對指定載入地址做任何處理,你會因為一個DLL可以載入到首選地址而另外四個DLL則需要載入器能找到能載入它們的地方而當機。很明顯,必須採取措施來避免這一情形。

那麼,該做些什麼來避免這種情況的發生呢?首先,載入器允許你制訂一個首選載入地址。對於Microsoft連結器,命令列選項是/BASE:xxxx,這裡xxxx是一個十六進位制格式的地址。對於Borland的TLINK32,等效開關是-B:xxxx。

知道了可以載連結時刻指定首選載入地址之後,你就可以試者把地址設定得足夠遠,使得任何兩個DLLs都不會重疊。但是這種工作很羅嗦而且容易出錯。比如,如果你把基地址挑得靠在一起,如果你下次修改了模組的程式碼,使得其記憶體範圍擴張到模組的空間,就很容易造成衝突。

更簡單的設定首選載入地址的方法是使用Win32
SDK的REBASE程式。REBASE的主要用途是改變一個已存在的可執行檔案的首選載入地址。REBASE的實際能力是可以處理一組檔案。REBASE建立一個程式將要載入的EXE和DLL的列表,並且為每個可執行模組計算載入地址,使得列表中的可執行模組不會和其他模組衝突。計算之後,REBASE就可以遍歷和修改每個可執行模組的首選載入地址。

典型情況下,REBASE被用做專案的系統的一部分。所有的部件都連結好之後,一個包含了所有可執行模組名字的檔案傳送給REBASE,REBASE依此修改每一個可執行模組。比如,假設你的專案包含A.EXE,B.DLL,C.DLL和D.DLL,只要建立一個如下所示的檔案:BASE_IT.TXT:

A.EXE
B.DLL
C.DLL
D.DLL

然後把這個檔案連同起始首選地址一起傳送給REBASE。比如:

REBASE -b 600000 -R C:\MYDIR -G BASE_IT.TXT

指定了BASE_IT.TXT列出的內容的映像應該是同一組的,而且起始地址是0x600000,並且列表中的檔名是位於C:\MYDIR目錄。REBASE還有其他的一些選項,這裡我並不打算描述。詳情參見SDK文件。儘管REBASE並非一個介面友好的工具,但是如果你正在製作一個不平凡的商業軟體的話,學習它的用法還是非常有價值的。



繫結

儘管避免地址空間衝突是一件很有價值的事情,但是還有很多方法可以用來降低Win32載入器的工作負載,以此改善可執行檔案的載入時間。除了映像PE檔案的片段到記憶體中之外,Win32載入器還要負責解決引入函式的引用。比如,假設你的程式呼叫了GetFocus函式。當你載入可執行模組時,Win32載入器不得不載記憶體中定位GetFocus,然後補足你的記憶體中模組的GetFocus的地址。

查詢GetFocus的地址的活動很像GetProcAddress所做的工作。也就是,根據給出的模組的控制程式碼和函式名字,搜尋指定模組的匯出表來查詢被匯出函式的地址。實際上,Win32載入器和GetProcAddress的動作相同並非偶然,在作業系統的內部,載入器和GetProcAddress使用了同樣的內部例程。

既然知道了Win32載入器和GetProcAddress共享了許多程式碼,你就可以發現對於你引入的每個函式,作業系統都要像你對每一個引入函式呼叫GetProcAddress那樣做出大致相同的工作。如果你對你的可執行檔案執行一個像DUMPBIN或者TDUMP這樣的程式,並且發現它引入了400個函式,想想看,每當你載入你的程式的時候,Windows要做400次GetProcAddress呼叫。夠嚇人的,是不是?

那麼,如何改善這種情況呢?每次你執行程式的時候,被匯入函式的地址也許不會改變。其實,當呼叫諸如GetFocus這樣的系統函式的時候,引入函式的地址是不會改變的,除非使用者安裝了作業系統的升級版或者補丁。

由於入口函式的地址不會改變很多(開發階段可能會有例外),如果有一種方法能夠一次獲得引入函式的地址,然後把它儲存在你的可執行檔案中,這豈非很偉大?現在,這種方法已經有了。這個過程稱為“繫結”。當Win32載入器遇到一個正確繫結的可執行檔案的時候,它就可以避免這些耗時的函式搜尋工作。作為一個參考,Windows
NT附帶的所有可執行檔案都已經經過了“繫結”。

繫結可以用兩種方法之一來完成。首先,你可以執行Win32 SDK的BIND程式。比如:
      BIND Cu FOOBAR.EXE
使得BIND遍歷FOOBAR的引入函式列表,計算這些函式的地址,把這些地址寫回FOOBAR.EXE。-u引數告訴BIND前進,並且把地址寫入可執行檔案。如果沒有-u引數,BIND遍歷查詢引入函式的地址,但是不改寫可執行檔案。

繫結可執行檔案的另一種方法是透過使用IMAGEHLP.DLL中的BindImage和BindImageEx函式。SDK的BIND工具僅僅是封裝了BindImageEx函式。我的樣本吸脂程式演示瞭如何使用BindImage。你可能會考慮用到BindImage和BindImageEx的一個地方是你的安裝過程。

關於繫結有兩個共同問題。首先,如果你繫結你的硬項,而後來匯入的DLLs發生了改變,這將會發生什麼事情呢?沒事兒。當IMAGEHLP繫結可執行檔案的時候,它也把一個表示匯入的DLL的時間的時間標記寫入到這個可執行檔案中。當Win32載入器處理可執行檔案中的匯入函式的時候,會把你的檔案中的時間標記同匯入函式的DLL的時間標記做比較。如果時間匹配,載入器結束工作。如果時間標記不同,你也不用擔心,載入器會象沒有經過繫結那樣處理可執行檔案。換言之,如果你繫結了一系列DLLs,而其中的一個或多個發生了改變,並不會降低你的程式的效能。

另一個有關繫結的問題是,當你無法確切知道你的程式將要執行的Win32平臺和版本(Windows 9x?Windows NT
3.51?4.0?哪一個Service
Pack?)的時候,會發生什麼事情。比如你的程式呼叫了USER32.DLL的GetFocus,而且你對這個程式作了繫結。唯一能夠感覺到你的繫結工作帶來的速度的提高效果的人是那些擁有與你的USER32.DLL版本完全相同的人。因此,繫結值得一做嗎?

即使你還沒有意識到把你的程式映像繫結到系統API函式有任何好處,你還是會從把EXE和DLLs繫結的做法中得到好處。同樣,如果你有好幾個互相呼叫的DLLs,把這些DLLs繫結起來似的你匯入的函式執行的更快的做法還是非常有價值的。當然,成功的繫結首先建立在你正確地REBASEd你的可執行檔案的基礎上。把你的可執行檔案繫結到家載器會移到別處的DLL上不會給你帶來任何好處。理想情況下,你的安裝程式將會把你的可執行檔案和DLL繫結起來作為安裝程式的一部分。




Liposuction32
理論已經足夠了!對我來說,本文最好的部分就是編寫一個樣本程式。有一個例外事,我的吸脂程式提供了我上面所述的每一個提示。實際上,只有一個核心程式,但是它既可以構造成一個命令列程式,(LIPO32.EXE)也可以構造為GUI程式(Liposuction32.EXE)。

如圖2所示的GUI版本對互動地分析可執行檔案是很有用的。你既可以在視窗頂部的編輯框中輸入檔名,也可以用拖-放的方式把任何地方的檔案拖到這個視窗。一旦給Liposuction32.EXE一個工作檔名,它就會在對話方塊的相關區域填寫與這個程式有關的資訊。底部的三個按鈕提供了命令列程式LIPO32未實現的功能。這些按鈕可以去除檔案中的除錯資訊,剔除重定位資訊,以及執行繫結工作。然後,這個程式會被重新分析以提供新的改進的版本。

圖2 Liposuction32

圖3所示的命令列版本LIPO32.EXE不想那麼時髦Liposuction32.EXE,但是在其他方面更勝一籌。由於這是一個命令列工具,你可以在你的BUILD過程終於它協同工作。同樣,你可以把它的所有輸出重定向到一個檔案中。如果你想分析大量檔案的話,這可是一個非常方便的手段。比如,我使用命令列版本分析\WINNT\SYSTEM32目錄下的每一個檔案。如果你喜歡這樣工作,可以求助於SHELL的FOR命令,比如,命令列

for %a in (*.exe *.dll) do lipo32.exe %a >> LIPO_OUTPUT

為當前目錄下的每個EXE和DLL檔案建立了一個LIPO32報告,並且把輸出放到一個名為LIPO_OUTPUT的檔案中。

Figure 3 LIPO32


對結果的解釋

我們先來討論GUI版本,看看這兩個程式給了你什麼資訊。我之所以選擇GUI版本是因為你總是可以看到某些表示這個程式的用途的資訊被顯示出來。如果命令列版本未發現什麼錯誤,它並不輸出任何特別的細節。

回到圖2,頂部的編輯框包含了即將被分析的程式檔名。你可以輸入程式名字,然後按下ENTER鍵來改變Liposuction32
的“顯微鏡”下的檔案。檔名的左下方是顯示重定位標的大小和映像是否繫結的區域。

標題為Incremental Linking的列表框告訴你在每個可執行檔案的程式碼片段裡找到了多少個INT 3。如前所述,當連結器執行增量連結的時候,會插入INT
3s。我是怎樣得知檔案中有多少個INT
3的呢?我使用了一種“殘忍”的強制措施,這種措施在我所做的所有測試中看起來工作的很正常。在每個程式碼片段裡,我反覆掃描三個值為0xCC的連續位元組。單個的0xCC是INT
3指令的操作碼。我選擇三個連續的0xCC作為觸發條件是因為其他包含三個0xCC的指令出現的機會非常小。一旦我在一行裡面發現三個INT 3,我繼續掃描其他的INT
3s直到程式碼結束或者找到了不同的位元組。

有一點很重要:在你的程式裡面出現幾百個INT 3s並不意味著曾經使用過增量連結。我說過連結器在預設情況下如何按照16位元組邊界對齊OBJ檔案和函式並用INT
3s填充其中的空隙。這些對齊用的INT 3s經常會顯示出來。我並未試圖把這種INT 3s同增量連結生成的INT 3s區分開來。畢竟,所有的INT
3都只不過是INT 3,而且它們都無謂地佔用了空間。如果你需要區分這兩種型別的INT
3,你可以修改我的程式碼來查詢不超過15位元組(16位元組OBJ檔案對齊情況下插入的最大填充程式碼)的INT 3s。

解釋報告的INT 3s的數量的意思有點棘手。如果你又不超過1K的INT 3s,你可能看到的是Microsoft Linker的16位元組填充物。如果有大量的INT
3s(比如,佔到了全部程式碼的25%),可能你看到的是增量連結的產物。如果你看到的INT
3s在1~10KB之間,這既可能是增量連結的產物,也可能是連結器把每個函式對齊到16位元組邊界的產物。我早些時候描述Visual
C++的不帶/O1開關的/Gy開關的時候曾經提到過這一點。我這裡用的是粗略的數值。如果你的程式特別小或者出奇大,就需要調整這些選項。

標記為Debug
Info的列表框可執行檔案中找到的除錯資訊的大小和型別。由Borland編譯器生成的程式的除錯資訊標記為BORLAND,對於Microsoft的除錯資訊,你會看到諸如CODEVIEW、COFF、FPO以及MISC等除錯資訊的組合。其他編譯器生成的除錯資訊可能會顯示為CODEVIEW資訊。

雜項的除錯資訊會僅僅佔據0x110位元組,而且可以被很安全地忽略掉。如果你看到檔案中的Borland、CodeView或者COFF資訊,就需要考慮一下了。檔案中可能包含一些你不希望其他人看到的符號資訊。FPO除錯資訊僅僅同其他除錯資訊一起使用,儘管它會佔據大量的空間。關鍵問題是當你看到這些除錯資訊的時候,可能這個程式是debug板,因此並未經過最佳化。應該把除錯資訊的出現當作更深層次問題來對待。當然,你總是可以發現例外。比如,大多數的Windows
NT系統DLLs包含FPO資訊以輔助除錯,但是它們在編譯的時候是經過最佳化處理的。

名為Unoptimized
Code的列表框僅顯示不使用最佳化器是造成的空間浪費。一個建議是如果你發現未最佳化程式碼的跡象,你可以知道最佳化器未能工作。吸脂程式找到的未最佳化程式碼是跳轉到後面的程式碼的JMP指令。這些JMPs指令浪費了5個位元組,卻什麼也做不了。吸脂程式合計了它發現的這種序列的總數並且報告結果。記住,這些JMPs僅僅是未最佳化程式碼中的一類。還有許多其他的程式碼序列也會浪費空間,而我的程式碼是不會查詢這類資訊的。

如果你發現大量的“愚蠢的JMPs”,你最好看看是否選擇了debug build方式(這時通常最佳化功能會關閉),如果你使用Visual
C++,你也可能會發現大量的INT 3s。矯正方法:用release方式重新編譯。

Uninitialized
Data列表框包含可執行檔案中所有包含未初始化資料的片段的列表。這個列表框應該是空的,因為連結器能夠把這些片段組合到其他初始化資料段中以減少片段的數量。如果你在這個列表框中看到了內容,你可能再用一個過時的連結器。

Combinable
Sections列表框列出了可執行檔案中可能被組合的片段。理論上,這些片段都可以被組合在一起(比如使用/MERGE開關)以減小片段數量,還可能節省磁碟空間。在每個可組合片段系列的末尾,列出了組合前和組合後會需要多少記憶體頁面。可是,記住,你並不想盲從程式的提示。正如我前面所述,可能會有更好的理由而不去組合某些片段。

編寫Combinable
Sections列表框的邏輯非常簡單,所以我不會指出每一種簡單的可能性,簡而言之,就是查詢具有相同屬性的片段的程式碼。儘管如此,在比較片段屬性之前,演算法不考慮.reloc和.rsrc段。一個嚴重的缺陷是,這個演算法邏輯不會提出可以被合併的片段,即使它們有不同的屬性。比如,.idata和.edata段理論上可以合併到.rdata段中,儘管他們的屬性不同。在我的測試中,這個列表框經常建議.idata段應該合併到.data段中。一個更好的選擇是把.idata放到.rdata中。

Load
Conflicts列表框報告了導致載入衝突的可執行模組的檔名和記憶體範圍。既然Liposuction32一次只檢查一個可執行檔案,它又是如何得知有載入衝突的呢?我的程式碼僅僅是簡單地做了載入器所做的工作,那就是:它觀察匯入表,並且提取每一個匯入的DLL的名字,然後,程式試圖定位匯入模組。如果找到了匯入模組,Liposuction32提取其最佳匯入地址。

當這些工作處理可執行檔案的直接匯入的所有模組的時候,如果一個匯入模組匯入了其他DLLs該怎麼辦?在定位和儲存每一個匯入模組到一個表中之後,Liposuction32對這個表執行排序,並且查詢那些如果按照首選載入地址載入就會線上性地址空間相互重疊的模組。如果你發現這個列表框中有內容,恐怕就需要使用我前面介紹的REBASE程式了。

Liposuction32底部的三個按鈕使得你可以修改一個已經存在的可執行檔案。Strip Debug
Info按鈕僅僅在可執行檔案包含除錯程式碼時可用。當我在Windows NT 4.0 Beta
2中編寫這段程式碼的時候,我發現IMAGEHLP若作用與Borland的可執行檔案,就會留下一個零長度檔案。這一問題應該會在Windows
NT的後續版本里面得到解決。如果這個按鈕可用,而你選擇了“剔除除錯程式碼”,則程式會呼叫IMAGEHLP的SplitSymbols函式。我注意到SplitSymbols還是會在檔案中留下雜項除錯資訊。由於這些資訊很小,你可以選擇忽略之。如果你真的想完全剔除它,重新連結這個可執行檔案而不要允許任何除錯標記選項。

Remove
Relocations按鈕在EXE檔案中存在重定位資訊的時候可用。對於DLLs來說,我故意禁止這個按鈕以防止無意剔除重定位資訊。如果你真的想刪除DLLs中的重定位資訊,你可以用-f選項執行REBASE。在程式內部,Remove
Relocations按鈕使用IMAGEHLP的RemoveLocations函式。在Windows NT beta
2中,我發想這個函式的一個問題會導致可執行檔案再也無法執行。這在以後的版本中應該會解決。

Bind按鈕總是可用的,即使可執行檔案的映像已經經過了繫結。為什麼要這麼做?我們假設經過檢查的可執行檔案已經經過了繫結,可能這個程式繫結的一個或者多個DLLs發生了改變。雖然我可以編寫一段用於從可執行檔案的繫結匯入表讀取資訊和檢查時間標記的程式碼,不過我想把這個工作作為那些有進取心的讀者的練習。




Liposuction32和Lipo32的程式碼

在結束之前,我們來複習一下Liposuction32和Lipo32的程式碼的重要部分。我將以一步一步的詳細分析來與你分享這段程式碼因為這裡有大量的程式碼。而且,這裡有一些值得了解的有用的提示,尤其是如果你將自己擴充這個程式的話。

兩個程式的核心程式碼都是LIPO32.CPP(如圖4所示)。AnalyzeFile函式就是分析一個可執行檔案的地方。它既可以透過GUI程式碼(LIPOGUI.CPP)呼叫,也可以透過命令列程式碼(CMDLINEUI.CPP)呼叫。LIPO32.CPP檔案自己並不輸出任何資訊。它檢查指定的檔案,然後呼叫一系列輸出函式輸出結果。輸出函式的原型在LIPO32OUTPUT.H中。

圖4 LIPO32.CPP

LIPOGUI.CPP和CMDLINEUI.CPP都實現了LIPO32OUTPUT.H中的函式。這是在GUI和命令列版本之間共享核心程式碼的關鍵。MAKEFILE中的定義HARDCORE=1告訴NMAKE應該連結命令列原始碼而不是GUI版本。

現在我們回到LIPO32.CPP中的AnalyzeFile函式,該函式所做的第一件事情是用傳入的檔名建立一個PE_EXE2物件。我會簡短描述一下PE_EXE2物件。在建立PE_EXE2物件之後,AnalyzeFile函式使用這個物件來驗證指定的檔案是否是一個真正的PE檔案並且是一個80386系列二進位制程式碼。如果兩個條件都不成立,AnalyzeFile報告一個錯誤並且返回。

AnalyzeFile函式的其他工作很簡單。使用PE_EXE2物件呼叫函式LookForIncrementalLinking,
LookForUnoptimizedCode, LookForDebugInfo, LookForRelocations,
LookForCombinableSections, LookForBSSSections, LookForLoadAddressConflicts, and
LookForBoundImage。我這裡略去了雜七雜八的函式細節。如果你感興趣,可以參見LIPO32.CPP原始碼,在那裡我做了很多註釋。

LIPO32.CPP核心程式碼嚴重依賴於PE_EXE2類。PE_EXE2是一個我用於封裝PE檔案細節的類。PE_EXE2其實是第三代派生類。可以參見圖5的類層次圖。



圖5PE_EXE2類層次圖。(原文這裡缺少圖5)

MEMORY_MAPPED_FILE類提供了用於獲取檔名並使之在記憶體中可用的的功能。建構函式和解構函式維護開啟和關閉檔案、記憶體映像和其他功能。如果你還沒有記憶體映像類,這個類或許對你有價值。

EXE_FILE類位於MEMORY_MAPPED_FILE之上。它的作用僅限於告知記憶體映像檔案是否是可執行檔案。如果檔案是可執行檔案,EXE_FILE類會告訴你它是什麼型別(MS-DOS?16-bit
Windows、OS/2 2.x、VxD還是PE)。

PE_EXE類是讓人感興趣的類。這各類對WINNT.H中定義的PE檔案資料結構做了很少的一點包裝。它的功能幾乎僅限於讀出PE頭資訊。此外,PE_EXE類也可以返回資訊和用PE檔案資料目錄――諸如匯入表達小或指向除錯資訊的指標――計算出來的指標。如果你的程式操作PE檔案,你可能會考慮把PE_EXE加入到你的工具箱中。

記住重要的一點:PE_EXE類的設計是與WINNT.H中的資訊一同工作的而不是替代之。比如,GetCharacteristics方法返回一個包含可執行檔案資訊的DWORD標誌字。這些標誌對應著WINNT.H中的有關定義。我並不在意重新生成WINNT.H中定義的所有#define和資料結構。如果你想使用PE_EXE類,你應該考慮把WINNT.H中的PE檔案結構和定義作為類的一部分。

最後,我來介紹一下PE_EXE2類,這是從PE_EXE類派生的。PE_EXE2提供兩類PE_EXE未提供的功能。首先,PE_EXE2提供了對可執行檔案中的片段的訪問。你可以用名字、從1開始的下標或相對虛擬地址(RVA)來搜尋一個片段。每個片段相關的方法都返回一個PESECTION.H中定義的PE_SECTION物件,PE_SECTION物件只是對WINNT.H中定義的IMAGE_SECTION_HEADER結構的簡單封裝。

PE_EXE2擁有而PE_EXE沒有的其他額外特性是PE_EXE2允許輕鬆訪問檔案中的除錯資訊。解釋除錯資訊有一點困難,因為,Borland和Microsoft在如何解釋PE格式的關鍵欄位方面歷來就不一樣。我建立了PE_EXE2類,因此我可以在其他無需除錯資訊和片段資訊的專案裡使用基類PE_EXE。

除了PE_EXE2類以外,吸脂程式程式碼以來的其他類是MODULE_DEPENDENCY_LIST類。這個類對於找到在程式地址空間相互重疊的可執行檔案時非常重要的。給它一個可執行檔案的名字,它就可以構造一個完整的該可執行檔案直接匯入或透過其他模組間接匯入的模組的列表。在構造了這個列表之後,該類可以用來透過名字查詢某個特殊的模組。該類還可以報告列表中有多少模組,以及列舉列表中的每一個模組。為了簡化MODULE_DEPENDENCY_LIST類的程式碼,我使用了前面描述的PE_EXE類。

MODULE_DEPENDENCY_LIST類的建構函式獲取可執行檔案的名字,並且用遞迴的方法查詢每一個匯入的DLL。為了重複Win32載入器的行為,建構函式臨時把當前工作目錄改變為原始可執行模組所在的目錄。模組列表建立之後,建構函式恢復了原始的當前工作目錄。我說明這一點只是由於這麼做會導致該類在多執行緒映用中不安全,因為當前工作目錄提供給整個程式而不是某個執行緒。


總結

我已經論述了可能導致可執行檔案效率低下的兩個方面:大小和效能。在大小方面,如果你還沒做過什麼努力,那麼就確保你的程式不是Debug版本。在速度方面,要想辦法用諸如REBASE這樣的工具避免載入地址衝突。我也論述了其他可以最佳化的部分,但是,即使你僅僅做了上面兩件事情,你也可以很好地改進你那拙劣的程式碼了。

這些天來,諸如GB(giga-byte)和200MHz之類的詞彙到處氾濫。看上去調整程式的效能使之儘可能高效率的做法已經是過時的藝術了。正如我在此處所言,成為一個程式設計團體的友好公民並不困難。如果我們一起來努力使得我們的程式碼乾淨而快速,我們就可以打破僅僅為了使工作能夠進行而不得不需要越來越大和越來越快的計算機這樣的惡性迴圈。

本文由Miller Freeman公司發表在《微軟系統月刊》1995年第一期。版權保留。未經Miller
Freeman公司同意,本文的任何部分不得以任何方式被複制(除了評論性文章)。

要想同Miller Freeman公司聯絡定購,在美國可用(800) 666-1084,其他國家用(303) 447-9330。其他調查,請用(415)
358-9500

[譯者注]本文由松鼠和老狐狸翻譯。

相關文章